Uh oh!
There was an error while loading. Please reload this page.
spec(automation): notify template locale is the deployment default, not per-recipient - #12447
Conversation
… not per-recipient `NotifyConfigSchema`'s doc block, the `template` field JSDoc/`.describe()`, and both `superRefine` refusal messages said the delivery path resolves `(name, recipient locale)` "per recipient" and "renders subject/body per recipient". The delivery path deliberately does not: `sys_user` carries no locale column and request-scoped locale does not exist at async delivery time, so the locale is `payload.locale` (interpolated once, before fan-out) or the deployment default `II18nService.getDefaultLocale()` — one value for the whole notification. `service-messaging/src/email-channel.ts` already documents this honestly; spec was the one place it was unqualified. Per the maintainer ruling of 2026-08-13 the behaviour is settled (no per-user locale until measured pull), so the prose moves. All five sites in the file now name the resolved value and date the deferral. The two test pins that asserted the old `/recipient locale/` string now assert the qualification and refuse a bare "recipient locale". Text only — no acceptance, refusal or delivery behaviour changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 126 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 ae41fbca5c44d792c4bb876b4e4a2cc8c73ee466 && git checkout ae41fbca5c44d792c4bb876b4e4a2cc8c73ee466
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8515954fb6dd4102e120b59ce6410f94600d5710 482326807ba69c19c481da1c464e1968d8914cff && git checkout -B drift-repro 8515954fb6dd4102e120b59ce6410f94600d5710 && git merge --no-ff 482326807ba69c19c481da1c464e1968d8914cff
node scripts/docs-audit/affected-docs.mjs --json 8515954fb6dd4102e120b59ce6410f94600d5710 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12178
NotifyConfigSchema's prose promised per-recipient locale resolution that the delivery path deliberately does not do. Per the maintainer ruling of 2026-08-13 the behaviour is the settled side — no per-user locale until measured pull — so the prose is the side that moves. Text only: no schema accepts or refuses anything it did not before, and no delivery behaviour is touched.The drift
The doc block and the
template.describe()said the delivery path resolves(name, recipient locale)per recipient and "renders subject/body per recipient". Read plainly — and this is the text a consuming app's author reads — that says the recipient's own language selects the template row.It does not.
service-messaging/src/email-channel.tsL86-99 has documented the truth honestly all along:sys_usercarries no locale column and request-scoped locale (Accept-Language→ExecutionContext.requestLocale) does not exist at async delivery time, so "recipient locale" resolves to the deployment default,II18nService.getDefaultLocale(). The one lever ispayload.locale, and it is interpolated once, before fan-out, so it is a single value for the whole notification.That gap was not cosmetic. The wording licensed exactly one conclusion — "convert the nodes and non-English users get non-English notifications" — which is false, and acting on it is a net regression:
TEMPLATE_*failures classifypermanentand dead-letter, and the inbox channel starts requiring an email service withrenderTemplate()where inline text needed none.Sites corrected — all five, not the two the card named
The card named two sites; a re-grep at claim time found five, and the sweep had to be consistent across the file or the corrected text would sit next to uncorrected copies of the same promise.
grep -c "recipient locale"on the file is now 0.NotifyConfigSchemadoc block — the localization-contract bullettemplatefield JSDoctemplate.describe()— the authored surface, and the one projected to docssuperRefinerefusal:templatecombined with inlinetitle/messagesuperRefinerefusal: neither content source givenEach now names the resolved value (
payload.localeif the producer set one, else the deployment defaultII18nService.getDefaultLocale()), states that it is one value per notification, not one per recipient, and dates the 2026-08-13 deferral in place so the limitation reads as a decision with provenance rather than a permanent property of the design — a per-user locale layers in as an override at that same seam when it lands. Modelled onemail-channel.ts's own wording, as the card asked. No hedging: the triage constraint was that declared must equal enforced in the text an author reads, so the text states the semantics rather than going vague about them.Tests
io-node-config.test.tsL277/L303 asserted/recipient locale/against these strings. Those pins moved onto the new truthful text rather than being deleted — and were tightened, since the old assertion would pass against either wording:A future edit that restores the bare promise now turns these RED.
Reverse-verification (expected direction: RED, and that is what was observed). The fix was committed first, so the restore had a real recovery point. The pre-#12178
.describe()text was restored on disk with the new assertions left in place:failing on exactly the intended pin:
Restore proven, not assumed — the mutated path was restored by naming
HEADexplicitly:never the bare two-dash form with no ref, which reads from the index the mutation may itself have written. Proof was then a
git hash-objectcomparison back to the HEAD blobb8e2344fand an emptygit diff HEAD. The script carried a trap on EXIT/INT/TERM using absolute paths, but the hash comparison is the proof; the trap is only crash-path convenience.One test fails rather than several because only the
.describe()was mutated, not the refusal messages. No rebuild leg applies — the test imports./io-node-config.zod.jssibling-relative fromsrc, so nothing resolves throughexports/dist.Generated projection
content/docs/references/automation/io-node-config.mdxregenerated viapnpm --filter @objectstack/spec gen:docs(never hand-edited); the diff is exactly the one table cell, 1 insertion / 1 deletion.gen:schemahad to run first — it is a gitignored prerequisite tree, andcheck:docsrefuses rather than rendering from a stale one.Changeset
@objectstack/spec: patch— following the describe/gloss-only precedent already in the tree (e.g.delivery-not-eligible-gloss-both-surfaces.md):packages/specpublishessrc/**/*.zod.ts, so corrected prose ships to consumers. Notskip-changeset.Gates — union run at
4823268, the final commit, on a clean treeGate families derived mechanically from the real 4-path changeset (
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack), not from memory. 47 run, all green, includingspec typecheck, the fullspecvitest package run,check:docs,check:generated,check:authorable-surface,check:skill-examples,check:liveness,check:nul-bytes,check:cross-package-test-inputs,check:merge-driver,check:engine-double-contract,check:where-matcher, and the doc/changeset families.Six of those first reported exit 1 as PREREQUISITE NOT MET on an unbuilt workspace, each printing its own "Nothing was measured … it is NOT a finding". They were re-run as real measurements after building
spec,formula,lint,client-reactandclient, and all five re-runnable ones went green:Declared narrowing:
check-dev-prereqsis not included in that green union. It reports "67 of 67 workspace packages declare an entry point under dist/ that is not on disk" — a statement about an unbuilt checkout, requiring a fullpnpm build, which is a repo-scale run CI owns. Its output says nothing about this diff. Same forcheck:type-check-debt --re-measure, which refuses without the built workspace closure;spec typecheck(which includescheck:test-typecheck, and so covers the edited test file) is green.Exit codes were captured before any pipe, and each verdict above is the gate's own printed result, not a bare
$?.Out of scope, filed not fixed
#12446 — the same promise survives in four sites outside
packages/spec, including the Studio form field description inservice-automation/src/builtin/notify-node.tsL197 andcontent/docs/automation/email-templates.mdxL164-166, which states outright that "one node mails each person in their own language". Filed unassigned, back-linked, dedup-searched first. Note for the record: the log line #12178 callsservice-messaging/src/plugin.tsis actually atmessaging-service-plugin.tsL250 — nosrc/plugin.tsexists in the tree.Generated by Claude Code