Skip to content

Commit de2b7e0

Browse files
fix(service-automation): a flow write node's refusal is worded in the run's locale, as the record doors word it (#25929)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XFgtJRPVDMB5VczqNK3rf7s)_ Fixes #25809 Clause-②: no Metadata-protocol: none ## What changes A refusal a flow's write node meets is now worded in the language of the person who started the run, as their own record save is. The run already carries that language (`AutomationContext.locale`, seeded by the `/actions` door at `packages/runtime/src/action-execution.ts` and by the trigger door). `resolveRunDataContext` (`packages/services/service-automation/src/runtime-identity.ts`), the one place that maps a run to the ObjectQL `context` its data nodes pass, dropped it. It now carries `locale` on both `runAs` arms (`RunDataContext.locale`). The engine's existing translation hook (`ObjectQL#validationMessageContext`, bridged to the i18n service) does the rest: an authored rule's `objects.OBJECT._validations.RULE.message`, the built-in catalog, a `DELETE_RESTRICTED` sentence's labels. The translated text reaches the caller through the path the inner-refusal ruling added: the crud node's catch arm puts it on `innerRefusal.message`, and the four doors emit it in the `FLOW_FAILED` details. No second translation path: nothing in service-automation translates anything. A run no person started (record-change, schedule, webhook) has no locale and keeps the authored text, as before. ## Measurement — red first `packages/services/service-automation/src/flow-write-refusal-locale.integration.test.ts`: a real kernel (ObjectQL + better-sqlite3), an object with an authored `script` rule, and a zh-CN bundle on the engine's i18n service. Each case first asserts the record door's own answer for the same write (`ql.insert` with `locale: 'zh-CN'` answers `金额不能为负数。`). - At `cb0a2fcf4` (origin/main) plus the test alone: **6 failed / 2 passed (8)**. `create_record` and `update_record` returned `innerRefusal.message: "Amount must not be negative."` under both `runAs` arms; `delete_record` returned `This Account is still referenced by 1 Deal record(s) through “Account”. Delete or reassign them first.` (English labels). The two passing cases are the no-locale controls. - With the fix (`447414c4`): **8 passed (8)**. ## Every flow write node, with a verdict | Node | Verdict | |:--|:--| | `create_record` | Gap (measured red: authored rule message in English). Fixed. | | `update_record` | Same gap (measured red). Fixed. | | `delete_record` | Same gap on `DELETE_RESTRICTED`: the sentence named objects by their English labels (measured red). Fixed. | | `get_record` | Read. ObjectQL reads `context.locale` only for refusal wording (validation messages, delete restriction), so a read has nothing to word. Carries the locale; no behaviour change. | | lookup expander (`plugin.ts`, `setRecordExpander`) | Read through `resolveRunDataContext`; same as `get_record`. | | `approval` / `approval_revise` (`plugin-approvals`) | Not a flow-generic data write: they write through the approval service under its own context, not through `resolveRunDataContext`. Out of this card's file surface; not measured. | | `connector_action`, `http_*`, `notify`, `screen` | No ObjectQL write. | All four data nodes take their context from `resolveRunDataContext` (`DATA_NODE_TYPES`), so the fix is one place and cannot drift between node types. ## Tests - New: `flow-write-refusal-locale.integration.test.ts` — 8 cases: create / update / delete under `runAs: 'user'` and `'system'` in zh-CN, plus the no-locale control per arm. - Ablation, one per arm, with the fix committed (`447414c4`), via `scripts/ablation-replace.mjs` (anchor 1 → 0, blob moved; restore: blob == HEAD `d5d8492ba58e`, `git diff HEAD` empty). The subject is imported from source (`./plugin.js`), so no `dist/` leg applies. - delete `...(locale ? { locale } : {}),` (system arm): 3 failed / 5 passed — the three `runAs: 'system'` zh-CN cases. - delete `if (locale) out.locale = locale;` (user arm): 3 failed / 5 passed — the three `runAs: 'user'` zh-CN cases. - `pnpm --filter @objectstack/service-automation test`: Test Files 203 passed (203), Tests 2667 passed (2667). - `pnpm --filter @objectstack/service-automation typecheck`: exit 0 (incl. `check:test-typecheck`). - `eslint --no-inline-config packages/services/service-automation`: exit 0. ## Gates (local subset) `node scripts/pm/dispatch-gates.mjs --commands` at `ba44680f4` derives 68 families for this diff (3 paths). All 68 ran with exit 0; `--ran` reconciliation: `68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN` (exit 0). - Two families answered exit 3 on the first pass for shallow-clone prerequisites, the known pair: `check-plugin-teardown-shape --self-test` (re-run after fetching its fixture commit) and `check:dual-build-cjs-loads` (re-run after a full `pnpm build`, lock VERDICT exit 0). Both re-ran to exit 0. - The first 56 families ran at `447414c4` (the code at its final bytes, before the changeset commit); the 12 the changeset added to the derivation ran at `ba44680f4`. - Every family CI runs is CI's verdict on this PR's check-runs; none is re-run here. `test:repo` and the repo-wide `pnpm lint` are CI's. ## Acceptance notes - `AutomationContext.locale`'s docblock (`packages/spec/src/contracts/automation-service.ts`) lists its readers as the refusing `end` node and the `screen` executor; the data nodes are now a third reader. Not edited here: `packages/spec/**` is outside this claim's file surface. Carrier: none. - Hooks fired by a flow's write now see the starter's `locale` on their session, as the same user's record-door write already gives them. Presentation only: no security middleware keys on it. - The `/actions` door's half (`ec.locale` → `AutomationContext.locale`) and the details carriage (`flowFailureDetails`) were already on main and are unchanged; this PR pins the engine leg, where the gap was. --- _Generated by [Claude Code](https://claude.ai/code/session_011W59GGh1a3YnDbRLcBJYxe)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_011W59GGh1a3YnDbRLcBJYxe --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent dd5e355 commit de2b7e0

3 files changed

Lines changed: 231 additions & 2 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation): a refusal a flow's write node meets is worded in the language of the person who started the run, as their own record save is
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A validation rule tripped through a record door (`POST` / `PATCH /api/v1/data/...`, batch, import) answers in the caller's locale: the engine looks the authored `message` up at `objects.OBJECT._validations.RULE.message` in the app's bundle. The same rule tripped by a flow's `create_record` or `update_record` answered in the authored English, and a `DELETE_RESTRICTED` refusal met by `delete_record` named its objects by their English labels — so a `FLOW_FAILED` from `POST /api/v1/actions/...` carried an English `innerRefusal.message` on a request whose screens rendered in Chinese.
11+
12+
The run already knew the language (`AutomationContext.locale`, seeded by the `/actions` and trigger doors); `resolveRunDataContext` dropped it from the context its data nodes hand the engine. It now carries `locale` on both `runAs` arms, and the engine's one translation hook does the rest. Nothing new translates anything: a run no person started has no locale and keeps the authored text.
13+
14+
What to do: nothing. Ship your translations under `objects.OBJECT._validations.RULE.message` as you already do for the record doors; a flow write now reads the same entry.
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* A refusal a flow's write node meets is worded in the run's locale, as the
5+
* same refusal on a record door is.
6+
*
7+
* The record doors hand `ExecutionContext.locale` to the engine, and the
8+
* engine resolves the refusal's text through its ONE translation hook
9+
* (`ObjectQL#validationMessageContext`, bridged to the i18n service): an
10+
* authored rule's `objects.<o>._validations.<rule>.message`, the built-in
11+
* catalog, a `DELETE_RESTRICTED` sentence's labels. A flow run started by a
12+
* person carries that person's locale on `AutomationContext.locale` (the
13+
* `/actions` and trigger doors seed it), but the data nodes' context
14+
* (`resolveRunDataContext`) dropped it — so the engine translated nothing and
15+
* the run's `innerRefusal.message` (the `FLOW_FAILED` details member the doors
16+
* emit) came back in the authored English while the same request's screens
17+
* rendered in Chinese.
18+
*
19+
* The fix adds no translation of its own: the locale rides into the data
20+
* context and the engine's existing hook does the rest. So these tests drive a
21+
* REAL engine (ObjectQL + better-sqlite3) with a bundle on its i18n service,
22+
* and assert what the run hands its caller — for every flow node that writes:
23+
* `create_record`, `update_record`, `delete_record`, under both `runAs` arms.
24+
* A run with no locale keeps the authored text: nothing is translated for a
25+
* caller who asked for no language.
26+
*/
27+
28+
import { describe, it, expect, afterEach } from 'vitest';
29+
import { ObjectKernel } from '@objectstack/core';
30+
import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql';
31+
import { SqlDriver } from '@objectstack/driver-sql';
32+
import { AutomationServicePlugin } from './plugin.js';
33+
import type { AutomationEngine } from './engine.js';
34+
import { installDefaultOrganization } from './default-organization.testkit.js';
35+
36+
function makeSqliteDriver() {
37+
return new SqlDriver({
38+
client: 'better-sqlite3',
39+
connection: { filename: ':memory:' },
40+
useNullAsDefault: true,
41+
});
42+
}
43+
44+
const RULE_EN = 'Amount must not be negative.';
45+
const RULE_ZH = '金额不能为负数。';
46+
47+
const crmAccount = {
48+
name: 'crm_account',
49+
label: 'Account',
50+
fields: {
51+
name: { name: 'name', label: 'Name', type: 'text' },
52+
},
53+
};
54+
55+
/** An authored rule whose message the app's zh-CN bundle translates. */
56+
const crmDeal = {
57+
name: 'crm_deal',
58+
label: 'Deal',
59+
fields: {
60+
name: { name: 'name', label: 'Name', type: 'text' },
61+
amount: { name: 'amount', label: 'Amount', type: 'number' },
62+
account: { name: 'account', label: 'Account', type: 'lookup', reference: 'crm_account', deleteBehavior: 'restrict' },
63+
},
64+
validations: [{
65+
type: 'script',
66+
name: 'amount_not_negative',
67+
message: RULE_EN,
68+
condition: 'record.amount < 0',
69+
fields: ['amount'],
70+
}],
71+
};
72+
73+
/** The app's zh-CN bundle, as an `II18nService` (a miss echoes the key back). */
74+
const ZH_BUNDLE: Record<string, string> = {
75+
'objects.crm_deal._validations.amount_not_negative.message': RULE_ZH,
76+
'objects.crm_account.label': '客户',
77+
'objects.crm_deal.label': '商机',
78+
'objects.crm_deal.fields.account.label': '所属客户',
79+
};
80+
const zhI18n = {
81+
t: (key: string, locale: string) => (locale?.startsWith('zh') ? ZH_BUNDLE[key] ?? key : key),
82+
};
83+
84+
const USER = { userId: 'usr_seller', tenantId: 'org_1', positions: [] as string[], permissions: [] as string[] };
85+
const SYS = { isSystem: true } as const;
86+
87+
type RunAs = 'user' | 'system';
88+
89+
/** start → one write node → end. */
90+
const oneNodeFlow = (name: string, runAs: RunAs, node: Record<string, unknown>) => ({
91+
name,
92+
label: name,
93+
type: 'autolaunched',
94+
runAs,
95+
nodes: [
96+
{ id: 'start', type: 'start', label: 'Start' },
97+
{ id: 'write', label: 'Write', ...node },
98+
{ id: 'end', type: 'end', label: 'End' },
99+
],
100+
edges: [
101+
{ id: 'e1', source: 'start', target: 'write' },
102+
{ id: 'e2', source: 'write', target: 'end' },
103+
],
104+
});
105+
106+
describe('a flow write node\'s refusal speaks the run\'s locale, as the record doors do', () => {
107+
let kernel: ObjectKernel;
108+
let ql: ObjectQL;
109+
let automation: AutomationEngine;
110+
111+
afterEach(async () => {
112+
try { await kernel?.shutdown(); } catch { /* noop */ }
113+
});
114+
115+
async function boot() {
116+
kernel = new ObjectKernel({ logger: { level: 'fatal' } });
117+
await kernel.use(new ObjectQLPlugin());
118+
await kernel.use(new AutomationServicePlugin({ suspendedRunStore: 'memory' }));
119+
await kernel.bootstrap();
120+
121+
ql = kernel.getService<ObjectQL>('objectql');
122+
automation = kernel.getService<AutomationEngine>('automation');
123+
124+
const driver = makeSqliteDriver();
125+
await driver.connect();
126+
ql.registerDriver(driver, true);
127+
ql.registry.registerObject(crmAccount as any, 'locale-test', 'locale-test');
128+
ql.registry.registerObject(crmDeal as any, 'locale-test', 'locale-test');
129+
await ql.syncSchemas();
130+
await installDefaultOrganization(ql);
131+
ql.setI18nService(zhI18n as any);
132+
}
133+
134+
/** Run `node` in a fresh flow; return the refused run's result. */
135+
async function refusedRun(runAs: RunAs, node: Record<string, unknown>, locale?: string) {
136+
const name = `write_${runAs}_${String(node.type)}_${locale ?? 'none'}`.replace(/-/g, '_').toLowerCase();
137+
automation.registerFlow(name, oneNodeFlow(name, runAs, node) as any);
138+
const res = await automation.execute(name, { ...USER, ...(locale ? { locale } : {}) });
139+
expect(res.success, `the write must be refused: ${JSON.stringify(res)}`).toBe(false);
140+
return res;
141+
}
142+
143+
const createBad = { type: 'create_record', config: { objectName: 'crm_deal', fields: { name: 'D1', amount: -5 } } };
144+
145+
for (const runAs of ['user', 'system'] as const) {
146+
describe(`runAs: '${runAs}'`, () => {
147+
it('create_record: the authored rule message arrives in zh-CN, as a POST to the record door answers', async () => {
148+
await boot();
149+
// The control: the record door's own answer for the same write.
150+
const door = await ql.insert('crm_deal', { name: 'D0', amount: -1 }, { context: { ...USER, locale: 'zh-CN' } })
151+
.then(() => undefined, (e: any) => String(e?.message));
152+
expect(door).toBe(RULE_ZH);
153+
154+
const res = await refusedRun(runAs, createBad, 'zh-CN');
155+
expect(res.innerRefusal).toEqual({ code: 'VALIDATION_FAILED', message: RULE_ZH });
156+
expect(res.error).toBe(`Node 'write' failed: create_record(crm_deal) failed: ${RULE_ZH}`);
157+
});
158+
159+
it('update_record: the same rule tripped by an update arrives in zh-CN', async () => {
160+
await boot();
161+
const deal = await ql.insert('crm_deal', { name: 'D2', amount: 10 }, { context: { ...USER } });
162+
const res = await refusedRun(runAs, {
163+
type: 'update_record',
164+
config: { objectName: 'crm_deal', filter: { id: String(deal.id) }, fields: { amount: -1 } },
165+
}, 'zh-CN');
166+
expect(res.innerRefusal).toEqual({ code: 'VALIDATION_FAILED', message: RULE_ZH });
167+
const after = await ql.findOne('crm_deal', { where: { id: deal.id }, context: SYS });
168+
expect(after!.amount, 'the refused update never landed').toBe(10);
169+
});
170+
171+
it('delete_record: a DELETE_RESTRICTED refusal names the objects by their zh-CN labels', async () => {
172+
await boot();
173+
const account = await ql.insert('crm_account', { name: 'Acme' }, { context: { ...USER } });
174+
await ql.insert('crm_deal', { name: 'D3', amount: 1, account: account.id }, { context: { ...USER } });
175+
const res = await refusedRun(runAs, {
176+
type: 'delete_record',
177+
config: { objectName: 'crm_account', filter: { id: String(account.id) } },
178+
}, 'zh-CN');
179+
expect(res.innerRefusal?.code).toBe('DELETE_RESTRICTED');
180+
expect(res.innerRefusal?.message).toContain('客户');
181+
expect(res.innerRefusal?.message).toContain('商机');
182+
expect(res.innerRefusal?.message).not.toContain('crm_account');
183+
});
184+
185+
it('no locale on the run: the authored text, untranslated', async () => {
186+
await boot();
187+
const res = await refusedRun(runAs, createBad);
188+
expect(res.innerRefusal).toEqual({ code: 'VALIDATION_FAILED', message: RULE_EN });
189+
});
190+
});
191+
}
192+
});

