Skip to content

Flow notify nodes cannot be localized: title/message are raw strings with no template or locale channel, so a four-locale app sends English-only notifications #9205

Closed

Description

@os-steve

Problem

HotCRM ships 4 locales (en, zh-CN, ja-JP, es-ES) and 16 notify flow nodes with channels: ['inbox', 'email']. Every one of them carries inline English literals:

title: 'Large deal won: {record.name}',
message: '{record.name} closed at {record.amount}. Congratulations to the team.',

A Japanese sales rep receives "Large deal won" in English, in their inbox and their mail. There is no way for a metadata app to do better today:

  • packages/spec/src/automation/io-node-config.zod.ts — NotifyNodeConfig.title / message are plain strings. {token} interpolation resolves record fields only; there is no template reference and no locale awareness.
  • packages/spec/src/system/translation.zod.ts:679 — the flows translation surface carries the flow label, per-screen headings and per-screen field copy. Its own guidance block states notification/toast text is not part of the surface (i18n: TranslationBundle has no surface for screen-flow UI — wizard screens render source-language only, even in a fully localized app #7646).
  • EmailTemplateDefinitionSchema (system/email-template.zod.ts) already resolves by (name, locale) via IEmailService.sendTemplate({ template, locale }) — but a notify node cannot reference one.

So the gap is a missing bridge, not a missing subsystem.

Ruled direction

Maintainer ruling, 2026-08-17, PM session chat (verbatim): 「立项,走 emailTemplates 路线」 — chosen over extending the flows translation surface (whose guidance explicitly excludes notification text; widening it would overturn #7646).

Proposed shape (for the spec seat to refine): let NotifyNodeConfig reference an email template by name — e.g. template: 'large_deal_won' (+ optional templateData) as an alternative to inline title/message — with the notification service resolving (name, recipient locale) at delivery time, for the email channel and ideally the inbox channel too. Inline strings remain valid (existing flows keep working); the template reference is the localizable path.

Notes for triage

  • This expands the accepted set of NotifyNodeConfig ⇒ semantic/contract change; per dispatch rules any implementing card is domain:spec and model: claude-fable-5.
  • Cross-repo contract-first: the consumer card (localize HotCRM's 16 notify nodes in 4 locales) is filed in hotcrm with a Blocked-by: on this issue.
  • Re-check commands: grep -n "title\|message\|template" packages/spec/src/automation/io-node-config.zod.ts · grep -rn "type: 'notify'" ../hotcrm/src/flows/ | wc -l (16 as of hotcrm@2342811).

Activity

  1. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    domain:specDomain lane — seat card indexed by label:pm:seat
    on Aug 17, 2026
  2. self-assigned this
    on Aug 17, 2026
  3. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor

    Claim: PM seat (spec lane), round 12b — queue-jump on maintainer instruction (2026-08-17 「9205 插队」), dispatched ahead of the serial-held queue cards.


    Generated by Claude Code

  4. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor
    {
      "issue": 9205,
      "status": "done",
      "branch": "claude/issue-9205-notify-template-locale-bridge",
      "pr_number": 9224,
      "pr_url": "https://github.com/objectstack-ai/objectstack/pull/9224",
      "schema_shape": "NotifyConfigSchema += `template` (sys_email_template name, read raw like topic/channels) + `templateData` (record; values interpolate {token} per run), as the localizable alternative to inline title/message; `title` optional in the type only — superRefine owes one content path (at-least-one preserved), refuses both-paths and templateData-without-template; describes state the (name, recipient locale)-at-delivery contract and mark inline as the non-localizable path",
      "dual_path_decision": "Loud refusal via superRefine at ['template'], naming both keys and which to keep — strongest measured precedent: objectNavTargetExclusivity (ui/app.zod.ts, 'unrepresentable rather than silently resolved by precedence'); time-relative-trigger's exactly-one refine is the second; screen's prose-only dual shape is the counter-precedent and predates both. Stated in the PR body.",
      "inbox_channel": "gap-documented",
      "locale_source": "Deployment default via II18nService.getDefaultLocale(), probed lazily at delivery time (tracks live localization.locale changes) — the #8195 ruled source used by sendTemplate's existing callers (auth emails). Measured: sys_user has no locale column (2026-08-13 ruling defers per-user locale until measured pull) and request-scoped locale does not exist at async delivery. Resolution order on the email channel: payload.locale (producer override) → deployment default → unset (sendTemplate's documented en-US ladder). Seam for a future per-user locale: EmailChannelOptions.getDefaultTemplateLocale.",
      "files_touched": [
        "packages/spec/src/automation/io-node-config.zod.ts",
        "packages/spec/src/automation/io-node-config.test.ts",
        "packages/spec/authorable-surface/automation.json (gen:schema)",
        "content/docs/references/** (gen:docs)",
        "packages/services/service-automation/src/builtin/notify-node.ts",
        "packages/services/service-automation/src/builtin/notify-node.test.ts",
        "packages/services/service-messaging/src/email-channel.ts",
        "packages/services/service-messaging/src/email-channel.test.ts",
        "packages/services/service-messaging/src/messaging-service-plugin.ts",
        ".changeset/notify-node-email-template-locale-bridge.md"
      ],
      "tests_added": "spec: 7 cases (template-path acceptance x2, both-paths refusal with prescription pins, templateData-orphan refusal, at-least-one refusal, describe-contract pins, template-path full-key acceptance); service-automation: 2 executor pins (template+interpolated templateData reach emit payload with no inline content keys; both-paths refuses at the parse seam); service-messaging: 7 channel pins (sendTemplate call shape incl. locale precedence x3, loud TEMPLATE_UNSUPPORTED graded permanent, TEMPLATE_NOT_FOUND surfaced verbatim graded permanent, transport status:'failed' stays retryable, no-template payload preserved byte-identically). Reverse verification, fix committed first, per leg via git restore --source=origin/main: spec leg 7 red/21 green; email-channel leg 6 red/8 green (the 1 green new case is the preservation pin — expected direction); notify-node leg 2 red/13 green. All directions matched the pre-declared expectation.",
      "gates_run": {
        "spec_build": "green",
        "spec_test_full": "407 files / 10834 tests green",
        "spec_typecheck": "green (incl. check:scripts-typecheck, check:test-typecheck)",
        "service_automation_test_full": "80 files / 964 tests green (typecheck-debt-exempt by ledger)",
        "service_messaging_test_full": "22 files / 236 tests green",
        "service_messaging_typecheck": "green",
        "check_generated": "all 13 artifacts up to date after gen:docs (authorable-surface + docs regenerated; api-surface unchanged-green)",
        "union_from_dispatch_gates_mjs": "check:nul-bytes, check:merge-driver, check:test-source-alias, check:type-source-resolution, check:spec-parsed-alias, check:cross-package-test-inputs, check:engine-double-contract, check:where-matcher, check:query-options-erasure, check:i18n (after building @objectstack/cli closure), check:type-check-coverage, check:type-check-debt, check:dev-prereqs, spec check:liveness/empty-state/strictness-ledger/variant-docs, lint check:doc-formula-expressions — all green",
        "union_head_sha": "2f80ce13c",
        "ci": "in_progress at report time (per contract, the convergence wait is the PM's)"
      },
      "changeset": ".changeset/notify-node-email-template-locale-bridge.md — minor: @objectstack/spec, @objectstack/service-automation, @objectstack/service-messaging (pure widening, no ADR-0087 marker)",
      "deviations": [
        "Referential validation of the template name: matched the measured tier for by-name references inside flow node configs (subflow/map.flowName), which is NO authoring-time check — no lint rule and validateCrossReferences does not descend into node configs. Enforcement is delivery-time TEMPLATE_NOT_FOUND, now graded permanent (dead immediately, code on the delivery row) instead of burning the retry schedule. No new validation tier invented.",
        "classifyError on the email channel now grades TEMPLATE_NOT_FOUND/TEMPLATE_INACTIVE/MISSING_VARIABLES/TEMPLATE_UNSUPPORTED as 'permanent' — a behavior refinement beyond the card's letter, justified as part of 'match existing sendTemplate failure semantics, pin them': these are metadata errors a retry cannot fix; transport failures stay retryable. Pinned both ways."
      ],
      "open_questions": [
        {
          "question": "Inbox channel localization: a template-path notify with channels ['inbox','email'] writes an inbox row whose title is the topic and whose body is empty (no locale-capable rendering seam from service-messaging to the email-template subsystem exists; IEmailService exposes only send/sendTemplate).",
          "options": [
            "A: follow-up card adding a render-only method to IEmailService (e.g. renderTemplate(input) → {subject, html?, text?}) that the inbox channel calls per recipient locale",
            "B: follow-up card duplicating the (name, locale) resolver + {{var}} renderer inside service-messaging against sys_email_template rows",
            "C: allow inline title/message to coexist with template as the inbox-only fallback (reverses this PR's dual-path refusal)"
          ],
          "recommendation": "A — one render authority (plugin-email keeps the locale ladder and ADR-0053 filters), smallest new surface, serves the real hotcrm inbox need; B forks the contract into a second renderer that drifts; C reintroduces the ambiguity the refusal exists to prevent. PM's call per the card."
        }
      ],
      "risks": [
        "Recipient locale is the deployment default, not per-reader: a four-locale app sends every recipient the deployment's language until a per-user locale lands (deferred by the 2026-08-13 ruling). The hotcrm consumer card should set expectations accordingly; the seam (EmailChannelOptions.getDefaultTemplateLocale) is where per-user resolution plugs in.",
        "The hotcrm consumer card's inbox half is constrained by the documented inbox gap above until the follow-up lands."
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  5. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor

    PM review — ACCEPT (round 12b, session session_01225pUjnCKWqxcc1PeqKFUq).

    PR #9224 verified; spot-checked on the branch head: template/templateData + the dual-path superRefine refusal in io-node-config.zod.ts, translation.zod.ts zero-diff (#7646 stands), sendTemplate/TEMPLATE_UNSUPPORTED/TEMPLATE_NOT_FOUND wiring in email-channel.ts, and the 10-file surface exactly in scope (hotcrm untouched).

    • Ruled route (「立项,走 emailTemplates 路线」) implemented as ruled; declare=enforce landed in the SAME PR (spec/security: field masking is all-or-nothing — no partial masking (phone last-4, ID middle-8), and maskingRule was pruned as dead in 2026-06 #8993 pin): executor forwards template+data, email channel resolves via the existing IEmailService.sendTemplate seam, template failures are LOUD and graded permanent (no silent downgrade to unlocalized content).
    • Dual-path ambiguity refused with named precedents (objectNavTargetExclusivity, time-relative-trigger) — not silent precedence. Referential tier matched to the in-family analog (subflow by-name: no authoring-time existence check; loud at delivery) with the tenant-authorable justification.
    • Two honest limits, endorsed and flagged for the maintainer:
      1. Recipient locale = deployment default, not per-user — measured: sys_user has no locale column (per-user locale was ruled deferred 2026-08-13) and no request locale exists at async delivery. Resolution ladder: producer payload.locale → II18nService.getDefaultLocale() (live-probed) → sendTemplate's en-US ladder; the future per-user seam is named (getDefaultTemplateLocale). A single-deployment 4-locale user base still gets one language — better than English-only, not yet per-reader.
      2. Inbox channel: gap documented, not wired — no render-only seam exists (IEmailService only sends); wiring it would mint a new contract surface or a drifting duplicate resolver, which the card rules out. Template-path notify with inbox channel degrades to topic-as-title. Follow-up filed by the seat (render-only method on IEmailService).
    • Reverse verification three-legged, direction pre-declared and observed; changeset minor ×3 per feat(spec): declare placeholder on FieldSchema — Option C ruling on objectui#4676 (#9019) #9113 precedent (pure widening, no ADR-0087 marker).

    On landing: hotcrm consumer card (16 nodes × 4 locales) unlocks — note its per-reader expectation is bounded by limit 1 until per-user locale lands.

    Landing flow: flip ready + auto-merge at gate-green.


    Generated by Claude Code

  6. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    Contributor

    Landed — unlock fact for downstream consumers (spec seat, session session_01225pUjnCKWqxcc1PeqKFUq).

    PR #9224 MERGED 04:08Z, content-probed on origin/main (template key + dual-path refusal in io-node-config.zod.ts; sendTemplate wiring in email-channel.ts). The hotcrm consumer card (16 notify nodes × 4 locales, Blocked-by: this issue) is now unblocked — that repo is outside this session's scope, so recording the discharge here for its lane's unlock scan. Two boundaries the consumer work must plan around (from the ACCEPT review):

    1. Recipient locale resolves to the deployment default (payload.locale → II18nService.getDefaultLocale() → sendTemplate's en-US ladder) — per-user locale is deferred by the 2026-08-13 ruling; the future seam is EmailChannelOptions.getDefaultTemplateLocale.
    2. The inbox channel does not render templates — a template-path notify's inbox row degrades to topic-as-title until the render-only seam lands (filed as [finding] Template-path notify's inbox channel degrades to topic-as-title: IEmailService has no render-only seam, so localized templates cannot reach sys_inbox_message #9225, awaiting triage).

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:specDomain lane — seat card indexed by label:pm:seatpm:dispatchedDispatched to a dev agent by /pm-dispatch

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions