spec(automation): notify template locale is the deployment default, not per-recipient - #12447

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording
Aug 26, 2026
Merged

spec(automation): notify template locale is the deployment default, not per-recipient#12447
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording

Conversation

@huangyiirene

@huangyiirenehuangyiirene commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

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.ts L86-99 has documented the truth honestly all along: sys_user carries no locale column and request-scoped locale (Accept-LanguageExecutionContext.requestLocale) does not exist at async delivery time, so "recipient locale" resolves to the deployment default, II18nService.getDefaultLocale(). The one lever is payload.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 classify permanent and dead-letter, and the inbox channel starts requiring an email service with renderTemplate() 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.

Line (pre-change)What it is
:135NotifyConfigSchema doc block — the localization-contract bullet
:181template field JSDoc
:188template.describe() — the authored surface, and the one projected to docs
:251superRefine refusal: template combined with inline title/message
:272superRefine refusal: neither content source given

Each now names the resolved value (payload.locale if the producer set one, else the deployment default II18nService.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 on email-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.ts L277/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:

expect(templateDoc).toMatch(/\(name,locale\)/);expect(templateDoc).not.toMatch(/recipientlocale/);expect(templateDoc).toMatch(/deploymentdefault/);expect(templateDoc).toContain('II18nService.getDefaultLocale()');expect(templateDoc).toMatch(/notoneperrecipient/);expect(templateDoc).toContain('2026-08-13');

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:

anchor hits before mutation (expect 1): 1
anchor hits after (expect 0): 0
old text hits after (expect 1): 1
post-mutation hash: 5211ac23… (pre: b8e2344f…)
MUTATION CONFIRMED ON DISK
ablation vitest exit: 1
Test Files 1 failed (1)
Tests 1 failed | 27 passed (28)

failing on exactly the intended pin:

AssertionError: expected 'Email template name (`sys_email_templ…' to match /\(name, locale\)/
❯ src/automation/io-node-config.test.ts:321:27

Restore proven, not assumed — the mutated path was restored by naming HEAD explicitly:

git checkout HEAD -- "$REPO_ROOT/packages/spec/src/automation/io-node-config.zod.ts"

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-object comparison back to the HEAD blob b8e2344fand an empty git 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.js sibling-relative from src, so nothing resolves through exports/dist.

Generated projection

content/docs/references/automation/io-node-config.mdx regenerated via pnpm --filter @objectstack/spec gen:docs (never hand-edited); the diff is exactly the one table cell, 1 insertion / 1 deletion. gen:schema had to run first — it is a gitignored prerequisite tree, and check:docs refuses 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/spec publishes src/**/*.zod.ts, so corrected prose ships to consumers. Not skip-changeset.

Gates — union run at 4823268, the final commit, on a clean tree

Gate 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, including spec typecheck, the full spec vitest 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-react and client, and all five re-runnable ones went green:

spec check:docs exit=0
spec check:generated exit=0
spec check:skill-examples exit=0 → ✅ 260 prose examples type-check across 3 surface(s)
lint check:doc-formula-expressions exit=0
lint check:doc-security-posture exit=0

Declared narrowing:check-dev-prereqs is 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 full pnpm build, which is a repo-scale run CI owns. Its output says nothing about this diff. Same for check:type-check-debt --re-measure, which refuses without the built workspace closure; spec typecheck (which includes check: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 in service-automation/src/builtin/notify-node.ts L197 and content/docs/automation/email-templates.mdx L164-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 calls service-messaging/src/plugin.ts is actually at messaging-service-plugin.ts L250 — no src/plugin.ts exists in the tree.


Generated by Claude Code

… 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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8515954fb6dd4102e120b59ce6410f94600d5710packageMentionDocs.

Which tree this was computed on

This run read content/docs from ae41fbca5c44d792c4bb876b4e4a2cc8c73ee466 — the merge of head 482326807ba69c19c481da1c464e1968d8914cff into base 8515954fb6dd4102e120b59ce6410f94600d5710, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 26, 2026 04:43
@huangyiirene
huangyiirene added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 4297fe7Aug 26, 2026
38 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-12178-notify-locale-wording branch August 26, 2026 05:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

spec(automation): notify template locale is the deployment default, not per-recipient - #12447

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording
Aug 26, 2026
Merged

spec(automation): notify template locale is the deployment default, not per-recipient#12447
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording

Conversation

@huangyiirene

@huangyiirenehuangyiirene commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

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.ts L86-99 has documented the truth honestly all along: sys_user carries no locale column and request-scoped locale (Accept-LanguageExecutionContext.requestLocale) does not exist at async delivery time, so "recipient locale" resolves to the deployment default, II18nService.getDefaultLocale(). The one lever is payload.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 classify permanent and dead-letter, and the inbox channel starts requiring an email service with renderTemplate() 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.

Line (pre-change)What it is
:135NotifyConfigSchema doc block — the localization-contract bullet
:181template field JSDoc
:188template.describe() — the authored surface, and the one projected to docs
:251superRefine refusal: template combined with inline title/message
:272superRefine refusal: neither content source given

Each now names the resolved value (payload.locale if the producer set one, else the deployment default II18nService.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 on email-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.ts L277/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:

expect(templateDoc).toMatch(/\(name,locale\)/);expect(templateDoc).not.toMatch(/recipientlocale/);expect(templateDoc).toMatch(/deploymentdefault/);expect(templateDoc).toContain('II18nService.getDefaultLocale()');expect(templateDoc).toMatch(/notoneperrecipient/);expect(templateDoc).toContain('2026-08-13');

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:

anchor hits before mutation (expect 1): 1
anchor hits after (expect 0): 0
old text hits after (expect 1): 1
post-mutation hash: 5211ac23… (pre: b8e2344f…)
MUTATION CONFIRMED ON DISK
ablation vitest exit: 1
Test Files 1 failed (1)
Tests 1 failed | 27 passed (28)

failing on exactly the intended pin:

AssertionError: expected 'Email template name (`sys_email_templ…' to match /\(name, locale\)/
❯ src/automation/io-node-config.test.ts:321:27

Restore proven, not assumed — the mutated path was restored by naming HEAD explicitly:

git checkout HEAD -- "$REPO_ROOT/packages/spec/src/automation/io-node-config.zod.ts"

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-object comparison back to the HEAD blob b8e2344fand an empty git 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.js sibling-relative from src, so nothing resolves through exports/dist.

Generated projection

content/docs/references/automation/io-node-config.mdx regenerated via pnpm --filter @objectstack/spec gen:docs (never hand-edited); the diff is exactly the one table cell, 1 insertion / 1 deletion. gen:schema had to run first — it is a gitignored prerequisite tree, and check:docs refuses 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/spec publishes src/**/*.zod.ts, so corrected prose ships to consumers. Not skip-changeset.

Gates — union run at 4823268, the final commit, on a clean tree

Gate 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, including spec typecheck, the full spec vitest 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-react and client, and all five re-runnable ones went green:

spec check:docs exit=0
spec check:generated exit=0
spec check:skill-examples exit=0 → ✅ 260 prose examples type-check across 3 surface(s)
lint check:doc-formula-expressions exit=0
lint check:doc-security-posture exit=0

Declared narrowing:check-dev-prereqs is 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 full pnpm build, which is a repo-scale run CI owns. Its output says nothing about this diff. Same for check:type-check-debt --re-measure, which refuses without the built workspace closure; spec typecheck (which includes check: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 in service-automation/src/builtin/notify-node.ts L197 and content/docs/automation/email-templates.mdx L164-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 calls service-messaging/src/plugin.ts is actually at messaging-service-plugin.ts L250 — no src/plugin.ts exists in the tree.


Generated by Claude Code

… 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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8515954fb6dd4102e120b59ce6410f94600d5710packageMentionDocs.

Which tree this was computed on

This run read content/docs from ae41fbca5c44d792c4bb876b4e4a2cc8c73ee466 — the merge of head 482326807ba69c19c481da1c464e1968d8914cff into base 8515954fb6dd4102e120b59ce6410f94600d5710, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 26, 2026 04:43
@huangyiirene
huangyiirene added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 4297fe7Aug 26, 2026
38 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-12178-notify-locale-wording branch August 26, 2026 05:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

spec(automation): notify template locale is the deployment default, not per-recipient - #12447

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording
Aug 26, 2026
Merged

spec(automation): notify template locale is the deployment default, not per-recipient#12447
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording

Conversation

@huangyiirene

@huangyiirenehuangyiirene commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

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.ts L86-99 has documented the truth honestly all along: sys_user carries no locale column and request-scoped locale (Accept-LanguageExecutionContext.requestLocale) does not exist at async delivery time, so "recipient locale" resolves to the deployment default, II18nService.getDefaultLocale(). The one lever is payload.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 classify permanent and dead-letter, and the inbox channel starts requiring an email service with renderTemplate() 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.

Line (pre-change)What it is
:135NotifyConfigSchema doc block — the localization-contract bullet
:181template field JSDoc
:188template.describe() — the authored surface, and the one projected to docs
:251superRefine refusal: template combined with inline title/message
:272superRefine refusal: neither content source given

Each now names the resolved value (payload.locale if the producer set one, else the deployment default II18nService.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 on email-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.ts L277/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:

expect(templateDoc).toMatch(/\(name,locale\)/);expect(templateDoc).not.toMatch(/recipientlocale/);expect(templateDoc).toMatch(/deploymentdefault/);expect(templateDoc).toContain('II18nService.getDefaultLocale()');expect(templateDoc).toMatch(/notoneperrecipient/);expect(templateDoc).toContain('2026-08-13');

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:

anchor hits before mutation (expect 1): 1
anchor hits after (expect 0): 0
old text hits after (expect 1): 1
post-mutation hash: 5211ac23… (pre: b8e2344f…)
MUTATION CONFIRMED ON DISK
ablation vitest exit: 1
Test Files 1 failed (1)
Tests 1 failed | 27 passed (28)

failing on exactly the intended pin:

AssertionError: expected 'Email template name (`sys_email_templ…' to match /\(name, locale\)/
❯ src/automation/io-node-config.test.ts:321:27

Restore proven, not assumed — the mutated path was restored by naming HEAD explicitly:

git checkout HEAD -- "$REPO_ROOT/packages/spec/src/automation/io-node-config.zod.ts"

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-object comparison back to the HEAD blob b8e2344fand an empty git 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.js sibling-relative from src, so nothing resolves through exports/dist.

Generated projection

content/docs/references/automation/io-node-config.mdx regenerated via pnpm --filter @objectstack/spec gen:docs (never hand-edited); the diff is exactly the one table cell, 1 insertion / 1 deletion. gen:schema had to run first — it is a gitignored prerequisite tree, and check:docs refuses 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/spec publishes src/**/*.zod.ts, so corrected prose ships to consumers. Not skip-changeset.

Gates — union run at 4823268, the final commit, on a clean tree

Gate 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, including spec typecheck, the full spec vitest 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-react and client, and all five re-runnable ones went green:

spec check:docs exit=0
spec check:generated exit=0
spec check:skill-examples exit=0 → ✅ 260 prose examples type-check across 3 surface(s)
lint check:doc-formula-expressions exit=0
lint check:doc-security-posture exit=0

Declared narrowing:check-dev-prereqs is 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 full pnpm build, which is a repo-scale run CI owns. Its output says nothing about this diff. Same for check:type-check-debt --re-measure, which refuses without the built workspace closure; spec typecheck (which includes check: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 in service-automation/src/builtin/notify-node.ts L197 and content/docs/automation/email-templates.mdx L164-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 calls service-messaging/src/plugin.ts is actually at messaging-service-plugin.ts L250 — no src/plugin.ts exists in the tree.


Generated by Claude Code

… 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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8515954fb6dd4102e120b59ce6410f94600d5710packageMentionDocs.

Which tree this was computed on

This run read content/docs from ae41fbca5c44d792c4bb876b4e4a2cc8c73ee466 — the merge of head 482326807ba69c19c481da1c464e1968d8914cff into base 8515954fb6dd4102e120b59ce6410f94600d5710, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 26, 2026 04:43
@huangyiirene
huangyiirene added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 4297fe7Aug 26, 2026
38 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-12178-notify-locale-wording branch August 26, 2026 05:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

spec(automation): notify template locale is the deployment default, not per-recipient - #12447

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording
Aug 26, 2026
Merged

spec(automation): notify template locale is the deployment default, not per-recipient#12447
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording

Conversation

@huangyiirene

@huangyiirenehuangyiirene commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

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.ts L86-99 has documented the truth honestly all along: sys_user carries no locale column and request-scoped locale (Accept-LanguageExecutionContext.requestLocale) does not exist at async delivery time, so "recipient locale" resolves to the deployment default, II18nService.getDefaultLocale(). The one lever is payload.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 classify permanent and dead-letter, and the inbox channel starts requiring an email service with renderTemplate() 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.

Line (pre-change)What it is
:135NotifyConfigSchema doc block — the localization-contract bullet
:181template field JSDoc
:188template.describe() — the authored surface, and the one projected to docs
:251superRefine refusal: template combined with inline title/message
:272superRefine refusal: neither content source given

Each now names the resolved value (payload.locale if the producer set one, else the deployment default II18nService.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 on email-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.ts L277/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:

expect(templateDoc).toMatch(/\(name,locale\)/);expect(templateDoc).not.toMatch(/recipientlocale/);expect(templateDoc).toMatch(/deploymentdefault/);expect(templateDoc).toContain('II18nService.getDefaultLocale()');expect(templateDoc).toMatch(/notoneperrecipient/);expect(templateDoc).toContain('2026-08-13');

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:

anchor hits before mutation (expect 1): 1
anchor hits after (expect 0): 0
old text hits after (expect 1): 1
post-mutation hash: 5211ac23… (pre: b8e2344f…)
MUTATION CONFIRMED ON DISK
ablation vitest exit: 1
Test Files 1 failed (1)
Tests 1 failed | 27 passed (28)

failing on exactly the intended pin:

AssertionError: expected 'Email template name (`sys_email_templ…' to match /\(name, locale\)/
❯ src/automation/io-node-config.test.ts:321:27

Restore proven, not assumed — the mutated path was restored by naming HEAD explicitly:

git checkout HEAD -- "$REPO_ROOT/packages/spec/src/automation/io-node-config.zod.ts"

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-object comparison back to the HEAD blob b8e2344fand an empty git 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.js sibling-relative from src, so nothing resolves through exports/dist.

Generated projection

content/docs/references/automation/io-node-config.mdx regenerated via pnpm --filter @objectstack/spec gen:docs (never hand-edited); the diff is exactly the one table cell, 1 insertion / 1 deletion. gen:schema had to run first — it is a gitignored prerequisite tree, and check:docs refuses 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/spec publishes src/**/*.zod.ts, so corrected prose ships to consumers. Not skip-changeset.

Gates — union run at 4823268, the final commit, on a clean tree

Gate 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, including spec typecheck, the full spec vitest 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-react and client, and all five re-runnable ones went green:

spec check:docs exit=0
spec check:generated exit=0
spec check:skill-examples exit=0 → ✅ 260 prose examples type-check across 3 surface(s)
lint check:doc-formula-expressions exit=0
lint check:doc-security-posture exit=0

Declared narrowing:check-dev-prereqs is 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 full pnpm build, which is a repo-scale run CI owns. Its output says nothing about this diff. Same for check:type-check-debt --re-measure, which refuses without the built workspace closure; spec typecheck (which includes check: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 in service-automation/src/builtin/notify-node.ts L197 and content/docs/automation/email-templates.mdx L164-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 calls service-messaging/src/plugin.ts is actually at messaging-service-plugin.ts L250 — no src/plugin.ts exists in the tree.


Generated by Claude Code

… 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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8515954fb6dd4102e120b59ce6410f94600d5710packageMentionDocs.

Which tree this was computed on

This run read content/docs from ae41fbca5c44d792c4bb876b4e4a2cc8c73ee466 — the merge of head 482326807ba69c19c481da1c464e1968d8914cff into base 8515954fb6dd4102e120b59ce6410f94600d5710, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 26, 2026 04:43
@huangyiirene
huangyiirene added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 4297fe7Aug 26, 2026
38 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-12178-notify-locale-wording branch August 26, 2026 05:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

spec(automation): notify template locale is the deployment default, not per-recipient - #12447

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording
Aug 26, 2026
Merged

spec(automation): notify template locale is the deployment default, not per-recipient#12447
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording

Conversation

@huangyiirene

@huangyiirenehuangyiirene commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

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.ts L86-99 has documented the truth honestly all along: sys_user carries no locale column and request-scoped locale (Accept-LanguageExecutionContext.requestLocale) does not exist at async delivery time, so "recipient locale" resolves to the deployment default, II18nService.getDefaultLocale(). The one lever is payload.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 classify permanent and dead-letter, and the inbox channel starts requiring an email service with renderTemplate() 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.

Line (pre-change)What it is
:135NotifyConfigSchema doc block — the localization-contract bullet
:181template field JSDoc
:188template.describe() — the authored surface, and the one projected to docs
:251superRefine refusal: template combined with inline title/message
:272superRefine refusal: neither content source given

Each now names the resolved value (payload.locale if the producer set one, else the deployment default II18nService.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 on email-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.ts L277/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:

expect(templateDoc).toMatch(/\(name,locale\)/);expect(templateDoc).not.toMatch(/recipientlocale/);expect(templateDoc).toMatch(/deploymentdefault/);expect(templateDoc).toContain('II18nService.getDefaultLocale()');expect(templateDoc).toMatch(/notoneperrecipient/);expect(templateDoc).toContain('2026-08-13');

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:

anchor hits before mutation (expect 1): 1
anchor hits after (expect 0): 0
old text hits after (expect 1): 1
post-mutation hash: 5211ac23… (pre: b8e2344f…)
MUTATION CONFIRMED ON DISK
ablation vitest exit: 1
Test Files 1 failed (1)
Tests 1 failed | 27 passed (28)

failing on exactly the intended pin:

AssertionError: expected 'Email template name (`sys_email_templ…' to match /\(name, locale\)/
❯ src/automation/io-node-config.test.ts:321:27

Restore proven, not assumed — the mutated path was restored by naming HEAD explicitly:

git checkout HEAD -- "$REPO_ROOT/packages/spec/src/automation/io-node-config.zod.ts"

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-object comparison back to the HEAD blob b8e2344fand an empty git 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.js sibling-relative from src, so nothing resolves through exports/dist.

Generated projection

content/docs/references/automation/io-node-config.mdx regenerated via pnpm --filter @objectstack/spec gen:docs (never hand-edited); the diff is exactly the one table cell, 1 insertion / 1 deletion. gen:schema had to run first — it is a gitignored prerequisite tree, and check:docs refuses 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/spec publishes src/**/*.zod.ts, so corrected prose ships to consumers. Not skip-changeset.

Gates — union run at 4823268, the final commit, on a clean tree

Gate 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, including spec typecheck, the full spec vitest 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-react and client, and all five re-runnable ones went green:

spec check:docs exit=0
spec check:generated exit=0
spec check:skill-examples exit=0 → ✅ 260 prose examples type-check across 3 surface(s)
lint check:doc-formula-expressions exit=0
lint check:doc-security-posture exit=0

Declared narrowing:check-dev-prereqs is 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 full pnpm build, which is a repo-scale run CI owns. Its output says nothing about this diff. Same for check:type-check-debt --re-measure, which refuses without the built workspace closure; spec typecheck (which includes check: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 in service-automation/src/builtin/notify-node.ts L197 and content/docs/automation/email-templates.mdx L164-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 calls service-messaging/src/plugin.ts is actually at messaging-service-plugin.ts L250 — no src/plugin.ts exists in the tree.


Generated by Claude Code

… 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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8515954fb6dd4102e120b59ce6410f94600d5710packageMentionDocs.

Which tree this was computed on

This run read content/docs from ae41fbca5c44d792c4bb876b4e4a2cc8c73ee466 — the merge of head 482326807ba69c19c481da1c464e1968d8914cff into base 8515954fb6dd4102e120b59ce6410f94600d5710, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 26, 2026 04:43
@huangyiirene
huangyiirene added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 4297fe7Aug 26, 2026
38 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-12178-notify-locale-wording branch August 26, 2026 05:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

spec(automation): notify template locale is the deployment default, not per-recipient - #12447

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording
Aug 26, 2026
Merged

spec(automation): notify template locale is the deployment default, not per-recipient#12447
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording

Conversation

@huangyiirene

@huangyiirenehuangyiirene commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

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.ts L86-99 has documented the truth honestly all along: sys_user carries no locale column and request-scoped locale (Accept-LanguageExecutionContext.requestLocale) does not exist at async delivery time, so "recipient locale" resolves to the deployment default, II18nService.getDefaultLocale(). The one lever is payload.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 classify permanent and dead-letter, and the inbox channel starts requiring an email service with renderTemplate() 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.

Line (pre-change)What it is
:135NotifyConfigSchema doc block — the localization-contract bullet
:181template field JSDoc
:188template.describe() — the authored surface, and the one projected to docs
:251superRefine refusal: template combined with inline title/message
:272superRefine refusal: neither content source given

Each now names the resolved value (payload.locale if the producer set one, else the deployment default II18nService.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 on email-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.ts L277/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:

expect(templateDoc).toMatch(/\(name,locale\)/);expect(templateDoc).not.toMatch(/recipientlocale/);expect(templateDoc).toMatch(/deploymentdefault/);expect(templateDoc).toContain('II18nService.getDefaultLocale()');expect(templateDoc).toMatch(/notoneperrecipient/);expect(templateDoc).toContain('2026-08-13');

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:

anchor hits before mutation (expect 1): 1
anchor hits after (expect 0): 0
old text hits after (expect 1): 1
post-mutation hash: 5211ac23… (pre: b8e2344f…)
MUTATION CONFIRMED ON DISK
ablation vitest exit: 1
Test Files 1 failed (1)
Tests 1 failed | 27 passed (28)

failing on exactly the intended pin:

AssertionError: expected 'Email template name (`sys_email_templ…' to match /\(name, locale\)/
❯ src/automation/io-node-config.test.ts:321:27

Restore proven, not assumed — the mutated path was restored by naming HEAD explicitly:

git checkout HEAD -- "$REPO_ROOT/packages/spec/src/automation/io-node-config.zod.ts"

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-object comparison back to the HEAD blob b8e2344fand an empty git 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.js sibling-relative from src, so nothing resolves through exports/dist.

Generated projection

content/docs/references/automation/io-node-config.mdx regenerated via pnpm --filter @objectstack/spec gen:docs (never hand-edited); the diff is exactly the one table cell, 1 insertion / 1 deletion. gen:schema had to run first — it is a gitignored prerequisite tree, and check:docs refuses 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/spec publishes src/**/*.zod.ts, so corrected prose ships to consumers. Not skip-changeset.

Gates — union run at 4823268, the final commit, on a clean tree

Gate 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, including spec typecheck, the full spec vitest 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-react and client, and all five re-runnable ones went green:

spec check:docs exit=0
spec check:generated exit=0
spec check:skill-examples exit=0 → ✅ 260 prose examples type-check across 3 surface(s)
lint check:doc-formula-expressions exit=0
lint check:doc-security-posture exit=0

Declared narrowing:check-dev-prereqs is 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 full pnpm build, which is a repo-scale run CI owns. Its output says nothing about this diff. Same for check:type-check-debt --re-measure, which refuses without the built workspace closure; spec typecheck (which includes check: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 in service-automation/src/builtin/notify-node.ts L197 and content/docs/automation/email-templates.mdx L164-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 calls service-messaging/src/plugin.ts is actually at messaging-service-plugin.ts L250 — no src/plugin.ts exists in the tree.


Generated by Claude Code

… 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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8515954fb6dd4102e120b59ce6410f94600d5710packageMentionDocs.

Which tree this was computed on

This run read content/docs from ae41fbca5c44d792c4bb876b4e4a2cc8c73ee466 — the merge of head 482326807ba69c19c481da1c464e1968d8914cff into base 8515954fb6dd4102e120b59ce6410f94600d5710, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 26, 2026 04:43
@huangyiirene
huangyiirene added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 4297fe7Aug 26, 2026
38 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-12178-notify-locale-wording branch August 26, 2026 05:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

spec(automation): notify template locale is the deployment default, not per-recipient - #12447

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording
Aug 26, 2026
Merged

spec(automation): notify template locale is the deployment default, not per-recipient#12447
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording

Conversation

@huangyiirene

@huangyiirenehuangyiirene commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

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.ts L86-99 has documented the truth honestly all along: sys_user carries no locale column and request-scoped locale (Accept-LanguageExecutionContext.requestLocale) does not exist at async delivery time, so "recipient locale" resolves to the deployment default, II18nService.getDefaultLocale(). The one lever is payload.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 classify permanent and dead-letter, and the inbox channel starts requiring an email service with renderTemplate() 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.

Line (pre-change)What it is
:135NotifyConfigSchema doc block — the localization-contract bullet
:181template field JSDoc
:188template.describe() — the authored surface, and the one projected to docs
:251superRefine refusal: template combined with inline title/message
:272superRefine refusal: neither content source given

Each now names the resolved value (payload.locale if the producer set one, else the deployment default II18nService.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 on email-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.ts L277/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:

expect(templateDoc).toMatch(/\(name,locale\)/);expect(templateDoc).not.toMatch(/recipientlocale/);expect(templateDoc).toMatch(/deploymentdefault/);expect(templateDoc).toContain('II18nService.getDefaultLocale()');expect(templateDoc).toMatch(/notoneperrecipient/);expect(templateDoc).toContain('2026-08-13');

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:

anchor hits before mutation (expect 1): 1
anchor hits after (expect 0): 0
old text hits after (expect 1): 1
post-mutation hash: 5211ac23… (pre: b8e2344f…)
MUTATION CONFIRMED ON DISK
ablation vitest exit: 1
Test Files 1 failed (1)
Tests 1 failed | 27 passed (28)

failing on exactly the intended pin:

AssertionError: expected 'Email template name (`sys_email_templ…' to match /\(name, locale\)/
❯ src/automation/io-node-config.test.ts:321:27

Restore proven, not assumed — the mutated path was restored by naming HEAD explicitly:

git checkout HEAD -- "$REPO_ROOT/packages/spec/src/automation/io-node-config.zod.ts"

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-object comparison back to the HEAD blob b8e2344fand an empty git 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.js sibling-relative from src, so nothing resolves through exports/dist.

Generated projection

content/docs/references/automation/io-node-config.mdx regenerated via pnpm --filter @objectstack/spec gen:docs (never hand-edited); the diff is exactly the one table cell, 1 insertion / 1 deletion. gen:schema had to run first — it is a gitignored prerequisite tree, and check:docs refuses 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/spec publishes src/**/*.zod.ts, so corrected prose ships to consumers. Not skip-changeset.

Gates — union run at 4823268, the final commit, on a clean tree

Gate 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, including spec typecheck, the full spec vitest 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-react and client, and all five re-runnable ones went green:

spec check:docs exit=0
spec check:generated exit=0
spec check:skill-examples exit=0 → ✅ 260 prose examples type-check across 3 surface(s)
lint check:doc-formula-expressions exit=0
lint check:doc-security-posture exit=0

Declared narrowing:check-dev-prereqs is 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 full pnpm build, which is a repo-scale run CI owns. Its output says nothing about this diff. Same for check:type-check-debt --re-measure, which refuses without the built workspace closure; spec typecheck (which includes check: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 in service-automation/src/builtin/notify-node.ts L197 and content/docs/automation/email-templates.mdx L164-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 calls service-messaging/src/plugin.ts is actually at messaging-service-plugin.ts L250 — no src/plugin.ts exists in the tree.


Generated by Claude Code

… 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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8515954fb6dd4102e120b59ce6410f94600d5710packageMentionDocs.

Which tree this was computed on

This run read content/docs from ae41fbca5c44d792c4bb876b4e4a2cc8c73ee466 — the merge of head 482326807ba69c19c481da1c464e1968d8914cff into base 8515954fb6dd4102e120b59ce6410f94600d5710, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 26, 2026 04:43
@huangyiirene
huangyiirene added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 4297fe7Aug 26, 2026
38 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-12178-notify-locale-wording branch August 26, 2026 05:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@huangyiirene@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

spec(automation): notify template locale is the deployment default, not per-recipient - #12447

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording
Aug 26, 2026
Merged

spec(automation): notify template locale is the deployment default, not per-recipient#12447
huangyiirene merged 1 commit into
mainfrom
claude/issue-12178-notify-locale-wording

Conversation

@huangyiirene

@huangyiirenehuangyiirene commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

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.ts L86-99 has documented the truth honestly all along: sys_user carries no locale column and request-scoped locale (Accept-LanguageExecutionContext.requestLocale) does not exist at async delivery time, so "recipient locale" resolves to the deployment default, II18nService.getDefaultLocale(). The one lever is payload.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 classify permanent and dead-letter, and the inbox channel starts requiring an email service with renderTemplate() 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.

Line (pre-change)What it is
:135NotifyConfigSchema doc block — the localization-contract bullet
:181template field JSDoc
:188template.describe() — the authored surface, and the one projected to docs
:251superRefine refusal: template combined with inline title/message
:272superRefine refusal: neither content source given

Each now names the resolved value (payload.locale if the producer set one, else the deployment default II18nService.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 on email-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.ts L277/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:

expect(templateDoc).toMatch(/\(name,locale\)/);expect(templateDoc).not.toMatch(/recipientlocale/);expect(templateDoc).toMatch(/deploymentdefault/);expect(templateDoc).toContain('II18nService.getDefaultLocale()');expect(templateDoc).toMatch(/notoneperrecipient/);expect(templateDoc).toContain('2026-08-13');

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:

anchor hits before mutation (expect 1): 1
anchor hits after (expect 0): 0
old text hits after (expect 1): 1
post-mutation hash: 5211ac23… (pre: b8e2344f…)
MUTATION CONFIRMED ON DISK
ablation vitest exit: 1
Test Files 1 failed (1)
Tests 1 failed | 27 passed (28)

failing on exactly the intended pin:

AssertionError: expected 'Email template name (`sys_email_templ…' to match /\(name, locale\)/
❯ src/automation/io-node-config.test.ts:321:27

Restore proven, not assumed — the mutated path was restored by naming HEAD explicitly:

git checkout HEAD -- "$REPO_ROOT/packages/spec/src/automation/io-node-config.zod.ts"

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-object comparison back to the HEAD blob b8e2344fand an empty git 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.js sibling-relative from src, so nothing resolves through exports/dist.

Generated projection

content/docs/references/automation/io-node-config.mdx regenerated via pnpm --filter @objectstack/spec gen:docs (never hand-edited); the diff is exactly the one table cell, 1 insertion / 1 deletion. gen:schema had to run first — it is a gitignored prerequisite tree, and check:docs refuses 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/spec publishes src/**/*.zod.ts, so corrected prose ships to consumers. Not skip-changeset.

Gates — union run at 4823268, the final commit, on a clean tree

Gate 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, including spec typecheck, the full spec vitest 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-react and client, and all five re-runnable ones went green:

spec check:docs exit=0
spec check:generated exit=0
spec check:skill-examples exit=0 → ✅ 260 prose examples type-check across 3 surface(s)
lint check:doc-formula-expressions exit=0
lint check:doc-security-posture exit=0

Declared narrowing:check-dev-prereqs is 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 full pnpm build, which is a repo-scale run CI owns. Its output says nothing about this diff. Same for check:type-check-debt --re-measure, which refuses without the built workspace closure; spec typecheck (which includes check: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 in service-automation/src/builtin/notify-node.ts L197 and content/docs/automation/email-templates.mdx L164-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 calls service-messaging/src/plugin.ts is actually at messaging-service-plugin.ts L250 — no src/plugin.ts exists in the tree.


Generated by Claude Code

… 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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 126 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8515954fb6dd4102e120b59ce6410f94600d5710packageMentionDocs.

Which tree this was computed on

This run read content/docs from ae41fbca5c44d792c4bb876b4e4a2cc8c73ee466 — the merge of head 482326807ba69c19c481da1c464e1968d8914cff into base 8515954fb6dd4102e120b59ce6410f94600d5710, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 26, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 26, 2026 04:43
@huangyiirene
huangyiirene added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 4297fe7Aug 26, 2026
38 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-12178-notify-locale-wording branch August 26, 2026 05:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@huangyiirene@claude