Repository navigation
feat(flows): localize every notify node through four-locale template bundles - #1893
claude[bot] wants to merge 1 commit into
Conversation
…bundles
The 15 `notify` nodes across 13 flows carried inline English `title`/`message`,
so a zh-CN or ja-JP owner read every automation notification in English. They
now reference a `sys_email_template` bundle by name and supply their record
values through `templateData`; `src/email-templates/` carries one row per
notification per locale (en-US / zh-CN / ja-JP / es-ES), 60 rows over 15 names.
`@objectstack/service-messaging` 17.4.0 — the pin this repo already installs —
resolves `(template, locale)` per recipient AFTER fan-out, off that recipient's
own `sys_user.locale` with the deployment default beneath it. No dependency
moved.
Each converted node DROPS its inline strings: `NotifyConfigSchema` rejects a
node carrying both and a node carrying neither (measured on the installed
17.4.0 — `custom@template` and `custom@title`). There is no fallback path to
keep.
The English rows are tagged `en-US`, not `en`: `EmailService` matches
`(name, locale)` exactly and retries once against the literal `en-US`, with no
language-subtag folding, so `en-US` is the ladder's floor and the only tag that
also catches an `en-US` column and a fifth locale nobody translated for.
Every string is the text the node already sent, with `{var.path}` re-expressed
as the `{{key}}` the renderer resolves. A conversion, not a rewrite.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0132iDHq4FLW2zS9VPXqbf9f
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
Seat note on the red
Baseline before this branch was ~139,029 / 140,000 → ~971 of headroom (the figure carried in the dispatch order). ⇒ the conversion added ~3,835 tokens inside the measured scope, about 4× the room available. ⛔ What must NOT happen hereThe dispatch order's wording, unchanged: "if the conversion does not fit in ~971 tokens, STOP AND REPORT with the measured overage and a proposed split — do NOT raise the ceiling, do NOT trim unrelated code to buy room, do NOT compress the translations into something unreadable to make the number work." The gate says the same thing in its own voice: raising the ceiling requires "a PR that quotes a maintainer ruling approving the new number." ⛔ No such ruling exists, and the seat will not manufacture one. ⛔ Deleting comments is also not a route — the collector strips them already, so it buys exactly zero. ⭐ The question worth answering before any reshapingThe gate prints its own exclusion: and lists ⇒ the first question is placement, not size. If four-locale template content can legitimately live under Status: #1185's dev is in flight. This PR is draft and stays draft. The seat is not pushing to this branch, not re-running the job, and not enqueuing anything — recorded here so the red is not left silent, per the standing rule. Generated by Claude Code |
Part of #1185 —⚠️ deliberately NOT
Fixes. The conversion is complete, but it cannot merge on two rulings this seat does not hold, and the card is going back to the decision box. A closing keyword would silently close a card that is still being decided.⛔ Do not flip this ready and do not arm auto-merge. Two checks are red by construction, both named below.
What this does
Every
notifynode insrc/flows/stops carrying inline Englishtitle/messageand references asys_email_templatebundle instead, supplying its record values throughtemplateData.src/email-templates/carries the bundles: 15 names, four locales each, 60 rows.@objectstack/service-messaging@17.4.0— already the pin — resolves(template, locale)per recipient, after fan-out, off that recipient's ownsys_user.localewith the deployment default beneath it. So one notification really does arrive in two languages for two people. No dependency moved; no file undertest/orsrc/objects/touched.Every string is the text the node already sent, with the flow's
{var.path}interpolations re-expressed as the{{key}}placeholders the renderer resolves againsttemplateData. A conversion, not a rewrite — no notification says anything new.🔴 Blocker 1 — the token ratchet, measured
hygiene:tokensis a shrink-only ratchet and this card adds four locale bundles. Measured with the gate itself, before and after:Where the 3,835 tokens went, per file, through the gate's own
authoredText()+tokensOf():src/email-templates/en-US.tssrc/email-templates/zh-CN.tssrc/email-templates/ja-JP.tssrc/email-templates/es-ES.tssrc/email-templates/index.tssrc/flows/— inline strings removed,template/templateDataadded⭐ The conversion is not what costs anything. Dropping 15 inline
title/messagepairs and adding 15template+templateDatapairs is a net −190 tokens: business semantics fell 85,799 → 85,609. The entire overage is the four-locale content, and there is no cheaper honest shape for it —EmailTemplateDefinitioncarries onelocaleper row, so four languages is four rows, by the spec's own key.The exclusion does not reach it, and that is measured, not assumed.
src/translationsandsrc/dataare outside the collector by ruling. Locale content cannot land there:TranslationDataSchemaisz.core.$strictand has no email-template face —Filing
emailTemplatesmetadata undersrc/translations/anyway would buy the exemption by directory name rather than by ruling. That is a maintainer's call, not this seat's, so it was not done.⛔ No ceiling was raised. ⛔ No unrelated code was trimmed to buy room. ⛔ The translations were not compressed to make the number work.
🔴 Blocker 2 — 10 assertions read the notification through its inline
titlepnpm test: 11 failed / 3,444 passed, in 4 files. One is downstream of blocker 1 (test/source-token-ratchet.test.tspins the gate green). The other 10 are one class:test/flow-scheduled.test.tstest/flow-record-change.test.tstest/flow-sla-ownerless-assignment.test.tsThey observe a notification's content by reading
n.titleoff the harness's capturedmessaging.emitpayload — e.g.expected 'undefined' to contain 'CASE-1', andalertFor()in the SLA suite filtersh.notificationsonString(n.title ?? '').includes(caseNumber).Deliveries are unaffected. In the same runs the audience assertions still pass (
expect(audiences.sort()).toEqual(['rep1','rep2']), and the "a notification went out with an empty audience" guard). What changed is that the content no longer travels inpayload.title— by design, that is the whole point of the conversion — so atitle-based filter now matches nothing. Repointing those assertions attemplate/templateDatais atest/**edit, which is epic #1579's fence this wave.What was measured rather than assumed
notifynodes across 13 files, not 16/14. Re-derived atda592c23two ways (git grepover thesrc/flows/directory pathspec, andgit ls-tree -rpiped togrepas a different-mechanism control) — both 15. The missing one issrc/flows/case-csat-followup.flow.ts, retired since;src/views/case.view.tsrecords the retirement.'src/flows/**/*.ts'pathspec returns 0 here: every flow file sits directly undersrc/flows/.case_escalationand the two approval flows each register twice. All 18 carrytemplate; 0 carry inline strings. All 15 template references resolve; no bundle is unreferenced.{{placeholder}}in all 60 rows is supplied by its node'stemplateData, and notemplateDatakey is unused — cross-checked overdist/objectstack.json..d.ts: inline-only OK · template-only OK · both →custom@template· neither →custom@title. So the card's Scope line "Keep the inline-string fallback only where the platform requires it" stays falsified: there is no such place.en-USand notenEmailService.resolveAndRenderTemplatematches(name, locale)exactly and retries exactly once againstDEFAULT_TEMPLATE_LOCALE, the literal'en-US'. There is no language-subtag folding anywhere on that path. This app'si18n.defaultLocaleis'en', so the obvious spelling — the one the card's Scope line andsupportedLocalesboth suggest — is'en', and it is wrong three ways:locale: 'en'bundlelocale: 'en-US'bundlesys_user.locale→ deployment default'en'en-USretry'en-US'TEMPLATE_NOT_FOUND'fr-FR')TEMPLATE_NOT_FOUNDTEMPLATE_NOT_FOUNDclassifies permanent — it dead-letters without retry.en-USis the ladder's floor, which is exactly what the spec's own contract note says from the other side.Gates
validatetypechecklintlint:i18n-gatei18n/missing-*hygienehygiene:tokensbuilden-US zh-CN ja-JP es-EStestExit codes captured after redirect to disk, never through a pipe.
The split, and why none of them is small
971 tokens of headroom. The smallest shippable unit is the barrel plus one locale —
index.ts+en-US.ts= 1,129 tokens, already 158 over. So there is no split by locale. Splitting by notification: 971 − 76 for the barrel leaves 895, against a mean of ~263 tokens per notification across its four rows ⇒ 3 of 15, leaving 12 unconverted and the card open for five more rounds, each hitting the same ceiling.The three routes that actually resolve it, for whoever rules:
authored totalceiling. The gate's own header says raising takes a maintainer ruling quoted in the raising PR. ~143,000 would clear it with the ruled 5% buffer intact.src/translationsandsrc/dataoutside the collector. This is the same class of content the 2026-08-17 exclusion ruling names — 「translations + seed 肯定是不需要算 token 的」 — and the same rationale ("a fifth locale is healthy growth and must never compete with business logic for the budget"). It costs 0 ceiling:src/flows/went down.⛔ This seat is not choosing. Note also that the dispatch for this card carried no four-axis decision frame, so none was invented to dress the recommendation up as one.
维护者速读(草稿)
改了什么 — 15 个自动化通知节点从内联英文改为按名引用邮件模板包;新增
src/email-templates/,15 个通知 × 4 语言 = 60 行。zh-CN / ja-JP / es-ES 用户从此收到母语通知,按收件人各自的sys_user.locale在扇出后逐人解析。为什么改 — 2026-08-17 维护者裁决「立项,走 emailTemplates 路线」;其前置平台能力 objectstack#13881 已于 17.4.0 落地,本卡按 2026-08-31 裁决退化为纯元数据转换。
风险与代价(含回滚) — 两处红灯,均非本席可清:① token 棘轮超 2,864(转换本身 −190,全部成本在四语言文案);② 10 条断言通过内联⚠️ 英文行标
title观察通知,需改test/**,属 #1579 围栏。回滚 = 还原本 PR 单个提交,无数据迁移、无依赖变动。en-US而非en,否则en-US与第五语言收件人会永久死信。席位意见 — (待席位填写)
你要做的 — 在三条路线中裁一条:A 抬
authored total上限;B 裁定语言包内容与translations/data同属棘轮豁免;C 先转 3 个。⛔ 本席不代裁,也未自拟决策轴。🤖 Generated with Claude Code
https://claude.ai/code/session_0132iDHq4FLW2zS9VPXqbf9f
Generated by Claude Code