Skip to content

Commit 7739573

Browse files
fix(core, service-messaging): a failed settings read is retried, not dead-lettered as TEMPLATE_INVALID (#27730)
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F <!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuPMZ8BHWtPf6nBTp51qSM64)_ Fixes #27257 Clause-②: yes (widening) — `resolveLocalizationContext`'s result (`@objectstack/core`) gains one optional member, `readFailed?: true`. Nothing is narrowed. Metadata-protocol: none ## 维护者速读 - 改了什么:`resolveLocalizationContext` 的结果新增可选字段 `readFailed: true`,只在直连 `sys_setting` 读取抛错时出现(新读与 30 秒失败记忆命中都带)。消息服务的邮件、站内信渠道据此区分:读失败时不带币种照常渲染;若被拒(`TEMPLATE_INVALID`),再以 ISO「无币种」码 `XXX` 作默认试渲染一次(只渲染、不发送):能过,说明唯一的拒绝是缺默认,改记为可重试;仍被拒(如 `@path` 值错误),原样返回那句拒绝、判永久。读成功且确实没有默认币种时,仍是 `TEMPLATE_INVALID`、永久失败、带补救说明。 - 为什么改:原先读失败与"没有默认币种"都返回 `currency: undefined`,一次瞬时的设置读取故障会让带无参 `currency` 洞的邮件进死信,永不发送。分诊 6496449802:读失败是瞬时的,要重试;只有读成功且无默认才是永久的。 - 风险与回滚:扩大一个已发布导出类型(可选字段,`Clause-②: yes (widening)`),7 个调用方源码兼容、类型检查通过;失败记忆不加长,#11877 不变。不含无参 currency 洞的模板在读故障期间照常发送。回滚即 revert。 - 先看:`packages/core/src/security/resolve-authz-context.ts`(结果类型与 `resolveLocalizationContext` 中 `value` 的构造及失败记忆写入);`packages/services/service-messaging/src/email-channel.ts` 的 `templateRefusalAfterCurrencyReadFault`;`messaging-service-plugin.ts` 的 `getTenantCurrency`。 - 两分钟验证:`pnpm --filter @objectstack/service-messaging exec vitest run src/template-tenant-currency.test.ts`,预期 21 条全过(读失败两渠道判 retryable、记忆命中同样、读成功无默认判 permanent、读失败下与币种无关的拒绝判 permanent)。 - 需要你做的:在本 PR 上批准这次公开类型扩面(分诊 6506866652 所要求);契约复核与增量复核均已 PASS(6508920767、6509670944),批准后席位落地。 ## What changes **The resolver says which of the two it is.** `resolveLocalizationContext` already computed `backendFailed` (the direct `sys_setting` read threw) but returned only the value, and the failure memo returned the bare value on a hit. The answer now carries `readFailed: true` in that case. It is built once and the memo stores that same object, so a hit inside the 30 s failure TTL says the read failed too. A settings-service refusal still does not count as a backend fault (#11877 unchanged), and the memo's lifetime is unchanged. On every successful read the member is absent. **Messaging grades from it.** `getTenantCurrency` (`messaging-service-plugin.ts`) keeps its signature and rejects when the read failed; with no default after a successful read it answers `undefined` as before. The email and inbox channels catch the rejection, render without a currency, and pass a render failure through `templateRefusalAfterCurrencyReadFault` (`email-channel.ts`, shared by both channels, not exported from the package entry): a `TEMPLATE_INVALID` that followed a failed read triggers ONE trial render (render only, never sent) with the ISO no-currency code `XXX` as the default. If that render passes, the missing default was the only refusal, and the failure is recorded as `the template was not rendered: the organization's default currency could not be read (...); the delivery is retried until that read succeeds`, which carries no `TEMPLATE_*` code, so `classifyError` grades it `retryable`. If the trial render still answers `TEMPLATE_INVALID` (for example an `@path` value refusal a default cannot cure), that refusal is returned as is and stays `permanent`. An email service with no `renderTemplate` cannot tell the two apart and is graded retryable (the helper's docblock says so). Every other failure and every refusal after a successful read is returned unchanged. **Public surface.** One optional member on the result type of an exported function of `@objectstack/core` (reachable from the package root through `security/index`). No new export name, no input change. `EmailChannelOptions` / `InboxChannelOptions` are unchanged in type; their `getTenantCurrency` JSDoc now states the rejection. ## Tests - `packages/core/src/security/resolve-localization-read-failed.test.ts` (new): a throwing direct read answers the built-ins with `readFailed: true`; the memo's hit keeps it with no second read; a successful read with no currency has no `readFailed`; a settings refusal in front of a successful direct read has none (#11877); past the TTL a recovered read answers the stored default with no kind (the memo is not lengthened). - `resolve-authz-context.test.ts` and `resolve-localization-cache.test.ts`: two existing failure-memo pins that compared the whole answer with `toEqual` now include `readFailed: true` (moved pins, same behaviour otherwise). - `packages/services/service-messaging/src/template-tenant-currency.test.ts`: through the plugin's own wiring (`MessagingServicePlugin.init`, `kernel:ready`, `getChannel`), for both the email and inbox channels: a failed read renders without `currency` and the refusal is `retryable` with no `TEMPLATE_` text; a second delivery served from the failure memo is `retryable` again with no second read; a successful read with no default gives exactly the render's `TEMPLATE_INVALID` sentence (with its remedy), graded `permanent`; a template that needs no default is delivered during a read fault; `TEMPLATE_NOT_FOUND` after a failed read stays `permanent`; a read that finds `CNY` formats in it; under a failed read, an `@path` refusal stays `permanent` as is, and a template with both a missing default and an `@path` refusal returns the `@path` refusal, `permanent`, nothing sent (2 pins per channel); the retryable pins assert the trial render used `XXX` and sent nothing. ## Acceptance notes - **Branch name.** The card thread's harness-pinned branch `claude/project-thread-tfwfer` names no issue; claim 6507228661 is the identity record. - **Same family, not here.** `plugin-auth` (`withMailTenantCurrency` wiring) and `service-automation` (`setTenantCurrencyResolver` wiring) also read a failed read as no default; triage filed them as #27710, `Blocked-by: #27257`, which reads the new member. - **A persistent fault.** Where the direct read fails for a long time (a `sys_setting` table that does not exist yet), a delivery whose template has a no-argument `currency` hole is now retried along the retry ladder before it dead-letters, instead of dead-lettering at once. A template without such a hole is unaffected. - **The PR body's attribution lines** name this card thread (the harness requires its own thread link), not the seat thread `order-common.md` names. ## Verification **Final head `4397f06d`** (S1 fix of contract review 6508920767 at `56a2a5cf`; `4397f06d` changes only the changeset prose). Seat-relayed readings from the card thread at `56a2a5cf`: ablations A4 (never re-grade) 6 red, A5 (re-grade every refusal) 3 red, A6 (ignore the trial render's answer) 4 red, each restored blob == HEAD; `@objectstack/service-messaging` 62 files / 755 tests passed, typecheck exit 0, eslint 0; derived gates 66/66 exit 0, `--ran` 0 NOT-MEASURED. CI on `4397f06d`: all required checks green. Delta contract review 6509670944: PASS. First head `aef05cf6` (merges `origin/main` at `ecd944f5`). The full runs below were taken on `55cc3270`, before the merge; after it, the scoped re-checks of the last bullet. - **Ablations** (`scripts/ablation-replace.mjs`, wrap mode, on `55cc3270`; each leg red, then restored with blob == HEAD and `git diff HEAD` empty): - A1, no kind on the fresh answer (`value = read`): core 3 of 5 red, messaging 4 of 17 red. - A2, the memo stores the bare value (`value: read` in the failure entry): core 1 red (the memo-hit pin), messaging 2 red (the memo-hit pins). - A3, the plugin never rejects: messaging 4 red. - A4, the channel never re-grades: messaging 4 red. - A5, the channel re-grades every `TEMPLATE_INVALID`: messaging 3 red (both "no default stays permanent" pins and the existing permanent pin). - The first A4 attempt was refused by the tool (its replacement text already occurred in the file) and restored; it was rerun with a distinct replacement. - **Tests and types** (under `os-verify-lock`, on `55cc3270`): `@objectstack/core` test 127 files / 2853 tests passed; `@objectstack/service-messaging` test 62 / 751 passed; typecheck exit 0 for `core`, `service-messaging` and the callers `mcp`, `plugin-audit`, `plugin-auth`, `plugin-hono-server`, `service-automation`, `rest`. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 66 families; each was run with its exit code recorded. `--ran`: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. The first run of `check:objectql-double-limit` flagged both new `sys_setting` doubles as unjudged; they now hold the caller's bound, and the gate is green. `check:i18n` and `check:dual-build-cjs-loads` needed the full `turbo run build` (114 tasks) first. - **Lint:** `pnpm lint` exit 0; ESLint on the changed files, 0 errors. - **After the merge** (`aef05cf6`): rebuilt `@objectstack/core...` and `service-messaging`; typecheck exit 0 for both; `@objectstack/core` `src/security` 60 files / 1164 tests and `@objectstack/service-messaging` 62 / 751 passed. - **Open PRs:** none of the 26 open PRs touches these files. - **Dogfood battery:** not run, because the title has no `!` and there is no `(narrowing)` arm. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgUg7mjioboFdpaj1fQ)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgUg7mjioboFdpaj1fQ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b00e3f4 commit 7739573

