Uh oh!
There was an error while loading. Please reload this page.
feat(automation): notify nodes reference email templates for localized delivery — resolve (name, recipient locale) at delivery time - #9224
Merged
Conversation
…es for localized delivery (#9205)
Contributor
📓 Docs Drift CheckThis PR changes 3 package(s): 106 hand-written doc(s) reference the affected code — list omitted above 15 rows. Re-derive: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.
|
This was referenced Aug 17, 2026
os-zhuang
marked this pull request as ready for review
August 17, 2026 03:49
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#9205
Bridges flow
notifynodes to the existing localized email-template subsystem, per the maintainer ruling recorded on the card (verbatim, untranslated): 「立项,走 emailTemplates 路线」 — a template reference on the node, not a widening of theflowstranslation surface (which would overturn #7646;translation.zod.tsis untouched).Spec contract (
packages/spec/src/automation/io-node-config.zod.ts)NotifyConfigSchemagains the localizable content path:template— asys_email_templatename (e.g.crm.large_deal_won). Read raw liketopic/channels(a static metadata cross-reference, never interpolated). The describe states the localization contract plainly: resolved by(name, recipient locale)at delivery time; inline strings are the non-localizable path.templateData— render context for the template's{{var}}holes; values interpolate{token}templates per run.titlebecomes.optional()only in the type — the acceptance set for existing authors is unchanged: a config with neithertitlenortemplateis still refused (previouslyinvalid_typeontitle, now acustomissue at['title']naming both options). Inline-only configs parse byte-identically (pinned).Dual-path decision — loud refusal, not precedence. Measured precedents:
objectNavTargetExclusivity(ui/app.zod.ts) refusesfilters+recordId/viewNamewith "the ambiguous combination is unrepresentable rather than silently resolved by precedence";time-relative-trigger.zod.tsrefuses on "exactly one ofwithinDays/offsetDays"; the counter-precedent (screen's two shapes, prose-only) predates both and enforces nothing. Followed the strongest: a node carrying bothtemplateand inlinetitle/messageis refused bysuperRefineat['template'], naming both keys, which to keep for localization, and why (runtime precedence would silently ignore one).templateDatawithouttemplateis likewise refused (nothing would ever read it).Template-name referential validation — tier measured, none invented. The in-family analog is a flow node's by-name reference to another flow (
subflow/map.flowName): it validates nowhere at authoring time — no lint rule, andvalidateCrossReferences(defineStack) does not descend into node configs. Matching that tier,templategets no new authoring-time existence check; enforcement is at delivery (TEMPLATE_NOT_FOUNDon the delivery row — made loud and terminal, below). This also matches the semantics of the reference:sys_email_templaterows are tenant-authorable in Studio and plugin-shipped (the auth bundle), so stack-time membership would refuse valid deployments.Delivery-time resolution (declare = enforce, same PR — the #8993 standing pin)
builtin/notify-node.ts): the executor forwardstemplate(raw) + interpolatedtemplateDatain the emit payload — which the outbox snapshots onto eachsys_notification_deliveryrow, so the durable (P1) path carries it identically to inline (P0). The "title is required" execute guard is skipped when a template is referenced. On the template path no inlinetitle/bodypayload keys are set: channels without template support fall back to the topic (honest degraded rendering, not an empty string). The descriptorconfigSchemamirrors the two new keys; the form↔Zod ledger (io-node-form-zod-ledger.test.ts) reconciles both directions and is green.email-channel.ts): a delivery whose payload carriestemplateroutes throughIEmailService.sendTemplate({ template, to, data, locale })— the existing seam, structurally mirrored as an optionalsendTemplateonEmailSenderSurface. An email service without it fails the delivery loudly (TEMPLATE_UNSUPPORTEDon the row) instead of silently downgrading to unlocalized content.sendTemplate's own failure vocabulary (TEMPLATE_NOT_FOUND/TEMPLATE_INACTIVE/MISSING_VARIABLES) surfaces verbatim on the delivery row, andclassifyErrorgrades all four permanent — dead immediately, matching "wrong metadata cannot be retried into working" — while transport failures (status: 'failed') stay retryable.Recipient locale source — measured, and its limits stated. The platform has no per-user locale:
sys_usercarries no locale column (the 2026-08-13 ruling defers one until measured pull), and request-scoped locale (Accept-Language→ExecutionContext.requestLocale) does not exist at async delivery time. The measured source used bysendTemplate's existing callers (auth emails, #8195) is the deployment default —II18nService.getDefaultLocale(), probed lazily at delivery so it tracks livelocalization.localechanges. The email channel resolves:payload.locale(producer override) → deployment default → unset (⇒sendTemplate's documenteden-USladder). When a per-user locale lands,EmailChannelOptions.getDefaultTemplateLocaleis the seam it plugs into; until then every recipient of one deployment gets the deployment's language — better than English-only, not yet per-reader.Inbox channel: gap documented (the card's "ideally" leg)
Measured: the inbox channel does not render templates at all today — it writes
sys_inbox_messagerows straight from the notification title/body and is not wired toNotificationTemplateStore(which is keyed by(topic, channel, locale), a different subsystem from email templates anyway). There is no existing seam that resolves-and-renders asys_email_templatewithout sending mail:IEmailServiceexposes onlysend/sendTemplate, and the locale ladder +{{var}}renderer (with ADR-0053 format filters) live inside plugin-email. Wiring the inbox would therefore mean either a newIEmailServicerender method (new contract surface) or a duplicated resolver/renderer in service-messaging (a second de-facto contract that drifts) — both are the "forced new subsystem" the card rules out. Consequence, precisely: a template-path notify withchannels: ['inbox', 'email']delivers a localized email, and an inbox row whose title is the topic and whose body is empty. Follow-up is the PM's call; the natural shape is a small render-only method onIEmailService.Tests
io-node-config.test.ts): template-only accepted (measured RED pre-change —templatewas an unrecognized key); inline-only accepted unchanged; both-paths combo refused (asserted bycode: 'custom'+path, never baresuccess === false, with the prescription's load-bearing phrases pinned);templateData-without-templaterefused; neither-path refused; the localization contract pinned in the describes.notify-node.test.ts): template + interpolatedtemplateDatareachemit()with no inline content keys; the both-paths combo refuses the step at the parse seam; all pre-existing inline-path pins unchanged.email-channel.test.ts): template + data + locale reachsendTemplateandsend()is never consulted; producerpayload.localewins over the deployment default; absent both ⇒ nolocalepassed (sendTemplate's ownen-USladder);sendTemplate-less service ⇒ loudTEMPLATE_UNSUPPORTED, graded permanent;TEMPLATE_NOT_FOUNDsurfaces verbatim, graded permanent; transportstatus:'failed'stays retryable; a payload withouttemplatekeeps the pre-change path byte-identically.Reverse verification (fix committed first; implementation files restored from
origin/mainwithgit restore --source, tests kept, then restored): spec contract ablated ⇒ the 7 new spec cases go red (template refused as unrecognized key; the at-least-one case falls back toinvalid_type) and all 21 pre-existing cases stay green;email-channel.tsablated ⇒ 6 of the 7 new channel cases go red (delivery falls into the legacy send path / no loud refusal) while the 7th — the no-template preservation pin — stays green by design, and all 7 pre-existing cases stay green;notify-node.tsablated ⇒ both new executor cases go red (2 red / 13 green). Direction observed matched the pre-declared expectation in all three legs.Verification
pnpm --filter @objectstack/spec build+ fullpnpm --filter @objectstack/spec test(407 files / 10834 tests) /typecheck— greenpnpm --filter @objectstack/service-automation test(80 files / 964 tests) and--filter @objectstack/service-messaging test(22 files / 236 tests) +service-messaging typecheck— green (service-automationis typecheck-debt-exempt by ledger)check:generatedregenerated: docs references + authorable-surface shards (gen:schema,gen:docs)node scripts/pm/dispatch-gates.mjsat the final head — union re-run at head2f80ce13c(clean tree) — all green; see the report commentChangeset
notify-node-email-template-locale-bridge.md— minor for@objectstack/spec,@objectstack/service-automation,@objectstack/service-messaging(declared-key addition, #9113 placeholder precedent; pure widening ⇒ no ADR-0087 marker).Cross-repo: the hotcrm consumer card (16 notify nodes × 4 locales) stays
Blocked-by:#9205 and unlocks when this lands. hotcrm is untouched here.Generated by Claude Code