‎packages/services/service-automation/src/runtime-identity.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,21 @@ export class UnscopedRunDataAccessError extends Error {
144144
* run. A run that resolves no principal is handed no context at all; it is
145145
* refused ({@link UnscopedRunDataAccessError}).
146146
*/
147-
export interface RunDataContext extends RunIdentityContext {}
147+
export interface RunDataContext extends RunIdentityContext {
148+
/**
149+
* The run's {@link AutomationContext.locale} — the language of the person
150+
* who started it — carried as `ExecutionContext.locale`, the field a record
151+
* door's write carries. The engine words a refused write in it through its
152+
* one translation hook (an authored rule's
153+
* `objects.<o>._validations.<rule>.message`, the built-in catalog, a
154+
* `DELETE_RESTRICTED` sentence's labels), so a flow's write node meets the
155+
* refusal the same request's record save would, and hands it to the run's
156+
* caller as `innerRefusal.message`. Nothing here translates: absent on a run
157+
* no person started, which keeps the authored text. Presentation, not
158+
* identity: no security middleware keys on it, on either `runAs` arm.
159+
*/
160+
locale?: string;
161+
}
148162

149163
/**
150164
* A flow run's {@link AutomationContext} as run setup leaves it: the context
@@ -262,6 +276,10 @@ export function runOrgUserIds(context: AutomationContext | undefined): string[]
262276
* deciding user, so approvals cascades resolve here instead of being refused
263277
* (#3783). Only its machine-driven sweeps stay user-less.
264278
*
279+
* Both principal arms also carry the run's `locale` when it has one
280+
* ({@link RunDataContext.locale}), so a refused write is worded for the
281+
* person who started the run, as their own record save would be.
282+
*
265283
* The engine sets {@link AutomationContext.runAs} on the run context at setup;
266284
* this function is the single place that maps it to an ObjectQL context, shared
267285
* by every data-touching node so the policy can't drift between node types —
@@ -271,6 +289,9 @@ export function runOrgUserIds(context: AutomationContext | undefined): string[]
271289
*/
272290
export function resolveRunDataContext(context: AutomationContext | undefined): RunDataContext | undefined {
273291
const flowRunId = context?.flowRunId;
292+
// The starter's language rides into every write, on both arms — see
293+
// RunDataContext.locale.
294+
const locale = typeof context?.locale === 'string' && context.locale.length > 0 ? context.locale : undefined;
274295
if (context?.runAs === 'system') {
275296
// Name the flow as the acting service principal (`svc:flow:<name>`,
276297
// ADR-0014 D2) — the audit writer falls back to `session.actor` when
@@ -320,6 +341,7 @@ export function resolveRunDataContext(context: AutomationContext | undefined): R
320341
// refused for want of one.
321342
...(context.userId ? {} : { positions: [], permissions: [] }),
322343
...(flowRunId ? { flowRunId } : {}),
344+
...(locale ? { locale } : {}),
323345
};
324346
}
325347
if (!context?.userId) {
@@ -340,7 +362,7 @@ export function resolveRunDataContext(context: AutomationContext | undefined): R
340362
throw new UnscopedRunDataAccessError(context);
341363
}
342364
// `context` is now narrowed to a defined AutomationContext with a userId.
343-
const out: RunIdentityContext = {
365+
const out: RunDataContext = {
344366
isSystem: false,
345367
userId: context.userId,
346368
positions: Array.isArray(context.positions) ? context.positions : [],
@@ -351,6 +373,7 @@ export function resolveRunDataContext(context: AutomationContext | undefined): R
351373
if (context.email) out.email = context.email;
352374
if (context.tenantId) out.tenantId = context.tenantId;
353375
if (flowRunId) out.flowRunId = flowRunId;
376+
if (locale) out.locale = locale;
354377
return out;
355378
}
356379

0 commit comments

Comments
 (0)