Skip to content

docs(spec): state the one resolution order for a navigation entry's label - #20875

Merged
os-justin merged 3 commits into
mainfrom
claude/issue-20849-nav-label-resolution-order
Sep 30, 2026
Merged

os-justin merged 3 commits into
mainfrom
claude/issue-20849-nav-label-resolution-order

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Closes #20849

Clause-②: no (description text and a pin; the key stays I18nLabelSchema, optional, and no accept set moves)

BaseNavItemSchema.label's JSDoc and describe now state one resolution order. First comes the id-keyed bundle entry that translateApp applies at /meta, over the app's navigation tree (not areas). Else a present label as authored: its inline locale map's value for the locale, else its text. Else, when the label is absent, render-time inheritance from the target. A present label is never replaced by its target's label and never translated by matching its text.

  • Pin: new packages/spec/src/system/i18n-resolver.nav-label-identity.test.ts, 3 tests, green at cefd0de416.
  • Ablation: I made translateApp also translate by text, in two variants. (A) takes the target's translation when the label equals the target name. (B) takes a nav key spelled like the text. In both, the no-id-key case went red and the id-key case stayed green. Restore was proven: blob 1e7995c1148d equals HEAD and git diff HEAD is empty.
  • Regenerated: content/docs/references/ui/app.mdx, via gen:docs (45 table rows). No other tracked artifact carries this describe. The metadata-forms bundles are registry-driven, not describe-driven.
  • Local runs at f2fe3da47d:
    • spec test: 579 files, 17069 tests passed.
    • spec typecheck: green, and the test program lists the new file.
    • check:generated: all 15 artifacts up to date.
    • Derived gates: 108 derived, 106 exited 0, 0 unrun. 2 are NOT MEASURED: check:dual-build-cjs-loads and check:type-check-debt, whose prerequisite is a full workspace build.
    • eslint on the 2 touched TS files: 0 findings. The config enables no type-aware rules, so the untouched files' verdicts cannot move.

Acceptance notes

  • The objectui renderer at 846cec0efe still translates a present label that equals its target's name (isCustomized). It also does not resolve an inline locale map on a nav entry, because resolveLabel reads only the keyed form. The renderer half is objectui#11201's.
  • translateApp never walks areas[].navigation, so the describe scopes the bundle step to navigation. The translation-reference lint still accepts area entry ids as keys.
  • translateApp also applies an id key to an entry whose label is absent. That is why the bundle step is listed first.

Generated by Claude Code

…abel

BaseNavItemSchema.label's JSDoc and describe now name the id-keyed bundle
step translateApp runs at the /meta boundary, the inline locale map, the
authored text and render-time inheritance, in that order, and say a present
label is never replaced by its target's label nor translated by matching
its text. A new pin holds translateApp to the id key.

Claude-Session: https://claude.ai/code/session_01Sfe5YjBLwB9J3y8fvm2xq1
Co-authored-by: Claude <noreply@anthropic.com>
Generated by `pnpm --filter @objectstack/spec gen:docs`; no hand edits.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 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 — 137 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 793fb8390e360e018870333ec8ee1b4160f93007 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 793fb8390e360e018870333ec8ee1b4160f93007

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f2fe3da47d36c72e684b34de98a7b96f89277304
Local-runs: none

Inputs read: card #20849 (body, claim 5911209418, report 5913034946, ruling 5913088150); objectui#11201 (body, triage 5909828367, ruling B 5910404448, execution note 5910537969 with 「11201 按此」/「同意」, claim 5912136282); PR #20875 body, 4-file list, net diff of the head against main 07356a6ab0 (merge-base 05a7547c9f; main has not touched any of the four paths since, so the merge is clean); the head's check-runs. Code read at objectstack main 07356a6ab0 (translateApp and lookupNavLabel there are byte-identical to the head's parent; #20823 changed only the picklist section below them) and at objectui origin/main 1263e405de (the dev read 846cec0efe; the only NavigationRenderer.tsx change between the two is objectui#11197's doc entry — the isCustomized guard at :369–:388 is unchanged).

① Derived judgments

