Skip to content

feat(i18n): the engine translates a screen's title and description in the run's locale - #22626

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22507-screen-description-server-pick
Oct 10, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-22507-screen-description-server-pick

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22507
Clause-②: yes (widening)

This PR implements the maintainer's ruling A on #22507 (comment 6093472708, 「22507 同意」): the engine picks a screen's translated title and description templates in the run's locale, then renders them. The ruling states the rule once:

a user-read flow string the server renders per run is translated where it is rendered, in the run's locale, before its holes are filled; a client overlay never touches a server-rendered slot.

It follows the refusing end node's pick (#22450, PR #22525, faf6348508) through the same channel. There is no second i18n path. PR #22555 (Part of #22507) keyed the option labels and the terminal toasts. This PR keys the last owed screen slot, so it closes the card. The approval node's decisionOutputs[].label row stays owed, as the ruling says.

What changes

@objectstack/spec

  • The flows face gains flows.FLOW.screens.NODE_ID.description, beside screens.NODE_ID.title. The address of title does not change.
  • A translated description is judged by the one text-slot judge (textSlotTemplateRefusal), the same rule ScreenConfigSchema applies to the source text and the refusals message applies to its translation. A single-brace token is refused, and the message gives its double-brace spelling. An empty string is accepted, because it is the untranslated slot os i18n extract writes.
  • The screen face's guidance.description entry is removed. A guidance entry filed under a declared key can never fire (the alias-integrity audit in strict-object.ts holds that). Its message, "the server picks both", now lives in the title and description describes and in the flows docblock, which replaces the "Not keyed" section with "picked by the engine".
  • flowScreenCopyKey(flowName, nodeId, key) spells the key the engine reads. It sits beside flowRefusalMessageKey. FLOW_SCREEN_COPY_KEYS is now ['title', 'description'], so the extractor and the schema pin pick the key up from the one list.
  • translateFlow overlays a translated description on a flow DOCUMENT, only where the screen authors one. It still overlays title there. Both are template-level, the same pick the engine makes, and neither touches a served screen (see Zone 2 item 5 below).
  • resolveFlowScreenTitle and FlowScreenLike are documented as not for a served ScreenSpec. The function stays exported for now, because the console at the .objectui-sha pin still imports it (Post-Task Checklist step 4).
  • ScreenSpec.title / .description and AutomationContext.locale are documented: a client draws the served copy as served and never overlays it, and the locale's readers now include the screen executor.
  • The family pin (flows-translation-face.test.ts): screen.description moves from owed to keyed at screens.NODE.description, and OWED is now approval.decisionOutputs[].label alone.
  • The liveness ledger: the flows.screens row cites the engine as the reader of the heading and the body text, and carries a dated note for the transition window. The refusals rows are repointed from the renamed private method.
  • dropped-refinements.baseline.json declares the 8 published sites where the new text-slot refinement cannot be stated in JSON Schema. These are the same 8 schemas where the refusals message's refinement sits (688 to 696 sites, 226 schemas). The build gate requires the declaration.
  • Regenerated: api-surface/system.json, export-origins/system.json and references/system/translation.mdx.

@objectstack/service-automation

  • AutomationEngine.renderFlowTextSlot(slot, variables, context) is the one translated-template pick, generalized from the refusal's private translatedRefusalTemplate, which is renamed translatedFlowTemplate. renderRefusalMessage now calls it with flowRefusalMessageKey. Its argument type FlowTextSlotTranslation is exported beside RefusalI18nService.
  • The screen executor (both branches, the flat field list and the object form) asks it for flowScreenCopyKey(context.flowName, node.id, 'title' | 'description'). The run's locale is AutomationContext.locale, negotiated by resolveBundleLocale. Only then does it render through renderTextSlot, so values fill a translated template.
  • Falls back to the authored template when there is no locale, no service, no entry or an empty one, or a translation that does not compile. In the last case a warn names the key. A failure of the authored template still throws, as before.
  • Two presence rules. The heading is picked even with no config.title, because the one title key covers the node label the heading falls back to. The body text is picked only where the screen authors one, because a bundle never adds body text the author did not write. That is the toasts' rule.

@objectstack/lint: a translated description over a screen that declares no config.description is translation-target-unknown (error).

