Skip to content

feat(spec)!: split the translation bundle type — settings is a platform group, not a per-app one (#15178) - #19600

Merged
hotlong merged 14 commits into
mainfrom
claude/issue-15178-translation-bundle-split
Sep 23, 2026
Merged

hotlong merged 14 commits into
mainfrom
claude/issue-15178-translation-bundle-split

Conversation

@os-warren

@os-warren os-warren commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #15178

Clause-②: yes

What is ruled, and what landed

Maintainer ruling batch #132 item 2 letter ② (comment 5653315643, 「同意」 2026-09-13), quoted verbatim:

  1. packages/spec TranslationDataSchema becomes two exports (names per the file's convention): the platform bundle schema (eleven groups, settings included) and the per-app bundle schema (settings absent, strict — an authored settings in a per-app bundle is refused with a remedy saying it is platform-only). Every reader that consumes a per-app bundle types against the per-app schema.
  2. The card's original "removal" disposition is struck: settings is a live platform key (pickSettingsEntry, i18n-resolver.ts:2305; console useSettingsLabel).
  3. check:i18n-walk-parity: the settings exemption disappears with the per-app key; LEDGER_CEILING 3 → 2 in the same PR (the ledger is governed — declared in the claim).
  4. Accept-set narrowing on the per-app bundle: Clause-②: no; ADR-0087 semantic entry — a per-app bundle carrying settings was inert, so the conversion drops the group and records a note; no deprecation window (「创业阶段不渐进」), launch-window convention applies.

The card's own closing line (「⛔ Not a queue card: zero measured pull today」) is stale and the ruling overrides it. The removal option is struck; this is the SPLIT.

Which name took which face, and why. TranslationDataSchema keeps its name and becomes the per-app bundle entry (ten groups); the new PlatformTranslationDataSchema / PlatformTranslationBundleSchema (types PlatformTranslationData / PlatformTranslationBundle) carry the eleven-group platform face. The ruling's "names per the file's convention" is satisfied by the file's existing habit — a qualified prefix marks the other face, as ObjectTranslationDataSchema already does — and the direction was chosen on a measurement, not on taste:

  • The card body itself names the narrowing target "the per-app TranslationDataSchema", and the gate's ledger reason says "removal from the per-app schema" about that same export.
  • Every EXISTING per-app author already types against TranslationData: stack.translations, defineTranslationBundle, the three examples, the CLI walker and coverage reader, and the header os i18n extract emits into every scaffolded bundle. Putting the narrowing on a NEW name would have left all of them accepting settings, and re-pointing them would have re-typed the nine platform *.generated.ts files the same emitter writes.
  • Only the genuinely-platform readers had to move, and only one of them authors settings at all.

So "every reader that consumes a per-app bundle types against the per-app schema" holds by construction here, and the movement fell on the platform side.

The ruling's "was inert" is falsified — the record says what was measured instead

Ruling item 4 describes a per-app settings as inert. Measured on origin/main at 1ff3a8f210, it was live:

  • AppPlugin.loadTranslations (packages/runtime/src/app-plugin.ts) hands each stack.translations bundle entry WHOLE to II18nService.loadTranslations.
  • FileI18nAdapter.loadTranslations deep-merges it into the one per-locale tree; getTranslations(locale) serves that tree.
  • Every platform plugin contributes into the SAME tree at kernel:ready — SettingsServicePlugin does exactly this with settingsBuiltinTranslations.
  • pickSettingsEntry reads pickData(bundle, locale)?.settings, and the console's useSettingsLabel scans every namespace carrying a settings branch. The tracked liveness ledger packages/spec/liveness/translation.json records that reader with its evidence pointer and says both doors "merge into ONE tree".

So an app-authored settings did not sit unread — but nor did it override the platform. The app's bundles load in AppPlugin's start() (kernel Phase 2) and the platform's at kernel:ready (Phase 3), and deepMerge gives the later source the leaf, so the platform won every key both defined. What an application actually had was a GAP FILLER on a namespace it does not own: it rendered only where the platform bundle carried no string for that key and locale. That makes the ruling's DIRECTION stronger, not weaker, and it changes only what the record must say: the drop is a visible change only on the screens where the entry was FILLING A GAP, and those fall back to the manifest's own English literal; where the platform already carried the string, nothing changes. The ADR-0087 semantic entry and the changeset both say so in those words rather than reciting the house "pure lossless delete" phrase.

Diff

Spec (packages/spec)

  • src/system/translation.zod.ts — translationDataShape() becomes appTranslationDataShape() (ten groups) and the settings group moves to platformSettingsShape(). TranslationDataSchema = per-app, strict, with a guidance prescription for both settings and the singular setting; the setting alias is deleted, because an alias prescribing a key the shape now rejects is a suggestion the author cannot take. PlatformTranslationDataSchema = the ten plus settings. TranslationBundleSchema is per-app; PlatformTranslationBundleSchema is new. TranslationItemSchema is UNCHANGED and still declares settings.
  • src/api/protocol.zod.ts — GetTranslationsResponseSchema.translations moves to the platform face. The served document is the merge of every loaded bundle, so it carries settings; leaving it on the per-app face would have published a declaration the server contradicts.
  • src/system/i18n-resolver.ts — pickData becomes generic over the entry type, and the settings resolvers take PlatformTranslationBundle. This WIDENS their parameter (every group is optional, so a per-app bundle is still assignable), so no caller — this repo's or the pinned sibling's — loses a call.
  • src/conversions/registry.ts — new D2 translation-per-app-settings-removed (toMajor: 18, retiredFromLoadPath: true), strips the group from per-app bundle ENTRIES only. An entry carrying locale is a translation ITEM and is left whole; the candidate value must additionally be a dict whose every key is a declared group, so an objects record holding an object literally named settings is not mistaken for a bundle.
  • src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts — the ADR-0087 semantic entry, plus the regenerated registry.ts and the extended step-18 rationale. Regenerated with gen:migration-registry, never hand-merged.
  • src/type-alias-convention.pin.test.ts — the two new aliases are pinned isomorphic (both faces are all-optional, no default or transform anywhere), and the pin count moves 784 → 786 with its receipt.
  • authorable-surface/system.json — system/TranslationData:settings deleted DELIBERATELY, the tripwire the strict-delete route owes. The build's own deletion gate then adjudicated it and printed its proof (authorable-surface 的 tombstone 门禁可被手编基线绕过 —— 删掉基线行就删掉了证据(#4638 / #4643 已两次这样过绿) #4650 proof 2): "def not reachable from the 30 metadata-type roots ... an over-collected entry, never parsed against a metadata document." Eleven system/PlatformTranslationData:* keys arrived in the same run.
  • Regenerated: api-surface/, export-origins/, declaration-map/, json-schema.manifest/, content/docs/references/**, the strictness-ledger counts.

The gate (ruling item 3) — scripts/check-i18n-walk-parity.mjs: the settings ledger row is gone, LEDGER_CEILING 3 → 2, the class-level note and the recorded self-test samples move with it. This is the shrinking direction of a governed, shrink-only ledger, declared in the claim and declared here. The gate's own rule at the ratchet says growth is the reviewed act; slack fails too, which is why the ceiling had to move in the same diff.

Platform readers — packages/services/service-settings/src/translations/{en,es-ES,ja-JP,zh-CN}.ts and their index.ts move to PlatformTranslationData / PlatformTranslationBundle. They are the only bundles in the repo that author settings.

Published prose — content/docs/protocol/kernel/i18n-standard.mdx published the eleven-group list as "rejected by name at both authoring doors", and skills/objectstack-i18n/SKILL.md published the same list to customer projects. A third published carrier, content/docs/ui/translations.mdx, taught settings as app-translatable in its own table. All three are corrected — an earlier draft of this sentence said "both", before the guide correction landed. docs/qa/platform-checklist/areas/i18n.json had a symbol anchor on the renamed shape function and a clause naming the wrong face.

Verification

⚠️ Provenance corrected — this table was NOT read at the final commit. It was read at 5283099838, which is the 6th of this branch's 10 commits; four have landed since (b33ea66cb3, d5e6b43977, b1e7984040, 8dcd6a42ae). An earlier draft of this line called it "the final commit on this branch", and the whole Verification table, the Tests section and the Reverse verification paragraph hang off it — so as written the body claimed readings that covered the derivation change and the guide correction. They did not.

⛔ Nothing below is therefore unmeasured at head; it is re-measured elsewhere, not here. What covers the later commits is the at-tier contract review of head 8dcd6a42ae (record on card #15178), which re-derived the carrier sweep over all 9156 tracked files, rendered the migration TODO live, ran the derivation ablation in memory, and read CI by job conclusion. ✅ One row IS unaffected and re-measured at head: the skills table — skills/objectstack-i18n/SKILL.md was last touched at 2602ccec10, earlier than 5283099838, and every figure in it reproduces at head (494→496 lines, 4713→4752 tokens, ceiling 6338, headroom 1586).

Instrument Reading Which side it can fail on
check:i18n-walk-parity 10 declared group(s), 8 walked, 2 exempted The 10 is the reading that discriminates: the per-app face has ten groups, the platform face eleven. It fails if a declared group has no emitter and no ledger row, if a ledger row is stale, and — the ratchet — if the ceiling has slack. Its --self-test battery is 43 cases.
check:authorable-surface (inside gen:schema) deletion allowed with a printed proof; 11 keys added It refuses ANY authorable key that vanishes without one of four proofs, and it refused this diff on the first attempt — that refusal is the control.
check:generated 15 of 15 artifacts current after --fix regenerated the 5 it proved stale, each re-checked It can fail on a stale artifact in either direction.
check:adr-0087-registration 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition It can fail on a breaking changeset with no marker; it reported 0 non-breaking changeset(s) seen before the changeset was committed, which is the control leg.
check-changeset-no-major no major bump ⚠️ Its clause-② axis printed LEVEL AXIS: NOT APPLICABLE — there is no PR payload on a local run, so it can fail HERE only on the major axis, not on the declaration.
check:spec-parsed-alias 1455 bare aliases, 786 pinned isomorphic, 669 paired It failed first with both new aliases named — that red is the control.
check:type-check-debt 4 ledger entr(ies) re-measured, 53 raw tsc errors, none above its recorded number Re-run after a rebuild; an earlier run exited 3 (PREREQUISITE NOT MET) on a dist older than its sources, which is NOT a reading.
pnpm lint (eslint . --no-inline-config) exit 0, whole repo, no narrowing Ran over the repo's own configured universe, so no narrowing claim is needed.

Gate families: scripts/pm/dispatch-gates.mjs derived 139 for this change set; --ran with exit codes recorded reconciles 139 accounted, 138 run, 0 UNRUN, 1 NOT MEASURED. The one is check:dual-build-cjs-loads, which exits 3 (PREREQUISITE NOT MET) without a full workspace build — recorded as NOT MEASURED and left to CI, which builds everything. The reconciliation's own caveat stands: it answers what this card DERIVES against what was RUN, and the artifact-roster families, the wide-population families and the path-scheduled CI jobs are outside that total.

Tests (turbo run test, --concurrency=2): @objectstack/spec 508 files / 14,902 tests, @objectstack/lint 106 files, @objectstack/service-settings 33 files, @objectstack/platform-objects 51 files, the three examples 41 files, @objectstack/cli unit tier 222 files / 3,141 tests — all pass. turbo run typecheck over spec, cli, lint, platform-objects, service-settings, service-i18n, runtime and rest: 64 tasks, all pass.

Reverse verification (one-shot, not left in the tree). With the fix committed, settings was put back on the per-app shape through scripts/ablation-replace.mjs — the mutation is proved on disk (anchor 1 → 0, blob 3a27c26f6a5c → a0b6be68af3e) — and the two refusal pins went RED by name. Restored with --restore: blob back to 3a27c26f6a5c, equal to HEAD, and git diff HEAD empty. The predicted direction was "turns red", and that is what was observed.

Skills bundle readings (skills/** is a governed surface)

Required because the diff touches a published skill. This is a CORRECTION, not an expansion — the added sentence exists because the old one became false.

Reading Before After Delta
skills/objectstack-i18n/SKILL.md, lines 494 496 +2
skills/objectstack-i18n/SKILL.md, tokens 4713 4752 +39 (ceiling 6338, headroom 1586)
Whole bundle, all SKILL.md lines 6145 6147 +2
Whole bundle, tokens (shipped tree) 140374 140413 +39

Before-tokens were measured by restoring the base file, reading check-skills-token-ratchet, and restoring with proof (blob equal to HEAD, git diff HEAD empty). check-skills-token-ratchet passes: 34 authored files within their ceilings.

⚠️ Landing tier. skills/** is Tier H on the governed register, so this PR's landing is Tier H on one path hit. It is left as a draft awaiting that record. If the seat would rather land the rest through the queue, the remedy the directive names is to split skills/objectstack-i18n/SKILL.md off into its own PR — but ⛔ not to ship the corrected schema while the published skill still teaches the key the parse now refuses.

Declared file-surface deviations

The claim declared the surface as translation.zod.ts + siblings, the walk-parity gate + fixtures, i18n-extract.ts + tests, migrations/entries/semantic/ + registry.ts, and .changeset/. Five paths outside it were edited, each forced by the ruling rather than chosen, and none widened silently:

  1. packages/spec/src/api/protocol.zod.ts — the served response must type against the platform face or it declares a shape the server contradicts. ⚠️ This path is held by open PR feat(automation): GET /automation/:name/runs retires cursor and computes hasMore #19493's sibling declaration set — specifically it was declared disjoint against [finding] three sibling list doors declare limit/cursor and never read them, one reporting hasMore: false as a literal — REBUILD of #19365, which stopped resolving on 2026-09-21 #19543, which enumerates it. One line changes (the import) plus one line in the response schema, plus a docblock. A textual conflict is possible; this PR is not asking to land first.
  2. packages/spec/src/system/i18n-resolver.ts — pickSettingsEntry reads .settings; without this the package does not typecheck. The parameter is widened, not narrowed.
  3. packages/services/service-settings/src/translations/* (5 files) — the only bundles that author settings; type annotations only.
  4. packages/spec/src/conversions/registry.ts — the D2 conversion ruling item 4 asks for ("the conversion drops the group and records a note"). The semantic entry alone records the judgment but rewrites nothing.
  5. content/docs/protocol/kernel/i18n-standard.mdx, skills/objectstack-i18n/SKILL.md, content/docs/ui/translations.mdx, docs/qa/platform-checklist/areas/i18n.json — published claims this change makes false, plus one symbol anchor the rename broke (check:platform-checklist went red on it and is green again). ⚠️ content/docs/ui/translations.mdx was added to this enumeration after the fact: it was edited in commit b1e7984040 and an earlier draft of this item listed only three paths, under-declaring the deviation by one.

packages/spec/src/migrations/registry.ts is the declared overlap with open PR #19493. Its generated regions were regenerated with scripts/pm/os-regen-merge.sh's generator (gen:migration-registry) and never hand-merged. ⚠️ One line in that file IS hand-written, and a reviewer of a generated file should be told: registry.ts:47 carries a value import of TranslationDataSchema, outside every <os-generated …> region (the first region opens at :1142). It is necessary, not an oversight — build-migration-registry.ts's parseEntry deliberately drops imports and carries only the initializer, so an entry that derives its text from a value needs that value in scope in the registry itself. The comment immediately above the import says so. It survives regeneration because renderRegistry splices only between the region markers, and check:generated is the instrument that would fail if that round-trip were unstable. packages/cli/src/utils/i18n-extract.ts was NOT edited — the walker does not move, because settings never had an emitter.

Acceptance notes

  • The translation metadata-type door is untouched and still accepts settings. The ruling names the per-app BUNDLE, and packages/spec/liveness/translation.json is that item's ledger, so narrowing the item would have moved a ledger outside this card's surface. It leaves a question worth a decision rather than a silent choice: an app admin authoring a translation item through Studio can still write settings, and authored-translation-sync merges the raw stored payload into the same served tree, so the refusal this PR adds at the file door does not reach the metadata door. Raised in the report, not decided here.
  • Platform bundles that author only shared groups (platform-objects, the five plugins, the other services) keep the narrower TranslationData / TranslationBundle types. They are assignable, and re-typing ~20 files that never carry settings would be churn with no contract effect. noted, not filed.
  • packages/lint/src/validate-translation-references.ts still lists settings among the groups it deliberately does not judge. The branch is now unreachable for a per-app stack rather than wrong. noted, not filed.

维护者速读(草稿)

  • 改了什么。 翻译包的类型一分为二:TranslationDataSchema 从此只表示「应用自己写的那一份」,十个分组;新的 PlatformTranslationDataSchema 是平台那一份,十一个,settings 留在它那里。应用再写 settings 会被按名字拒绝,并告诉作者这是平台专属。
  • 为什么改。 一个类型同时代表两种包,是这张卡上每一次误读的源头:当初的普查拿「按应用问」的问题去问一个分不出应用和平台的类型,得到零,就差点把平台每天在读的键删掉。更要紧的是实测结果:应用写的 settings 并不是没人读 —— 它和平台那一份合进同一棵已服务的树。但它不是覆盖者:应用包在 kernel 第 2 阶段加载、平台包在第 3 阶段,合并时叶子归后到的一方,所以两边都定义的键,平台永远赢。应用那份实际是个补缺者:只在平台包对那个键、那个语言没有字符串时才显示。
  • 风险与代价(含回滚)。 风险在于:升级后,两边都有的键屏幕上根本不变(平台本来就赢);只有应用包在补空的那些键会变 —— 那里平台压根没有字符串,所以屏幕上会回落到 manifest 自己的英文字面量,而不是「平台自带的字」。⚠️ 一个本地化部署里冒出一串英文,是比「换成平台的措辞」更响亮的一种结果,请按这个来衡量。这是有意的,changeset 与 ADR-0087 条目都写明了,不是静默变化。发布面动了,所以带 minor changeset(发射窗口惯例,major 会被门禁拒收)。回滚就是回滚这个 PR:没有数据迁移、没有存储改动,os migrate meta 的那条转换只在作者主动运行时改源码。
  • 席位意见。 这一轮的方向是 dev 第一手实测出来的,不是复述:证伪器跑真链路,外加两个亮控 —— 一个把加载顺序反过来证明仪器对顺序敏感,一个用平台没翻译的命名空间证明 app 那份真的被加载且在服务。结论从「覆盖平台文案」改成「只填平台没有的空」,所以 ADR 条目按实测写是对的,原裁决的方向不动。本席不建议拆技能文件:拆了等于在窗口期里让已发布技能继续教一个已被拒收的键,而 skills/** 这道 Tier H 的门你本来就得为它开一次。⚠️ 另:本 PR 先前那份契约复审记录已作废(跑在已退役的档位上,台账见 decision(pm): this seat dispatched 5 clause-② reviews at the RETIRED model tier after the maintainer ruling landed — two PRs merged on void verdicts #19603),新的达档复审在 the current CONTRACT_REVIEW_TIER 上重跑;⛔ 结果出来之前本席不做任何落地动作,也不翻 ready。
  • 你要做的。 ① 这个 PR 碰了 skills/,按规矩属于 Tier H,落地要维护者的那句话;若不想为一条文案更正开这道门,可以把那个技能文件单独拆一个 PR——但⛔ 不能一边发布新契约、一边让已发布技能继续教一个现在会被拒收的键。② 裁决里「was inert」这句与实测不符(它是活的),ADR 条目按实测写,请确认这个改写符合原意。③ translation 元数据门仍接受 settings,那是本卡范围之外的一个口子,见上面的验收注记。

Generated by Claude Code

…rm-only

`TranslationDataSchema` served two bundles at once (per-app `stack.translations`
and the platform packages' own code-authored bundles), and that is what made
every reading of `settings` wrong. It now names the PER-APP bundle entry — ten
groups, `settings` refused by name with a platform-only remedy — and the new
`PlatformTranslationDataSchema` / `PlatformTranslationBundleSchema` carry the
eleven-group platform face.

Ruling batch #132 item 2 letter ② (2026-09-13).

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
…n + ADR-0087 semantic entry)

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

github-actions Bot commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/service-settings, @objectstack/spec, touching 46 documentable anchor(s). ⚠️ 6 changed file(s) yielded no anchor (packages/services/service-settings/src/translations/en.ts, packages/spec/api-surface/system.json, packages/spec/authorable-surface/system.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

12 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 confirmText (literal, a string literal in platformSettingsShape; a string literal in translationDataShape))
  • content/docs/ai/connect-mcp.mdx (via confirmText (literal, a string literal in platformSettingsShape; a string literal in translationDataShape))
  • content/docs/api/client-sdk.mdx (via getTranslations (sdk, the bare tail of client method i18n.getTranslations, bound to GET /api/v1/i18n/translations/:locale; the bare tail of client method i18n.getTranslations, bound to GET /i18n/translations/:locale), i18n.getTranslations (sdk, the route ledger binds it to GET /api/v1/i18n/translations/:locale; the route ledger binds it to GET /i18n/translations/:locale, selected by route anchor /translations/:locale))
  • content/docs/api/plugin-endpoints.mdx (via /translations/:locale (route, bridged from symbol GetTranslationsResponseSchema — its route source's handler names it))
  • content/docs/automation/flows.mdx (via successMessage (literal, a string literal in platformSettingsShape; a string literal in translationDataShape))
  • content/docs/getting-started/build-with-claude-code.mdx (via successMessage (literal, a string literal in platformSettingsShape; a string literal in translationDataShape))
  • content/docs/kernel/services-checklist.mdx (via getTranslations (sdk, the bare tail of client method i18n.getTranslations, bound to GET /api/v1/i18n/translations/:locale; the bare tail of client method i18n.getTranslations, bound to GET /i18n/translations/:locale), /api/v1/i18n/translations/:locale (route, a path literal in acceptanceCriteria; a path literal in semantic), /translations/:locale (route, bridged from symbol GetTranslationsResponseSchema — its route source's handler names it))
  • content/docs/protocol/kernel/i18n-standard.mdx (via PlatformTranslationData (symbol, a top-level type), TranslationDataSchema (symbol, a top-level const), confirmText (literal, a string literal in platformSettingsShape; a string literal in translationDataShape), globalActions (literal, a string literal in PER_APP_SETTINGS_PLATFORM_ONLY; a string literal in PlatformTranslationDataSchema; a string literal in TranslationDataSchema; a string literal in apply), metadataForms (literal, a string literal in PER_APP_SETTINGS_PLATFORM_ONLY; a string literal in apply), settingsCommon (literal, a string literal in PER_APP_SETTINGS_PLATFORM_ONLY; a string literal in apply), getTranslations (sdk, the bare tail of client method i18n.getTranslations, bound to GET /api/v1/i18n/translations/:locale; the bare tail of client method i18n.getTranslations, bound to GET /i18n/translations/:locale), /translations/:locale (route, bridged from symbol GetTranslationsResponseSchema — its route source's handler names it))
  • content/docs/protocol/objectui/actions.mdx (via confirmText (literal, a string literal in platformSettingsShape; a string literal in translationDataShape), successMessage (literal, a string literal in platformSettingsShape; a string literal in translationDataShape))
  • content/docs/protocol/objectui/record-alert.mdx (via confirmText (literal, a string literal in platformSettingsShape; a string literal in translationDataShape), successMessage (literal, a string literal in platformSettingsShape; a string literal in translationDataShape))
  • content/docs/ui/actions.mdx (via confirmText (literal, a string literal in platformSettingsShape; a string literal in translationDataShape), successMessage (literal, a string literal in platformSettingsShape; a string literal in translationDataShape))
  • content/docs/ui/translations.mdx (via globalActions (literal, a string literal in PER_APP_SETTINGS_PLATFORM_ONLY; a string literal in PlatformTranslationDataSchema; a string literal in TranslationDataSchema; a string literal in apply), metadataForms (literal, a string literal in PER_APP_SETTINGS_PLATFORM_ONLY; a string literal in apply), settingsCommon (literal, a string literal in PER_APP_SETTINGS_PLATFORM_ONLY; a string literal in apply), /translations/:locale (route, bridged from symbol GetTranslationsResponseSchema — its route source's handler names it))

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

  • content/docs/releases/v16.mdx (via globalActions (literal, a string literal in PER_APP_SETTINGS_PLATFORM_ONLY; a string literal in PlatformTranslationDataSchema; a string literal in TranslationDataSchema; a string literal in apply))
  • content/docs/releases/v17/17-1.mdx (via confirmText (literal, a string literal in platformSettingsShape; a string literal in translationDataShape), successMessage (literal, a string literal in platformSettingsShape; a string literal in translationDataShape))

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
  • 6 changed file(s) yielded no anchor (packages/services/service-settings/src/translations/en.ts, packages/spec/api-surface/system.json, packages/spec/authorable-surface/system.json, …) — pages documenting those are invisible to this run
  • 10 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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 — 138 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 70dccce03842a4e43abd91ca38ae6bafa1a28174 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from f8be76094775e5562d65838d15cef48616490850 — the merge of head 9479042a3f31ac54f6072f61300bb7705dc7e669 into base 70dccce03842a4e43abd91ca38ae6bafa1a28174, 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 f8be76094775e5562d65838d15cef48616490850 && git checkout f8be76094775e5562d65838d15cef48616490850
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 70dccce03842a4e43abd91ca38ae6bafa1a28174 9479042a3f31ac54f6072f61300bb7705dc7e669 && git checkout -B drift-repro 70dccce03842a4e43abd91ca38ae6bafa1a28174 && git merge --no-ff 9479042a3f31ac54f6072f61300bb7705dc7e669

node scripts/docs-audit/affected-docs.mjs --json 70dccce03842a4e43abd91ca38ae6bafa1a28174

⚠️ 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 70dccce03842a4e43abd91ca38ae6bafa1a28174 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…rm did

Rework on the at-tier FAIL. `AppPlugin` loads the app's bundles in its own
`start()` (kernel Phase 2); `SettingsServicePlugin` contributes the platform's
settings translations from a `kernel:ready` hook (Phase 3); `deepMerge` gives
the later source the leaf. So a per-app `settings` entry was a GAP FILLER —
it rendered only where the platform bundle carried no string for that key and
locale, and lost every key both defined. Dropping it sends those gaps back to
the manifest's own English literal, not "the platform's strings again".

Nine sites carried the reversed claim: the changeset body, the D2 conversion's
docblock and `summary`, the ADR-0087 entry's `reason` and `acceptanceCriteria`,
their two generated copies in `migrations/registry.ts` (regenerated, never
hand-edited), the step-18 `rationale`, and the parse-time refusal text
`PER_APP_SETTINGS_PLATFORM_ONLY` — plus the two schema docblocks that carried
it. Text only: no schema, face, ledger, conversion behaviour or level changes.

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
`PER_APP_SETTINGS_PLATFORM_ONLY` told a refused author to use "the groups this
bundle does declare" and then left out `settingsCommon` — which is precisely
the nearest legitimate neighbour for someone who just had `settings` refused,
and the one key that makes the boundary legible: the Settings UI SHELL strings
stay authorable, only the per-namespace manifest copy leaves. Read off the
BUILT `TranslationDataSchema.shape`, the per-app face declares ten. The message
now enumerates all ten, in declaration order, and says what the neighbour is.

The changeset's own `Clause-②` line moves `no` -> `yes`: four genuinely-new
exported symbols trip the mechanical floor. Documentation consistency only —
the gate reads the PR body, and the `minor` bumps are unaffected.

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
`content/docs/ui/translations.mdx` is a published app-author guide, and its
"What you can translate" table told an application to put its settings copy in
`settings` — a group this change makes platform-only and refuses by name in a
file-authored bundle. Shipping the refusal beside a page that teaches the
refused key is the state this PR's own body calls unacceptable.

The row now names `globalActions` and `messages` only, and a new row points at
`settingsCommon.sourceLabels` — the Settings UI shell strings that DO stay on
the per-app face — while saying where the per-namespace manifest copy is
translated instead.

Two neighbours in the same sections said the opposite and are corrected with
it. "Authoring in the product" claimed a `translation` item carries the **same**
groups as a file bundle; it carries one more. And "only the groups on this page
are accepted ... in a runtime item AND in a file-authored bundle" would have
become false the moment `settings` left the page, because the registered item
still declares it. Both now state the one asymmetry explicitly.

Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx
Co-authored-by: Claude <noreply@anthropic.com>
…t a literal

The semantic migration entry for `translation-per-app-settings-platform-only`
enumerated nine of the ten groups the per-app face declares and omitted
`settingsCommon` — the nearest legitimate neighbour for an author who has just
had `settings` refused. `os migrate meta --from 17` prints this string verbatim
to the operator (`packages/cli/src/commands/migrate/meta.ts` renders
`surface → replacement`), so the omission reached a user-facing surface.

Correcting the literal would leave the construct that produced it. A
hand-maintained copy of a schema's key set already drifted here once, through a
full review of the surrounding change, so the copy is deleted rather than
pinned: `replacement` is now a getter that reads
`Object.keys(TranslationDataSchema.shape)`. There is one spelling of the set,
and a group added to the per-app face reaches this message the day it is
declared, with nothing to remember. Ordered remedy — delete the construct that
permits the error before reaching for a check that only reddens it.

`Object.keys` on a zod object shape returns the declaration order of the literal
it was built from, which is the order the sentence promises; the separator and
the shape of the sentence are unchanged, so the rendered paragraph reads as it
did. A getter rather than an eager template because importing the registry must
not force the lazy translation schema at module load.

The registry is generated by concatenating entry literals, and the generator
treats a file's imports as scaffolding — so `registry.ts` carries its own
hand-written value import outside the generated regions, with a comment saying
why. Verified: it survives `gen:migration-registry`, and
`system/translation.zod.ts`'s own 26-module closure reaches nothing under
`migrations/`, so the edge adds no cycle.

The changeset gains one sentence naming `settingsCommon` as unaffected. The
declared semver level is untouched — both packages stay `minor`; two prose
corrections and one derivation move no published signature.

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

Copy link
Copy Markdown
Collaborator Author

Blockers 1 and 2 are CLEARED. Only blocker 3 remains, and it is the one no seat may clear.

domain:spec execution seat 2, session session_01UDXER3sdqfeVYpEWZs5mZx, 2026-09-22T16:55Z. Supersedes the reading in 5775717416 (2026-09-22T11:35Z) on blockers 1 and 2 only; its blocker 3 stands unchanged. ⛔ No label written, ⛔ no ready-flip, ⛔ no enqueue, ⛔ no auto-merge, ⛔ no approving review.

Head is now 9479042a3f31ac54f6072f61300bb7705dc7e669, 28 files, +929 / −180.

# blocker reading this act
1 merge conflict ✅ cleared — mergeable_state: clean. Resolved by a real merge commit (67e369ecb6, parents 34b6a25b2c + fb7b74691f) plus a regeneration commit
2 the contract-review record's tier ✅ cleared — record re-taken on THIS head: comment 5780146227 on card #15178, VERDICT: PASS, Served-tier: on a seat-supplied stamp control of 96/96
3 governed-surface authorization ⛔ stands — and it is the maintainer's, ⛔ not this seat's

CI, read by job conclusions on this head — latest run per check NAME, ⛔ never an aggregate roll-up

35 names — 33 success, 2 skipped, ⛔ 0 failure, 0 in_progress. All seven required contexts success, each named and read individually: Lint & Repo Gates · TypeScript Type Check · Test Core · Dogfood Regression Gate · Build Core · Temporal Conformance (live PG + MySQL) · Governed Surface Queue Guard. ⇒ genuinely green, ⛔ not filtered-green: nothing required is skipped here.

Blocker 3, re-measured in this act

node scripts/pm/check-governed-merges.mjs --pr 19600 → exit 3, which is that tool's GOVERNED verdict (⛔ not PREREQUISITE NOT MET — its own table says so, and reading it the other way is a trap this seat has already fallen into once).

1 of 28 paths hits the register: skills/objectstack-i18n/SKILL.md (skills/**, the published skills catalog). One hit governs the whole PR — 「混合 diff 一条命中即整 PR 分叉」 — so the other 27 paths are ⛔ not a mitigating argument. 1,109 changed lines, under the human-merge threshold.

⚖️ Landing tier H(人合). Two routes, and ⛔ this seat may take neither on its own:

  1. The maintainer's hand — flip it ready and merge. ⚠️ It is a draft, and it stays one: the standing red line is 「⛔ 受管面 PR 无授权批准时永不翻 ready、入队或挂 auto-merge」, so ⛔ this seat will not flip it ready to make the merge button appear, even though that is the only thing standing between here and a hand-merge.
  2. An authorized APPROVED review under GOVERNED_APPROVERS on record — then this seat flips ready, enqueues and lands it, and reports the landing shape by parent count.

⛔ This seat never approves a governed-surface PR under any account, and ⛔ never merges one.

⭐ One finding from the conflict round, recorded here because it changes how the next one is done

The dispatch warned that git merge-tree exit 0 is a false green on os-regen paths. On this pair it was ⛔ not hypothetical: the merge commit reverted main's enableOnInstall correction (#19690 / #19691) at content/docs/references/api/protocol.mdx:1913, and the regeneration commit 9479042a3f restored it. ⇒ the regeneration was ⛔ not cosmetic — it repaired a real silent drop, and git diff origin/main...HEAD on that file is now only the intended settings row move.

⇒ ⛔ never read git merge-tree exit 0 as a clean merge on a generated-doc path; diff the merge commit against both parents.

State

⛔ PR stays draft. Card #15178 stays pm:dispatched with its assignee — 在飞卡跟到 MERGED, and this one is waiting on a human step, ⛔ not on more work. Nothing further is owed by this seat until route 1 or route 2 happens; it is carried in the shift report's 等人合项 section and ⛔ the seat does not idle on it.


Generated by Claude Code

@hotlong
hotlong marked this pull request as ready for review September 23, 2026 02:26
@hotlong
hotlong enabled auto-merge September 23, 2026 02:26
@hotlong
hotlong added this pull request to the merge queue Sep 23, 2026
Merged via the queue into main with commit d0f1845 Sep 23, 2026
40 checks passed
@hotlong
hotlong deleted the claude/issue-15178-translation-bundle-split branch September 23, 2026 03:04
@objectstack-fleet

Copy link
Copy Markdown
Contributor

Supersession note for this PR's acceptance note 「TranslationItemSchema is UNCHANGED」 — ruling 5770445203 (#19620, letter B) step ③ asks for it to be flipped. That PR (#19600) is merged, so its body stays as is; this comment records the flip instead.

PR #19945 (#19620) narrows the item door too: settings and the setting alias leave TranslationItemSchema. An item carrying either is refused at parse with the platform-only prescription, and stored item rows are converted with a loud notice. The note above is therefore superseded in the same release. The unreleased .changeset/15178-…md sentence is corrected in PR #19945, subject to the maintainer's confirmation.

domain:spec#4 · session_019c3Hi6ZMU1p6m6aA6Bz45d · 2026-09-24T03:12Z


Generated by Claude Code

akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…e in a bracketed opener (objectstack-ai#19683)

Fixes objectstack-ai#16245

Clause-②: yes

Every refusal `ObjectStackProtocolImplementation` and
`SysMetadataRepository` raised opened with a bracketed tag that restates
the `code` the same throw declares. The 2026-08-29 maintainer ruling is
ONE envelope semantics — `error` is HUMAN LANGUAGE, `code` is the
MACHINE TOKEN — and a prefix is removed *because* the same fact already
rides the `code` axis. These met that condition by construction, so they
are gone; `code` and `status` are untouched on every one of them.

27 files, 116 derived gate families, head `5e294644b2`.

## What was measured, not assumed

**The inventory is 38, not the 30 in the card's title.** Re-derived by
pairing every throw site in the two named files with the `code` it
declares, rather than by grepping literal tags:

| reading | count |
| --- | --- |
| literal `[tag]` message openers | 37 |
| of those, restating their own declared `code` | 37 (mismatches: 0) |
| openers spelled by interpolation | 1 |
| **total** | **38** |

The card measured 31 tagged sites / 30 restatements on 2026-09-06; the
tree moved 16 days, and `[unanswerable_target]` — the one mismatch,
which objectstack-ai#16145 already removed — is correctly absent from this
re-derivation. Firing controls on the same command and scope: a
nonexistent tag returns 0, `unanswerable_target` returns only its pin
and a comment, and the pattern finds the idiom in 20+ files repo-wide.

**The 38th is the one no grep could find.** `assertOverlayAllowed`'s
shared emitter opened its message with an interpolated `[${code}]`,
built from the same variable it assigns to `err.code` three lines down —
the most redundant member of the family and invisible to every
literal-tag probe, which is why it is absent from the card's inventory.

**Reachability re-verified.** `withoutDeclaredCodePrefix` returns the
message unchanged unless `message.startsWith(error.code)` AND a colon
separator follows. `[no_draft]` starts with neither, so it was never
stripped and reached the caller in `error.message`. This is a
published-output change, which is why `Clause-②` is `yes`.

**Nothing consumed the tag** — the open question triage left on the
card. The only consumers found anywhere are strippers:
`@object-ui/react`'s `extractWriteErrorMessage` and two `plugin-detail`
call sites each remove a leading bracketed prefix before showing the
sentence to a user, beside the `SCREAMING_SNAKE:` strip. They
independently confirm the tag was arriving, and they cannot break on its
absence — the regex simply matches nothing.

**Two bracketed vocabularies are deliberately kept**, because neither
restates a declared code: the `path [zod code]` locators inside a
validation headline, and the `[rule]` locators the author-time gate
composes. The `[Protocol]` and `[SysMetadataRepository]` logger prefixes
are likewise untouched and declared by name in the new pin.

## The published docs this change falsified are repaired here

Removing the openers made documented payloads wrong. Repairing what this
round makes false is part of the round.

- **`content/docs/api/error-catalog.mdx`** (`:609`, `:660`) — both
documented `INVALID_REQUEST` payloads showed a `message` opening with
the tag beside a `code` field already carrying the token. Hand-written
page, edited directly.
- **`packages/spec/src/api/protocol.zod.ts:815`** — the promotion
`describe()` said the lookup answers 404 `[no_draft]`; it now names
`NO_DRAFT`, the axis that still carries it.
- **`content/docs/references/api/protocol.mdx`** — AUTO-GENERATED from
that source, so it was ⛔ never hand-edited: regenerated with
`check:generated --fix`, which proved exactly 1 of 15 artifacts stale
and rewrote only that one. The page moved exactly 1 line.

⛔ **The three carriers in `content/docs/releases/v17/` are deliberately
left alone.** Release notes record what shipped in the version they
document; v17.1 and v17.2 did emit those tags, so those pages are
historically accurate and are not a defect to repair.

## The blast radius, closed

A message-text change breaks every pin asserting that text. Thirteen
pins in `packages/metadata-protocol`, two in `packages/runtime`, six in
`packages/objectql`, and two in `packages/cli`.

⚠️ The objectql and cli consumers surfaced on successive CI runs rather
than together, because the shard stopped at the first failure and
reported `@objectstack/cli was scheduled but never reached`. A green
shard is only evidence about what it reached — the run that finally
reported `11 of 11 scheduled package(s) reported, 0 never reached` is
the one that measured the whole set. The consumer scan is therefore
repo-wide and not CI-led: the only remaining assertion on a removed
opener anywhere in the tree is a `packages/rest` test that constructs
its own error, which is fixture drift, not a consumer.

The six objectql assertions split into two families, and only one is
this card's to retire:

- **Five** pinned the opener itself. They move to the ENVELOPE (`code` +
`status`), which is strictly stronger than what they had — two were bare
`rejects.toThrow(/regex/)`, which a plain `Error` would have satisfied.
In each case the pre-change producer string carried the lowercase token
ONLY inside the brackets, with zero occurrences elsewhere in the
message.
- **One is not that.** `protocol-object-overlay-layer.test.ts` asserts
captured `console.warn` output, and a log line has no envelope beside it
— the `code` axis a caller reads does not exist there, so removing the
opener took the only machine-readable token with it and left nothing to
fall back on. Genuine collateral. ⇒ Fixed at the **producer's log
site**, not in the assertion: the boot warning now prints the declared
code itself, as the sibling branch twelve lines above already does. ⛔
The opener was NOT restored at the producer — that would reintroduce the
defect for every caller in order to serve one log site.

Three pins elsewhere were only ever green because the tag happened to
spell the token; they now read `err.code`.

### `os meta delete` — two surfaces, two different answers

The CLI carries this refusal on both a human and a machine surface, and
the two assertions do not move the same way.

- **stdout is the human surface.** `printError` prints the message and
nothing else, so the token never belonged there. That assertion now
reads the sentence, and the structural controls already in the test —
exit code, the reset request's `parentVersion`, the `if-match` header —
are what keep it from degrading into "something went wrong".
- **`--format json` is the machine surface, and it already carries the
token.** Measured rather than assumed, by dumping the real envelope
through a temporary probe with a proven restore:

```
{ "success": false,
  "error": "view/json_probe has been modified since you loaded it. Expected parent sha256:… but current is sha256:….",
  "code": "METADATA_CONFLICT",
  "httpStatus": 409 }
```

So the assertion reads `code` and `httpStatus`, which a prose match over
`error` only ever approximated. ⛔ **No producer change was needed and
the opener was not re-added** — `errorCodeFields` has carried this since
objectstack-ai#13347. Had the envelope carried no `code`, that would have been a real
regression on a documented machine-readable surface and the fix would
have belonged at the producer, exactly as it did for the boot log line
above.

## Tests

- `pnpm --filter @objectstack/metadata-protocol test` — **186 files /
2650 tests pass**, 3 files and 19 tests skipped. `typecheck` exit 0, and
`--listFiles` shows all 189 test files inside the tsc program, so the
new pin is type-checked.
- `packages/objectql` — the 5 files, **159 tests**, exit 0; `typecheck`
exit 0. `packages/runtime` and `packages/rest` targeted — exit 0.
- `pnpm lint` — the whole repo, `eslint . --no-inline-config`, exit 0,
zero findings. No narrowing was used.
- All **113** derived gate families run, **0 NOT MEASURED, 0 UNRUN**,
every exit code recorded and none is 3 (`dispatch-gates --ran`
reconciliation). `check:dual-build-cjs-loads` and
`check:type-check-debt` first answered `PREREQUISITE NOT MET` (exit 3,
explicitly not a pass); the whole-workspace build they ask for was paid
and both then read 0.

**Two reverse verifications**, each with on-disk proof and a proven
restore:

1. One opener re-inserted at a named anchor (anchor 1 to 0, blob
`ce0ae6aafeb6` to `734afa23b233`). The new absence pin went **red**,
naming `protocol.ts:8204`. Restored; worktree blob equals the HEAD blob
and `git diff HEAD` empty.
2. The log-site fix ablated to prove it is load-bearing rather than
cosmetic. Because objectql resolves this package through `dist`, the
mutation was rebuilt and confirmed present in **both** `dist/index.js`
and `dist/index.cjs` before any verdict was read — an unbuilt ablation
here would have stayed green and vouched for nothing. The test then went
**red**. Restored, rebuilt, marker count in `dist` back to 0, whole-tree
`git status --porcelain` empty.

## Changeset

`minor` on **both** `@objectstack/metadata-protocol` and
`@objectstack/spec`, measured rather than pattern-matched. Both are
public, non-private packages. metadata-protocol ships `files: ["dist",
…]` and the new message bytes appear twice each in `dist/index.js` and
`dist/index.cjs`, while both controls (the old literal opener, the
interpolated opener) read 0. spec ships `dist` **and**
`src/**/*.zod.ts`, and the new describe bytes appear in
`dist/api/index.js` and the browser builds. `content/docs/**` is shipped
by no package, so the mdx edits publish nothing on their own. `Clause-②:
yes` takes at least `minor`; no `(narrowing)` arm, because nothing an
author can write is removed, renamed or narrowed.

## Acceptance notes

Noted, not filed, and ⛔ not ridden along:

1. **`packages/metadata-protocol/src/runtime-authoring-gate.ts`** still
writes `[invalid_metadata]` in front of `code = 'INVALID_METADATA'` /
`status = 422`. Same package, same family, same ruling, third producer —
outside the two files this card named, and it reaches `dist/index.js`.
The nearest same-class remainder and the obvious next increment.
2. **`packages/rest/src/rest-server.ts`** raises its own
`[invalid_request]` opener, and
**`packages/runtime/src/domains/packages.ts`** its own
`[writable_package_required]` one. The idiom is repo-wide well beyond
this card's two files.
3. **`packages/spec/src/api/protocol.zod.ts` WAS taken, not merely
flagged** — the describe correction at `:815` is in this diff, and
triage's boundary comment `5571629394` names exactly that correction as
part of the deliverable, which is what makes `Fixes objectstack-ai#16245` honest.
**`packages/spec/src/api/plugin-rest-api.zod.ts:898` was taken too**, on
the at-tier review's reading: it described the same `publishMetaItem`
door as answering 404 `[no_draft]` while `protocol.zod.ts` now says
`NO_DRAFT`, and BOTH ship in this same `@objectstack/spec` minor — twice
over, as published `.zod.ts` source and in `dist/api/index.{js,mjs}`.
One release carrying two contradictory descriptions of one door is a
defect in this change, not a follow-up. Nothing regenerates from it (all
15 spec artifacts re-checked clean). `metadata_conflict` on that line
stays lowercase deliberately — it is the shared spelling in
`protocol.zod.ts` too, so "correcting" it would introduce the very
inconsistency this removes. ⚠️ PR objectstack-ai#19600 also holds `protocol.zod.ts`,
at `@@ -27,7` and `@@ -3167,9`, while this diff's single hunk is `@@
-812,7` — roughly 2,350 lines clear, so it is not a region collision. It
goes to the merge queue, ⛔ never hand-sequenced.
4. **Current-tense claims about current behaviour, outside this card's
lane** — found by `git grep` over the whole tracked tree at this head,
with a dark control (`[no_draft_xyzzy]`) returning 0 so the probe
discriminates. ⛔ Not edited:
- `packages/client/src/index.ts:2151` — JSDoc "404 [no_draft] when there
is nothing to publish" (`domain:cli`).
- `docs/qa/platform-checklist/areas/access-security.json` at `:1816`,
`:1818`, `:1875` (`domain:devx`).
- `docs/qa/platform-checklist/areas/studio-authoring.json` at `:477` and
`:514` — the same probe finds two more checklist carriers in a second
area file (`domain:devx`).
5. **Governed-surface carriers, ⛔ untouched** (`docs/adr/**` is Tier H,
and triage forbade touching it on this card):
`docs/adr/0005-metadata-customization-overlay.md:348` and
`docs/adr/0010-metadata-protection-model.md:480` and `:485` each show a
wire payload whose `error` opens with the retired tag. ⚠️ Those same ADR
payloads also spell `code` in lowercase (`"code": "not_overridable"`),
which disagrees with the SCREAMING_SNAKE rule and with what the runtime
actually emits — a pre-existing inaccuracy this change does not cause
but does sit beside.
6. **Fixture drift, harmless but self-propagating**: hand-written
envelope fixtures in `packages/spec/src/api/protocol.test.ts` and mock
refusals in `plugin-security`, `cli`, `service-package` and
`packages/client/src/client.test.ts` still fabricate the bracketed
spelling. They stay green because they construct their own errors, which
is exactly how the idiom reaches the next author who copies one.
7. **One byte changed beyond the tags** — the seed-body refusal opened
`the published seed bodies failed…` once its tag was removed, and now
opens `The published…`.

⛔ `packages/*/CHANGELOG.md` entries that quote the old tags are
release-owned history in the past tense, describing what the code did
when they were written. Not a falsified claim, and not touched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…face (objectstack-ai#19054) (objectstack-ai#19618)

Fixes objectstack-ai#19054

Clause-②: no

Executes the maintainer ruling recorded verbatim on the card:
「organizationField 撤出可授权面 同意你的建议」. `object.tenancy.organizationField`
leaves the authorable surface at protocol 18 (ADR-0049
enforce-or-remove). The divergence the key existed for is **not**
retired — only its authorability.

## What the key was, and why it could never be more than one table's
fact

It answered "which column says who this platform row is ABOUT", where
`tenancy.tenantField` answers "what is this object WALLED by". The
spec's own docblock stated the consequence: *"For ordinary objects the
two coincide and `organizationField` is never needed."* Re-measured at
head before this branch: the entire repository declared it **once**, on
`packages/platform-objects/src/identity/sys-api-key.object.ts` — the
better-auth credential table — and zero business objects declared it.
Its readers were three platform-row writers, scope-pinned by name, so an
application declaration was inert by construction while still being
authorable on every object.

## The shape of the change

`TenancyConfigSchema` is a `strictObject`, so this is the
strict-deletion route:

- the key is deleted from the shape, and `TENANCY_RETIRED_KEY_GUIDANCE`
gains its prescription beside the two v15.0 precedents
(`tenancy.strategy`, `tenancy.crossTenantAccess`). Authoring it is now
**refused with the prescription**, not stripped
- D2 conversion `object-tenancy-organization-field-removed` (`toMajor:
18`, `retiredFromLoadPath: true`) strips it from authored sources and
stored `sys_metadata` rows; D3 wires it into the protocol-18 chain step;
`RETIRED_KEYS_BY_MAJOR[18]` declares
`data/TenancyConfig:organizationField`
- the `authorable-surface/data.json` row is deleted in this same commit
— the strict route's tripwire, with the build computing the
guidance-route proof for itself
- the liveness ledger row is **deleted** (not tombstoned): the key
leaves the walked shape entirely, so a surviving row would read as an
ORPHAN. `liveness/README.md`'s `object` row records why, and
`state-counts.md` moves `object` 51 → 50 live

Limb 0 of the shared resolver now reads a platform-internal table
instead of a declaration:

```
PLATFORM_STAMP_ORGANIZATION_COLUMNS = { sys_api_key: 'active_organization_id' }
```

keyed by the object's registered NAME, read by the STAMP face alone.
`resolveRecordOrganizationField` and `createRecordOrganizationResolver`
keep their signatures — `check:api-surface` is byte-identical — and the
engine-bound face passes the name it was asked about rather than reading
`objectDef.name`, because several engine doubles in this monorepo return
a bare `{ tenancy, fields }` map with no `name`.

## The two facts the card said must survive

1. `sys_api_key` is `managedBy: 'better-auth'`, so
`resolveInjectedSystemColumns` bails before tenancy is consulted and no
`organization_id` is injected. Pinned, and the pin is now stated as the
better-auth bail rather than as key-blindness
(`packages/spec/src/data/injected-system-columns.test.ts`).
2. ⛔ The column is **not** renamed to `organization_id`. In this
platform "has an `organization_id` column" IS the wall, so the rename
would wall the credential table on an equality that excludes NULL.
`plugin-security`'s Layer-0 suite pins both halves against the real
shipped object.

The stamp/wall divergence pin is green:
`resolveRecordWallOrganizationField` never read the key and is
untouched.

## Base merge after objectstack-ai#19600 landed, and the tombstone version it exposed
(2026-09-23)

The collision partner this section used to name, objectstack-ai#19610, has landed, and
so has objectstack-ai#19600 (card objectstack-ai#15178, merged at 03:04:25Z as `d0f1845657`). objectstack-ai#19600
is one of the three PRs in the serial on
`packages/spec/src/migrations/registry.ts` described in notice
`5780847968`. After it landed, this PR read `dirty`.

⚠️ **The sentence that stood here before was wrong in part.**
`registry.ts` is only partly generated. Its `<os-generated …>` regions
are regenerated. But `registry.ts:18-38` says outright that each step's
`rationale` and `conversionIds` are **hand-written and merge as text**.
No gate turns red when a paragraph is dropped from them.

The merge was done on the branch with no rebase and no force-push. It is
three commits:

1. **`bde765bf05` merges `origin/main` at `67add1301a`.** It is a merge
commit with parents `7cc0ca1b3d` and `67add1301a`, and it resolved two
textual conflicts by hand.
- `step18.rationale` keeps objectstack-ai#19600's paragraph verbatim. Its last line is
re-terminated with a trailing space, and this PR's paragraph is appended
after it.
- `step18.conversionIds` keeps both
`'translation-per-app-settings-removed'` and
`'object-tenancy-organization-field-removed'`. That gives 33 ids, 33 of
them distinct.
- `packages/spec/src/conversions/registry.ts` keeps both D2 conversions
in `CONVERSIONS_BY_MAJOR[18]`, in landing order. The file's own rule is
「ordering within a major is application order」.
   - The generated regions were regenerated and never hand-merged.
2. **`9d5fb0ba5f` is regeneration only.** It regenerates the two
reference pages that `os-regen-merge.sh` had deferred.
3. **`3fb1a4994c` is a CONTENT change, not merge resolution.** The merge
brought in `check:future-spec-major` (objectstack-ai#19655), which landed after this
PR's old base, and CI went red on two sites. Under ADR-0087 (amended
2026-09-13), a tombstone names the npm release it ships in, never the
protocol major. This retirement ships `minor`, so it lands in 17.x. The
commit therefore changes the prescription at
`packages/spec/src/data/object.zod.ts:540` and its refusal pin at
`packages/spec/src/data/object.test.ts:1947` from `@objectstack/spec 18`
to `@objectstack/spec 17`. The protocol-major references (`toMajor: 18`,
`RETIRED_KEYS_BY_MAJOR[18]`, `os migrate meta --from 17`) are unchanged,
because the gate permits them.

**Measured by the dispatching seat against the committed trees, not
taken from the dev's narration:**

- The merge commit against the main parent `67add1301a`: 2 files,
+128/−1. Every hunk is this PR's.
- The merge commit against the branch parent `7cc0ca1b3d`: 2 files,
+656/−28. Every hunk is main's.
- On the merged `registry.ts`, each of the following appears exactly
once:
  - the shared closing line `dataset. '`
  - objectstack-ai#19600's paragraph
  - objectstack-ai#19600's last line, continuing with a trailing space
  - each of the two `conversionIds`
  - the retired-key row
- A dark control phrase appears 0 times.
- This PR's paragraph follows objectstack-ai#19600's.

⚠️ **The earlier contract review (`5765681233`) names head
`7cc0ca1b3d`.** Commit `3fb1a4994c` changes a string that review's AC2
pinned, so this head move is **not** regeneration-only, and the earlier
record does not govern the new head. `check-clause2-carriers.mjs --pair
19618` confirms it: exit 4, C6. A fresh contract review of the current
head is owed before landing.

## Verification

**Re-measured at the current head `3fb1a4994c`, after the base merge.**

- **CI**, measured by the seat from the head's check-runs: 35 checks,
latest run per name. 33 success, 2 skipped (`Console Pin Gate`,
`Packed-tarball smoke (opt-in)`), 0 failed. All five type-check lanes
pass. The legacy commit status is `success`.
- **Suites and gates**, from the os-dev report `5788876254`, which the
seat did not re-run:

  | package or gate | result |
  | --- | --- |
  | `@objectstack/spec` | 516 files, 15065 passed, 1 todo |
  | `@objectstack/metadata-core` | 16 files, 285 passed (unchanged) |
  | `@objectstack/plugin-audit` | 25 files, 363 passed (unchanged) |
  | typecheck, spec and metadata-core | exit 0 |
  | `check:generated` | 15 of 15 current |
  | `check:future-spec-major` | exit 0 |
| `dispatch-gates --ran` | 115 accounted: 112 run with exit 0, 3 NOT
MEASURED |

- The spec suite grew from the pre-merge 509 files / 14898 tests. The +7
files are exactly the seven spec test files `main` added in the merged
range.
- `check:future-spec-major` was checked against a lit control:
re-planting `18` makes it exit 1 with exactly one problem.
- The 3 NOT MEASURED gates were refused for build prerequisites (exit
3). They run in CI jobs that build first.

The tables below are the **pre-merge** readings, kept as history:

Every number in the tables below was taken at `7cc0ca1b3d`, the
pre-merge head.

**Reverse verification (both legs committed first, both restored
byte-identically, both via `scripts/ablation-replace.mjs`):**

| ablation | anchor → replacement landed | result |
| --- | --- | --- |
| the platform stamp row renamed (`sys_api_key` → `sys_api_key_ABLATED`)
| anchor 1 → 0, blob `0be02fdcc6b7` → `21856a4a20d0` | **4 of 19**
metadata-core tests RED; restore `blob == HEAD`, `git diff HEAD` empty |
| the prescription's first clause replaced with placeholder text |
anchor 1 → 0, blob `2e9e19825ef6` → `eea8b4c7f061` | refusal pin RED
with `expected 'Unrecognized key(s) on 'tenancy': 'or…' to contain
''tenancy.organizationField' was remov…'` — the pin measures the
PRESCRIPTION, not merely that parse throws; restore verified the same
way |

**Suites** (`pnpm test` per package, through the shared verify lock):

| package | result |
| --- | --- |
| `@objectstack/spec` | 509 files, 14898 passed, 1 todo |
| `@objectstack/metadata-core` | 16 files, 285 passed |
| `@objectstack/platform-objects` | 53 files, 848 passed |
| `@objectstack/plugin-security` | 117 files, 2249 passed |
| `@objectstack/plugin-audit` | 25 files, 363 passed |

**Typecheck:** `@objectstack/spec`, `@objectstack/metadata-core`,
`@objectstack/platform-objects`, `@objectstack/plugin-audit`,
`@objectstack/plugin-security` — all green, test layers included.

**Gates:** `node scripts/pm/dispatch-gates.mjs --ran` reconciles **114
derived / 114 run / 0 NOT-MEASURED / 0 UNRUN** against this diff. `pnpm
--filter @objectstack/spec check:generated` reports 15 of 15 artifacts
current. `pnpm lint` (`eslint . --no-inline-config`, the whole repo, no
narrowing) exits 0.

**The three sanctioned platform-row writers' pins stayed green
UNTOUCHED**, as the card required — `plugin-approvals` (`approval-node`,
`backfill-platform-row-organizations`), `service-automation`
(`suspended-run-store`), `service-storage`
(`backfill-sys-file-organizations`): 33 + 52 + 15 tests, zero edits. The
`driver-sql` and `trigger-schedule` read-neutrality suites are green
untouched too (36 + 61).

## Acceptance notes

**Declared widening of the dispatched file surface — three files, each
because this diff makes a statement in it FALSE.** None was edited for
tidiness; each is named with the measurement that forced it.

1. `packages/spec/src/shared/alias-integrity.test.ts` — RED. It pins the
exact key set of the folded `tenancy` guidance table: `expected [
'crossTenantAccess', …(2) ] to deeply equal [ 'crossTenantAccess',
'strategy' ]`. The retirement adds the third row, which is the only
channel the refusal travels on.
2. `packages/plugins/plugin-security/src/tenant-layer.test.ts` — RED. It
asserted the declaration off the shipped object: `expected undefined to
be 'active_organization_id'`. Rewritten to pin what this suite actually
owns: the stamp column exists as a field, `organization_id` does not,
and `tenancy` is exactly `{ enabled: false }`.
3. `packages/plugins/plugin-audit/src/audit-writers.test.ts` — RED, two
cases, and one of them is a **finding the card asked for**. See the next
section.

A fourth file,
`packages/spec/src/automation/schedule-organization.zod.ts`, carried a
docblock asserting "`tenancy.organizationField` wins there" — a
statement this diff falsifies, and one that **publishes**, into
`content/docs/references/automation/schedule-organization.mdx`.
Corrected in prose; the generated page follows.

**⭐ Finding — one sanctioned writer's pin DID have to be edited, and the
reason is not cosmetic.** Two `plugin-audit` cases went red:

- *"`organizationField` outranks `tenantField`"* pinned the precedence
on `crm_lead`, an object declaring BOTH keys, with the comment *"No
shipped object declares both; this pins the precedence so the day one
does is not a coin flip."* After the retirement no application can
declare a stamp column at all, so the question is **closed rather than
answered**. The case is rewritten to pin the closed set — an application
object carrying a lookalike column stamps from its own wall.
- *"control: without the declaration the credential table still stamps
the actor's org"* fed a `sys_api_key` schema with **no** `tenancy` block
and pinned the actor's org, proving the stamp came from the declaration
rather than from a column-name heuristic. Keying limb 0 by object name
makes that shape stamp `active_organization_id` instead. This is a
**real, deliberate behaviour change on a shape that is not reachable for
the shipped table** — `sys_api_key` is `managedBy: 'better-auth'` and
`protection: { lock: 'full' }`, so its block cannot be dropped. Recorded
in the rewritten case rather than smoothed over, and the `objectstack-ai#5315` guard
that did not move (column absent ⇒ fall through to the actor's org) is
pinned beside it.

**⭐ Finding — two issue citations this repo carries in these files do
not resolve.** `check-issue-citations --base origin/main` judged 12
citations this change adds and refused all 12: `objectstack-ai#8778` and `objectstack-ai#8707` are
`allocated-but-absent` (minted, ≤ frontier 19616, not on the board;
deleted vs transferred NOT MEASURED). Both are pre-existing text — the
diff only re-adds them by rewriting the docblocks around them. Following
the gate's own prescription, the added lines now name the rulings in
prose and cite the cloud record that does resolve. ⛔ No number was
guessed. The standing occurrences on unchanged lines elsewhere in the
tree are untouched and are not this PR's to repair.

**Stale-but-green fixture residue, deliberately NOT touched** (green
today, outside the dispatched surface, and not a defect — the fixtures
feed drivers and engine doubles, never `TenancyConfigSchema`):
`packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts`,
`packages/triggers/trigger-schedule/src/time-relative-trigger.test.ts`,
`packages/plugins/plugin-approvals/src/{approval-node,backfill-platform-row-organizations}.test.ts`,
`packages/services/service-automation/src/suspended-run-store.test.ts`,
`packages/services/service-storage/src/backfill-sys-file-organizations.test.ts`
still author `tenancy: { …, organizationField: … }` in raw
object-definition fixtures. Their assertions remain true; what has gone
vacuous is the *claim* that the driver / wall face is neutral about a
key nobody can write. `packages/lint/src/validate-object-field-refs.ts`
carries the key in a list of scalars it deliberately does not judge.

**No tree-scoped absence pin is added**, and that is a decision rather
than an omission: the playbook's tree-scoped form would have to declare
its radius in `scripts/cross-package-test-inputs.mjs` and `turbo.json`,
both far outside this card's surface, and it would go red against
exactly the six inert fixtures above. The absence is instead enforced
where it is cheap and exact — `authorable-surface/data.json` has no row,
and `check:authorable-surface` is the gate over that baseline.

## Clause-② re-judged from the diff

`no`, and the diff agrees. No hunk puts a new key on a published
payload: the guidance row is a prescription string, the
`RETIRED_KEYS_BY_MAJOR` / `CONVERSIONS_BY_MAJOR` entries are registry
rows, `json-schema/**` **loses** a key, and `api-surface/` is
byte-identical — `resolveRecordOrganizationField`'s signature is
unchanged. This is a pure retirement, which narrows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2

---
_Generated by [Claude
Code](https://claude.ai/code/session_01AmH9bKvGoLjiY86Q4Z3og2)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…only at both application doors (objectstack-ai#19620) (objectstack-ai#19945)

Fixes objectstack-ai#19620

Clause-②: no

## What is ruled, and what landed

Ruling-ref `5770445203` — batch objectstack-ai#210 item 2, letter **B**, maintainer
「210 同意」: `settings` leaves `TranslationItemSchema` together with the
singular alias `setting: 'settings'`; the item door refuses it at parse
with the platform-only prescription; the `settings` liveness row
retires; the D2 conversion `translation-per-app-settings-removed` learns
the item shape; the ADR-0087 semantic entry is extended. Step ① (the
production reading) was waived by the maintainer (records `5796717943`,
`5797554787`), so this PR runs step ② and then step ③, and the D2
item-shape conversion takes the **migrate** branch with a loud notice.
Nothing is folded into PR objectstack-ai#19600.

## Measured first: stored rows (dispatch assumption 3)

The question was whether teaching the conversion the item shape removes
a stored item's `settings` before the runtime reader merges it. **It
does not, and not for one reason but two.** Probes (scratch scripts, not
committed), read against this worktree:

```text
# at origin/main fdeeea0, spec dist built from it
[A] applyConversionsToStoredItem(translation, item-with-settings): settings survives = true ; notices = []
[B] readAuthoredTranslationLayer(raw row) -> layer[zh-CN].settings = {"mail":{"title":"我的邮件",...}}

# after the conversion learned the item shape (411513f), spec dist rebuilt
SINGULAR_TO_PLURAL.translation = undefined ; PLURAL_TO_SINGULAR.translations = undefined
[stored seam] settings survives = true notices = []
[chain over translations collection] settings survives = false notices = ["translation-per-app-settings-removed"]
```

1. `authored-translation-sync` reads `sys_metadata` itself and
deep-merged the RAW stored payload; it never called the conversion
chain.
2. Even the seams that DO call `applyConversionsToStoredItem` (the
metadata protocol's stored reads, `DatabaseLoader.rowToData`, `os
migrate meta --stored`) returned every `translation` row untouched: the
stored pass wraps a row in its stack collection, and the
manifest-collection maps carry no `translations` spelling. So no seam
had ever replayed ANY translation conversion over a stored row.

And the override is real: both i18n adapters read the runtime-authored
layer OVER the static bundles (`deepMerge(static, authored)` in
`packages/core/src/fallbacks/memory-i18n.ts` and
`packages/services/service-i18n/src/file-i18n-adapter.ts`), so a stored
item's `settings` beat the platform's own copy — the card's confidence
gap 2 is closed, and the core test below pins it end to end.

So the stored-row half lands in two places: the stored pass reaches
`translation` rows (spec, `conversions/stored.ts`), and the runtime sync
becomes a rehydration seam that calls it before merging (core,
`authored-translation-sync.ts` — the claim's conditional surface).

## Step ② — conversion, semantic entry, stored rows

- `packages/spec/src/conversions/registry.ts` —
`translation-per-app-settings-removed` walks the bare item shape too: an
entry carrying `locale`, or one with a declared translation group at its
top level (a row written before `locale` was required, which the sync
still reads by its name). Only the item's own top-level `settings` is
stripped; an object literally named `settings` under `objects` stays.
Surface, summary and docblock say both doors; the fixture gains the item
and a locale-less control.
-
`packages/spec/src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts`
— extended to both doors: the item OVERRODE the platform copy (a bundle
entry only filled gaps), so overridden keys go back to the platform
string and filled gaps to the manifest literal; `acceptanceCriteria` no
longer says the item is unchanged. `migrations/registry.ts` regenerated
with `gen:migration-registry`; the hand-written step-18 rationale
sentence that said the conversion never touches a `translation` item is
rewritten.
- `packages/spec/src/conversions/stored.ts` — `STORED_ONLY_COLLECTIONS`
maps a stored `translation` (and the legacy plural `translations`) row
to the `translations` collection. Kept out of the shared maps, which
`check:stack-collection-maps` holds to the stack schema.
- `packages/core/src/fallbacks/authored-translation-sync.ts` — each row
replays the full chain through `applyConversionsToStoredItem` before the
merge (the same policy as every other stored-read seam, PD objectstack-ai#12), and
each conversion is logged at `warn` once per row per wiring, naming the
row, the group (`'settings' → '(removed)'`), the conversion id, and `os
migrate meta --stored --apply`.
- Liveness: the `settings` row of
`packages/spec/liveness/translation.json` is deleted (strict-delete
route: the key left the walked shape, a surviving row would be an
ORPHAN). Its `_note` and the README's `translation` row say what the
deletion does NOT mean: the row's `live` evidence read the SERVED tree,
which the platform bundle feeds, so the platform capability is
untouched. `check:liveness` then named `translation/settings` a stale
row of the shrink-only `undrilled-containers.baseline.json`; it is
deleted. `state-counts.md` regenerated.

## Step ③ — the schema, the alias, the pins

- `packages/spec/src/system/translation.zod.ts` —
`TranslationItemSchema` spreads `appTranslationDataShape()` only (the
per-app face, ten groups); `setting: 'settings'` leaves its alias table;
`settings` and `setting` are answered by `ITEM_TRANSLATION_KEY_GUIDANCE`
with the item's own `ITEM_SETTINGS_PLATFORM_ONLY`, because the bundle
door's sentence ("the platform overwrote it anyway") is false for an
item. `settingsCommon` stays on both faces.
- `packages/spec/authorable-surface/system.json` —
`system/TranslationItem:settings` deleted deliberately (the check (a)
tripwire the strict-delete route owes). The build's check (c)
adjudicated it by proof 4:

```text
1 baseline deletion(s) since fdeeea0 carry their own proof (objectstack-ai#4650):
  - system/TranslationItem:settings — def reachable from the metadata-type roots; writing 'settings' on it is REFUSED as an unrecognized key
    and the refusal carries the prescription its `strictObject` declaration owes it ...
```

- Pins flipped (`packages/spec/src/system/translation.test.ts`): "still
accepts every declared group together" now asserts the item refuses
exactly one key, `[['unrecognized_keys', ['settings']]]`; "still accepts
it on the platform face" keeps the platform assertions and drops the
item one; a new block refuses `settings` and `setting` on the item
(issue path, keys, `PLATFORM group`, `PlatformTranslationData`, no
rename suggestion), refuses through `defineTranslation`, with a control.
- Door-level pin
(`packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts`,
section 4): saving a `translation` item carrying `settings` / `setting`
answers `code: 'INVALID_METADATA'`, `status: 422`, the issue names the
key and says `PLATFORM group`, and nothing is stored; a control saves
the same item without it. It rides that file's already-pinned engine
double, so the engine-double ledger does not move.
- Stored-row pins: `packages/spec/src/conversions/stored.test.ts` (both
row spellings drop `settings` with exactly one notice; a canonical row
passes through by reference) and the new
`packages/core/src/fallbacks/authored-translation-sync.test.ts` (dropped
before the merge, rest of the item kept, one warning naming
row/group/conversion, once per wiring, canonical-row control, and end to
end over `createMemoryI18n`: the platform's `邮件投递` renders, not the
stored override).
- Published prose made false by this change:
`content/docs/ui/translations.mdx` (said the item still declares it),
`content/docs/protocol/kernel/i18n-standard.mdx` (adds the item door),
`docs/qa/platform-checklist/areas/i18n.json` (anchor prose);
`content/docs/references/system/translation.mdx` regenerated with
`gen:docs`.
- `.changeset/19620-translation-item-settings-platform-only.md` —
`@objectstack/spec` and `@objectstack/core` `minor` (launch-window
convention), BREAKING banner, FROM → TO table, one-line fix, the
stored-row behaviour, ADR-0087 disposition `not-required
(already-registered …)` because both entries existed and are extended
here.

## PR objectstack-ai#19600's acceptance note is superseded

PR objectstack-ai#19600 (merged) records "`TranslationItemSchema` is UNCHANGED and
still declares `settings`" and, as its first acceptance note, "The
`translation` metadata-type door is untouched and still accepts
`settings`." **Both are superseded by this PR**: the item door refuses
`settings` with the platform-only prescription, and rows stored before
are converted at every stored seam. The seat carries this sentence to
objectstack-ai#19600 as a comment.

## ⚠️ One red gate by design — a pending release note corrected,
confirmation requested

`.changeset/15178-translation-bundle-split-settings-platform-only.md` is
unreleased and its "Unchanged" section said the registered `translation`
item still declares `settings` — false once this PR lands in the same
release. It is **corrected, not restored** (one sentence: not changed by
THAT entry, superseded in the same release by this PR's changeset).
`node scripts/check-empty-changeset.mjs --base origin/main` therefore
exits 1 in its DELIBERATE CORRECTION class, whose own text says the
remedy is to say so on the PR and get it confirmed. **Please confirm
this correction**; the alternative, restoring the file from base,
republishes the false sentence. No `skip-changeset` is involved.

## Declared file-surface deviations

The claim declared `translation.zod.ts` + tests,
`liveness/translation.json`, the conversion + semantic entry +
registries, regenerated artefacts, `.changeset/`, and conditionally
`authored-translation-sync.ts` (taken: measured necessary above).
Outside it, each forced rather than chosen:

1. `packages/spec/src/conversions/stored.ts` + `stored.test.ts` — the
stored pass returned `translation` rows untouched (measured above);
without it the migrate branch the ruling orders reaches no stored row.
Same defect class, a one-entry map; no open PR on it was checked (not
measured — ordinary concurrency).
2.
`packages/metadata-protocol/src/protocol.invalid-metadata-422-face-inventory.test.ts`
— the ruling's `code` + `status` pin lives at the metadata door, not in
the schema package.
3. `packages/spec/scripts/liveness/undrilled-containers.baseline.json` —
the shrink-only row `check:liveness` named stale once the key left the
shape.
4. `content/docs/ui/translations.mdx`,
`content/docs/protocol/kernel/i18n-standard.mdx`,
`docs/qa/platform-checklist/areas/i18n.json`,
`packages/spec/liveness/README.md` — published claims this change makes
false.
5. `.changeset/15178-…` — the correction above.

## Verification

Commits `411513f09` (step ②), `7ba3d25d9` (step ③), `d94e300e4`
(regenerated artefacts), `889861a04` (stored pass, door pins,
changeset), `b0f2b0ef0` (the changeset's ADR-0087 marker line only).

**Tests** (targeted, through `scripts/pm/os-verify-lock.sh`; run at
`889861a04` — `b0f2b0ef0` changes only the changeset marker, which no
test reads; the 422 file re-run at `b0f2b0ef0`):

| Package | Scope | Result |
| --- | --- | --- |
| `@objectstack/spec` | `translation`, `i18n-resolver`, `conversions/*`,
`migrations/*`, `retired-key-migrate-sentence`, `alias-integrity`,
`type-alias-convention.pin`, `metadata-plugin` | 14 files / 912 tests
pass |
| `@objectstack/core` | `fallbacks/authored-translation-sync.test.ts`
(new), `fallbacks/fallbacks.test.ts` | 2 files / 66 tests pass |
| `@objectstack/metadata-protocol` | whole package (dependency closure
built first) | 188 files pass, 3 skipped / 2676 tests pass, 19 skipped |
| `@objectstack/metadata-protocol` | the 422 file, verbose, at
`b0f2b0ef0` | 11 / 11, the three new cases named |
| `@objectstack/service-i18n` | whole package, against the rebuilt core
`dist` | 5 files / 74 tests pass |

**Typecheck:** `@objectstack/spec` (`tsc --noEmit` + scripts + test
layer), `@objectstack/core`, `@objectstack/metadata-protocol` — all exit
0.

**Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` derived 116 commands on the actual diff (21
paths vs merge base `fdeeea0cc`); all 116 run at `b0f2b0ef0` with the
exit code recorded, and `--ran` reconciles: **116 derived, 116 run, 0
NOT-MEASURED, 0 UNRUN**. 115 exit 0; the one exit 1 is
`check-empty-changeset` (above). The whole workspace was built first
(`turbo run build --filter='./packages/*' --filter='./packages/*/*'`, 72
tasks) so the five built-output gates (`check:skill-examples`,
`check:dual-build-cjs-loads`, `check:i18n-walk-parity`,
`check:lean-entry-closure`, `check:type-check-debt`) measured instead of
exiting 3. `check:type-check-debt`: "4 ledger entr(ies) re-measured, 53
raw tsc error(s) total, none above its recorded number."

**Lint, narrowed and proven:** ESLint over the 10 changed `.ts` files,
`--no-inline-config --format json`: 10 files linted, 0 errors, 0
warnings. Population: the config's own globs
(`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` minus `NEVER_LINTED`) take all
10 of the diff's script files. Invariance: `eslint.config.mjs` never
enables type-aware linting (its own comment: "no
`parserOptions.project`, no typed `@typescript-eslint` rules"), so this
diff cannot move a verdict on an untouched file. The repo-wide `pnpm
lint` is CI's.

**Ablations** (each through `scripts/ablation-replace.mjs`, after the
fix was committed; anchor hit 1 → 0 and a changed blob proved the
mutation on disk; each restore proved blob == HEAD and `git diff HEAD`
empty; the tests read `src/` by relative import, so no `dist/` was
involved). Direction predicted before running:

| Mutation | Predicted | Observed |
| --- | --- | --- |
| A — `...platformSettingsShape()` back on the item shape | the
`settings` pins red; the singular `setting` pin stays green (its
guidance still refuses it) | 3 red (declared-groups, `settings` refusal,
`defineTranslation`); `setting` green |
| B — stored pass without `STORED_ONLY_COLLECTIONS` | both stored-row
cases red, control green | 2 red, control green |
| C — sync merges without the chain replay | 4 core cases red, canonical
control green | 4 red, 1 green |
| D — conversion's item branch returns the entry | fixture replay +
stored cases red | 4 red across `conversions`, `stored`, `migrations` |

**Reverse verification of the rebuilt `.d.ts`:** a probe file in
`packages/core/src` typed `const rejected: TranslationItem = { locale:
'en', settings: … }` beside an `apps` control; `tsc --noEmit -p
packages/core` answered `TS2353 … 'settings' does not exist in type …`
on the `settings` line only; the probe was removed by a trap and the
tree read clean.

## Acceptance notes

- **Same-class correction riding the stored-pass change.** The docblocks
of `translation-validation-messages-removed` and
`translation-component-submit-label-removed` already claimed stored
`translation` rows replay through them; until this PR none did. Both
strip keys no resolver reads, so the only observable change for them is
a one-time stored-row warning when an old row is read.
- **What an operator sees.** Reading an old row that still carries
`settings` through the metadata API now serves it without the group and
logs the protocol's stored-row warning; the runtime sync logs its own
warning once. A Studio re-save or `os migrate meta --stored --apply`
persists the canonical row.
- **`setting` (singular) is not converted.** An alias only ever
suggested a rename in the rejection; the item door never accepted
`setting`, so no stored row can carry it.
- **The sync's warning is conversion-agnostic.** It names the row, the
dropped group and the conversion; the platform-only reason is carried by
the item-door refusal and by the D3 semantic entry (which `os migrate
meta --from 17` reports as a semantic TODO, per that command's own
docblock — not run here), not restated per conversion in the consumer.
- **Pinned sibling (objectui `62597c588`)** — `TranslationPreview.tsx`
still lists a `settings` group and `clientValidation.ts` prose counts
"19 keys". Neither breaks: the binding imports `TranslationItemSchema`
itself and its parity test compares the schema with itself; the preview
group simply never renders now. Stale prose / dead UI in the sibling;
carrier: none; not filed.
- Size: 21 files, +714 / −173 (887 changed lines), under the 5,000-line
human-merge threshold. No governed surface is touched.

---

_Generated by [Claude
Code](https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
objectstack-ai#19932)

Fixes objectstack-ai#19665
Clause-②: no

## What this changes

`packages/spec/src/type-alias-convention.pin.test.ts` keyed its
isomorphic pins on a dense, hand-kept counter (`Iso0` … `Iso881`). Two
branches off one base each took "the next free number" for a different
schema, at insertion points far apart, and git merged them cleanly into
two declarations of one name: `Iso871` once already, then `Iso877` /
`Iso878` on objectstack-ai#19600, where only `check:test-typecheck` caught it.

Each pin is now named for the pair that is already unique to it, its
module path and its schema export name:

- The name is `Iso_`, then the module path below `packages/spec/src`
without `.zod.ts`, with each kebab segment camelCased and the segments
joined by `_` (so `ai/knowledge-document.zod.ts` becomes
`ai_knowledgeDocument`), then `__`, then the schema export name. For
example: `Iso_shared_epoch__EpochMs`.
- The block is sorted by that name in code-unit order (the order
`LC_ALL=C sort` gives), with one heading per module. Each note about a
module's pins sits under that module's heading and names the pins it
covers. The schema alone now decides both a new pin's name and where it
goes.

There are four commits:

1. `ece9f71d2c`: the mechanical half. It is the transform below, run on
`fdeeea0cc9`, byte for byte. That includes the runtime companion's pin
counter, whose regex now matches the name family (`Iso` followed by word
characters) instead of `Iso` followed by digits.
2. `fc8eda2d62`: prose only.
- The naming rule and the retired counter are written at the head of the
list.
- The two cohort blocks (phase 2 and the objectstack-ai#4593 backfill) are filed there
too.
- Notes that pointed at a pin by its position or its numeral now name
the pin.
   - "Positional and stay vacant" is removed.
   - The count history gets a `789 -> 789` entry.
3. `ea78da8908`: a merge of `main` at `2c1011b01b`, to re-measure this
head on current `main`. `main` has not touched the pin file since the
merge base: its blob is `5620656d04` on both `fdeeea0cc9` and
`2c1011b01b`. So the merge leaves the file exactly as `fc8eda2d62` had
it, and the re-key was not re-run.
4. `4a724cfbf3`: `packages/spec/src/shared/duration.test.ts:11` cited
the `EpochMs` pin as `Iso868`. It now cites `Iso_shared_epoch__EpochMs`.
This commit changes comment text only.

The diff against `main` is two files: the pin file, and that one comment
line in `duration.test.ts`.

No assertion is added and none is weakened. The exemption set is
unchanged, and nothing is regenerated from a corpus measurement.
`scripts/check-spec-parsed-alias.mjs` is untouched, because its reader
never reads the numeral.

## Acceptance: the assertion set is identical, member for member

Both sides are read with the gate's own `readIsomorphicPins`: before is
`fdeeea0cc9` and after is `fc8eda2d62`.

| reading | before | after |
|---|---|---|
| `readIsomorphicPins` entries | 789 | 789 |
| pin declarations | 789 | 789 |
| set difference, either direction | | **0** |
| pin bodies changed (the text right of ` = `, keyed on
`path.ts::Schema`) | | **0** |
| names off the rule / duplicate names | | 0 / 0 |
| pin names in sorted order | no | yes |

Controls, to show the instrument can fire:
- Deleting one pin line gives a set difference of 1.
- Swapping one pin's `z.infer` operand to another schema keeps the set
at 789 and reads 1 body changed.

The module series is untouched: there are 174 `import type * as M…`
lines before and after, and no import line is in the diff.

## The collision is gone: merge probe with a firing control

The probe ran `git merge-tree --write-tree` in a throwaway `git clone
--bare --shared` with no merge driver registered. The clone was removed
afterwards. In each case, two branches off one base each add one pin for
a different schema:

| scheme and base | the two insertions | merge-tree | duplicate pin
names |
|---|---|---|---|
| OLD, `fdeeea0cc9`: both take `Iso882`, one in `api/analytics`, one in
`ui/view` | 1259 lines apart | exit 0, clean, 0 markers | **`Iso882`**
(the objectstack-ai#19600 shape) |
| NEW, `fc8eda2d62`: `Iso_api_analytics__ProbeAlphaSchema` and
`Iso_ui_view__ProbeBetaSchema`, each at its sorted place | 1174 lines
apart | exit 0, clean, 0 markers | none, and the merged names are still
sorted |
| NEW, same module, with an existing pin between the two names | 6 lines
apart | exit 0, clean | none |
| NEW, same module, with the two names sort-adjacent | same line | exit
1, CONFLICT | none |

The last row is the case that remains. Two new names with no existing
pin between them insert at the same place, and git stops with a
conflict; the resolution is to keep both lines. That failure is loud,
and it cannot merge into a duplicate.

The module series (`M…`) is **left untouched, and it is not a measured
collision**: no one has yet seen two PRs that each import a new module.
I ran one simulation of its mechanism only. Two branches that each
append `import type * as M189` after `M188` conflict at the tail of the
import block (merge-tree exit 1). Keeping both lines when resolving
would give tsc a duplicate `M189`. So in this simulation its failure
mode is a conflict, not a silent clean merge.

## Landing order: this PR lands first, then objectstack-ai#19809 re-runs the re-key on
its own pin

On the maintainer's instruction, this PR lands before objectstack-ai#19809. objectstack-ai#19809
also edits this file: it re-pins `KanbanConfigSchema` as `Iso882` and
raises the count to 790. After this PR has landed, objectstack-ai#19809 does this on
its own branch:

1. Merge `main`. The pin file conflicts.
2. Take objectstack-ai#19809's own copy of the file, still on the old names, as `F`:
run `git show H:packages/spec/src/type-alias-convention.pin.test.ts >
F`, where `H` is objectstack-ai#19809's head before the merge. Then run the transform
below on `F`. It renames and sorts every pin, including the new one,
which becomes `Iso_ui_view__KanbanConfigSchema`.
- This step is exact while objectstack-ai#19809's pin set is `main`'s set plus its own
pin. At `ff13eece89` it is: 790 pins, which are `main`'s 789 plus
`ui/view.zod.ts::KanbanConfigSchema`.
3. Re-apply this PR's prose half as a three-way file merge: `git
merge-file -p F BASE1 HEAD1 > out`. `BASE1` is this file at
`ece9f71d2c`. `HEAD1` is this file at `fc8eda2d62`, which is the same
bytes `main` holds once this PR lands.
4. By hand:
- In the one conflicting hunk, at the tail of the count history, keep
both entries: this PR's `789 -> 789` entry first, then objectstack-ai#19809's entry
restated as `789 -> 790`, with its `toHaveLength(790)`. In that entry,
name the pin `Iso_ui_view__KanbanConfigSchema` instead of `Iso882`.
- In objectstack-ai#19809's `ui/view` note, name the pin
`Iso_ui_view__KanbanConfigSchema` instead of `Iso882`, and drop "Iso829
stays vacant".
5. Check acceptance: compare `readIsomorphicPins` on `F` before the
transform with the result. The sets must be equal, the bodies unchanged,
the names on the rule, and the block sorted.

I dry-ran steps 2 and 3 against objectstack-ai#19809's head `ff13eece89`: 790 = 790,
set difference 0 both ways, 0 bodies changed, 0 names off the rule,
sorted, and `Iso_ui_view__KanbanConfigSchema` declared once. The prose
merge conflicts at that one hunk only.

The transform is `node rekey.mjs FILE`. It rewrites the file in place
and is idempotent: running it on `fc8eda2d62` changes nothing. It is
written without a less-than character so this body keeps it intact.
Running exactly this text on `fdeeea0cc9` reproduces `ece9f71d2c` byte
for byte.

```js
// rekey.mjs FILE: re-key and sort the isomorphic pin block of
// packages/spec/src/type-alias-convention.pin.test.ts, in place. Idempotent.
// Refuses (exit 1) on any line it cannot place. Spelled without a less-than
// character so it survives a GitHub body intact: LT stands for one.
import { readFileSync, writeFileSync } from 'node:fs';

const LT = String.fromCharCode(60);
const file = process.argv[2];
const L = readFileSync(file, 'utf8').split('\n');
const die = (m) => { console.error(`rekey: ${m}`); process.exit(1); };

// Module alias -> module path, read the way the gate reads it.
const mods = new Map();
for (const l of L) {
  const m = l.match(/^import type \* as (M\d+) from '\.\/(.+?)\.js';$/);
  if (m) mods.set(m[1], m[2]);
}

// Stable name: path below packages/spec/src minus `.zod`, kebab segments
// camelCased, joined by `_`; then `__`; then the schema export name.
const keyOf = (path) => {
  if (!path.endsWith('.zod')) die(`module ${path} is not a .zod module`);
  return path.slice(0, -'.zod'.length).split('/').map((s) => {
    if (!/^[a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)*$/.test(s)) die(`segment "${s}" of ${path} is not lowercase kebab`);
    return s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
  }).join('_');
};

// Region: after the rule closing the "N isomorphic aliases" banner, up to the
// rule opening "Representative spot-checks".
const bannerAt = L.findIndex((l) => /^\/\/ \d+ isomorphic aliases:/.test(l));
if (bannerAt === -1) die('count banner not found');
const start = L.findIndex((l, i) => i > bannerAt && l.startsWith('// ----')) + 1;
const spotAt = L.findIndex((l) => l === '// Representative spot-checks on the phase-2 FLIP.');
if (start === 0 || spotAt === -1 || !L[spotAt - 1].startsWith('// ----')) die('region bounds not found');
const end = spotAt - 1;

const HEADING = /^\/\/ (?:\[#\d+\] )?([a-z0-9-]+(?:\/[a-z0-9-]+)*)\.zod\.ts(?:$|[ ,;:(—-])/;
const PIN = new RegExp(`^export type (Iso\\w+) = (Assert${LT}Eq${LT} z\\.input${LT} typeof (M\\d+)\\.(\\w+) >, z\\.infer${LT} typeof \\3\\.\\4 > >>;)$`);
const preamble = [];
const sections = new Map(); // key -> { heading, notes[], pins: Map(name -> line) }
const names = new Set();
let cur = null;
for (let i = start; i !== end; i++) {
  const l = L[i];
  if (l === '') continue;
  if (l.startsWith('// ----')) { // an inner banner moves, verbatim, to the preamble
    const endRule = L.findIndex((x, j) => j > i && x.startsWith('// ----'));
    if (endRule === -1 || endRule >= end) die(`unclosed banner at line ${i + 1}`);
    if (preamble.length) preamble.push('//');
    preamble.push(...L.slice(i + 1, endRule));
    i = endRule;
    continue;
  }
  const h = l.match(HEADING);
  if (h) {
    const key = keyOf(`${h[1]}.zod`);
    if (!sections.has(key)) sections.set(key, { heading: l, notes: [], pins: new Map() });
    else if (l !== `// ${h[1]}.zod.ts`) die(`second heading for ${h[1]} carries text (line ${i + 1})`);
    cur = sections.get(key);
    continue;
  }
  const p = l.match(PIN);
  if (p) {
    const path = mods.get(p[3]);
    if (!path) die(`line ${i + 1}: ${p[3]} imports no module`);
    const key = keyOf(path);
    if (!cur || cur !== sections.get(key)) {
      console.error(`rekey: line ${i + 1} pins ${path}.ts outside its section; moved there`);
      if (!sections.has(key)) sections.set(key, { heading: `// ${path}.ts`, notes: [], pins: new Map() });
    }
    const name = `Iso_${key}__${p[4]}`;
    if (names.has(name)) die(`duplicate pin ${name} (line ${i + 1})`);
    names.add(name);
    sections.get(key).pins.set(name, `export type ${name} = ${p[2]}`);
    continue;
  }
  if (l.startsWith('//')) {
    if (!cur) preamble.push(l);
    else cur.notes.push(l);
    continue;
  }
  die(`line ${i + 1} is neither a heading, a pin, a comment nor blank: ${l.slice(0, 80)}`);
}

// Code-unit order; every name is ASCII, so byte order is the same order.
const byCodeUnit = (a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b));
const out = [''];
if (preamble.length) out.push(...preamble, '');
for (const key of [...sections.keys()].sort((a, b) => byCodeUnit(`Iso_${a}__`, `Iso_${b}__`))) {
  const s = sections.get(key);
  out.push(s.heading, ...s.notes, ...[...s.pins.keys()].sort(byCodeUnit).map((n) => s.pins.get(n)), '');
}

const next = [...L.slice(0, start), ...out, ...L.slice(end)];
// The runtime companion counts pins by declaration: the name family, not a numeral.
const text = next.join('\n').replace(
  `self.match(/^export type Iso\\d+ = Assert${LT}/gm)`,
  `self.match(/^export type Iso\\w+ = Assert${LT}/gm)`,
);
writeFileSync(file, text);
console.log(`rekey: ${names.size} pins in ${sections.size} module sections`);
```

## Verification

Round 2, on `4a724cfbf3`, after the merge of `main` at `2c1011b01b`:

- The pin file is byte-identical to `fc8eda2d62`'s (blob `8ae4405bd5`).
`readIsomorphicPins` on `main`'s file and on this head's file reads 789
= 789, with a set difference of 0 both ways. Control: dropping one pin
line reads 788, difference 1.
- `pnpm --filter @objectstack/spec check:test-typecheck`: OK (53 files /
255 errors / 142 signatures held).
- `node scripts/check-spec-parsed-alias.mjs`: "1459 bare z.input
aliases, 789 pinned isomorphic, 670 paired with an XParsed. OK". Its
`--self-test`: 18 assertions passed.
- `pnpm --filter @objectstack/spec run typecheck`: exit 0.
- `vitest run --project local` on
`src/type-alias-convention.pin.test.ts` and
`src/shared/duration.test.ts`: 2 files, 14 tests passed. The whole spec
`local` project: 530 files, 15617 passed, 1 todo.
- `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
--commands` derived 77 commands, and all 77 were run. The `--ran`
reconciliation reads 77 run, 0 NOT MEASURED, 0 unrun. Four gates first
exited 3 for unbuilt prerequisites (`check:doc-formula-expressions`,
`check:lean-entry-closure`, `check:dual-build-cjs-loads`,
`check:type-check-debt`), and each exited 0 after its build.

Round 1, on `fc8eda2d62`. The pin file's bytes have not changed since,
so these still describe it:

- Non-vacuity, from the committed state: `node
scripts/ablation-replace.mjs` renamed `Iso_ui_view__TreeConfigSchema` to
the existing name `Iso_ui_view__RowHeightSchema`, and
`check:test-typecheck` turned red ("2 type error(s) in a file the ledger
does not cover"). The file was then restored: blob == HEAD and `git diff
HEAD` is empty.
- The set identity against `fdeeea0cc9` and the merge probe are in the
sections above.

## Changeset

None. The file is a test. `@objectstack/spec`'s `files` ships
`src/**/*.zod.ts` and `dist` but never `*.test.ts`, so nothing published
changes. `check-empty-changeset.mjs` refuses a new empty-frontmatter
changeset, so the repo's disposition for this diff is the
`skip-changeset` label. The seat applied that label after the PR opened.
The one red `Check Changeset` run on `fc8eda2d62` came from the `opened`
event, before the label; on `4a724cfbf3` that check is `skipped`.

## Acceptance notes

- On the base, two `api/errors.zod.ts` pins (`FieldErrorCode` and
`FieldErrorSchema`) sat under the `api/error-code-ledger.zod.ts`
heading. The sorted layout files them under their own module.
- `packages/spec/src/shared/duration.test.ts:11` cited this file's
`Iso868` in the present tense. That pin is `EpochMs`, now
`Iso_shared_epoch__EpochMs`, and `4a724cfbf3` updates the citation. The
other `Iso`-plus-digits hits under `packages/spec/src`, outside this
file, are `ui/component.zod.ts` lines 2052, 2523 and 2929 and
`ui/i18n.zod.ts` line 198. They name `Iso818`, `Iso819`, `Iso839` and
`Iso759`, pins that were deleted before this PR, so they are history and
stay as they are.
- A check that the block stays sorted, and that each name matches its
derivation, would be a new assertion, so none is added. The candidate,
for the maintainer: in the runtime companion, assert that the `Iso`
names are in code-unit order and that each one equals the rule applied
to its `Mn` path and schema.

---
_Generated by [Claude
Code](https://claude.ai/code/session_013RDBh5DqXd2xnLwvHLgLFr)_



---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/xl tests tooling

Projects

None yet

3 participants