From ebb460a714b52be7a823fe3cb04e515474f190e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:28:00 +0000 Subject: [PATCH 1/4] test(translation): an approval node's step label has no flows translation key (repro) Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .../validate-translation-references.test.ts | 121 ++++++++++++++++++ packages/spec/src/system/translation.test.ts | 39 ++++++ 2 files changed, 160 insertions(+) diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 02c193dd130..455ef802895 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -2230,6 +2230,123 @@ describe('validateTranslationReferences — flow refusals (#22450)', () => { }); }); +describe('validateTranslationReferences — flow approval steps (`approvals..label`)', () => { + /** + * HotCRM's measured shape: an approval flow whose step is an `approval` + * node, beside a screen, a refusing `end` and a `decision`, so each verdict + * below is judged against the same metadata as the clean run and differs + * only in the bundle. + */ + const approvalStack = { + flows: [ + { + name: 'opportunity_approval', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'gate', type: 'decision', label: 'Large deal?' }, + { id: 'collect', type: 'screen', label: 'Collect', config: { fields: [{ name: 'note' }] } }, + { + id: 'manager_review', + type: 'approval', + label: 'Sales Manager Review', + config: { approvers: [{ type: 'manager' }] }, + }, + { id: 'refuse', type: 'end', label: 'Refused', config: { outcome: 'refused', message: 'Refused.' } }, + ], + }, + ], + }; + const bundle = (flows: unknown) => ({ ...approvalStack, translations: [{ 'zh-CN': { flows } }] }); + + it('reports nothing for the label of an `approval` node — the key the step has no other home for', () => { + expect( + validateTranslationReferences(bundle({ opportunity_approval: { approvals: { manager_review: { label: '销售经理审批' } } } })), + ).toEqual([]); + }); + + it('controls: the screen and refusal keys of the same flow still resolve beside it', () => { + expect( + validateTranslationReferences( + bundle({ + opportunity_approval: { + screens: { collect: { title: '收集', fields: { note: { label: '备注' } } } }, + refusals: { refuse: { message: '已拒绝。' } }, + approvals: { manager_review: { label: '销售经理审批' } }, + }, + }), + ), + ).toEqual([]); + }); + + it('refuses an approval key on a node of another type, by name', () => { + const findings = validateTranslationReferences(bundle({ opportunity_approval: { approvals: { gate: { label: 'x' } } } })); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN); + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.opportunity_approval.approvals.gate'); + expect(findings[0].message).toBe( + 'Translations are keyed to approval step "gate", which flow "opportunity_approval" declares as a ' + + '`decision` node, not an `approval`, so nothing resolves this key.', + ); + expect(findings[0].hint).toBe('Approval-step translations are keyed by the `id` of an `approval` node.'); + }); + + it('refuses an approval key on a screen node, pointing at the screen group', () => { + const findings = validateTranslationReferences(bundle({ opportunity_approval: { approvals: { collect: { label: 'x' } } } })); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.opportunity_approval.approvals.collect'); + expect(findings[0].message).toBe( + 'Translations are keyed to approval step "collect", which flow "opportunity_approval" declares as a ' + + '`screen` node; its heading is keyed `screens.collect.title`.', + ); + }); + + it('refuses an approval key over an UNKNOWN node, with the nearest approval node offered', () => { + const findings = validateTranslationReferences( + bundle({ opportunity_approval: { approvals: { manager_reveiw: { label: 'x' } } } }), + ); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN); + expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.opportunity_approval.approvals.manager_reveiw'); + expect(findings[0].message).toContain( + 'Translations are keyed to approval step "manager_reveiw", which flow "opportunity_approval" declares no ' + + 'node for, so the step keeps its source-locale label.', + ); + expect(findings[0].message).toContain('Did you mean "manager_review"?'); + }); + + it('points a `screens` key written over an approval node at the `approvals` group (the measured HotCRM key)', () => { + const findings = validateTranslationReferences( + bundle({ opportunity_approval: { screens: { manager_review: { title: '销售经理审批' } } } }), + ); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN); + expect(findings[0].path).toBe('translations[0]["zh-CN"].flows.opportunity_approval.screens.manager_review'); + expect(findings[0].message).toBe( + 'Translations are keyed to screen "manager_review", which flow "opportunity_approval" declares as an ' + + '`approval` node; its step label is keyed `approvals.manager_review.label`.', + ); + }); + + it('points a `refusals` key written over an approval node at the `approvals` group', () => { + const findings = validateTranslationReferences( + bundle({ opportunity_approval: { refusals: { manager_review: { message: 'x' } } } }), + ); + expect(findings).toHaveLength(1); + expect(findings[0].message).toBe( + 'Translations are keyed to refusal "manager_review", which flow "opportunity_approval" declares as an ' + + '`approval` node; its step label is keyed `approvals.manager_review.label`.', + ); + }); + + it('control: a `screens` key over a non-approval, non-screen node keeps its own diagnosis', () => { + const findings = validateTranslationReferences(bundle({ opportunity_approval: { screens: { gate: { title: 'x' } } } })); + expect(findings).toHaveLength(1); + expect(findings[0].message).toContain('declares as a `decision` node, not a `screen`'); + }); +}); + describe('validateTranslationReferences — flow toasts and screen-field options (#22507)', () => { /** * hotclm's measured shape, trimmed: a screen flow that authors a completion @@ -3906,6 +4023,10 @@ describe('[#22161] one-line verdicts — translation-target-unknown, translation '`end` node that completes', 'node, not a refusing `end`', 'a node id flow', + 'node, not an `approval`', + '`screen` node; its heading is keyed', + 'declares no node for, so the step keeps', + '`approval` node; its step label is keyed', 'Translations are keyed to parameter "', 'does not declare in `outcomeMessages`', 'declares no `outcomeMessages` at all', diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 9e38a4115ed..0cff6ae7536 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -1472,6 +1472,45 @@ describe('translation unknown-key strictness', () => { }); }); + // ────────────────────────────────────────────────────────────────────────── + // An approval node's step label has a key: `flows..approvals..label` + // ────────────────────────────────────────────────────────────────────────── + describe('flow approvals — `flows..approvals..label`', () => { + const parse = (flows: unknown) => TranslationDataSchema.safeParse({ flows }); + + it('accepts and keeps a translated approval-step label, at both doors', () => { + const flows = { opportunity_approval: { approvals: { manager_review: { label: '销售经理审批' } } } }; + const result = parse(flows) as { success: boolean; data: any; error?: unknown }; + expect(result.success, JSON.stringify(result.error)).toBe(true); + expect(result.data.flows.opportunity_approval.approvals.manager_review.label).toBe('销售经理审批'); + expect(TranslationItemSchema.safeParse({ locale: 'zh-CN', flows }).success).toBe(true); + }); + + it('accepts an empty label — the untranslated skeleton slot', () => { + expect(parse({ f: { approvals: { n: { label: '' } } } }).success).toBe(true); + }); + + it('names `title` as the rename onto `label` it is — the spelling a screen key carries', () => { + const result = parse({ f: { approvals: { n: { title: 'x' } } } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.code === 'unrecognized_keys')!; + expect(issue.path).toEqual(['flows', 'f', 'approvals', 'n']); + expect(issue.message).toContain('`title` → `label`'); + }); + + it('closes the entry: a key the approval node does not declare is refused', () => { + const result = parse({ f: { approvals: { n: { instructions: 'x' } } } }); + expect(result.success).toBe(false); + expect(result.error!.issues.some((i) => i.code === 'unrecognized_keys')).toBe(true); + }); + + it('names `approval` as the rename onto the `approvals` group', () => { + const result = parse({ f: { approval: { n: { label: 'x' } } } }); + expect(result.success).toBe(false); + expect(result.error!.issues.find((i) => i.code === 'unrecognized_keys')?.message).toContain('`approval` → `approvals`'); + }); + }); + it('names which action surface the key landed on', () => { const onObject = TranslationDataSchema.safeParse({ objects: { account: { _actions: { merge: { confirm: 'ok?' } } } }, From 9913812ba0bd505e1fa542f06ecdbab1bdbb9ccf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:29:28 +0000 Subject: [PATCH 2/4] feat(spec,lint): flows translation face gains approvals.NODE.label for an approval node's step label Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .../src/validate-translation-references.ts | 85 +++++++++++++++---- packages/spec/liveness/translation.json | 13 +++ .../src/system/flows-translation-face.test.ts | 41 ++++++--- packages/spec/src/system/translation.zod.ts | 48 +++++++++++ 4 files changed, 155 insertions(+), 32 deletions(-) diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index a8e27f98a56..e8329170008 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -116,6 +116,14 @@ * are each an orphan, with its own diagnosis. The message's `{{ }}` holes are * the schema's to judge (`TranslationDataSchema`, the one text-slot judge). * + * `flows..approvals..label` is judged the same way: the node id + * must name an `approval` node (`APPROVAL_NODE_TYPE`), at any depth. A node of + * another type and an id the flow does not declare are each an orphan, with + * its own diagnosis — a `screen` node pointed at its `screens..title`. + * The reverse slip, a `screens` or `refusals` key written over an approval + * node, is pointed at `approvals..label` rather than told only that + * the node is not a screen. + * * `flows..successMessage` / `.errorMessage` (#22507), the terminal * toasts, are judged against the flow itself: `translateFlow` overlays a toast * only where the flow AUTHORS one, so a translated toast over a flow that @@ -198,7 +206,7 @@ */ import { expandViewContainer } from '@objectstack/spec'; -import { flowScreenFieldOptionKey } from '@objectstack/spec/automation'; +import { APPROVAL_NODE_TYPE, flowScreenFieldOptionKey } from '@objectstack/spec/automation'; import { FLOW_TERMINAL_MESSAGE_KEYS, hasPlatformObjectPrefix, @@ -394,6 +402,11 @@ interface FlowFacts { * renders no message, so a key over one is an orphan. */ refusals: Set; + /** + * The ids of the `approval` nodes — the nodes a + * `flows..approvals.` key addresses, at any depth. + */ + approvals: Set; /** * [#22507] The terminal toasts the flow AUTHORS (`successMessage` / * `errorMessage` declared as a string) — the only ones a @@ -1423,6 +1436,7 @@ function buildUniverse(stack: AnyRec): Universe { if (!ownDeclaration && flows.has(flowName)) return; const screens = new Map(); const refusals = new Set(); + const approvals = new Set(); const otherNodes = new Map(); const messages = new Set(FLOW_TERMINAL_MESSAGE_KEYS.filter((key) => typeof flow[key] === 'string')); for (const { node } of walkFlowNodes(flow, '')) { @@ -1436,6 +1450,7 @@ function buildUniverse(stack: AnyRec): Universe { if (nodeType === END_NODE_TYPE && isRec(node.config) && node.config.outcome === REFUSED_OUTCOME) { refusals.add(nodeId); } + if (nodeType === APPROVAL_NODE_TYPE) approvals.add(nodeId); continue; } const config = isRec(node.config) ? node.config : undefined; @@ -1455,7 +1470,7 @@ function buildUniverse(stack: AnyRec): Universe { hasDescription: typeof config?.description === 'string' && config.description.length > 0, }); } - flows.set(flowName, { screens, refusals, messages, otherNodes }); + flows.set(flowName, { screens, refusals, approvals, messages, otherNodes }); }; for (const flow of recordsOf(stack.flows)) collectFlowRecord(flow, { ownDeclaration: true }); for (const flow of artifactProvidedRecords(stack, 'flows')) { @@ -1923,12 +1938,14 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind orphan( `${inLocale} · flow "${flowName}" · screen "${nodeId}"`, screenPath, - otherType - ? `Translations are keyed to screen "${nodeId}", which flow "${flowName}" ` + - `declares as a \`${otherType}\` node, not a \`screen\`, so nothing resolves these keys.` - : `Translations are keyed to screen "${nodeId}", which flow "${flowName}" ` + - `declares no screen node for, so the wizard step keeps its source-locale heading.` + - suggest(nodeId, flow.screens.keys()), + otherType === APPROVAL_NODE_TYPE + ? approvalRedirect('screen', nodeId, flowName) + : otherType + ? `Translations are keyed to screen "${nodeId}", which flow "${flowName}" ` + + `declares as a \`${otherType}\` node, not a \`screen\`, so nothing resolves these keys.` + : `Translations are keyed to screen "${nodeId}", which flow "${flowName}" ` + + `declares no screen node for, so the wizard step keeps its source-locale heading.` + + suggest(nodeId, flow.screens.keys()), `Screen translations are keyed by the node's \`id\` (the client's ` + `\`ScreenSpec.nodeId\`), not its \`label\`.`, ); @@ -1985,20 +2002,41 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind orphan( `${inLocale} · flow "${flowName}" · refusal "${nodeId}"`, `${flowPath}.refusals.${nodeId}`, - nodeType === END_NODE_TYPE - ? `Translations are keyed to refusal "${nodeId}", which flow "${flowName}" declares as an ` + - `\`end\` node that completes, not one declaring \`outcome: 'refused'\`, so nothing ` + - `resolves this key.` - : nodeType - ? `Translations are keyed to refusal "${nodeId}", which flow "${flowName}" declares as a ` + - `\`${nodeType}\` node, not a refusing \`end\`, so nothing resolves this key.` - : `Translations are keyed to refusal "${nodeId}", a node id flow "${flowName}" does not ` + - `declare, so the refusal keeps its source-locale message.` + - suggest(nodeId, flow.refusals), + nodeType === APPROVAL_NODE_TYPE + ? approvalRedirect('refusal', nodeId, flowName) + : nodeType === END_NODE_TYPE + ? `Translations are keyed to refusal "${nodeId}", which flow "${flowName}" declares as an ` + + `\`end\` node that completes, not one declaring \`outcome: 'refused'\`, so nothing ` + + `resolves this key.` + : nodeType + ? `Translations are keyed to refusal "${nodeId}", which flow "${flowName}" declares as a ` + + `\`${nodeType}\` node, not a refusing \`end\`, so nothing resolves this key.` + : `Translations are keyed to refusal "${nodeId}", a node id flow "${flowName}" does not ` + + `declare, so the refusal keeps its source-locale message.` + + suggest(nodeId, flow.refusals), `Refusal translations are keyed by the \`id\` of an \`end\` node declaring ` + `\`outcome: 'refused'\`.`, ); } + // ── flows..approvals. ────────────────────────────── + for (const nodeId of Object.keys(asRecord(rawFlow.approvals))) { + if (flow.approvals.has(nodeId)) continue; + const nodeType = flow.screens.has(nodeId) ? SCREEN_NODE_TYPE : flow.otherNodes.get(nodeId); + orphan( + `${inLocale} · flow "${flowName}" · approval step "${nodeId}"`, + `${flowPath}.approvals.${nodeId}`, + nodeType === SCREEN_NODE_TYPE + ? `Translations are keyed to approval step "${nodeId}", which flow "${flowName}" declares as a ` + + `\`screen\` node; its heading is keyed \`screens.${nodeId}.title\`.` + : nodeType + ? `Translations are keyed to approval step "${nodeId}", which flow "${flowName}" declares as a ` + + `\`${nodeType}\` node, not an \`approval\`, so nothing resolves this key.` + : `Translations are keyed to approval step "${nodeId}", which flow "${flowName}" declares no ` + + `node for, so the step keeps its source-locale label.` + + suggest(nodeId, flow.approvals), + `Approval-step translations are keyed by the \`id\` of an \`approval\` node.`, + ); + } } } } @@ -2006,6 +2044,17 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind return findings; } +/** + * The verdict for a `screens` / `refusals` key written over an `approval` + * node: the step's one translatable string is its label, under `approvals`. + */ +function approvalRedirect(group: 'screen' | 'refusal', nodeId: string, flowName: string): string { + return ( + `Translations are keyed to ${group} "${nodeId}", which flow "${flowName}" declares as an ` + + `\`approval\` node; its step label is keyed \`approvals.${nodeId}.label\`.` + ); +} + /** `v` as a record of sub-nodes, or an empty record when absent/malformed. */ function asRecord(v: unknown): Record { return isRec(v) ? v : {}; diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index fd99d1c2dfe..c619a941121 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -165,6 +165,19 @@ } }, "note": "[#22450] Seeded `live` together with its reader: the key and the engine read that makes it true land in one change (Prime Directive 10). The run stores the refusal already rendered (`sys_automation_run.refusal_message`, `AutomationResult.refusalMessage`), so no overlay on the served string could translate it; the engine picks the translated TEMPLATE before it fills the holes, the validation-message precedent (`objectValidationMessageKey`, read by objectql's rule evaluator through the same `i18n` channel). A resumed leg renders in the locale the run was started in, and a run no person started (record-change, schedule) carries no locale and stores the authored message. The translation is a text slot like the source: `TranslationDataSchema` refuses a single-brace `{token}` in it through `textSlotTemplateRefusal`, and `objectstack validate` (`validate-translation-references.ts`) refuses a key over an unknown flow, an unknown node, or a node that is not an `end` declaring `outcome: 'refused'`. No objectui reader is involved: the console draws the served `refusalMessage` verbatim. DRILLED to its one key, `message`, so no blanket verdict covers an unclassified child: the entry's face is closed (`.strict()`), and `message` carries the reader and producer evidence." + }, + "approvals": { + "status": "planned", + "verifiedAt": "2026-10-20", + "evidenceScope": "in-repo", + "note": "PLANNED: the spec half of a contract-first split. The face declares `flows..approvals..label`, the step label of an `approval` node, and `objectstack validate` (packages/lint/src/validate-translation-references.ts#validateTranslationReferences) refuses a key over an unknown flow, an unknown node, or a node that is not an `approval`. No reader renders it yet: measured at this commit's base 81e6edd463, packages/plugins/plugin-approvals/src/approval-service.ts copies the node's authored `label` onto `sys_approval_request.current_step_label` when the request opens, and nothing re-derives it in the reader's locale. Carrier: the `plugin-approvals` reader half of #24863. Flip to `live` on the commit that cites that reader. DRILLED to its one key, `label`, so no blanket verdict covers an unclassified child: the entry's face is closed (`.strict()`).", + "children": { + "label": { + "status": "planned", + "verifiedAt": "2026-10-20", + "note": "Planned with its container `flows.approvals` (see that row): overlays the `approval` node's own `FlowNode.label`, the step name an approval request shows; read by no consumer yet." + } + } } }, "note": "[#7646] Contract-first spec half of the screen-flow localization split, and `planned` is the honest status rather than `live` or `dead`: `dead` means declared with no consumer and no plan, while this group was ruled into the vocabulary by the maintainer specifically so the runner half could be built against it (the same ruling fixes the boundary — runner chrome, Cancel/Submit, stays in the console's own message catalog, NOT here). Addressing is measured against what the runner already holds: `flows..screens.` — the node id reaches the client verbatim as `ScreenSpec.nodeId` (packages/spec/src/contracts/automation-service.ts:138), which is also what correlates a resume back to its pause point — and `.fields.` (packages/spec/src/automation/builtin-node-config.zod.ts:382, forwarded as `ScreenFieldSpec.name`). Key face measured against `ScreenFieldConfigSchema`, not mirrored from the report: the per-field keys are `label`, `placeholder` and `inlineHelpText`, each spelled as the screen field spells the string it overlays. The report's `help` was never a key here: when #7646 ruled the face the screen field declared nothing help-shaped, so it would have parsed clean and translated nothing (the ADR-0078 shape #6080 kept out of the page-component face). #17306 gave the screen field `inlineHelpText`, and the face gained it on 2026-10-02 once the console drew the help text; `help` / `helpText` / `hint` / `tooltip` / `description` are aliases onto it on the field surface. `options` stayed out, on `guidance`, because a value-keyed map could not address `ScreenFieldConfig.options[].value`, which is unconstrained (superseded 2026-10-09, see the end of this note). Flip to `live` with an objectui screen-flow-runner evidence pointer when the downstream consumer card lands; the resolver-side helper (a `FLOW_SCREEN_COPY_KEYS` sibling of `PAGE_COMPONENT_COPY_KEYS` in packages/spec/src/system/i18n-resolver.ts) is deliberately NOT in this change — #7634 was in flight on that file. 2026-09-27 (#20296): the `screens` child FLIPPED to `live` (objectui's FlowRunner reads it at the `.objectui-sha` pin f8a9d0fb; see that row), and `authorHint` was rewritten to say so. The container keeps `planned` + `authorWarn` for `label` alone, which nothing reads yet (#20318). The CLI i18n coverage gate keys its whole-group `flows.*` demand off this `authorWarn`, so dropping the bit waits for that reader, and then the whole group flips. 2026-10-05: the `label` child FLIPPED to `live` (objectui's FlowRunner names the flow by `flows..label`, then the served `flowLabel`, then the API name, read at the `.objectui-sha` pin 0abd4f9f8; see that row). Both children are `live`, so the container flips with them, and `authorWarn` and `authorHint` are dropped: a `live` row refuses them, and no half of the group is unread any more. Dropping the bit switches the CLI i18n coverage gate's whole-group `flows.*` demand back on, which is what that gate was built to do on this day. 2026-10-09 (#22507): the closing pass for user-read flow text. The face gains the terminal toasts (`successMessage` / `errorMessage`, child rows `planned`) and, inside `screens`, a screen field's option labels (`fields..options.`, each option keyed by `String(value)`; `ScreenFieldConfigSchema` refuses two options of one field that collide as text). Both are read by `translateFlow` and await the objectui runner change, so neither is drawn translated yet. The container stays `live`: its `label` and `screens` readers are unchanged. The screen `description` stays unkeyed and is recorded as owed in `packages/spec/src/system/flows-translation-face.test.ts`: it is a `{{ }}` template the server renders per run, so its reader is a decision of its own. 2026-10-10 (#22507, maintainer ruling A): that decision is taken. The screen face gains `description`, and the engine picks the translated `title` and `description` templates in the run's locale before it renders them (see the `screens` row). The container stays `live`." diff --git a/packages/spec/src/system/flows-translation-face.test.ts b/packages/spec/src/system/flows-translation-face.test.ts index 1a4a01d0973..54deeeb67e3 100644 --- a/packages/spec/src/system/flows-translation-face.test.ts +++ b/packages/spec/src/system/flows-translation-face.test.ts @@ -17,7 +17,9 @@ * disposition: * * - `keyed` — the slot's key in `TranslationDataSchema.flows`, which must - * exist in the face (parsed and kept, not stripped); + * exist in the face (parsed and kept, not stripped); a slot + * one node type reads under one key and another under a + * second (`node.label`) lists the second as `also`; * - `noReader` — no person running the flow reads it as text (a machine * name, a predicate, a value the run binds, design-time copy * only Studio draws), with the reason; @@ -65,12 +67,19 @@ import { TranslationDataSchema } from './translation.zod'; type FacePath = string; type Disposition = - | { readonly keyed: FacePath; readonly note?: string } + | { readonly keyed: FacePath; readonly also?: readonly FacePath[]; readonly note?: string } | { readonly noReader: string } | { readonly elsewhere: string } | { readonly owed: string }; -const keyed = (path: FacePath, note?: string): Disposition => ({ keyed: path, ...(note ? { note } : {}) }); +const keyed = (path: FacePath, note?: string, also?: readonly FacePath[]): Disposition => ({ + keyed: path, + ...(also ? { also } : {}), + ...(note ? { note } : {}), +}); + +/** Every face key a `keyed` row names: its own, then its `also` keys. */ +const keysOf = (d: Disposition): FacePath[] => ('keyed' in d ? [d.keyed, ...(d.also ?? [])] : []); const noReader = (reason: string): Disposition => ({ noReader: reason }); const FRAMEWORK_FIELD = @@ -108,12 +117,14 @@ const RUNNER_SLOTS: Readonly> = { 'flow._lockDocsUrl': noReader(FRAMEWORK_FIELD), // FlowNodeSchema (its `config` is judged per type below) - 'node.id': noReader('the node id — the address of `screens.` and `refusals.`'), + 'node.id': noReader('the node id — the address of `screens.`, `refusals.` and `approvals.`'), 'node.type': noReader('a node-type token'), 'node.label': keyed( 'screens.NODE.title', - "a screen's fallback heading (`config.title ?? node.label`), which the one `title` key covers; on any other node " - + 'it is designer-canvas text', + "a screen's fallback heading (`config.title ?? node.label`), which the one `title` key covers; an `approval` " + + "node's step name, which the approval request it opens shows its submitter and approvers " + + '(`approvals.NODE.label`); on any other node it is designer-canvas text', + ['approvals.NODE.label'], ), 'node.connectorConfig': noReader('connector and action ids, and input values the engine resolves'), 'node.inputSchema': noReader("design-time parameter declarations for the designer's input mapping"), @@ -173,9 +184,9 @@ const OTHER_NODE_SLOTS: Readonly> = { 'http.headers': noReader('the request headers sent to the endpoint — values with `{{ }}` holes, not copy'), [`${APPROVAL_NODE_TYPE}.decisionOutputs[].label`]: { owed: - "the approver reads it as an input label in the approval decision dialog. No translation face addresses an " - + "approval node's copy — a surface of its own, outside the screen-flow runner's `flows` face, and a decision " - + 'for that surface.', + "the approver reads it as an input label in the approval decision dialog. The `approvals.` group keys " + + "only the step's label; the decision dialog's field labels are a surface of their own, and wait on a reader " + + 'for that dialog.', }, }; @@ -350,10 +361,12 @@ describe('the flows translation face covers every user-read flow slot (#22507)', for (const [row, disposition] of Object.entries(RUNNER_SLOTS)) { if (!('keyed' in disposition)) continue; expect(runner.has(row), `keyed row ${row} must name a leaf exactly, not a sub-tree`).toBe(true); - const entry = sampleEntry(disposition.keyed); - const result = TranslationDataSchema.safeParse({ flows: { f: entry } }); - expect(result.success, `${row} → flows..${disposition.keyed}: ${JSON.stringify(result.error?.issues)}`).toBe(true); - expect(readAt((result as { data: any }).data.flows.f, disposition.keyed), `${row}: the key was stripped`).toBe('x'); + for (const key of keysOf(disposition)) { + const entry = sampleEntry(key); + const result = TranslationDataSchema.safeParse({ flows: { f: entry } }); + expect(result.success, `${row} → flows..${key}: ${JSON.stringify(result.error?.issues)}`).toBe(true); + expect(readAt((result as { data: any }).data.flows.f, key), `${row}: the key was stripped`).toBe('x'); + } } }); @@ -362,7 +375,7 @@ describe('the flows translation face covers every user-read flow slot (#22507)', // The face walk is a record over flow names; its leaves start `*.`. const keys = [...faceLeaves].map((leaf) => leaf.replace(/^\*\./, '')); const filled = new Set( - Object.values(RUNNER_SLOTS).flatMap((d) => ('keyed' in d ? [facePattern(d.keyed)] : [])), + Object.values(RUNNER_SLOTS).flatMap((d) => keysOf(d).map(facePattern)), ); expect(keys.sort()).toEqual([...filled].sort()); }); diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 9cf3e7ef5f5..d0bc611f495 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -1223,6 +1223,7 @@ const appTranslationDataShape = () => ({ * flows..screens..fields..inlineHelpText * flows..screens..fields..options. (#22507) * flows..refusals..message (a refused `end` node, #22450) + * flows..approvals..label (an `approval` node's step label) * * Every user-read string slot of the flow schemas is either one of these * keys or recorded, with its reason, in the family pin @@ -1315,6 +1316,38 @@ const appTranslationDataShape = () => ({ * refuses a key over an unknown flow, an unknown node, or a node that is not * a refused `end`, as it does for the screen keys. * + * ## `approvals` — an `approval` node's step label + * + * flows..approvals..label + * + * An `approval` node (`ApprovalNodeConfigSchema`) is a step a person reads + * by name: the approval request it opens snapshots the node's `label` as the + * step the submitter and the approvers see (`current_step_label`). Until + * this key the group had nowhere to put it, so a pack's step name was + * refused `translation-target-unknown` under `screens` (the node is not a + * screen) and rendered in the source language on every console. + * + * - **Grouped by node type, like `screens`.** `screens` addresses `screen` + * nodes and `refusals` refusing `end` nodes, each by `FlowNode.id`; this + * group addresses `approval` nodes the same way, at any depth (an ADR-0031 + * region included). + * - **The leaf is `label`, the source's own spelling.** The node config + * declares no heading of its own (no `title`, no instructions, no comment + * prompt), so the one person-facing string an approval step carries is + * `FlowNode.label`. A screen spells its leaf `title` because + * `ScreenConfigSchema.title` is what it overlays; `title` here is an alias + * onto `label`. + * - ⛔ **The decision dialog's output labels are NOT here.** A typed + * `decisionOutputs[]` entry's `label` is copy the decision dialog draws, + * recorded as owed in `flows-translation-face.test.ts`; it waits on a + * reader for that dialog, not on this key. + * + * `objectstack validate` refuses a key over an unknown flow, an unknown + * node, or a node that is not an `approval`, and points a `screens` or + * `refusals` key written over an approval node at this group. This is the + * spec half of a contract-first split: the reader that renders the step + * label in the reader's locale is `plugin-approvals`'s, a downstream change. + * * ## `successMessage` / `errorMessage` — the terminal toasts (#22507) * * flows..successMessage @@ -1390,6 +1423,7 @@ const appTranslationDataShape = () => ({ aliases: { name: 'label', title: 'label', nodes: 'screens', steps: 'screens', screen: 'screens', pages: 'screens', refusal: 'refusals', ends: 'refusals', + approval: 'approvals', // The spellings the toast pair arrives in from an action translation, // which spells its own success toast `successMessage` with these aliases. success: 'successMessage', successText: 'successMessage', @@ -1480,6 +1514,20 @@ const appTranslationDataShape = () => ({ + 'the message it translates, rendered by the engine in the run\'s locale in place of the authored one', ), })).optional().describe("Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'`"), + approvals: z.record(z.string(), strictObject({ + surface: 'this flow approval-step translation', + history: TRANSLATION_HISTORY, + // An approval step's one person-facing string is the NODE's `label` + // (`FlowNode.label`), so the leaf is spelled as the source spells it. + // A screen's headline is `title` one group over; an author who holds + // that spelling (the measured HotCRM key) is told the rename. + aliases: { title: 'label', name: 'label', heading: 'label', stepLabel: 'label' }, + }, { + label: z.string().optional().describe( + "Translated approval-step label — overlays the `approval` node's own `label`, the step name an approval " + + 'request shows its submitter and approvers', + ), + })).optional().describe("Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node"), })).optional().describe('Screen-flow translations keyed by flow name'), /** From c218f16520ba97239d07b63c89cf39c6f4a2a956 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:43:16 +0000 Subject: [PATCH 3/4] chore(spec): regenerate docs, strictness-ledger and liveness counts for flows.approvals Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- content/docs/references/system/translation.mdx | 3 +++ .../2026-07-unknown-key-strictness-ledger.counts/system.md | 2 +- packages/spec/liveness/state-counts/translation.md | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index d77f7654442..03873930696 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -262,6 +262,7 @@ Translation data for a single object | **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | | **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | +| **approvals** | `Record` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node | ### Nested Shape: `PlatformTranslationData.metadataForms[string]` @@ -464,6 +465,7 @@ Translation data for a single object | **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | | **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | +| **approvals** | `Record` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node | ### Nested Shape: `TranslationData.metadataForms[string]` @@ -626,6 +628,7 @@ Translation data for a single object | **errorMessage** | `string` | optional | Translated failure toast — overlays the flow's own `errorMessage`, a plain string the engine carries verbatim on the terminal result; only a flow that authors one is translated | | **screens** | `Record }>` | optional | Screen translations keyed by screen node id (`FlowNode.id`, the client's `ScreenSpec.nodeId`) | | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | +| **approvals** | `Record` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node | ### Nested Shape: `TranslationItem.metadataForms[string]` diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index fee2a67d822..63e71ca418e 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 375 | +| `system/` | 376 | diff --git a/packages/spec/liveness/state-counts/translation.md b/packages/spec/liveness/state-counts/translation.md index 6fb4ed0141f..151faa42c81 100644 --- a/packages/spec/liveness/state-counts/translation.md +++ b/packages/spec/liveness/state-counts/translation.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `translation` | 29 | 0 | 0 | 0 | 0 | 29 | +| `translation` | 29 | 0 | 0 | 0 | 1 | 30 | From 00cadf4d632a04f073f3715823be861855c0f530 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:43:29 +0000 Subject: [PATCH 4/4] chore(changeset): minor for flows.approvals translation key Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz Co-authored-by: Claude --- .changeset/24915-approval-step-translation-key.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/24915-approval-step-translation-key.md diff --git a/.changeset/24915-approval-step-translation-key.md b/.changeset/24915-approval-step-translation-key.md new file mode 100644 index 00000000000..01b92a04d31 --- /dev/null +++ b/.changeset/24915-approval-step-translation-key.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +--- + +feat(spec): the `flows` translation face gains `approvals..label`, the step label of an `approval` node + +Clause-②: yes (widening) + +- **Before.** `TranslationDataSchema.flows.` carried `label`, `successMessage`, `errorMessage`, `screens.` (screen nodes) and `refusals.` (refusing `end` nodes), and nothing for an `approval` node. A language pack had nowhere to put an approval step's name: `approvals` was refused as an unknown key, and a `screens..title` key over an approval node was refused `translation-target-unknown` by `objectstack validate`, because the node is not a screen. +- **Now.** `flows..approvals..label` is a declared key, addressed by the `approval` node's `id` (at any depth, regions included) and overlaying that node's own `label`, the step name an approval request shows. The entry is closed: `title`, `name`, `heading` and `stepLabel` are named as renames onto `label`, and `approval` as the rename onto the `approvals` group. +- **`objectstack validate`.** `@objectstack/lint`'s translation-reference check accepts the key over an `approval` node and refuses it, as `translation-target-unknown`, over an unknown node or a node of another type (a `screen` node is pointed at its `screens..title`). A `screens` or `refusals` key written over an approval node is now pointed at `approvals..label` instead of being told only that the node is not a screen. +- **Not yet rendered.** This is the spec half: the key parses and validates, and the approvals service does not read it yet, so an approval request still shows the authored step label. Its liveness row is `planned` until that reader lands. +- **Unchanged.** Every existing `flows` key, and every verdict on a key over a node that is not an approval node.