Accept set and public surface — unchanged, right. The only schema-adjacent edit is the JSDoc and the .describe() string of BaseNavItemSchema.label in packages/spec/src/ui/app.zod.ts; the key stays I18nLabelSchema.optional(), every nav-item arm still inherits it, and the new test file exports nothing. The describe reaches the published package (built zod, JSON schema) and one tracked artifact, content/docs/references/ui/app.mdx: 45 rows changed, one label row per nav-arm table, all identical — the regeneration is consistent. A git grep of the head finds the retired wording ("renders verbatim and is never overwritten") nowhere but the changeset that quotes it, so no other tracked artifact carries the old describe. The Spec property liveness and Lint & Repo Gates runs (which carry check:generated) are green.

The describe, sentence by sentence (852 characters; AI-facing). Each is tested against the tree and given the moment it is true.

  1. "Display proper label. Optional." — true now: I18nLabelSchema.optional().
  2. "(1) the bundle entry apps.APP.navigation.ID.label for the active locale chain, keyed by this entry's id — translateApp applies it at the /meta boundary, over the app's navigation tree (not areas)" — true on main when this lands. lookupNavLabel (i18n-resolver.ts:1130) loops localeChain(opts) (requested locale, then the declared fallbackChain unless the requested locale is the defaultLocale) and reads apps[app].navigation[navId].label; translateApp (:1154) calls it with node.id only, walks doc.navigation and node.children, and reads no areas; METADATA_DOCUMENT_TRANSLATORS wires app: translateApp (:1053), and the only callers of translateMetadataDocument outside spec are packages/rest/src/meta-item-read-gate.ts (translateMetaList, translateMetaDocument) and rest-server.ts:10563 (type object only) — that is the /meta boundary. Listing this step first is exact: the code applies an id key whether or not a label is authored (if (translated) next.label = translated, no presence test).
  3. "(2) else a present label as authored — its inline locale map's value for that locale, else its text" — the plain-string half is true everywhere now (translateApp leaves an unkeyed label untouched; resolveI18nLabel returns a string as itself; objectui's resolveLabel returns a string as itself). The map half is true at the spec now (I18nLabelSchema is z.union([z.string(), InlineLocaleMapSchema]); resolveI18nLabel in ui/i18n-label-resolver.ts walks the locale ladder) and in objectui's metadata-admin preview (app-shell/src/views/metadata-admin/previews/navItemLabel.ts calls resolveI18nLabel), but NOT in the runtime sidebar at objectui origin/main: resolveNavItemLabel hands a present label to resolveLabel, which knows only the keyed { key, defaultValue } form, so a map-valued nav ENTRY label yields label.defaultValue || label.key, i.e. nothing, and the typeof item.label !== 'string' arm returns that. This half holds in the console only once objectui routes nav entry labels through resolveI18nLabel — see ③ for who carries that. No in-repo producer writes a map-valued nav entry label (the dev's grep, 0 hits), so the gap has no reach today.
  4. "(3) else (absent) the CURRENT label of what the entry opens, at render time — the view's label when it names a labelled view, else the object's / dashboard's label — localized by the target's own translation" — true now, in objectui origin/main: item.label === undefined goes to inheritedNavItemLabel (view label, else object label, else viewName; object label else objectName; dashboard label else dashboardName), and useNavTargetLabel reads the shell's metadata (already translated at /meta by the same translator table), resolves a map label through resolveI18nLabel, and passes the text through useObjectLabel's bundle lookup. Precision gap, not a falsehood: the describe stops at "the object's / dashboard's label" and omits the ladder's last rungs (the target's machine name, then the entry's id) that the renderer and the 11201 execution note both state; the old describe omitted them too, and "Every real destination must have identity and text" half-covers it.
  5. "A present label is never replaced by its target's label and never translated by matching its text." — true on main when this lands for the one objectstack resolver: translateApp reads no target metadata and keys on node.id alone, and the new pin locks it. At objectui origin/main the runtime sidebar still breaks both halves: the isCustomized guard passes a plain-string present label that equals its objectName / viewName / dashboardName (trimmed, case-folded) to the object / view / dashboard resolver, which reads objects.NAME.label and its siblings from the bundle — the label is translated BECAUSE its text matches, and the value written is the target's label. That is exactly the rule ruling B retires; objectui#11201 stage 1 (claim 5912136282) deliberately leaves the guard in place, stage 2 removes it on the census. So this sentence is fully true only after objectui#11201 stage 2 lands. Keeping it unqualified is right: the execution note 5910537969 states this order as the contract and forbids the renderer keeping a rule of its own, the old text ("verbatim, never overwritten") was already violated by the same arm, and a describe states the contract, not a consumer's lag. The PR body's Acceptance notes name the lag.
  6. "Every real destination must have identity and text: identity is the target, text is inherited at render. No stored inherited flag; nothing is materialised for the absent case." — kept verbatim; true now (translateApp copies nodes and writes label only when a bundle key answers; the schema has no inherited flag; inheritance is computed per render in objectui).

Length and precision as AI-facing text: long, but every clause carries a rule an author acts on — the three-step order, the two prohibitions, the navigation-not-areas scope (the one warning that stops an author writing an area entry's key and expecting it to apply), and the no-materialisation rule. Two nits, neither blocking: the describe does not say who performs step (2)'s map resolution (resolveI18nLabel on the server, the renderer on the client), and the missing last rungs in item 4 above.

The JSDoc. Same sentences, plus two of its own: "the one place this step runs" — true now: lookupNavLabel is the only reader of apps.*.navigation.*.label under packages/*/src on main (git grep); objectui's useObjectLabel().navGroupLabel reads the same key but has no first-party caller at origin/main (definition only) and its docblock defers to translateApp. "(pinned in system/i18n-resolver.nav-label-identity.test.ts)" — true, the file is in the diff. One nit: the opening line "Resolved in ONE order, keyed by the entry's identity" is broader than steps (2)–(3), which key on presence, not identity; the body corrects it.

The changeset text. "read literally, forbids the id-keyed localization translateApp already performs at the /meta boundary" — true. The order it restates carries the same moments as the describe. "No accepted shape changes: label stays an optional I18nLabel" — true. Clause-②: no present.

The PR body. The order paragraph: as the describe. "3 tests, green at cefd0de416" — three it blocks; the head's Test Core runs are green. The ablation paragraph is consistent with the fixture by reading: leg A (a label equal to the target name takes the target's translation) changes only nav_account (the only objectName match) to 客户, leg B (a nav key spelled like the text) changes all three to 按文本-客户, 按文本-管道, 按文本-总览; both differ from the pinned ['account', 'pipeline', 'sales_overview'], so the no-id-key test is the one that reds, and the id-key test stays green as long as the id branch is consulted first — the reported failure strings are precisely those values. The pin's fixture also holds _views.pipeline.label and dashboards.sales_overview.label, so its coverage exceeds leg A's objectName-only rule. "Restore was proven: blob 1e7995c1148d equals HEAD" — f2fe3da47d:packages/spec/src/system/i18n-resolver.ts is 1e7995c1148d… and the file is not in the PR's file list. "45 table rows" — confirmed. "The metadata-forms bundles are registry-driven, not describe-driven" — the extractor keys navigation entries off item.label, never a describe; the platform-objects source-hashes name apps.*.navigation.*.label keys of app labels only. The Acceptance notes: all three are true at origin/main 1263e405de (guard present; resolveLabel keyed-form only; translateApp never walks areas[].navigation while validate-translation-references.ts:1179–:1183 walks app.areas[].navigation into the accepted key set; an id key applies to a label-absent entry).

The pin, statically. The app fixture has only name, label, navigation (the two required AppSchema keys plus the tree); each entry uses only keys its strict arm declares and snake_case ids; the bundles are locale → TranslationDataSchema with apps.crm.navigation.KEY.label under the strict navigation record — every safeParse is expected to pass, and CI confirms.

② Semver level

@objectstack/spec patch — right. The diff publishes a describe-string change inside the built schema and JSON schema, a regenerated reference page, and a test; no key, type, default, or accepted shape moves, and no consumer can break. Clause-②: no (the claim's Clause-②: no and the PR body's agree with the diff: description text and a pin, the key stays I18nLabelSchema, optional, no accept set moves).

③ Boundary flags

Dev flags, from the os-dev-report comment 5913034946:

  • deviations[0] PR assignee not set — resolved: PR docs(spec): state the one resolution order for a navigation entry's label #20875 now carries assignee os-justin (the seat's ruling said it would set it; it is set).
  • deviations[1] no labels written — resolved: the PR carries needs:contract-review and the auto labels; nothing the dispatch named is missing.
  • deviations[2] model-free trailer pair — verified on all three commits (the session-link trailer plus a model-free Co-authored-by), as AGENTS.md requires.
  • open_questions[0] (keep "never translated by matching its text" and the map clause though the renderer lags) — the seat ruled A; independently judged right, per ① items 3 and 5. Residual to escalate: the seat's ruling assigns "map-valued entry labels" to objectui#11201, but that card's ruling and stage-1 claim scope the guard and the producers only, and stage 1 explicitly excludes NavigationRenderer.tsx. The map-clause conformance (resolveNavItemLabel reading a nav entry label through resolveI18nLabel) has no explicit line on any objectui card. Zero reach today, so not blocking; the seat should name it on objectui#11201 stage 2 or file the objectui card, so the describe's step (2) has a carrier.
  • open_questions[1] (keep "(not areas)") — A, right: the claim is as narrow as the enforcement, and validate-translation-references.ts accepting areas[].navigation ids that translateApp never applies is a real latent mismatch. No app in the repo declares areas (grep of *.app.ts / *.app.json at main: 0), so unfiled is acceptable; see the second out-of-scope finding for its carrier state.
  • open_questions[2] (an id key wins over an absent label) — A, right: the text states the code. The recommendation's basis holds: the extractor's walkNavigation emits apps.APP.navigation.ID.label only if (id && item.label), so a platform bundle loses the key when the label goes; the "check:i18n treats a source-less key as drift" half is the dev's statement, not re-read here. objectui#11201 stage 2 should drop the id keys it orphans — a note for that card's seat.
  • out_of_scope_findings[0] (map-valued nav entry label renders empty in the sidebar; carrier objectui#11201, noted not filed) — same residual as open_questions[0]: carrier named by the seat, not by the carrier's own text. Escalate as above.
  • out_of_scope_findings[1] (area entry ids validate as nav keys but never apply at /meta; carrier PR feat(spec): the picklist metadata kind — a shared option list select fields reference by name (#19518) #20823, noted not filed) — the carrier is stale: feat(spec): the picklist metadata kind — a shared option list select fields reference by name (#19518) #20823 merged to main (addbbf02ab) without it, so this finding now has no carrier at all. Zero reach keeps it non-blocking; the seat should file the p4 card or name the next i18n-resolver.ts PR that carries it.

Nothing in the diff breaches the claim's file surface: i18n-resolver.ts and i18n-resolver.test.ts are untouched, no objectui or skills text is edited, no schema key moves.

Check-runs on the head, deduped by name keeping the newest started_at: 35 distinct, all completed, none running. 31 success — Build Core, Build Docs, Check Changeset, Check Documentation Links, Dogfood Regression Gate (rollup, 1/3, 2/3, 3/3), Dogfood Verify CLI, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance (live PG + MySQL), Test Core (rollup, 1/6 through 6/6), The card this PR closes must claim this branch, Type Check (consumer gates, debt ledger, source gates, workspace), TypeScript Type Check, filter. 4 skipped by their own condition, not failures — Auto Label and Check PR Size (newest re-run on the label event; their earlier runs succeeded), Console Pin Gate (the console path filter is off for this diff), Packed-tarball smoke (opt-in label absent). The two gate families the dev could not measure locally (check:type-check-debt, check:dual-build-cjs-loads) are answered green by Type Check · debt ledger and Lint & Repo Gates / Build Core.

Implemented-by: claude/issue-20849-nav-label-resolution-order
Reviewed-by: session_01Sfe5YjBLwB9J3y8fvm2xq1

VERDICT: PASS

Adopted and posted by domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1) · 2026-09-30T14:41Z · rendered by the seat's at-tier review subagent on this head. The seat read its served tier family from the subagent transcript before posting.


Generated by Claude Code

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

Projects

None yet

2 participants