From c68405e7c8b83052dfa89809ec41a985b34de4df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:14:22 +0000 Subject: [PATCH 1/9] feat(i18n,automation): a refused end node's message translates in the run's locale The flows translation group gains flows.FLOW.refusals.NODE_ID.message, judged as the same text slot as the source message. The trigger and action doors forward the request's resolved ExecutionContext.locale as AutomationContext.locale, and the engine picks the translated template through a lazily bridged i18n service before it renders the holes. objectstack validate refuses a refusal key over an unknown flow, an unknown node, or a node that is not a refused end; the extractor emits the skeleton and coverage rows. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/cli/src/utils/i18n-coverage.ts | 5 +- packages/cli/src/utils/i18n-extract.ts | 28 ++++ .../src/validate-translation-references.ts | 55 ++++++- packages/runtime/src/action-execution.ts | 4 + packages/runtime/src/domains/automation.ts | 5 + .../services/service-automation/src/engine.ts | 153 +++++++++++++++++- .../services/service-automation/src/index.ts | 3 + .../services/service-automation/src/plugin.ts | 31 +++- packages/spec/liveness/translation.json | 8 + .../spec/src/contracts/automation-service.ts | 33 ++++ .../spec/src/kernel/execution-context.zod.ts | 12 +- packages/spec/src/system/i18n-resolver.ts | 18 +++ packages/spec/src/system/translation.zod.ts | 71 +++++++- 13 files changed, 411 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index 8eafdc248a3..3fde9109b4a 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -356,7 +356,8 @@ const COVERAGE_SOURCE: Record picklist: 'picklist', page: 'page', // Screen-flow copy (`flows..label`, `flows..screens..title`, and - // the per-field `label` / `placeholder`) — the author's own wizard text, so + // the per-field `label` / `placeholder`) and a refused `end` node's message + // (`flows..refusals..message`, #22450) — the author's own text, so // it keeps its own bucket and reports as `i18n/missing-flow` rather than // folding away with `--include-platform`. Until this bucket existed the // family could not report a screen-flow gap at all: HotCRM measured @@ -425,7 +426,7 @@ const SOURCE_SURFACE: Record = { dataset: 'datasets', picklist: 'picklists', page: 'pages', - flow: 'flow screens', + flow: 'flow screens and refusals', metadataForm: 'metadata forms', }; diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 521163a07d8..4ae39e26f20 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -108,6 +108,7 @@ * flows..screens..fields..label * flows..screens..fields..placeholder * flows..screens..fields..inlineHelpText + * flows..refusals..message (#22450) * ^ walked because the ledger's `flows` row is `live` and warns no * author; a group the ledger does warn on is left out of the walk * (see `authorWarnedTranslationGroups`) @@ -1674,6 +1675,13 @@ function walkPicklists(config: any, out: ExpectedEntry[]): void { */ const SCREEN_NODE_TYPE = 'screen'; +/** + * [#22450] The node whose message `flows..refusals..message` + * addresses: an `end` node declaring `outcome: 'refused'` (`EndConfigSchema`). + */ +const END_NODE_TYPE = 'end'; +const REFUSED_OUTCOME = 'refused'; + /** * Depth ceiling for the region recursion, mirroring the ceiling the spec-side * walks use (`conversions/walk.ts`, `automation/control-flow.zod.ts`) and for @@ -1877,6 +1885,26 @@ function walkScreenFlows(config: any, out: ExpectedEntry[]): void { } } } + + // `flows..refusals..message` (#22450) — the message of every + // `end` node declaring `outcome: 'refused'`, at any depth of the same node + // universe. The engine reads that key (`flowRefusalMessageKey`) in the + // run's locale before it renders the holes, so the seed is the authored + // TEMPLATE, holes and all: a translator keeps the `{{ }}` holes, and the + // schema judges the translation as the same text slot. Unlike the flow + // `label`, this is demanded for a flow with no screen too: the trigger and + // action doors start any flow by name for a person, and the refusal is the + // answer that person reads (hotcrm's `quote_generation` refuses before its + // first screen). A run no person started carries no locale and stores the + // authored message. + for (const node of nodes) { + if (!node || typeof node !== 'object' || node.type !== END_NODE_TYPE) continue; + const cfg = node.config && typeof node.config === 'object' ? node.config : {}; + if (cfg.outcome !== REFUSED_OUTCOME) continue; + const nodeId = typeof node.id === 'string' && node.id.length > 0 ? node.id : undefined; + if (!nodeId) continue; + pushOptional(out, ['flows', flowName, 'refusals', nodeId, 'message'], inlineText(cfg.message), 'flow', scope); + } } } diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index de2dc9a541a..e8de8998398 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -103,6 +103,13 @@ * resolved, so they need no check of their own — the schema closes the leaf * vocabulary (`.strict()`), which is a shape concern, not a reference. * + * `flows..refusals..message` (#22450) is judged the same way one + * level down: the node id must name an `end` node declaring + * `outcome: 'refused'`, at any depth (the same `walkFlowNodes` universe). A + * completed `end`, a node of another type and an id the flow does not declare + * are each an orphan, with its own diagnosis. The message's `{{ }}` holes are + * the schema's to judge (`TranslationDataSchema`, the one text-slot judge). + * * ── Cross-package objects ──────────────────────────────────────────────── * * A stack legitimately translates objects it does not define — `sys_user`'s @@ -183,6 +190,15 @@ import { viewObjectName } from './view-walk.js'; */ const SCREEN_NODE_TYPE = 'screen'; +/** + * [#22450] The node a `flows..refusals.*` key addresses: an `end` node + * declaring `outcome: 'refused'` (`EndConfigSchema`). Local consts for + * {@link SCREEN_NODE_TYPE}'s reason — `end` is a structural node type and + * `'refused'` a member of the schema's `outcome` enum, neither exported by name. + */ +const END_NODE_TYPE = 'end'; +const REFUSED_OUTCOME = 'refused'; + export const TRANSLATION_TARGET_UNKNOWN = 'translation-target-unknown'; export const TRANSLATION_OPTION_KEY_UNKNOWN = 'translation-option-key-unknown'; @@ -327,6 +343,12 @@ interface ScreenFacts { interface FlowFacts { /** Screen node id → that screen's facts. Keyed by `FlowNode.id`. */ screens: Map; + /** + * The ids of the `end` nodes declaring `outcome: 'refused'` (#22450) — the + * nodes a `flows..refusals.` key addresses. A completed `end` + * renders no message, so a key over one is an orphan. + */ + refusals: Set; /** * Every NON-screen node id → its `type`. The `screens` group addresses screen * nodes only, so a key naming a real `decision` node is still an orphan — but @@ -1309,6 +1331,7 @@ function buildUniverse(stack: AnyRec): Universe { if (!flowName) return; if (!ownDeclaration && flows.has(flowName)) return; const screens = new Map(); + const refusals = new Set(); const otherNodes = new Map(); for (const { node } of walkFlowNodes(flow, '')) { const nodeId = strName(node.id); @@ -1316,6 +1339,11 @@ function buildUniverse(stack: AnyRec): Universe { const nodeType = strName(node.type); if (nodeType !== SCREEN_NODE_TYPE) { if (nodeType && !otherNodes.has(nodeId)) otherNodes.set(nodeId, nodeType); + // [#22450] A refusing `end`, at any depth: the engine reads the + // translated message of whichever `end` the run reaches. + if (nodeType === END_NODE_TYPE && isRec(node.config) && node.config.outcome === REFUSED_OUTCOME) { + refusals.add(nodeId); + } continue; } const config = isRec(node.config) ? node.config : undefined; @@ -1326,7 +1354,7 @@ function buildUniverse(stack: AnyRec): Universe { } screens.set(nodeId, { fields, objectName: strName(config?.objectName) }); } - flows.set(flowName, { screens, otherNodes }); + flows.set(flowName, { screens, refusals, otherNodes }); }; for (const flow of recordsOf(stack.flows)) collectFlowRecord(flow, { ownDeclaration: true }); for (const flow of artifactProvidedRecords(stack, 'flows')) { @@ -1759,6 +1787,31 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind ); } } + // ── flows..refusals. (#22450) ────────────────────── + for (const nodeId of Object.keys(asRecord(rawFlow.refusals))) { + if (flow.refusals.has(nodeId)) continue; + const nodeType = flow.screens.has(nodeId) ? SCREEN_NODE_TYPE : flow.otherNodes.get(nodeId); + 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. Only an \`end\` declaring \`outcome: 'refused'\` shows a ` + + `message, so nothing resolves this key.` + : nodeType + ? `Translations are keyed to refusal "${nodeId}", which flow "${flowName}" declares as a ` + + `\`${nodeType}\` node, not a refusing \`end\`. Only an \`end\` declaring ` + + `\`outcome: 'refused'\` shows a message, so nothing resolves this key.` + : `Translations are keyed to refusal "${nodeId}", a node id flow "${flowName}" does not ` + + `declare. 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'\`.` + + (flow.refusals.size > 0 + ? ` Declared refusing end node ids: ${listNames(flow.refusals)}.` + : ` Flow "${flowName}" declares no refusing \`end\` node at all.`), + ); + } } } } diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index e241e514d0b..01991a5ab62 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -1040,6 +1040,10 @@ export async function dispatchFlowAction(deps: ActionExecutionDeps, ...(Array.isArray(ec?.positions) && ec.positions.length ? { positions: ec.positions } : {}), ...(Array.isArray(ec?.permissions) && ec.permissions.length ? { permissions: ec.permissions } : {}), ...(ec?.tenantId ? { tenantId: ec.tenantId } : {}), + // [#22450] The caller's already-resolved language, the trigger door's + // twin (`buildAutomationContext`): a refused run's message renders in + // it. See `AutomationContext.locale`. + ...(typeof ec?.locale === 'string' && ec.locale.length > 0 ? { locale: ec.locale } : {}), params: seedFlowActionParams(deps, action, { objectName, record, params, recordId }), // [#19846] Which of those keys the CALLER supplied, stated here where it // is known rather than inferred downstream from the merged bag. diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 4945776a222..89759ab2330 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -156,6 +156,11 @@ export function buildAutomationContext(body: any, context: HttpProtocolContext): if (Array.isArray(ec?.positions) && ec.positions.length) automationContext.positions = ec.positions; if (Array.isArray(ec?.permissions) && ec.permissions.length) automationContext.permissions = ec.permissions; if (ec?.tenantId) automationContext.tenantId = ec.tenantId; + // [#22450] The caller's language, already resolved (the request's + // `Accept-Language` first, then the `localization` settings) — forwarded, + // never re-derived, so a refused run's message renders in the language of + // the console around it. `AutomationContext.locale` names its reader. + if (typeof ec?.locale === 'string' && ec.locale.length > 0) automationContext.locale = ec.locale; return automationContext; } diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index 4d9047ddc38..4a2bcc50fae 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -251,7 +251,11 @@ import { describeThrownForLog } from './thrown-cause-diagnostics.js'; // `./builtin/` is safe in this direction: `template.ts` imports only // `../guard-refusal.js` and package-external contracts, so nothing it pulls in // reaches back here. -import { renderTextSlot } from './builtin/template.js'; +import { renderTextSlot, FlowTextTemplateError } from './builtin/template.js'; +// [#22450] The one bundle-locale negotiation rule (exact → case-insensitive → +// base language → variant expansion), asked about the locales the i18n service +// holds — the same question objectql's `negotiatedMessageLocale` asks it. +import { flowRefusalMessageKey, resolveBundleLocale } from '@objectstack/spec/system'; /** * Does this `decision` take EVERY out-edge whose condition holds (#15429)? @@ -1289,6 +1293,44 @@ function isRefusalSignal(err: unknown): err is FlowRefusalSignal { return typeof err === 'object' && err !== null && (err as FlowRefusalSignal).__flowRefused === true; } +/** + * [#22450] The slice of `II18nService` (`@objectstack/spec/contracts`) a + * refusing `end` node reads: `t()` for the translated template, and + * `getLocales()`, when the service offers it, to negotiate the run's locale. + */ +export interface RefusalI18nService { + t(key: string, locale: string, params?: Record): string; + getLocales?(): string[]; +} + +/** + * [#22450] The locale `service` can actually ANSWER in, for the locale the run + * asked for. `AutomationContext.locale` is the door's `ExecutionContext.locale`, + * whose header leg is the request's tag verbatim, so a bare `zh` has to reach a + * `zh-CN` bundle the way the document translators reach it. + * + * ⛔ No rule of its own: `resolveBundleLocale` is the one negotiation rule, asked + * about what `getLocales()` reports, exactly as objectql's + * `negotiatedMessageLocale` asks it. The requested tag passes through untouched + * whenever there is nothing to negotiate against (no `getLocales`, a throwing or + * empty answer, no match). + */ +function negotiatedServiceLocale(service: RefusalI18nService, requested: string): string { + if (typeof service.getLocales !== 'function') return requested; + let available: unknown; + try { + available = service.getLocales(); + } catch { + return requested; + } + if (!Array.isArray(available) || available.length === 0) return requested; + const offered: Record = {}; + for (const code of available) { + if (typeof code === 'string' && code.length > 0) offered[code] = true; + } + return resolveBundleLocale(offered, requested) ?? requested; +} + /** * [#18714] "The child run this frame is parked on finished `refused`" — the * RESUMED-leg counterpart of the synchronous `subflow` / `map` executors' @@ -2445,6 +2487,8 @@ export class AutomationEngine implements IAutomationService { private flowCredentialSource?: FlowCredentialSource; /** [#20281 stage ③] The connector sync executor — see {@link setConnectorPullSource}. */ private connectorPullSource?: (request: ConnectorSourcePullRequest) => Promise; + /** [#22450] The lazy `i18n` service reader — see {@link setI18nServiceSource}. */ + private i18nServiceSource?: () => RefusalI18nService | undefined; /** * Re-entrancy guard for record-triggered flows (complements the intra-run * {@link MAX_NODE_REENTRIES} back-edge guard, which cannot see a self-trigger @@ -4794,6 +4838,21 @@ export class AutomationEngine implements IAutomationService { * materialized, which the engine does not hold, so the plugin hands the * engine the call rather than the map. */ + /** + * [#22450] Attach a reader of the `i18n` service, through which a refusing + * `end` node picks its translated message + * (`flows..refusals..message`) in the run's locale. The + * automation plugin calls this at `init()` with a reader that asks the + * kernel at QUESTION time, the bridge objectql takes for its validation + * messages made lazy: the `i18n` provider may register after this plugin + * starts, and a reading taken at boot would record its absence for good. + * With none attached (a bare engine), or one that answers nothing, every + * refusal renders its authored message. + */ + setI18nServiceSource(source: (() => RefusalI18nService | undefined) | undefined): void { + this.i18nServiceSource = source; + } + setConnectorPullSource( source: ((request: ConnectorSourcePullRequest) => Promise) | undefined, ): void { @@ -10966,6 +11025,93 @@ export class AutomationEngine implements IAutomationService { variables.set(nodeId, { ...base, error: message }); } + /** + * [#22450] A refusing `end` node's message, rendered in the run's language. + * + * The run stores the refusal already rendered + * (`sys_automation_run.refusal_message`, `AutomationResult.refusalMessage`), + * so the translation has to be picked HERE, as a template, before its + * `{{ }}` holes are filled. This is the validation-message precedent + * (objectql's `authoredRuleMessage`) on the automation side: + * + * - the address is `flowRefusalMessageKey` (`@objectstack/spec/system`); + * - the channel is the existing `i18n` service, reached through + * {@link setI18nServiceSource} — no second i18n path; + * - the locale is `AutomationContext.locale`, the door's already-resolved + * `ExecutionContext.locale`, negotiated against the locales the service + * holds by `resolveBundleLocale`, the one bundle-locale rule. + * + * The authored template renders whenever no translation is picked: no + * locale on the run (a record-change or schedule trigger, code calling + * `execute` directly), no service, no entry (`t()` echoes the key on a + * miss), or a service that throws. A resumed leg renders in the locale the + * run was STARTED in — the context is persisted with the suspended run and a + * resume carries none of its own. + * + * A translation is a text slot like the message it translates: the + * single-brace `{token}` is refused at every door that reads the bundle + * (`TranslationDataSchema`, the one text-slot judge). If one still fails to + * compile here, it is a translator's defect, not the run's: the refusal + * must not become a failure, so the authored message renders and the line + * below names the key and the reason. + */ + private renderRefusalMessage( + flowName: string, + nodeId: string, + authored: string | undefined, + variables: Map, + context: AutomationContext, + ): string | undefined { + const translated = this.translatedRefusalTemplate(flowName, nodeId, context.locale); + if (translated !== undefined) { + try { + return renderTextSlot(translated.template, variables); + } catch (err: unknown) { + if (!(err instanceof FlowTextTemplateError)) throw err; + this.logger.warn( + `[automation] flow '${flowName}' refused at node '${nodeId}': the '${translated.locale}' translation ` + + `at '${translated.key}' does not compile (${err.message}), so the refusal shows the authored ` + + `message instead. Fix the translation's holes to match the authored message.`, + ); + } + } + return renderTextSlot(authored, variables); + } + + /** + * [#22450] The translated refusal TEMPLATE for `locale`, or `undefined` when + * none is picked — see {@link renderRefusalMessage} for every reason. + */ + private translatedRefusalTemplate( + flowName: string, + nodeId: string, + requested: string | undefined, + ): { template: string; key: string; locale: string } | undefined { + if (typeof requested !== 'string' || requested.length === 0) return undefined; + let service: RefusalI18nService | undefined; + try { + service = this.i18nServiceSource?.(); + } catch { + return undefined; + } + if (!service || typeof service.t !== 'function') return undefined; + const locale = negotiatedServiceLocale(service, requested); + const key = flowRefusalMessageKey(flowName, nodeId); + try { + const template = service.t(key, locale); + // II18nService echoes the key back on a miss. + if (typeof template === 'string' && template.length > 0 && template !== key) { + return { template, key, locale }; + } + } catch (err: unknown) { + this.logger.warn( + `[automation] flow '${flowName}' refused at node '${nodeId}': the i18n service threw reading ` + + `'${key}' (${err instanceof Error ? err.message : String(err)}), so the refusal shows the authored message.`, + ); + } + return undefined; + } + /** * Execute a node with timeout support, fault edge handling, and step logging. */ @@ -11002,7 +11148,10 @@ export class AutomationEngine implements IAutomationService { // Rendered HERE, against the live variable map, because // that is what makes the text per-record; a template on the // wire would put the rendering in every runner. - renderTextSlot(endConfig.message, variables), + // [#22450] …in the run's language: the TEMPLATE is picked + // before it is rendered, because the run stores the + // rendered text and nothing downstream can translate it. + this.renderRefusalMessage(flow.name, node.id, endConfig.message, variables, context), ); } return; diff --git a/packages/services/service-automation/src/index.ts b/packages/services/service-automation/src/index.ts index b3bfcdbe0c1..be3077d9145 100644 --- a/packages/services/service-automation/src/index.ts +++ b/packages/services/service-automation/src/index.ts @@ -79,6 +79,9 @@ export type { // barrel-reachable through `setFlowCredentialSource` and that method. FlowCredentialSource, FlowCredentialHolding, + // [#22450] What the reader `AutomationEngine.setI18nServiceSource` takes + // answers — barrel-reachable through that method. + RefusalI18nService, } from './engine.js'; // [#11997] ADR-0005 overlay precedence for same-named flow definitions. The boot diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 885704fb42e..409519569ba 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -14,7 +14,7 @@ import { isConnectorUpstreamUnavailable, RetryConfigSchema } from '@objectstack/ import { stripReadDecorations } from '@objectstack/spec/kernel'; import type { MetadataMutationEvent } from '@objectstack/metadata-protocol'; import { AutomationEngine } from './engine.js'; -import type { AutomationEngineOptions, RunSummaryLogLevel } from './engine.js'; +import type { AutomationEngineOptions, RefusalI18nService, RunSummaryLogLevel } from './engine.js'; import { describeThrownForLog, thrownMessageText } from './thrown-cause-diagnostics.js'; import { registerFlowCredentialRedactor } from './flow-credential-projection.js'; import { resolveFlowPrecedence, renderFlowContender } from './flow-precedence.js'; @@ -549,6 +549,30 @@ export function packagedFlowReader(ctx: Pick): (nam }; } +/** + * [#22450] The engine's reader of the `i18n` service, through which a refusing + * `end` node picks its translated message in the run's locale — objectql's + * validation-message bridge (`ObjectQLPlugin`, `setI18nService`), made lazy. + * + * Resolved at QUESTION time, never at boot, for {@link packagedFlowReader}'s + * reason: the `i18n` provider may register after this plugin's `init()`, and a + * registry read turned into a recorded "not there" would be the + * startup-registry verdict AGENTS.md forbids. A composition with no `i18n` + * service, or one whose service has no `t()`, answers `undefined`, and every + * refusal renders its authored message. + */ +export function i18nServiceReader(ctx: Pick): () => RefusalI18nService | undefined { + return () => { + let service: RefusalI18nService | undefined; + try { + service = ctx.getService('i18n'); + } catch { + return undefined; + } + return typeof service?.t === 'function' ? service : undefined; + }; +} + /** * AutomationServicePlugin — Core engine plugin * @@ -942,6 +966,11 @@ export class AutomationServicePlugin implements Plugin { // registered, so no caller can resolve the service without it. this.engine.setConnectorPullSource((request) => this.pullConnectorSource(request)); + // [#22450] A refused run's message in the run's language: the engine + // reads the `i18n` service through this reader when a refusing `end` + // node renders, never at boot (see `i18nServiceReader`). + this.engine.setI18nServiceSource(i18nServiceReader(ctx)); + // Register as global service — other plugins access via ctx.getService('automation') ctx.registerService('automation', this.engine); diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index e8bc04eb2d8..f8422648264 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -133,6 +133,14 @@ "evidence": "packages/spec/src/system/i18n-resolver.ts#resolveFlowScreenTitle (reads `flows..screens..title` down the locale chain and falls back to the screen's own title); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#localizeScreen (the heading through `resolveFlowScreenTitle`, and each field's copy from `bundle[language]?.flows?.[flowName]?.screens?.[screen.nodeId]?.fields`); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#overlayFieldCopy (walks the spec's imported `FLOW_SCREEN_FIELD_COPY_KEYS`, which are `label` / `placeholder` / `inlineHelpText`, and overlays each key on its own, falling back to the authored string); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#activeFlowsBundle (reads the active language's `flows` group out of the i18next `translation` resource tree); objectui @31971ff1e: packages/app-shell/src/views/FlowRunner.tsx#FlowRunner (draws `shown.title` as the dialog title and hands the localized screen to `ScreenView`); objectui @31971ff1e: packages/app-shell/src/views/ScreenView.tsx#ScreenView (draws each field's `label`, hands `placeholder` to the control, and draws `inlineHelpText` under the control, which names it in `aria-describedby`)", "producer": "packages/runtime/src/app-plugin.ts#loadTranslations (hands each bundle's locale data to the i18n service whole, `flows` included); packages/runtime/src/domains/i18n.ts#handleI18nRequest (the translations route answers `getTranslations(locale)` with no group filter); objectui @31971ff1e: apps/console/src/loadLanguage.ts#loadLanguage (fetches the locale's translations and runs `transformSpecTranslations`); objectui @31971ff1e: packages/i18n/src/utils/spec-translations.ts#transformSpecTranslations (forwards every group it does not flatten, `flows` among them, verbatim under the `app` namespace); objectui @31971ff1e: packages/i18n/src/provider.tsx#I18nProvider (adds the loaded payload to the `translation` resource bundle that `activeFlowsBundle` reads)", "note": "FLIPPED planned → live 2026-09-27 (#20296). It was PLANNED with its container under the #7646 contract-first split: the spec declared the vocabulary and the screen-flow runner half was a downstream objectui card. That half has landed client-side, the side #11287 picked (objectui#5920, FlowRunner's `localizeScreen`), and the `.objectui-sha` pin f8a9d0fb carries it, so every objectui pointer above was read at the pin this repo builds against. Each read is unchanged at objectui main 256b4c9e. Per-screen heading + per-field copy, keyed by `FlowNode.id` / `ScreenFieldConfig.name`, the identifiers the client already holds as `ScreenSpec.nodeId` / `ScreenFieldSpec.name`. Each key falls back to the authored string on its own. The screen `description` and the runner chrome stay untranslated here by ruling. Deeper conventions (`screens..title`, `screens..fields..{label,placeholder,inlineHelpText}`) are governed by the runner and the spec's `FLOW_SCREEN_COPY_KEYS` / `FLOW_SCREEN_FIELD_COPY_KEYS`, not by ledger rows: the one-drill-level boundary this ledger's type note states. MOUNT CHAIN, closed by hand at the pin: `FlowRunner` is mounted by `useConsoleActionRuntime`'s dialogs (the console root `ConsoleShell` renders them, and ObjectView / DeclaredActionsBar each run their own runtime), by `RecordDetailView`, and by the console's `developer/flow-runs` route (`FlowRunsPage`). A `type: 'flow'` action whose run pauses at a screen node opens it with `{ flowName, runId, screen }`. The bundle reaches it through the console root's `I18nProvider loadLanguage` (apps/console main.tsx). ⚠️ NOT flipped with it: the container `flows` keeps `planned` + `authorWarn` for its `label` child, which nothing reads yet (#20318), and its `authorHint` now says that `screens` is read and the flow label is not. That bit is group-level and has two readers: @objectstack/lint's warn map, and the CLI i18n coverage gate (`authorWarnedTranslationGroups` in packages/cli i18n-extract.ts), which holds back the whole `flows.*` demand while it is set. Dropping it before the label has a reader would switch on demand for `flows..label` too, so it drops when #20318 lands (seat ruling on #20296). RE-VERIFIED 2026-10-02 (#17306) at the `.objectui-sha` pin 31971ff1e, which carries objectui 81778b955 (REST compare 81778b955...31971ff1e answers `ahead`, `behind_by 0`): the per-field face gained `inlineHelpText` beside `label` and `placeholder`, the screen field's own help-text key. Every objectui pointer in `evidence` and `producer` was re-read at that pin and repinned from f8a9d0fb. `overlayFieldCopy` imports the spec's key list rather than retyping it, so the new key needed no objectui edit, and `ScreenView` draws the help text under the control (objectui#9248). The status is unchanged: this row was already `live`. 2026-10-05: the `⚠️ NOT flipped with it` clause above is superseded. The `label` child is `live` now (objectui's FlowRunner reads it at the pin 0abd4f9f8), so the container flipped to `live` and dropped `authorWarn` and `authorHint`; see the container note." + }, + "refusals": { + "status": "live", + "verifiedAt": "2026-10-09", + "evidenceScope": "in-repo", + "evidence": "packages/services/service-automation/src/engine.ts#translatedRefusalTemplate (reads `flows..refusals..message` through the `i18n` service, spelled by `flowRefusalMessageKey`, in the run's `AutomationContext.locale` negotiated by `resolveBundleLocale`; a miss echoes the key and picks nothing); packages/services/service-automation/src/engine.ts#renderRefusalMessage (renders the picked translation in place of the authored `end` `message` through `renderTextSlot`, the one text renderer, so the refusal the run stores and returns is in the run's language; the authored template renders when nothing is picked)", + "producer": "packages/runtime/src/app-plugin.ts#loadTranslations (hands each bundle's locale data to the i18n service whole, `flows` included); packages/core/src/fallbacks/authored-translation-sync.ts#wireAuthoredTranslationSync (runtime-authored translation items reach the same service); packages/services/service-automation/src/plugin.ts#i18nServiceReader (the engine's lazy reader of that service); packages/runtime/src/domains/automation.ts#buildAutomationContext and packages/runtime/src/action-execution.ts#dispatchFlowAction (the trigger and action doors forward the request's already-resolved `ExecutionContext.locale` as `AutomationContext.locale`, which persists with a suspended run)", + "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." } }, "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` stays out, on `guidance`, because a value-keyed map cannot address `ScreenFieldConfig.options[].value`, which is unconstrained. 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." diff --git a/packages/spec/src/contracts/automation-service.ts b/packages/spec/src/contracts/automation-service.ts index 90bb4566012..476bcb6fad3 100644 --- a/packages/spec/src/contracts/automation-service.ts +++ b/packages/spec/src/contracts/automation-service.ts @@ -91,6 +91,39 @@ export interface AutomationContext { * stays tenant-scoped. ADR-0049 / #1888. */ tenantId?: string; + /** + * The language of the person who started the run (BCP-47, e.g. `zh-CN`): + * the door's already-resolved `ExecutionContext.locale`, forwarded as-is. + * It is never resolved a second time here. The request's own + * `Accept-Language` wins over the user's and the workspace's `localization` + * settings, and that precedence lives in one place, the shared + * execution-context assembler. + * + * Who sets it: the two doors that start a run on a person's behalf, from the + * request's execution context — + * - the trigger door, `buildAutomationContext` in `@objectstack/runtime`'s + * `domains/automation.ts` (`POST /api/v1/automation/:name/trigger`, the + * legacy trigger route, and a declarative `type: 'flow'` endpoint); + * - the action door, `dispatchFlowAction` in `@objectstack/runtime`'s + * `action-execution.ts` (REST `POST /api/v1/actions/...` and the MCP + * `run_action` bridge). + * + * Its reader: the engine's refusing `end` node, which picks the translated + * message at `flows..refusals..message` + * (`flowRefusalMessageKey`) in this locale before it renders the `{{ }}` + * holes (#22450). With no locale, or no translation, the authored message + * renders. + * + * It lives as long as the run: it is persisted with a suspended run, so a + * resumed leg renders in the STARTER's locale whoever resumes it (a resume + * carries no locale of its own), and a `subflow` / `map` child inherits it + * with the rest of the parent's context. Absent on a run no person started: + * a record-change, schedule or webhook trigger, or code calling `execute` + * directly. Such a run stores the authored message. + * + * Presentation, not authorization: no security middleware keys on it. + */ + locale?: string; /** * Effective execution identity for the run's DATA operations, established by * the engine from {@link FlowParsed.runAs} at run setup (ADR-0049 / #1888): diff --git a/packages/spec/src/kernel/execution-context.zod.ts b/packages/spec/src/kernel/execution-context.zod.ts index 233330053c4..a1eea6e6da4 100644 --- a/packages/spec/src/kernel/execution-context.zod.ts +++ b/packages/spec/src/kernel/execution-context.zod.ts @@ -94,9 +94,15 @@ export const ExecutionContextSchema = lazySchema(() => z.object({ timezone: z.string().optional(), /** - * Active locale (BCP-47, e.g. `en-US`), resolved from the `localization` - * settings alongside `timezone`. Drives message catalogs and number/date - * formatting. When unset, consumers treat it as `en-US`. + * Active locale (BCP-47, e.g. `en-US`). The request's own `Accept-Language` + * wins (its highest-priority tag, `preferredLocaleFromHeader`); without one, + * it is resolved from the `localization` settings alongside `timezone`. The + * precedence lives in the shared assembler (`assembleExecutionContextOrGuest` + * in `@objectstack/core`), so the REST and MCP faces cannot disagree. Unset + * for an anonymous caller. + * Drives message catalogs and number/date formatting, and a flow run started + * by this request carries it as `AutomationContext.locale`. When unset, + * consumers treat it as `en-US`. */ locale: z.string().optional(), diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index a8eade91831..e067d9a8e5c 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -2694,6 +2694,24 @@ export function objectValidationMessageKey(objectName: string, ruleName: string) return `objects.${objectName}._validations.${ruleName}.message`; } +/** + * Dot-notation i18n key for a refused `end` node's message — + * `flows..refusals..message` (#22450). + * + * A sibling of {@link objectValidationMessageKey}, spelled here for the same + * reason: its consumer holds an `II18nService` (which takes a key), not a + * `TranslationBundle`. Used by the automation engine, which picks the + * translated TEMPLATE in the run's locale (`AutomationContext.locale`) before + * it renders the `{{ }}` holes. The run stores the refusal already rendered, + * so nothing downstream could translate it afterwards. + * + * `flowName` is `Flow.name` and `nodeId` is the refusing `end` node's + * `FlowNode.id`, the same addressing the `screens` group uses. + */ +export function flowRefusalMessageKey(flowName: string, nodeId: string): string { + return `flows.${flowName}.refusals.${nodeId}.message`; +} + export function resolveObjectFieldLabels( data: TranslationData | undefined, objectName: string, diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 2d70234867c..5425a6003df 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -11,6 +11,7 @@ import { retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; +import { textSlotTemplateRefusal } from '../automation/flow-text-slot-template'; export const LocaleSchema = lazySchema(() => z.string().describe('BCP-47 Language Tag (e.g. en-US, zh-CN)')); export type Locale = z.input; @@ -1187,6 +1188,7 @@ const appTranslationDataShape = () => ({ * flows..screens..fields..label * flows..screens..fields..placeholder * flows..screens..fields..inlineHelpText + * flows..refusals..message (a refused `end` node, #22450) * * **The hole this closes (#7646).** A `type: 'screen'` flow is a wizard the * user reads — a heading, a list of labelled inputs — and the bundle had no @@ -1243,21 +1245,53 @@ const appTranslationDataShape = () => ({ * and in the completion toast, falling back to the label the engine serves * as `AutomationResult.flowLabel` and then to the flow's API name. Both were * measured at the `.objectui-sha` pin `0abd4f9f8`. See the `flows` rows in - * `liveness/translation.json`: the group and both its children are `live`. + * `liveness/translation.json`: the group and its children are `live`. + * + * ## `refusals` — a refused `end` node's message (#22450) + * + * flows..refusals..message + * + * An `end` node declaring `outcome: 'refused'` carries a `message` the user + * reads in place of the flow's completion (`EndConfigSchema`), and until this + * key the group had nowhere to put it: a first-class refusal rendered in the + * source language on every console, beside a translated flow label and + * translated screens. Addressed like the screens, by `Flow.name` and + * `FlowNode.id` (a refused `end` nested in an ADR-0031 region included). + * + * **Picked before it is rendered, by the engine.** The run stores the + * refusal already rendered (`sys_automation_run.refusal_message`, + * `AutomationResult.refusalMessage`), so an overlay on that string could not + * work. `AutomationEngine` reads `flowRefusalMessageKey(flow, node)` + * (`system/i18n-resolver.ts`) through the `i18n` service, in the run's + * `AutomationContext.locale`, and renders the translation in place of the + * authored template. With no locale, or no entry, the authored template + * renders. + * + * **The same text slot as the source.** A translation keeps the `{{ }}` + * holes of the message it translates, and the one text-slot judge + * (`textSlotTemplateRefusal`) refuses a single-brace `{token}` here exactly + * as `EndConfigSchema` refuses it on the source. `objectstack validate` + * 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. */ flows: z.record(z.string(), strictObject({ surface: 'this flow translation', history: TRANSLATION_HISTORY, - aliases: { name: 'label', title: 'label', nodes: 'screens', steps: 'screens', screen: 'screens', pages: 'screens' }, + aliases: { + name: 'label', title: 'label', nodes: 'screens', steps: 'screens', screen: 'screens', pages: 'screens', + refusal: 'refusals', ends: 'refusals', + }, guidance: { successMessage: '`flow.successMessage` / `flow.errorMessage` are not part of the flows translation surface — ' - + 'it carries the flow label, per-screen headings and per-screen field copy. The terminal ' - + 'toast renders the string authored on the flow.', + + 'it carries the flow label, per-screen headings, per-screen field copy and the message of ' + + "each `end` node declaring `outcome: 'refused'` (`refusals..message`). The " + + 'terminal toast renders the string authored on the flow.', errorMessage: '`flow.errorMessage` / `flow.successMessage` are not part of the flows translation surface — ' - + 'it carries the flow label, per-screen headings and per-screen field copy. The terminal ' - + 'toast renders the string authored on the flow.', + + 'it carries the flow label, per-screen headings, per-screen field copy and the message of ' + + "each `end` node declaring `outcome: 'refused'` (`refusals..message`). The " + + 'terminal toast renders the string authored on the flow.', }, }, { label: z.string().optional().describe('Translated flow label'), @@ -1303,6 +1337,31 @@ const appTranslationDataShape = () => ({ inlineHelpText: z.string().optional().describe('Translated screen field help text (drawn under the input)'), })).optional().describe('Screen field translations keyed by field name (`ScreenFieldConfig.name`)'), })).optional().describe('Screen translations keyed by screen node id (`FlowNode.id`, the client\'s `ScreenSpec.nodeId`)'), + refusals: z.record(z.string(), strictObject({ + surface: 'this flow refusal translation', + history: TRANSLATION_HISTORY, + // The words a refusal arrives spelled in — the same aliases + // `EndConfigSchema` renames onto its own `message`. + aliases: { text: 'message', reason: 'message', description: 'message' }, + guidance: { + title: + 'An `end` node has no heading — a runner shows the refusal `message` alone under the flow\'s ' + + 'label. Translate `message`; a `title` belongs to a screen (`screens..title`).', + }, + }, { + message: z.string().min(1) + // The one text-slot judge, applied to the translation exactly as + // `EndConfigSchema` applies it to the message it translates. + .superRefine((value, ctx) => { + const refusal = textSlotTemplateRefusal(value); + if (refusal !== undefined) ctx.addIssue({ code: 'custom', message: refusal }); + }) + .optional() + .describe( + "Translated refusal message of an `end` node declaring `outcome: 'refused'` — a `{{ }}` template like " + + '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'`"), })).optional().describe('Screen-flow translations keyed by flow name'), /** From 751e9c6b492e171cdc8f8665a8e2466f9366962f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:40:51 +0000 Subject: [PATCH 2/9] chore(spec): declare the refusal translation's dropped text-slot refinement sites The translated refusal message carries the text-slot judge as a refinement, which JSON Schema cannot state; the ledger names each published site. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .../spec/dropped-refinements.baseline.json | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index cb8e909fb59..2206faf6e4f 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -102,6 +102,7 @@ "manifest.reports.element.runtimeFilter.lazy", "manifest.sharingRules.element.sharedWith", "manifest.skills.element.triggerConditions.element", + "manifest.translations.element.valueType.flows.valueType.refusals.valueType.message", "manifest.views.element.form", "manifest.views.element.form.sections.element.in", "manifest.views.element.form.submitBehavior.options[1].url", @@ -233,6 +234,11 @@ "package.manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, + "api/GetTranslationsResponse": { + "sites": [ + "translations.flows.valueType.refusals.valueType.message" + ] + }, "api/GetUiViewResponse": { "sites": [ "form", @@ -306,6 +312,7 @@ "options[1].manifest.reports.element.runtimeFilter.lazy", "options[1].manifest.sharingRules.element.sharedWith", "options[1].manifest.skills.element.triggerConditions.element", + "options[1].manifest.translations.element.valueType.flows.valueType.refusals.valueType.message", "options[1].manifest.views.element.form", "options[1].manifest.views.element.form.sections.element.in", "options[1].manifest.views.element.form.submitBehavior.options[1].url", @@ -1153,6 +1160,16 @@ "buckets.element.lifecyclePolicy.rules.element" ] }, + "system/PlatformTranslationBundle": { + "sites": [ + "valueType.flows.valueType.refusals.valueType.message" + ] + }, + "system/PlatformTranslationData": { + "sites": [ + "flows.valueType.refusals.valueType.message" + ] + }, "system/Schedule": { "sites": [ "options[0].timezone" @@ -1193,6 +1210,21 @@ "rateLimit" ] }, + "system/TranslationBundle": { + "sites": [ + "valueType.flows.valueType.refusals.valueType.message" + ] + }, + "system/TranslationData": { + "sites": [ + "flows.valueType.refusals.valueType.message" + ] + }, + "system/TranslationItem": { + "sites": [ + "flows.valueType.refusals.valueType.message" + ] + }, "ui/Action": { "sites": [ "in", From cdee8fa80a86333a6f284c56503dd00c677ddd2d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:40:51 +0000 Subject: [PATCH 3/9] chore(spec): regenerate the artifacts check:generated proved stale api-surface, export-origins, references docs, strictness-ledger counts and liveness state counts, from a freshly built packages/spec. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 2 +- content/docs/references/system/translation.mdx | 9 ++++++--- .../system.md | 2 +- packages/spec/api-surface/system.json | 1 + packages/spec/export-origins/system.json | 1 + packages/spec/liveness/state-counts/translation.md | 2 +- 6 files changed, 11 insertions(+), 6 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index f05b0e279e8..163ede93376 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1624,7 +1624,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | -| **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | +| **flows** | `Record; refusals?: Record }>` | optional | Screen-flow translations keyed by flow name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 4e28995a8ea..0f8c5ee8afa 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -176,7 +176,7 @@ Platform translation data — the per-app groups plus the platform-only `setting | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | -| **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | +| **flows** | `Record; refusals?: Record }>` | optional | Screen-flow translations keyed by flow name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | @@ -259,6 +259,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated flow label | | **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'` | ### Nested Shape: `PlatformTranslationData.metadataForms[string]` @@ -375,7 +376,7 @@ Per-app translation data for objects, apps, and UI messages | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | -| **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | +| **flows** | `Record; refusals?: Record }>` | optional | Screen-flow translations keyed by flow name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | @@ -457,6 +458,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated flow label | | **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'` | ### Nested Shape: `TranslationData.metadataForms[string]` @@ -524,7 +526,7 @@ One locale of translations — the `translation` metadata type | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | | **pages** | `Record` | optional | Page translations keyed by page name | -| **flows** | `Record }>` | optional | Screen-flow translations keyed by flow name | +| **flows** | `Record; refusals?: Record }>` | optional | Screen-flow translations keyed by flow name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") | @@ -616,6 +618,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated flow label | | **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'` | ### 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 0d9366a9ebe..a45160e4971 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/` | 354 | +| `system/` | 355 | diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index fa002c24036..aeb340c4630 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -788,6 +788,7 @@ "deriveImplicitPackageBook (function)", "docAudienceAllows (function)", "emailTemplateForm (const)", + "flowRefusalMessageKey (function)", "gcsStorageExample (const)", "globalFilterKey (function)", "hasMovedFileColumns (function)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index f6ced167812..32f6cea50f9 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -749,6 +749,7 @@ "deriveImplicitPackageBook": "src/system/book.zod.ts#deriveImplicitPackageBook (function)", "docAudienceAllows": "src/system/book.zod.ts#docAudienceAllows (function)", "emailTemplateForm": "src/system/email-template.form.ts#emailTemplateForm (const)", + "flowRefusalMessageKey": "src/system/i18n-resolver.ts#flowRefusalMessageKey (function)", "gcsStorageExample": "src/system/object-storage.zod.ts#gcsStorageExample (const)", "globalFilterKey": "src/system/i18n-resolver.ts#globalFilterKey (function)", "hasMovedFileColumns": "src/system/migration.zod.ts#hasMovedFileColumns (function)", diff --git a/packages/spec/liveness/state-counts/translation.md b/packages/spec/liveness/state-counts/translation.md index 8738431449d..8b97be69b57 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` | 26 | 0 | 0 | 0 | 0 | 26 | +| `translation` | 27 | 0 | 0 | 0 | 0 | 27 | From e0d595a95a337e791ececd6c66eb16f4a78aacbd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:40:51 +0000 Subject: [PATCH 4/9] test(automation): pin the refusal translation reader Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .../src/end-node-refusal-translation.test.ts | 242 ++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 packages/services/service-automation/src/end-node-refusal-translation.test.ts diff --git a/packages/services/service-automation/src/end-node-refusal-translation.test.ts b/packages/services/service-automation/src/end-node-refusal-translation.test.ts new file mode 100644 index 00000000000..5dce858c7e9 --- /dev/null +++ b/packages/services/service-automation/src/end-node-refusal-translation.test.ts @@ -0,0 +1,242 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #22450 — a refusing `end` node's message renders in the run's locale. + * + * `flows..refusals..message` is the key; this file pins its + * reader. The run stores the refusal ALREADY rendered (`refusalMessage` on the + * result and on the terminal history row), so the engine picks the translated + * TEMPLATE before it fills the `{{ }}` holes, through the `i18n` service the + * automation plugin bridges (`setI18nServiceSource`), in + * `AutomationContext.locale` — the door's already-resolved + * `ExecutionContext.locale`. The producer half (the two doors forwarding it) is + * `flow-run-locale.test.ts` in `@objectstack/runtime`; the contexts below are + * the literal shape those doors hand the engine. + * + * The i18n service here is the REAL in-memory implementation + * (`createMemoryI18n`, `@objectstack/core`), loaded the way `AppPlugin` loads a + * stack's bundles — not a double that answers whatever the test wants. + * + * ⚠️ Direction, predicted before running: every "translated" pin is RED on the + * unfixed engine (it rendered the authored English template whatever the + * locale); every "authored" pin is green on both sides on purpose — they fence + * the fallback. + */ + +import { describe, it, expect } from 'vitest'; +import { createMemoryI18n } from '@objectstack/core'; +import type { AutomationContext } from '@objectstack/spec/contracts'; +import { flowRefusalMessageKey } from '@objectstack/spec/system'; + +import { AutomationEngine } from './engine.js'; +import { InMemorySuspendedRunStore } from './suspended-run-store.js'; +import { installBuiltinNodes } from './builtin/index.js'; +import { i18nServiceReader } from './plugin.js'; + +interface LogLine { level: string; message: string } + +function createTestLogger(lines: LogLine[] = []): any { + const at = (level: string) => (message: unknown) => { lines.push({ level, message: String(message) }); }; + return { info: at('info'), warn: at('warn'), error: at('error'), debug: at('debug'), child: () => createTestLogger(lines) }; +} + +const AUTHORED = 'Refused: {{ record.name }} is a confirmed duplicate'; +const ZH_TEMPLATE = '已拒绝:{{ record.name }} 是已确认的重复记录'; +const ACME = { id: 'rec_1', name: 'Acme Corp' } as const; + +/** The context a door hands the engine — `locale` is the request's resolved one. */ +function doorContext(locale?: string): AutomationContext { + return { + event: 'manual', object: 'account', record: { ...ACME }, userId: 'usr_1', + ...(locale ? { locale } : {}), + } as unknown as AutomationContext; +} + +/** `start → finish`, the finish an `end` declaring `outcome: 'refused'`. */ +function refusingFlow(name = 'dedupe') { + return { + name, label: name, type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'finish', type: 'end', label: 'Finish', config: { outcome: 'refused', message: AUTHORED } }, + ], + edges: [{ id: 'e0', source: 'start', target: 'finish' }], + }; +} + +/** A stack's bundles, loaded the way `AppPlugin.loadTranslations` hands them to the service. */ +function i18nWith(bundles: Record>) { + const i18n = createMemoryI18n(); + for (const [locale, data] of Object.entries(bundles)) i18n.loadTranslations(locale, data); + return i18n; +} + +const ZH_BUNDLE = { flows: { dedupe: { refusals: { finish: { message: ZH_TEMPLATE } } } } }; + +function engineWith(i18n: unknown, lines: LogLine[] = []) { + const store = new InMemorySuspendedRunStore(); + const engine = new AutomationEngine(createTestLogger(lines), store); + // The plugin's own reader, over a kernel whose `i18n` service is `i18n`. + engine.setI18nServiceSource(i18nServiceReader({ + getService: ((name: string) => { + if (name === 'i18n' && i18n) return i18n as T; + throw new Error(`service '${name}' not registered`); + }) as never, + })); + return { engine, store }; +} + +describe('#22450 — the key the reader asks for', () => { + it('is `flows..refusals..message`', () => { + expect(flowRefusalMessageKey('dedupe', 'finish')).toBe('flows.dedupe.refusals.finish.message'); + }); +}); + +describe('#22450 — a zh-CN run of a refused flow stores the translated message', () => { + it('renders the zh-CN template with its hole filled, on the result and on the history row', async () => { + const { engine, store } = engineWith(i18nWith({ 'zh-CN': ZH_BUNDLE })); + engine.registerFlow('dedupe', refusingFlow() as never); + + const result = await engine.execute('dedupe', doorContext('zh-CN')); + + expect(result.success).toBe(true); + expect(result.status).toBe('refused'); + expect(result.refusalMessage).toBe('已拒绝:Acme Corp 是已确认的重复记录'); + + const runs = await engine.listRuns('dedupe', { limit: 1 }); + const record = await store.loadTerminal!(runs[0]!.id); + expect(record?.status).toBe('refused'); + expect(record?.refusalMessage).toBe('已拒绝:Acme Corp 是已确认的重复记录'); + }); + + it('negotiates the requested tag the way the bundle readers do: `zh` reaches the `zh-CN` bundle', async () => { + const { engine } = engineWith(i18nWith({ en: {}, 'zh-CN': ZH_BUNDLE })); + engine.registerFlow('dedupe', refusingFlow() as never); + + const result = await engine.execute('dedupe', doorContext('zh')); + + expect(result.refusalMessage).toBe('已拒绝:Acme Corp 是已确认的重复记录'); + }); +}); + +describe('#22450 — the authored message renders whenever no translation is picked', () => { + it('CONTROL — a locale with no entry falls back to the source message', async () => { + const { engine } = engineWith(i18nWith({ en: {}, 'zh-CN': ZH_BUNDLE })); + engine.registerFlow('dedupe', refusingFlow() as never); + + const result = await engine.execute('dedupe', doorContext('fr-FR')); + + expect(result.status).toBe('refused'); + expect(result.refusalMessage).toBe('Refused: Acme Corp is a confirmed duplicate'); + }); + + it('a run with no locale (no person started it) stores the authored message', async () => { + const { engine } = engineWith(i18nWith({ 'zh-CN': ZH_BUNDLE })); + engine.registerFlow('dedupe', refusingFlow() as never); + + const result = await engine.execute('dedupe', doorContext()); + + expect(result.refusalMessage).toBe('Refused: Acme Corp is a confirmed duplicate'); + }); + + it('a composition with no `i18n` service renders the authored message', async () => { + const { engine } = engineWith(undefined); + engine.registerFlow('dedupe', refusingFlow() as never); + + const result = await engine.execute('dedupe', doorContext('zh-CN')); + + expect(result.refusalMessage).toBe('Refused: Acme Corp is a confirmed duplicate'); + }); + + it('a translation that does not compile leaves the refusal a refusal, in the authored text, and says why', async () => { + const lines: LogLine[] = []; + const broken = { flows: { dedupe: { refusals: { finish: { message: '已拒绝:{{ record.name | no_such_formatter }}' } } } } }; + const { engine } = engineWith(i18nWith({ 'zh-CN': broken }), lines); + engine.registerFlow('dedupe', refusingFlow() as never); + + const result = await engine.execute('dedupe', doorContext('zh-CN')); + + expect(result.status).toBe('refused'); + expect(result.refusalMessage).toBe('Refused: Acme Corp is a confirmed duplicate'); + const warning = lines.find((l) => l.level === 'warn' && l.message.includes('flows.dedupe.refusals.finish.message')); + expect(warning?.message).toContain("the 'zh-CN' translation"); + }); +}); + +describe('#22450 — a door-started run carries its locale into a resumed leg', () => { + /** `start → ask (screen) → finish (refused)`: the refusal renders on the RESUMED leg. */ + function reviewFlow() { + return { + name: 'review', label: 'review', type: 'screen', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'ask', type: 'screen', label: 'Ask', + config: { waitForInput: true, title: 'Confirm', fields: [{ name: 'ok', label: 'OK', type: 'checkbox' }] }, + }, + { id: 'finish', type: 'end', label: 'Finish', config: { outcome: 'refused', message: AUTHORED } }, + ], + edges: [ + { id: 'e0', source: 'start', target: 'ask' }, + { id: 'e1', source: 'ask', target: 'finish' }, + ], + }; + } + const REVIEW_ZH = { flows: { review: { refusals: { finish: { message: ZH_TEMPLATE } } } } }; + + it('a hot resume renders in the locale the run was STARTED in — the resume carries none', async () => { + const { engine } = engineWith(i18nWith({ 'zh-CN': REVIEW_ZH })); + installBuiltinNodes(engine, { logger: createTestLogger(), getService: () => undefined } as never); + engine.registerFlow('review', reviewFlow() as never); + + const paused = await engine.execute('review', doorContext('zh-CN')); + expect(paused.status).toBe('paused'); + + const resumed = await engine.resume(paused.runId!, { variables: { ok: true } } as never); + + expect(resumed.status).toBe('refused'); + expect(resumed.refusalMessage).toBe('已拒绝:Acme Corp 是已确认的重复记录'); + }); + + it('a cold resume — a second engine over the same store — reads the locale persisted with the run', async () => { + const i18n = i18nWith({ 'zh-CN': REVIEW_ZH }); + const first = engineWith(i18n); + installBuiltinNodes(first.engine, { logger: createTestLogger(), getService: () => undefined } as never); + first.engine.registerFlow('review', reviewFlow() as never); + const paused = await first.engine.execute('review', doorContext('zh-CN')); + expect(paused.status).toBe('paused'); + expect((await first.store.load(paused.runId!))?.context?.locale).toBe('zh-CN'); + + // A restart: a new engine, nothing in memory, the store the only carrier. + const second = new AutomationEngine(createTestLogger(), first.store); + second.setI18nServiceSource(() => i18n as never); + installBuiltinNodes(second, { logger: createTestLogger(), getService: () => undefined } as never); + second.registerFlow('review', reviewFlow() as never); + + const resumed = await second.resume(paused.runId!, { variables: { ok: true } } as never); + + expect(resumed.status).toBe('refused'); + expect(resumed.refusalMessage).toBe('已拒绝:Acme Corp 是已确认的重复记录'); + }); +}); + +describe('#22450 — the plugin\'s reader is lazy', () => { + it('asks the kernel at question time: an `i18n` service registered after the reader was made is read', () => { + const services = new Map(); + const reader = i18nServiceReader({ + getService: ((name: string) => { + if (!services.has(name)) throw new Error(`service '${name}' not registered`); + return services.get(name) as T; + }) as never, + }); + expect(reader()).toBeUndefined(); + const i18n = i18nWith({}); + services.set('i18n', i18n); + expect(reader()).toBe(i18n); + }); + + it('a service with no `t()` answers nothing', () => { + const reader = i18nServiceReader({ getService: (() => ({ getLocales: () => [] })) as never }); + expect(reader()).toBeUndefined(); + }); +}); From 8830f205918fe3b17e297976236da81f61f54d9f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 15:53:40 +0000 Subject: [PATCH 5/9] test(i18n): pin the refusal key's doors, judge, extractor and schema The trigger and action doors forward the locale; os validate refuses a refusal key over a completed end, an unknown node and an unknown flow; the extractor scaffolds and demands the key; the schema judges a translated refusal as a text slot and accepts the skeleton's empty slot. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .../test/i18n-flow-refusal-coverage.test.ts | 112 +++++++++++++++++ .../validate-translation-references.test.ts | 93 ++++++++++++++ packages/runtime/src/flow-run-locale.test.ts | 117 ++++++++++++++++++ packages/spec/src/system/translation.test.ts | 61 ++++++++- packages/spec/src/system/translation.zod.ts | 5 +- 5 files changed, 385 insertions(+), 3 deletions(-) create mode 100644 packages/cli/test/i18n-flow-refusal-coverage.test.ts create mode 100644 packages/runtime/src/flow-run-locale.test.ts diff --git a/packages/cli/test/i18n-flow-refusal-coverage.test.ts b/packages/cli/test/i18n-flow-refusal-coverage.test.ts new file mode 100644 index 00000000000..0f7542f27b0 --- /dev/null +++ b/packages/cli/test/i18n-flow-refusal-coverage.test.ts @@ -0,0 +1,112 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#22450 — a refused `end` node's message joins the flow bucket. +// +// An `end` node declaring `outcome: 'refused'` carries a `message` the person +// who started the run reads in place of a completion. The bundle now has a key +// for it (`flows..refusals..message`) and the engine reads that +// key in the run's locale before it renders the holes. These pins are the CLI +// half: the walker scaffolds the key, the coverage gate demands it, and the +// key the walker writes is the very address the engine asks for +// (`flowRefusalMessageKey`), so the two cannot drift. +// +// The ledger mock is `i18n-flow-screen-coverage.test.ts`'s, for its reason: +// these pins measure the bucket itself, whatever the shipped ledger says next. + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@objectstack/lint', async (importOriginal) => ({ + ...(await importOriginal()), + authorWarnedProperties: () => new Set(), +})); + +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; +import { TranslationDataSchema, flowRefusalMessageKey } from '@objectstack/spec/system'; + +const REFUSAL = 'Qualification approval comes first: {{ record.name }} is held.'; + +/** hotcrm's measured shape: a refusal on the start leg, before the first screen. */ +const quoteGeneration = { + name: 'quote_generation', + label: 'Generate Quote', + type: 'screen', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'get_held', type: 'decision', label: 'Held?' }, + { id: 'refuse_held', type: 'end', label: 'Quote Refused', config: { outcome: 'refused', message: REFUSAL } }, + { id: 'screen_1', type: 'screen', label: 'Quote', config: { title: 'Quote' } }, + { id: 'done', type: 'end', label: 'Done', config: { outcome: 'completed' } }, + ], + edges: [], +}; + +const refusalEntries = (config: any) => + collectExpectedEntries(config).filter((e) => e.path[0] === 'flows' && e.path[2] === 'refusals'); + +describe('what the walker harvests from a refusing `end` node (#22450)', () => { + it('emits the message of the refused end only, keyed as the engine reads it, seeded with the template', () => { + const entries = refusalEntries({ flows: [quoteGeneration] }); + + expect(entries.map((e) => e.path.join('.'))).toEqual([flowRefusalMessageKey('quote_generation', 'refuse_held')]); + // The seed is the authored TEMPLATE, holes and all: the translator keeps them. + expect(entries[0]).toMatchObject({ sourceValue: REFUSAL, inline: REFUSAL, source: 'flow' }); + }); + + it('reaches a refusing `end` nested in an ADR-0031 region', () => { + const flow = { + name: 'bulk_check', + label: 'Bulk Check', + nodes: [ + { + id: 'guard', type: 'try_catch', label: 'Guard', + config: { + try: { nodes: [{ id: 'nested_refusal', type: 'end', label: 'No', config: { outcome: 'refused', message: 'Refused.' } }] }, + catch: { nodes: [] }, + }, + }, + ], + }; + expect(refusalEntries({ flows: [flow] }).map((e) => e.path.join('.'))).toEqual([ + 'flows.bulk_check.refusals.nested_refusal.message', + ]); + }); +}); + +describe('the coverage gate demands the refusal (#22450)', () => { + const tree = (flows?: Record) => ({ + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + flows: [quoteGeneration], + translations: [{ 'zh-CN': { ...(flows ? { flows } : {}) } }], + }); + const zhRefusals = (config: any) => + computeI18nCoverage(config).issues.filter((i) => i.locale === 'zh-CN' && i.key.includes('.refusals.')); + + it('reports the untranslated refusal in the flow bucket', () => { + const issues = zhRefusals(tree()); + expect(issues.map((i) => i.key)).toEqual(['flows.quote_generation.refusals.refuse_held.message']); + expect(issues[0]!.source).toBe('flow'); + }); + + it('goes quiet once it is translated', () => { + const issues = zhRefusals(tree({ + quote_generation: { refusals: { refuse_held: { message: '须先完成资格审批:{{ record.name }} 已被暂缓。' } } }, + })); + expect(issues).toEqual([]); + }); +}); + +describe('the skeleton `os i18n extract` writes (#22450)', () => { + const result = extractTranslations({ flows: [quoteGeneration] }, { locales: ['en', 'zh-CN'] }); + + it('writes the template into the default locale and an empty slot into the others', () => { + expect((result.bundles.en as any).flows.quote_generation.refusals).toEqual({ refuse_held: { message: REFUSAL } }); + expect((result.bundles['zh-CN'] as any).flows.quote_generation.refusals).toEqual({ refuse_held: { message: '' } }); + }); + + it('parses through the schema it is written for, in every locale', () => { + for (const [locale, bundle] of Object.entries(result.bundles)) { + expect(TranslationDataSchema.safeParse(bundle).success, `skeleton for ${locale}`).toBe(true); + } + }); +}); diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 1a8e70ec068..8e310618f48 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -2118,6 +2118,99 @@ describe('validateTranslationReferences — flows (#7646 / #11287)', () => { }); }); +describe('validateTranslationReferences — flow refusals (#22450)', () => { + /** + * hotcrm's measured shape: a refusal on the START leg (`get_held → + * refuse_held`), ahead of the first screen, beside a completing `end` and a + * `decision` — so each orphan diagnosis below is judged against the same + * metadata as the clean run, and differs only in the bundle. + */ + const quoteStack = { + flows: [ + { + name: 'quote_generation', + type: 'screen', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'get_held', type: 'decision', label: 'Held?' }, + { + id: 'refuse_held', + type: 'end', + label: 'Quote Refused', + config: { outcome: 'refused', message: 'Qualification approval comes first: {{ record.name }}.' }, + }, + { id: 'screen_1', type: 'screen', label: 'Quote', config: { fields: [{ name: 'quoteName' }] } }, + { id: 'done', type: 'end', label: 'Done', config: { outcome: 'completed' } }, + ], + }, + ], + }; + const bundle = (flows: unknown) => ({ ...quoteStack, translations: [{ 'zh-CN': { flows } }] }); + const firstSentence = (message: string) => message.slice(0, message.indexOf('. ') + 1); + + it('reports nothing for the message of an `end` declaring `outcome: \'refused\'`', () => { + expect( + validateTranslationReferences( + bundle({ quote_generation: { refusals: { refuse_held: { message: '须先完成资格审批:{{ record.name }}。' } } } }), + ), + ).toEqual([]); + }); + + it('refuses a refusal key on a COMPLETED `end` node', () => { + const findings = validateTranslationReferences( + bundle({ quote_generation: { refusals: { done: { message: '完成' } } } }), + ); + 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.quote_generation.refusals.done'); + expect(firstSentence(findings[0].message)).toBe( + 'Translations are keyed to refusal "done", which flow "quote_generation" declares as an `end` node that completes.', + ); + expect(findings[0].hint).toContain('Declared refusing end node ids: refuse_held.'); + }); + + it('refuses a refusal key over an UNKNOWN node, with the nearest refusing node offered', () => { + const findings = validateTranslationReferences( + bundle({ quote_generation: { refusals: { refuse_hold: { message: '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.quote_generation.refusals.refuse_hold'); + expect(firstSentence(findings[0].message)).toBe( + 'Translations are keyed to refusal "refuse_hold", a node id flow "quote_generation" does not declare.', + ); + expect(findings[0].message).toContain('Did you mean "refuse_held"?'); + }); + + it('refuses a refusal key over an UNKNOWN flow, at the flow level', () => { + const findings = validateTranslationReferences( + bundle({ quote_generations: { refusals: { refuse_held: { message: '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.quote_generations'); + expect(firstSentence(findings[0].message)).toBe( + 'Translations are keyed to flow "quote_generations", which this stack does not define.', + ); + }); + + it('diagnoses a refusal key on a node of another type as such', () => { + for (const [nodeId, type] of [['get_held', 'decision'], ['screen_1', 'screen']] as const) { + const findings = validateTranslationReferences( + bundle({ quote_generation: { refusals: { [nodeId]: { message: 'x' } } } }), + ); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe(`translations[0]["zh-CN"].flows.quote_generation.refusals.${nodeId}`); + expect(firstSentence(findings[0].message)).toBe( + `Translations are keyed to refusal "${nodeId}", which flow "quote_generation" declares as a \`${type}\` node, not a refusing \`end\`.`, + ); + } + }); +}); + describe('validateTranslationReferences — namespaces deliberately not judged', () => { it('ignores messages, validationMessages, settings, metadataForms and settingsCommon', () => { const findings = validateTranslationReferences({ diff --git a/packages/runtime/src/flow-run-locale.test.ts b/packages/runtime/src/flow-run-locale.test.ts new file mode 100644 index 00000000000..8e9f6562c45 --- /dev/null +++ b/packages/runtime/src/flow-run-locale.test.ts @@ -0,0 +1,117 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The two flow doors forward the caller's language — `AutomationContext.locale` + * (#22450). + * + * A refusing `end` node's message is rendered once, by the engine, and stored + * rendered, so the engine has to know the run's language when it renders. The + * doors already hold it: `ExecutionContext.locale`, resolved once per request + * by the shared assembler (the request's `Accept-Language` first, then the + * `localization` settings). They forward it as-is — ⛔ never re-derived here. + * + * What this file pins — the PRODUCER half. The consumer half, on the real + * automation engine, is `end-node-refusal-translation.test.ts` in + * `@objectstack/service-automation`, which runs the context these doors build + * through a refusal on the start leg and on a resumed leg. + * + * 1. The trigger door (`buildAutomationContext`) forwards `ec.locale`, and + * both of its routes hand it to the engine. + * 2. The action door (`dispatchFlowAction`) forwards it, for REST `/actions` + * and MCP `run_action` alike. + * 3. A request with no resolved locale (anonymous, or no header and no + * settings) adds no key — absent, never an empty string. + */ + +import { describe, it, expect, vi } from 'vitest'; + +import { HttpDispatcher } from './http-dispatcher.js'; +import { dispatchFlowAction, loadActionSubjectRecord } from './action-execution.js'; +import { buildAutomationContext } from './domains/automation.js'; + +const FLOW_NAME = 'quote_generation'; + +const QUOTE = { + name: 'generate_quote', + label: 'Generate quote', + objectName: 'crm_opportunity', + type: 'flow', + target: FLOW_NAME, +}; + +function ec(locale?: string) { + return { + userId: 'usr_1', tenantId: 'org_1', positions: [], permissions: [], systemPermissions: [], + ...(locale !== undefined ? { locale } : {}), + }; +} + +/** An automation double that records the context it is handed. */ +function makeAutomation() { + const execute = vi.fn(async (_flow: string, _context?: any) => ({ success: true, output: {} })); + const getFlow = vi.fn(async (name: string) => (name === FLOW_NAME ? { name } : null)); + return { execute, getFlow }; +} +const contextOf = (automation: { execute: any }) => automation.execute.mock.calls[0]?.[1]; +const flowDeps = (automation: any): any => ({ + resolveService: async (_ctx: any, name: string) => (name === 'automation' ? automation : undefined), +}); +const REQUEST: any = { request: {}, environmentId: 'platform' }; + +describe('[#22450] the trigger door forwards the caller\'s locale', () => { + it('`buildAutomationContext` carries `executionContext.locale` as `locale`', () => { + const ctx: any = buildAutomationContext( + { recordId: 'opp_1', objectName: 'crm_opportunity', params: {} }, + { request: {}, executionContext: ec('zh-CN') } as any, + ); + expect(ctx.locale).toBe('zh-CN'); + }); + + it('a request with no resolved locale adds no key', () => { + const ctx: any = buildAutomationContext( + { recordId: 'opp_1', objectName: 'crm_opportunity', params: {} }, + { request: {}, executionContext: ec() } as any, + ); + expect('locale' in ctx).toBe(false); + const empty: any = buildAutomationContext({ params: {} }, { request: {}, executionContext: ec('') } as any); + expect('locale' in empty).toBe(false); + }); + + for (const route of [ + { label: 'POST /:name/trigger', path: `/${FLOW_NAME}/trigger` }, + { label: 'legacy POST /trigger/:name', path: `/trigger/${FLOW_NAME}` }, + ]) { + it(`${route.label} hands the engine the locale`, async () => { + const automation = makeAutomation(); + const resolve = (n: string) => (n === 'automation' ? automation : undefined); + const kernel: any = { getService: resolve, getServiceAsync: async (n: string) => resolve(n), context: { getService: resolve } }; + await new HttpDispatcher(kernel).handleAutomation( + route.path, 'POST', + { recordId: 'opp_1', objectName: 'crm_opportunity', params: {} }, + { request: {}, executionContext: ec('zh-CN') } as any, + ); + expect(automation.execute).toHaveBeenCalledTimes(1); + expect(contextOf(automation).locale).toBe('zh-CN'); + }); + } +}); + +describe('[#22450] the action door forwards the caller\'s locale', () => { + it('`dispatchFlowAction` carries `ec.locale` as `locale`', async () => { + const automation = makeAutomation(); + const subject = await loadActionSubjectRecord('crm_opportunity', 'opp_1', async () => ({ record: { id: 'opp_1' } })); + await dispatchFlowAction(flowDeps(automation), REQUEST, QUOTE, { + objectName: 'crm_opportunity', subject, params: {}, recordId: 'opp_1', ec: ec('zh-CN'), envId: 'platform', + }); + expect(contextOf(automation).locale).toBe('zh-CN'); + }); + + it('a request with no resolved locale adds no key', async () => { + const automation = makeAutomation(); + const subject = await loadActionSubjectRecord('crm_opportunity', 'opp_1', async () => ({ record: { id: 'opp_1' } })); + await dispatchFlowAction(flowDeps(automation), REQUEST, QUOTE, { + objectName: 'crm_opportunity', subject, params: {}, recordId: 'opp_1', ec: ec(), envId: 'platform', + }); + expect('locale' in contextOf(automation)).toBe(false); + }); +}); diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 120224ed511..5473c8c52c9 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; -import { PAGE_COMPONENT_COPY_KEYS, FLOW_SCREEN_COPY_KEYS, FLOW_SCREEN_FIELD_COPY_KEYS } from './i18n-resolver'; +import { PAGE_COMPONENT_COPY_KEYS, FLOW_SCREEN_COPY_KEYS, FLOW_SCREEN_FIELD_COPY_KEYS, flowRefusalMessageKey } from './i18n-resolver'; +import { TEXT_SLOT_TEMPLATE_REFUSAL } from '../automation/flow-text-slot-template'; import { FlowSchema } from '../automation/flow.zod'; import { ScreenConfigSchema, ScreenFieldConfigSchema } from '../automation/builtin-node-config.zod'; import { @@ -1291,6 +1292,62 @@ describe('translation unknown-key strictness', () => { }); }); + // ────────────────────────────────────────────────────────────────────────── + // #22450 — a refused `end` node's message has a key, judged as a text slot + // ────────────────────────────────────────────────────────────────────────── + describe('flow refusals — `flows..refusals..message`', () => { + const parse = (flows: unknown) => TranslationDataSchema.safeParse({ flows }); + const firstSentence = (message: string) => message.slice(0, message.indexOf('. ') + 1); + + it('accepts a translated refusal that keeps the `{{ }}` holes of the message it translates', () => { + const result = parse({ quote_generation: { refusals: { refuse_held: { message: '须先完成资格审批:{{ record.name }}。' } } } }); + expect(result.success).toBe(true); + }); + + it('is the address the engine asks the i18n service for', () => { + // The one spelling, read by the engine (`flowRefusalMessageKey`) and + // written by a bundle: the parsed path and the key are the same string. + const result = parse({ quote_generation: { refusals: { refuse_held: { message: 'x' } } } }) as { success: true; data: any }; + expect(result.data.flows.quote_generation.refusals.refuse_held.message).toBe('x'); + expect(flowRefusalMessageKey('quote_generation', 'refuse_held')).toBe('flows.quote_generation.refusals.refuse_held.message'); + }); + + it('refuses a single-brace `{token}` in a translation, through the one text-slot judge', () => { + const result = parse({ quote_generation: { refusals: { refuse_held: { message: '已拒绝:{record.name}' } } } }); + expect(result.success).toBe(false); + const issue = result.error!.issues[0]!; + expect(issue.path).toEqual(['flows', 'quote_generation', 'refusals', 'refuse_held', 'message']); + expect(firstSentence(issue.message)).toBe(firstSentence(TEXT_SLOT_TEMPLATE_REFUSAL)); + // …and the remedy names the hole spelling of the very token. + expect(issue.message).toContain('{{ record.name }}'); + }); + + it('reaches the metadata-item door too', () => { + const flows = { quote_generation: { refusals: { refuse_held: { message: '已拒绝:{record.name}' } } } }; + const item = TranslationItemSchema.safeParse({ locale: 'zh-CN', flows }); + expect(item.success).toBe(false); + expect(item.error!.issues[0]!.path).toEqual(['flows', 'quote_generation', 'refusals', 'refuse_held', 'message']); + }); + + it('accepts an empty message — the untranslated slot `os i18n extract` writes into a skeleton', () => { + expect(parse({ quote_generation: { refusals: { refuse_held: { message: '' } } } }).success).toBe(true); + }); + + it('refuses a `title` with the guidance that an `end` node has no heading', () => { + const result = parse({ quote_generation: { refusals: { refuse_held: { title: 'Refused' } } } }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.code === 'unrecognized_keys')!; + expect(issue.path).toEqual(['flows', 'quote_generation', 'refusals', 'refuse_held']); + expect(issue.message).toContain('An `end` node has no heading'); + }); + + it('names `reason` as the rename onto `message` it is', () => { + const result = parse({ quote_generation: { refusals: { refuse_held: { reason: 'x' } } } }); + expect(result.success).toBe(false); + expect(result.error!.issues.find((i) => i.code === 'unrecognized_keys')?.message).toContain('`reason` → `message`'); + }); + }); + it('names which action surface the key landed on', () => { const onObject = TranslationDataSchema.safeParse({ objects: { account: { _actions: { merge: { confirm: 'ok?' } } } }, @@ -1316,7 +1373,7 @@ describe('translation unknown-key strictness', () => { globalActions: { export_csv: { label: 'Export', params: { format: { label: 'Format' } } } }, dashboards: { sales: { label: 'Sales', widgets: { rev: { title: 'Revenue', description: 'vs last quarter' } }, globalFilters: { region: { label: 'Region', options: { emea: 'EMEA' } } } } }, pages: { home: { label: 'Home', title: 'Welcome' } }, - flows: { lead_conversion: { label: 'Convert Lead', screens: { details: { title: 'Details', fields: { name: { label: 'Name', placeholder: 'Enter a name' } } } } } }, + flows: { lead_conversion: { label: 'Convert Lead', screens: { details: { title: 'Details', fields: { name: { label: 'Name', placeholder: 'Enter a name' } } } }, refusals: { duplicate: { message: 'Refused: {{ record.name }} is a duplicate' } } } }, metadataForms: { object: { label: 'Object', fields: { name: { label: 'Name' } } } }, settingsCommon: { sourceLabels: { env: 'Env', tenant: 'Tenant' } }, }; diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 5425a6003df..91fdec44371 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -1349,7 +1349,10 @@ const appTranslationDataShape = () => ({ + 'label. Translate `message`; a `title` belongs to a screen (`screens..title`).', }, }, { - message: z.string().min(1) + // No `.min(1)`, like every sibling leaf: an empty string is the + // untranslated slot `os i18n extract` writes into a skeleton, and the + // engine reads it as no translation (the authored message renders). + message: z.string() // The one text-slot judge, applied to the translation exactly as // `EndConfigSchema` applies it to the message it translates. .superRefine((value, ctx) => { From 437c830ae7ffc6d4f8c7cde9e72cec122a28e0b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:20:41 +0000 Subject: [PATCH 6/9] chore(changeset): the refusal translation key and its reader Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- .changeset/22450-flow-refusal-translation.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/22450-flow-refusal-translation.md diff --git a/.changeset/22450-flow-refusal-translation.md b/.changeset/22450-flow-refusal-translation.md new file mode 100644 index 00000000000..6522fe1a484 --- /dev/null +++ b/.changeset/22450-flow-refusal-translation.md @@ -0,0 +1,19 @@ +--- +"@objectstack/spec": minor +"@objectstack/service-automation": minor +"@objectstack/runtime": minor +"@objectstack/lint": minor +"@objectstack/cli": minor +--- + +feat(i18n): a refused `end` node's message translates in the run's locale (`flows..refusals..message`) + +Clause-②: yes (widening) + +- **What is new.** An `end` node declaring `outcome: 'refused'` shows its `message` to the person who started the run. Translate it under `flows..refusals..message`, keyed by the flow's `name` and the `end` node's `id`, the same addressing the `screens` keys use. Before this key, a refusal rendered in the source language on every console. +- **Keep the holes.** The translation is a `{{ }}` template like the message it translates (`'已拒绝:{{ record.name }} 是重复记录'`). A single-brace `{token}` in a translation is refused when the bundle is parsed, with the same text-slot refusal the source message gets. +- **Where it is read.** The engine picks the translated template before it renders the holes, through the `i18n` service, in the run's locale, and the run stores and returns that text (`refusalMessage`). With no translation for that locale, the authored message renders. `flowRefusalMessageKey(flowName, nodeId)` (`@objectstack/spec/system`) spells the key. +- **The run's locale.** `AutomationContext.locale` (`@objectstack/spec/contracts`) is new and optional. The trigger door (`POST /api/v1/automation/:name/trigger`, the legacy trigger route, a `type: 'flow'` endpoint) and the action door (`POST /api/v1/actions/...`, the MCP `run_action` bridge) set it from the request's resolved locale: `Accept-Language` first, then the `localization` settings. It is persisted with a paused run, so a resumed leg renders in the locale the run was started in. A run no person started (a record-change or schedule trigger) has no locale and stores the authored message. +- **`objectstack validate`** refuses a refusal key over an unknown flow, an unknown node, a completed `end` node, or a node of another type (`translation-target-unknown`, error). +- **`os i18n extract` and the coverage gate** now scaffold and demand the refusal message of every refusing `end` node in the `flow` bucket (`i18n/missing-flow`). A project that declares `supportedLocales` and has a refusing `end` with no translation now gets one missing-key issue per locale for it. +- **`AutomationEngine.setI18nServiceSource()`** (`@objectstack/service-automation`) attaches the `i18n` reader. The automation plugin wires it, so a host using `AutomationServicePlugin` needs no change. A host constructing the engine directly gets the authored message until it attaches one. From 2788f5e6e6cf3b595eb1e3b7025940291d91db01 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 16:34:28 +0000 Subject: [PATCH 7/9] chore(liveness): drill the refusals row to its one key check:liveness refuses a blanket verdict over an unclassified child; the entry's only key, message, carries the reader and producer evidence. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/spec/liveness/translation.json | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index f8422648264..c87ec1ebc1c 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -138,9 +138,17 @@ "status": "live", "verifiedAt": "2026-10-09", "evidenceScope": "in-repo", - "evidence": "packages/services/service-automation/src/engine.ts#translatedRefusalTemplate (reads `flows..refusals..message` through the `i18n` service, spelled by `flowRefusalMessageKey`, in the run's `AutomationContext.locale` negotiated by `resolveBundleLocale`; a miss echoes the key and picks nothing); packages/services/service-automation/src/engine.ts#renderRefusalMessage (renders the picked translation in place of the authored `end` `message` through `renderTextSlot`, the one text renderer, so the refusal the run stores and returns is in the run's language; the authored template renders when nothing is picked)", - "producer": "packages/runtime/src/app-plugin.ts#loadTranslations (hands each bundle's locale data to the i18n service whole, `flows` included); packages/core/src/fallbacks/authored-translation-sync.ts#wireAuthoredTranslationSync (runtime-authored translation items reach the same service); packages/services/service-automation/src/plugin.ts#i18nServiceReader (the engine's lazy reader of that service); packages/runtime/src/domains/automation.ts#buildAutomationContext and packages/runtime/src/action-execution.ts#dispatchFlowAction (the trigger and action doors forward the request's already-resolved `ExecutionContext.locale` as `AutomationContext.locale`, which persists with a suspended run)", - "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." + "evidence": "packages/services/service-automation/src/engine.ts#translatedRefusalTemplate (the one reader of the group: each `refusals.` entry is read for its `message`, the drilled child below)", + "children": { + "message": { + "status": "live", + "verifiedAt": "2026-10-09", + "evidenceScope": "in-repo", + "evidence": "packages/services/service-automation/src/engine.ts#translatedRefusalTemplate (reads `flows..refusals..message` through the `i18n` service, spelled by `flowRefusalMessageKey`, in the run's `AutomationContext.locale` negotiated by `resolveBundleLocale`; a miss echoes the key and picks nothing); packages/services/service-automation/src/engine.ts#renderRefusalMessage (renders the picked translation in place of the authored `end` `message` through `renderTextSlot`, the one text renderer, so the refusal the run stores and returns is in the run's language; the authored template renders when nothing is picked)", + "producer": "packages/runtime/src/app-plugin.ts#loadTranslations (hands each bundle's locale data to the i18n service whole, `flows` included); packages/core/src/fallbacks/authored-translation-sync.ts#wireAuthoredTranslationSync (runtime-authored translation items reach the same service); packages/services/service-automation/src/plugin.ts#i18nServiceReader (the engine's lazy reader of that service); packages/runtime/src/domains/automation.ts#buildAutomationContext and packages/runtime/src/action-execution.ts#dispatchFlowAction (the trigger and action doors forward the request's already-resolved `ExecutionContext.locale` as `AutomationContext.locale`, which persists with a suspended run)" + } + }, + "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." } }, "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` stays out, on `guidance`, because a value-keyed map cannot address `ScreenFieldConfig.options[].value`, which is unconstrained. 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." From 3b83c95baaebb38d187614a98f1d24ada5a56a35 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 17:04:55 +0000 Subject: [PATCH 8/9] test(spec): the text-slot judge's lead sentence is the whole constant Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/spec/src/system/translation.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/spec/src/system/translation.test.ts b/packages/spec/src/system/translation.test.ts index 5473c8c52c9..465173f10e9 100644 --- a/packages/spec/src/system/translation.test.ts +++ b/packages/spec/src/system/translation.test.ts @@ -1317,7 +1317,8 @@ describe('translation unknown-key strictness', () => { expect(result.success).toBe(false); const issue = result.error!.issues[0]!; expect(issue.path).toEqual(['flows', 'quote_generation', 'refusals', 'refuse_held', 'message']); - expect(firstSentence(issue.message)).toBe(firstSentence(TEXT_SLOT_TEMPLATE_REFUSAL)); + // The judge's lead sentence is the whole of `TEXT_SLOT_TEMPLATE_REFUSAL`. + expect(firstSentence(issue.message)).toBe(TEXT_SLOT_TEMPLATE_REFUSAL); // …and the remedy names the hole spelling of the very token. expect(issue.message).toContain('{{ record.name }}'); }); From 5b3bf68de58a9a51a71357fc6cbbdaebdb2e0bef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 19:20:36 +0000 Subject: [PATCH 9/9] chore(spec): the dropped-refinements ledger's header totals match its body 226 published schemas and 688 sites, the 6 schemas and 8 sites the refusal translation's text-slot refinement adds. Claude-Session: https://claude.ai/code/session_01KNKBCRDJCu5tGy3TEbvtrF Co-authored-by: Claude --- packages/spec/dropped-refinements.baseline.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 2206faf6e4f..c5fc10a4729 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -2,8 +2,8 @@ "description": "Shrink-only ledger of every PUBLISHED JSON Schema that is STILL WIDER than the Zod type it was generated from, because a rule written as `.refine()` reaches the runtime and not the file (#18670). `z.toJSONSchema()` has no arm for a `custom` check: a plain record, the same record with a `.refine()`, and the same record with an ABORTING `.refine()` all project byte-identically (measured on zod 4.4.3, the version packages/spec resolves). So a document one of these files ACCEPTS can still be refused at parse time, and an author -- or an AI -- validating against packages/spec/json-schema/** finds out a release later. Each `sites` path is a position under that schema at which a refinement is dropped; the same paths are written onto the artifact itself as `x-dropped-refinements`. Item 2 closed the first patterns: a refinement DECLARED through the closed list in src/shared/refinement-projection.ts is emitted into the published file, reads `projected` rather than `dropped`, and its row LEAVES this ledger in the same PR -- which is why the ledger shrinks and never grows on a repair. Every refinement outside that closed list stays here, and adding an arm to the list is a public-contract decision, not a refactor. Hand-edited on purpose and with no `gen:` script: a generator would let a new gap be admitted by running a command instead of by a decision, which is the silence this ledger exists to end. Adding, removing or moving a site fails packages/spec/scripts/build-schemas.ts until the line moves with it, and the failure prints the corrected entry in full. ⛔ Do not delete or weaken a refinement to shorten this file -- the runtime rule is correct; it is the projection that is silent, and the remedy is to teach the closed list a NAMED pattern, never to drop the rule.", "measured": { "zod": "4.4.3", - "publishedSchemasWithDroppedRefinements": 220, - "droppedRefinementSites": 680, + "publishedSchemasWithDroppedRefinements": 226, + "droppedRefinementSites": 688, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 },