Status: Stable · v1.x (2026-06-17). RFC 0103. A capability-gated surface for durable, authored, structured localized content (pages → sections), extending the Stable
i18n.mdannex. It reuses the annex'sAccept-Language/Content-Languagenegotiation and fallback verbatim and adds only the content data model + a per-section field merge. Additive; layers oni18n.md(Stable v1.1). Keywords MUST, SHOULD, MAY, MUST NOT follow RFC 2119. Seeauth.mdfor the status legend.
Why this exists
i18n.md (Stable) localizes transient, host-rendered human-facing text — interrupt prompts, error envelopes, conversation prompts — by negotiating Accept-Language → Content-Language with a normative fallback. It is explicitly scoped away from structured content.
This document adds the complementary surface: durable, authored, structured localized content. The motivating need is a host serving authored pages (marketing, help, onboarding) in multiple locales — a CMS-shaped surface — where the page shape and its locale-resolution must be agreed so a page authored on host A resolves identically on host B. The same argument that put locale negotiation in the spec (so every host fills Content-Language the same way) applies to the content data model layered on top of it.
Two facts from the existing corpus shape the design:
1. There is one locale mechanism, and there must not be a second. i18n.md makes Accept-Language authoritative and forbids sniffing locale from the request body or overriding the header. A ?locale= query parameter is therefore forbidden on this surface. 2. Tenant is derived from the credential. Per auth.md §"Identity claims", tenant is the top-level isolation boundary derived from the caller's credential (RFC 0048). This surface scopes all content to a tenant; how a tenant is resolved on an anonymous public request is host-defined (§F).
Scope
In scope:
- A normative content data model: a page composed of sections, where a section is one record with a base
datapayload plus a sparselocalizationsmap. - A normative per-section field merge that resolves a section body for the negotiated locale.
- A public, cacheable delivery endpoint and a tenant-scoped admin CRUD surface.
- A
contentcapability advertisement that reuses thei18nlocale set. - Security invariants for the public delivery path.
Out of scope:
- Locale negotiation — reused verbatim from
i18n.md(this doc adds no second mechanism). - Per-locale publication state (atomic across locales for v1; §E).
- Field-level concurrency on locale writes (last-write-wins; §G).
- Section body field shapes — open and host/section-type-defined; the protocol closes the envelope, not the body.
§A — The content capability
A host that serves this surface advertises a content block in /.well-known/openwop (schema: capabilities.schema.json §content):
{
"capabilities": {
"i18n": { "supported": true, "defaultLocale": "en", "supportedLocales": ["en", "es", "pt-BR", "fr"] },
"content": { "supported": true, "baseLocale": "en", "supportedLocales": ["es", "pt-BR", "fr"] }
}
}
Normative constraints (MUST):
1. A host advertising content.supported: true MUST also advertise i18n.supported: true. Content delivery rides the annex's Accept-Language handling; advertising content without the annex is incoherent and MUST NOT validate as conformant. 2. content.baseLocale MUST equal capabilities.i18n.defaultLocale. 3. The resolvable content locale set is content.baseLocale ∪ content.supportedLocales. Every member MUST be an element of capabilities.i18n.supportedLocales (content may be authored for fewer locales than the host negotiates human-facing text for, never more). 4. content.supportedLocales MUST NOT contain content.baseLocale (the base locale is carried by section data, not a localizations entry). 5. Hosts SHOULD advertise only locales that content delivery actually returns; advertising a locale no surface serves is dishonest per the INTEROP-MATRIX honesty rule.
A host that omits the content block serves no content surface and is unaffected; the conformance scenarios skip cleanly.
§B — Content data model
The core decision: content is one section record with a base data payload plus a sparse localizations map — never one record per locale.
Section (localized-content-section.schema.json):
{
"sectionId": "hero",
"sectionType": "hero",
"data": { "heading": "Welcome", "cta": "Get started" },
"localizations": {
"es": { "heading": "Bienvenido", "cta": "Empezar" },
"pt-BR": { "heading": "Bem-vindo" }
},
"status": "published",
"enabled": true,
"order": 0
}
data(object, REQUIRED): base/default-locale fields, authored incontent.baseLocale. Open object.localizations(object, REQUIRED, MAY be{}): keys MUST match^[a-z]{2}(-[A-Z]{2})?$and MUST NOT equalbaseLocale; values are partial overlays ofdata.status(draft|published, REQUIRED),enabled(boolean, REQUIRED),order(integer, REQUIRED).
Page (localized-content-page.schema.json): { pageId, slug (^[a-z][a-z0-9-]*$), name, status (draft|published), sectionOrder[], seo? }.
Language settings (localized-content-language-settings.schema.json): { baseLocale, supportedLocales[], autoTranslateOnPublish }. Invariant (MUST): baseLocale ∉ supportedLocales. This object is the source of truth for the advertised content block; the advertisement MUST reflect it.
Resolved delivery response (localized-content-page-response.schema.json): { version, generatedAt, locale, slug, page, sections[] } — locale is the negotiated locale (= Content-Language); sections[] carry the already-merged bodies (no localizations map appears in the response).
§C — Locale negotiation + the per-section field merge
Negotiation — reused, not redefined. Locale selection is i18n.md §"Accept-Language request header" + §"Fallback rules (normative)" verbatim: parse Accept-Language (a malformed header MUST NOT 400 — fall back to default), honor q-values, try the language family, fall back to content.baseLocale (== i18n.defaultLocale), and set Content-Language to the locale actually used. There is no ?locale= parameter. The negotiated locale then drives the merge.
Per-section field merge (the one new normative algorithm). After negotiation selects negotiatedLocale, each section resolves its body:
resolveSection(section, negotiatedLocale, baseLocale):
if negotiatedLocale == baseLocale or section.localizations is empty:
return section.data
if section.localizations[negotiatedLocale] exists: # exact-locale override
return { ...section.data, ...section.localizations[negotiatedLocale] }
if negotiatedLocale contains '-': # language-family override
lang = negotiatedLocale.split('-')[0]
if section.localizations[lang] exists:
return { ...section.data, ...section.localizations[lang] }
return section.data # base fallback
- The merge is a shallow field overlay (MUST): locale fields override base fields key-by-key; missing locale fields fall through to
data; nested objects are replaced, not deep-merged. - This ordering (exact → family → base) is the section-level analogue of the annex's locale-level fallback. Both are normative so a client sending
Accept-Language: pt-BRresolves byte-identically on every host. Hosts MUST implementresolveSectionidentically; it is shared verbatim with the conformance suite.
Positive example. Accept-Language: pt-BR, the §B section → localizations["pt-BR"] exists → { heading: "Bem-vindo", cta: "Get started" } (heading overridden, cta falls through to base). Content-Language: pt-BR.
Negative example (fails validation). A section whose localizations contains the base locale ({ "en": {...} } with baseLocale: "en") MUST be rejected; a key EN or en_US (wrong case / underscore) MUST fail the ^[a-z]{2}(-[A-Z]{2})?$ pattern.
§D — Delivery + admin endpoints
Public delivery (cacheable, published-only, locale via Accept-Language):
GET /v1/content/pages/{slug} Accept-Language: pt-BR → resolved page, Content-Language: pt-BR
GET /v1/content/sections/{sectionId}
- Negotiates locale per §C; sets
Content-Languageto the locale used. - Sets
Vary: Accept-Language, Accept-EncodingandCache-Control: public, max-age=300, stale-while-revalidate=3600. - Serves
status: "published"content only. Response validates againstlocalized-content-page-response.schema.json.
Admin CRUD (auth + write scope, tenant-scoped; locale targeted in the request body for writes):
GET /v1/content/pages POST /v1/content/pages
PATCH /v1/content/pages/{pageId} DELETE /v1/content/pages/{pageId}
POST /v1/content/pages/{pageId}/sections
PUT /v1/content/pages/{pageId}/sections/{sectionId} { locale, data }
DELETE /v1/content/pages/{pageId}/sections/{sectionId}/locales/{locale}
GET /v1/content/settings PUT /v1/content/settings
Admin writes target a locale in the body ({ locale, data }): locale == baseLocale upserts data, otherwise it upserts localizations[locale]. Write locale is validated ^[a-z]{2}(-[A-Z]{2})?$. Only public delivery negotiates via the header.
§E — Publish granularity (atomic across locales)
status is a property of the section / page, not of an individual locale. For v1 a section is published or draft atomically across all its locales; there is no per-locale publish state. Per-locale publish is a future additive enhancement (a new optional field). Rationale: per-locale publish multiplies the visibility state space and complicates the published-cache invariant (§F) without a demonstrated need.
§F — Security invariants (normative)
These are MUST-level and verified by the SECURITY-invariant gate (content-published-cache-no-draft, content-response-tenant-scoped, content-no-cross-tenant-enumeration):
- Published cache MUST NOT include draft content. The public, cacheable delivery path serves
status: "published"sections only; a draft section (or draft page) MUST NOT appear in any public response, and MUST NOT be written into any shared/public cache entry. A cache key MUST be scoped such that a draft can never be served from a cached published response. - Content responses MUST be tenant-scoped. Every read and write is scoped by the resolved tenant. A page/section belonging to another tenant MUST be unreachable.
- No cross-tenant enumeration. A request for a
slug/pageId/sectionIdthat exists under a different tenant MUST return the same404as a wholly nonexistent id — the response MUST NOT distinguish "exists for another tenant" from "does not exist". Content-Languageis request-scoped, not logged. Consistent withi18n.md§"Replay determinism", the negotiated content locale is a response-time concern and is not part of any run event log.
Tenant resolution on the public delivery path is host-defined (RFC 0103 register G11). When the request is authenticated, the tenant is the credential-derived RFC 0048 identity triple's tenant. When the request is anonymous, the host resolves the tenant by a host-defined mapping (e.g. domain / Host header). The protocol does not prescribe a single anonymous-resolution mechanism — doing so would invent a URL→tenant coupling the rest of the corpus avoids. The cross-host content-portability guarantee is scoped to a resolved (tenant, locale) pair: given the same resolved tenant and negotiated locale, resolveSection is byte-identical across hosts. It does NOT extend to how an anonymous request selects its tenant — mirroring how i18n.md leaves the host's default locale host-defined. The §F tenant-scoping and no-enumeration invariants hold relative to the host-resolved tenant.
§G — Compatibility + replay
Additive per COMPATIBILITY.md: a new optional capability block, four new schemas, a new /v1/content/* path space — none of which alter an existing wire shape. Hosts that omit content are unaffected. No event-log shape changes; this surface emits no run events in v1 (any future content-lifecycle events — e.g. content.page.published — would be additive AsyncAPI channels). Locale writes are read-modify-write of the localizations JSON (last-write-wins under concurrent editing; field-level concurrency is out of scope v1).
Conformance
Hosts that advertise capabilities.content.supported: true are expected to pass the capability-gated scenario conformance/src/scenarios/localized-content-delivery.test.ts. The scenario verifies (server-free legs always-on; behavioral legs gated on a live host):
- The four schemas validate the §B shapes; the negative cases (base locale in
localizations, bad key case) are rejected. resolveSectionexact-hit / language-family / default-fallback / partial-translation cases (the §C reference algorithm).- §A capability coherence:
contentrequiresi18n;baseLocale == i18n.defaultLocale;({baseLocale} ∪ supportedLocales) ⊆ i18n.supportedLocales;baseLocale ∉ supportedLocales. - Behavioral (live host): malformed
Accept-Languagesucceeds with base locale;Content-Languagereflects the locale used; published-only delivery; tenant isolation + no cross-tenant enumeration.
Hosts that don't advertise the capability skip-equivalent.
See also
i18n.md— the Stable annex this surface reuses (negotiation, fallback,capabilities.i18n).capabilities.md— thecontentcapability block lives besidei18n.auth.md§"Identity claims" — tenant/workspace/principal identity triple (tenant resolution).rest-endpoints.md— error envelope shape for the admin surface.RFCS/0103-localized-content-surface.md— the RFC +registers/0103-*.{gaps,risks}.md.- RFC 9110 §12.5.4 (
Accept-Language), BCP 47 (language tags).