Skip to content

Commit 6a940d1

Browse files
docs(email-templates): copy the folded locale ladder of record into three stale texts (#29498)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ84dfchKnEZZMLPSx82D49u7)_ Fixes #29032 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:邮件模板 locale 解析的三处旧文字(文档页、metadata-core 文档注释、平台清单条目)改为照抄 spec 的 `SendTemplateInput.locale` 阶梯;无代码改动。 - 为什么改:#28880 起内置加载器会折叠语言子标签(zh → zh-CN)并先试声明的回退语言,三处仍写「精确匹配、不折叠」,QA 跑 main 会判错。 - 风险与回滚:仅文字与清单条目;回滚即 revert 本 PR。metadata-core 的注释随 `dist/index.d.ts` 发布,故带 patch changeset。 - 先看:`docs/qa/platform-checklist/areas/integration-system.json`(acceptance[3] 条款与 verify 行)、`content/docs/automation/email-templates.mdx`(Locale resolution 节)。 - 两分钟验证:`pnpm check:platform-checklist`,预期 exit 0。 - 需要你做的:无,席位落地。 ## What changes Three texts outside `packages/spec` described the exact `(name, locale)` ladder that #28880 retired. Each now follows `SendTemplateInput.locale` in `packages/spec/src/contracts/email-service.ts` (landed with PR #29014), and keeps `en` and `en-US` as two rows of one bundle. - `content/docs/automation/email-templates.mdx`, *Locale resolution*: the ladder is copied from the spec docblock: the named-locale ladder over the built-in loader (fold by `resolveBundleLocale`, then the declared fallback locale folded the same way, then `en-US`, then `TEMPLATE_NOT_FOUND`), the replacement-loader exact ladder, and the no-locale ladder. The page names the spec docblock as the record. - `packages/metadata-core/src/item-key-discriminators.ts`: the docblock no longer says the resolution is exact with no folding. It keeps its own rule of citing rather than quoting: it cites `SendTemplateInput.template` and `SendTemplateInput.locale` and keeps the one point the table rests on, that every rung loads one row at one tag exactly, so folding picks a row and never merges two. - `docs/qa/platform-checklist/areas/integration-system.json`, item `integration-system.email-template-render`: acceptance[3]'s clause copies the ladder; its verify line now scores a `zh` recipient getting the `zh-CN` row as the pass (it scored `en-US` before) and a `ja-JP` recipient getting `en-US` through the showcase's declared fallback `en`; steps[4] and variants[0] add those recipients; `automated.ref` no longer calls the uncovered half "the no-folding case" and names plugin-email's fold pins below the oracle's layer. Revision 12 → 13, one history row (ref #29032). - `.changeset/29032-email-template-locale-ladder-docblock.md`: `@objectstack/metadata-core` patch. The docblock ships: after `pnpm --filter '@objectstack/metadata-core...' build`, its text is in `dist/index.d.ts` and `dist/index.d.cts` (`grep -rl 'That resolution is exact' packages/metadata-core/dist` matched both on the base tree; positive control `ITEM_KEY_DISCRIMINATORS` matched `index.js`, `index.cjs` and both `.d.ts` files). ## Why A QA run against `main` reads the checklist clause and scores the folding behaviour as a FAIL, and authors reading the docs page are told `zh` never reaches `zh-CN`. The producer is `EmailService.resolveAndRenderTemplate` in `packages/plugins/plugin-email/src/email-service.ts`, which matches the spec docblock (read at base `8ca9dd7b`). ## Tests No behaviour changed, so there is no new test and no ablation. The ladder the texts now describe is pinned by `packages/plugins/plugin-email/src/template-locale-fold.test.ts` and `template-locale-fold.integration.test.ts`, untouched here. ## Acceptance notes - The `TemplateLoader` interface docblock in `packages/plugins/plugin-email/src/email-service.ts` still says the loader resolves "under the exact rule below — there is no best match and no language-subtag folding". That stays true of `load()` itself (each call loads one tag exactly; the fold lives in the service), so it is not one of the stale texts and is left alone. - The docs page and checklist clause cite the issue-free spec docblock as the record, so the next rewording on the spec side is one place to re-copy. - `pnpm check:dual-build-cjs-loads` was NOT MEASURED locally (exit 3, PREREQUISITE NOT MET: it loads every package's built output and this worktree built only the closures below). It reads no file this diff changes beyond a comment in `metadata-core`; CI's `Lint & Repo Gates` runs it. ## Verification All readings at head `4a0e6715`, worktree off base `8ca9dd7b`. <details><summary>dispatch-gates: 82 derived, 81 exit 0, 1 NOT MEASURED</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 82 commands. Each was run with its exit code captured before any pipe. - 78 exited 0 on the first pass. Three exited 3 (prerequisite) and exited 0 on a rerun: `check-plugin-teardown-shape.mjs --self-test` (after fetching its pinned fixture commit), `spec check:skill-examples` (after building `@objectstack/client` and `client-react`) and `check:lean-entry-closure` (after building `@objectstack/objectql`). - `pnpm check:dual-build-cjs-loads`: exit 3, NOT MEASURED (see Acceptance notes). - `dispatch-gates --ran`: "82 derived famil(ies) accounted for — 81 run, 1 NOT-MEASURED", exit 0. - `pnpm check:platform-checklist`: exit 0 (in the 81). </details> <details><summary>docs build and the published docblock</summary> - Docs build, the production command from `apps/docs/vercel.json` under `TURBO_FORCE=true`: `Tasks: 2 successful, 2 total`, `Cached: 0 cached`, and `apps/docs/.next/BUILD_ID` present. - `pnpm --filter @objectstack/metadata-core build && pnpm --filter @objectstack/metadata-core typecheck`: VERDICT command-exit 0. After the rebuild, `dist/index.d.ts` and `dist/index.d.cts` carry the new docblock and no longer contain "That resolution is exact". </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. (no names added) - [x] 3. No formatter ran; `pnpm lint` passes. (no lint-covered source changed; CI runs it) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the os-dev contract's Chinese summary). --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWaao8NE8qbxe4dXUL)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent a922779 commit 6a940d1

4 files changed

Lines changed: 65 additions & 30 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/metadata-core': patch
3+
---
4+
5+
docs(metadata-core): the `ITEM_KEY_DISCRIMINATORS` docblock no longer says email template resolution is exact with no language-subtag folding
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The docblock ships in the package's type declarations, and it still described the exact `(name, locale)` ladder: the named tag matched exactly, then `en-US`. Over the built-in registry template loader a named locale is now folded against the bundle's tags, then the stack's declared fallback locale is tried, then `en-US`. The docblock now cites `SendTemplateInput.locale` in `@objectstack/spec` as the ladder of record instead of restating its rungs, and keeps the point the table rests on: every rung loads one row at one tag exactly, so `en` and `en-US` stay two distinct rows of one bundle. No code changed.

‎content/docs/automation/email-templates.mdx‎

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -244,23 +244,41 @@ export const passwordResetZh = defineEmailTemplateDefinition({
244244
});
245245
```
246246

247-
`sendTemplate({ template, locale })` walks a fixed ladder — exact, then default,
248-
then deterministic:
249-
250-
1. **`locale`, matched exactly.** There is **no language-prefix matching**:
251-
`zh` does not resolve `zh-CN`, and `en` does not resolve `en-US`. Author the
252-
tags your callers actually pass.
253-
2. **`en-US`** — which is also where a call that omits `locale` *starts*, so
254-
"no locale" means the default rather than an arbitrary row.
255-
3. Only for a call that named **no** locale, and only when the bundle has no
256-
`en-US` row at all: the bundle's lowest locale tag. A single-locale tenant
257-
keeps rendering, and renders identically on every boot.
258-
259-
A call that names a locale with no exact row and no `en-US` row fails with
260-
`TEMPLATE_NOT_FOUND` — it does not silently fall through to another language.
261-
That rung ordering exists because one seam once answered "whichever row the
262-
store yields first" and a no-locale send rendered `zh-CN` out of an
263-
`en-US` + `zh-CN` bundle.
247+
The template name is matched exactly; only the locale member is resolved, by
248+
the ladder below. Every rung of it loads one row at one tag exactly, so `en` and
249+
`en-US` stay two distinct rows of the bundle: folding chooses which row is read,
250+
it never merges two. Resolution is deterministic, never "whichever row the store
251+
yields first". The ladder of record is `SendTemplateInput.locale` in
252+
`packages/spec/src/contracts/email-service.ts`; this section copies it.
253+
254+
A call that **names** a locale, over the built-in registry template loader (the
255+
one `EmailServicePlugin` wires), walks:
256+
257+
1. **this locale, folded** against the locale tags the template's bundle carries
258+
by `resolveBundleLocale` (`@objectstack/spec/system`, the BCP-47 fold
259+
plugin-email imports rather than copying): exact, then case-insensitive
260+
(`zh-cn` → `zh-CN`), then base language (`en-GB` → `en`), then variant
261+
expansion (`zh` and `zh-TW` → `zh-CN`), the first match in ascending tag
262+
order;
263+
2. **the stack's declared fallback locale**, as
264+
`II18nService.getFallbackLocale()` answers it (`i18n.fallbackLocale`, else
265+
`defaultLocale`), folded the same way. A host whose i18n service declares
266+
none, or that has no i18n service, skips this rung;
267+
3. **`en-US`**, exactly;
268+
4. none of them lands on a row: `TEMPLATE_NOT_FOUND`, which is permanent.
269+
270+
A rung that resolves to a tag already tried is skipped. A replacement
271+
`TemplateLoader` keeps the exact ladder: this locale matched exactly, then
272+
`en-US`, then `TEMPLATE_NOT_FOUND`, with no fold and no declared fallback.
273+
274+
A call that **omits** `locale` keeps its own ladder. The service's part of it
275+
holds over any loader: no fold, no declared fallback, and `en-US` first,
276+
exactly, so "no locale" means the default rather than an arbitrary member. When
277+
that misses, the service asks the loader for its own no-locale answer. The
278+
built-in registry loader answers the bundle's `en-US` member, else its lowest
279+
locale tag, so a single-locale bundle keeps rendering and does so identically on
280+
every boot; a replacement `TemplateLoader` gives its own deterministic best
281+
answer for the name.
264282

265283
## Reaching a template
266284

‎docs/qa/platform-checklist/areas/integration-system.json‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -897,7 +897,7 @@
897897
"title": "Email templates resolve from the registry (no sys_email_template row since ADR-0131 D6/D10), resolve (name, locale) with en-US fallback, render {{path}} holes, gate on required variables and active:false, survive admin edits across redeploys — and the raw POST /api/v1/email/send door authenticates, requires manage_platform_settings, bounds sentBy, from, headers, organizationId and identityMail, refuses anonymous, and 400s malformed input",
898898
"since": "v15",
899899
"status": "active",
900-
"revision": 12,
900+
"revision": 13,
901901
"priority": "P2",
902902
"surface": "mixed",
903903
"fixtures": {
@@ -914,7 +914,7 @@
914914
]
915915
},
916916
"variants": [
917-
"locale resolution: exact (name, locale) match / en-US fallback / two-locale bundle picking by recipient locale",
917+
"locale resolution: exact (name, locale) match / a named locale folded to a bundle member (zh → zh-CN) / the declared fallback locale, then en-US / two-locale bundle picking by recipient locale",
918918
"body: bodyHtml + bodyText authored / bodyText omitted (service derives text from HTML — omitted from the ROW, not nulled)",
919919
"gate: active:false → TEMPLATE_INACTIVE",
920920
"gate: missing required variable → fail-fast (requireVars)",
@@ -926,7 +926,7 @@
926926
"drive a send with full data ({ title, project }) through the scratch notify flow of knownGaps[1] (POST /api/v1/automation; channels ['email'], template, templateData; fired through its api trigger); capture the rendered subject/body from the sys_email row it lands (the dev log transport logs no body)",
927927
"drive a send MISSING the required `title`; capture the fast failure",
928928
"make the template inactive (a Studio save of the item with active:false, or a scratch template authored active:false) and send; capture the TEMPLATE_INACTIVE error",
929-
"author the zh-CN twin; send to a zh-CN-locale recipient and to an unmatched-locale recipient; capture which row rendered each",
929+
"author the zh-CN twin; send to a zh-CN-locale recipient, to a zh-locale recipient (a tag the bundle folds) and to a recipient whose language the bundle lacks (e.g. ja-JP); capture which row rendered each. The showcase declares i18n fallbackLocale 'en' (examples/app-showcase/objectstack.config.ts), which folds to the en-US row",
930930
"author a template with fromOverride + replyTo and verify both on the outbound message. fromOverride is an object { name?, address } with address an email (EmailAddressInlineSchema, packages/spec/src/system/email-template.zod.ts) and replyTo a plain address string; a string fromOverride is refused at save, 422 INVALID_METADATA 'expected object'",
931931
"edit the template wording as an admin in Studio (an environment overlay over the declared item), redeploy/reboot, and re-read the served item over GET /api/v1/meta/email_template/showcase_task_done_email — the admin's wording must still be served",
932932
"on the WRITABLE showcase copy (fixtures.requires[3]), edit the DECLARED source template and metadata-reload WITHOUT a reboot, then GET /api/v1/meta/email_template/<name> — the registry serves the edited item (email_template is allowRuntimeCreate:true; the plugin subscribes to metadata changes). Keep the reboot out of it: under os dev's default watcher a declared-source save recompiles AND restarts the server (packages/cli/src/commands/dev.ts, --restart defaults on), which proves nothing about a reload. Compile into the running copy's artifact with objectstack compile --output COPY/dist/objectstack.json, or boot the copy with os dev --no-restart (the watcher then only rebuilds the artifact), and read the listener's PID before and after (lsof -ti tcp:PORT): an unchanged PID is part of the evidence, and a changed one means this half was not driven. On the stock read-only showcase this half is not drivable: record it partial, as QA run #23166 did",
@@ -954,9 +954,9 @@
954954
"evidence": "the error"
955955
},
956956
{
957-
"clause": "(name, locale) resolution is an EXACT match with no language-subtag folding — two rows with one name are an i18n bundle, each reachable ONLY from its own exact tag; a call naming a locale with no row retries the single literal rung en-US and then raises TEMPLATE_NOT_FOUND, and a call naming no locale starts at en-US",
957+
"clause": "(name, locale) resolution walks the ladder of record, SendTemplateInput.locale in packages/spec/src/contracts/email-service.ts: two rows with one name are an i18n bundle, the name is matched exactly and only the locale member is resolved, and every rung loads one row at one tag exactly, so en and en-US stay two distinct rows (folding chooses which row is read, it never merges two). A call that NAMES a locale, over the built-in registry template loader, walks: (1) this locale, folded against the bundle's locale tags by resolveBundleLocale — exact, then case-insensitive (zh-cn → zh-CN), then base language (en-GB → en), then variant expansion (zh and zh-TW → zh-CN), the first match in ascending tag order; (2) the stack's declared fallback locale (II18nService.getFallbackLocale(): i18n.fallbackLocale, else defaultLocale), folded the same way, skipped when none is declared; (3) en-US, exactly; (4) TEMPLATE_NOT_FOUND, which is permanent. A rung that resolves to a tag already tried is skipped; a replacement TemplateLoader keeps the exact ladder. A call that names no locale starts at en-US exactly, with no fold and no declared fallback, then takes the loader's own no-locale answer (the built-in loader: the bundle's lowest locale tag)",
958958
"oracle": "log",
959-
"verify": "the zh-CN recipient gets the zh-CN render; a recipient whose locale has no row (e.g. zh) gets the en-US row, NOT the zh-CN one",
959+
"verify": "the zh-CN recipient gets the zh-CN render; the zh recipient gets the zh-CN render too (variant expansion); a recipient whose language the bundle lacks (e.g. ja-JP) gets the en-US row, through the showcase's declared fallback en folding to en-US. A zh recipient getting the en-US row is the FAIL",
960960
"evidence": "the two renders"
961961
},
962962
{
@@ -994,7 +994,7 @@
994994
],
995995
"automated": {
996996
"kind": "e2e",
997-
"ref": "packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts — on its own fixture package (auth.password_reset), not the showcase template: acceptance[0] (the registry serves the authored item beside the built-in and sendTemplate renders it, the subject exact and the body by containment), and halves only of acceptance[3] (the exact zh-CN member is reachable; not the no-folding case or the TEMPLATE_NOT_FOUND end) and of acceptance[5] (a Studio save sends on the next mail with no restart; not redeploy survival, not the declared-source reload). + packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts ('after a cold boot the metadata door and the rendered mail both carry the admin's wording') — acceptance[5]'s redeploy-survival half on the showcase. + packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts ('control: a locale nobody saved keeps the package ladder') — acceptance[3]'s en-US rung for a locale with no row (ja-JP). acceptance[6] rests on no pin; acceptance[7]'s capability, sentBy, sender, headers-shape, organization and identity-mail bounds are pinned below the e2e layer by packages/rest/src/email-send-door-bounds.test.ts (the real Hono transport over a contract-typed mail-service double — the dev-transport landing and the sys_email row stay hand-driven), and acceptance[1], [2] and [4] on one below their oracle's layer: packages/plugins/plugin-email/src/send-template.test.ts drives EmailService over a fake loader and a capture transport, asserting MISSING_VARIABLES (acceptance[1]), TEMPLATE_INACTIVE (acceptance[2]) and the fromOverride and derived text (acceptance[4]) at unit — whether the error names the variable, the registry-resolved inactive item, replyTo and the row stay hand-driven: drive them by hand"
997+
"ref": "packages/qa/dogfood/test/email-template-materialization.dogfood.test.ts — on its own fixture package (auth.password_reset), not the showcase template: acceptance[0] (the registry serves the authored item beside the built-in and sendTemplate renders it, the subject exact and the body by containment), and halves only of acceptance[3] (the exact zh-CN member is reachable; not the fold rungs, the declared-fallback rung or the TEMPLATE_NOT_FOUND end, which packages/plugins/plugin-email/src/template-locale-fold.test.ts pins at unit and template-locale-fold.integration.test.ts on a booted EmailServicePlugin, below this oracle's layer) and of acceptance[5] (a Studio save sends on the next mail with no restart; not redeploy survival, not the declared-source reload). + packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts ('after a cold boot the metadata door and the rendered mail both carry the admin's wording') — acceptance[5]'s redeploy-survival half on the showcase. + packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts ('control: a locale nobody saved keeps the package ladder') — acceptance[3]'s en-US rung for a locale with no row (ja-JP). acceptance[6] rests on no pin; acceptance[7]'s capability, sentBy, sender, headers-shape, organization and identity-mail bounds are pinned below the e2e layer by packages/rest/src/email-send-door-bounds.test.ts (the real Hono transport over a contract-typed mail-service double — the dev-transport landing and the sys_email row stay hand-driven), and acceptance[1], [2] and [4] on one below their oracle's layer: packages/plugins/plugin-email/src/send-template.test.ts drives EmailService over a fake loader and a capture transport, asserting MISSING_VARIABLES (acceptance[1]), TEMPLATE_INACTIVE (acceptance[2]) and the fromOverride and derived text (acceptance[4]) at unit — whether the error names the variable, the registry-resolved inactive item, replyTo and the row stay hand-driven: drive them by hand"
998998
},
999999
"source": [
10001000
"packages/spec/src/system/email-template.zod.ts#fromOverride (requireds, dotted-name regex, aliases #5013, variables/required, fromOverride/replyTo/active)",
@@ -1078,6 +1078,12 @@
10781078
"date": "2026-11-01",
10791079
"change": "Two QA run #28656 (W3-RT) findings. knownGaps[1]'s recipe was incomplete. #28656 met three refusals on the way: an inline title/message beside template (400), type 'autolaunched' at POST /api/v1/automation/NAME/trigger (403), and type 'api' with no config.secret or secretRef (400). It now spells a type:'api' flow with start config {triggerType:'api', hookId, secret}, a notify node with channels ['email'], template and templateData and no inline title/message, fired as the admin. Source: NotifyConfigSchema's template/inline exclusivity (io-node-config.zod.ts) and validateApiTriggerSecret (flow-registration-checks.ts). The 403's rule is read from flow-start-admission.ts, which refuses a runAs: 'system' self-triggered type. That #28656's flow declared runAs: 'system' is not confirmed from source, and the 400 and 403 statuses are #28656's readings. steps[7] said 'metadata-reload WITHOUT a reboot' on a copy whose os dev watcher restarts on every save (dev.ts --restart, default on). It now names objectstack compile --output into the running copy's dist/objectstack.json or --no-restart, and requires an unchanged listener PID as evidence. No clause changed. Re-verified against source at base 1c5b1773ec. Wave anchor #27377, wave-3 accuracy slice S10 (#28701); findings parked in anchor comment 6557904764",
10801080
"ref": "#28656"
1081+
},
1082+
{
1083+
"revision": 13,
1084+
"date": "2026-11-02",
1085+
"change": "CORRECTION: acceptance[3] said (name, locale) resolution is an EXACT match with no language-subtag folding, and its verify line scored a zh recipient getting the en-US row as the pass. Since #28880 a named locale, over the built-in registry template loader, is folded against the bundle's tags by resolveBundleLocale, then the stack's declared fallback locale is tried folded the same way, then en-US, then TEMPLATE_NOT_FOUND, so a run against main scored the clause wrong. The clause now copies the ladder of record, SendTemplateInput.locale in packages/spec/src/contracts/email-service.ts, and keeps en and en-US as two rows of one bundle; the verify line and steps[4] add a zh recipient (folds to zh-CN) and a recipient whose language the bundle lacks (declared fallback en folds to en-US); variants[0] names the fold and fallback rungs; automated.ref no longer calls the uncovered half 'the no-folding case' and names the plugin-email fold pins below the oracle's layer. Read from source at base 8ca9dd7b",
1086+
"ref": "#29032"
10811087
}
10821088
]
10831089
},

‎packages/metadata-core/src/item-key-discriminators.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@
1010
* an i18n bundle, one row per locale tag — and its header says a template
1111
* "is resolved by `(name, locale)`".
1212
*
13-
* That resolution is exact. `SendTemplateInput.template` in
14-
* `packages/spec/src/contracts/email-service.ts` rules out any best match and
15-
* any language-subtag folding, and `SendTemplateInput.locale` there is the
16-
* ladder of record: the named tag matched exactly, then the literal `en-US`,
17-
* then — only for a call that named no locale, and only when the bundle has
18-
* no `en-US` row — the bundle's lowest locale tag. Both are cited rather than
19-
* quoted, so a rewording on the spec side cannot strand a copy here.
13+
* The pair stays the identity whichever row a send reads.
14+
* `SendTemplateInput.template` in `packages/spec/src/contracts/email-service.ts`
15+
* matches the name exactly and resolves only the locale member, by the ladder
16+
* of record on `SendTemplateInput.locale` there. Every rung of it loads one row
17+
* at one tag exactly, so `en` and `en-US` stay two distinct rows of the bundle:
18+
* folding chooses which row is read, it never merges two. The ladder's rungs
19+
* are cited rather than quoted, so a rewording on the spec side cannot strand
20+
* a copy here.
2021
*
2122
* Every layer that keys metadata by `name` therefore has to agree on ONE
2223
* answer to "what else is part of this type's identity?", or a bundle survives

0 commit comments

Comments
 (0)