Skip to content

feat(spec,rest,lint,cli): a form view's title is translated through objects.OBJECT._views.VIEW.title, and GET /forms/:slug serves it in the visitor's language - #25035

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24969-form-view-title-key
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24969-form-view-title-key

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Closes #24969

Clause-②: yes (widening)

What changes

A public form could not be titled in more than one language. FormViewSchema.title had no translation key: the _views.VIEW translation entry named title as a rename onto label, translateView never read config.title, and GET /forms/:slug answered the top-level label from the matched view before translation. A form that declared a title served it in the authoring language, so declaring one replaced a translated heading (the object label) with an untranslated one in every other locale.

  • Spec (packages/spec/src/system/translation.zod.ts): objects.OBJECT._views.VIEW.title is a declared key. The title -> label alias on that entry is removed, and heading and formTitle are named as renames onto title.
  • Resolver (packages/spec/src/system/i18n-resolver.ts): translateView overlays the key onto a served form item's config.title (new translateFormTitle), through the same one _views read (lookupViewText) as the view's label and description. It follows the rules every other view string follows: only an authored title is translated (a form without one gains none), a missing key keeps the authored title, and an explicit override beats the catalog against the packaged view (ADR-0029 D9.2a, valueOverridesPackagedBase).
  • REST (packages/rest/src/form-routes.ts): the served form.title is the translated one, because the route already serves the translated view's config. The top-level label is now taken from the translated view when the view authors a label. When the view authors none, the route still answers none, because the translator falls back to the view's machine name and that must not shadow the title. The console's payload.label ?? form.title ?? objectSchema.label stays meaningful: all three arrive translated, in the same order.
  • objectstack validate (packages/lint/src/validate-translation-references.ts): a new forms fact set holds the _views keys of every form the composer serves: formViews.KEY entries, the default form (form or its name, asked of expandViewContainer), and viewKind: 'form' items. A title key over a form resolves. Over a list view it is refused, as translation-target-unknown, with: View "all_leads" of object "crm_lead" is not a form view, so no title resolves this key. The fix line points at _views.VIEW.label. The default form's key is legal for title alone, and its other tails stay non-keys as before.
  • CLI extractor (packages/cli/src/utils/i18n-extract.ts): it emits _views.VIEW.title for every form a container serves, keyed by the composer's runtime name (new pushContainerFormTitles), and for a viewKind: 'form' item. So os i18n extract scaffolds the slot, and check:i18n-coverage reports an authored title that a supported locale leaves untranslated.
  • Showcase: examples/app-showcase authors title: 'New contact' on formViews.create. The new slot raised its untranslated count by one (414 to 415), so the zh-CN bundle now carries _views.create.title.

Why its own key, not the view's label key

The card asked for one of two options: reuse _views.VIEW.label if title aliases label, or add a key. I read the schemas. The title -> label alias exists only on the TRANSLATION entry (a "did you mean" for a refused key). The authored shapes keep them separate:

  • ViewItem.label names the view.
  • FormViewSchema.title is the heading the form draws.
  • A container's form entries (formViews.KEY, form) carry no label at all, because FormViewSchema declares none. expandViewContainer stamps label: v.label, which is undefined.
  • A viewKind: 'form' item can author both, with different text, and the console prefers label.

So they are two source strings. If both used one key, one translation would stand for two different strings. The new key mirrors the authored key, the way emptyState.title does. It sits in the same _views node and uses the same view key, so it adds no second key convention.

Tests

