Skip to content

feat(flows): notify nodes speak the recipient's own language - #1949

Merged
os-elon-musk merged 1 commit into
mainfrom
claude/issue-1945-notify-templates-per-module
Sep 16, 2026
Merged

os-elon-musk merged 1 commit into
mainfrom
claude/issue-1945-notify-templates-per-module

Conversation

@os-elon-musk

Copy link
Copy Markdown
Collaborator

Part of #1945 — ⚠️ deliberately NOT a closing keyword.

Predecessor: PR #1893. That PR (another seat, 2026-09-11, no movement since) did this
same conversion against the pre-ADR-0130 tree, and its whole file surface went away with the
src/flows/ relayout. This is a re-landing of that work, not a rewrite, and it is a new PR:
⛔ nothing here drives, takes over, modifies or closes #1893.

What this does

Every notify node across sales, service and revenue stops carrying an inline English
title/message and references a sys_email_template bundle instead, supplying its record
values through templateData. The delivery path resolves (name, locale) per recipient,
after fan-out
— the recipient's own sys_user.locale, else the deployment default — so one
notification addressed to a Chinese-speaking rep and a Japanese-speaking manager arrives in two
languages.

Wording is unchanged. Every string is the text its node already sent, with the flow's
{var.path} interpolations re-expressed as the {{key}} holes the renderer fills from that
node's templateData. No notification says anything new.

⚠️ First deliverable — the cross-domain reference measurement

Result: zero cross-domain templates. Every one is one-to-one with a single owning package,
so the per-owner split the ruling prescribes needs no shared directory and no copy.

Method. The notify nodes were enumerated at RUNTIME, not by grep: the four package flow
barrels were imported and every node tree walked recursively, including loop bodies and the
try / catch regions of the guarded() containment (six of the nineteen nodes sit inside
one, and a flat file scan cannot see which flow owns them). Each node's content was then
grouped across packages on four independent axes, so a single normalisation choice cannot
manufacture the answer:

axis distinct groups groups spanning more than one package
title + message together 19 0
topic 18 0
title alone 19 0
message alone 19 0

The one axis that collapses (topic, 18 not 19) is lead_routing, carried by two nodes of the
same flow in the same package — so it does not span domains either, and those two get
distinct template names (crm.lead_routing_hot / crm.lead_routing_new) because their copy
differs.

Ruling ③ — where the files landed

