Skip to content

Commit 86c6955

Browse files
fix(service-automation): a currency hole refused after a failed tenant-settings read is a runtime failure, not a guard refusal (#28110)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DF8zq975FQQqJfYxnH4mVgp)_ domain:server#4 · os-elon-musk · session_016WV4czcfiRnuFCuLdmegYt Fixes #28056 Clause-②: no (a refusal that followed a failed read of the organization's default currency is now an ordinary runtime failure, which a `fault` edge routes; every run that succeeded still succeeds, and a refusal after a read that succeeded stays the guard refusal it was) Metadata-protocol: none ## 维护者速读 - 改了什么:flow 文本里 `{{ amount | currency }}` 缺租户默认币种时,若是因为读 `sys_setting` 失败(而不是组织真没设),现在判为普通运行时失败:fault 边能接住,`errorHandling: retry` 读恢复后就成功;报错说“设置读不出来”,不再叫人去设币种。 - 为什么改:自动化插件的币种解析器忽略 `readFailed`,运行准备又把拒绝和“没有”一样吞掉,于是一次瞬时读故障被当成 guard 拒绝(不可路由),原因还写错。messaging 通道在 #27257 已按同样规则分级。 - 风险与回滚:只在“本次读失败 + 用占位币种重渲染能成功”时改判;真没设币种、或模板别处本身有错,仍是原 guard 拒绝。无新错误码,不动 `packages/spec` 与 `AutomationContext`,读失败标记放在本包 WeakMap,不持久化。回滚即 revert。 - 先看:`packages/services/service-automation/src/builtin/template.ts`(`gradeAfterTenantCurrencyReadFault`)、`src/dag-traversal.ts`(executeNode 的 throw 分支)、`src/run-resume.ts`(两处 catch)、`src/plugin.ts`(`readFailed` 时拒绝)。 - 两分钟验证:`pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/builtin/notify-currency-read-fault.test.ts src/builtin/notify-currency-tenant-default.test.ts`,预期全绿:读失败被 fault 边接住、retry 后发出 CN¥ 标题;真没设币种仍不路由。 - 需要你做的:无,席位落地。 ## What changes `FlowTenantCurrencyResolver` gives a rejection its own meaning: the read FAILED. `undefined` still means the read succeeded and found no default. - **`plugin.ts`.** The automation plugin's tenant-currency resolver rejects when `resolveLocalizationContext` answers `readFailed` (#27257), as the messaging channels' `getTenantCurrency` does. It used to return `localization.currency` and drop `readFailed`. - **`run-resume.ts`.** Run setup (`resolveRunContext`) and a resumed segment's re-read (`withTenantCurrency`) still leave `AutomationContext.currency` unset on a rejection, and now note the failed read beside that context (`noteTenantCurrencyReadFault`). The resumed segment notes a copy, so the persisted context never carries one segment's fault into a later resume. Nothing is added to `AutomationContext` (`packages/spec`) or the persisted run. - **`builtin/template.ts`.** `gradeAfterTenantCurrencyReadFault(err, variables, context)`: a `FlowTextTemplateError` thrown for a context whose read failed is rendered once more with the placeholder currency `XXX` (ISO 4217's "no currency"). If that renders, the only refusal was the missing default, and a plain `Error` without the guard brand is returned, saying the `localization.currency` setting could not be read. If it still refuses, the guard refusal is returned as it stands. Any other failure, and every refusal after a successful read, is returned unchanged. Same probe as service-messaging's `templateRefusalAfterCurrencyReadFault` (#27257). - **`dag-traversal.ts`.** `executeNode`'s throw arm grades the thrown failure first, so the failure step, `$error` and the `isGuardRefusal` routing decision all read the graded failure. This is the engine's existing classification (#3863): a non-guard failure is routable by a `fault` edge; `errorHandling.strategy: 'retry'` re-runs a failed run either way. No new code. - **`engine.ts`.** The `FlowTenantCurrencyResolver` docblock states the rejection meaning. The seam is shared by every text slot a node renders, so `http` and `screen` slots are graded the same way as `notify`. ## Why A transient `sys_setting` read failure failed a `notify` run as a permanent guard refusal: a declared `fault` edge could not route it, and the error told the operator to set a default currency the organization may already have. ## Verification Measured on `main` `5448585d` before the change (a scratch test, not committed): a `notify` flow with a `fault` edge, the resolver throwing: | Case | Before | After | |:--|:--|:--| | read fails, `fault` edge declared | run `failed`; the edge is not taken; error "...the organization has none set... Set the default currency in Setup → Localization" | run completes through the `fault` edge; `$error.message` says the `localization.currency` setting could not be read | | read fails, `errorHandling: retry` (`maxRetries: 2`) | re-run (3 reads), every attempt refused | unchanged retry; the run succeeds on the attempt whose read succeeds | | read succeeds, no default | guard refusal, not routed | unchanged | | read succeeds, `CNY` | `Won CN¥1,234.50` | unchanged | Pins, `src/builtin/notify-currency-read-fault.test.ts` (3 red on `main`, 10/10 green on this head): - engine: a failed read is routed by the `fault` edge and `$error` names the failed read; retry succeeds once the read does; a successful read with no default keeps the guard refusal (not routed); a successful read with a default is unchanged. - resume: a resumed segment whose read fails is routed by its `fault` edge, and the persisted context stays as it parked. - unit: the probe regrades only a refusal a default would cure; with no failed read noted, every refusal is returned as it stands. - plugin: booted over an ObjectQL whose `sys_setting` read throws, the refusal is routed; over one that finds no default, the guard refusal stands. The existing `notify-currency-tenant-default.test.ts` row "refuses the node by name when the read fails" still holds (no `fault` edge there, so the run still fails, naming the hole and `localization.currency`). Ablations (each restored through `scripts/ablation-replace.mjs`, blob == HEAD after): - A1 the throw arm does not grade → 3 red (engine, resume, plugin). - A2 the plugin resolver ignores `readFailed` → 1 red (plugin). - A3 the resumed segment does not note the failure → 1 red (resume). - A4 no probe (always regrade) → 1 red (a refusal a default would not cure). - A5 run setup does not note the failure → 2 red (engine, plugin). Suites: `@objectstack/service-automation` full vitest 228 files / 2886 tests pass; `pnpm --filter @objectstack/service-automation typecheck` (incl. `check:test-typecheck`) passes. `dispatch-gates --commands` derived 62 families at head `e62036b8`: all exit 0 (`check:dual-build-cjs-loads` after the `dogfood^...` + `runtime^...` dist build), `--ran` reconcile 62/62, 0 NOT MEASURED; `pnpm lint` exit 0. `main` merged at `549203d0`; nothing on `main` since touches `service-automation`. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_016WV4czcfiRnuFCuLdmegYt Co-authored-by: Claude <noreply@anthropic.com>
1 parent 11a7f96 commit 86c6955

7 files changed

Lines changed: 397 additions & 10 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/service-automation': patch
3+
---
4+
5+
fix(service-automation): a currency hole refused after a failed tenant-settings read is a runtime failure, not a guard refusal (#28056)
6+
7+
Clause-②: no (a refusal that followed a failed read of the organization's default currency is now an ordinary runtime failure, which a `fault` edge routes; every run that succeeded still succeeds, and a refusal after a read that succeeded stays the guard refusal it was)
8+
Metadata-protocol: none
9+
10+
A flow text slot's `{{ amount | currency }}` hole with no argument formats in the organization's default currency (`localization.currency`). When none reached the run, the node was refused as a guard refusal: a `fault` edge could not route it, and the message told the operator to set the default currency. That is right when the read succeeded and found none. It was also what happened when the read of `sys_setting` failed, so a transient backend fault failed a `notify` run with a misleading cause, where a retry would succeed.
11+
12+
- **The resolver says when the read failed.** The automation plugin's tenant-currency resolver now rejects when `resolveLocalizationContext` answers `readFailed`, as the messaging channels' reader does (#27257). `FlowTenantCurrencyResolver` documents it: `undefined` means the read succeeded and found no default; a rejection means the read failed.
13+
- **Run setup notes the failure.** Run setup, and a resumed segment that re-reads the default, still leave `AutomationContext.currency` unset, and note the failed read beside that run's context. Nothing is added to `AutomationContext` or the persisted run.
14+
- **The refusal is graded.** When a text slot refuses after a failed read, it is rendered once more with a placeholder currency. If that renders, the only refusal was the missing default: the node fails with a plain runtime error that says the setting could not be read, so a `fault` edge routes it and a flow under `errorHandling.strategy: 'retry'` succeeds once the read does. If it still refuses, a default would not cure it, and the guard refusal stands.
15+
- **Unchanged.** A read that succeeds and finds a default formats in it. A read that succeeds and finds none refuses the node as before.
16+
17+
It applies to every text slot a node renders (`notify`, `http`, `screen`), since they share one renderer.
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28056] A failed read of the organization's default currency is not a read
5+
* that found none. A `{{ amount | currency }}` hole with no argument that meets
6+
* no tenant default refuses its node as a guard refusal: the metadata needs a
7+
* setting, a `fault` edge does not route it. That grade is right only when the
8+
* read succeeded. When the read FAILED (the tenant-currency resolver rejected,
9+
* which the automation plugin does when `resolveLocalizationContext` answers
10+
* `readFailed`), nothing was learned, and a refusal a default would have cured
11+
* is an ordinary runtime failure: a `fault` edge routes it, and the run
12+
* succeeds once the read does. The messaging channels grade the same refusal
13+
* the same way (#27257).
14+
*/
15+
16+
import { describe, expect, it } from 'vitest';
17+
import { LiteKernel } from '@objectstack/core';
18+
import { AutomationEngine } from '../engine.js';
19+
import { InMemorySuspendedRunStore } from '../suspended-run-store.js';
20+
import { AutomationServicePlugin } from '../plugin.js';
21+
import { isGuardRefusal } from '../guard-refusal.js';
22+
import { installBuiltinNodes } from './index.js';
23+
import type { MessagingServiceSurface } from './notify-node.js';
24+
import {
25+
FlowTextTemplateError,
26+
gradeAfterTenantCurrencyReadFault,
27+
noteTenantCurrencyReadFault,
28+
renderTextSlot,
29+
} from './template.js';
30+
31+
function createTestLogger() {
32+
return {
33+
info: () => {},
34+
warn: () => {},
35+
error: () => {},
36+
debug: () => {},
37+
child: () => createTestLogger(),
38+
} as any;
39+
}
40+
41+
function fakeMessaging() {
42+
const emitted: Array<Parameters<MessagingServiceSurface['emit']>[0]> = [];
43+
const service: MessagingServiceSurface = {
44+
async emit(n) {
45+
emitted.push(n);
46+
return { notificationId: 'evt_1', delivered: n.audience.length, failed: 0 };
47+
},
48+
};
49+
return { service, emitted };
50+
}
51+
52+
/**
53+
* A notify whose title formats in the tenant default, with a `fault` edge to a
54+
* handler that notifies `$error.message`, so what the edge carried is visible.
55+
*/
56+
function flowWith(extra: Record<string, unknown> = {}) {
57+
return {
58+
name: 'deal_won_notice',
59+
label: 'Deal Won Notice',
60+
type: 'autolaunched' as const,
61+
variables: [{ name: 'amount', type: 'number' as const, isInput: true }],
62+
...extra,
63+
nodes: [
64+
{ id: 'start', type: 'start' as const, label: 'Start' },
65+
{
66+
id: 'notify',
67+
type: 'notify' as const,
68+
label: 'Notify',
69+
config: { recipients: ['user_1'], title: 'Won {{ amount | currency }}', message: 'Booked' },
70+
},
71+
{
72+
id: 'handled',
73+
type: 'notify' as const,
74+
label: 'Handled',
75+
config: { recipients: ['ops'], title: 'Not sent', message: '{{ $error.message }}' },
76+
},
77+
{ id: 'end', type: 'end' as const, label: 'End' },
78+
],
79+
edges: [
80+
{ id: 'e1', source: 'start', target: 'notify' },
81+
{ id: 'e2', source: 'notify', target: 'end' },
82+
{ id: 'f1', source: 'notify', target: 'handled', type: 'fault' as const },
83+
],
84+
};
85+
}
86+
87+
function engineWith(flow: ReturnType<typeof flowWith>, store?: InMemorySuspendedRunStore) {
88+
const messaging = fakeMessaging();
89+
const engine = new AutomationEngine(createTestLogger(), store);
90+
installBuiltinNodes(engine, {
91+
logger: createTestLogger(),
92+
getService: (name: string) => (name === 'messaging' ? messaging.service : undefined),
93+
} as never);
94+
engine.registerFlow(flow.name, flow);
95+
return { engine, messaging };
96+
}
97+
98+
/** Each node's status in the run's summary. */
99+
function statusOf(result: unknown, nodeId: string): string | undefined {
100+
const nodes = ((result as { summary?: { nodes?: Array<{ nodeId: string; status: string }> } }).summary?.nodes ?? []);
101+
return nodes.find((n) => n.nodeId === nodeId)?.status;
102+
}
103+
104+
/** What the fault handler was handed: the `$error.message` it notified, if it ran. */
105+
function handledError(emitted: ReturnType<typeof fakeMessaging>['emitted']): string | undefined {
106+
const handled = emitted.find((n) => (n.payload as { title?: string }).title === 'Not sent');
107+
return handled ? String((handled.payload as { body?: string }).body) : undefined;
108+
}
109+
110+
/** The notifications the guarded notify itself sent. */
111+
function sent(emitted: ReturnType<typeof fakeMessaging>['emitted']) {
112+
return emitted.filter((n) => (n.payload as { title?: string }).title !== 'Not sent');
113+
}
114+
115+
describe('notify — a currency hole refused after a FAILED tenant-settings read is a runtime failure', () => {
116+
it('a fault edge routes it, and $error names the failed read rather than a missing setting', async () => {
117+
const { engine, messaging } = engineWith(flowWith());
118+
engine.setTenantCurrencyResolver(() => { throw new Error('the localization settings could not be read'); });
119+
120+
const result = await engine.execute('deal_won_notice', { params: { amount: 1234.5 }, tenantId: 'org_cn' } as any);
121+
122+
expect(result.success, JSON.stringify(result)).toBe(true);
123+
expect(statusOf(result, 'notify')).toBe('failure');
124+
expect(statusOf(result, 'handled')).toBe('success');
125+
const handled = handledError(messaging.emitted);
126+
expect(handled).toContain('{{ amount | currency }}');
127+
expect(handled).toContain('could not be read');
128+
expect(handled).not.toContain('Set the default currency');
129+
expect(sent(messaging.emitted)).toHaveLength(0);
130+
});
131+
132+
it('under errorHandling retry the run succeeds once the read does, and the title formats in the default', async () => {
133+
const flow = flowWith({ errorHandling: { strategy: 'retry', maxRetries: 2, backoffMs: 1 } });
134+
flow.edges = flow.edges.filter((e) => e.type !== 'fault');
135+
const { engine, messaging } = engineWith(flow);
136+
let reads = 0;
137+
engine.setTenantCurrencyResolver(() => {
138+
reads += 1;
139+
if (reads === 1) throw new Error('the localization settings could not be read');
140+
return 'CNY';
141+
});
142+
143+
const result = await engine.execute('deal_won_notice', { params: { amount: 1234.5 }, tenantId: 'org_cn' } as any);
144+
145+
expect(result.success, JSON.stringify(result)).toBe(true);
146+
expect(reads).toBe(2);
147+
expect(messaging.emitted[0]?.payload).toMatchObject({ title: 'Won CN¥1,234.50' });
148+
});
149+
150+
it('a read that SUCCEEDED and found no default keeps the guard refusal: the fault edge does not route it', async () => {
151+
const { engine, messaging } = engineWith(flowWith());
152+
engine.setTenantCurrencyResolver(() => undefined);
153+
154+
const result = await engine.execute('deal_won_notice', { params: { amount: 1234.5 }, tenantId: 'org_none' } as any);
155+
156+
expect(result.success).toBe(false);
157+
expect(String(result.error)).toContain('Set the default currency');
158+
expect(statusOf(result, 'handled')).toBeUndefined();
159+
expect(messaging.emitted).toHaveLength(0);
160+
});
161+
162+
it('a read that succeeded with a default is unchanged: the notification goes out in it', async () => {
163+
const { engine, messaging } = engineWith(flowWith());
164+
engine.setTenantCurrencyResolver(() => 'CNY');
165+
166+
const result = await engine.execute('deal_won_notice', { params: { amount: 1234.5 }, tenantId: 'org_cn' } as any);
167+
168+
expect(result.success, JSON.stringify(result)).toBe(true);
169+
expect(statusOf(result, 'notify')).toBe('success');
170+
expect(statusOf(result, 'handled')).toBeUndefined();
171+
expect(messaging.emitted[0]?.payload).toMatchObject({ title: 'Won CN¥1,234.50' });
172+
});
173+
});
174+
175+
describe('a resumed segment whose tenant-settings read fails grades the refusal the same way', () => {
176+
it('the resumed notify\'s refusal is routed by its fault edge', async () => {
177+
const parked = flowWith();
178+
parked.type = 'screen' as never;
179+
parked.nodes.splice(1, 0, {
180+
id: 'ask',
181+
type: 'screen' as never,
182+
label: 'Ask',
183+
config: { waitForInput: true, title: 'Confirm', fields: [{ name: 'ok', label: 'OK', type: 'checkbox' }] } as never,
184+
});
185+
parked.edges[0] = { id: 'e0', source: 'start', target: 'ask' };
186+
parked.edges.push({ id: 'e1b', source: 'ask', target: 'notify' });
187+
188+
const store = new InMemorySuspendedRunStore();
189+
// Parked by an engine with no resolver: the persisted context carries no currency.
190+
const paused = await engineWith(parked, store).engine.execute('deal_won_notice', { params: { amount: 1234.5 }, tenantId: 'org_cn' } as any);
191+
expect(paused.status).toBe('paused');
192+
193+
const { engine, messaging } = engineWith(parked, store);
194+
engine.setTenantCurrencyResolver(() => { throw new Error('the localization settings could not be read'); });
195+
const resumed = await engine.resume(paused.runId!, { variables: { ok: true } } as never);
196+
197+
expect(resumed.success, JSON.stringify(resumed)).toBe(true);
198+
expect(statusOf(resumed, 'handled')).toBe('success');
199+
expect(handledError(messaging.emitted)).toContain('could not be read');
200+
expect(sent(messaging.emitted)).toHaveLength(0);
201+
// The persisted context is left as it parked.
202+
expect((await store.load(paused.runId!))?.context ?? {}).not.toHaveProperty('currency');
203+
});
204+
});
205+
206+
describe('gradeAfterTenantCurrencyReadFault — the probe decides', () => {
207+
const vars = new Map<string, unknown>([['amount', 5]]);
208+
const refusalOf = (source: string): FlowTextTemplateError => {
209+
try {
210+
renderTextSlot(source, vars);
211+
} catch (err) {
212+
return err as FlowTextTemplateError;
213+
}
214+
throw new Error(`expected '${source}' to refuse`);
215+
};
216+
217+
it('a refusal a default would cure, after a failed read, becomes an unbranded runtime failure', () => {
218+
const context = {};
219+
noteTenantCurrencyReadFault(context, new Error('connection reset'));
220+
const graded = gradeAfterTenantCurrencyReadFault(refusalOf('{{ amount | currency }}'), vars, context);
221+
expect(graded).toBeInstanceOf(Error);
222+
expect(graded).not.toBeInstanceOf(FlowTextTemplateError);
223+
expect(isGuardRefusal(graded)).toBe(false);
224+
expect((graded as Error).message).toContain('connection reset');
225+
});
226+
227+
it('a refusal a default would NOT cure stays the guard refusal, even after a failed read', () => {
228+
const context = {};
229+
noteTenantCurrencyReadFault(context, new Error('connection reset'));
230+
const refusal = refusalOf('{{ amount | currency }} {{ amount | bogus }}');
231+
expect(gradeAfterTenantCurrencyReadFault(refusal, vars, context)).toBe(refusal);
232+
});
233+
234+
it('with no failed read on the context, every refusal is returned as it stands', () => {
235+
const refusal = refusalOf('{{ amount | currency }}');
236+
expect(gradeAfterTenantCurrencyReadFault(refusal, vars, {})).toBe(refusal);
237+
expect(gradeAfterTenantCurrencyReadFault(refusal, vars, undefined)).toBe(refusal);
238+
expect(isGuardRefusal(refusal)).toBe(true);
239+
});
240+
});
241+
242+
describe('the automation plugin\'s tenant-currency resolver rejects a failed read', () => {
243+
/** Boot the plugin over an ObjectQL whose `sys_setting` read answers `rows`, or throws. */
244+
async function boot(settingRows: Array<Record<string, unknown>> | 'throws') {
245+
const messaging = fakeMessaging();
246+
const kernel = new LiteKernel({ logger: { level: 'silent' } } as never);
247+
kernel.use(new AutomationServicePlugin());
248+
kernel.use({
249+
name: 'test.harness',
250+
type: 'standard' as const,
251+
version: '1.0.0',
252+
dependencies: ['com.objectstack.service-automation'],
253+
async init(ctx: any) {
254+
ctx.registerService('objectql', {
255+
async find(object: string) {
256+
if (object !== 'sys_setting') return [];
257+
if (settingRows === 'throws') throw new Error('connection reset reading sys_setting');
258+
return settingRows;
259+
},
260+
});
261+
ctx.registerService('messaging', messaging.service);
262+
},
263+
async start() {},
264+
} as never);
265+
await kernel.bootstrap();
266+
const engine = kernel.getService('automation') as AutomationEngine;
267+
engine.registerFlow('deal_won_notice', flowWith());
268+
return { engine, messaging };
269+
}
270+
271+
it('a sys_setting read that throws: the fault edge routes the refusal', async () => {
272+
const { engine, messaging } = await boot('throws');
273+
const result = await engine.execute('deal_won_notice', { params: { amount: 1234.5 }, tenantId: 'org_cn', runAs: 'system' } as any);
274+
expect(result.success, JSON.stringify(result)).toBe(true);
275+
expect(handledError(messaging.emitted)).toContain('could not be read');
276+
expect(sent(messaging.emitted)).toHaveLength(0);
277+
});
278+
279+
it('a sys_setting read that finds no default: the guard refusal stands', async () => {
280+
const { engine } = await boot([]);
281+
const result = await engine.execute('deal_won_notice', { params: { amount: 1234.5 }, tenantId: 'org_cn', runAs: 'system' } as any);
282+
expect(result.success).toBe(false);
283+
expect(String(result.error)).toContain('Set the default currency');
284+
});
285+
});

‎packages/services/service-automation/src/builtin/template.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,3 +141,60 @@ export function renderTextSlot(value: unknown, variables: VariableMap, currency?
141141
const text = String(result.value);
142142
return text === '' ? undefined : text;
143143
}
144+
145+
/**
146+
* [#28056] The run contexts whose tenant-currency read FAILED, each with the
147+
* read's error text. Run setup notes a context here when the
148+
* `FlowTenantCurrencyResolver` rejects, so a text slot's refusal can tell "the
149+
* organization has no default" from "the read learned nothing". Held beside the
150+
* context, never on it: `AutomationContext` has no member for it, and a
151+
* persisted context must not carry one run's transient fault into the next.
152+
*/
153+
const tenantCurrencyReadFaults = new WeakMap<object, string>();
154+
155+
/**
156+
* Stands in for the tenant default when a refusal is probed. `XXX` is ISO
157+
* 4217's "no currency" code, so a hole formats with it. Never shown: the probe
158+
* only renders.
159+
*/
160+
const NO_CURRENCY_PROBE = 'XXX';
161+
162+
/** Record that `context`'s run could not read its organization's default currency. */
163+
export function noteTenantCurrencyReadFault(context: object, err: unknown): void {
164+
tenantCurrencyReadFaults.set(context, err instanceof Error ? err.message : String(err));
165+
}
166+
167+
/**
168+
* [#28056] Grade a node's thrown failure against a failed tenant-currency read.
169+
*
170+
* A {@link FlowTextTemplateError} is a guard refusal. That is right when the
171+
* read succeeded: the organization has no default, and only a setting or the
172+
* hole's own code cures it. When the read FAILED for this run's `context`, the
173+
* slot is rendered once more with {@link NO_CURRENCY_PROBE} as the default:
174+
*
175+
* - The probe renders: the only refusal was the missing default, which the read
176+
* would have supplied. A plain `Error` is returned instead, without the guard
177+
* brand, so a `fault` edge routes it like any runtime failure, and the run
178+
* can succeed once the read does.
179+
* - The probe still refuses: a default cannot cure the slot, and the refusal is
180+
* returned as it stands.
181+
*
182+
* Any other failure, and every refusal after a read that succeeded, is returned
183+
* unchanged. The messaging channels grade the same refusal the same way
184+
* (#27257).
185+
*/
186+
export function gradeAfterTenantCurrencyReadFault(err: unknown, variables: VariableMap, context: unknown): unknown {
187+
if (!(err instanceof FlowTextTemplateError) || typeof context !== 'object' || context === null) return err;
188+
const fault = tenantCurrencyReadFaults.get(context);
189+
if (fault === undefined) return err;
190+
try {
191+
renderTextSlot(err.source, variables, NO_CURRENCY_PROBE);
192+
} catch {
193+
return err;
194+
}
195+
return new Error(
196+
`flow text template \`${err.source}\`: a hole formats in the tenant default currency, and the ` +
197+
`organization's \`localization.currency\` setting could not be read (${fault}), so the text was not ` +
198+
'rendered. The read failure is transient: the run can succeed once the setting can be read.',
199+
);
200+
}

0 commit comments

Comments
 (0)