fix(spec): the page-name route is canonical for a region-level page:header - #16588
Conversation
…header` `translatePage` stops reading the id route (`pages.PAGE.components.HEADERID.*`) for a `page:header` at region level. That route was live and PREFERRED there, while the CLI extractor deliberately offers nothing under it for the same component -- so every key read through it was a key no tooling ever offered, counted or reported, and the header's `title` had two addresses while its `subtitle` had one. The gate mirrors the extractor's emission exception in `collectExpectedEntries` (same shape, opposite verb), so the failure pair `walkAddressedPageComponents` exists to prevent cannot reopen from the resolver side. A `page:header` nested inside a container is unchanged: the page-name route does not reach it, so it stays id-only. The walk's `addressed` arbitration is untouched -- a region-level header's id still claims its entry and still blocks a nested namesake, which is what the extractor does too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
The visitor's lead comment still described the id route as beating the page-name route for a header that carries an id -- the sentence the gate below it now contradicts. Rewritten to say what the code does. Changeset: `minor` on `@objectstack/spec`, stating the behaviour change and the FROM -> TO mapping for a bundle that used the components key. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
…ssue-16147-page-header-canonical-i18n-route
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 130 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 fff02857d27681ea1a977b21ed5b350efc761303 && git checkout fff02857d27681ea1a977b21ed5b350efc761303
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ac9376a7428996bbfa9807cea823ae52c1879a1e 07d4cd435767058f8baef669a70971ce374bccf2 && git checkout -B drift-repro ac9376a7428996bbfa9807cea823ae52c1879a1e && git merge --no-ff 07d4cd435767058f8baef669a70971ce374bccf2
node scripts/docs-audit/affected-docs.mjs --json ac9376a7428996bbfa9807cea823ae52c1879a1e |
Contract review at
|
…:header The batch #58 pin in i18n-resolver.test.ts stayed green with the gate reverted to `if (addressed)`: homeBundle carries pages.sales_home_page.label, so the page-name overlay wins for `title` whether or not the id route was read. The test pinned precedence (page name wins when both routes are present) while its title claimed "page name only" - a pin that cannot fail on the regression it names. Split into two tests. The precedence pin keeps its body under a title that says what it measures. A new pin hands translatePage an id-only bundle (no pages.PAGE.label / title / subtitle), so the page-name overlay has nothing to win with and only the id route could move the header title, and asserts the authored title survives. Two controls keep the empty reading honest: the same id-only bundle translates the component that owns the id, and a nested page:header with that id stays id-addressed. Only the test file changes; the resolver, extractor, cli parity pin and changeset are untouched. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T6HeZvT9wdSJD1ZxJb5Eno
Repair of the spec-package pin (review finding: a pin that cannot fail)Commit What was wrong. The batch #58 test used What changed. The test is split in two:
Proof it fires now (at
The spec test imports Whole package at Note for the body: the table row for Generated by Claude Code |
B1 repaired — the pin now fails on the regression it names. Armed.Head The repair was proved by watching the pin go red, not by watching it pass. Both legs carry on-disk landing proofs (marker
⭐ And the precedence pin stayed green under the mutation, as predicted — which is the point: the two tests now measure different things, and only one of them is about closure. The split, and why the controls matterThe old body was kept and retitled to what it actually measures — "prefers the page-name route over the id route … when a bundle carries both". That is precedence, and it was always true; it was only ever mis-labelled as closure. The new pin hands Two controls keep that empty reading honest, and without them it would be worth nothing: the same bundle does translate the Fences held: resolver, extractor, cli parity pin and changeset untouched;
|
Fixes #16147
The ruling this implements
Maintainer 「同意」, director seat, decision batch #58 (2026-09-06), option 1 — recorded on the card as comment 5559944903:
The contract question was settled by the maintainer. Nothing here re-opens it; what is open for review is execution.
STEP ONE — the reading the ruling required before any resolver edit
Posted to the card first, as comment 5567808954 (08:37:58Z; the first edit landed at 08:41:14Z): the served
/i18n/translationscatalog endpoint mirrors NEITHER side. Both of its mounts (packages/runtime/src/domains/i18n.tsandservice-i18n'sregisterI18nRoutes) reduce toi18n.getTranslations(locale)handed straight to the response envelope — a verbatim pass-through of the stored bundle. Driven through the realHttpDispatcher, the served body came back byte-identical to what was loaded, carrying thecomponents.HEADERID.titlekey unresolved and unranked, and it synthesized no key for a component that had none. So it does not change in this PR, and the file surface stayspackages/specplus thepackages/cliparity pins.The card's Leg A / Leg B / Leg C were re-measured here, not inherited — triage said explicitly it had not re-run them. All three reproduced on this tree; details and the outputs are in that comment.
What changed
packages/spec/src/system/i18n-resolver.tstranslatePage's per-component overlay no longer looks up the id route for a region-levelpage:header; the now-unreachable "the id route already resolvedtitle" guard on the page-name overlay is deleted with itpackages/spec/src/system/translation.zod.tspagesandpages.PAGE.componentsstate the one-address rulepackages/cli/test/platform-page-i18n-parity.test.tspackages/spec/src/system/i18n-resolver.test.ts.changeset/page-header-page-name-route-canonical.mdminoron@objectstack/spec, stating the behaviour change and the FROM to TO mappingThe gate is written to mirror the extractor's emission exception, same shape and opposite verb, so the two sides cannot drift apart again from the resolver's side:
Which keys this actually moves. The gate removes the id route for the whole of
PAGE_COMPONENT_COPY_KEYS, not justtitle— but for this component that is a one-key change in practice. Pertranslation.zod.ts's measured key table, the only copy proppage:headerdeclares istitle, and itssubtitlewas deliberately never in thecomponentsface at all;description/placeholder/emptyTextare declared by other components. The one residual is alabelwritten at the component's top level (legal on any component, and the shape the publishedobjectstack-uiskill's example uses): it loses its id-route overlay. It was already invisible toos i18n extractandos i18n coverage, which is the whole defect this closes, and a header's rendered copy isproperties.title/.subtitle, which the page-name route covers.Blast radius inside this repository: zero. Machine-checked, not assumed — every region-level
page:headershipped in this repo (examples/app-crm, fiveexamples/app-showcasepages,packages/qa/downstream-contract, the three plugin-carried Setup pages, andscripts/fixtures/i18n-walk-parity/every-group.stack.json) is id-less, and no shipped bundle writes acomponents.HEADERIDkey at all. The only region-level headers carrying an id anywhere in the tree are the fixtures in the two test files this PR changes, plus one documentation example that does not use the id as a translation address.The walk's
addressedarbitration is deliberately untouched. A region-level header's id still claims its bundle entry and still blocks a nested namesake — which component owns an id is a property of the document, decided identically for every consumer ofwalkAddressedPageComponents, and the extractor blocks the namesake the same way. Only whether this consumer reads the entry changed.The parity pins
The existing differential's
appliedButNotOfferedlist read['hdr']— the standing exception, documented in the test as deliberate. It now reads[], in both directions, and the comment says an entry reappearing is a regression rather than an exception to document.A new case pins the ruled invariant directly, over the whole shared key list rather than
titlealone, with five halves in one place: the id key is neither offered nor read for a region-levelpage:header; a positive control proves that empty is a reading (the same bundle shape still reaches a component the id route does serve); the page-name route still translates the header, so "one address" cannot regress into "no address"; a nestedpage:headeris still id-addressed on both sides; and the card's Leg A shape 3 — a bundle carrying both routes — now resolves to the page-name one.Clause-②: yes
Measured, then decided.
The mechanical floor is not tripped. There is no new export and no new payload key, and every artifact gate that would see a moved surface is green on this diff:
check:api-surface,check:authorable-surface,check:export-origins,check:declaration-map,check:docs,check:llms-txt— the fullcheck:generatedwrapper reports all 15 artifacts up to date, with nothing regenerated.translatePage's signature is unchanged, no published type moved, andTranslationBundleSchema's accept-set is untouched:pages.PAGE.components.HEADERID.titlestill parses exactly as before.And that is precisely the argument for
yes. This narrows what a published function reads. The observable effect on an existing bundle is a silent fallback, and every gate above is structurally blind to it — their green is evidence that no artifact moved, not evidence that behaviour did not. Under the lane's own tie-break — 「claim 拿不准 ⇒ 按 yes 派契约复审档」 — a behaviour change on a published surface that no mechanical check can see is exactly the case the review tier exists for.addressedarbitration alone.On the bump level, measured rather than argued. The changeset is
minor.majorwas never available:check-changeset-no-major.mjs's launch-window guard refuses a PR that introduces one, and it is green here (✓ This diff introduces no major bump). Its second axis — the one that refusespatchon a package a clause-② PR grew — reportedLEVEL AXIS: NOT MEASUREDlocally, because nopull_requestpayload exists outside CI; with this body declaring clause ② it will be judged on the PR, andminoris what that axis asks for. The reasoning for not treating this as a removal is written into the changeset itself: nothing an author can write is removed or renamed, so there is no key to tombstone and no ADR-0087 conversion to register (check-adr-0087-registration.mjsgreen).⛔ This PR stays draft by dispatch instruction: not flipped ready, not enqueued, no auto-merge.
Verification
Every derivation is anchored to a literal sha, never to the moving
origin/mainref: the branch point isb0529e1488ac9a72891115210949ba631d7e61fe, andmainwas merged in atce8caba91403c8f160cb7764c63b08371a13db99(clean, zero path overlap with this diff, noos-regendeferral).mainmoved again while this ran — it always does; the merge queue rebuilds the PR as merged onto whatevermainis at landing time and re-runs the required set, which is the race-free re-check.Per the multi-agent §10 scoping rule, the post-merge re-check is scoped to the overlap rather than repeated whole:
packages/specmoved on both sides, so it was rebuilt and its generated artifacts re-checked;main's spec commits touch the liveness ledgers,manifest.zod.ts,knowledge-source.zod.tsandapi/protocol.zod.ts— none of them this diff's behaviour — so the targeted i18n suites and both typechecks were re-run rather than the full suite a second time.driver-sqland five others missing their declared.d.ts,platform-objectsmissing its emitted JS. Each presented exactly as AGENTS.md §9 describes: a failure that reads as if this change broke something. The firstcliunit run is therefore reported as NOT MEASURED (75 files failed to collect onMODULE_NOT_FOUND, and all four apparent assertion failures were the same missing package). The prescribed remedy — aturbo --forcerebuild of the affected package — was applied, and every number quoted here is from a run after that repair.Tests
On the branch before the
mainmerge (59d91d1230):pnpm --filter @objectstack/spec test— 484 files, 13149 tests, all passed (through the shared verify lock, VERDICT command-exit 0).pnpm --filter @objectstack/cli exec vitest run --project unit— 182 files, 2479 passed + 6 expected-fail (2485), exit 0. Theintegrationlayer is declared to CI: this diff touches no integration-layer file, nobin/entry and no spawn helper.After the merge, on
e34ab343b6, scoped per §10 —packages/specrebuilt first, then:platform-page-i18n-parity.test.ts(--project unit)i18n-resolver.test.ts+translation.test.tspnpm --filter @objectstack/cli typecheck(tsc --noEmit+ test layer)pnpm --filter @objectstack/spec typecheckpnpm --filter @objectstack/spec check:generatedpnpm check:i18n-walk-parityAblation — the new pin is proven capable of failing
packages/cliresolvestranslatePagethrough@objectstack/spec'sexports, i.e. through spec's builtdist/, so a source mutation is invisible until spec is rebuilt and the run would be falsely green. Every leg therefore rebuilds and proves the mutation reacheddist/before its colour is read. The mutation reverts the gate to the pre-rulingif (addressed).1 -> 0, injected line count1, blob hash8162eca7->c5a73613ablation-dist-preflight --absent: marker absent from all 218 built files4 failed | 22 passed (26), vitest exit 1git checkout HEAD -- PATH; blob hash back to8162eca7; whole-treegit status --porcelainemptyablation-dist-preflight(default): marker present in 4 built files26 passed (26), vitest exit 0The four that went red are exactly the four assertions the ruling moves: the two set comparisons, the collision case, and the new
neither offers nor reads the id key…pin. Restoring was proven by a whole-tree status, not a per-path diff — the preflight warns that a per-path diff is clean on exactly the tree that is still mutated.Two honest notes on the instrument:
copy?.titleguard, the page-name overlay still wins there, so it printedBY-NAMEon both sides. It proves nothing in either direction and is not counted. A correct probe — an id-only bundle, where nothing can overwrite the id route — was run against the restored artifact: a region-level header with an id resolves toAUTHORED(id route not read) while a nested one resolves toBY-ID-NESTED(id route still read). That is the contrast the ruling asks for, read straight offdist/.dist/evidence above (absent from all 218/present in 4) is what makes the red a reading rather than a coincidence.Gates
Derived from the change set rather than recalled:
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, then every derived command run with its exit code captured before any pipe (cmd > log 2>&1; EXIT=$?, never through a pipe). The run record was handed back to--ranfor reconciliation: 77 derived, 77 accounted for, 0 unrun — the six the first pass missed were the changeset families, which only become derivable once the changeset file exists, and they were run afterwards.pnpm check:i18n-walk-parity— the gate closest to this change — is green with a real measurement once bothdist/trees are built:11 declared group(s), 8 walked, 3 exempted — every declared group has an extractor face, self-test 23/23.Two came back
PREREQUISITE NOT MET(exit 3) and are reported as NOT MEASURED, never as a pass and never as a finding:check:dual-build-cjs-loadsandcheck:type-check-debtboth need a full workspace closure build that does not fit this container's foreground budget. Both are declared to CI, which builds the closure first.check:i18n-walk-parityrefused for the same prerequisite reason and spelled it exit 1, the code a real finding uses, while its siblings use the repo's sharedEXIT_PREREQUISITE_NOT_MET === 3. Filed out of scope and untouched here as #16558.Lint: not narrowed.
pnpm lintiseslint . --no-inline-config, and that exact command was run whole, on the final commite34ab343b6— 6279 files, 0 errors, 0 warnings, exit 0. No narrowing to declare, so none of the three narrowing receipts is owed.Test narrowing, declared.
turbo ls --affectednames 70 packages — everything downstream ofpackages/spec. Rather than assume, the risk set was measured: of every test file namingpage:header, six also call a translation path, and they live incli,platform-objects,restandspec. Of those, only the two files changed here carry a region-levelpage:headerwith an id —rest.test.ts's header has no id, and neither do the fixtures ini18n-extract.test.ts,translation.test.tsor the platform-objects nav parity test. The remaining affected packages are spec consumers whose suites are CI's to run.Pinned sibling: nothing is removed or renamed, so the Console Pin Gate concern does not arise;
objectuiimports none oftranslatePage,walkAddressedPageComponentsorPAGE_COMPONENT_COPY_KEYS.Blast radius
Inherited from the card, attributed — not measured by this seat. HotCRM measured zero overrides of this kind: all five of its region-level headers carry ids and none writes the
componentskey. This seat did not re-run that corpus measurement and does not claim it. The in-repo half above was measured here.What is deliberately NOT in this PR
packages/cli/src/utils/i18n-extract.tsis untouched.Generated by Claude Code
Generated by Claude Code