Maintainer ruling, quoted verbatim and untranslated (#1920, 2026-09-16):

1920 src 下只能是模块,应该是按 owner 域拆进 src/{sales,revenue,service}/email-templates/

So there is ⛔ no src/email-templates/ at the root. The 19 templates split by the owner domain
of the flow that names them:

package templates rows (x4 locales) notify nodes converted
src/sales/email-templates/ 14 56 14
src/revenue/email-templates/ 3 12 3
src/service/email-templates/ 2 8 2
total 19 76 19

_bundle.ts — the one spelling of "a locale pack becomes rows" — lives in src/sales/ because
two other packages need it, which is the AGENTS.md import rule's own answer for a shared source
(lowest package all consumers depend on); service and revenue import it along their edge into
sales, exactly as flows/_guarded-iteration.ts and objects/_hook-api.ts are imported.

Registration goes through byExportName() in objectstack.composition.ts, so reaching the
package barrel IS reaching the registration — no hand-kept ordered array, because nothing about
the order is load-bearing here: a row is resolved by (name, locale) at delivery, never by
position. objectstack.config.ts hands the result to defineStack({ emailTemplates }).

⚠️ The English rows are tagged en-US, not en, and that is the resolution ladder's doing.
EmailService.resolveAndRenderTemplate matches (name, locale) exactly, then retries exactly
once against its own DEFAULT_TEMPLATE_LOCALE, the literal en-US. A recipient on this app's
default en therefore misses the first probe and lands on the second. Tagging the rows en
instead would invert that: en-US is a truthy preferred locale, so the third probe is skipped
and the send fails TEMPLATE_NOT_FOUND.

Token ratchet — measured per package, on all twelve rows

node scripts/check-source-token-ratchet.mjs → exit 0, twelve of twelve green. The new
directories land in the residual layer (other authored metadata), counted in each
package's authored total and in no headline layer — confirmed by the gate's own printed line.

package · authored total before after delta headroom left
src/sales 96,351 99,340 +2,989 660 / 100,000
src/revenue 17,485 18,279 +794 721 / 19,000
src/service 20,423 21,127 +704 873 / 22,000

⭐ The conversion itself gives tokens back; the four-locale content is the whole cost. The
business semantics layer (which is where flows/ is counted) went DOWN in all three packages
— sales 54,285 → 54,179, revenue 15,196 → 15,169, service 12,646 → 12,593, −186 in total —
because 19 inline title/message pairs are longer than 19 template + templateData pairs.
The gross cost of the new directories, through the gate's own authoredText() + tokensOf():
sales ~3,096 · revenue ~822 · service ~757.

⛔ No ceiling was raised. ⛔ No unrelated prose was compressed to buy room. ⛔ Nothing was filed
into an unmeasured directory to dodge the count: the content sits in email-templates/, which
is measured, and translations was not used as a hiding place.

The test/** fence — exactly the 10 assertions it was opened for

Ruling ② opened #1579's fence for the assertions that observe a notification through its inline
n.title — an implementation detail sys_email_template replaces, not a behaviour. All ten are
repointed at the templateData key carrying the same value, and nothing else under test/
changed
:

file assertion sites repointed to
test/flow-scheduled.test.ts 5 case_number · contract_number x2 · subject · name
test/flow-record-change.test.ts 4 name · first_name · last_name · subject
test/flow-sla-ownerless-assignment.test.ts 1 case_number, in the alertFor() correspondence filter

⚠️ A correction to the card's split, re-measured here. The card records these as 5 / 3 / 2.
That is the count of failing TEST CASES that PR #1893 observed; counted as ASSERTION SITES the
split is 5 / 4 / 1 — flow-record-change has two expect lines in one test (Ada and
Lovelace), and the SLA suite has one filter used by two tests. The total is 10 either way,
and the set of lines is the same set.

The audience, severity and "a template dot-walked a lookup" guards standing beside them are
untouched and still pass — deliveries were never broken by this conversion.

Reverse verification — the repointed assertions can still fail

A toContain reading a key that no longer exists would pass vacuously, so this was proved, not
argued. From the committed implementation, one render input in case-sla-monitor.flow.ts was
mutated (case_number fed from the case SUBJECT instead of the case number), proved to have
reached disk (anchor text 1 to 0, injected text 0 to 1, blob hash moved off the HEAD blob),
and the two suites re-run:

MUTATED   exit 1 — 2 files failed, 3 tests:
  flow-scheduled            > alerts the case owner, and only for the newly-breached case
  flow-sla-ownerless        > alerts the owner the assignment produced, not the empty slate...
  flow-sla-ownerless        > hands an already-owned breach over AND still alerts the owner...
RESTORED  exit 0 — 2 files passed

Restore is proved by bytes, not by an exit code: the restored blob hash equals the HEAD blob
hash (3c1537e4...) and git diff HEAD on that path is empty. The mutation is not in this PR.

Verification

pnpm verify — exit 0, captured into a file before any pipe read it:

$ pnpm verify > verify.log 2>&1; EXIT=$?
VERIFY_EXIT=0

Chain: validate · typecheck · lint · lint:i18n-gate · hygiene · hygiene:tokens ·
build · test. Verdict lines from the run: ✓ i18n lint gate: 0 i18n/missing-* issues ·
✓ source hygiene clean · ✓ source token ratchet clean · ✓ Build complete ·
Test Files 172 passed (172) · Tests 3685 passed | 1 skipped (3686). The 13 author-time
warnings the build prints are the pre-existing advisory set, unchanged by this PR.

Acceptance audit over the BUILT artifact (dist/objectstack.json), both directions on
every claim:

notify nodes in the artifact          22   (19 authored; 3 flows register twice)
  carrying `template`                 22
  carrying an inline title / message   0 / 0
sys_email_template rows               76   (19 names x 4 locales; 0 name short of its 4)
template references resolving to no template   0
templates referenced by no node                0
nodes with a placeholder no templateData supplies   0
nodes with a templateData key no row reads          0
templates whose locales disagree on their holes     0

The last row is worth its own line: it catches a translation that quietly dropped a {{hole}},
which no reference-closure check would see.

Changeset — minor

.changeset/notify-nodes-speak-the-recipients-language.md. Minor, not patch: this is
user-visible new behaviour, not a fix. Notifications that were English for everyone now arrive
in each recipient's own language, and the copy becomes administrator-editable in Studio as
sys_email_template rows instead of needing a code change. Not skip-changeset: the
compiled artifact gains 76 rows and 19 flows change shape, so this plainly ships something.
Not major: nothing an existing consumer depends on is removed or renamed.

What was measured rather than assumed

  • 19 notify nodes, not 18; 19 templates, not 15. Both card figures are PR feat(flows): localize every notify node through four-locale template bundles #1893's readings
    on the pre-migration tree, and Track A has landed since. The four extra nodes are
    account-approval.flow.ts (2) and lead-conversion-approval.flow.ts (2).
  • The flow file surface, re-counted on 9531cde: src/sales/flows/ holds 14 *.flow.ts
    of which 10 carry a notify node (the card's own comment already corrected 8 to 10);
    src/revenue/flows/ 5 of which 3; src/service/flows/ 4 of which 2. 15 files
    touched, 19 nodes.
  • 22 registered nodes vs 19 authored, because opportunity-approval, lead-conversion-approval's
    siblings and case-escalation each register two flows from one file. The artifact audit
    counts 22 and all 22 carry a template.
  • Registration is byExportName(), not an ordered array — checked against
    test/helpers/registration-lists.ts rather than assumed: the five hand-kept ordered lists are
    allHooks / allFlows / allSkills / CrmSharingRules / CrmSeedData, and this collection
    is not one of them.
  • ⛔ src/marketing/ is untouched (it authors no notify node) and so is content/docs/releases/.

Generated by Claude Code

Every `notify` node across sales, service and revenue stops carrying an
inline English `title`/`message` and references a `sys_email_template`
bundle instead, supplying its record values through `templateData`.
The delivery path resolves `(name, locale)` per recipient AFTER fan-out,
off that person's own `sys_user.locale`, so one notification addressed to
two people with different languages arrives in two languages.

19 templates x 4 locales = 76 rows, split by OWNER DOMAIN into
`src/{sales,revenue,service}/email-templates/` — 14 / 3 / 2. Measured
first: no template is referenced from more than one package, on four
independent axes, so the split needs no shared directory.

Wording is unchanged throughout: every string is the text its node
already sent, with the flow's `{var.path}` interpolations re-expressed as
the `{{key}}` holes the renderer fills from `templateData`.

Ten assertions that observed a notification through its inline `n.title`
are repointed at the `templateData` key carrying the same value; the
audience, severity and dot-walk guards beside them are untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3YsvpK1PvYf9n1YUhYP6W
@vercel

vercel Bot commented Sep 16, 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 16, 2026 9:27am UTC

Request Review

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 ci/cd CI plumbing and the verification pipeline configuration Build and app configuration files i18n

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants