Skip to content

feat(i18n): a refused end node's message translates in the run's locale - #22525

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-22450-flow-refusal-translation
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-22450-flow-refusal-translation

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22450

Clause-②: yes (widening)

A refused end node's message now has a translation key, flows.FLOW.refusals.NODE_ID.message. The run renders it in the locale of the person who started it. Before this, a refusal showed in the source language on every console, even when the flow label and screens around it were translated. This PR implements route A of the seat's order on the card (comment 6083596139), in one PR, so the key ships together with its reader.

Why the engine translates, not the renderer

The run stores the refusal already rendered: sys_automation_run.refusal_message and AutomationResult.refusalMessage. Nothing downstream can translate it after that. So the engine picks the translated TEMPLATE before it fills the double-brace holes. This follows the validation-message precedent: objectValidationMessageKey, read by objectql's rule evaluator through the same i18n channel.

What changed

@objectstack/spec

  • TranslationDataSchema.flows gains refusals.NODE_ID.message.
    • The text-slot judge (textSlotTemplateRefusal) applies to the translation exactly as EndConfigSchema applies it to the source message, so a single-brace token is refused.
    • An empty string is accepted. It is the untranslated slot os i18n extract writes.
    • The aliases text / reason / description point to message, and a title gets guidance.
  • flowRefusalMessageKey(flowName, nodeId) sits beside objectValidationMessageKey in system/i18n-resolver.ts.
  • AutomationContext.locale is new and optional. It is documented as the door's already-resolved ExecutionContext.locale.
  • The ExecutionContext.locale docblock is corrected. It said the locale came from the localization settings, but the request's Accept-Language wins (assemble-execution-context.ts, requestLocale ?? localization?.locale).
  • The liveness ledger gains the row translation/flows/refusals (live), which names the engine as its reader.
  • dropped-refinements.baseline.json declares the 8 published sites where the new text-slot refinement cannot be stated in JSON Schema. The build gate requires that declaration (see Acceptance notes).
  • Regenerated: api-surface/system.json, export-origins/system.json, references/system/translation.mdx, references/api/protocol.mdx, the strictness-ledger counts and the liveness state counts.

@objectstack/runtime. The two door builders forward ec.locale:

  • buildAutomationContext in domains/automation.ts, which serves the trigger route, the legacy trigger route and type: 'flow' endpoints;
  • dispatchFlowAction in action-execution.ts, which serves REST /actions and MCP run_action.

@objectstack/service-automation

  • The refusing end branch of executeNode calls renderRefusalMessage.
    • It asks the i18n service for flowRefusalMessageKey(flow, node) in context.locale, negotiated by resolveBundleLocale against getLocales() (objectql's rule).
    • It renders the translation through renderTextSlot.
    • It falls back to the authored template when there is no locale, no service, no entry, or the service throws.
    • A translation that does not compile also falls back to the authored message, with a warn naming the key. The refusal stays a refusal; it never becomes a run failure.
  • The plugin wires a lazy reader, i18nServiceReader(ctx), at init(). It resolves at question time, never at boot.
  • RefusalI18nService is exported as the type the new public setI18nServiceSource() takes.

@objectstack/lint. validateTranslationReferences judges flows.FLOW.refusals.NODE_ID beside the screens judge, as translation-target-unknown (error). It refuses:

  • an unknown flow (the existing flow-level arm);
  • an unknown node id, with a did-you-mean;
  • a completed end;
  • a node of another type.

The universe is the walkFlowNodes one, so nested nodes count.

@objectstack/cli. The extractor emits flows.FLOW.refusals.NODE_ID.message for every end declaring outcome: 'refused', at any depth, seeded with the authored template, holes included. The coverage bucket flow now reads "flow screens and refusals".

Sub-points, as ruled

  • A resumed leg renders in the STARTER's stored locale. ResumeSignal gets no locale. The context is persisted with the suspended run (context_json), and subflow/map children inherit it through their existing spread.
  • A run that no person started (record-change, schedule, wait timer) carries no locale and stores the authored text.

Pins

Pin Where Shape
A zh-CN refused run stores the translated message, with a hole filled (result and history row) service-automation/src/end-node-refusal-translation.test.ts real createMemoryI18n
zh negotiates to the zh-CN bundle same
Control: a locale with no entry falls back to the source message same
No locale, no service, or a non-compiling translation renders the authored text (the last one also warns) same
A door-started run carries the locale into a resumed leg, hot and cold (second engine over the same store) same
The trigger door (both routes) and the action door forward ec.locale; an absent or empty locale adds no key runtime/src/flow-run-locale.test.ts producer half
os validate refuses a key on a completed end, an unknown node, an unknown flow, or a node of another type lint/src/validate-translation-references.test.ts rule + severity + path + first sentence
The schema refuses a single-brace token in a translation, at both doors spec/src/system/translation.test.ts issue path + first sentence equal to TEXT_SLOT_TEMPLATE_REFUSAL's
The extractor emits exactly the engine's key; the coverage gate demands it, then goes quiet; the skeleton parses cli/test/i18n-flow-refusal-coverage.test.ts

Verification

Final head 5b3bf68de5 (the seat's edit, from the dev's report 6088248845 on #22450).

  • The last commit brings packages/spec/dropped-refinements.baseline.json's measured header totals to 226 / 688. The spec local suite caught the miss at 2788f5e6e6, and the re-run of scripts/dropped-refinements.test.ts and src/system/translation.test.ts gave 2 files / 171 tests passed.
  • Typecheck exit 0 for spec, lint, cli, runtime and service-automation.
  • Full suites: lint 131 files / 5984; service-automation 180 / 2212; runtime 346 / 4883 (19 skipped); spec repo 54 / 915.
  • The cli unit tier: 274 of 275 files passed. One forks-pool worker exited unexpectedly in a file the reporter did not name; the i18n subset was re-run verbose, 27 files / 331 tests, and the crashed file stays NOT MEASURED.
  • Gates: dispatch-gates --commands derived 119 commands, all 119 exit 0. The artifact-roster block: 48 exit 0, and the 3 PR-context guards exit 0 when wired to this PR.
  • CI on 5b3bf68de5 is green.

The lines below were written at 3b83c95baa.

All readings were taken at head 3b83c95baa. That head includes a merge of origin/main 446c8b2a6.

  • Build (verify lock): pnpm --filter @objectstack/spec build exited 0. The turbo closure builds of the touched packages exited 0.
  • Typecheck, pnpm --filter PKG run typecheck, exited 0 for each of @objectstack/spec, @objectstack/lint, @objectstack/cli, @objectstack/runtime and @objectstack/service-automation.
  • Full package suites:
    • @objectstack/lint: 131 files, 5984 tests passed.
    • @objectstack/service-automation: 180 files, 2212 tests passed.
    • @objectstack/cli unit project: 274 of 275 files and 4058 of 4059 tests passed. One forks worker exited unexpectedly ("Worker exited unexpectedly"), and the default reporter does not name its file. The i18n subset is re-run verbose to rule this PR in or out. That result, and the full @objectstack/spec local and repo projects and the full @objectstack/runtime suite, were still queued on the shared verify lock when this PR was opened. The os-dev-report comment on i18n(flows): an end node's outcome: 'refused' message has no translation key — a first-class refusal renders English in every locale #22450 carries them.
  • Targeted files:
    • translation.test.ts: 144 of 144 passed.
    • validate-translation-references.test.ts: passed.
    • end-node-refusal-translation.test.ts + end-node-refused-outcome.test.ts: 23 of 23 passed.
    • flow-run-locale.test.ts + flow-caller-param-keys.test.ts: 18 of 18 passed.
    • The three cli flow coverage files: 38 of 38 passed.
  • Ablation of the reader. The anchor this.renderRefusalMessage(...) in engine.ts was replaced with the old renderTextSlot(endConfig.message, variables), using scripts/ablation-replace.mjs (anchor 1 to 0, blob 4a2bcc50 to 74fefde0).
    • Result: 5 failed, 6 passed. The failures were the zh-CN render, the zh negotiation, the compile-failure warning, the hot resume and the cold resume.
    • The 6 that stayed green are the authored-fallback fences, the key pin and the lazy-reader pins.
    • Restored: blob equal to HEAD, git diff HEAD empty, 11 of 11 passed.
    • Observed direction: red, as predicted, except that the compile-failure pin also went red. Its warning half needs the reader.
  • Gates:
    • node scripts/pm/dispatch-gates.mjs --commands printed 119 commands; all 119 exited 0.
    • --ran: "119 derived famil(ies) accounted for — 119 run, 0 NOT-MEASURED".
    • Artifact rosters: 48 of 51 exited 0, check:error-code-provenance included. No new error code is stamped. The other three refuse without PR context (exit 2, NOT WIRED). check:partof-closing-keyword exited 0 with this body as PR_BODY.
  • ESLint, narrowed to the 17 changed source and test files: eslint --no-inline-config --format json reports 17 files, 0 errors, 0 warnings.
    • The population is the changed .ts files. None is ignored, since an ignored file would surface as a warning.
    • The count of 17 is read from the JSON.
    • Invariance: eslint.config.mjs enables no type-aware linting (no parserOptions.project) and no import-graph plugin, so this diff cannot move a verdict on an untouched file.
  • Line budget: 1108 additions and 23 deletions (1131 changed lines, generated files included), under the 3,000-line threshold.

Acceptance notes

  • The dropped-refinements ledger grows by 8 sites. It is a shrink-only ledger. The new refinement is the text-slot judge on a translated refusal, which the seat's order requires. z.toJSONSchema has no arm for it, and the closed projection list (src/shared/refinement-projection.ts) has no exact pattern for a single-brace-token rule. So the build gate requires the sites to be declared. The source message's own refinement is already dropped the same way, under flows.element.
  • Holes are judged at parse; compilation happens at render. TranslationDataSchema refuses a single-brace token. A translation whose double-brace holes do not compile (an unknown formatter, say) passes os validate. At run time the refusal then shows the authored message and a warn names the key. The source message is compiled at registerFlow and by os validate's expression pass. Compiling translations at os validate would need a new finding kind in the translation judge. That is left out, to keep this PR's surface as ordered.
  • New demand for i18n-opted-in projects. A project that declares supportedLocales and has a refusing end now gets one i18n/missing-flow issue per locale until it translates the refusal. The changeset says so.
  • The interfaces left unchanged. No objectui change: the console draws the served refusalMessage verbatim. No REST, wire or sys_automation_run shape change.

Generated by Claude Code

claude added 9 commits October 9, 2026 15:14
… 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 <noreply@anthropic.com>
…nement 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/declarative-endpoints.mdx (via /api/v1/automation/:name/trigger (route, a path literal in a comment in AutomationContext))
  • content/docs/automation/flows.mdx (via AutomationContext (symbol, a top-level interface), AutomationServicePlugin (symbol, a top-level class), /api/v1/actions/... (route, a path literal in a comment in AutomationContext), /api/v1/automation/:name/trigger (route, a path literal in a comment in AutomationContext))
  • content/docs/kernel/architecture.mdx (via getService (literal, a string literal in i18nServiceReader))
  • content/docs/kernel/cluster.mdx (via getService (literal, a string literal in i18nServiceReader))
  • content/docs/kernel/runtime-services/audit-service.mdx (via getService (literal, a string literal in i18nServiceReader))
  • content/docs/kernel/services-checklist.mdx (via getLocales (symbol, a method of interface RefusalI18nService), getService (literal, a string literal in i18nServiceReader), /api/v1/i18n/locales (route, the route ledger binds it to client method i18n.getLocales))
  • content/docs/plugins/anatomy.mdx (via getService (literal, a string literal in i18nServiceReader))
  • content/docs/plugins/development.mdx (via getService (literal, a string literal in i18nServiceReader))
  • content/docs/protocol/kernel/http-protocol.mdx (via getLocales (symbol, a method of interface RefusalI18nService), /api/v1/automation/:name/trigger (route, a path literal in a comment in AutomationContext))
  • content/docs/protocol/kernel/index.mdx (via getService (literal, a string literal in i18nServiceReader))
  • content/docs/ui/actions.mdx (via AutomationContext (symbol, a top-level interface), dispatchFlowAction (symbol, a top-level function), /api/v1/actions/... (route, a path literal in a comment in AutomationContext))

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

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

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

What this run could not see
  • 6 changed file(s) yielded no anchor (packages/services/service-automation/src/index.ts, packages/spec/api-surface/system.json, packages/spec/dropped-refinements.baseline.json, …) — pages documenting those are invisible to this run
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 149 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ee8751d41e61a18f7819e4d3ad2c340f51ab2418 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from aff780d7e22d2927b0c95990e6a1c2a67a787346 — the merge of head 5b3bf68de58a9a51a71357fc6cbbdaebdb2e0bef into base ee8751d41e61a18f7819e4d3ad2c340f51ab2418, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin aff780d7e22d2927b0c95990e6a1c2a67a787346 && git checkout aff780d7e22d2927b0c95990e6a1c2a67a787346
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ee8751d41e61a18f7819e4d3ad2c340f51ab2418 5b3bf68de58a9a51a71357fc6cbbdaebdb2e0bef && git checkout -B drift-repro ee8751d41e61a18f7819e4d3ad2c340f51ab2418 && git merge --no-ff 5b3bf68de58a9a51a71357fc6cbbdaebdb2e0bef

node scripts/docs-audit/affected-docs.mjs --json ee8751d41e61a18f7819e4d3ad2c340f51ab2418

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

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

… 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 <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5b3bf68de58a9a51a71357fc6cbbdaebdb2e0bef
Local-runs: none

Inputs: card #22450 (body and all six comments: the triage direction 6078674703, the claim 6083289288, the seat order 6083596139, both os-dev-reports and the ACCEPT 6088299236), PR #22525 (body, 26-file list, and the net diff of refs/pm/pr-22525 against its merge-base with origin/main, 446c8b2a6: 26 files, +1110 / -25), and the check-runs on the head. Every reading of the tree below was taken with git show / git grep on the fetched ref — nothing checked out, built, run or re-run.

Check-runs on the head, settled: 39 success, 7 skipped, 0 failures, 0 in progress. The seven required contexts are each success: Lint & Repo Gates, TypeScript Type Check, Test Core, Dogfood Regression Gate, Build Core, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard. Spec property liveness, Build Docs, Check Changeset, Check Documentation Links, Dogfood Verify CLI and the four Type Check sub-jobs are success; Console Pin Gate is skipped (no removal). Governed surface: none in the file list. Line budget: 1,135 changed lines, under 3,000.

① Derived judgments

Each accept-set or public-surface change the diff implies, named right or wrong.

  1. TranslationDataSchema.flows.FLOW.refusals.NODE_ID.message now parses — and through the shared shape so do PlatformTranslationData, TranslationItem, both bundle wrappers and GetTranslationsResponse. Before, refusals was refused as an unrecognized key. A widening, in the shape triage ruled. RIGHT.
  2. The refusals.NODE_ID entry is strict with one leaf, message. text / reason / description are aliases onto message and are refused with the rename — the same three EndConfigSchema renames onto its own message (read at the head); title is refused with the guidance that an end node has no heading. RIGHT.
  3. message is judged by textSlotTemplateRefusal: a single-brace token is refused at the leaf's own path, lead sentence equal to TEXT_SLOT_TEMPLATE_REFUSAL, at both the data door and the metadata-item door (pinned). It is the judge EndConfigSchema applies to the source message, which is the triage direction: the translation keeps the double-brace holes. RIGHT.
  4. message accepts the empty string, where the source's message is .min(1). RIGHT by sibling precedent: the skeleton os i18n extract writes an empty slot into every non-default locale (pinned), and the engine reads an empty answer as no translation, so the authored message renders.
  5. Flow-level aliases refusal and ends onto refusals — guidance on an unrecognized key; nothing is accepted. RIGHT.
  6. AutomationContext.locale, optional, on the public contract interface. No Zod schema exists for AutomationContext (0 hits for AutomationContextSchema at the head), so this is a type-level widening with no strict parse to refuse it. It persists whole: suspended-run-store.ts writes JSON.stringify(run.context ?? {}) into context_json and parses it back with no key projection, on the resume row and on the terminal row alike, so the cold-resume pin, taken over the in-memory store, holds for the SQL store too; the subflow executor spreads the parent context into the child, so children inherit it. RIGHT.
  7. The ExecutionContext.locale docblock now states Accept-Language first, then the localization settings, unset for an anonymous caller — read against the assembler at the head, locale: anonymous ? undefined : (requestLocale ?? localization?.locale). Doc-only, accurate. RIGHT.
  8. flowRefusalMessageKey is a new export of @objectstack/spec/system; api-surface/system.json and export-origins/system.json carry it. RIGHT.
  9. The two runtime doors forward ec.locale only as a non-empty string, never re-derived; the resume door and the record-change / schedule / webhook producers forward nothing, as sub-points (i) and (ii) ruled. Pinned at the producer half on both trigger routes and the action door. RIGHT.
  10. The engine picks the translated template before renderTextSlot. It falls back to the authored text on: no locale, no source, a source that throws (silent — the plugin's own reader never throws, so this fences a custom source), a service with no t, a miss (t() echoes the key: memory-i18n.ts returns the key when the value is null, read at the head), a service that throws on read (warn), and a translation that does not compile (FlowTextTemplateError only — warn naming the key and the locale; anything else rethrows). The locale is negotiated by the spec's resolveBundleLocale against getLocales(). One load-bearing detail checked: t() is called with no params, so the service's own placeholder interpolation never runs on the template and the holes reach renderTextSlot intact. RIGHT.
  11. AutomationEngine.setI18nServiceSource() and the exported type RefusalI18nService are new public surface of @objectstack/service-automation. i18nServiceReader is exported from plugin.ts but not from the barrel (index.ts exports only AutomationServicePlugin, createPackageFileLoader and the options type), so it is not public. The reader resolves at question time and records nothing — no startup-registry verdict. RIGHT.
  12. os validate judges flows.FLOW.refusals.NODE_ID as translation-target-unknown (error): a completed end, a node of another type (a screen included), an unknown id with a did-you-mean over the refusing ids, and the unknown flow by the existing flow-level arm. The universe is walkFlowNodes (nested nodes count) and artifact-provided flows go through the same collector. Net, the lint accept-set widens: the key was refused outright by the schema before. RIGHT.
  13. The CLI walker emits the key for every refusing end, in any flow. walkScreenFlows iterates every config.flows entry with no type filter (read at the head), so an autolaunched or API flow with a refusing end gets its skeleton key and coverage row — matching the engine, which reads the key on any flow; the label demand stays screen-only because its only reader is the console runner. New demand: one i18n/missing-flow per locale for an untranslated refusal in a project declaring supportedLocales. RIGHT, and declared.
  14. translateFlow is untouched. It has no source caller at the head (only its own tests and the changelog), and the console draws the served refusalMessage verbatim, so no display face needs an overlay; the card's acceptance allowed the renderer route and the seat ordered it. RIGHT.
  15. Generated artifacts: the references docs, the strictness-ledger count (+1 site, the one new strict object), the liveness state counts, and dropped-refinements.baseline.json (+6 published schemas / +8 sites; the header 226 / 688 checked against the body: six new schema blocks of one site each plus two sites added under the manifest entries). The authorable surface lists TranslationData:flows at the top level only, so the nested key owes no entry there. RIGHT.

No accept-set is narrowed, and no author-facing string carries a tracker number.

② Semver level

.changeset/22450-flow-refusal-translation.md: five entries, all minor — @objectstack/spec, @objectstack/service-automation, @objectstack/runtime, @objectstack/lint, @objectstack/cli — exactly the five published packages (all public, at 17.7.0) the diff touches. No skip-changeset; no CHANGELOG.md edited.

Per package, against what it publishes:

  • spec: a new schema key, a new export, a new optional interface member. minor. RIGHT.
  • service-automation: a new engine method, a new exported type, new behaviour on the refusing end. minor. RIGHT.
  • runtime: new door behaviour, no new export. minor is the honest level for a feature a caller observes on the wire (the stored refusalMessage changes language); patch would understate it. RIGHT.
  • lint: a new judge arm under the existing rule id, no new rule id. minor. RIGHT.
  • cli: a new extracted key and a new coverage demand. The demand can red an i18n-opted-in project's coverage gate after upgrade; the changeset says so in words, and the precedent is the i18n/missing-flow bucket and the flows demand, which moved under minors in the cli changelog. minor. RIGHT.

Clause-②: yes (widening) is on the changeset body and on the PR body and matches the diff: every change widens, nothing is removed or renamed, so no ADR-0087 disposition marker and no migration text are owed. RIGHT.

③ Boundary flags

Round-1 open question (route A / A2 / B / C / D, report 6083547305): answered by the seat order 6083596139, route A in one PR. The diff implements A on the widened surface and nothing outside it — the five added paths, the claim's own paths, tests, generated artifacts and the changeset; 26 files, no breach. Sub-points (i) and (ii) are implemented and pinned (hot and cold resume; a user-less run stores the authored text). Round-2 open_questions is empty.

Dev deviations (report 6088248845), each answered:

  1. The shrink-only dropped-refinements ledger grows by 6 schemas / 8 sites. Answered: the growth is a new .superRefine JSON Schema cannot state; the ledger's own text makes an arm in the closed projection list a public-contract decision outside this card; the source message's own refinement is dropped the same way under flows.element. Accepted. (A named projection arm for the single-brace rule would retire these sites and the source's together — a candidate card, not owed here.)
  2. The liveness row is drilled to message. Answered: the gate refused an undeclared container inheritance; the drilled row carries reader and producer evidence and Spec property liveness is green. Accepted.
  3. An empty message is accepted. Answered in ① item 4. Accepted.
  4. A translation whose double-brace holes do not compile passes os validate and falls back at run time with a warn. Answered on the runtime side: the refusal stays a refusal, the authored text renders, and the line names the key, the locale and the reason — the person sees a visibly untranslated refusal, and nothing claims a persistence that did not land, so warn is the right level under the degradation rule. ESCALATED on the authoring side: a translator's unknown formatter passes os validate and surfaces only in a server log, which reads as a metadata-authoring trap in Prime Directive 10's wording; the remedy is a new finding kind in the translation judge that compiles each refusals.NODE_ID.message with the expression pass the source message already gets. The seat ordered this surface out and the runtime fails safe, so it is a follow-up card for the owning seat to file, not a blocker on this head.
  5. The PR body was written once at 3b83c95baa. Answered: the seat appended a final-head block naming 5b3bf68de5 and its readings; the diff reviewed here is the head's. Accepted.
  6. Verify-lock batches ran in the background. Process, not contract. Accepted.
  7. origin/main was merged once at 446c8b2a6 and has moved since. Answered: this review is of the merge-base delta, which is the PR's own change; the queue rebuilds the merged generation and re-runs the required set, which is the §10 re-verification. Accepted.
  8. No 维护者速读 section. Answered: no governed path in the file list. Accepted.

Report caveats: the one unnamed cli unit file left NOT MEASURED by a forks-worker crash is answered by the head's Test Core (all six shards success), the gate verdict for that family. The ablation (5 red / 6 green, restore blob-equal) is reported with its shape and is not re-run here.

Implemented-by: claude/issue-22450-flow-refusal-translation
Reviewed-by: session_01KNKBCRDJCu5tGy3TEbvtrF

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

i18n(flows): an end node's outcome: 'refused' message has no translation key — a first-class refusal renders English in every locale

2 participants