9 files changed

Lines changed: 431 additions & 18 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/service-messaging': patch
4+
---
5+
6+
fix(core, service-messaging): a failed settings read no longer dead-letters a templated mail as `TEMPLATE_INVALID`; only a read that finds no default currency is permanent (#27257)
7+
8+
Clause-②: yes (widening) — `resolveLocalizationContext`'s result (`@objectstack/core`) gains one optional member, `readFailed?: true`. Nothing is narrowed.
9+
Metadata-protocol: none
10+
11+
- **Before.** `resolveLocalizationContext` answered `currency: undefined` both when the organization has no default currency and when its `sys_setting` read failed (the failure is memoized for 30 s). So a messaging delivery whose template has a `{{ amount | currency }}` hole with no argument was refused `TEMPLATE_INVALID` during a transient settings fault, graded permanent and never sent, although a retry would have worked.
12+
- **Now.** The answer carries `readFailed: true` when the direct `sys_setting` read threw, on the fresh read and on a hit of the failure memo alike. A settings-service refusal does not count, and the memo is not lengthened. Messaging's tenant-currency read rejects on it; the email and inbox channels then render without a currency. When that render answers `TEMPLATE_INVALID`, they render the template once more without sending, with the no-currency code `XXX` as the default: if that passes, the missing default was the only refusal, and the delivery is recorded without a `TEMPLATE_*` code and retried; if it still refuses, that refusal stands and is graded permanent, as before. Once the read succeeds, an organization with no default currency gets `TEMPLATE_INVALID` with its remedy, graded permanent, as before.
13+
- **Unchanged.** A template with no no-argument `currency` hole is sent during a settings fault, as before. Every other template failure keeps its grade. The other callers of `resolveLocalizationContext` see the same values; the new member is optional and absent on every successful read.

‎packages/core/src/security/resolve-authz-context.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,7 +455,8 @@ describe('resolveLocalizationContext — failure-only cross-request cache (#1022
455455
const second = await resolveLocalizationContext({ ql, organizationId: 'o1' });
456456
// Functional behavior is unchanged — still the built-in defaults — the
457457
// second call just doesn't repeat the failing query (and its log line).
458-
expect(first).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined });
458+
// [#27257] Both say the read failed, the memoized one included.
459+
expect(first).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined, readFailed: true });
459460
expect(second).toEqual(first);
460461
expect(ql.counts.sys_setting).toBe(1);
461462
});

