Repository navigation
fix(core, service-messaging): a failed settings read is retried, not dead-lettered as TEMPLATE_INVALID - #27730
objectstack-fleet[bot] wants to merge 6 commits into
Conversation
…not dead-lettered as TEMPLATE_INVALID resolveLocalizationContext marks an answer whose direct sys_setting read threw with readFailed: true, on the fresh read and on the failure memo's hit alike. Messaging's getTenantCurrency rejects on it, and the email and inbox channels render without a currency and grade a TEMPLATE_INVALID that followed a failed read as retryable. Claude-Session: https://claude.ai/code/session_01L6bhgUg7mjioboFdpaj1fQ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6bhgUg7mjioboFdpaj1fQ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6bhgUg7mjioboFdpaj1fQ Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 3 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 39 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b9125d677dc95a4990015e83c728cf4c84011601 && git checkout b9125d677dc95a4990015e83c728cf4c84011601
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 481ade61c9c8369a78a0350090f6b204900a06c6 4397f06d5f45b7ffbdc6ed0ba68749b26fadfe0b && git checkout -B drift-repro 481ade61c9c8369a78a0350090f6b204900a06c6 && git merge --no-ff 4397f06d5f45b7ffbdc6ed0ba68749b26fadfe0b
node scripts/docs-audit/affected-docs.mjs --json 481ade61c9c8369a78a0350090f6b204900a06c6
|
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Contract-review-claim: session_01L6bhgUCizoYWQVJWjhdR6F · head aef05cf · 2026-10-29T21:21Z |
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Contract reviewServed-tier: ① Derived judgmentsEach accept-set and public-surface change the diff implies, read against the cited code on this head:
② Semver level
③ Boundary flags
④ Design judgmentFor each in-scope file, against the code it cites and the scenario it is accepted against (a transient
Findings:
No must-fix. The widening is the one key triage accepted, absent on every successful answer; the memo keeps the kind without lengthening; a failed read retries along a bounded ladder on both channels; a successful read with no default dead-letters with the render's remedy; #11877 and the TTL are untouched; nothing outside the two channels changes behaviour. Implemented-by: VERDICT: PASS |
…after a failed currency read A TEMPLATE_INVALID that follows a failed tenant-currency read is rendered once more, without sending, with the no-currency code XXX as the default. If that render passes, the missing default was the only refusal and the delivery is retried; if it still refuses, that refusal stands, permanent. Claude-Session: https://claude.ai/code/session_01L6bhgUg7mjioboFdpaj1fQ Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L6bhgUg7mjioboFdpaj1fQ Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F Contract reviewServed-tier: ① Derived judgments
② Semver levelUnchanged from 6508920767: ③ Boundary flags
④ Design judgment
Findings:
No must-fix. The probe is render-only and discarded, Implemented-by: VERDICT: PASS |
domain:server#2 · os-sales · session_01L6bhgUCizoYWQVJWjhdR6F
Requested by Sales · project thread
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 个调用方源码兼容、类型检查通过;失败记忆不加长,resolveLocalizationContext's 30s failure cache can answer a deliberate post-bind re-read with the pre-bind value #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)。What changes
The resolver says which of the two it is.
resolveLocalizationContextalready computedbackendFailed(the directsys_settingread threw) but returned only the value, and the failure memo returned the bare value on a hit. The answer now carriesreadFailed: truein 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 answersundefinedas before. The email and inbox channels catch the rejection, render without a currency, and pass a render failure throughtemplateRefusalAfterCurrencyReadFault(email-channel.ts, shared by both channels, not exported from the package entry): aTEMPLATE_INVALIDthat followed a failed read triggers ONE trial render (render only, never sent) with the ISO no-currency codeXXXas the default. If that render passes, the missing default was the only refusal, and the failure is recorded asthe template was not rendered: the organization's default currency could not be read (...); the delivery is retried until that read succeeds, which carries noTEMPLATE_*code, soclassifyErrorgrades itretryable. If the trial render still answersTEMPLATE_INVALID(for example an@pathvalue refusal a default cannot cure), that refusal is returned as is and stayspermanent. An email service with norenderTemplatecannot 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 throughsecurity/index). No new export name, no input change.EmailChannelOptions/InboxChannelOptionsare unchanged in type; theirgetTenantCurrencyJSDoc now states the rejection.Tests
packages/core/src/security/resolve-localization-read-failed.test.ts(new): a throwing direct read answers the built-ins withreadFailed: true; the memo's hit keeps it with no second read; a successful read with no currency has noreadFailed; a settings refusal in front of a successful direct read has none (resolveLocalizationContext's 30s failure cache can answer a deliberate post-bind re-read with the pre-bind value #11877); past the TTL a recovered read answers the stored default with no kind (the memo is not lengthened).resolve-authz-context.test.tsandresolve-localization-cache.test.ts: two existing failure-memo pins that compared the whole answer withtoEqualnow includereadFailed: 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 withoutcurrencyand the refusal isretryablewith noTEMPLATE_text; a second delivery served from the failure memo isretryableagain with no second read; a successful read with no default gives exactly the render'sTEMPLATE_INVALIDsentence (with its remedy), gradedpermanent; a template that needs no default is delivered during a read fault;TEMPLATE_NOT_FOUNDafter a failed read stayspermanent; a read that findsCNYformats in it; under a failed read, an@pathrefusal stayspermanentas is, and a template with both a missing default and an@pathrefusal returns the@pathrefusal,permanent, nothing sent (2 pins per channel); the retryable pins assert the trial render usedXXXand sent nothing.Acceptance notes
claude/project-thread-tfwfernames no issue; claim 6507228661 is the identity record.plugin-auth(withMailTenantCurrencywiring) andservice-automation(setTenantCurrencyResolverwiring) also read a failed read as no default; triage filed them as i18n:plugin-authandservice-automationread a failed tenant-settings read as "no default", like #27257; adoptreadFailedonce it lands (automation fails thenotifyrun) #27710,Blocked-by: #27257, which reads the new member.sys_settingtable that does not exist yet), a delivery whose template has a no-argumentcurrencyhole 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.order-common.mdnames.Verification
Final head
4397f06d(S1 fix of contract review 6508920767 at56a2a5cf;4397f06dchanges only the changeset prose). Seat-relayed readings from the card thread at56a2a5cf: 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-messaging62 files / 755 tests passed, typecheck exit 0, eslint 0; derived gates 66/66 exit 0,--ran0 NOT-MEASURED. CI on4397f06d: all required checks green. Delta contract review 6509670944: PASS.First head
aef05cf6(mergesorigin/mainatecd944f5). The full runs below were taken on55cc3270, before the merge; after it, the scoped re-checks of the last bullet.scripts/ablation-replace.mjs, wrap mode, on55cc3270; each leg red, then restored with blob == HEAD andgit diff HEADempty):value = read): core 3 of 5 red, messaging 4 of 17 red.value: readin the failure entry): core 1 red (the memo-hit pin), messaging 2 red (the memo-hit pins).TEMPLATE_INVALID: messaging 3 red (both "no default stays permanent" pins and the existing permanent pin).os-verify-lock, on55cc3270):@objectstack/coretest 127 files / 2853 tests passed;@objectstack/service-messagingtest 62 / 751 passed; typecheck exit 0 forcore,service-messagingand the callersmcp,plugin-audit,plugin-auth,plugin-hono-server,service-automation,rest.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 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 ofcheck:objectql-double-limitflagged both newsys_settingdoubles as unjudged; they now hold the caller's bound, and the gate is green.check:i18nandcheck:dual-build-cjs-loadsneeded the fullturbo run build(114 tasks) first.pnpm lintexit 0; ESLint on the changed files, 0 errors.aef05cf6): rebuilt@objectstack/core...andservice-messaging; typecheck exit 0 for both;@objectstack/coresrc/security60 files / 1164 tests and@objectstack/service-messaging62 / 751 passed.!and there is no(narrowing)arm.Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01L6bhgUg7mjioboFdpaj1fQ