Measured at head 6f43b6b2e0, which is merged with origin/main 73432299c0.

  • Repro first. Commit 87d4974eab adds the tests before the fix.
  • Reverse verification. After committing the fix, I restored the five source files to base 7532ea3c08, in the working tree only. The mutation was confirmed on disk: translateFormTitle count 0, forms.add count 0, pushContainerFormTitles count 0, servedLabel count 0. I rebuilt spec, and ablation-dist-preflight --absent found the marker in 0 of 232 built files. Results:
    • spec: 6 failed | 162 passed. The new resolver cases failed, and so did the title and heading schema cases.
    • rest: 3 failed | 6 passed. These were the translated title, the label-not-machine-name case and the authored-label case. The "no title" controls stayed green.
    • lint: 3 failed | 188 passed.
    • cli: 4 failed | 1 passed.
    • Restored with git checkout HEAD -- PATHS. git diff HEAD was empty and git status --porcelain was clean. After the spec rebuild, the preflight found the marker in 6 built files.
  • Controls.
    • A form with no title gains none, and /forms/:slug still answers no label, so the heading falls back to the translated object label (工单).
    • The label key does not reach the title, and the title key does not reach the label.
    • An en request is served as authored.
    • A title edited by an environment overlay keeps the edit. This runs on the real ObjectStackProtocolImplementation over a real SchemaRegistry.
    • A list view's label key is still accepted.
    • The _views entry stays closed.
  • Package runs.
    • Pre-merge, at 32124e9b0a plus the showcase commit:
      • pnpm --filter @objectstack/spec test: 704 files, 21381 passed. pnpm --filter @objectstack/spec typecheck passed too.
      • pnpm --filter @objectstack/rest test: 308 files, 6166 passed.
      • pnpm --filter @objectstack/lint test: 152 files, 7030 passed.
      • pnpm --filter @objectstack/cli exec vitest run --project unit: 4654 of 4655 passed. The one failure was boot-preparation-parity.test.ts, which hit its 5000 ms timeout under shared-box load. It passes alone, and this diff does not touch serve.ts or that file.
    • After the merge, re-run on 6f43b6b2e0:
      • the touched suites passed: spec 3 files/181, rest 2 files/23, lint 1 file/191, and cli 5 files/102 (including boot-preparation-parity);
      • typecheck passed for rest, lint and cli.
    • The cli integration tier is declared to CI.
  • Generated artifacts. pnpm --filter @objectstack/spec check:generated reports all 14 up to date. --fix regenerated content/docs/references/system/translation.mdx. check:authorable-surface, check:liveness and check:strictness-ledger were already green; the objects liveness row is live and undrilled.
  • i18n gates.
    • pnpm check:i18n: OK, 10 packages in sync.
    • pnpm check:i18n-coverage: OK, 14 configs, 621 baselined, none new.
    • pnpm check:i18n-walk-parity: OK.
    • pnpm check:i18n-stale-fill: OK.
    • pnpm check:nul-bytes: OK.
  • Derived gates. dispatch-gates --commands derived 117 commands on 6f43b6b2e0, the same list as before the merge. 116 exited 0. pnpm check:type-check-debt is NOT MEASURED: its closure build queued on the shared verify lock behind a sibling run and timed out (exit 99, then exit 3). check:type-check-coverage exited 0, and the rest, lint and cli typecheck runs above passed.
  • eslint, narrowed to the 11 touched source and test files (--no-inline-config --format json): 11 files, 0 errors, 0 warnings, and no file was ignored. The config declares no type-aware linting (no parserOptions.project), so this diff cannot change the verdict on any untouched file.

Review tier

packages/spec is a published schema, so this PR owes a CONTRACT_REVIEW_TIER review of record before it enters the queue (pm-dispatch references/contract-review.md). It touches no governed surface, so no Tier S or Tier H landing rule applies.

