feat(spec)!: split the translation bundle type — settings is a platform group, not a per-app one (#15178) - #19600
Conversation
…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>
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDXER3sdqfeVYpEWZs5mZx Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 12 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 138 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…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>
Blockers 1 and 2 are CLEARED. Only blocker 3 remains, and it is the one no seat may clear.
Head is now
CI, read by job conclusions on this head — latest run per check NAME, ⛔ never an aggregate roll-up35 names — 33 Blocker 3, re-measured in this act
1 of 28 paths hits the register: ⚖️ Landing tier H(人合). Two routes, and ⛔ this seat may take neither on its own:
⛔ 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 doneThe dispatch warned that ⇒ ⛔ never read State⛔ PR stays draft. Card #15178 stays Generated by Claude Code |
|
Supersession note for this PR's acceptance note 「 PR #19945 (#19620) narrows the item door too:
Generated by Claude Code |
…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>
…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>
…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>
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>
Fixes #15178
Clause-②: yes
What is ruled, and what landed
Maintainer ruling batch #132 item 2 letter ② (comment 5653315643, 「同意」 2026-09-13), quoted verbatim:
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.
TranslationDataSchemakeeps its name and becomes the per-app bundle entry (ten groups); the newPlatformTranslationDataSchema/PlatformTranslationBundleSchema(typesPlatformTranslationData/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, asObjectTranslationDataSchemaalready does — and the direction was chosen on a measurement, not on taste:TranslationDataSchema", and the gate's ledger reason says "removal from the per-app schema" about that same export.TranslationData:stack.translations,defineTranslationBundle, the three examples, the CLI walker and coverage reader, and the headeros i18n extractemits into every scaffolded bundle. Putting the narrowing on a NEW name would have left all of them acceptingsettings, and re-pointing them would have re-typed the nine platform*.generated.tsfiles the same emitter writes.settingsat 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
settingsas inert. Measured onorigin/mainat1ff3a8f210, it was live:AppPlugin.loadTranslations(packages/runtime/src/app-plugin.ts) hands eachstack.translationsbundle entry WHOLE toII18nService.loadTranslations.FileI18nAdapter.loadTranslationsdeep-merges it into the one per-locale tree;getTranslations(locale)serves that tree.kernel:ready—SettingsServicePlugindoes exactly this withsettingsBuiltinTranslations.pickSettingsEntryreadspickData(bundle, locale)?.settings, and the console'suseSettingsLabelscans every namespace carrying asettingsbranch. The tracked liveness ledgerpackages/spec/liveness/translation.jsonrecords that reader with its evidence pointer and says both doors "merge into ONE tree".So an app-authored
settingsdid not sit unread — but nor did it override the platform. The app's bundles load inAppPlugin'sstart()(kernel Phase 2) and the platform's atkernel:ready(Phase 3), anddeepMergegives 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()becomesappTranslationDataShape()(ten groups) and thesettingsgroup moves toplatformSettingsShape().TranslationDataSchema= per-app, strict, with aguidanceprescription for bothsettingsand the singularsetting; thesettingalias is deleted, because an alias prescribing a key the shape now rejects is a suggestion the author cannot take.PlatformTranslationDataSchema= the ten plussettings.TranslationBundleSchemais per-app;PlatformTranslationBundleSchemais new.TranslationItemSchemais UNCHANGED and still declaressettings.src/api/protocol.zod.ts—GetTranslationsResponseSchema.translationsmoves to the platform face. The served document is the merge of every loaded bundle, so it carriessettings; leaving it on the per-app face would have published a declaration the server contradicts.src/system/i18n-resolver.ts—pickDatabecomes generic over the entry type, and the settings resolvers takePlatformTranslationBundle. 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 D2translation-per-app-settings-removed(toMajor: 18,retiredFromLoadPath: true), strips the group from per-app bundle ENTRIES only. An entry carryinglocaleis atranslationITEM and is left whole; the candidate value must additionally be a dict whose every key is a declared group, so anobjectsrecord holding an object literally namedsettingsis not mistaken for a bundle.src/migrations/entries/semantic/18.translation-per-app-settings-platform-only.ts— the ADR-0087 semantic entry, plus the regeneratedregistry.tsand the extended step-18 rationale. Regenerated withgen: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:settingsdeleted 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." Elevensystem/PlatformTranslationData:*keys arrived in the same run.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: thesettingsledger row is gone,LEDGER_CEILING3 → 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}.tsand theirindex.tsmove toPlatformTranslationData/PlatformTranslationBundle. They are the only bundles in the repo that authorsettings.Published prose —
content/docs/protocol/kernel/i18n-standard.mdxpublished the eleven-group list as "rejected by name at both authoring doors", andskills/objectstack-i18n/SKILL.mdpublished the same list to customer projects. A third published carrier,content/docs/ui/translations.mdx, taughtsettingsas 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.jsonhad a symbol anchor on the renamed shape function and a clause naming the wrong face.Verification
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.mdwas last touched at2602ccec10, earlier than5283099838, and every figure in it reproduces at head (494→496 lines, 4713→4752 tokens, ceiling 6338, headroom 1586).check:i18n-walk-parity10 declared group(s), 8 walked, 2 exempted10is 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-testbattery is 43 cases.check:authorable-surface(insidegen:schema)check:generated--fixregenerated the 5 it proved stale, each re-checkedcheck:adr-0087-registration1 declared-breaking changeset(s), each carrying an ADR-0087 disposition0 non-breaking changeset(s) seenbefore the changeset was committed, which is the control leg.check-changeset-no-majorno major bumpLEVEL AXIS: NOT APPLICABLE— there is no PR payload on a local run, so it can fail HERE only on themajoraxis, not on the declaration.check:spec-parsed-alias1455 bare aliases, 786 pinned isomorphic, 669 pairedcheck:type-check-debt4 ledger entr(ies) re-measured, 53 raw tsc errors, none above its recorded numberpnpm lint(eslint . --no-inline-config)Gate families:
scripts/pm/dispatch-gates.mjsderived 139 for this change set;--ranwith exit codes recorded reconciles 139 accounted, 138 run, 0 UNRUN, 1 NOT MEASURED. The one ischeck: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/spec508 files / 14,902 tests,@objectstack/lint106 files,@objectstack/service-settings33 files,@objectstack/platform-objects51 files, the three examples 41 files,@objectstack/cliunit tier 222 files / 3,141 tests — all pass.turbo run typecheckover 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,
settingswas put back on the per-app shape throughscripts/ablation-replace.mjs— the mutation is proved on disk (anchor 1 → 0, blob3a27c26f6a5c→a0b6be68af3e) — and the two refusal pins went RED by name. Restored with--restore: blob back to3a27c26f6a5c, equal to HEAD, andgit diff HEADempty. 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.
skills/objectstack-i18n/SKILL.md, linesskills/objectstack-i18n/SKILL.md, tokensSKILL.mdlinesBefore-tokens were measured by restoring the base file, reading
check-skills-token-ratchet, and restoring with proof (blob equal to HEAD,git diff HEADempty).check-skills-token-ratchetpasses: 34 authored files within their ceilings.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 splitskills/objectstack-i18n/SKILL.mdoff 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:packages/spec/src/api/protocol.zod.ts— the served response must type against the platform face or it declares a shape the server contradicts.cursorand computeshasMore#19493's sibling declaration set — specifically it was declared disjoint against [finding] three sibling list doors declarelimit/cursorand never read them, one reportinghasMore: falseas 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.packages/spec/src/system/i18n-resolver.ts—pickSettingsEntryreads.settings; without this the package does not typecheck. The parameter is widened, not narrowed.packages/services/service-settings/src/translations/*(5 files) — the only bundles that authorsettings; type annotations only.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.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-checklistwent red on it and is green again).content/docs/ui/translations.mdxwas added to this enumeration after the fact: it was edited in commitb1e7984040and an earlier draft of this item listed only three paths, under-declaring the deviation by one.packages/spec/src/migrations/registry.tsis the declared overlap with open PR #19493. Its generated regions were regenerated withscripts/pm/os-regen-merge.sh's generator (gen:migration-registry) and never hand-merged.registry.ts:47carries a value import ofTranslationDataSchema, outside every<os-generated …>region (the first region opens at:1142). It is necessary, not an oversight —build-migration-registry.ts'sparseEntrydeliberately 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 becauserenderRegistrysplices only between the region markers, andcheck:generatedis the instrument that would fail if that round-trip were unstable.packages/cli/src/utils/i18n-extract.tswas NOT edited — the walker does not move, becausesettingsnever had an emitter.Acceptance notes
translationmetadata-type door is untouched and still acceptssettings. The ruling names the per-app BUNDLE, andpackages/spec/liveness/translation.jsonis 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 atranslationitem through Studio can still writesettings, andauthored-translation-syncmerges 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-objects, the five plugins, the other services) keep the narrowerTranslationData/TranslationBundletypes. They are assignable, and re-typing ~20 files that never carrysettingswould be churn with no contract effect. noted, not filed.packages/lint/src/validate-translation-references.tsstill listssettingsamong 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 阶段,合并时叶子归后到的一方,所以两边都定义的键,平台永远赢。应用那份实际是个补缺者:只在平台包对那个键、那个语言没有字符串时才显示。minorchangeset(发射窗口惯例,major会被门禁拒收)。回滚就是回滚这个 PR:没有数据迁移、没有存储改动,os migrate meta的那条转换只在作者主动运行时改源码。skills/**这道 Tier H 的门你本来就得为它开一次。CONTRACT_REVIEW_TIER上重跑;⛔ 结果出来之前本席不做任何落地动作,也不翻 ready。skills/,按规矩属于 Tier H,落地要维护者的那句话;若不想为一条文案更正开这道门,可以把那个技能文件单独拆一个 PR——但⛔ 不能一边发布新契约、一边让已发布技能继续教一个现在会被拒收的键。② 裁决里「was inert」这句与实测不符(它是活的),ADR 条目按实测写,请确认这个改写符合原意。③translation元数据门仍接受settings,那是本卡范围之外的一个口子,见上面的验收注记。Generated by Claude Code