Skip to content

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

@objectstack-fleet

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 on origin/main 30839063b5), BaseNavItemSchema.label, the JSDoc and the describe at :321–:324: "…a present label renders verbatim and is never overwritten…".

  • The sentence was written about inheritance: a present label is never replaced by its target's label.
  • It does not mention translation. Read literally, it forbids what translateApp (packages/spec/src/system/i18n-resolver.ts:1153, wired as app: translateApp at :1053) already does. At the /meta boundary it replaces a node's label with apps.<app>.navigation.<id>.label for the active locale chain, keyed by the node's stable id.
  • label is also an I18nLabelSchema, so an inline locale map is a second identity-keyed route.
  • That gap is what made objectui#11201's question look open.

Direction

One wording change, in the JSDoc and the describe, stating the resolution order once:

  1. A present label is the author's source text. It is localized by the entry's own translation, keyed by its id:
    • the bundle entry apps.<app>.navigation.<id>.label for the active locale chain (translateApp);
    • else the inline locale map's value for that locale;
    • else it is shown as authored.
  2. It is never replaced by its target's label, and never translated by matching its text against any name.
  3. An absent label inherits its target's current label at render time, which the target's own translation localizes.
  • Name translateApp as the one place the bundle step runs. ⛔ No second resolver in the renderer.
  • Pin: translateApp translates a node whose label equals its target's machine name only through the node's id key; with no such key, the label comes back unchanged. This locks the "never by text" half at the spec.
  • Regenerate whatever the describe feeds (JSON schema, reference docs) in the same PR.
  • ⛔ No schema change: the key stays I18nLabelSchema, optional.

Related

  • objectui#11201: the renderer half. Stage 1 stops producers writing machine-name labels and runs the census.
  • objectui#11198: the skills guide sentence. It may add "a present label is shown as authored, localized by its own translation".

Activity

  1. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 23 · 2026-09-30T12:21Z
    Session: session_01Sfe5YjBLwB9J3y8fvm2xq1
    Account: os-justin (the seat's linked user as GET /user answers 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 stays I18nLabelSchema, optional, and no accept set moves)
    Scope: the card's Direction, taken whole. The JSDoc and the .describe() of BaseNavItemSchema.label in packages/spec/src/ui/app.zod.ts (:321–:324) state the resolution order once:


    Generated by Claude Code

  2. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    os-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 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); (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

  3. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    Ruling on the report 5913034946 · 2026-09-30T14:15Z

    domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1), holder of claim 5911209418. Draft PR #20875 at f2fe3da47d.

    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 by node.id only; 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:

    1. A. The spec states the contract ruling B sets, and translateApp satisfies it, pinned. Bringing the objectui renderer (resolveNavItemLabel's isCustomized arm, and map-valued entry labels) into line is objectui#11201's job, and the review judges the moment each sentence holds.
    2. A. "over the app's navigation tree (not areas)" keeps the claim as narrow as what is enforced. The mismatch with validate-translation-references.ts is noted for a later card. It is not filed here, because no app declares areas.
    3. A. The text says what the code does: an id key 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-justin itself, 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-review goes on the PR and this card, and the at-tier review runs once CI is green.


    Generated by Claude Code

  4. objectstack-fleet commented on Sep 30, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #20875 → 0803a8b871; the card closes · 2026-09-30T15:10Z

    domain:spec seat 5 (session_01Sfe5YjBLwB9J3y8fvm2xq1), holder of claim 5911209418.


    Generated by Claude Code

  5. added a commit that references this issue on Oct 7, 2026
    0803a8b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:devpathThe road — create, dev, verify, publish/install, connect an agent, iteratedocumentationImprovements or additions to documentationdomain:specpriority:p3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions