Skip to content

feat(flows): localize every notify node through four-locale template bundles - #1893

Closed
claude[bot] wants to merge 1 commit into
mainfrom
claude/issue-1185-notify-template-conversion
Closed

claude[bot] wants to merge 1 commit into
mainfrom
claude/issue-1185-notify-template-conversion

Conversation

@claude

@claude claude Bot commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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 notify node in src/flows/ stops carrying inline English title/message and references a sys_email_template bundle instead, supplying its record values through templateData. 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 own sys_user.locale with the deployment default beneath it. So one notification really does arrive in two languages for two people. No dependency moved; no file under test/ or src/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 against templateData. A conversion, not a rewrite — no notification says anything new.

🔴 Blocker 1 — the token ratchet, measured

hygiene:tokens is a shrink-only ratchet and this card adds four locale bundles. Measured with the gate itself, before and after:

BEFORE  (da592c23, untouched)   authored total ~139,029 / 140,000   headroom ~971    exit 0
AFTER   (234f0d0f, this PR)     authored total ~142,864 / 140,000   OVER BY ~2,864   exit 1

Where the 3,835 tokens went, per file, through the gate's own authoredText() + tokensOf():

scope tokens
src/email-templates/en-US.ts +1,053
src/email-templates/zh-CN.ts +832
src/email-templates/ja-JP.ts +908
src/email-templates/es-ES.ts +1,155
src/email-templates/index.ts +76
src/flows/ — inline strings removed, template/templateData added −190

⭐ The conversion is not what costs anything. Dropping 15 inline title/message pairs and adding 15 template + templateData pairs 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 — EmailTemplateDefinition carries one locale per 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/translations and src/data are outside the collector by ruling. Locale content cannot land there: TranslationDataSchema is z.core.$strict and has no email-template face —

TranslationData accepts emailTemplates face: false
  -> unrecognized_keys: ["emailTemplates"]

Filing emailTemplates metadata under src/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 title

pnpm test: 11 failed / 3,444 passed, in 4 files. One is downstream of blocker 1 (test/source-token-ratchet.test.ts pins the gate green). The other 10 are one class:

file failures
test/flow-scheduled.test.ts 5
test/flow-record-change.test.ts 3
test/flow-sla-ownerless-assignment.test.ts 2

They observe a notification's content by reading n.title off the harness's captured messaging.emit payload — e.g. expected 'undefined' to contain 'CASE-1', and alertFor() in the SLA suite filters h.notifications on String(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 in payload.title — by design, that is the whole point of the conversion — so a title-based filter now matches nothing. Repointing those assertions at template / templateData is a test/** edit, which is epic #1579's fence this wave.

What was measured rather than assumed

  • 15 notify nodes across 13 files, not 16/14. Re-derived at da592c23 two ways (git grep over the src/flows/ directory pathspec, and git ls-tree -r piped to grep as a different-mechanism control) — both 15. The missing one is src/flows/case-csat-followup.flow.ts, retired since; src/views/case.view.ts records the retirement. ⚠️ The 'src/flows/**/*.ts' pathspec returns 0 here: every flow file sits directly under src/flows/.
  • 18 nodes in the built artifact, because case_escalation and the two approval flows each register twice. All 18 carry template; 0 carry inline strings. All 15 template references resolve; no bundle is unreferenced.
  • Every {{placeholder}} in all 60 rows is supplied by its node's templateData, and no templateData key is unused — cross-checked over dist/objectstack.json.
  • Exclusivity re-measured on the installed 17.4.0, not read off a .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.
  • ⚠️ "dedupe: several nodes share a shape" is also falsified. All 15 carry distinct copy. They share a structural shape, not text, and merging any two would mean rewriting a notification's wording — so the deduped set is 15, not fewer.

⚠️ Why the English rows are tagged en-US and not en

EmailService.resolveAndRenderTemplate matches (name, locale) exactly and retries exactly once against DEFAULT_TEMPLATE_LOCALE, the literal 'en-US'. There is no language-subtag folding anywhere on that path. This app's i18n.defaultLocale is 'en', so the obvious spelling — the one the card's Scope line and supportedLocales both suggest — is 'en', and it is wrong three ways:

recipient locale: 'en' bundle locale: 'en-US' bundle
no sys_user.locale → deployment default 'en' ✅ ✅ via the en-US retry
column says 'en-US' ❌ TEMPLATE_NOT_FOUND ✅
a fifth locale ('fr-FR') ❌ TEMPLATE_NOT_FOUND ✅

TEMPLATE_NOT_FOUND classifies permanent — it dead-letters without retry. en-US is the ladder's floor, which is exactly what the spec's own contract note says from the other side.

Gates

gate exit note
validate 0 8 pre-existing author-time warnings, none about notify or emailTemplates
typecheck 0
lint 0 4 warnings / 12 suggestions, all pre-existing
lint:i18n-gate 0 0 i18n/missing-*
hygiene 0
hygiene:tokens 1 blocker 1 — over by ~2,864
build 0 artifact carries 60 rows over 15 names, locales en-US zh-CN ja-JP es-ES
test 1 blocker 2 — 11 failed / 3,444 passed

Exit 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:

  • A — raise the authored total ceiling. 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.
  • B — rule that authored locale bundles join src/translations and src/data outside 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.
  • C — convert 3 of 15 now. Fits, and leaves a half-localized app plus a standing invitation to the next agent to copy a dead shape.

⛔ 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

…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
@vercel

vercel Bot commented Sep 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
hotcrm Ignored Ignored Sep 11, 2026 3:50am UTC

Request Review

@github-actions github-actions Bot added configuration Build and app configuration files backend Server-side behaviour — hooks, flows, actions labels Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Seat note on the red Quality Checks — ⛔ the seat is NOT touching this branch. The dispatching order pre-specified this exact outcome, and the dev owns the next move.

Quality Checks failed on 234f0d0f at the source token ratchet, which is the hazard this card was dispatched with an explicit hard budget for:

authored total       ~142,864   ceiling ~140,000   ✗ over by ~2,864
business semantics    ~85,609   ceiling ~100,000   ✓ headroom ~14,391
interaction layer     ~38,585   ceiling  ~40,000   ✓ headroom  ~1,415

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 here

The 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 reshaping

The gate prints its own exclusion:

outside the ratchet by ruling (src/translations, src/data): 45 files, ~93,003 tokens
  — healthy growth, never measured here.

and lists src/email-templates among the directories "not in a headline layer (counted in the total only)" — i.e. inside the measured total.

⇒ the first question is placement, not size. If four-locale template content can legitimately live under src/translations — where this repo's ruling already says locale content belongs and growth is expressly healthy — the cost may fall to near zero without compressing anything. If it cannot, then the overage is real and the honest output is a stop with a number and a proposed split, which is a good result for this card, ⛔ not a failure.

⚠️ Either way that is a placement decision with a ruling behind it, so it gets measured and reported, ⛔ not guessed at by reshaping until the number goes green.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend Server-side behaviour — hooks, flows, actions configuration Build and app configuration files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants