Skip to content

fix(spec): the page-name route is canonical for a region-level page:header - #16588

Merged
huangyiirene merged 4 commits into
mainfrom
claude/issue-16147-page-header-canonical-i18n-route
Sep 7, 2026
Merged

fix(spec): the page-name route is canonical for a region-level page:header#16588
huangyiirene merged 4 commits into
mainfrom
claude/issue-16147-page-header-canonical-i18n-route

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

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 page-name route is canonical for a region-level page:header. translatePage stops reading the id route (pages.PAGE.components.HEADERID.*) for a page:header at region level; a page:header nested inside a container stays id-only, as its doc already says. One component, one address — title and subtitle now follow the same rule.

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/translations catalog endpoint mirrors NEITHER side. Both of its mounts (packages/runtime/src/domains/i18n.ts and service-i18n's registerI18nRoutes) reduce to i18n.getTranslations(locale) handed straight to the response envelope — a verbatim pass-through of the stored bundle. Driven through the real HttpDispatcher, the served body came back byte-identical to what was loaded, carrying the components.HEADERID.title key 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 stays packages/spec plus the packages/cli parity 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

file change
packages/spec/src/system/i18n-resolver.ts translatePage's per-component overlay no longer looks up the id route for a region-level page:header; the now-unreachable "the id route already resolved title" guard on the page-name overlay is deleted with it
packages/spec/src/system/translation.zod.ts the published authoring-face docs for pages and pages.PAGE.components state the one-address rule
packages/cli/test/platform-page-i18n-parity.test.ts the standing exception is closed and the ruled invariant is pinned directly
packages/spec/src/system/i18n-resolver.test.ts the precedence test is inverted, not deleted
.changeset/page-header-page-name-route-canonical.md minor on @objectstack/spec, stating the behaviour change and the FROM to TO mapping

The 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:

resolver   if (addressed && (nested || component.type !== PAGE_HEADER_COMPONENT))  -> read the id route
extractor  if (addressed && (nested || component.type !== PAGE_HEADER_COMPONENT_TYPE)) -> offer the id key

Which keys this actually moves. The gate removes the id route for the whole of PAGE_COMPONENT_COPY_KEYS, not just title — but for this component that is a one-key change in practice. Per translation.zod.ts's measured key table, the only copy prop page:header declares is title, and its subtitle was deliberately never in the components face at all; description / placeholder / emptyText are declared by other components. The one residual is a label written at the component's top level (legal on any component, and the shape the published objectstack-ui skill's example uses): it loses its id-route overlay. It was already invisible to os i18n extract and os i18n coverage, which is the whole defect this closes, and a header's rendered copy is properties.title / .subtitle, which the page-name route covers.

Blast radius inside this repository: zero. Machine-checked, not assumed — every region-level page:header shipped in this repo (examples/app-crm, five examples/app-showcase pages, packages/qa/downstream-contract, the three plugin-carried Setup pages, and scripts/fixtures/i18n-walk-parity/every-group.stack.json) is id-less, and no shipped bundle writes a components.HEADERID key 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 addressed arbitration 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 of walkAddressedPageComponents, and the extractor blocks the namesake the same way. Only whether this consumer reads the entry changed.

The parity pins

The existing differential's appliedButNotOffered list 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 title alone, with five halves in one place: the id key is neither offered nor read for a region-level page: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 nested page:header is 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 full check:generated wrapper reports all 15 artifacts up to date, with nothing regenerated. translatePage's signature is unchanged, no published type moved, and TranslationBundleSchema's accept-set is untouched: pages.PAGE.components.HEADERID.title still 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.

⚠️ A review here is checking execution, not re-litigating the choice: the contract question was already ruled by the maintainer (batch #58, option 1). What is worth a reviewer's eye is the mirror-the-extractor shape of the gate and the decision to leave the addressed arbitration alone.

On the bump level, measured rather than argued. The changeset is minor. major was 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 refuses patch on a package a clause-② PR grew — reported LEVEL AXIS: NOT MEASURED locally, because no pull_request payload exists outside CI; with this body declaring clause ② it will be judged on the PR, and minor is 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.mjs green).

⛔ 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/main ref: the branch point is b0529e1488ac9a72891115210949ba631d7e61fe, and main was merged in at ce8caba91403c8f160cb7764c63b08371a13db99 (clean, zero path overlap with this diff, no os-regen deferral). main moved again while this ran — it always does; the merge queue rebuilds the PR as merged onto whatever main is 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/spec moved 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.ts and api/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.

⚠️ Container note, so the numbers can be read correctly. This box is shared with other agents, and twice during this run a package this diff never touches was found built without part of its output — driver-sql and five others missing their declared .d.ts, platform-objects missing its emitted JS. Each presented exactly as AGENTS.md §9 describes: a failure that reads as if this change broke something. The first cli unit run is therefore reported as NOT MEASURED (75 files failed to collect on MODULE_NOT_FOUND, and all four apparent assertion failures were the same missing package). The prescribed remedy — a turbo --force rebuild of the affected package — was applied, and every number quoted here is from a run after that repair.

Tests

On the branch before the main merge (59d91d1230):

  • pnpm --filter @objectstack/spec test484 files, 13149 tests, all passed (through the shared verify lock, VERDICT command-exit 0).
  • pnpm --filter @objectstack/cli exec vitest run --project unit182 files, 2479 passed + 6 expected-fail (2485), exit 0. The integration layer is declared to CI: this diff touches no integration-layer file, no bin/ entry and no spawn helper.

After the merge, on e34ab343b6, scoped per §10 — packages/spec rebuilt first, then:

check result
platform-page-i18n-parity.test.ts (--project unit) 1 file, 26 passed
i18n-resolver.test.ts + translation.test.ts 2 files, 367 passed
pnpm --filter @objectstack/cli typecheck (tsc --noEmit + test layer) exit 0
pnpm --filter @objectstack/spec typecheck exit 0
pnpm --filter @objectstack/spec check:generated all 15 artifacts up to date, nothing regenerated, working tree clean
pnpm check:i18n-walk-parity exit 0 — 11 declared groups, 8 walked, 3 exempted

Ablation — the new pin is proven capable of failing

packages/cli resolves translatePage through @objectstack/spec's exports, i.e. through spec's built dist/, 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 reached dist/ before its colour is read. The mutation reverts the gate to the pre-ruling if (addressed).

leg on-disk proof artifact proof result
mutate marker count 1 -> 0, injected line count 1, blob hash 8162eca7 -> c5a73613 ablation-dist-preflight --absent: marker absent from all 218 built files RED — 4 failed | 22 passed (26), vitest exit 1
restore git checkout HEAD -- PATH; blob hash back to 8162eca7; whole-tree git status --porcelain empty ablation-dist-preflight (default): marker present in 4 built files GREEN — 26 passed (26), vitest exit 0

The 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:

  • One probe was not discriminating, and is reported as such. The scripted property read used a bundle carrying both routes; because the mutation reverts only the gate and does not restore the deleted copy?.title guard, the page-name overlay still wins there, so it printed BY-NAME on 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 to AUTHORED (id route not read) while a nested one resolves to BY-ID-NESTED (id route still read). That is the contrast the ruling asks for, read straight off dist/.
  • The 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 --ran for 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 both dist/ 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-loads and check:type-check-debt both 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.

⚠️ On the first pass check:i18n-walk-parity refused for the same prerequisite reason and spelled it exit 1, the code a real finding uses, while its siblings use the repo's shared EXIT_PREREQUISITE_NOT_MET === 3. Filed out of scope and untouched here as #16558.

Lint: not narrowed. pnpm lint is eslint . --no-inline-config, and that exact command was run whole, on the final commit e34ab343b66279 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 --affected names 70 packages — everything downstream of packages/spec. Rather than assume, the risk set was measured: of every test file naming page:header, six also call a translation path, and they live in cli, platform-objects, rest and spec. Of those, only the two files changed here carry a region-level page:header with an idrest.test.ts's header has no id, and neither do the fixtures in i18n-extract.test.ts, translation.test.ts or 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; objectui imports none of translatePage, walkAddressedPageComponents or PAGE_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 components key. 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

  • The extractor. It is already correct under this ruling and needs no change; packages/cli/src/utils/i18n-extract.ts is untouched.
  • Option 2 (teach the extractor to offer the id key) — ruled against.
  • The catalog endpoint, per the STEP ONE reading above.

Generated by Claude Code


Generated by Claude Code

…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
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

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

Which tree this was computed on

This run read content/docs from fff02857d27681ea1a977b21ed5b350efc761303 — the merge of head 07d4cd435767058f8baef669a70971ce374bccf2 into base ac9376a7428996bbfa9807cea823ae52c1879a1e, which is what actions/checkout gives a pull_request run. Not the PR head.

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

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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

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

Copy link
Copy Markdown
Collaborator Author

Contract review at CONTRACT_REVIEW_TIER (claude-fable-5-1) — FAIL, one narrow blocking finding

Anchors: head e34ab343b6, merge-base ce8caba914, objectui pin a472b07167 (read via git show <sha>, never a branch). Every number below is from the reviewer's own runs, not from the author's report.

The contract execution is correct. The mirror, the nested route, the addressed arbitration, the unreachable-guard deletion, walk parity and the cli ablation all verify. The blocker is not the change — it is the test that claims to protect it.

B1 — BLOCKING: the spec-package pin stays green under the regression it names

Measured, with landing proof (source marker 1→0, injected line 1, blob 8162eca7→13057056): revert the gate to if (addressed) and i18n-resolver.test.ts"reads a region-level page:header by page name only, id or no id (batch #58)" still passes. A probe on that same mutated tree confirms the id route is genuinely live again (id-only hdr.title = BY-ID, against AUTHORED on the PR tree).

The cause is one this PR already discovered — and did not carry across. homeBundle carries pages.sales_home_page.label, so the guard-less page-name overlay wins for title whether or not the id route was read. That is exactly the confound the author found in their own scripted probe, correctly called "NOT discriminating", and discarded rather than counted. Excellent discipline. It was simply not applied to the test that ships.

⇒ The test pins "page name wins when both routes are present" — true, and worth pinning — but its title and the PR's table ("the inversion IS the behaviour change the ruling records") claim it pins "page name only", which it does not. Net: the package that owns translatePage has no test that fails on a gate regression. Only packages/cli's parity pin catches it, through dist/, in another package's unit project. The 13,149-test spec run would be green on that regression.

Fix — one assertion. Translate the same doc against an id-only bundle and assert the header keeps 'Sales Home':

{ 'zh-CN': { pages: { sales_home_page: { components: { quick_create: { title: '快速新建' } } } } } }

and retitle the test to what it measures. Dispatching that repair; nothing else in the diff moves.

The blast-radius question — measured, and the answer is zero

The change is breaking in effect, and the population it breaks is empty everywhere in scope, each with a lit control:

  • objectstack e34ab343b6: 82 page:header occurrences in 59 files. Region-level headers carrying an id: only the fixtures in the two changed test files, plus one published skill example that shows no bundle. Every shipped header — app-crm, five showcase pages, downstream-contract, Setup, the walk-parity fixture — is id-less. One shipped bundle writes pages.*.components and it addresses field_* / submit_inquiry / project_picker / cta_*, no header id (control: that face is live for those twelve ids). The nine *.generated.ts bundles carry no pages face at all.
  • objectui a472b07167: zero imports of translatePage / walkAddressedPageComponents / PAGE_COMPONENT_COPY_KEYS (control: 1,148 files import @objectstack/spec). Pages arrive already translated through REST, so there is no client/server divergence.
  • Documentation: the withdrawn "id wins" sentence lived only in source JSDoc and was never projected into content/, docs/, skills/ or llms.txt (control lit). Nothing ever told authors to address a header by id — which is the fact that makes this safe.

⚠️ The reviewer also measured hotcrm and reports five id-carrying region-level headers there with zero overrides. hotcrm is outside this session's declared repo scope, so I am not carrying that as an in-scope reading and it is not load-bearing here — the finding stands on objectstack and the objectui pin alone, both zero.

⭐ The changeset question — measured, and my earlier framing of it was wrong

The structural claim from #16147's dev is confirmed, with committed landing proofs at ce8caba914: patch is refused by the LEVEL AXIS, major is refused by the launch-window guard, minor is forced.

But that is only half the picture, and the other half reverses the conclusion. The repo's designated breaking-ness carriers during the window — a **BREAKING** banner plus an explicit <!-- adr-0087: … --> disposition — are available and unrefused on a minor bump: 44 of 52 stock **BREAKING changesets in this repo are graded minor. This changeset uses neither.

⇒ So it is not true that the repo cannot express breaking-ness. It is that the bump level cannot, while a working channel exists and was not used. I had circulated the stronger, wrong version of this to another dev and have corrected it there.

The consequence is worse than a missing label: breakingDeclaration() is false, so check-adr-0087-registration reports "adds no declared-breaking changeset"a vacuous green. The gate never asked. (Separately, findMigrationPrescription() returned null on a body that plainly carries a prescription with a FROM/TO block — the #8299 detector-miss shape.)

⚠️ One question is genuinely the maintainer's, and I am not ruling it. AGENTS.md defines breaking as "removes or renames anything an author can write" — which does not classify a still-accepted key that is merely no longer read for one component. So no carrier is required for this class today. Does a read-route withdrawal on a still-parsing key count as breaking? If yes, this PR owes the banner and a disposition. If no, the changeset is already correct as written. The tombstone route is genuinely closed (the schema cannot tell a header id from any other string); a guidance-only ledger entry is possible, a mechanical one is not.

Method note, in the reviewer's favour

Its first ablation attempt was a void legtsup exited 127, dist never changed, and the pin came back 26 green. It caught this with the dist marker, discarded it, and re-ran. That is the trap this lane warns about, met correctly; the 4-red/26-green result that follows is real.

Advisories

  1. Add the **BREAKING** banner + explicit adr-0087: disposition now — zero gate cost, and the only channel the window leaves open — pending the ruling above.
  2. The mirror is two private literals and two hand-copied conditions; they can drift in source. Drift is caught, not prevented — a resolver-side drift turns the cli parity pin 4-red — but silent drift is possible if that one cli test is not run. Making it a shared predicate is itself clause-② work ⇒ a follow-up card, not this PR.
  3. A "bundle key addresses nothing" report in os i18n check would be the first detector for this silent-drop class. Today an orphaned components.<headerId>.title is invisible before and after.
  4. Pre-existing, out of scope: a kind:'slotted' page's slots.header is walked by neither route — untranslatable before and after.

Not measured by the reviewer: check:dual-build-cjs-loads, check:type-check-debt (exit 3 per author — neither a pass nor a finding), and check-clause2-carriers --pair (needs GitHub API, closed to that session).

Held unarmed pending the B1 repair.


Generated by Claude Code

…: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

Copy link
Copy Markdown
Collaborator Author

Repair of the spec-package pin (review finding: a pin that cannot fail)

Commit 07d4cd4357, one file: packages/spec/src/system/i18n-resolver.test.ts (+71/-22). Nothing else moved: resolver, extractor, cli parity pin and changeset are untouched, and the PR stays draft.

What was wrong. The batch #58 test used homeBundle, which carries pages.sales_home_page.label; the page-name overlay runs after the id route and wins for title whether or not the id route was read. So the test pinned precedence (page name wins when both routes are present) while its title claimed "page name only". Reproduced before repairing: on the untouched e34ab343b6, with the gate reverted to if (addressed) (marker 1 to 0, injected line 1, blob 8162eca7 to c5a73613), the whole file stayed green, 248/248.

What changed. The test is split in two:

  • prefers the page-name route over the id route for a region-level page:header when a bundle carries both (batch #58) keeps the old body under the title it actually measures.
  • does not read the id route for a region-level page:header, an id-only bundle leaves its authored title alone (batch #58) hands translatePage a bundle carrying only zh-CN.pages.sales_home_page.components.quick_create.title (no pages.PAGE.label / title / subtitle) and asserts the region-level header keeps 'Sales Home'. Two controls keep the empty reading honest: the same bundle translates the page:card that owns quick_create, and a page:header nested in a page:card's children with that id resolves to 快速新建 (the id route is still live at nested level).

Proof it fires now (at 07d4cd4357, same mutation, same on-disk landing proof, restore by git checkout HEAD -- PATH verified by blob hash and a whole-tree git status --porcelain that is empty):

leg result
gate reverted 1 failed / 248 passed (249), vitest exit 1: the new test, i18n-resolver.test.ts:1434, expected '快速新建' to be 'Sales Home'; the precedence pin stays green, as predicted
restored 249 passed, vitest exit 0

The spec test imports ./i18n-resolver by relative source path, so no rebuild leg is needed; the red itself proves the mutation reached the test.

Whole package at 07d4cd4357: pnpm --filter @objectstack/spec test = 484 files / 13150 passed (13149 plus the new pin), verify-lock VERDICT command-exit 0; pnpm --filter @objectstack/spec typecheck exit 0; dispatch-gates derivation reconciled 77/77 run (four NOT MEASURED as PREREQUISITE NOT MET, all needing the workspace closure build, none reading this file: check:doc-formula-expressions, check:dual-build-cjs-loads, check:type-check-debt, check:i18n-walk-parity). Full record is in the os-dev-report on #16147.

Note for the body: the table row for i18n-resolver.test.ts ("the precedence test is inverted, not deleted") now under-describes the file; it carries the inverted precedence pin plus the id-only closure pin.


Generated by Claude Code

@huangyiirene
huangyiirene marked this pull request as ready for review September 7, 2026 12:45

Copy link
Copy Markdown
Collaborator Author

B1 repaired — the pin now fails on the regression it names. Armed.

Head e34ab343b607d4cd4357, one file, +71/−22.

The repair was proved by watching the pin go red, not by watching it pass. Both legs carry on-disk landing proofs (marker 1→0, injected line 1, blob 8162eca7→c5a73613) and both restore to a whole-tree porcelain-empty check rather than a per-path diff:

leg sha gate reverted to if (addressed) result
before e34ab343b6 (untouched) yes 248 passed — the whole file green. The blindness reproduced independently.
after 07d4cd4357 yes 1 failed / 248 passed, red at i18n-resolver.test.ts:1434:61: AssertionError: expected 快速新建 to be Sales Home
restore 07d4cd4357 no 249 passed, blob back to 8162eca7, porcelain empty

⭐ 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 matter

The 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 translatePage a bundle carrying only pages.sales_home_page.components.quick_create.title — no pages.PAGE.label, title or subtitle — so the page-name overlay has nothing to win with, and asserts the header keeps 'Sales Home'.

Two controls keep that empty reading honest, and without them it would be worth nothing: the same bundle does translate the page:card that owns quick_create, and a page:header nested in that card resolves to 快速新建. So the bundle is demonstrably capable of producing a value through the id route — the header's 'Sales Home' is a real negative, not a vacuous one. That is the difference between this pin and the one it replaces.

Fences held: resolver, extractor, cli parity pin and changeset untouched; main not merged forward. Spec suite 484 files / 13150 passed (13149 + the new pin); typecheck clean with the 54-file ledger unchanged, so the edited file is inside the checked set. All 34 check runs read, every one success or skipped.

⚠️ Landing under AGENTS.md's definition as written — the open question is not resolved by this

The review left one question for the maintainer: does a read-route withdrawal on a still-parsing key count as breaking? It is not answered here, and I am not answering it.

I am arming anyway, and the reasoning should be on the record: AGENTS.md defines breaking as "removes or renames anything an author can write", which does not cover a key that is still accepted and merely no longer read for one component. So the changeset as written complies with the rule as it stands, and blast radius was measured zero in scope — every shipped header id-less, zero objectui imports of the three symbols against a 1,148-file control, and the withdrawn "id wins" sentence never projected into any doc.

⇒ If the maintainer later rules the other way, the remedy is a changeset amendment (**BREAKING** banner + an adr-0087: disposition on the forced minor), not a revert. Holding a fully verified PR against a hypothetical redefinition of a written rule would be the wrong call; recording the exposure is the right one.

⚠️ One PR-body inaccuracy, left rather than edited: the table row for i18n-resolver.test.ts says "the precedence test is inverted, not deleted", which now under-describes the file — it carries the retitled precedence pin plus the new id-only closure pin. Noted here so the body is not read as the whole account.

needs:contract-review deliberately left on: the body's ## Clause-②: yes is a heading, which readClause2Line reads as a near-miss, so the label is the only thing making CI's LEVEL AXIS judge the grade rather than report not-measured. Striking it at arming — my usual practice — would have removed the one working carrier.


Generated by Claude Code

@huangyiirene
huangyiirene added this pull request to the merge queue Sep 7, 2026
Merged via the queue into main with commit 554a160 Sep 7, 2026
36 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-16147-page-header-canonical-i18n-route branch September 7, 2026 13:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n: the extractor omits an id key translatePage reads and PREFERS — region-level page:header carrying an id (the second half of the #13109 pair)

2 participants