Acceptance notes

  • Open PRs on overlapping files. I checked these after the merge. Only feat(spec,lint): a bulk action's confirmText is a template slot over count and its params, judged at validate, compile and the publish gate #24947 touches i18n-extract.ts, in pushBulkActionDefs (confirmText), and the hunks do not overlap. No other open PR touches translation.zod.ts, i18n-resolver.ts, form-routes.ts or validate-translation-references.ts. spec/i18n: a page:tabs item label has no translation key, and translatePage never translates it, so a localized page keeps its tab labels in the authoring language #24968 is not open as a PR yet.
  • Inference, not measured: a form formViews.KEY that retargets another object (its own data.object). The lint rule files its _views key under the form's own binding, as it already does for that form's label. translateView and the new extractor slot key it under the container object (ExpandedViewItem.object). The label key already has the same split. No shipped example retargets a form view.
  • Inference, not measured: a viewKind: 'form' item. It is a ViewItem whose name is spec-qualified as OBJECT.KEY. The extractor's view.name branch and the lint rule both use the raw name as the _views key, while translateView strips the object prefix. The new title slot follows that branch's existing key, so it inherits the behaviour of label there. No shipped example authors one.
  • The default form's _views.form.label and description are read by translateView for the served OBJECT.form item, but lint still calls them non-keys. Its documented reason is that _views.* is a list-view convention. This PR registers that key for title only and leaves the earlier decision alone.
  • FormViewSchema.title's own .describe() was not edited to name the key. The translation face documents the key, and editing the describe would churn the JSON schema and docs for no behaviour change.

Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cli, @objectstack/lint, @objectstack/rest, @objectstack/spec, touching 14 documentable anchor(s).

7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/ai/skills-reference.mdx (via formViews (literal, a string literal in collectViewRecord))
  • content/docs/protocol/kernel/i18n-standard.mdx (via formViews (literal, a string literal in collectViewRecord))
  • content/docs/protocol/kernel/plugin-spec.mdx (via formViews (literal, a string literal in collectViewRecord))
  • content/docs/ui/create-vs-edit-form.mdx (via formViews (literal, a string literal in collectViewRecord))
  • content/docs/ui/forms.mdx (via formViews (literal, a string literal in collectViewRecord))
  • content/docs/ui/public-data-collection.mdx (via formViews (literal, a string literal in collectViewRecord))
  • content/docs/ui/views.mdx (via formViews (literal, a string literal in collectViewRecord))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-6.mdx (via emptyState (literal, a string literal in ObjectTranslationDataSchema))
  • content/docs/releases/v17/17-7.mdx (via emptyState (literal, a string literal in ObjectTranslationDataSchema), formViews (literal, a string literal in collectViewRecord))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 152 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 601b258d72cc6dde7e838a79d20eef18ba381103 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 483b10b24b4a67930c9dc84c96f29c089a0db0c2 — the merge of head 6f43b6b2e08d7e8f0a57b1bfd3213f3911c90602 into base 601b258d72cc6dde7e838a79d20eef18ba381103, 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 483b10b24b4a67930c9dc84c96f29c089a0db0c2 && git checkout 483b10b24b4a67930c9dc84c96f29c089a0db0c2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 601b258d72cc6dde7e838a79d20eef18ba381103 6f43b6b2e08d7e8f0a57b1bfd3213f3911c90602 && git checkout -B drift-repro 601b258d72cc6dde7e838a79d20eef18ba381103 && git merge --no-ff 6f43b6b2e08d7e8f0a57b1bfd3213f3911c90602

node scripts/docs-audit/affected-docs.mjs --json 601b258d72cc6dde7e838a79d20eef18ba381103

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 601b258d72cc6dde7e838a79d20eef18ba381103 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6f43b6b2e08d7e8f0a57b1bfd3213f3911c90602
Local-runs: none
Scope: packages/spec/src/system/i18n-resolver.ts, packages/spec/src/system/translation.zod.ts + packages/rest/src/form-routes.ts, packages/lint/src/validate-translation-references.ts, packages/cli/src/utils/i18n-extract.ts, .changeset/24969-form-view-title-translation-key.md, content/docs/references/system/translation.mdx, examples/app-showcase/src/system/translations/index.ts, packages/spec/src/ui/view.zod.ts (cited, unchanged)

Reviewed against card #24969 (body, claim 6303839918, dev report 6307030278), standing ruling 「甲」 (#6015 comments 6292005854 and 6303865025), the PR body and file list, the three-dot diff origin/main...6f43b6b2e0 (13 files, +833/-17), and the head's check-runs. Read-only: no checkout, build, test or gate re-run.