‎packages/core/src/security/resolve-authz-context.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2211,7 +2211,22 @@ export interface ResolveLocalizationInput {
22112211
userId?: string;
22122212
}
22132213

2214-
type LocalizationResult = { timezone: string; locale: string; currency?: string };
2214+
type LocalizationResult = {
2215+
timezone: string;
2216+
locale: string;
2217+
currency?: string;
2218+
/**
2219+
* Present only when the direct `sys_setting` read THREW (a backend fault),
2220+
* on a fresh read and on a memoized one alike: every value above is then the
2221+
* built-in default, not a stored answer. Absent, a missing `currency` means
2222+
* the read succeeded and the organization has no default, which is permanent
2223+
* until someone sets one; present, it means nothing was learned, and a caller
2224+
* that would refuse work for want of a default retries instead. A
2225+
* settings-service refusal never sets it (#11877), and it does not lengthen
2226+
* the failure memo. (#27257)
2227+
*/
2228+
readFailed?: true;
2229+
};
22152230

22162231
/**
22172232
* Process-local TTL cache for the FAILED-READ outcome of
@@ -2630,7 +2645,9 @@ function putLocalizationEntry(ql: object, key: string, entry: LocalizationCacheE
26302645
* {@link LOCALIZATION_FAILURE_CACHE_TTL_MS} per `(ql, organizationId, userId)` so
26312646
* the failing query — and the driver's log line for it — does not repeat
26322647
* every request (#10221). A settings-service refusal is not a backend fault
2633-
* and never populates that memo (#11877).
2648+
* and never populates that memo (#11877). Such an answer carries
2649+
* `readFailed: true`, fresh and memoized alike, so a caller can tell "the read
2650+
* failed" from "the read found no default" (#27257).
26342651
*
26352652
* A SUCCESSFUL read is cached too, since #11966 (leg C of #11633) — but only
26362653
* when the engine carries the write-epoch and middleware seams, and only until
@@ -2667,7 +2684,10 @@ export async function resolveLocalizationContext(input: ResolveLocalizationInput
26672684
}
26682685
}
26692686

2670-
const { value, backendFailed } = await resolveLocalizationContextUncached(input);
2687+
const { value: read, backendFailed } = await resolveLocalizationContextUncached(input);
2688+
// [#27257] The kind travels ON the answer, and the memo stores that same
2689+
// answer, so a hit inside the failure TTL still says the read failed.
2690+
const value: LocalizationResult = backendFailed ? { ...read, readFailed: true } : read;
26712691
if (!cacheable) return value;
26722692

26732693
if (backendFailed) {

‎packages/core/src/security/resolve-localization-cache.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,6 +463,7 @@ describe('resolveLocalizationContext — the failure memo survives what retires
463463
timezone: 'UTC',
464464
locale: 'en-US',
465465
currency: undefined,
466+
readFailed: true,
466467
});
467468
expect(ql.counts.sys_setting).toBe(1);
468469

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#27257] `resolveLocalizationContext` tells "the read failed" from "the read
4+
// found no default". A messaging channel refuses a mail whose
5+
// `{{ amount | currency }}` hole has no default, and grades that refusal
6+
// permanent; it may do so only when the read succeeded. So the answer carries
7+
// `readFailed: true` exactly when the direct `sys_setting` read threw, on the
8+
// fresh read AND on the hit the failure memo serves inside its TTL, and never
9+
// otherwise.
10+
11+
import { describe, it, expect, vi, afterEach } from 'vitest';
12+
import { resolveLocalizationContext } from './resolve-authz-context.js';
13+
14+
/** A `sys_setting` read answering `rows`, recording every read. */
15+
function makeQl(rows: Array<Record<string, unknown>>) {
16+
const reads: string[] = [];
17+
return {
18+
reads,
19+
async find(object: string, o: any) {
20+
reads.push(object);
21+
const where = o?.where ?? {};
22+
const matched = rows.filter((r) =>
23+
Object.entries(where).every(([k, v]) => {
24+
if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`);
25+
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(r[k]);
26+
return r[k] === v;
27+
}),
28+
);
29+
// Hold the caller's bound, after the filter and by presence.
30+
return typeof o?.limit === 'number' ? matched.slice(0, o.limit) : matched;
31+
},
32+
};
33+
}
34+
35+
/** A `sys_setting` read that throws: the backend fault the failure memo is for. */
36+
function makeBrokenQl() {
37+
const reads: string[] = [];
38+
return {
39+
reads,
40+
async find(object: string): Promise<never> {
41+
reads.push(object);
42+
throw new Error('connection reset reading sys_setting');
43+
},
44+
};
45+
}
46+
47+
afterEach(() => {
48+
vi.useRealTimers();
49+
});
50+
51+
describe('resolveLocalizationContext — a failed read says so (#27257)', () => {
52+
it('a direct read that throws answers the built-ins WITH `readFailed: true`', async () => {
53+
const ql = makeBrokenQl();
54+
const answer = await resolveLocalizationContext({ ql, organizationId: 'o1' });
55+
expect(answer).toEqual({ timezone: 'UTC', locale: 'en-US', currency: undefined, readFailed: true });
56+
expect(ql.reads).toHaveLength(1);
57+
});
58+
59+
it('the failure memo\'s hit keeps the kind: no second read, still `readFailed: true`', async () => {
60+
const ql = makeBrokenQl();
61+
await resolveLocalizationContext({ ql, organizationId: 'o1' });
62+
const hit = await resolveLocalizationContext({ ql, organizationId: 'o1' });
63+
expect(ql.reads).toHaveLength(1); // served by the memo, not a fresh read
64+
expect(hit.readFailed).toBe(true);
65+
expect(hit.currency).toBeUndefined();
66+
});
67+
68+
it('a read that SUCCEEDS and finds no default carries no `readFailed` at all', async () => {
69+
const ql = makeQl([
70+
{ namespace: 'localization', key: 'timezone', scope: 'tenant', organization_id: 'o1', value: 'Asia/Tokyo' },
71+
]);
72+
const answer = await resolveLocalizationContext({ ql, organizationId: 'o1' });
73+
expect(answer.timezone).toBe('Asia/Tokyo');
74+
expect(answer.currency).toBeUndefined();
75+
expect(answer).not.toHaveProperty('readFailed');
76+
});
77+
78+
it('a settings-service refusal in front of a SUCCESSFUL direct read is not a failed read (#11877)', async () => {
79+
const settings = {
80+
get: async () => { throw new Error('unused'); },
81+
getMany: async () => { throw new Error("unknown settings namespace 'localization'"); },
82+
};
83+
const ql = makeQl([]);
84+
const answer = await resolveLocalizationContext({ ql, settings, organizationId: 'o1' });
85+
expect(answer.currency).toBeUndefined();
86+
expect(answer).not.toHaveProperty('readFailed');
87+
});
88+
89+
it('the memo is not lengthened: past its TTL a recovered read answers the stored default, no kind', async () => {
90+
vi.useFakeTimers();
91+
const broken = makeBrokenQl();
92+
const healthy = makeQl([
93+
{ namespace: 'localization', key: 'currency', scope: 'tenant', organization_id: 'o1', value: 'CNY' },
94+
]);
95+
// ONE engine (the memo is keyed on it), whose backend recovers mid-test.
96+
let backend: { find(object: string, o?: any): Promise<unknown> } = broken;
97+
const ql = { find: (object: string, o: any) => backend.find(object, o) };
98+
expect((await resolveLocalizationContext({ ql, organizationId: 'o1' })).readFailed).toBe(true);
99+
backend = healthy;
100+
await vi.advanceTimersByTimeAsync(30_001);
101+
const recovered = await resolveLocalizationContext({ ql, organizationId: 'o1' });
102+
expect(recovered.currency).toBe('CNY');
103+
expect(recovered).not.toHaveProperty('readFailed');
104+
expect(broken.reads.length + healthy.reads.length).toBe(2);
105+
});
106+
});

‎packages/services/service-messaging/src/email-channel.ts‎

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,58 @@ export interface EmailChannelOptions {
117117
* hole with no argument formats in. `undefined` (no default set, or not
118118
* wired) leaves the render without one, so such a hole refuses the delivery
119119
* with `TEMPLATE_INVALID` instead of formatting in a guessed currency.
120+
* REJECTS when the read itself failed: the render then runs without one,
121+
* and a `TEMPLATE_INVALID` that a default would cure is graded retryable
122+
* ({@link templateRefusalAfterCurrencyReadFault}).
120123
*/
121124
getTenantCurrency?(organizationId: string | undefined): Promise<string | undefined>;
122125
}
123126

127+
/**
128+
* The ISO 4217 code for "no currency" (`XXX`, formatted `¤1,234.50`): the
129+
* stand-in default {@link templateRefusalAfterCurrencyReadFault} renders with
130+
* to ask whether a default would cure a refusal. Never sent: the probe only
131+
* renders.
132+
*/
133+
const NO_CURRENCY_PROBE = 'XXX';
134+
135+
/**
136+
* [#27257] The row error of a templated delivery whose render failed. A read
137+
* that FAILED is not a read that found no default: when `getTenantCurrency`
138+
* rejected and the render then answered `TEMPLATE_INVALID`, the template is
139+
* rendered once more WITHOUT sending (`renderWithDefault`), with
140+
* {@link NO_CURRENCY_PROBE} as the default.
141+
*
142+
* - The probe renders: the only refusal was the missing default, which the
143+
* read would have supplied. The error is rewritten WITHOUT a `TEMPLATE_*`
144+
* code, so `classifyError` grades it retryable.
145+
* - The probe still answers `TEMPLATE_INVALID`: a default cannot cure the
146+
* template (a hole the grammar refuses, an `@path` value that is not a
147+
* code). That refusal is returned as it stands, graded permanent.
148+
* - No probe (an email service without `renderTemplate`) or a probe failing
149+
* any other way: the cause cannot be told apart, and the read fault is
150+
* transient, so it is rewritten and retried.
151+
*
152+
* Any other failure, and every refusal after a read that succeeded, is
153+
* returned unchanged. Shared by the email and inbox channels.
154+
*/
155+
export async function templateRefusalAfterCurrencyReadFault(
156+
message: string,
157+
currencyReadFault: string | undefined,
158+
renderWithDefault: ((currency: string) => Promise<unknown>) | undefined,
159+
): Promise<string> {
160+
if (currencyReadFault === undefined || !/\bTEMPLATE_INVALID\b/.test(message)) return message;
161+
if (renderWithDefault) {
162+
try {
163+
await renderWithDefault(NO_CURRENCY_PROBE);
164+
} catch (err) {
165+
const standing = (err as Error)?.message ?? String(err);
166+
if (/\bTEMPLATE_INVALID\b/.test(standing)) return standing;
167+
}
168+
}
169+
return `the template was not rendered: the organization's default currency could not be read (${currencyReadFault}); the delivery is retried until that read succeeds`;
170+
}
171+
124172
/**
125173
* The ONE token both members of this channel use for "there is no transport"
126174
* (commit 879b51270) — the reason `isAvailable()` already returns, reused verbatim so the
@@ -351,8 +399,16 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel
351399
}
352400
const templateLocale = recipientLocale;
353401
const data = (payload.templateData ?? undefined) as Record<string, unknown> | undefined;
402+
let currency: string | undefined;
403+
let currencyReadFault: string | undefined;
404+
try {
405+
currency = await opts.getTenantCurrency?.(n.organizationId);
406+
} catch (err) {
407+
// The read failed and learned nothing: render without a
408+
// currency, and grade a refusal below (#27257).
409+
currencyReadFault = (err as Error)?.message ?? String(err);
410+
}
354411
try {
355-
const currency = await opts.getTenantCurrency?.(n.organizationId);
356412
const result = (await email.sendTemplate({
357413
template: templateName,
358414
to: address,
@@ -377,8 +433,21 @@ export function createEmailChannel(opts: EmailChannelOptions): MessagingChannel
377433
// TEMPLATE_INACTIVE / MISSING_VARIABLES / TEMPLATE_INVALID)
378434
// arrives as a thrown Error — keep the code at the front of
379435
// the row's error so classifyError() below can grade it
380-
// permanent.
381-
return { ok: false, error: (err as Error)?.message ?? String(err) };
436+
// permanent — unless the currency read failed first.
437+
const renderTemplate = email.renderTemplate?.bind(email);
438+
return {
439+
ok: false,
440+
error: await templateRefusalAfterCurrencyReadFault(
441+
(err as Error)?.message ?? String(err),
442+
currencyReadFault,
443+
renderTemplate && ((probe) => renderTemplate({
444+
template: templateName,
445+
...(data !== undefined ? { data } : {}),
446+
...(templateLocale ? { locale: templateLocale } : {}),
447+
...{ currency: probe },
448+
})),
449+
),
450+
};
382451
}
383452
}
384453

‎packages/services/service-messaging/src/inbox-channel.ts‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import type {
88
MessagingChannelContext,
99
SendResult,
1010
} from './channel.js';
11-
import type { EmailSenderSurface } from './email-channel.js';
11+
import { templateRefusalAfterCurrencyReadFault, type EmailSenderSurface } from './email-channel.js';
1212
import { RECIPIENT_LOCALE_FIELD, USER_OBJECT, resolveRecipientLocale } from './recipient-locale.js';
1313
import { FAN_OUT_SYSTEM_CONTEXT } from './fan-out-system-context.js';
1414
import { assertActorReferenceResolves } from './actor-reference.js';
@@ -61,6 +61,9 @@ export interface InboxChannelOptions {
6161
* hole with no argument formats in. `undefined` (no default set, or not
6262
* wired) leaves the render without one, so such a hole refuses the delivery
6363
* with `TEMPLATE_INVALID` instead of formatting in a guessed currency.
64+
* REJECTS when the read itself failed: the render then runs without one,
65+
* and a `TEMPLATE_INVALID` that a default would cure is graded retryable
66+
* (`templateRefusalAfterCurrencyReadFault` in `email-channel.ts`).
6467
*/
6568
getTenantCurrency?(organizationId: string | undefined): Promise<string | undefined>;
6669
/** User identity object the recipient's locale is read from (default `sys_user`). */
@@ -194,8 +197,16 @@ export function createInboxChannel(opts: InboxChannelOptions): MessagingChannel
194197
deploymentLocale,
195198
);
196199
const templateData = (payload.templateData ?? undefined) as Record<string, unknown> | undefined;
200+
let currency: string | undefined;
201+
let currencyReadFault: string | undefined;
202+
try {
203+
currency = await opts.getTenantCurrency?.(n.organizationId);
204+
} catch (err) {
205+
// The read failed and learned nothing: render without a
206+
// currency, and grade a refusal below (#27257).
207+
currencyReadFault = (err as Error)?.message ?? String(err);
208+
}
197209
try {
198-
const currency = await opts.getTenantCurrency?.(n.organizationId);
199210
const rendered = await email.renderTemplate({
200211
template: templateName,
201212
...(templateData !== undefined ? { data: templateData } : {}),
@@ -209,8 +220,20 @@ export function createInboxChannel(opts: InboxChannelOptions): MessagingChannel
209220
// TEMPLATE_INACTIVE / MISSING_VARIABLES / TEMPLATE_INVALID)
210221
// arrives as a thrown Error — keep the code at the front of
211222
// the row's error so classifyError() below can grade it
212-
// permanent.
213-
return { ok: false, error: (err as Error)?.message ?? String(err) };
223+
// permanent — unless the currency read failed first.
224+
return {
225+
ok: false,
226+
error: await templateRefusalAfterCurrencyReadFault(
227+
(err as Error)?.message ?? String(err),
228+
currencyReadFault,
229+
(probe) => email.renderTemplate!({
230+
template: templateName,
231+
...(templateData !== undefined ? { data: templateData } : {}),
232+
...(templateLocale ? { locale: templateLocale } : {}),
233+
...{ currency: probe },
234+
}),
235+
),
236+
};
214237
}
215238
}
216239

0 commit comments

Comments
 (0)