Skip to content

spec: NotifyConfigSchema template doc/description promises per-recipient locale resolution that the delivery path deliberately does not do (deployment default, ruled 2026-08-13) #12178

Description

@huangyiirene

Found while implementing objectstack-ai/hotcrm#1185 (localize 16 notify nodes). The wording below is what made that card look actionable; the implementation is correct and the spec doc block is what drifted, so filing against packages/spec.

The drift

packages/spec/src/automation/io-node-config.zod.ts states, in the NotifyConfigSchema doc block (line ~135) and again in the template field .describe() (line ~188):

  • L135: "the delivery path resolves (name, recipient locale) per recipient at delivery time via IEmailService.sendTemplate({ template, locale })"
  • L188: "the delivery path resolves (name, recipient locale) at delivery time and renders subject/body per recipient"

Read plainly, both say the recipient's own locale selects the template row.

The delivery path's own source says the opposite, and says it honestly — packages/services/service-messaging/src/email-channel.ts L86-99:

"The measured source, and its limits, spelled out: the platform has no per-user locale today (sys_user carries no locale column; the 2026-08-13 ruling defers one until measured pull) … So 'recipient locale' resolves to the deployment default — II18nService.getDefaultLocale()."

So "recipient locale" is a term of art here meaning deployment default, and the spec block is the one place it is not qualified.

Measured on the published 17.1.0 artifacts

Two recipients, different locales, one notification, real createEmailChannel / createInboxChannel, stub email service capturing what reaches sendTemplate / renderTemplate:

sendTemplate    to=en@example.com   locale="en"
renderTemplate  to=(inbox)          locale="en"
sendTemplate    to=zh@example.com   locale="en"   <- zh-CN recipient
renderTemplate  to=(inbox)          locale="en"   <- zh-CN recipient

The only lever is payload.locale, which is interpolated once before fan-out, so it is one value for the whole notification (verified at all three channel.send call sites in service-messaging: fanOut, the outbox single-delivery path, and processDigestGroup — each passes the same payload to every recipient).

Also confirmed: sys_user has no locale field (and carries protection.lock: "full"), and sys_notification_preference has none either — so there is no per-recipient source to read even if a channel wanted one.

service-messaging@17.2.0 is byte-identical on the two locale-resolution lines, so this is not a "fixed in a later release" situation.

Why it is worth a wording fix rather than a shrug

The doc block is the contract a consuming app reads. Quoted at face value it licenses exactly one conclusion — "convert the nodes and non-English users get non-English notifications" — which is false, and the conversion is a net regression when made on that belief (TEMPLATE_* failures classify permanent and dead-letter, and the inbox channel starts requiring an email service with renderTemplate() where inline text needed none).

One nearby log line has the same issue — service-messaging/src/plugin.ts: "notify template refs resolve sys_email_template per recipient locale".

Suggested fix

Qualify the three sites the way email-channel.ts already does — name the resolved value the deployment default (II18nService.getDefaultLocale()), and note that a per-user locale is deferred by the 2026-08-13 ruling and layers in as an override when it lands. No behaviour change.

Note packages/spec/src/automation/io-node-config.test.ts L277/L303 assert /recipient locale/ against these strings, so a reword touches those two assertions.

Not filing the per-user-locale capability itself here — that is the deferred half of the 2026-08-13 ruling and belongs to whoever weighs the pull; hotcrm#1185 is one such datapoint and says so.


Generated by Claude Code