Check-runs on the head at the time of this record: 40 runs, 0 red. 33 completed (25 success, 8 skipped by their own filters: Console Pin Gate, Dogfood Verify CLI, Temporal Conformance, Dogfood Regression Gate shards, 18.x upgrade harness, Packed-tarball smoke). 7 still in progress: Test Core (1/6 through 6/6) and Type Check · workspace. Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Lint · ESLint, Lint & Repo Gates, Check Changeset, Spec property liveness, Governed Surface Queue Guard and Build Core are green. The verdict below is conditional on none of the 7 pending runs going red; a red there is to be named and this record re-read.

① Derived judgments

  1. A separate key objects.OBJECT._views.VIEW.title, not the label key — justified. Read in packages/spec/src/ui/view.zod.ts: FormViewSchema declares title ("Form title") and no label; the ViewItem union has exactly two arms, list and form, and ListViewSchema carries no top-level title. So a container's formViews.KEY / form entry has one string (title) and a viewKind: 'form' item can author two (label names the view, config.title is the heading). One translation standing for two source strings would be wrong whenever the two differ. The new key sits in the same _views node under the same view key lookupViewText already reads for label / description, so it is one address convention, not a second one. The title -> label alias removed from the _views entry was a refusal hint on a strict object (the new spec test shows heading is still refused with a rename hint), so no bundle could legally carry _views.VIEW.title before this PR: the key is new, nothing valid today is renamed or reshaped. heading and formTitle are added as refusal hints onto title; the entry stays closed.

  2. translateView overlay on config.title — correct and bounded. translateFormTitle returns the input by identity when the title is not a non-empty string (so a form without a title gains none and config keeps its reference), when valueOverridesPackagedBase(packagedConfig, 'title', title) says the served title was edited on top of the package (ADR-0029 D9.2a, the same rule label / description / bulk-action defs / sections use), or when no locale on the chain carries the key. The read is through the one lookupViewText, widened to 'title'. Observation, not a defect: the overlay is applied wherever config is an object, not only when viewKind === 'form'; because only FormViewSchema carries a top-level title, a list view's config.title is always undefined and the read is a no-op there, so the resolver and the lint refusal agree on the authored surface. The spec tests pin the translated title, the fallback chain, a missing key, a form with no title, the no-leak between label and title, the override rule and non-mutation.

  3. /forms/:slug — what an existing client receives, judged. Before: label: match.view?.label ?? match.form?.label (untranslated) and form.title as authored. After: form.title is the translated title (the route already served the translated view's config when match.form === match.view.config), and label is view.label from the translated view only when match.view.label !== undefined (the translator always sets label, falling back to the machine name, so the second guard view.label !== undefined is always true; the effective condition is "the view authors a label"). For a container form (no authored label) the answer is unchanged: label absent, form.title now translated. For a viewKind: 'form' item that authors a label, a non-default-locale client now receives the translated label where it received the authored one. For a default-locale or en request every field is served as authored. The console's payload.label ?? form.title ?? objectSchema.label fall-through order is untouched and all three arms now arrive translated. This is the card's asked-for change and a widening: a client sees a translated string where it saw the authoring-language one; the shape of the response, its keys and the fallback order do not move. The REST test runs the real route over a real SchemaRegistry and ObjectStackProtocolImplementation and covers translated, en, missing key, no title, machine-name-not-served, overlay override, and the authored-label item via a protocol double.

  4. Lint acceptance / refusal — no narrowing of anything valid today. facts.forms registers formViews.KEY (by registry key, as views already does), the default form (by the composer's own runtime key via expandViewContainer(...).find(isDefault), the same discipline as defaultListViewKey), and a viewKind: 'form' item. A title tail over a name in views but not in forms (a list view) is translation-target-unknown with a one-line verdict and a fix hint pointing at _views.VIEW.label; the entry is then skipped, so a list view's own label key on the same entry is still accepted (CONTROL test present). Nothing valid today is refused: the spec schema refused _views.*.title before this PR, so the lint refusal only names, with a better message, a key that was already illegal. The default form's key becomes legal for title alone; its other tails stay non-keys (test present), which is a deliberate non-widening the dev flags under ③.

  5. Extractor slot and coverage demand — acceptable. pushContainerFormTitles emits _views.KEY.title for every form a container serves, keyed by the composer's runtime name (asked of expandViewContainer, never re-derived), and the viewKind: 'form' branch emits it for an item. pushOptional records a titleless form's slot without an inline value, and the CLI test shows the coverage gate reports only authored titles a supported locale leaves untranslated. The showcase authors title: 'New contact' on formViews.create with zh-CN supported, so the slot became a real missing translation (414 to 415); authoring _views.create.title: '新建联系人' in the showcase bundle is the correct way to keep the repo gate's baseline unchanged ("621 baselined, none new") rather than ratcheting the baseline. For a user app, a newly reported i18n/missing-view on an authored title under a supported locale is a report, not a refusal of valid metadata: the key did not exist to be demanded before, and the demand follows from the author's own supportedLocales declaration. Docs regenerated (translation.mdx, the four _views rows and the per-view table).

② Semver level

Changeset declares minor for @objectstack/spec, @objectstack/rest, @objectstack/lint, @objectstack/cli, all at 18.0.0, with Clause-②: yes (widening) on the changeset and the PR body. That matches what publishes: spec gains an optional key on a strict entry and a resolver that overlays it (additive); rest serves translated values in a response of unchanged shape (additive); lint names a refusal for a key that was already refused at parse (no narrowing); cli emits a new extractor slot (additive). No BREAKING shape is owed. Standing ruling 「甲」 as amended (6303865025) governs narrowings in 18.x and does not bear on this widening; the claim's Responsibility: n/a — a widening line is consistent with it. Semver judgment: minor, as declared. Clause-②: yes (widening).

③ Boundary flags

  • Dev's out-of-scope notes (report 6307030278, PR Acceptance notes), none a blocker, inference not measured, no shipped example reaches them: (a) a formViews.KEY that retargets another object via its own data.object is keyed by lint under the form's binding but by translateView and the extractor under the container object; the label key already has the same split. (b) A viewKind: 'form' item's spec-qualified OBJECT.KEY name is used raw by the extractor's view.name branch and by lint, while translateView strips the prefix; the title slot inherits that branch's existing behaviour. (c) The default form's _views.form.label / description are read by translateView but stay lint non-keys; this PR registers title only. Flagged for a carrier if one is ever needed; not for this PR.
  • FormViewSchema.title's own .describe() does not name the key; the translation face documents it. Flag only.
  • Stale PR-body sentence: in Tests, Derived gates, the body still reads "pnpm check:type-check-debt is NOT MEASURED" (lock queue timeout). The dev report says it was re-run under the lock after the PR opened, exit 0, 117 of 117, and asks the seat to correct the body. The head's Type Check · debt ledger check-run is green, which corroborates. Flag only: the body line is stale and is the seat's to correct; it does not change the verdict.
  • 7 check-runs were still in progress when this record was rendered (Test Core 1/6 to 6/6, Type Check · workspace). None red. A red among them would be named and this record re-read.

Implemented-by: claude/issue-24969-form-view-title-key
Reviewed-by: session_01D6F9axfoPzR7t8G1J7Ytzz

VERDICT: PASS

Seat re-read of the check-runs at landing: all 42 runs on 6f43b6b2e0 have completed: 36 succeeded, the rest were skipped, and none is red.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 21, 2026 03:36
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 21, 2026 03:36
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 21, 2026
Merged via the queue into main with commit eddffc3 Oct 21, 2026
43 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24969-form-view-title-key branch October 21, 2026 04:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/l tests tooling

Projects

None yet

2 participants