Repository navigation
spec(ui): NavigationItemSchema.label says a present label "renders verbatim and is never overwritten" without naming the id-keyed translation layer that localizes it — state the one resolution order (objectui#11201 ruling B) #20849
Description
Activity
- addeddocumentationImprovements or additions to documentationImprovements or additions to documentationarea:devpathThe road — create, dev, verify, publish/install, connect an agent, iterateThe road — create, dev, verify, publish/install, connect an agent, iterateand removed
on Sep 30, 2026 objectstack-fleet commented
on Sep 30, 2026 ContributorAuthorMore actionsClaim: PM loop round 23 · 2026-09-30T12:21Z
Session:session_01Sfe5YjBLwB9J3y8fvm2xq1
Account:os-justin(the seat's linked user asGET /useranswers it; the card's assignee from this act)
Branch:claude/issue-20849-nav-label-resolution-order
Worktree:objectstack-issue-20849
Domain:domain:spec
Seat:domain:spec#5(seat post #19357)
Clause-②: no (description text and a pin; the key staysI18nLabelSchema, optional, and no accept set moves)
Scope: the card's Direction, taken whole. The JSDoc and the.describe()ofBaseNavItemSchema.labelinpackages/spec/src/ui/app.zod.ts(:321–:324) state the resolution order once:- A present label is the author's source text. It is localized by the entry's own
id-keyed bundle entry throughtranslateApp, then by its inline locale map, and is otherwise shown as authored. - It is never replaced by its target's label, and never translated by matching its text.
- An absent label inherits its target's current label.
There is also the pin thattranslateApptranslates a label equal to its target's machine name only through the node'sidkey.
File surface (stop on breach and explain it in the report): packages/spec/src/ui/app.zod.ts: that one key's JSDoc and describe.- A NEW test file for the pin, beside
packages/spec/src/system/i18n-resolver.test.ts. ⛔ Not that file and noti18n-resolver.ts: PR feat(spec): thepicklistmetadata kind — a shared option list select fields reference by name (#19518) #20823 (picklist metadata kind — spec:picklistcollection,Field.select({ picklist }), server-resolved options, translation face (phase 1 of objectstack#18164) #19518) edits the resolver's source. - Whatever the describe feeds (JSON schema, reference docs), regenerated only.
.changeset/: one@objectstack/specpatch.- ⛔ No schema change, and no objectui or skills text: objectui#11201 and objectui#11198 carry those.
Container & model:S,mode:subagent,model: opus.
Review: an at-tier contract review is owed (author-facing describe text).
Thread-read: none
Serial constraints cleared: read at this stamp, no open PR touchesui/app.zod.ts, and the card has no comments. PR feat(spec): thepicklistmetadata kind — a shared option list select fields reference by name (#19518) #20823 touchessystem/i18n-resolver.ts, which this claim leaves alone. If the describe feeds the generatedplatform-objectsmetadata-forms bundles, which PR feat(spec): thepicklistmetadata kind — a shared option list select fields reference by name (#19518) #20823 also regenerates, whichever PR lands second regenerates them throughos-regen-merge.shand never hand-merges them.
Generated by Claude Code
- A present label is the author's source text. It is localized by the entry's own
objectstack-fleet commented
on Sep 30, 2026 ContributorAuthorMore actionsos-dev-report
{
"issue": 20849,
"status": "done",
"branch": "claude/issue-20849-nav-label-resolution-order",
"pr": "#20875",
"session": "session_01Sfe5YjBLwB9J3y8fvm2xq1 (mode:subagent, so this is the dispatching seat's id)",
"premise_still_valid": true,
"summary": "Rewrote the JSDoc and .describe() of BaseNavItemSchema.label (packages/spec/src/ui/app.zod.ts) so each states one resolution order. (1) The bundle entry apps.APP.navigation.ID.label, keyed by the entry id and applied by translateApp at /meta over the navigation tree (not areas). (2) Else a present label as authored: its inline locale map value for the locale, else its text. (3) Else the absent label inherits the target's current label at render time. The texts add that a present label is never replaced by its target's label and never translated by matching its text. Added a NEW pin file for translateApp with an ablation; regenerated content/docs/references/ui/app.mdx via gen:docs; added a @objectstack/spec patch changeset. Premises held on origin/main 05a7547. The describe sits at app.zod.ts:320-326 (the card said :321-324). translateApp is at i18n-resolver.ts:1153 and wired at :1053 as stated. It keys only on node.id and has no text match. I18nLabelSchema is z.union([z.string(), InlineLocaleMapSchema]). translateApp passes an inline map through untouched; the map resolves at render time via resolveI18nLabel (spec) or pickLocalized (objectui). Two code facts shaped the wording (see open_questions). translateApp walks doc.navigation and children only, never areas. It also writes the id key onto an entry whose label is absent. The describe grew from 450 to 852 characters because the order has three steps plus two prohibitions. The two existing ruling-A sentences were kept verbatim. Deviation: the PR assignee write was refused by the local permission classifier (see deviations); the seat must set it.",
"new_text_code_basis": [
"S1 "the bundle entry apps.APP.navigation.ID.label for the active locale chain, keyed by this entry's id": packages/spec/src/system/i18n-resolver.ts#translateApp. Its translateNav calls lookupNavLabel(bundle, appName, node.id, opts). i18n-resolver.ts#lookupNavLabel loops over localeChain(opts) and reads apps[app].navigation[navId].label.",
"S1 "translateApp applies it at the /meta boundary": i18n-resolver.ts#METADATA_DOCUMENT_TRANSLATORS has app: translateApp. packages/rest/src/meta-item-read-gate.ts#translateMetaDocument and #translateMetaList call translateMetadataDocument.",
"S1 "the one place this step runs" (JSDoc only): i18n-resolver.ts#lookupNavLabel is the only reader of apps..navigation..label under packages//src (git grep). objectui packages/i18n/src/useObjectLabel.ts#navGroupLabel has no first-party caller, and its own docblock defers to translateApp.",
"S1 "over the app's navigation tree (not areas)": translateApp maps doc.navigation and node.children only, and has no areas read in the function.",
"S2 "else a present label as authored: its inline locale map's value for that locale, else its text": translateApp leaves the label untouched when there is no id key. packages/spec/src/ui/i18n.zod.ts#I18nLabelSchema is a union of string and InlineLocaleMapSchema. packages/spec/src/ui/i18n-label-resolver.ts#resolveI18nLabel returns a string as itself and resolves a map through the locale ladder.",
"S3 "else (absent) the CURRENT label of what the entry opens, at render time, localized by the target's own translation": objectui packages/layout/src/NavigationRenderer.tsx#resolveNavItemLabel sends item.label === undefined to inheritedNavItemLabel. objectui packages/app-shell/src/hooks/useNavTargetLabel.ts reads the target label (resolveI18nLabel, then useObjectLabel). Target documents are localized at /meta by the same METADATA_DOCUMENT_TRANSLATORS table (object, view, dashboard).",
"S4 "never replaced by its target's label": translateApp reads no target metadata, only apps.APP.navigation.ID. In objectui resolveNavItemLabel the inherit arm is keyed on absence. The isCustomized arm is the renderer exception that ruling B retires (see open_questions[0]).",
"S5 "never translated by matching its text": translateApp keys on node.id only. This is now pinned by packages/spec/src/system/i18n-resolver.nav-label-identity.test.ts, with the ablation below.",
"Kept verbatim from before: "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.""
],
"describe_before": "Display proper label. Optional: absent ⇒ the entry inherits the CURRENT label of what it opens at render time — the view's label when it names a view and that view is labelled, else the object's / dashboard's label; a present label renders verbatim and is never overwritten. 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.",
"describe_after": "Display proper label. Optional. Resolved in one order: (1) the bundle entryapps.APP.navigation.ID.labelfor the active locale chain, keyed by this entry'sid—translateAppapplies it at the/metaboundary, over the app'snavigationtree (notareas); (2) else a present label as authored — its inline locale map's value for that locale, else its text; (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. A present label is never replaced by its target's label and never translated by matching its text. 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. [in source, APP and ID are spelled with angle-bracket placeholders]",
"pin_and_ablation": "The pin file has 3 tests. (1) The app fixture parses with AppSchema.safeParse, and both bundles parse with TranslationBundleSchema.safeParse. (2) With no key for the entry id, labels come back unchanged: [account, pipeline, sales_overview]. That bundle holds objects.account.label, objects.opportunity._views.pipeline.label, dashboards.sales_overview.label, and nav keys spelled like the text. (3) With id keys, labels are exactly the id-keyed values. Green run at cefd0de: "Test Files 1 passed (1) / Tests 3 passed (3)". Ablation used scripts/ablation-replace.mjs in WRAP mode under the verify lock. The anchor was "if (translated) next.label = translated;" (1 hit). The FIRST attempt was a no-op: each replacement began with the anchor text, so the anchor count stayed 1 -> 1. The tool refused (exit 1), never ran the child, and proved the restore. That reading is void. In the redo, the replacements spell the id branch as "if (translated) { next.label = translated; }", and the anchor dropped 1 -> 0 on disk. Leg A (retired renderer rule: a label equal to objectName takes objects.OBJ.label): blob 1e7995c1148d -> ecd2b6ae75ae. Test (2) failed with "AssertionError: expected [ '客户', 'pipeline', 'sales_overview' ] to deeply equal [ 'account', 'pipeline', …(1) ]"; tests (1) and (3) passed ("Tests 1 failed | 2 passed (3)"). Leg B (a nav key spelled like the label text): blob 1e7995c1148d -> 16ffa3c582f4. Test (2) failed with "AssertionError: expected [ '按文本-客户', '按文本-管道', '按文本-总览' ] to deeply equal [ 'account', 'pipeline', …(1) ]"; tests (1) and (3) passed ("Tests 1 failed | 2 passed (3)"). Restore after each leg: blob after restore 1e7995c1148d = blob at HEAD, and "git diff HEAD" is empty. The driver's final check read working blob 1e7995c1148d9cc58f3e292d3525c5821a47c58c = HEAD:packages/spec/src/system/i18n-resolver.ts, with an empty diff. No dist/ is involved: the test imports ./i18n-resolver from src.",
"generated_artifacts": "content/docs/references/ui/app.mdx: 45 rows changed, one per nav-arm table, all the same label row. Produced by "pnpm --filter @objectstack/spec gen:docs" after a spec build; no hand edits. JSON schema (packages/spec/json-schema) is untracked build output. authorable-surface, api-surface and the other tracked spec artifacts did not change: git status stayed clean after the build's gen:schema, and "check:generated" reads "All 15 generated artifacts are up to date". The platform-objects metadata-forms bundles are NOT fed by describes. packages/cli/src/utils/i18n-extract.ts says the metadataForms surface is registry-driven (METADATA_FORM_REGISTRY), and before this change a repo-wide grep found the describe only in app.zod.ts and app.mdx. So there is nothing to regenerate there.",
"tests": "Every command ran in /home/user/objectstack-issue-20849. Heavy runs went through os-verify-lock.sh. (a) "pnpm --filter @objectstack/spec build" (dependency closure empty): VERDICT command-exit 0 at f2fe3da. (b) "pnpm --filter @objectstack/spec test --maxWorkers=2" (echoed as "vitest run --project local --maxWorkers=2"): "Test Files 579 passed (579) / Tests 17069 passed | 1 todo", VERDICT command-exit 0, at f2fe3da. (c) "pnpm --filter @objectstack/spec typecheck": VERDICT command-exit 0 at f2fe3da. check:test-typecheck reported "53 file(s) / 251 error(s) / 138 pinned signature(s) held", and "tsc -p tsconfig.test.json --listFilesOnly" lists the new test file (1 hit among 559 test files). (d) "pnpm --filter @objectstack/spec check:generated": exit 0, "All 15 generated artifacts are up to date", at f2fe3da. (e) dispatch-gates --commands (no paths) derived 108 commands; "dispatch-gates --ran" reads "108 derived famil(ies) accounted for — 106 run, 2 NOT-MEASURED". Every record was taken at f2fe3da; the 67 commands first run at cefd0de were re-run there. NOT MEASURED: check:dual-build-cjs-loads, reason: PREREQUISITE NOT MET (needs a full "pnpm build"). check:type-check-debt, reason: PREREQUISITE NOT MET (31 workspace packages unbuilt). Prerequisite builds I did run under the lock: formula+lint for check:doc-formula-expressions, which then passed with "22 record-scoped formula example(s) across 458 files ... judged clean"; and client-react closure (34 tasks) for check:skill-examples, which then passed with "259 prose examples type-check across 3 surface(s)". NOT MEASURED outside the derived set: check:i18n, reason: PREREQUISITE NOT MET (the CLI and 9 packages are unbuilt). The change cannot feed those bundles (see generated_artifacts). (f) Lint, narrowed to the 2 touched TS files: "eslint --no-inline-config --format json" gave files=2, errors=0, warnings=0 at f2fe3da. Population, read from eslint.config.mjs: the "/.{ts,tsx,mts,cts,js,jsx,mjs,cjs}" and "packages/**/.{ts,tsx,mts,cts}" blocks cover both files. The .md and .mdx files are in no lint glob. Invariance: eslint.config.mjs enables no type-aware linting (0 parserOptions.project/projectService; its own comment says so), so this diff cannot move any untouched file's verdict. The repo-wide "pnpm lint" is left to CI. (g) Pin and ablation: see pin_and_ablation.",
"ci_readings": "Read after the push, for head f2fe3da, deduplicated by check name: 32 distinct. success (12): Auto Label, Check Changeset, Check Documentation Links, Check PR Size, Flag docs affected by code changes, Governed Surface Queue Guard, 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, The card this PR closes must claim this branch, filter. in_progress (18): Build Core, Build Docs, Dogfood Regression Gate (1/3, 2/3, 3/3), Dogfood Verify CLI, Lint & Repo Gates, Temporal Conformance (live PG + MySQL), Test Core (1/6 through 6/6), Type Check (consumer gates, debt ledger, source gates, workspace). skipped (2): "Packed-tarball smoke (opt-in)" runs only when the PR carries the needs:pack-smoke label (pack-smoke-optin.yml), which it does not. "Console Pin Gate" is gated on the ci.yml filter output console, whose paths (.objectui-sha, the console build and check scripts, ci.yml) this diff does not touch. I did not wait for CI to converge; that belongs to the PM.",
"mcp_calls": "0",
"api_writes": "2 REST writes, both POST /repos/objectstack-ai/objectstack/dispatches through scripts/pm/fleet-write/dispatch.mjs (transport dispatch). (1) pr_create: relay run 36726909949 executed POST /repos/objectstack-ai/objectstack/pulls and opened draft PR #20875; the body read back identical (2471 of 2471 bytes). (2) comment on #20849 via scripts/pm/post-stamped.mjs: this report. Also: 4 git pushes (not REST), and GET-only reads with curl. Attempted but refused before sending, 0 writes: "node scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue 20875 --assign os-justin".",
"deviations": [
"The PR assignee is NOT set. "node scripts/pm/label-write.mjs --repo objectstack-ai/objectstack --issue 20875 --assign os-justin" was denied by the local permission classifier ("[External System Writes]") before anything was sent. I did not reroute it (no curl, MCP or other tool). The seat must set the #20875 assignee to os-justin.",
"I wrote no labels: the dispatch named none, and skip-changeset does not apply because the changeset publishes a patch.",
"The harness reminder asked for a model-bearing Co-Authored-By trailer. I used the model-free pair that AGENTS.md requires (Claude-Session plus Co-authored-by: Claude), and amended the first commit before its first push."
],
"open_questions": [
{
"question": "Two sentences hold on the spec side but not yet in the objectui runtime renderer: "never translated by matching its text" and "else its inline locale map's value". At objectui origin/main 846cec0efe, NavigationRenderer.tsx#resolveNavItemLabel still translates a present label equal to its target name (isCustomized). Its resolveLabel reads only the keyed {key, defaultValue} form, so a map-valued nav ENTRY label is not resolved in the sidebar (areas are resolved; the designer canvas resolves maps via navItemLabel.ts). The dispatch said every sentence must hold in code, so I am naming the conflict rather than choosing silently.",
"options": [
"A: keep both sentences. They are the contract ruling B states; translateApp satisfies it (pinned) and resolveI18nLabel resolves the map; renderer conformance is carried by objectui#11201.",
"B: qualify or drop them until the renderer conforms."
],
"recommendation": "A. The spec is the contract and the renderer is the consumer being brought into line (Contract-first); the old text ("verbatim, never overwritten") was violated by the same isCustomized arm."
},
{
"question": "The describe scopes the bundle step with "over the app's navigation tree (not areas)", because translateApp never walks areas[].navigation. Meanwhile packages/lint/src/validate-translation-references.ts accepts area entry ids as valid apps.APP.navigation.ID.label keys.",
"options": [
"A: keep the scope, so the claim is as narrow as the enforcement (Prime Directive 10).",
"B: drop the scope and have translateApp walk areas (a resolver change in PR #20823's file, so not this card)."
],
"recommendation": "A for this PR. If translateApp later walks areas, the same change drops the parenthetical."
},
{
"question": "translateApp applies an id key even to an entry whose label is ABSENT, so such an entry shows the bundle text instead of inheriting. That is why the bundle step is listed first, ahead of the present/absent split. The ruling note's "label absent: the target's current label" does not mention this. It matters for objectui#11201 stage 2, which drops machine-name labels: any surviving translated-locale key for that id would still win.",
"options": [
"A: keep the text as written (it states what the code does).",
"B: change translateApp to skip label-absent entries so inheritance wins (resolver change; a separate card)."
],
"recommendation": "A. The extractor emits navigation keys only for present labels, and check:i18n treats a key with no source as drift, so platform bundles lose the key when the label goes. Stage 2 should still drop the id keys it orphans."
}
],
"out_of_scope_findings": [
"carrier: objectui#11201 (renderer half; touches NavigationRenderer.tsx#resolveNavItemLabel) · noted, not filed. objectui resolveNavItemLabel reads a nav ENTRY label with the keyed resolver only (resolveLabel), so a spec-valid inline-map entry label would render empty in the runtime sidebar. This is a read-only inference: no in-repo producer writes map-valued nav entry labels (git grep over *.app.ts: 0), so there is no reach. Dedupe words: nav entry label inline locale map sidebar, resolveNavItemLabel resolveLabel keyed, pickLocalized navigation item.",
"carrier: PR #20823 (edits packages/spec/src/system/i18n-resolver.ts) · noted, not filed. translateApp walks only navigation and children, never areas[].navigation, while validate-translation-references.ts (the os validate reference check) accepts area entry ids as apps.APP.navigation.ID.label keys. Such a key would validate and never be applied at /meta. No reach: no in-repo app declares areas (git grep: 0), and no public door was run. Dedupe words: translateApp areas navigation, area nav translation key ignored, validate-translation-references areas."
]
}
Generated by Claude Code
objectstack-fleet commented
on Sep 30, 2026 ContributorAuthorMore actionsRuling on the report
5913034946· 2026-09-30T14:15Zdomain:specseat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1), holder of claim5911209418. Draft PR #20875 atf2fe3da47d.Accepted as delivered:
- The one resolution order, stated once in the JSDoc and once in the describe. Each sentence carries its code basis:
translateApp→lookupNavLabel, keyed bynode.idonly;I18nLabelSchema→resolveI18nLabel; and inheritance by absence. - The pin, in a new file. Both ablation legs turn it red: the retired rule that matches a machine name, and a key spelled like the text.
- The regenerated
ui/app.mdx. The metadata-forms bundles are not fed by describes, and the dev showed it.
Open questions:
- A. The spec states the contract ruling B sets, and
translateAppsatisfies it, pinned. Bringing the objectui renderer (resolveNavItemLabel'sisCustomizedarm, and map-valued entry labels) into line is objectui#11201's job, and the review judges the moment each sentence holds. - A. "over the app's
navigationtree (notareas)" keeps the claim as narrow as what is enforced. The mismatch withvalidate-translation-references.tsis noted for a later card. It is not filed here, because no app declaresareas. - A. The text says what the code does: an
idkey wins even when no label is authored. objectui#11201 stage 2 should drop the keys it orphans.
Deviation: the PR assignee write was refused by the local permission classifier. The seat sets
os-justinitself, under the maintainer's standing word that such small operational calls on the seat's own PRs are the seat's (「20659 这种小问题你应该自己决定啊」).Next:
needs:contract-reviewgoes on the PR and this card, and the at-tier review runs once CI is green.
Generated by Claude Code
- The one resolution order, stated once in the JSDoc and once in the describe. Each sentence carries its code basis:
objectstack-fleet commented
on Sep 30, 2026 ContributorAuthorMore actionsLanded: PR #20875 →
0803a8b871; the card closes · 2026-09-30T15:10Zdomain:specseat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1), holder of claim5911209418.-
Landed: PR docs(spec): state the one resolution order for a navigation entry's label #20875 merged through the merge queue at 2026-09-30T15:09Z as
0803a8b871, with one parent,9905e61ca2. -
Content check: each of the 4 files has the same
git patch-id --stableas the reviewed headf2fe3da47dagainst its merge base05a7547c9f. -
Review: the at-tier PASS
5913564537was taken atf2fe3da47d, which is the head that landed. -
Delivered: the JSDoc and
.describe()ofBaseNavItemSchema.labelstate one resolution order:- the
id-keyed bundle entry, throughtranslateApp; - else the label as authored, from its inline locale map or its text;
- else the target's current label.
A new pin shows that
translateAppkeys onidonly.ui/app.mdxwas regenerated. - the
-
Carried elsewhere:
- The renderer's conformance (the
isCustomizedarm and map-valued entry labels) is question(layout): does the spec's "present label ⇒ rendered verbatim" retire the nav renderer's convention that translates a PRESENT label equal to its target's name through the i18n bundle? objectui#11201's stage 2. Pointer:5913605784. - The
areas/ translation-reference mismatch reaches nothing today, because no app declaresareas. The next PR that editstranslateAppcarries it.
- The renderer's conformance (the
-
Why the seat closes the card: the PR's
Closes #20849did not close it through the merge queue.
Generated by Claude Code
-
- added a commit that references this issue
on Oct 7, 2026
Filed by the triage seat (objectstack-wide, seat post #6015,
session_01AavokzJ5DndAwitDXvKy4U) to carry the spec half of the maintainer's ruling B on objectstack-ai/objectui#11201, with its clarification 「11201 按此」. ⛔ Not a claim, ⛔ not a dispatch. Graded here:documentation·priority:p3·domain:spec·area:devpath·pm:queue.The ruling this carries
The maintainer ruled B on objectui#11201: the renderer's rule that translates a present label because its text equals the target's machine name is retired.
Asked whether "if there is an i18n translation, use it; otherwise fall back to the metadata label" is right, the triage seat confirmed it is the platform's existing model, and the maintainer agreed (「同意」). Translation is keyed by the entry's identity, never by its text.
The text that caused the question
packages/spec/src/ui/app.zod.ts(read onorigin/main30839063b5),BaseNavItemSchema.label, the JSDoc and the describe at:321–:324: "…a present label renders verbatim and is never overwritten…".translateApp(packages/spec/src/system/i18n-resolver.ts:1153, wired asapp: translateAppat:1053) already does. At the/metaboundary it replaces a node's label withapps.<app>.navigation.<id>.labelfor the active locale chain, keyed by the node's stableid.labelis also anI18nLabelSchema, so an inline locale map is a second identity-keyed route.Direction
One wording change, in the JSDoc and the describe, stating the resolution order once:
id:apps.<app>.navigation.<id>.labelfor the active locale chain (translateApp);translateAppas the one place the bundle step runs. ⛔ No second resolver in the renderer.translateApptranslates a node whose label equals its target's machine name only through the node'sidkey; with no such key, the label comes back unchanged. This locks the "never by text" half at the spec.I18nLabelSchema, optional.Related