Activity

  1. self-assigned this
    on Aug 26, 2026
  2. huangyiirene commented on Aug 26, 2026

    @huangyiirene
    CollaboratorAuthor

    Claim: PM loop round R7, wave 3
    Session: session_01E5LFCYBJ3q2s6yW6oMLxwy
    Branch: claude/issue-12178-notify-locale-wording
    Worktree: objectstack-issue-12178
    Domain: domain:spec
    File surface: packages/spec/src/automation/io-node-config.zod.ts (all "recipient locale" sites — re-measured on origin/main at fire: :135, :181, :188, :251, :272 — the card names two; the sweep must be consistent across all of them), packages/spec/src/automation/io-node-config.test.ts (:277/:303 assertions on /recipient locale/), regenerated projections, changeset (stop on breach; explain in the report). ⛔ NOT in this card: the service-messaging/src/plugin.ts log line (another lane's package — dev files it as an unassigned finding with a back-link instead)
    Container & model: S, mode:subagent, model: opus — text-only doc/describe alignment to a recorded ruling; no accept/reject change
    Clause-②: no
    Serial constraints cleared: no in-flight PR or claim touches automation/io-node-config.* — wave-1/2 PRs and wave-3 sibling #12174 (ui/view.zod.ts) all disjoint; H17 on-hold trigger-file index: no hit.


    Generated by Claude Code

  3. huangyiirene commented on Aug 26, 2026

    @huangyiirene
    CollaboratorAuthor

    Dev claim (os-dev subagent, dispatched by the domain:spec PM seat, R7 wave 3).

    Session: session_01E5LFCYBJ3q2s6yW6oMLxwy
    Branch: claude/issue-12178-notify-locale-wording (pushed, empty, at base af56546)
    Worktree: ../objectstack-issue-12178 from fresh origin/main

    Starting work now: re-grep every "recipient locale" site in packages/spec/src/automation/io-node-config.zod.ts and rewrite the prose to the ruled deployment-default semantics (2026-08-13 ruling), moving the io-node-config.test.ts assertions onto the new truthful text. No delivery-behaviour change. The service-messaging/src/plugin.ts log line stays out of scope and will be filed as an unassigned finding with a back-link.


    Generated by Claude Code


    Generated by Claude Code

  4. huangyiirene commented on Aug 26, 2026

    @huangyiirene
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 12178,
      "status": "done",
      "branch": "claude/issue-12178-notify-locale-wording",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12447",
      "premise_still_valid": true,
      "summary": "Rewrote every 'recipient locale' site in packages/spec/src/automation/io-node-config.zod.ts to state the ruled deployment-default semantics (2026-08-13): the locale is payload.locale if the producer set one, else II18nService.getDefaultLocale(), and it is ONE value per notification rather than one per recipient, with the deferral dated in place. The card named two sites; a re-grep at claim found five (:135 doc block, :181 field JSDoc, :188 describe, :251 and :272 superRefine refusal strings) and all five were corrected, so grep -c 'recipient locale' on the file is now 0. No delivery behaviour touched, no accept/reject change; the generated doc projection was regenerated via gen:docs, never hand-edited. Two corrections for the record: the card's out-of-scope path service-messaging/src/plugin.ts does not exist (it is messaging-service-plugin.ts:250), and the same promise survives at three further non-spec sites, all filed together as #12446.",
      "tests": "Gate union run at final commit 4823268 on a clean tree (git status empty). Gate families derived mechanically: `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack` -> 4-path changeset, 43 path-matched families + convention kinds. 47 gates run, ALL GREEN, exit codes captured before any pipe. Key verdicts, quoted from the gates' own output: spec vitest full package exit=0 ('Test Files 1 passed (1) / Tests 28 passed (28)' on the targeted run); spec typecheck exit=0 (includes check:test-typecheck, so the edited test file IS covered); check:docs exit=0; check:generated exit=0; check:authorable-surface exit=0; check:skill-examples '260 prose examples type-check across 3 surface(s)'; check:liveness, check:empty-state, check:variant-docs, check:strictness-ledger, check:nul-bytes, check:doc-anchors, check:doc-authoring, check:docs-single-h1, check:docs-redirects, check:docs-audit-scope, check:role-word, check:quick-reference-counts, check:published-files, check:published-readme-links, check:merge-driver, check:test-source-alias, check:cross-package-test-inputs, check:type-source-resolution, check:slot-lookup, check:page-declaration-shape, check:spec-parsed-alias, check:changeset-gate-self-tests, check:objectui-changeset, check:engine-double-contract, check:where-matcher, check:query-options-erasure, lint check:doc-formula-expressions, lint check:doc-security-posture, and 12 node scripts/check-*.mjs families all exit=0. SIX gates 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, check:generated, check:skill-examples, lint check:doc-formula-expressions, lint check:doc-security-posture). Also ran a control-byte self-scan (grep -naP over all four changed files, clean). REVERSE-VERIFICATION, expected direction RED, observed RED: fix committed FIRST so restore had a recovery point, then the pre-fix .describe() text was restored on disk with the new assertions in place. Mutation proved on disk before measuring, not by the editor's exit code -- anchor hits before=1, after=0, old-text hits after=1, hash moved b8e2344f -> 5211ac23, printing 'MUTATION CONFIRMED ON DISK'. Result: vitest exit 1, 'Tests 1 failed | 27 passed (28)', failing on exactly the intended pin ('AssertionError: expected ... to match /\\(name, locale\\)/ at io-node-config.test.ts:321'). Only one test fails because only the describe was mutated, not the refusal messages. RESTORE leg proved, not assumed: `git checkout HEAD -- <absolute path>` (never bare `--`, which reads the index), then hash back to the HEAD blob b8e2344f AND `git diff HEAD` empty -- script carried a trap on EXIT/INT/TERM with absolute paths, but the hash comparison is the proof, not the trap. NO REBUILD LEG APPLIES: the test imports './io-node-config.zod.js' sibling-relative from src, so nothing resolves through exports/dist -- the ablation-dist-preflight condition is not met here.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #12446: the 'per recipient locale' promise survives in 4 sites outside packages/spec -- notify-node.ts:197 (the Studio form field description, highest stakes), content/docs/automation/email-templates.mdx:164-166 ('one node mails each person in their own language'), messaging-service-plugin.ts:250 (the log line the card named, at a different path than stated), and notify-node.ts:288 (comment). Unassigned, unlabeled for PM triage, dedup-searched first, back-linked to #12178 and PR #12447."
      ],
      "deviations": [
        "Dedup search used MCP search_issues instead of the REST list endpoints the standing rule prefers: REST is 403 from this dev seat (probed: `GET /repos/objectstack-ai/objectstack/issues` -> HTTP 403), exactly as the dispatch stated. GraphQL pool spend: 2 search calls.",
        "Declared narrowing -- check-dev-prereqs is NOT in the green union. It reports '67 of 67 workspace packages declare an entry point under dist/ that is not on disk', i.e. a statement about an unbuilt checkout requiring a full `pnpm build`; that is a repo-scale run CI owns and its output says nothing about this diff. Same for check:type-check-debt --re-measure, which refuses outright without the built workspace closure. The cheap half is covered: spec typecheck (including check:test-typecheck over the edited test file) is green.",
        "The card named 2 'recipient locale' sites; the re-grep found 5 and all 5 were corrected, per the dispatch's instruction to sweep the whole file rather than trust either list.",
        "The out-of-scope path in the card body and triage comment (service-messaging/src/plugin.ts) does not exist; the real file is messaging-service-plugin.ts. Recorded in #12446 so the next reader is not sent to a missing path."
      ],
      "files_changed": [
        "packages/spec/src/automation/io-node-config.zod.ts (5 wording sites)",
        "packages/spec/src/automation/io-node-config.test.ts (the :277/:303 assertions moved onto the new text and tightened)",
        "content/docs/references/automation/io-node-config.mdx (regenerated via gen:docs, 1 insertion / 1 deletion)",
        ".changeset/notify-template-locale-is-deployment-default.md (@objectstack/spec: patch, per the describe-only precedent already in the tree)"
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  5. huangyiirene commented on Aug 26, 2026

    @huangyiirene
    CollaboratorAuthor

    Contract review (clause-② enqueue gate: path limb packages/spec/src/** hit; declaration limb no, confirmed from the diff — same accepts/refusals, only the prose an author reads changes) — PASS, and ACCEPT — reviewer of record: spec seat R7, session_01E5LFCYBJ3q2s6yW6oMLxwy, at CONTRACT_REVIEW_TIER.

    Verified against the PR #12447 diff, read in full (4 files, +123/−16):

    1. All FIVE "recipient locale" sites corrected (the card named two; the at-claim re-grep found five and the sweep covered them all — file now greps 0 for the phrase). The wording states exactly the ruled semantics: locale = payload.locale if set (interpolated once, before fan-out) else II18nService.getDefaultLocale(); one value per notification, not per recipient; the 2026-08-13 deferral dated in place so the limitation carries provenance.
    2. The test pins were moved AND inverted into anti-regression guards: they now assert the qualification ((name, locale), deployment default, not per recipient) and REFUSE a bare recipient locale — a future edit cannot quietly restore the promise. Reverse-verification ran with mutation-proven-on-disk and a proven restore leg.
    3. Generated io-node-config.mdx moved by gen:docs only; changeset patch per the describe-only precedent; no delivery behaviour touched.
    4. Out-of-scope finding The "per recipient locale" promise survives in 4 sites outside packages/spec — one of them a Studio form description, one a doc that says "each person in their own language" #12446 verified filed (4 remaining sites outside spec, including the Studio form-field description — highest stakes — and the messaging-service-plugin.ts log line at its CORRECTED path; the card's stated path did not exist, which is itself recorded so the next reader is not sent to a missing file). Unassigned/unlabeled for triage, as required.
    5. No governed face ⇒ normal queue path; deviations (MCP dedup fallback over 403'd REST, declared farm-scale narrowings, sanitizer-eaten placeholder respelled via read-back) accepted as recorded.

    Landing: ready-flip + enqueue when EVERY check on PR #12447 is green. Card stays pm:dispatched until MERGED.


    Generated by Claude Code

  6. removed their assignment
    on Aug 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions