Skip to content

feat(spec)!: a locale tag written in another case is corrected to its BCP-47 case - #29190

Closed
objectstack-fleet[bot] wants to merge 5 commits into
mainfrom
claude/project-thread-ih2cyh
Closed

objectstack-fleet[bot] wants to merge 5 commits into
mainfrom
claude/project-thread-ih2cyh

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

domain:spec#1 · os-steve · session_01ADHkmRmpbFs6uQ8AuadsTr

Requested by Steve · project thread
Fixes #28993

Clause-②: no (narrowing)
Metadata-protocol: narrowed
Skills-docs: updated here — content/docs/protocol/kernel/i18n-standard.mdx (the "Case is not judged: the resolvers fold it" sentence now says case is corrected on parse, a stored row's locale is folded by the conversion translation-locale-tag-case-folded, and a bundle with two keys that fold to one tag is refused). skills/objectstack-i18n/SKILL.md already spells every tag in its canonical case and teaches no case rule, so no skill text or eval moves.

维护者速读

  • 改了什么:区域标签(locale)大小写写得不标准时,解析会改成 BCP-47 标准写法(zh-cn → zh-CN,zh-hans-cn → zh-Hans-CN),只改大小写,不改别的;库里已经按 zh-cn 存下的翻译,读出时由一个新的存量转换(translation-locale-tag-case-folded)按标准写法归位。同一个翻译包里同时写了 zh-cn 和 zh-CN 两个键,会被拒绝并点名两个键。
  • 为什么改:翻译以 zh-cn 保存时接口回 200,但 zh-CN 会话永远看不到这些翻译(i18n: a translation saved with locale 'zh-cn' answers 200 but is never merged for a zh-CN session — #25822 left case unjudged on a runtime case-fold that does not happen #28993),因为语言解析先精确匹配,内置的 zh-CN 包总是先命中。现在解析层只留一种写法,每个读者看到同一个标签。
  • 风险与回滚:这是一处破坏性收窄(BREAKING),落地要你拍板:同一个翻译包里只差大小写的两个键(如 zh-cn 和 zh-CN)以前能通过,现在会被拒绝并点名两个键,仓内命中 0 处。大小写更正本身是放宽,其它原来能通过的输入照样通过,JSON Schema 不变。还有两处按原写法精确比较的地方(邮件模板的语言下限告警、通知模板的语言梯):配置里写成 zh-cn 的默认或支持语言现在读作 zh-CN,存量模板行若仍标 zh-cn 就对不上,需要把模板行改成标准写法,行侧归位随 server 车道的入口卡落地。如果你选合并,一笔提交就能换成「按标准写法的键优先合并」,声明行回到 yes (widening) / semantic,不再是破坏性改动。保存入口仍原样存 zh-cn(入口属 server 车道,另起一张卡),但读出已归位,不影响本卡效果。回滚即撤掉本 PR。
  • 先看:packages/spec/src/system/translation.zod.ts(canonicalLocaleCase 与两个翻译包 schema 上的 superRefine / overwrite);packages/spec/src/conversions/registry.ts 新增的 translation-locale-tag-case-folded(存量行大小写归位,排在 spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string() #25822 的 translation-locale-tag-canonicalized 之后);packages/spec/src/system/translation.test.ts 新增的一组测试。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/system/translation.test.ts,应全部通过,其中包含新增的「LocaleSchema corrects a tag to its BCP-47 case (i18n: a translation saved with locale 'zh-cn' answers 200 but is never merged for a zh-CN session — #25822 left case unjudged on a runtime case-fold that does not happen #28993)」一组。
  • 需要你做的:回答席位的决策卡:同一翻译包里只差大小写的两个键,是拒绝还是合并。

What changes

  • LocaleSchema corrects case (packages/spec/src/system/translation.zod.ts). A tag that passes LOCALE_TAG_SHAPE is returned in its BCP-47 case (RFC 5646 §2.1.1): the language subtag lower case, a 4-letter script title case, a 2-letter region upper case, any other subtag lower case, and every subtag after a singleton lower case. The fold is .overwrite(canonicalLocaleCase), so z.input and z.output are both string, Locale stays z.input, and the published JSON Schema does not move (check:generated regenerated nothing). A canonical tag is returned unchanged, and nothing that parsed before is refused except the bundle case below.
    • Case only, not Intl.getCanonicalLocales. Measured on Node v22.23.4: Intl.getCanonicalLocales rewrites more than case (iw → he, in → id, tl → fil, cmn → zh, mo → ro, art-lojban → jbo, en-us-POSIX → en-US-u-va-posix) and throws on tags LOCALE_TAG_SHAPE accepts (zh-Hans-Hans, sgn-BE-FR, en-GB-oed, ar-aao, en-a-b). Replacing a legacy subtag would move a translation to a locale its author never named, and the answer would depend on the runtime's ICU data.
    • Readers whose output changes: a translation item's locale; TranslationBundle / PlatformTranslationBundle keys; i18n.defaultLocale, fallbackLocale, supportedLocales; a seed dataset's locale scope (data/seed.zod.ts) and the seed loader's locale (data/seed-loader.zod.ts). EmailTemplateDefinitionSchema.locale does not use LocaleSchema and is untouched (spec: the email template locale docs say the resolver folds no language subtag and retries only en-US, which #28880 makes wrong #28896 / docs(spec): the email template locale docs describe the ladder #28880 landed #29014 unaffected); PackageLocaleSchema (marketplace) is a separate schema and is untouched.
  • Bundle keys fold, and two keys that fold to one tag are refused (the narrowing, BREAKING; see Acceptance notes). Both bundle schemas are now z.record(LocaleTagKeySchema, …).superRefine(refuseLocaleKeysFoldingTogether).overwrite(foldLocaleKeys). The key schema is a shape-only twin of LocaleSchema (same regex, message and description, no fold): a folding key schema would let z.record keep the later of two colliding keys and drop the other with no issue. The refinement runs before the fold and refuses { 'zh-cn': …, 'zh-CN': … } with a custom issue at the second key, naming both keys, the tag and the release (Refused since @objectstack/spec 18). The fold then rebuilds the record with canonical keys, in order, and returns the input itself when every key is already canonical.
  • Stored rows read back canonical, through a D2 entry of their own (packages/spec/src/conversions/registry.ts). The new ADR-0087 D2 conversion translation-locale-tag-case-folded (toMajor: 18, retiredFromLoadPath: true, retiredAfter: '18.2.0', the same surface as spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string() #25822's entry) folds a tag in another case to the LocaleSchema parse: an item's locale, i18n.defaultLocale / fallbackLocale / supportedLocales, data[].locale[] and bundle keys, one notice each. It folds tag-shaped values only and leaves the rest to the entry before it. A bundle key that folds onto a key already holding different strings is left as stored and reported as a TODO (naming the release); an identical twin is merged by renameKey. Its order is 76.5, right after spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string() #25822's translation-locale-tag-canonicalized (76), so an underscore spelling is made a tag first and folded second (zh_cn → zh-cn → zh-CN).
    • spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string() #25822's entry keeps its own stamp. translation-locale-tag-canonicalized stays retiredAfter: '18.1.0' with its code unchanged: its docblock now says case is kept as written there and points at the case entry, retired a release later, because the premise that every resolver folds case was measured false. Its fixture moves only to stay disjoint (see Forced test edits).
    • Why stored rows need it. The save door (saveMetaItem) stores the authored body, so a zh-cn save stays zh-cn at rest; core's readAuthoredTranslationLayer replays the full conversion chain over each stored row before keying it by data.locale, so the row reaches zh-CN sessions from the next sync on, with nothing to re-save. An artifact built by @objectstack/spec 18.2.0 or earlier is folded the same way inside its declared-floor window.
  • Exact matchers that read a folded locale against a row tagged as written. Two consumers compare a folded i18n locale with a row whose locale is not folded: the email-template floor warning (warnEmailTemplateLocaleFloor in stack.zod.ts) compares i18n.supportedLocales with emailTemplates[].locale, and the messaging template ladder compares localeCandidates(i18n.getDefaultLocale()) with sys_notification_template.locale. A row tagged zh-cn no longer matches a supported or default locale authored as zh-cn, which now reads zh-CN. The D3 entry's replacement and acceptance criteria name both: the consumer folds case or the row is re-tagged, and the row-side fold lands with the server sibling card (the save door storing the parsed locale). Not changed here.
  • Upgrade path: new ADR-0087 D3 semantic entry packages/spec/src/migrations/entries/semantic/18.translation-locale-case-canonicalized.ts, conversionIds: ['translation-locale-tag-case-folded']. The sibling entry 18.translation-locale-non-tag-refused.ts said case is not refused because the resolvers fold it; it now says the parse corrects it.
  • Docs: content/docs/protocol/kernel/i18n-standard.mdx, one paragraph (see Skills-docs:). This file is outside the claim's listed surface: a bounded in-place fix of a sentence this diff makes false.
  • Dropped-refinements ledger (packages/spec/dropped-refinements.baseline.json): +6 sites, 983 → 989. The bundle superRefine has no JSON Schema form, so it lands at the root of system/TranslationBundle and system/PlatformTranslationBundle, and at manifest.translations.element in the four api/*InstalledPackage* responses that embed a bundle. Entries are the ones the spec build's gate printed.
  • Changeset: .changeset/28993-locale-case-canonicalized.md, @objectstack/spec: minor, titled feat(spec)!:. It carries the two declaration lines above, a BREAKING banner for the refusal with its migration (FROM a bundle holding both spellings, { 'zh-cn': A, 'zh-CN': B }, TO one canonical key holding both sets of strings, merged by hand), and one ADR-0087 disposition, registered translation-locale-case-canonicalized (the D3 entry is new in this diff and prescribes that merge). Its stored-rows bullet names the new conversion, and a bullet names the two exact matchers.
  • Not changed, on purpose: core memory-i18n.ts#resolveLocale (triage 6570179836: no third runtime case-fold); the save door in packages/metadata-protocol (domain:server, handed off as a sibling card); the two exact matchers above.

Forced test edits

  • packages/spec/src/conversions/registry.ts, the fixture of spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string() #25822's translation-locale-tag-canonicalized: its stored item locale: 'pt-br' is now pt-BR on both sides (before and after), expectedNotices unchanged at 7. The fixture runner replays the whole chain, so a pt-br there would now be folded by the case entry and give one notice that is not this entry's; fixtures stay disjoint. The case belongs to the new entry's fixture.
  • packages/spec/src/shared/retired-key-removal-version.pin.test.ts: translation-locale-tag-case-folded is retired after 18.2.0, the newest published stable release, so the census pin requires a JOIN row with the prescriptions an author meets. A lone tag is corrected, not refused, so the row reads the collision two keys of one bundle make once folded: the bundle refusal at the parse, and the conversion's TODO for a stored pair (a new todo row kind, for a data-at-rest prescription that has no parse refusal to read). Both messages now name @objectstack/spec 18, which the row derives from the stamp.
  • packages/spec/src/system/translation.test.ts: the existing accepts the tag %j (case is not judged) row is retitled (case is corrected, never refused). Its assertion is unchanged; the old title stated the rule this PR reverses.

Tests

packages/spec/src/system/translation.test.ts, new describe LocaleSchema corrects a tag to its BCP-47 case (#28993):

  • Fold table: zh-cn, ZH-CN → zh-CN; zh-hans-cn, ZH-HANS-cn → zh-Hans-CN; EN → en; en-us → en-US; yue-hk → yue-HK; sr-LATN-rs → sr-Latn-RS; es-419, de-1996 unchanged; en-US-u-CA-Gregory → en-US-u-ca-gregory; en-x-US → en-x-us.
  • A canonical tag is returned unchanged (6 tags).
  • Case only: iw stays iw, TL → tl, cmn-cn → cmn-CN, and zh-hans-hans → zh-Hans-Hans (a tag Intl.getCanonicalLocales throws on).
  • Every reader sees the canonical spelling: a translation item, defineTranslation, TranslationConfig (defaultLocale, fallbackLocale, supportedLocales), SeedSchema.locale, SeedLoaderConfig.locale.
  • Bundle keys are folded, in authored order, values untouched: TranslationBundleSchema, defineTranslationBundle, PlatformTranslationBundleSchema.
  • Refusal: both bundle schemas refuse zh-cn beside zh-CN with exactly [['custom', 'zh-CN']] (code, path), and the message names both keys.
  • CONTROL accepts: distinct tags in one bundle (zh-CN, zh-TW, zh-hans-cn); zh-cn in one bundle and zh-CN in another, each parsed on its own (two bundles of stack.translations are separate records the runtime merges per locale).
  • Stored-row seam: applyConversionsToStoredItem('translation', { locale: 'zh-cn', … }) reads back zh-CN; a canonical row reads back equal; an underscored zh_cn row reads back zh-CN (the order: spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string() #25822's entry makes it a tag, the case entry folds it).
  • Case entry at rest: retiredAfter is 18.2.0; a bundle with zh-cn beside a different zh-CN is left as stored with one TODO at translations[0].zh-cn; an identical ja-jp / ja-JP twin is merged with one notice.
  • The case entry's own fixture (run by the conversion fixture suite over the whole chain): pt-br → pt-BR, bundle keys ko-kr → ko-KR and IT → it, i18n en-us → en-US and zh-hans-cn → zh-Hans-CN, data[].locale zh-cn → zh-CN; 7 notices.

Acceptance notes

  • The narrowing beside the widening. A bundle holding two keys that fold to one tag parses on main today (both keys kept). After this PR it is refused, naming both keys. Measured on 6b3ba82c over every TS/TSX/JS/MJS/CJS/JSON/YAML file in the repo: 0 such pairs, and 0 locale-keyed bundle keys in a non-canonical case outside tests and the conversion fixtures. Under AGENTS.md's level precedence (a value refused is narrowed, narrowed over semantic) the seat amended the claim (6573207423) to Clause-②: no (narrowing) / Metadata-protocol: narrowed, and this body and the changeset carry those lines. Landing waits for the maintainer's word on the seat's decision card: refuse naming both keys, or merge with the canonical key winning. The merge is prepared as a one-commit swap on top of this head (8 files, +85 / -92: the refinement and its ledger lines go, foldLocaleKeys deep-merges, the refusal tests become merge tests in both key orders with an unmerged CONTROL, the census row keeps only the TODO, and the prose moves with it); with it the change is non-breaking and the lines go back to yes (widening) / semantic. On that build (one local commit, 4f8cfe7a, on 263f6c89; the patch is /mnt/project-files/spec-seat1/28993-merge-fallback.patch in the project's files) the targeted spec run (translation, conversions, migrations and the census pin) read 30 files / 1244 tests passed, the spec build and check:generated were green, check:adr-0087-registration read "this PR adds no declared-breaking changeset", and check-changeset-no-major --event with the lines swapped exited 0 (yes (widening) / semantic).
  • The save door. Measured with a held-mutation probe: saveMetaItem (packages/metadata-protocol/src/protocol.ts) runs schema.safeParse(item) and stores the request body verbatim for every type except view, plus four named grafts, so a schema transform never reaches storage. Storing the parsed locale there is a domain:server slice; the seat files it. Until it lands, a lowercase save is raw at rest and the case entry keeps folding it on every read.
  • packages/lint/src/lint-liveness-properties.ts and its test describe a bundle as z.record(LocaleSchema, TranslationDataSchema) in comments. The key schema is now a shape-only twin of LocaleSchema; the comments' point (keys are locale codes, groups live one level down) still holds. Not changed here.

Verification

Head 263f6c89 (base 6b3ba82c). Round 3 is two commits on top of round 2's 3b3284c9: 713814b9 (the case fold as its own D2 entry, the S2 text, the docs and changeset lines that name it) and 263f6c89 (the census pin's JOIN row, and the two collision messages naming the release). The spec suite, build, consumer packages, ablations, gate families and dogfood battery below were run on 263f6c89. The runtime, rest, downstream-contract, lint, cli and metadata readings and the end-to-end proof were taken on 102f342e and carry: round 3 moves no reader outside packages/spec, and the end-to-end path is pinned at the spec level by the stored-row seam test. Every heavy run went through os-verify-lock.sh; its VERDICT line is quoted.

packages/spec: suite, type check, build, generated artifacts
  • pnpm --filter @objectstack/spec test && pnpm --filter @objectstack/spec typecheck (typecheck includes check:scripts-typecheck and check:test-typecheck): Test Files 789 passed, 1 skipped (790); Tests 23384 passed, 1 skipped, 1 todo. VERDICT command-exit 0. The skipped file is scripts/root-entry-type-nameability.pin.test.ts, which skips itself while spec's dist is stale (it was: the merge fallback had just been built in this worktree). Rerun after the build below: 1 file, 2 tests passed.
  • pnpm --filter @objectstack/spec build && pnpm --filter @objectstack/spec check:generated: "All 14 generated artifacts are up to date". VERDICT command-exit 0. Nothing regenerated: the published JSON Schema of a locale and of both bundles does not move.
  • Targeted, vitest run src/system/translation.test.ts src/conversions src/migrations (on 713814b9): Test Files 29 passed (29), Tests 1184 passed. src/shared/retired-key-removal-version.pin.test.ts and scripts/conversions-major18-merge.test.ts (on 263f6c89): 289 tests passed together with translation.test.ts.
  • pnpm turbo run build --concurrency=2 --filter='./packages/**': Tasks 78 successful, 78 total. VERDICT command-exit 0.
  • eslint --no-inline-config over the changed .ts files: exit 0.
Consumer packages

On 263f6c89:

  • @objectstack/core (the i18n fallbacks; readAuthoredTranslationLayer replays the chain): Test Files 129 passed (129), Tests 2909 passed. VERDICT command-exit 0.
  • @objectstack/metadata-protocol (the save door, the seed loader, stored-row conversions): Test Files 297 passed, 4 skipped (301), Tests 22106 passed, 26 skipped. VERDICT command-exit 0.
  • @objectstack/service-i18n: Test Files 4 passed (4), Tests 71 passed. VERDICT command-exit 0.

Carried from 102f342e: @objectstack/runtime 513/513 files, 8272 passed + 19 skipped; @objectstack/rest 366/366, 6944 passed + 332 skipped; @objectstack/downstream-contract 3/3, 31; @objectstack/lint src/lint-liveness-properties.test.ts 111/111; @objectstack/cli test/metadata-type-schema-gate.test.ts (integration project) 8/8; @objectstack/metadata src/serializers/typescript-serializer-annotation.test.ts 7/7. Each VERDICT command-exit 0.

End-to-end: a zh-cn save reaches a zh-CN session (carried from 102f342)

A one-off scratch test (deleted afterwards): the real saveMetaItem over a stub engine saves { name: 'zh_cn', locale: 'zh-cn', objects: { project: { label: '项目(lower)' } } }; core's readAuthoredTranslationLayer reads the stored rows; createMemoryI18n is loaded with the stock zh-CN strings and then the layer.

E2E save.success= true stored rows= 1 stored.locale= zh-cn layer keys= ["zh-CN"] zh-CN serves= 项目(lower)

On main (6b3ba82c) the same stored row gave layer keys= ["zh-cn"] zh-CN serves= 项目. On 263f6c89 the same seam is pinned in translation.test.ts (applyConversionsToStoredItem reads zh-cn and zh_cn back as zh-CN), and core's suite above is green.

Ablations

Round 3, on 263f6c89 in a side worktree, each through ablation-replace on packages/spec/src/conversions/registry.ts and restored to the HEAD blob (966e69e5). Command: vitest run src/system/translation.test.ts src/conversions src/shared/retired-key-removal-version.pin.test.ts (25 files, 1049 tests green at HEAD).

  • The case entry's row order: 76.5 → order: 75.5 (before spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string() #25822's entry): 1 failed, 1048 passed (the stored-row seam: zh_cn stays zh-cn, because the case entry runs before the _ is rewritten).
  • The case entry's return parsed.success ? parsed.data : undefined; → return parsed.success ? v : undefined; (shape still checked, case no longer folded): 4 failed, 1045 passed (the stored-row seam, the collision test, the census row's TODO, the entry's fixture).
  • The census row, by hand with the file restored from a copy: dropping the release from the bundle refusal, then from the conversion's TODO, each turned the translation-locale-tag-case-folded row red ("names no release"), 1 failed of 60.

Round 1 (on 102f342e, still applicable to the parse side): the case fold .overwrite(canonicalLocaleCase) → identity gave 14 failed; the refusal's first-key lookup → undefined gave 2 failed (both bundle refusal tests); the key fold folded[canonicalLocaleCase(key)] → folded[(key)] gave 2 failed.

Derived gate families, declaration checks and the dogfood battery (on 263f6c8)
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 263f6c89: 105 commands (75 pnpm, 30 direct node), the same list as at 3b3284c9; change set 9 paths vs merge base 6b3ba82cb, 565 changed lines (+543 / -22).
  • All 105 exit 0, each exit captured before any pipe. dispatch-gates --ran: "105 derived famil(ies) accounted for — 105 run, 0 NOT-MEASURED (a DERIVED zero — all 105 recorded an exit code and none of them is 3)".
  • check-adr-0087-registration.mjs --base origin/main (one of the 105): exit 0, "1 declared-breaking changeset(s), each carrying an ADR-0087 disposition", .changeset/28993-locale-case-canonicalized.md [BREAKING+bang+clause-②-narrowing+metadata-protocol-narrowed] registered translation-locale-case-canonicalized (new here: translation-locale-case-canonicalized).
  • check-changeset-no-major.mjs --base origin/main --event (this body and title, offline): exit 0, "LEVEL AXIS: this PR declares clause-② no (narrowing) and metadata-protocol narrowed", direction arm narrowing (BREAKING, ships minor during the launch window), @objectstack/spec: minor, no major.
  • check-partof-closing-keyword.mjs with this body as PR_BODY (offline): exit 0.
  • Dogfood battery, pnpm exec turbo run build --concurrency=2 --filter='@objectstack/dogfood^...' && pnpm --filter @objectstack/dogfood test under the lock (no OS_TEST_SHARD, no path), VERDICT command-exit 1 (held 36m56s): closure Tasks 68 successful, 68 total; Test Files 1 failed, 383 passed, 3 skipped (387); Tests 3161 passed, 33 skipped (3194). The one red file is test/package-first-authoring.dogfood.test.ts: its first beforeAll (bootStack(crmStack)) hit the 10000 ms hook timeout, and its other three describes then refused to boot because com.example.crm "already has a live bootStack kernel in this process" (5 tests skipped, hence 33 skipped against round 2's 28). The battery ran beside the 105-gate loop on the same 4 cores. That file alone under the lock, twice: Test Files 1 passed, Tests 5 passed, both times (VERDICT command-exit 0, held 41 s and 36 s). Reported as found; nothing skipped or narrowed.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…BCP-47 case

LocaleSchema now returns each tag in its BCP-47 case (zh-cn -> zh-CN,
zh-hans-cn -> zh-Hans-CN, EN -> en) instead of accepting the authored case,
so a translation saved as zh-cn is merged into the zh-CN locale a session
asks for. The fold is case-only: Intl.getCanonicalLocales also replaces
legacy subtags and throws on tags the shape accepts.

The two bundle records judge their keys together and refuse two keys that
fold to one tag, naming both, rather than letting the later one replace the
earlier. The #25822 stored-row conversion folds case too, so a row the save
door stored as zh-cn is read back as zh-CN by every rehydration seam. New
semantic entry translation-locale-case-canonicalized; the stale "case is not
judged" sentences are corrected.

Claude-Session: https://claude.ai/code/session_01ADHkmRmpbFs6uQ8AuadsTr
Co-authored-by: Claude <noreply@anthropic.com>
…efinements ledger

The refusal of two locale keys that fold to one tag is a superRefine with no
JSON Schema form, so the published TranslationBundle / PlatformTranslationBundle
(and the four installed-package envelopes that embed manifest.translations)
gain one dropped-refinement site each. Sites as the build-schemas gate printed
them; measured totals recounted.

Claude-Session: https://claude.ai/code/session_01ADHkmRmpbFs6uQ8AuadsTr
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:system tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 22 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/actions-as-tools.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/ai/agents.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/api/client-sdk.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/api/error-catalog.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/data-modeling/index.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/deployment/environment-variables.mdx (via fallbackLocale (literal, a string literal in apply))
  • content/docs/deployment/validating-metadata.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/getting-started/examples.mdx (via i18n.supportedLocales (literal, a string literal in apply))
  • content/docs/kernel/services-checklist.mdx (via defaultLocale (literal, a string literal in apply), fallbackLocale (literal, a string literal in apply))
  • content/docs/permissions/authorization.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/protocol/kernel/i18n-standard.mdx (via LocaleSchema (symbol, a top-level const), defaultLocale (literal, a string literal in apply), fallbackLocale (literal, a string literal in apply))
  • content/docs/ui/index.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/ui/translations.mdx (via defaultLocale (literal, a string literal in apply), fallbackLocale (literal, a string literal in apply))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v13.mdx (via crm_lead (literal, a string literal in fixture))
  • content/docs/releases/v17/17-5.mdx (via retiredAfter (symbol, a field of const object translationLocaleTagCaseFolded), retiredFromLoadPath (symbol, a field of const object translationLocaleTagCaseFolded), crm_lead (literal, a string literal in fixture))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/dropped-refinements.baseline.json) — pages documenting those are invisible to this run
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 168 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a19759ac83745bde6f2e985e5f8cde0b4da96611 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 7014166509325a1182c15d627683e88b6ed6e8ae — the merge of head 263f6c891eac65f49418adc1f13aa7004bf6b9be into base a19759ac83745bde6f2e985e5f8cde0b4da96611, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7014166509325a1182c15d627683e88b6ed6e8ae && git checkout 7014166509325a1182c15d627683e88b6ed6e8ae
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a19759ac83745bde6f2e985e5f8cde0b4da96611 263f6c891eac65f49418adc1f13aa7004bf6b9be && git checkout -B drift-repro a19759ac83745bde6f2e985e5f8cde0b4da96611 && git merge --no-ff 263f6c891eac65f49418adc1f13aa7004bf6b9be

node scripts/docs-audit/affected-docs.mjs --json a19759ac83745bde6f2e985e5f8cde0b4da96611

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a19759ac83745bde6f2e985e5f8cde0b4da96611 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…wing

The refusal of a translation bundle whose two keys fold to one tag refuses
an input that parsed before, so the changeset now carries the amended
declaration lines (Clause-② no (narrowing), Metadata-protocol narrowed),
a BREAKING banner with its FROM -> TO migration, and the ADR-0087
disposition for the new D3 entry.

Claude-Session: https://claude.ai/code/session_01ADHkmRmpbFs6uQ8AuadsTr
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot changed the title feat(spec): a locale tag written in another case is corrected to its BCP-47 case feat(spec)!: a locale tag written in another case is corrected to its BCP-47 case Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Head-sha: 3b3284c9b72e83e349eb79d56856fa7295899234 · read 2026-11-02T09:58Z

维护者速读

  • 改了什么:区域标签大小写写得不标准时,解析统一改成 BCP-47 写法(zh-cn → zh-CN),只改大小写;库里已按 zh-cn 存下的翻译行,读出时也归位。同一个翻译包里同时写了 zh-cn 和 zh-CN 会被拒,报错点名两个键。
  • 为什么改:zh-cn 保存时接口回 200,但 zh-CN 会话永远看不到这些翻译(i18n: a translation saved with locale 'zh-cn' answers 200 but is never merged for a zh-CN session — #25822 left case unjudged on a runtime case-fold that does not happen #28993)。
  • 风险与回滚:撞键拒收是一处收窄(BREAKING),仓内命中 0 处。若你选「合并」,一笔提交改成按标准写法的键优先合并,变回非破坏性。保存入口仍原样存 zh-cn,读出已归位;入口的修补另起 server 卡。回滚即撤销本 PR。
  • 先看:packages/spec/src/system/translation.zod.ts(canonicalLocaleCase 与两个翻译包 schema),conversions/registry.ts 的 translation-locale-tag-canonicalized。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/system/translation.test.ts。
  • 需要你做的:在决策卡上选「拒收并点名两个键」或「自动合并」。

席位意见:可落地,等你选 + 契约复审。

席位核对 — ACCEPT · domain:spec#1 · head 3b3284c9

  1. Shape: draft, base main, Fixes #28993, Clause-②: no (narrowing) / Metadata-protocol: narrowed at line start in the body and the changeset (spec minor, feat(spec)!, BREAKING banner with migration FROM { 'zh-cn': A, 'zh-CN': B } TO one canonical key merged by hand, one ADR-0087 marker registered translation-locale-case-canonicalized). The lines follow claim amendment 6573207423, which records the seat's correction: a kept refusal is narrowed. Within the claimed surface. Not governed.
  2. Ruling held: triage 6570179836 asked for case canonicalized at LocaleSchema, with no third runtime fold. The diff folds case only, via .overwrite(canonicalLocaleCase), so z.input and z.output stay string, Locale stays z.input, and the published JSON Schema doesn't move. It is not Intl.getCanonicalLocales, which the dev measured rewriting iw→he, cmn→zh and the like, and throwing on shapes LOCALE_TAG_SHAPE accepts. Core resolveLocale is untouched.
  3. Bundle keys: a shape-only key twin, superRefine(refuseLocaleKeysFoldingTogether) before overwrite(foldLocaleKeys). The refinement must run on the unfolded record, or z.record would keep the later of two colliding keys silently. The refusal names both keys and the tag. Control: two separate bundles in stack.translations with zh-cn and zh-CN are still accepted and merged as one locale.
  4. Stored rows (option A, seat-ruled at needs_decision): the spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string() #25822 conversion translation-locale-tag-canonicalized now folds case too. Core's readAuthoredTranslationLayer replays it before keying a row. Dev's E2E: a real saveMetaItem, then readAuthoredTranslationLayer, then createMemoryI18n gives layer keys ["zh-CN"], and zh-CN serves the authored string. A bundle rename that would land on a key the row already has is left as stored and reported, never merged. New D3 entry translation-locale-case-canonicalized. The stale "case is not judged" text is fixed in the D3 sibling entry and i18n-standard.mdx.
  5. Readings (dev, on 3b3284c9): spec 790 files / 23382 tests and typecheck at round 1. This round touched the changeset only. 105/105 derived gates exit 0, --ran gives a DERIVED zero. check:adr-0087-registration exit 0. Offline --event exit 0. Dogfood 384 passed + 3 skipped, VERDICT 0. Consumers at round 1: core, metadata-protocol, service-i18n, runtime, rest, downstream-contract, lint, cli and metadata, all green. 4/4 ablations red, then restored. Check-runs on 3b3284c9 at this reading: 59 success, 10 skipped, 0 failed.
  6. Maintainer fork: the refusal (decision card, refuse recommended) or the fallback. The fallback is one format-patch commit on this head, /mnt/project-files/spec-seat1/28993-merge-fallback.patch (7 files, applies clean; adr gate exit 0, non-breaking; lines back to yes (widening) / semantic). Contract review reads this head. If the maintainer picks merge, the fallback head gets a delta re-check.
  7. PR body: the dev patched it via issue_patch with the seat signature as line 1, and Fixes #28993 is on line 4. The bytes read back identical, and the closing gate passes.
  8. Handoff: domain:server sibling card, so that saveMetaItem stores the parsed locale. Filed by the seat after merge.
  9. Landing: the maintainer's answer, plus one CONTRACT_REVIEW_TIER review (a narrowing), handed to triage (line below). Then the seat readies and queues.

Contract-review-handoff: triage — CONTRACT_REVIEW_TIER unavailable (maintainer standing word 2026-10-28T15:04Z: the Fable quota is short, so contract reviews wait for triage) · head 3b3284c

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 3b3284c9b72e83e349eb79d56856fa7295899234 · 2026-11-02T10:05Z

Taken on the lane's Request: contract-review doorbell (#28646, 6574268181). A narrowing on LocaleSchema with a stored-row conversion, at CONTRACT_REVIEW_TIER.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3b3284c9b72e83e349eb79d56856fa7295899234
Local-runs: none
Scope: packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/semantic/18.translation-locale-case-canonicalized.ts, packages/spec/src/migrations/entries/semantic/18.translation-locale-non-tag-refused.ts, packages/spec/src/system/translation.zod.ts + packages/spec/src/system/translation.test.ts, packages/spec/src/conversions/stored.ts, packages/spec/src/conversions/types.ts, packages/spec/src/migrations/chain.ts, packages/spec/src/stack.zod.ts, packages/spec/src/data/seed.zod.ts, packages/spec/src/data/seed-loader.zod.ts, packages/spec/src/system/email-template.zod.ts, packages/spec/src/marketplace/package.zod.ts, packages/spec/src/system/i18n-resolver.ts, packages/spec/dropped-refinements.baseline.json, .changeset/28993-locale-case-canonicalized.md, content/docs/protocol/kernel/i18n-standard.mdx, packages/core/src/fallbacks/memory-i18n.ts, packages/core/src/fallbacks/authored-translation-sync.ts, packages/metadata-protocol/src/protocol.ts, packages/metadata-protocol/src/protocol-stored-item-conversion.ts, packages/metadata-protocol/src/seed-loader.ts, packages/runtime/src/app-plugin.ts, packages/metadata-core/src/artifact-forward-conversion.ts, packages/metadata/src/plugin.ts, packages/services/service-i18n/src/file-i18n-adapter.ts, packages/lint/src/lint-liveness-properties.ts, docs/adr/0087-metadata-protocol-upgrade-contract.md, AGENTS.md

Inputs read: card #28993 body and all six comments (triage 6570179836, claim 6571022743, dev reports 6571338866 / 6573167205 / 6574216803, claim amendment 6573207423); PR #29190 body, file list, both PR comments and the net diff against main; the check-runs on the head (every completed run is success or skipped; none failed, none pending — Check Changeset, PR-body guards, Lint & Repo Gates, Spec property liveness, Governed Surface Queue Guard, Type Check UI, Build & E2E UI, Dogfood Regression Gate, all Test Core and Test UI shards). The cited code was read on origin/main and prr-29190; nothing was built or run.

① Derived judgments

  1. LocaleSchema accept-set unchanged, output changed — LOCALE_TAG_SHAPE is the same regex; .overwrite(canonicalLocaleCase) keeps z.input = z.output = string, Locale stays z.input, and the published JSON Schema does not move (check:generated green inside Lint & Repo Gates). Semantic, not narrowed. Right.
  2. Every LocaleSchema reader folds — on main the readers are TranslationItemSchema.locale (translation.zod.ts:2616), TranslationConfigSchema defaultLocale / supportedLocales / fallbackLocale (:2539–2543), SeedSchema.locale (seed.zod.ts:245), SeedLoaderConfigSchema.locale (seed-loader.zod.ts:285) and, on main, the two bundle record keys. The PR's list matches that census exactly. Right.
  3. The bundle records are the one narrowing — z.record(LocaleTagKeySchema, …).superRefine(refuseLocaleKeysFoldingTogether).overwrite(foldLocaleKeys): a key twin without the fold is necessary, since a folding key schema would let z.record keep the later of two colliding keys and drop the other with no issue (Zod 4 writes the key schema's output as the key). { 'zh-cn': …, 'zh-CN': … } parsed on main and is refused now, so the accept-set shrinks; everything else that parsed still parses. The declaration Clause-②: no (narrowing) / Metadata-protocol: narrowed follows AGENTS.md checklist 3 (narrowed over semantic). Right.
  4. Dropped-refinements ledger +6 — the superRefine has no JSON form, so it lands at the roots of system/TranslationBundle and system/PlatformTranslationBundle and at manifest.translations.element in the four api/*InstalledPackage* responses that embed a bundle. The six entries in the diff are exactly those sites. Right. Consequence worth stating: a JSON-Schema consumer (Studio form validation, a third-party validator) cannot see the refusal; only a Zod parse can. That is the ledger's standing policy, not this PR's defect.
  5. No new public surface — canonicalLocaleCase, LocaleTagKeySchema, refuseLocaleKeysFoldingTogether, foldLocaleKeys are module-private; the docblock says why an exported fold would invite a second fold in a consumer. Right.
  6. Conversion translation-locale-tag-canonicalized widened, not replaced — a retired D2 entry (toMajor: 18, retiredFromLoadPath: true, retiredAfter: '18.1.0') now also rewrites case, with the LocaleSchema parse as its oracle. Stored rows replay it through applyConversionsToStoredItem (full chain, retired included, stored.ts), which core's readAuthoredTranslationLayer calls before keying a row by data.locale (authored-translation-sync.ts:172–189) and the protocol's convertStoredItem calls on reads (protocol-stored-item-conversion.ts:87). Fixture: 2 items + 4 bundle keys + defaultLocale + 2 supportedLocales + 1 seed scope = 10 notices, as declared. Right for stored rows; see ④-2 for the built-artifact window.
  7. New D3 semantic entry translation-locale-case-canonicalized with conversionIds: ['translation-locale-tag-canonicalized'] — the chain's semanticTodoAbsent (chain.ts:166) suppresses the TODO whenever that conversion applied, which is the intended coupling. This is the first time two D3 entries name one conversion (18.translation-locale-non-tag-refused already does); no registry test forbids it and the generated registry.ts is gitignored and rebuilt (AGENTS.md §663), so nothing to commit. Right.
  8. Core resolveLocale untouched — per triage 6570179836. Note the premise as stated on the card is half right: resolveLocale on main already has a case-insensitive rung (memory-i18n.ts:53–56); the defect is that mergedLocale reads one exact key (:116–120), so an authored layer keyed zh-cn is never merged under zh-CN. The PR fixes the key, which is the correct seam. Right.
  9. EmailTemplateDefinitionSchema.locale and PackageLocaleSchema left alone — neither imports LocaleSchema; the email ladder already matches exact / case-insensitive / base subtag (email-template.zod.ts:174–185), so folding i18n.fallbackLocale cannot break it. Right.
  10. Seed scope stays matched — datasetAllowsLocale compares through normalizeLocaleTag (trim + lower, seed-loader.ts:335–351), so a folded Seed.locale against a raw i18n.defaultLocale from resolveSeedLocale (app-plugin.ts:2272–2275) still matches. Right.

② Semver level

.changeset/28993-locale-case-canonicalized.md: '@objectstack/spec': minor, title feat(spec)!:, Clause-②: no (narrowing), Metadata-protocol: narrowed, a BREAKING banner with Before / Now / Fix (FROM { 'zh-cn': A, 'zh-CN': B } TO one canonical key, merged by hand), and exactly one marker adr-0087: registered translation-locale-case-canonicalized, an id new in this diff. Against AGENTS.md checklist 3: several kinds ⇒ narrowed over semantic; (narrowing) is BREAKING; a kind takes at least minor on the package the diff moves; a breaking changeset carries its migration and one ADR-0087 disposition. All four hold; Check Changeset and Lint & Repo Gates (which carries check:adr-0087-registration) are green on the head. Level and lines are right for the tree as built. On the merge fork the lines go back to yes (widening) / semantic with no banner and no marker, also right; the two forks cannot share one changeset, so whichever the maintainer picks, the changeset on the landing head must be re-read once.

One wording nit, not a gate: the changeset says the D3 entry "carries both halves to os migrate meta"; a within-major semantic change is only printed by --from 17, as every 18.x entry is. Standing property of the chain, not this PR's.

③ Boundary flags

  1. The decision card cannot be found. Claim amendment 6573207423, the round-2 dev report and the lane's seat record all say the refuse-vs-merge question "is asked on the seat's decision card". A search of every open needs-user-decision card (12 at review time), of issues created today after the claim amendment, and of issues naming 28993, 29190, locale, case or zh-CN finds no such card. Either it was never filed or it lives outside the repo. Escalated: the maintainer's word must be recorded on a findable card (a needs-user-decision issue, or i18n: a translation saved with locale 'zh-cn' answers 200 but is never merged for a zh-CN session — #25822 left case unjudged on a runtime case-fold that does not happen #28993 itself) before the refusal half lands; a landing gate that points at nothing is not a gate.
  2. Round-1 open question (door stores raw body) — answered by the seat as option A. Measured true in the tree: saveMetaItem parses with schema.safeParse and stores the request body plus named grafts, none of which touches locale. The read seams (readAuthoredTranslationLayer, convertStoredItem) replay the conversion, so the card's acceptance holds end to end. Consequence: the stored.ts module doc's invariant — "the write path keeps new rows canonical and makes this pass a strictly shrinking concern" — is false for this shape until the domain:server slice lands. The dev's handoff says that card is "filed by the seat after merge". Escalated as should-fix: file and link the sibling card before landing, so the invariant debt is tracked from the day the conversion starts carrying new rows (see ④-2).
  3. Round-2 open question (refuse vs merge) — open. Judged in ④-1 and in the closing note.
  4. Deviations declared: content/docs/protocol/kernel/i18n-standard.mdx outside the claimed surface — a one-sentence fix of a rule this diff makes false; acceptable. The end-to-end proof is a deleted scratch test; the permanent pin is the stored-row seam test in translation.test.ts, which is the spec-side half, and the door test belongs to the server slice. Ledger +6 is gate-printed. PR-body sizing of the fallback patch (+75/−72 vs +80/−84) is cosmetic.
  5. Out-of-scope findings confirmed: packages/lint/src/lint-liveness-properties.ts comments name z.record(LocaleSchema, …) — the walker reads keys by shape, not by schema identity, so no behaviour moves; the comment is now inexact and can be fixed on the merge-fallback or a docs rider. EmailTemplateDefinitionSchema.locale does not use LocaleSchema (verified). Not raised by the dev and not this card's: FileI18nAdapter.getTranslations (service-i18n) keys exactly with no case rung at all, so a user whose locale column reads zh-cn gets nothing from the service-i18n provider on main today and after this PR alike; worth a card of its own since the user column accepts any case.

④ Design judgment

1. packages/spec/src/system/translation.zod.ts — judged against triage 6570179836 (canonicalize at LocaleSchema; corrected, not refused; no third runtime fold) and its first consumers (TranslationItemSchema, both bundle records, TranslationConfigSchema, SeedSchema, defineStack's parse).

  • Fold vs BCP-47 (RFC 5646 §2.1.1). Language lower; a 2-character subtag upper (region); a 4-character subtag title (script); everything else lower; every subtag after a singleton lower. Checked by hand on the shape LOCALE_TAG_SHAPE admits: zh-hant-tw → zh-Hant-TW, EN-us → en-US, sr-LATN-rs → sr-Latn-RS, zh-yue-hk → zh-yue-HK (3-letter extlang stays lower), es-419 and de-1996 unchanged (a digit has no case; a 4-character variant must start with a digit, so title-casing it is a no-op), en-US-u-CA-Gregory → en-US-u-ca-gregory, en-x-US → en-x-us, en-GB-oed → en-GB-oed. That is the RFC's recommended convention exactly, and the regex restricts letters to ASCII so toLowerCase has no locale pitfalls. Not using Intl.getCanonicalLocales is right: it replaces legacy subtags (iw → he) and throws on shapes the regex admits, both pinned by tests. Holds.
  • Where the fold lands. Every LocaleSchema reader (①-2). defineStack in strict mode returns the parsed stack (stack.zod.ts:6205–6322, ObjectStackDefinitionSchema.safeParse then data), and stack.translations is z.array(TranslationBundleSchema) (:732), so a file-authored zh-cn key reaches AppPlugin.loadTranslations as zh-CN. The artifact door parses too (packages/metadata/src/plugin.ts:1376). The save door stores the authored body (③-2), the resolver is untouched by ruling and already case-folds the request, the email ladder is its own and case-insensitive. The fold is applied at every door that parses; the one door that stores without honouring the parse is named and handed off. Holds, with ③-2.
  • The collision refusal. Located on the unfolded record (superRefine before overwrite), issue custom at the second key as written, message names both keys and the tag and prescribes the remedy ("Merge the two by hand and keep one key, zh-CN"). Two bundles in stack.translations with zh-cn and zh-CN are not a collision — loadTranslations deep-merges per key — and the CONTROL pins that. Holds. One asymmetry, should-fix at most: the stored-row conversion merges a renamed key onto an existing one when the two values are identical (renameKey) and TODOs only when they differ, while the parse refuses the pair even when both values are identical. The docblock claims "the policy the stored-row conversion already follows"; it is the stricter cousin. Either refuse only when the values differ, or say so in the message. Cosmetic for authors (the remedy is a one-line merge), so should-fix.
  • Fork consistency. The refusal is named in six places that must move together on the merge fork: the refinement and its two .superRefine calls, the key-twin docblock's purpose sentence, the conversion docblock's "TranslationBundleSchema refuses the same pair", both D3 entries' replacement / acceptanceCriteria, i18n-standard.mdx, the changeset and the ledger. The dev's fallback patch claims 7 files; the count of places above is consistent with it. Not re-judged here; the fallback head gets its own delta read.
  • Try to break it. zh-cn beside ZH-CN beside zh-CN: two issues, each naming the first key — correct. A bundle whose key fails the regex and also collides: the regex issue is raised by the record, the refinement adds one more — noisy, harmless. A record with no keys: foldLocaleKeys returns the input itself — correct, and the identity short-circuit when every key is canonical preserves reference equality for callers that compare. Nothing found that breaks.

2. packages/spec/src/conversions/registry.ts — judged against ADR-0087 D2 (lossless, declared, tested, windowed), the stored-conversion rules (stored.ts, AGENTS.md PD 12) and its first consumers (applyConversionsToStoredItem, AppPlugin.forwardConverted, the artifact window in metadata-core).

  • Lossless and declared. A case variant has exactly one tag it can have meant; the rewrite takes the LocaleSchema parse as oracle so the two never disagree; _ + case in one value is one notice, one rewrite. Fixture before/after and expectedNotices: 10 agree with the code path by hand count. Idempotent: canonical(raw) === raw short-circuits. Holds.
  • Legacy group names. On main a letters-only bundle key was never judged. Now it is rewritten when it is a tag in another case (IT → it). The comment's reason in the PR body ("no group name of 8 letters or fewer is in mixed case") is not the load-bearing fact; the load-bearing fact is that a letters-only string is tag-shaped only at 2–3 letters, and no TranslationDataSchema / PlatformTranslationDataSchema group is that short (objects, apps, pages, flows, hooks, books, messages, settings, globalActions, …). Conclusion holds; the in-code comment ("Letters only and not a tag: a legacy item's group name") states it correctly.
  • Collision policy. renameKey returns null when the target key holds different strings → TODO, left as stored, named path and remedy. Matches the D3 entry's acceptance text. Holds.
  • The window — should-fix. The case half is folded into an entry stamped retiredAfter: '18.1.0'. AppPlugin.loadTranslations takes its bundles from the forward-converted copy, not from a parse (app-plugin.ts:2470–2485, convertedCollection / forwardConverted(ctx).definition.translations), and the artifact window replays a retired entry only when floor is below the runtime label, or floor is at or below the entry's retiredAfter, or floor is M.0.0 (artifact-forward-conversion.ts:38–40). So an artifact built by the last release with a zh-cn key, run on main while main still carries that release's label, is neither folded by the conversion (18.1.0 is below its floor) nor parsed on the i18n path — its zh-cn key loads unfolded and the card's defect recurs for that artifact until the next release label moves. This is exactly the between-releases scenario the per-entry retiredAfter was amended for (Forward conversion never opens on unreleased main: spec still labelled 17.4.0 while main refuses 17.5.0 retirements, so artifacts built by the published 17.4.0 CLI are refused #20390 ruling A), and the honest retiredAfter for the case half is the last release whose parse kept case as written, not 18.1.0. Remedy, one of: give the case half its own retired conversion entry with its own retiredAfter (the D3 entry already has the matching id), or move the shared entry's retiredAfter forward (replaying the _/padding half on 18.2-built artifacts is idempotent and harmless), or have AppPlugin key bundles off the parsed definition. Narrow (dev-only, between releases, pre-built artifacts), not a regression against main where nothing folds at all — should-fix, not must-fix.
  • The stored.ts invariant — should-fix (③-2). With the door storing raw, this pass is not "strictly shrinking" for case until the server slice lands; the kit ages out one major after retiring (ruling 6434038284), so there is a protocol major of slack, but the card must exist to be scheduled.

3. packages/spec/src/migrations/entries/semantic/18.translation-locale-case-canonicalized.ts — judged against ADR-0087 D3 (one entry per semantic change, authored for its major; conversionIds names the mechanical repair) and its consumers (chain.ts and os migrate meta). surface lists the five readers (①-2); replacement is "nothing to rewrite" plus the one hand-merge for a colliding pair; reason records the measured premise failure and why the fold is case-only; acceptanceCriteria are checkable against the tests in this diff (fold table, every-reader, stored-row seam, refusal, conversion TODO). conversionIds coupling is correct for semanticTodoAbsent. The entry's text names the refusal, so it is fork-dependent (④-1). Holds.

4. packages/spec/src/migrations/entries/semantic/18.translation-locale-non-tag-refused.ts — one sentence of replacement moves from "Case is not judged: zh-cn is accepted, because the resolvers fold case" to "Case is not refused: the parse corrects it", cross-referencing the new entry. The reason paragraph still says "The resolvers fold case and nothing else, so those strings never rendered" — as history of why _ failed it is still true (core's resolver does fold case), so no stale claim is left. Holds.

Premature part, and whether it must wait. The case fold (LocaleSchema, the five readers), the stored-row conversion and the new D3 entry implement triage's ruling and are landable on either fork. The collision refusal — refuseLocaleKeysFoldingTogether, its two .superRefine calls, the six ledger rows, the BREAKING banner, the no (narrowing) / narrowed lines, and the sentences naming the refusal in the two D3 entries, the conversion docblock and i18n-standard.mdx — is the part the open decision governs. It must wait for the maintainer's word on a findable card (③-1). It does not have to block the fix: the merge fallback is non-breaking, matches triage's "corrected, not refused" literally, and the dev reports it green; if the maintainer does not answer in time, the fold can land behind the fallback and the refusal can return as its own narrowing PR. Should the maintainer choose refuse, the tree as built is the right shape for it.

Implemented-by: claude/project-thread-ih2cyh
Reviewed-by: session_01AavokzJ5DndAwitDXvKy4U

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract-review-claim: session_01ADHkmPi25XXKx92Xmg4viS · head 3b3284c · 2026-11-02T10:44Z

Triage ruling 6575141496 returned contract review to the lane, so the spec seat reviews this head itself at CONTRACT_REVIEW_TIER. The earlier handoff line in the seat record is withdrawn for this head.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3b3284c9b72e83e349eb79d56856fa7295899234
Local-runs: none
Scope: packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/semantic/18.translation-locale-case-canonicalized.ts, packages/spec/src/migrations/entries/semantic/18.translation-locale-non-tag-refused.ts, packages/spec/src/system/translation.zod.ts, packages/spec/src/system/translation.test.ts, packages/spec/dropped-refinements.baseline.json + packages/core/src/fallbacks/authored-translation-sync.ts, packages/core/src/fallbacks/memory-i18n.ts, packages/services/service-i18n/src/file-i18n-adapter.ts, packages/spec/src/conversions/stored.ts, packages/spec/src/conversions/apply.ts, packages/spec/src/conversions/walk.ts, packages/spec/src/conversions/types.ts, packages/spec/src/conversions/retired-after.census.test.ts, packages/metadata-core/src/artifact-forward-conversion.ts, packages/runtime/src/app-plugin.ts, packages/runtime/src/domains/i18n.ts, packages/metadata-protocol/src/protocol.ts, packages/spec/src/stack.zod.ts, packages/spec/src/data/seed.zod.ts, packages/spec/src/data/seed-loader.zod.ts, packages/services/service-messaging/src/template-renderer.ts, packages/services/service-messaging/src/email-channel.ts

Judged on the head's tree, the card's body and every comment on #28993, and the check-runs on the head (every non-skipped run success, the three Check Changeset and two PR-body guards runs after the body edit included). The seat's own ACCEPT record was not read. The changeset and the mdx are prose faces: read for ② and ③, not reviewed in ④.

① Derived judgments

  1. LocaleSchema output moves, input and published shape do not — RIGHT. .overwrite(canonicalLocaleCase) sits after the regex, so a non-tag is still refused with LOCALE_TAG_REFUSAL, a tag-shaped value is returned in RFC 5646 §2.1.1 case, z.input stays string, Locale stays z.input, and .overwrite has no JSON Schema form (the ledger's measured.zod is 4.4.3; the generated artifacts are claimed unchanged and nothing in the diff touches them). Readers that now see the canonical spelling, each confirmed in the live tree: a translation item's locale (translation.zod.ts), TranslationConfigSchema.defaultLocale / fallbackLocale / supportedLocales, SeedSchema.locale[] (seed.zod.ts), SeedLoaderConfigSchema.locale (seed-loader.zod.ts), and both bundle records' keys. EmailTemplateDefinitionSchema.locale (email-template.zod.ts) and PackageLocaleSchema (marketplace/package.zod.ts) do not import LocaleSchema and are untouched, as the PR says.
  2. Accept-set: nothing new is accepted; one input is newly refused — RIGHT, and correctly declared. A TranslationBundle or PlatformTranslationBundle holding two keys that fold to one tag parsed on main (Zod 4's record keeps the later key); on this head refuseLocaleKeysFoldingTogether adds one custom issue at the second key naming both keys and the tag. The key schema is a shape-only twin (LocaleTagKeySchema, same regex, message and description), so the published shape of a bundle key does not move and the record cannot silently collapse two keys before the refinement sees them. The refinement runs before foldLocaleKeys, so it judges the keys as written.
  3. Where the refusal is met — RIGHT as built, read in the cited code: defineTranslationBundle (parses), defineStack / StackSchema (translations: z.array(TranslationBundleSchema), stack.zod.ts L732) and every door that parses a stack (os validate, os build, a source-stack boot). The translation save door (saveMetaItem) parses TranslationItemSchema, not a bundle, so a stored row never meets it. The four api/*InstalledPackage* response schemas embed the bundle at manifest.translations.element and now carry the refinement (the six ledger sites); the tree validates no response against them (only packages/runtime/src/route-ledger.ts names them), so the consequence there is for a client-side validator, not the server.
  4. Bundle keys fold in authored order, values untouched, identity when already canonical — RIGHT. foldLocaleKeys is .overwrite on the record output, so the parse output's keys are canonical for defineTranslationBundle and for the stack's translations; AppPlugin.loadTranslations reads the defined stack's translations through forwardConverted and pushes each bundle's keys to loadTranslations(locale, data) (memory-i18n and the file adapter both deep-merge per locale), so a zh-cn bundle authored through defineStack now lands on zh-CN.
  5. The stored-row conversion translation-locale-tag-canonicalized widens to case — RIGHT for stored rows, which is the card's scenario. applyConversionsToStoredItem (stored.ts) wraps a translation row in translations via STORED_ONLY_COLLECTIONS and replays with includeRetired: true; readAuthoredTranslationLayer (authored-translation-sync.ts) replays it over each row BEFORE keying by data.locale, so a row stored as zh-cn is keyed zh-CN and mergedLocale('zh-CN') in memory-i18n deep-merges it over the stock bundle; the file adapter's getTranslations is exact-match only, so the fold is what makes the row reach a zh-CN session there too. The conversion stays retiredFromLoadPath: true, so normalizeStackInput does not fold a live author; the parse does — one fold, not two, consistent with triage 6570179836 (no third runtime case-fold; resolveLocale is untouched).
  6. The bundle-key "letters only" skip is narrowed to non-tags — RIGHT. Every translation group key (objects, apps, messages, globalActions, settings, …) is four letters or more, and LOCALE_TAG_SHAPE admits a letters-only key only at 2–3 letters, so the only letters-only key the conversion now rewrites is a 2–3 letter tag in another case (IT → it); a legacy item's group keys are still never misread. The fixture's 10 notices (two items, en, ja-JP, ko-KR, it, defaultLocale, two supportedLocales, one seed scope) match the walk.
  7. Public surface — RIGHT: no new export. canonicalLocaleCase, LocaleTagKeySchema, refuseLocaleKeysFoldingTogether and foldLocaleKeys are module-private; the D3 entry adds a registry row, not an API.
  8. Dropped-refinements ledger +6 (983 → 989) — RIGHT: the root site of system/TranslationBundle and system/PlatformTranslationBundle, plus manifest.translations.element in the four embeddings; the entries are the gate's own print and Lint & Repo Gates is green.

② Semver level

.changeset/28993-locale-case-canonicalized.md: '@objectstack/spec': minor, title feat(spec)!:, Clause-②: no (narrowing), Metadata-protocol: narrowed, a BREAKING banner with its FROM → TO migration (a bundle holding both spellings → one canonical key, merged by hand) and exactly one ADR-0087 marker, registered translation-locale-case-canonicalized. The PR body carries the same two lines and the same ! title.

Judgment: CORRECT. The diff refuses a value that parsed before (the colliding pair) and changes what a parse returns (the case fold): under the checklist's precedence narrowed wins over semantic, a (narrowing) arm is BREAKING, and a protocol kind on a package the diff moves takes at least minor. The lines match claim amendment 6573207423, which corrected the seat's original yes (widening) / semantic once the collision refusal existed. Check Changeset and PR-body guards are success on the head after the body edit; the dev's offline check-changeset-no-major and check-adr-0087-registration readings agree and are not re-run here. Not skip-changeset, not major: right.

③ Boundary flags

  • Save door stores the authored body (dev report 6571338866, carried in every report; handoff_slices). Verified in protocol.ts (the spec-conformance block: every type but view keeps request.item verbatim plus graftFoldedFormSections, graftNormalizedOperators, withParsedStoredActions, withDeclaredPageTypeDefault; none touches locale). Answered for the card: the read side carries the fold (①5) and a zh-cn row reaches zh-CN sessions from the next sync. Escalated, not closed: stored.ts's stated invariant ("the write path keeps new rows canonical and makes this pass a strictly shrinking concern") is open for this shape until the domain:server sibling card grafts parsed.data.locale onto the stored translation body. Not blocking this PR; the seat files the card.
  • open_questions[0] (round 2): refuse as built, or merge with the canonical key winning — the maintainer's decision; not ruled here. Consequences of the refusal as built on this head, read in the tree:
    1. An author whose ONE bundle holds zh-cn beside zh-CN is refused at defineTranslationBundle, defineStack, os validate and os build, with one custom issue at the second key naming both keys and zh-CN; the fix is a hand merge.
    2. The policy is per record, not per stack: two bundles in stack.translations, one keyed zh-cn and one zh-CN, pass (the CONTROL test pins it) and loadTranslations deep-merges them as one locale with the later bundle winning each leaf — so the same two sets of strings are refused in one shape and merged in another. The merge fork would make the two shapes agree; the refusal leaves them apart. Named for the decision, not judged.
    3. A pre-built artifact holding the pair is never re-parsed at boot (AppPlugin.loadTranslations reads the converted copy); the conversion leaves the pair as stored with a TODO when the strings differ (renameKey returns null) and drops the redundant twin when they are identical, so such an artifact keeps serving zh-cn as a separate locale, visible only through os migrate meta --stored / the TODO list.
    4. The four api/*InstalledPackage* response schemas now carry the refinement (six ledger sites); the server validates no response against them, so a hosted package whose manifest holds the pair is served, and only a client that validates with the published schema would refuse it.
    5. The (narrowing) arm put the dogfood battery on the head; Dogfood Regression Gate is success.
  • content/docs/protocol/kernel/i18n-standard.mdx outside the claim's surface (dev deviation). A bounded one-sentence fix of text the diff makes false; prose face, declared in the body. Accepted.
  • End-to-end proof is a one-off scratch test, not a permanent test (dev deviation). The permanent spec-side pin is the stored-row seam test (applyConversionsToStoredItem('translation', …)); a permanent door test belongs to the server slice. Accepted.
  • Ledger +6 (dev deviation). Right (①8).
  • PR body sizes the merge swap at +75 / −72; the regenerated patch is +80 / −84 (dev deviation, round 2). Body prose only; corrected in the report. No action.
  • Title gained !; body opens with the signature line so Fixes #28993 is line 4 (dev deviations). Match the changeset; PR-body guards success. No action.
  • out_of_scope_findings: packages/lint/src/lint-liveness-properties.ts comments name z.record(LocaleSchema, …). The walker reads locale keys off the parsed stack and judges the data under each; the comment's point holds. No action.
  • out_of_scope_findings: EmailTemplateDefinitionSchema.locale does not use LocaleSchema, so spec: the email template locale docs say the resolver folds no language subtag and retries only en-US, which #28880 makes wrong #28896 / docs(spec): the email template locale docs describe the ladder #28880 landed #29014 are untouched. True of the schema; incomplete as a consequence — see ④ should-fix 2: the exact matchers that compare a folded i18n locale with a raw template tag are affected, and the entry should say so.
  • Claim constraints: memory-i18n.ts#resolveLocale untouched (held); no file under packages/metadata-protocol touched (held); the generated registry.ts not hand-edited (held, the D3 entry is a new file under entries/semantic/).

④ Design judgment

packages/spec/src/system/translation.zod.ts — judged against the card's scenario (a zh-cn save never merged for a zh-CN session), triage 6570179836 (fold at LocaleSchema, corrected not refused, no third runtime fold) and its first consumers (defineTranslation, defineTranslationBundle, StackSchema.translations, TranslationConfigSchema, the two seed schemas). HOLDS.

  • canonicalLocaleCase is RFC 5646 §2.1.1: index 0 lower; a 1-char subtag is a singleton and everything after it is lower; 2-char upper; 4-char title; else lower. Tried to break it: es-419 and de-1996 (digits unmoved by toUpperCase / charAt(0).toUpperCase()), zh-Hans-Hans, sgn-BE-FR, en-GB-oed, en-US-u-ca-gregory, en-x-us, cmn-CN, iw — every one lands where the RFC's convention puts it, none is rewritten to another locale, none throws. Case only, not Intl.getCanonicalLocales, is the right call for the reasons the docblock measures (legacy-subtag replacement, ICU-dependent output, throws on shapes the regex admits).
  • Fold after regex: a non-tag still fails with the prescription; a tag is folded; the parse output is the one spelling. z.input unchanged, published JSON Schema unchanged.
  • The bundle records: judging the keys together, before the fold, through a shape-only key twin is the only arrangement under Zod 4's record that neither drops a key silently nor moves the published key shape; the refinement's issue path (the second key) and message (both keys and the tag) give an author the fix. foldLocaleKeys returns the input itself when nothing moves, so a canonical bundle is identity through the parse.
  • No must-fix. One should-fix, filed under the D3 entry below (the fold's reach stops at a boundary two exact matchers cross).

packages/spec/src/conversions/registry.ts (translation-locale-tag-canonicalized) — judged against ADR-0087 D2 (stored.ts, apply.ts, types.ts), the stored-row seam (readAuthoredTranslationLayer) and the artifact-ingestion door (artifact-forward-conversion.ts). HOLDS for stored rows; one should-fix on the artifact window.

  • asTag is one LocaleSchema.safeParse and returns parsed.data, so the conversion folds exactly as the parse does and cannot drift from it; canonical tries the raw value, then the trimmed _-replaced value; value returns the raw string when the canonical equals it, so the pass is idempotent and a canonical tag never emits a notice. The bundle branch skips a canonical key, skips a letters-only non-tag (a legacy group key), reports any other non-tag, and renames through renameKey, which merges only an identical twin and otherwise leaves both keys with a TODO — the same no-winner policy the parse now enforces. The fixture's before/after and 10 notices follow from the walk.
  • Stored rows: applyConversionsToStoredItem replays retired entries, readAuthoredTranslationLayer replays before keying, memory-i18n merges authored over static per exact locale — the card's defect is closed one layer below HTTP, and the GET /i18n/translations/zh-CN route (domains/i18n.ts) asks the service for the exact locale first.
  • Should-fix 1 — the case half inherits spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string() #25822's retiredAfter: '18.1.0', which leaves a pre-built 18.2.0 artifact outside the door's window on an unreleased runtime. applyArtifactForwardConversions opens a retired entry when the floor is below the runtime label, OR floor ≤ retiredAfter, OR floor = M.0.0. An artifact compiled by the released 18.2.0 spec with a zh-cn bundle key or item locale declares floor 18.2.0; on a runtime built from main while the label is still 18.2.0 none of the three arms opens, and AppPlugin.loadTranslations reads the converted copy without a re-parse, so that artifact keeps serving zh-cn as its own locale — the card's defect, for that artifact, until the label moves or it is rebuilt. Stored rows are unaffected (the stored pass ignores the window) and a released 18.3.0 runtime opens the first arm, so this is a pre-release-only gap; but it is exactly the gap retiredAfter exists to close, and the entry's stamp cannot be moved to 18.2.0 because the census pins it to the tarball that first carried the retirement. The ADR-0087 shape for a rewrite whose authoring surface accepted the old spelling through 18.2.0 is a D2 entry of its own for the case half, stamped retiredAfter: '18.2.0' (the D3 entry already calls itself "the case half of the family"); failing that, the conversion docblock and the D3 acceptance sentence ("A built artifact inside the conversion window is folded the same way") should say the window for the case half is 18.1.0's, not 18.2.0's.

packages/spec/src/migrations/entries/semantic/18.translation-locale-case-canonicalized.ts — judged against ADR-0087 D3 (SemanticMigration: id, surface, replacement, reason, acceptanceCriteria, conversionIds) and its first consumer (os migrate meta / the upgrade guide through the generated registry). HOLDS in shape; conversionIds names the existing D2 entry; replacement, reason and acceptance pin the parse fold, the stored-row read-back, the pair refusal and the case-only rule.

  • Should-fix 2 — the entry (and the PR's "EmailTemplate … unaffected" reading) stops at the schema boundary, while two exact matchers in the tree cross it. The fold reaches i18n.defaultLocale / fallbackLocale / supportedLocales; it does not reach EmailTemplateDefinitionSchema.locale, sys_notification_template.locale or sys_user.locale, by design. But warnEmailTemplateLocaleFloor (stack.zod.ts) builds supportedSet from the folded supportedLocales and compares emailTemplates[].locale raw, so an author who wrote zh-cn on both sides loses that warning silently; and the messaging ladder (localeCandidates(i18n.getDefaultLocale()) in template-renderer.ts, recipientLocale ?? defaultLocale in email-channel.ts) queries sys_notification_template.locale exactly, so a deployment whose i18n.defaultLocale was zh-cn beside rows tagged zh-cn now walks zh-CN → zh → en and misses the exact row it hit before. Neither is the card's scenario, both need a lowercase author on both sides (in-repo count 0 per the dev's measurement), and the claim kept the email-template schema out — so this is a consequence to NAME, not a reason to fail: the D3 entry's replacement / acceptanceCriteria should say that a consumer comparing a declared i18n locale with a row's raw tag must fold or re-tag the rows (and the sibling server card is where a template-row fold would land), so an upgrading author reads it in os migrate meta rather than in a dead-lettered send.

packages/spec/src/migrations/entries/semantic/18.translation-locale-non-tag-refused.ts — one sentence rewritten to point at the new entry; the rest of the entry is history and stays true (_, padding and language names are refused; the resolvers fold case and nothing else is why _ spellings never rendered). HOLDS. No finding.

Floor paths outside hits:

  • packages/spec/src/system/translation.test.ts — pins the fold table, canonical identity, case-only (iw, TL, cmn-cn, zh-hans-hans), every reader, key fold in authored order, the refusal (code, path, both keys in the message) on both bundle schemas, the CONTROL (distinct tags; one tag across two bundles) and the stored-row seam with its idempotence. Adequate for the tree as built; the dev's four ablations are reported, not re-run. The retitled (case is corrected, never refused) row keeps its assertion. No finding.
  • packages/spec/dropped-refinements.baseline.json — the six new sites are the ones the refinement creates; Lint & Repo Gates green. No finding.

No must-fix. Two should-fixes, neither on the card's own path.

Implemented-by: claude/project-thread-ih2cyh
Reviewed-by: session_01ADHkmPi25XXKx92Xmg4viS

VERDICT: PASS

…d after 18.2.0

The case half of `translation-locale-tag-canonicalized` rode that entry's
retiredAfter 18.1.0, so an artifact the released 18.2.0 spec built with a
`zh-cn` key fell outside the ingestion window. The case fold is now the D2
entry `translation-locale-tag-case-folded`, stamped retiredAfter 18.2.0 and
ordered right after the underscore entry (`zh_cn` -> `zh-cn` -> `zh-CN`). The
underscore entry is back to its released code; its docblock points at the new
entry and its fixture keeps to its own spellings. The D3 entry and the
changeset now also name the two exact matchers that compare a folded i18n
locale with a row tagged as written (the email-template floor warning and the
messaging template ladder).

Claude-Session: https://claude.ai/code/session_01ADHkmRmpbFs6uQ8AuadsTr
Co-authored-by: Claude <noreply@anthropic.com>
…tions name

`translation-locale-tag-case-folded` is retired after 18.2.0, the newest
published stable release, so the census pin wants a JOIN row with the
prescriptions an author meets for its surface. A lone tag is corrected, not
refused, so those are the collision two keys of one bundle make once folded:
the bundle refusal at the parse and the conversion's TODO for a stored pair.
Both now name the release (`@objectstack/spec 18`). The pin gains a todo row
kind for a data-at-rest prescription with no parse refusal to read.

Claude-Session: https://claude.ai/code/session_01ADHkmRmpbFs6uQ8AuadsTr
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract-review-claim: session_01ADHkmPi25XXKx92Xmg4viS · head 263f6c8 · 2026-11-02T12:59Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 263f6c8 · 2026-11-02T12:59Z

Taken under (b): the head has waited 90 min since the spec seat's PASS at 3b3284c9 (6575488221), with no lane claim or hold. A delta re-review of 713814b9 + 263f6c89.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 263f6c891eac65f49418adc1f13aa7004bf6b9be
Local-runs: none
Scope: packages/spec/src/conversions/registry.ts, packages/spec/src/migrations/entries/semantic/18.translation-locale-case-canonicalized.ts, packages/spec/src/migrations/entries/semantic/18.translation-locale-non-tag-refused.ts, packages/spec/src/system/translation.zod.ts, packages/spec/src/shared/retired-key-removal-version.pin.test.ts, packages/spec/src/system/translation.test.ts, packages/spec/dropped-refinements.baseline.json + packages/core/src/fallbacks/authored-translation-sync.ts, packages/core/src/fallbacks/memory-i18n.ts, packages/services/service-i18n/src/file-i18n-adapter.ts, packages/spec/src/conversions/stored.ts, packages/spec/src/conversions/apply.ts, packages/spec/src/conversions/walk.ts, packages/spec/src/conversions/types.ts, packages/spec/src/conversions/conversions.test.ts, packages/spec/src/conversions/retired-after.census.test.ts, packages/metadata-core/src/artifact-forward-conversion.ts, packages/runtime/src/app-plugin.ts, packages/runtime/src/domains/i18n.ts, packages/metadata-protocol/src/protocol.ts, packages/spec/src/stack.zod.ts, packages/spec/src/data/seed.zod.ts, packages/spec/src/data/seed-loader.zod.ts, packages/services/service-messaging/src/template-renderer.ts, packages/services/service-messaging/src/email-channel.ts

Delta review. The PASS record on 3b3284c9 (this session, same card) judged the head before this one; this record judges 263f6c89 as a whole, reading the two new commits (713814b9, 263f6c89) against the tree they land in, the card's body and every comment on #28993 (the round-3 report 6577410913 included), the PR body as edited for this head, and the check-runs on 263f6c89: 69 runs, 59 success, 10 skipped, none failed, none pending. The seat's own records were not read. The changeset and the mdx are prose faces: read for ② and ③, not reviewed in ④.

Delta against 3b3284c9: 7 files, +296 / −71. The case fold leaves #25822's conversion and becomes a D2 entry of its own (translation-locale-tag-case-folded, toMajor: 18, retiredFromLoadPath: true, retiredAfter: '18.2.0', order: 76.5); #25822's entry has its code, summary and retiredAfter: '18.1.0' restored, its docblock pointing at the new entry, and one fixture value made canonical (pt-br → pt-BR, 7 notices as before). The D3 entry's conversionIds points at the new entry; its replacement and acceptance name the two exact matchers. The bundle refusal message and the new entry's TODO name @objectstack/spec 18. A JOIN row for the 18.2.0 entry is added to retired-key-removal-version.pin.test.ts, with a new todo row kind. Two tests are added. The changeset, the mdx and the PR body follow the split. No schema shape moves; dropped-refinements.baseline.json is unchanged in the delta.

① Derived judgments

Carried from the record on 3b3284c9, re-read on this head and unchanged: LocaleSchema folds case after the regex with z.input and the published JSON Schema unchanged; every reader named (item locale, both bundle keys, i18n.defaultLocale / fallbackLocale / supportedLocales, seed locale[], seed-loader locale) sees the canonical spelling; EmailTemplateDefinitionSchema.locale and PackageLocaleSchema are untouched; the only newly refused input is a bundle with two keys folding to one tag, met where a bundle is parsed and nowhere server-side on a response; keys fold in authored order through a shape-only key twin; no new public export; ledger +6 unchanged. New or moved on this head:

  1. The stored-row fold is now translation-locale-tag-case-folded, after spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string() #25822's entry — RIGHT. MAJOR_18_CONVERSIONS sorts by ascending order (ties by id; OrderedConversion.order is a number), so 76.5 runs right after 76: an _ or padded spelling is made a tag by spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string() #25822's entry, then folded here (zh_cn → zh-cn → zh-CN; the new seam test pins it). The entry folds only a value LocaleSchema.safeParse accepts and that differs from its parse; a non-tag is returned untouched with no TODO, because the entry before already reported it ('not a locale!!' in the fixture confirms a TODO is not a notice and the stack is unchanged, so the two fixtures stay disjoint under the whole-table runner in conversions.test.ts). renameKey merges an identical twin (one notice) and leaves a differing pair as stored with a TODO at the folded key's path — the same no-winner policy as the parse's refusal.
  2. spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string() #25822's entry restored — RIGHT. isTag / canonical / value and the bundle branch's /^[A-Za-z]+$/ || isTag(key) skip are byte-for-byte the main logic; summary back to the two spellings; retiredAfter: '18.1.0' kept, so the census pin and the tarball it names agree. The fixture's pt-br → pt-BR on both sides is forced by the whole-table fixture run (the new entry would otherwise fold it) and changes no notice count.
  3. retiredAfter: '18.2.0' on the new entry — RIGHT against conversions/types.ts (the newest published stable at the moment the retirement lands, read off the label; the package label is 18.2.0) and against retired-after.census.test.ts (a stamp between the last censused release and a stable label is admitted; the stamp is at the label). Against the artifact door (artifact-forward-conversion.ts): an artifact compiled by 18.2.0 declares floor 18.2.0 ≤ 18.2.0, so the second arm opens and a zh-cn key in a pre-built artifact is folded on a main-built runtime still labelled 18.2.0 — the gap the round-1 should-fix 1 named is closed. An artifact built by the release that carries this PR has folded keys at parse.
  4. The D3 entry re-pointed and widened — RIGHT. conversionIds: ['translation-locale-tag-case-folded'] (the case half), surface unchanged, replacement and acceptance now name warnEmailTemplateLocaleFloor (stack.zod.ts: i18n.supportedLocales vs emailTemplates[].locale) and the messaging ladder (localeCandidates(i18n.getDefaultLocale()) vs sys_notification_template.locale, template-renderer.ts and email-channel.ts) with the prescription "re-tag the row or fold case in the consumer" and the row-side fold assigned to the save-door card — the consequence the round-1 should-fix 2 asked to be named, read correctly against both consumers.
  5. The refusal message and the TODO name the release — RIGHT. The pin test derives the release from retiredAfter (at the newest published stable ⇒ PROTOCOL_MAJOR, 18) and requires a JOIN row for every entry stamped there; the bundle refusal gains "Refused since @objectstack/spec 18", the TODO "since @objectstack/spec 18". A version, not a tracker number; the same wording LOCALE_TAG_REFUSAL already carries. The refusal's code, path and both-keys content are unchanged, and the round-1 test still pins them.
  6. A new todo row kind in the pin test — RIGHT in shape: it runs the named conversion over a stack with reportTodo, requires at least one TODO, and renders each reason as a prescription the release derivation judges — the only channel a data-at-rest prescription has. Test-only; no runtime surface.

② Semver level

Unchanged from 3b3284c9 and still CORRECT: '@objectstack/spec': minor, feat(spec)!:, Clause-②: no (narrowing), Metadata-protocol: narrowed, BREAKING banner with its FROM → TO migration, exactly one ADR-0087 marker (registered translation-locale-case-canonicalized); the PR body carries the same two lines and title. The delta adds a D2 conversion (a stored-row rewrite, no authorable key added or removed) and names two consumers in the changeset body — neither moves the kind or the level: the refusal is still the one narrowed fact, narrowed over semantic, protocol kind on a moved package ⇒ minor, (narrowing) ⇒ BREAKING. Check Changeset and PR-body guards are success on 263f6c89.

③ Boundary flags

  • Round-1 should-fix 1 (artifact window) — taken. Judged in ①3 / ④: closed by the D2 split; no residue.
  • Round-1 should-fix 2 (exact matchers) — taken. Judged in ①4 / ④: named in the D3 entry, the changeset and the PR body; the row-side fold is added to the server hand-off's acceptance. No residue on this PR.
  • Save door stores the authored body (handoff_slices, carried). Unchanged on this head; verified in protocol.ts on round 1. The read side carries the fold through both entries; the stored.ts invariant for this shape stays with the domain:server sibling card, whose acceptance now also carries the template-row fold. Not blocking.
  • open_questions[0]: refuse (as built) or merge with the canonical key winning — the maintainer's; not ruled. Consequences as built, re-read on this head: unchanged from the round-1 record (refusal only where a bundle is parsed; per record, not per stack, so two bundles zh-cn + zh-CN still pass and deep-merge; a pre-built artifact with the pair is served unparsed and now TODO'd by the case entry with the release named; the four api/*InstalledPackage* response schemas carry a refinement no server enforces). New on this head: the merge fork would also remove the pin test's refusal row and keep its todo row, and the dev reports that swap prepared (8 files, +85 / −92) and checked; not judged.
  • Ninth file, retired-key-removal-version.pin.test.ts (dev deviation). Forced by the pin's first guard once an entry is stamped at the newest published stable; inside the claim's packages/spec surface. Accepted.
  • Dogfood battery on 263f6c89 (dev report): one file, test/package-first-authoring.dogfood.test.ts, hit its 10 s beforeAll boot timeout while the 105-gate loop ran on the same cores; alone under the lock it passed twice (5/5). The CI Dogfood Regression Gate is success on this head (its shard matrix skipped, as on 3b3284c9). The check-runs are the gate verdicts here; the local red is read as the dev reads it, a boot timeout under contention, and the dev did not re-run the battery as a whole. Noted, not escalated: nothing in the delta touches a boot path.
  • The PR body's merge-swap size now reads +85 / −92 (round-3 patch). Body prose; matches the report. No action.
  • Carried deviations (mdx outside the claim surface, bounded; E2E a one-off scratch proof with the permanent pin in translation.test.ts; ledger +6; body signature line first with Fixes #28993 on line 4, PR-body guards success). Unchanged; accepted on round 1.
  • out_of_scope_findings (lint liveness comments; EmailTemplateDefinitionSchema.locale not using LocaleSchema) — unchanged; the matchers that read it are now named where an upgrading author reads them.
  • Claim constraints: memory-i18n.ts#resolveLocale untouched (held); nothing under packages/metadata-protocol touched (held); the generated migration registry.ts not hand-edited (held).
  • The report is stamped 2026-11-02T12:41Z; no later comment on the card changes any of the above.

④ Design judgment

packages/spec/src/conversions/registry.ts — the new translationLocaleTagCaseFolded judged against ADR-0087 D2 (types.ts: a retired entry carries retiredAfter, the stored pass replays it, the artifact door reads the stamp), its place in the chain (conversions.test.ts runs the whole table over every fixture and requires disjoint fixtures), the stored-row seam (stored.ts → readAuthoredTranslationLayer → memory-i18n / file adapter) and the artifact door. HOLDS.

  • Tried to break the ordering: a stored ' zh-cn ' is trimmed to zh-cn by 76 (one notice) and folded to zh-CN by 76.5 (one notice); a stored zh_CN is a tag after 76 and canonical, so 76.5 is silent; a stored not a locale!! is TODO'd by 76 and ignored by 76.5, so one TODO, not two; a bundle { 'zh_cn': A, 'zh-CN': B } with different strings: 76 renames zh_cn to zh-cn, a key the bundle does not hold, with one notice; 76.5 then finds zh-cn beside zh-CN with different strings and TODOs it — the pair ends as stored under two keys with one TODO naming the fold, which is the right outcome and the one the parse refuses; with identical strings both entries collapse it in two steps with two notices and no loss. Idempotent on a canonical stack (folded(raw) === raw returns early; the i18n and data branches return the same reference when nothing moves).
  • Fixture disjointness holds in both directions: 76's fixture now carries only values 76.5 leaves alone (pt-BR, zh-CN, en, ja-JP, en-US), and 76.5's fixture carries only values 76 leaves alone (pt-br, ko-kr, IT as letters-only, en-us, zh-hans-cn, zh-cn, and the non-tag it does not judge). IT is folded here because LocaleSchema admits a 2–3 letter tag and every translation group key is 4 letters or more, so a legacy item's group keys are still never touched.
  • spec: a translation item accepts any locale string ("not a locale!!" saves 200, valid) — LocaleSchema is a bare z.string() #25822's entry: code identical to main, docblock accurate (case kept as written there; its fold is the next entry, retired a release later), retiredAfter unmoved so the census stays true.
  • No finding.

packages/spec/src/migrations/entries/semantic/18.translation-locale-case-canonicalized.ts — judged against D3 (SemanticMigration) and its consumers (os migrate meta, the upgrade guide). HOLDS. conversionIds names the entry that does the stored-row work; replacement and acceptance now carry the fold, the two exact matchers with their prescription, the artifact window stated as "built by @objectstack/spec 18.2.0 or earlier" (true of the new stamp), the canonical-tag identity and the pair refusal. Naming two internal functions in author-facing text is unusual for a D3 entry, but it is what makes the sentence verifiable by the author who hits it, and it carries no tracker number. No finding.

packages/spec/src/migrations/entries/semantic/18.translation-locale-non-tag-refused.ts — unchanged in the delta; still true on this head (its "see translation-locale-case-canonicalized" points at the D3 entry, which now points at the new D2). No finding.

packages/spec/src/system/translation.zod.ts — the delta changes one docblock reference and the refusal message's release clause. The fold, the key twin, the refinement's code and path and foldLocaleKeys are as judged on 3b3284c9 and hold. The message now reads as the pin test requires and as LOCALE_TAG_REFUSAL already does. No finding.

packages/spec/src/shared/retired-key-removal-version.pin.test.ts (floor; a gate and the rule it enforces read as one) — the JOIN row's two renderers read the two channels an author meets for this surface: the parse refusal on a colliding bundle and the case entry's TODO on the same pair at rest. The todo renderer is generic (id + stack), throws on an unknown id, and requires at least one TODO so a row cannot pass vacuously; the header names the new kind. The derivation (retiredAfter at the newest published stable ⇒ PROTOCOL_MAJOR) is the file's own and is exercised by the row the first guard demands. No finding.

Floor paths outside hits: translation.test.ts adds the zh_cn → zh-CN chain pin and a direct test of the case entry (stamp 18.2.0, TODO path on a differing pair, one notice on an identical twin, output shape) — adequate for the split. dropped-refinements.baseline.json is unchanged in the delta. No finding.

No must-fix. No should-fix. Both round-1 should-fixes are taken as asked.

Implemented-by: claude/project-thread-ih2cyh
Reviewed-by: session_01ADHkmPi25XXKx92Xmg4viS

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 263f6c891eac65f49418adc1f13aa7004bf6b9be
Local-runs: none
Scope: .changeset/28993-locale-case-canonicalized.md, content/docs/protocol/kernel/i18n-standard.mdx, packages/spec/src/conversions/registry.ts (translationLocaleTagCanonicalized, new translationLocaleTagCaseFolded, MAJOR_18_CONVERSIONS, inApplicationOrder), packages/spec/src/migrations/entries/semantic/18.translation-locale-case-canonicalized.ts, packages/spec/src/shared/retired-key-removal-version.pin.test.ts, packages/spec/src/system/translation.test.ts, packages/spec/src/system/translation.zod.ts + packages/spec/package.json on main (version), packages/spec/dropped-refinements.baseline.json (conflict only)

Inputs read:

  • The spec seat's PASS record 6575488221 at 3b3284c9b72e83e349eb79d56856fa7295899234 and its two should-fixes.
  • This seat's PASS 6574667011 at the same head.
  • The delta git diff 3b3284c9 263f6c89: 7 files, +296/−71, two commits.
  • The head's check-runs, and git merge-tree against today's origin/main.

A delta review. Every judgment of 6575488221 and 6574667011 not restated below stands, and this record adopts them for 263f6c89.

① Derived judgments

  • Should-fix 1 of 6575488221 is answered by a D2 entry of its own, as that record prescribed.
    • translation-locale-tag-canonicalized returns to its shipped reach: the _ and padded spellings, retiredAfter: '18.1.0', which the census pins. Case is kept as written there.
    • The new translation-locale-tag-case-folded (toMajor: 18, retiredFromLoadPath: true, retiredAfter: '18.2.0') carries the case half. 18.2.0 is the version @objectstack/spec carries on main, so a pre-built 18.2.0 artifact is now inside the window on an unreleased runtime. That is exactly the gap the record named.
    • The semantic entry's conversionIds now names the new entry.
  • Application order: right. MAJOR_18_CONVERSIONS sorts by numeric order, with ties broken by id (inApplicationOrder). Fractional orders already have precedent (35.5, twice). At 76.5 the case fold runs after the separator rewrite at 76, so zh_cn → zh-cn → zh-CN. That chain is pinned in translation.test.ts through applyConversionsToStoredItem.
  • The separator entry no longer folds case, and it does not have to. It now treats any LocaleSchema-passing value as already a tag. Because the parse folds case, a lowercase tag passes and is left for the next entry. A letters-only bundle key is skipped there (legacy group names) and judged only by the case entry, where folded(key) must parse as a tag. The fixtures are disjoint between the two entries, and their expected notices (7 + 7) add up to the old combined 10 plus the new seed and supported cases.
  • The collision policy is unchanged and now pinned at rest.
    • A key that folds onto a key holding different strings is left as stored with a TODO.
    • An identical twin is merged (renameKey).
    • TranslationBundleSchema refuses the pair at parse.
    • The pin test's new todo renderer joins the case entry to both channels: the parse refusal and the os migrate meta TODO. The guard for the newest-release row is therefore satisfied for retiredAfter: '18.2.0'.
  • Should-fix 2 of 6575488221 is answered in text. The semantic entry's replacement and acceptanceCriteria, and the changeset, now name both exact matchers that compare a folded i18n locale with a raw row tag: warnEmailTemplateLocaleFloor and the messaging ladder over sys_notification_template.locale. They give the remedy (re-tag the row, or fold in the consumer) and say where the row-side fold lands.
  • Gate verdicts on this head: 59 success, 10 skipped, 0 pending, 0 failures (total_count: 69).
  • But the PR now reads mergeable: false, mergeable_state: dirty. git merge-tree against today's origin/main reports one conflict, in the generated packages/spec/dropped-refinements.baseline.json. fix(spec)!: a dashboard that declares two global filters under one name is refused #29162 (38a278b7, the dashboard duplicate-filter refusal) landed a row in the same file after this head was pushed. Nothing hand-written conflicts.

② Semver level

Unchanged by the delta. The changeset keeps Clause-②: no (narrowing) · Metadata-protocol: narrowed and its <!-- adr-0087: registered translation-locale-case-canonicalized --> marker. A second D2 conversion under the same semantic entry adds no published key: the public conversion list gains an id, which is ADR-0087's own channel. Right.

③ Boundary flags

  • The two should-fixes of 6575488221 are answered (①).
  • The refusal message now says "Refused since @objectstack/spec 18". That is the channel wording the newest-release join reads. Right.
  • The conflict is in a generated baseline, so it is answered by tooling, not by hand: merge origin/main, then regenerate the baseline with the repo's own generator.

④ Design judgment

Splitting the case fold into its own entry is the ADR-0087-true shape. A retired entry's retiredAfter is the release whose tarball first carried the retirement, so it cannot move. A fold that shipped later needs a window of its own. Folding on parse for a live author while the stored and built paths go through a later-retired conversion gives every reader one answer.

Tried to break it:

  • zh_CN (already canonical once separated): made a tag at 76, tag === raw at 76.5, so one notice. Right.
  • A legacy item with group keys: it never takes the 'locale' in entry arm. A key such as globalActions is not tag-shaped, so folded is undefined and the key is skipped. Right.
  • A bundle holding EN beside en with identical strings: merged, with a notice. With different strings: a TODO, and the bundle is left as stored. Right.
  • Idempotence: a canonical tag gives folded(v) === v, so nothing is emitted. Right.
  • Security: nothing here is security-relevant.

Must-fix: none on the reviewed head.

Landing condition (not a design finding): the head is dirty against main in the generated dropped-refinements.baseline.json. Merge origin/main and regenerate the file with its generator, never by hand. The merge-only head is a delta: read the resolution and the gates on the merged tree, and re-read nothing else.

Should-fix: none new.

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-support-ai · session_01WGufzhJG3tKnksHVa1krBz

Superseded by #29833: the same five commits through 263f6c89, resumed on the card thread branch claude/project-thread-bo9b0g (claim 6595878613 on #28993, ruling A 6595806433) and merged with origin/main. Closing this draft so #28993 has one open closing PR (PR-body guards). Contract reviews 6577781599 and 6577787009 stay here as the history of head 263f6c89.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/l tests tooling

Projects

None yet

1 participant