@objectstack/cli: os i18n extract scaffolds screens.NODE_ID.description for each screen that authors one, seeded with the authored template, holes and all. The coverage gate demands it in the flow bucket. An unauthored description is not even a seed-less entry, so a bundle that externalizes one is not demanded in every locale for a string nothing shows.

Examples and docs: app-todo zh-CN and ja-JP translate success_screen.description, and app-crm zh-CN translates its three screen descriptions. Without these, check:i18n-coverage grows past its baseline. content/docs/ui/translations.mdx stops saying a description renders as authored. The unreleased sibling changeset from #22555 had a bullet saying a translated description is still refused by the schema; that bullet now points at this entry.

Zone 2: the PM's mechanism assumptions, measured at d748ae80af

  1. Confirmed. screen-nodes.ts:173 defined text, which calls renderTextSlot(v, variables) and nothing else, used at :201-:202 (object form) and :285-:286 (flat screen), and no i18n reader was asked.
  2. Confirmed. The channel is setI18nServiceSource (engine.ts:4881). The refusal's pick was translatedRefusalTemplate (:11114), called by renderRefusalMessage (:11087). It was private, so the executor could not reach it. It is now the shared public renderFlowTextSlot, which reads through the same i18nServiceSource field. The run's locale at a screen node is the executor's context.locale, the run context resolveRunContext builds (:6160). That context is persisted with a suspended run, so a resumed leg reads the starter's locale. The flow name is context.flowName, stamped at the same construction point.
  3. Confirmed. At d748ae80af, the flows face keyed screens.NODE_ID.title, and guidance.description refused description (translation.zod.ts:1352). The family pin listed screen.description as owed (flows-translation-face.test.ts:124, OWED at :179).
  4. Confirmed. walkScreenFlows iterates FLOW_SCREEN_COPY_KEYS (i18n-extract.ts:1884), so adding the key to the list adds the skeleton and coverage rows. Coverage needs one extra rule here: no seed-less entry for an unauthored description.
  5. translateFlow applies a screen title today, but on the flow DOCUMENT's config.title, which is the template, not the served slot.
    • At the .objectui-sha pin 20c6d351a, objectui's only call is translateFlow({ name, label }) (FlowRunner.tsx:274), which passes no nodes.
    • So the ruling's "a client overlay never touches a server-rendered slot" requires no change to translateFlow. It gains description at the same template level, so the document translator stays complete over the declared face.
    • The served-slot overlay the ruling retires is resolveFlowScreenTitle in objectui's localizeScreen (FlowRunner.tsx:252). That is the objectui half; see below.

Verification

Readings are at head a42541293c (merge of origin/main 18d999031b) unless named.

  • Tests, each under the verify lock:
    • @objectstack/spec local tier: 640 files, 19105 passed and 1 todo.
    • @objectstack/spec repo tier: 54 files, 915 passed.
    • @objectstack/service-automation: 182 files, 2298 passed, including the new screen-copy-translation.test.ts (13 tests).
    • @objectstack/lint: 134 files, 6122 passed.
    • @objectstack/cli unit tier: 277 files, 4109 passed. The integration tier is declared to CI.
  • Typecheck: pnpm --filter PKG run typecheck exits 0 for all four packages, test layers included. The spec test layer holds 52 files / 246 errors / 135 pinned signatures, unchanged.
  • Ablation of the executor's description pick, at 5a2c8649ee.
    • Mutation, through scripts/ablation-replace.mjs: in screen-nodes.ts, screenText('description', cfg.description, 'the body text') became renderTextSlot(cfg.description, variables). The anchor went x1 to x0, and the blob went 5ac2066c to f4aa30f5.
    • Predicted: red on the 5 translated-description pins, green on the 8 fallback pins. Observed: 5 failed / 8 passed (the zh-CN render, the zh negotiation, the resumed leg, the object-form screen, the broken-translation warn).
    • Restored: blob 5ac2066c matches HEAD, and git diff HEAD is empty.
    • No dist step was needed: the test imports the executor through ./builtin/index.js, which is source.
  • Generated: pnpm --filter @objectstack/spec check:generated reports "All 15 generated artifacts are up to date", after the merge and the rebuild.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 118 commands at a42541293c. 117 were run with their exit codes recorded. --ran reports 117 run, 0 NOT-MEASURED and 1 UNRUN: check:dual-build-cjs-loads, left unrun by the dispatch because it needs a whole-workspace build.
    • 115 exited 0. Two of them first refused (exit 3, PREREQUISITE NOT MET) and exited 0 after the named build: check:skill-examples after building @objectstack/client-react, and check:i18n-coverage after building the example closure. The latter reads "OK (13 configs, 621 baselined, none new)".
    • check-empty-changeset exits 1 by design. See "A pending release note, corrected" below.
    • check:platform-checklist exits 1, and that is main's state, not this diff. Its ABSENT SYMBOL lines name packages/metadata-protocol/src/protocol.ts and packages/services/service-storage/src/attachment-access-hooks.ts, and the checklist tree plus both files diff empty against origin/main e22315238f. The canEdit line is [finding] check:platform-checklist is red on main: attachments-storage.json anchors attachment-access-hooks.ts#canEdit, which #22513 (ce3d0ad41) moved to checkEdit #22557.
  • ESLint, narrowed: eslint --no-inline-config --format json over the 18 changed .ts files reports 18 results, 0 errors and 0 warnings. An ignored file would surface as a warning.
    • eslint.config.mjs enables no type-aware linting (no parserOptions.project), so this diff cannot move a verdict on an untouched file.
  • Size: 1,051 changed lines, generated files included.

A pending release note, corrected (check-empty-changeset stays red)

.changeset/22507-flow-options-toasts-translation.md is #22555's unreleased note. Its last bullet said a screen's description "is still refused by the schema". This PR makes that sentence false, and both notes would ship in the same release. So the bullet now points at this PR's entry. Restoring it from the base would publish the false sentence. The gate's own text names this case, a deliberate correction, and asks for confirmation on the PR rather than a restore. The other bullets and the frontmatter of that note are unchanged.

The objectui consumer half

This is filed into objectui#12075, as the claim records. At the pin 20c6d351a, FlowRunner.tsx has to change in these places:

  • :252: drop const title = resolveFlowScreenTitle(...) and return { ...screen, fields }.
  • :162: drop the import.
  • The docs at :83-:96, :106-:108, :119-:122, :240-:242 and the JSX comment at :543-:544.
  • Add no description overlay.
  • __tests__/FlowRunner.flowsTranslation-5920.test.tsx pins the heading overlay (:117, :168) and has to pin the served heading drawn as served.

Until that lands, the runner replaces a served, already-translated heading with the translated template. That draws a hole literally exactly where it did before this PR, so the window adds no regression.

Acceptance notes

  • The title translation is not judged by the text-slot judge. Judging it would narrow what the face accepts today, which conflicts with the dispatched Clause-②: yes (widening). A single-brace token in a translated heading still renders literally, as it did under the client overlay. The report's open question asks the seat whether to follow up.
  • resolveFlowScreenTitle retires once the .objectui-sha pin carries the objectui change. The function has no other caller.
  • approval.decisionOutputs[].label stays owed, as pinned.
  • The claim's file surface did not name these files: packages/spec/src/contracts/automation-service.ts (docblocks), packages/spec/dropped-refinements.baseline.json, content/docs/ui/translations.mdx, the app-crm zh-CN bundle and the feat(spec,lint,cli): a screen field's option labels and a flow's terminal toasts have keys in the flows translation face #22555 changeset. Each is a mechanical follow-through of the same change.

Generated by Claude Code

…title and description templates in the run's locale

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF
…clare its dropped refinements

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/cli, @objectstack/lint, @objectstack/service-automation, @objectstack/spec, touching 20 documentable anchor(s). ⚠️ 5 changed file(s) yielded no anchor (packages/services/service-automation/src/index.ts, packages/spec/api-surface/system.json, packages/spec/dropped-refinements.baseline.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/automation/flows.mdx (via AutomationContext (symbol, a top-level interface))
  • content/docs/ui/actions.mdx (via AutomationContext (symbol, a top-level interface))

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

  • content/docs/releases/v15.mdx (via AutomationContext (symbol, a top-level interface))
  • content/docs/releases/v16.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-0.mdx (via AutomationEngine (symbol, a top-level class))
  • content/docs/releases/v17/17-3.mdx (via AutomationContext (symbol, a top-level interface))
  • content/docs/releases/v17/17-5.mdx (via AutomationContext (symbol, a top-level interface))
  • content/docs/releases/v17/17-6.mdx (via AutomationEngine (symbol, a top-level class))

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
  • 5 changed file(s) yielded no anchor (packages/services/service-automation/src/index.ts, packages/spec/api-surface/system.json, packages/spec/dropped-refinements.baseline.json, …) — pages documenting those are invisible to this run
  • 2 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 — 145 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 83b8b80728e1b1120324abea31b750cb238c3aa4 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 83b8b80728e1b1120324abea31b750cb238c3aa4

⚠️ 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 83b8b80728e1b1120324abea31b750cb238c3aa4 → 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: a42541293c719e1722fede05d5acf13fff0e2c00
Local-runs: none

What was read. Card #22507 (body and all 14 comments: triage 6085404571, unlock 6089070768, claim 6090837460, PR #22555's rounds and landing 6092343836, the decision request 6092351336, ruling A 6093472708, claim 6093845640, the dev report 6094650615, the seat's ACCEPT 6094676110); PR #22626 (body, 29-file list, net diff against merge-base 18d999031b with origin/main 83b8b80728: 29 files, +882 / −169, identical to the PR's file list; the branch merged main once, at the head commit); the check-runs on the head; PR #22525 (faf6348508, #22450) as the precedent. The branch had not moved: refs/pm/pr-22626 resolves to the head above. No governed path in the file list (no docs/adr, .claude, skills, AGENTS.md, CLAUDE.md, NORTH-STAR); 1,051 changed lines, under the 3,000-line human-merge line. The .objectui-sha pin is unchanged at 20c6d351a, and no objectui path is touched.

① Derived judgments

Each accept-set or public-surface change the net diff implies, judged against ruling A (6093472708) and the precedent faf6348508.

  1. The new face key flows.FLOW.screens.NODE_ID.description, and its judge — right. translation.zod.ts declares description beside title (whose address is unchanged), optional, no .min(1), with textSlotTemplateRefusal in a superRefine: a single-brace token is refused at the key's own path with the {{ }} spelling, an empty string parses (the skeleton slot os i18n extract writes). That is byte-for-byte the shape i18n(flows): an end node's outcome: 'refused' message has no translation key — a first-class refusal renders English in every locale #22450 gave refusals.NODE_ID.message, so the face keeps one convention (unlock 6089070768). Pinned in translation.test.ts at both doors (TranslationDataSchema and TranslationItemSchema). The 8 new dropped-refinements.baseline.json sites (688 to 696, 226 schemas unchanged) are the same 8 published schemas that carry the refusal refinement — the build-gate declaration the precedent also needed.
  2. The lint door's new error — right. validate-translation-references.ts records hasDescription per screen and reports translation-target-unknown (error) for a description translation over a screen that declares no config.description, with the remedy. It is the toasts' rule one level down (feat(spec,lint,cli): a screen field's option labels and a flow's terminal toasts have keys in the flows translation face #22555), and the right severity: nothing reads such a key, so the author would be translating a string nothing shows. The holes are left to the schema's judge, as i18n(flows): an end node's outcome: 'refused' message has no translation key — a first-class refusal renders English in every locale #22450 did. Pinned positive and negative (rule, severity, path, first sentence).
  3. AutomationEngine.renderFlowTextSlot public and FlowTextSlotTranslation exported — right, and no wider than the ruling requires. The screen executor lives in builtin/screen-nodes.ts, a separate module registered through registerScreenNodes(engine, ctx); a TypeScript private is unreachable from there, so the refusal's private pick had to become a member the builtin can call. The alternative — a free function handed the i18nServiceSource and the logger — would be the second i18n path the dispatch forbids. The method is the generalisation of the refusal's translatedRefusalTemplate (renamed translatedFlowTemplate, still private), reads the same i18nServiceSource field through setI18nServiceSource, negotiates through the same negotiatedServiceLocale, and renderRefusalMessage now delegates to it with flowRefusalMessageKey — one pick for both readers. Exporting the argument type beside RefusalI18nService is exactly what feat(i18n): a refused end node's message translates in the run's locale #22525 did for setI18nServiceSource's parameter type. One readonly interface and one method: additive, minor, and the refusal's 11 pins in end-node-refusal-translation.test.ts still cover the old path (the dev's full service-automation run, 182 files / 2298, includes them; Test Core confirms, see ③).
  4. The removed guidance.description entry — right, and required, not optional. The ruling says the string is "rewritten"; the diff removes it. strict-object.ts (ReportSchema 的 filter 别名指向 filters —— 一个 ReportSchema 同样拒绝的键(#4001 战役自己的假处方,第 5 例) #5013, alias-integrity.test.ts) holds that an aliases / guidance table is a claim that the key it is filed under is one the shape REJECTS — an alias runs only from the unrecognized_keys path, so a declared key can never reach it. Once description is declared, a guidance entry under it is a false claim the audit refuses. The ruling's words live where an author now reads them: the title and description describes and the flows docblock's section "picked by the engine", which replaces "Not keyed". The old translation.test.ts pin asserting the refusal is replaced by the acceptance pin.
  5. The CLI extractor and coverage rows — right. walkScreenFlows already iterated FLOW_SCREEN_COPY_KEYS, so widening that tuple to ['title', 'description'] adds the skeleton row and the coverage demand through the one list, as feat(spec,lint,cli): a screen field's option labels and a flow's terminal toasts have keys in the flows translation face #22555 did for options. The one extra rule — if (key === 'description' && authored === undefined) continue — is right: the engine reads body text only where authored, so an unauthored description is not even a seed-less entry, and a bundle that externalises one is not demanded per locale for a string nothing shows. The seed is the authored template, holes and all. Pinned in i18n-flow-screen-coverage.test.ts: demanded where authored, quiet once translated, no entry for the bare screen, skeleton parses.
  6. FLOW_SCREEN_COPY_KEYS widened — right, with the consumer checked. objectui at the pin 20c6d351a imports FLOW_SCREEN_FIELD_COPY_KEYS (the field keys) and resolveFlowScreenTitle only; it never iterates FLOW_SCREEN_COPY_KEYS, so the widening creates no client description overlay. The transition window is exactly the title-only one the ruling describes.
  7. The screen executor matches the ruling exactly, heading and body alike. Both branches — the object form (former :201–:202) and the flat screen (former :285–:286) — now go through heading() / body() → screenText → engine.renderFlowTextSlot({ key: flowScreenCopyKey(context.flowName, node.id, key), authored }) → translatedFlowTemplate (the setI18nServiceSource channel, context.locale, the resolveBundleLocale negotiation, a miss or an empty slot picks nothing) → renderTextSlot(translated.template, variables), else renderTextSlot(slot.authored, variables). So the translated TEMPLATE is picked, the authored template is the fallback when the bundle has none, and only then does renderTextSlot fill the holes — the ruling's sentence, in that order. A translation that does not compile warns with the key and falls back; a failure of the authored template still throws. renderTextSlot(undefined) returns undefined, so a screen with no config.title still falls to node.label / the object name. The two presence rules are right: the heading is asked for always (the one title key covers the node label the heading falls back to — the semantics the client overlay already had), the body only where authored (the toasts' rule, which the lint door and the coverage rule both assume). context.flowName is stamped by resolveRunContext from flow.name on every execute, and both subflow and map children re-enter execute, so a screen inside a child flow is keyed under the child's own name — the address the lint and extractor walk. The flowName === undefined branch (an executor driven outside a run) renders as authored and is unreachable from execute. 13 pins in screen-copy-translation.test.ts over the real createMemoryI18n: zh-CN heading and body with holes filled, zh to zh-CN, the resumed leg in the starter's locale, title covering the node label, the object form, and every fallback (locale miss, default locale, no locale, no service, empty skeleton slot, unauthored body not added, non-compiling translation warns). The dev's ablation of the body pick went 5 red / 8 green as predicted and restored blob-equal.
  8. approval.decisionOutputs[].label stays owed — right. flows-translation-face.test.ts: screen.description moves to keyed('screens.NODE.description', …) and OWED is now exactly [APPROVAL_NODE_TYPE.decisionOutputs[].label], as the ruling pinned.
  9. translateFlow overlays description at the document level — right. Same template-level pick on a flow DOCUMENT, only where the screen authors one; the served ScreenSpec is never its input. objectui at the pin calls translateFlow({ name, label }) with no nodes (FlowRunner.tsx:274), so no served screen is touched. The negative pin for an off-spec description is replaced by the positive one (i18n-resolver.test.ts), and resolveFlowScreenTitle / FlowScreenLike are re-documented as not for a served screen and kept exported because the pinned console imports them (Post-Task step 4 held: nothing the sibling imports is removed).
  10. Docs, ledger, generated artifacts, examples — right. content/docs/ui/translations.mdx stops saying a description renders as authored; the generated references/system/translation.mdx carries description? in the three screens rows; api-surface / export-origins gain flowScreenCopyKey only; the liveness screens row's evidence now cites the engine for heading and body, and the refusals rows are repointed after the rename. app-todo zh-CN and ja-JP translate success_screen.description (keeping the {{ subject }} hole), and app-crm zh-CN translates its three screen descriptions — I checked that all four screens author a description, so the repo's own bundles pass the new lint door, and the coverage gate holds at its baseline. ScreenSpec.title / .description and AutomationContext.locale docblocks now say a client draws the served copy as served.
  11. The PR body's first line Fixes #22507 — right. The ruling treats the approval label as "another surface, another decision", so this PR closes the card; the claim-guard checks on the head agree.

② Semver level

All four packages publish publicly at 17.7.0 (publishConfig.access: public, no private), so each minor has a real target. Every changeset and the PR body declare Clause-②: yes (widening); check-adr-0087-registration reads the arm as non-breaking, so no disposition marker is owed on these four.

  • .changeset/22507-screen-copy-engine-pick-spec.md — @objectstack/spec minor, Clause-②: yes (widening). Right: a new optional key on the face, a new export (flowScreenCopyKey), a widened tuple (FLOW_SCREEN_COPY_KEYS), a guidance refusal that becomes acceptance, docblocks. Nothing removed, renamed or narrowed. The body states the hole-keeping rule with an example, the only-where-authored rule, who translates, and names resolveFlowScreenTitle's coming retirement and the unjudged title.
  • .changeset/22507-screen-copy-engine-pick-automation.md — @objectstack/service-automation minor, Clause-②: yes (widening). Right: one new public method, one new exported type, new behaviour (a served screen translated where a bundle and a locale exist) with the authored text as the fallback everywhere it was the output before. The body names the fallback cases and the two presence rules.
  • .changeset/22507-screen-copy-engine-pick-cli.md — @objectstack/cli minor, Clause-②: yes (widening). Right: a new skeleton row and a new coverage demand for i18n-opted-in projects, the same shape feat(i18n): a refused end node's message translates in the run's locale #22525 and feat(spec,lint,cli): a screen field's option labels and a flow's terminal toasts have keys in the flows translation face #22555 shipped as minor; the body says a project with supportedLocales gets one issue per locale per authored description, and that a screen without one is never asked.
  • .changeset/22507-screen-copy-engine-pick-lint.md — @objectstack/lint minor, Clause-②: yes (widening). Right: at the base a description key under screens was refused at parse, so no valid bundle could reach the lint; now it parses where authored and the lint errors where not — the net accept set is strictly wider.

The DELIBERATE CORRECTION — .changeset/22507-flow-options-toasts-translation.md (PR #22555's pending note)

Check Changeset / check-empty-changeset is red on this head by design: the PR modifies a changeset it did not add. The note is PR #22555's, present at the merge base and still pending on origin/main 83b8b80728 (not yet consumed by a release). The gate's own header names this class — a PR that changed behaviour a PENDING note describes and corrected the note in the same stroke — and its remedy is "do NOT restore it; get it confirmed on the PR". Its frontmatter (spec / lint / cli minor), Clause-②: yes (narrowing), the ADR-0087 marker, the BREAKING banner and every other bullet are byte-unchanged; the diff is one bullet, +1 / −1. Sentence by sentence:

  • OLD, sentence 1: "Still not keyed: a screen's description." — false once this PR lands: translation.zod.ts declares flows.FLOW.screens.NODE_ID.description, and the family pin records the slot as keyed.
  • OLD, sentence 2: "It is a {{ }} template the server renders per run, so its translation has to be chosen before that render;" — the fact half stays true, but as the stated reason for the slot's ABSENCE from the face it is false: the engine now chooses the template before that render (renderFlowTextSlot), so the sentence would send an upgrading reader to look for a key that exists.
  • OLD, sentence 3: "a translated description is still refused by the schema with that reason." — false once this PR lands: the guidance.description refusal is removed and the schema accepts the key (judged by the text-slot rule); translation.test.ts pins the acceptance where it pinned the refusal.
  • NEW: "A screen's description is keyed too, in the same release, and translated by the engine rather than the console: see the entry for flows.FLOW.screens.NODE_ID.description." — true, in each clause: "keyed too" (the face declares it); "in the same release" (both notes are pending on main at 17.7.0 and changeset version consumes them together — a release cut before this PR lands would delete the base file and this PR could not merge as-is, so the clause holds whenever the PR can land); "translated by the engine rather than the console" (screen-nodes.ts through renderFlowTextSlot; objectui adds no description overlay); "see the entry for …" (the spec changeset's title carries that key verbatim, and the lint and cli changesets name it in their bodies, so the pointer resolves inside the same version of each of the three CHANGELOGs the note's frontmatter bumps).

So the rewrite is right: every falsified sentence is gone and the replacement is true. This record names the note and is the same-head confirmation the landing rule takes; Check Changeset is not one of the seven required contexts.

No other pending note or doc on the head says a screen description is refused or untranslatable. Swept .changeset/, content/docs/, docs/, packages/, skills/, examples/ at the head: the remaining description mentions are the text-slot template rules (#22110, #22477, template.ts, migrations/registry.ts, flows.mdx), which already list a screen's description as a {{ }} slot, and unrelated description fields. One residual sentence sits inside the liveness ledger's screens row note — flagged in ③.

③ Boundary flags

Every dev flag (6094650615), the seat's decisions (6094676110), and what the check-runs say.

  • Deviation 1 — guidance.description removed rather than rewritten: answered, right (① item 4).
  • Deviation 2 — five files outside the claim's surface (contracts/automation-service.ts docblocks; dropped-refinements.baseline.json; the app-crm zh-CN bundle; content/docs/ui/translations.mdx; the feat(spec,lint,cli): a screen field's option labels and a flow's terminal toasts have keys in the flows translation face #22555 note): answered, each accepted. Each is forced by the change itself — a build gate, the coverage gate (app-crm authors three screen descriptions, so the baseline would grow without them), a sentence made false, and the pending note above. The ruling itself named "the generated references follow" and the coverage-gate bundle; the claim's "stop on breach" was aimed at a substantive breach, and none of these is one.
  • Deviation 3 — main merged once (18d999031b), not re-merged at report time: answered. The net diff is clean against that merge base, GitHub reports the PR mergeable: true against main 83b8b80728 (mergeable_state: blocked only because it is a draft with checks still running), and the queue rebuilds on current main anyway.
  • Deviation 4 — model-free trailers: answered. All five branch commits carry Co-authored-by: Claude and Claude-Session: …session_01KNKBCRDJCu5tGy3TEbvtrF; the merge commit carries none, as a merge does.
  • Open question 1 — should a translated title be judged by the text-slot judge: answered by the seat, correctly, as a follow-up. Judging it narrows an existing key and would contradict this PR's yes (widening); measured reach 0. The seat's ACCEPT records it filed as i18n(flows): a translated screen title is not judged by the text-slot judge, so a single-brace {name} in a translated heading renders literally while the description and refusal message beside it are refused #22627 (asserted by the ACCEPT; not an input of this record, so not independently re-read here). This PR documents the gap in the flows docblock and the spec changeset's last bullet, so no reader is misled meanwhile.
  • Out-of-scope finding [0] — check:platform-checklist red on main (three access-security.json absent symbols): escalated by the seat into check:platform-checklist is red on main #22594, the watchdog card; the gate is out of per-PR CI, and the dev showed the checklist and both cited files diff empty against origin/main. Not this PR's.
  • Out-of-scope finding [1] — resolveFlowScreenTitle loses its last caller once objectui drops the heading overlay: answered. It stays exported here (the pin imports it), is documented as not for a served screen, and retires with the .objectui-sha bump that carries objectui#12075. Right order: never remove what the pinned sibling imports.
  • Out-of-scope finding [2] — RefusalI18nService now serves the screen executor too, name kept: answered. Renaming a published export is breaking; the docblock says it is named for its first reader. Acceptance note, no carrier.
  • The objectui consumer half: the claim folds it into objectui#12075 and the dev report carries the file-and-line change at the pin (FlowRunner.tsx:252 drops the resolveFlowScreenTitle overlay, no description overlay added, the 5920 test re-pinned). Nothing in objectui is in this PR, as the claim ordered (⛔ held). Until it lands, the runner overlays a served translated heading with the same translated template, drawing a hole literally exactly where it did before — the window the ruling accepted.
  • One residual, carried as a nit — not a FAIL. The landing record 6092343836 carried the previous contract review's nit that the liveness screens row note still says the description stays untranslated "by ruling", to be rewritten by "the routing PR". This PR re-points the row's evidence to the engine and APPENDS a dated 2026-10-10 paragraph stating the new truth, but the sentence "The screen description and the runner chrome stay untranslated here by ruling." is still inside the note's 2026-09-27 paragraph, unmarked. The ledger's convention is chronological, dated appends with "superseded" markers, and check:liveness reads status and evidence (both right), so the row is not wrong — but the sentence is. Carry it to the landing record, and mark it superseded on the next touch of liveness/translation.json; it does not justify a new head that would void this record.
  • Observation, no weight: content/docs/ui/translations.mdx's "Two strings stay outside the group" still describes the flow's successMessage as outside the group although feat(spec,lint,cli): a screen field's option labels and a flow's terminal toasts have keys in the flows translation face #22555 keyed it; that sentence predates this PR (it was "Three strings…" at the base) and describes the console's behaviour at the pin. It belongs to objectui#12075's landing, not here.
  • Check-runs on the head at 2026-10-10T06:42Z. Of the seven required contexts, six are success: Lint & Repo Gates (06:41Z), TypeScript Type Check (all four lanes success), Dogfood Regression Gate (3/3), Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. The seventh, Test Core, is still in progress: 1/6 success, 2/6 to 6/6 running, no red. Not waited on beyond a few minutes, per the brief: the landing rule still needs every check green, so the owning seat reads Test Core to completion before enqueue. The only reds are the two Check Changeset runs, the designed red judged above; it is advisory, not required. Also green: both claim guards, Part-of PR must not also close its card, Spec property liveness, Check PR Size, Build Docs, Dogfood Verify CLI; Console Pin Gate skipped (no pin move).
  • Mergeable against main: yes (mergeable: true at 83b8b80728; state blocked = draft + pending checks).

Implemented-by: claude/issue-22507-screen-description-server-pick
Reviewed-by: session_01KNKBCRDJCu5tGy3TEbvtrF

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landing pre-checks at a42541293c, by the owning seat: queued with one red by design (Check Changeset, the DELIBERATE CORRECTION class)

domain:spec seat 3 (#18883) · zhuangjianguo · session session_01KNKBCRDJCu5tGy3TEbvtrF · 2026-10-10T06:53Z · holder of claim 6093845640.

  • The review: contract review PASS 6094764765 names this head. It judges the executor exact to ruling A on both branches, the new public AutomationEngine.renderFlowTextSlot necessary and no wider than required, and the removed guidance.description entry required. Nothing it records blocks. Its one nit, the stale 2026-09-27 sentence in the liveness screens row note, rides this card's landing record to be marked superseded on the next ledger touch.
  • CI: 32 success, 5 skipped, 2 failures. check-expected-skips --pr 22626 reads "OK — 5 skipped check-run(s), every one in the roster". The two other conclusions are both Check Changeset = failure, on two pull_request events.
  • Check Changeset goes to the queue red, under the three conditions together:
    1. The source says it is red by design. scripts/check-empty-changeset.mjs's DELIBERATE CORRECTION text: "this gate stays red either way". The red names exactly the pending note this PR corrects, .changeset/22507-flow-options-toasts-translation.md (PR feat(spec,lint,cli): a screen field's option labels and a flow's terminal toasts have keys in the flows translation face #22555's).
    2. It does not run on merge_group. It lives in .github/workflows/pr-automation.yml, whose only trigger is pull_request.
    3. It is recorded on the PR: this comment. The confirmation the landing rule takes is the same-head PASS record that names the note and judges each rewritten sentence: 6094764765.
  • Governed: check-governed-merges --pr objectstack-ai/objectstack#22626 reads NOT governed; 1051 changed lines.
  • Closing keywords: the body carries Fixes #22507 alone, and no commit message carries one.
  • main drift since the merge base 18d999031b: main (now 83b8b80728) moved none of the PR's 29 paths. GitHub reports mergeable: true (unstable, from the red above).

needs:contract-review comes off; pr_ready and automerge_enable follow.


Generated by Claude Code

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/xl tests tooling

Projects

None yet

2 participants