Skip to content

Email template resolver ignores i18n.fallbackLocale and does no language-subtag folding: en-GB / zh-TW recipients never reach en / zh-CN rows #28880

Closed

Description

@objectstack-fleet

Filed by the objectstack-ai/duly lane seat, from a measurement on duly (objectstack-ai/duly#145, PR objectstack-ai/duly#159). For central triage; no domain:* label is set here.

What happens (measured on @objectstack 18.2.0)

IEmailService.sendTemplate resolves (name, recipientLocale) exactly. On a miss it tries only the literal en-US (EMAIL_TEMPLATE_FLOOR_LOCALE / DEFAULT_TEMPLATE_LOCALE), then throws TEMPLATE_NOT_FOUND. That error is permanent, so the delivery dead-letters. The code is plugin-email EmailService.resolveAndRenderTemplate, at dist/index.js lines 1304–1313 in 18.2.0. The plugin-email CHANGELOG states this ladder as intended: "the named locale exactly, then en-US, then — only for a call that named none — the bundle's lowest locale tag."

Two inputs the app declares never reach that ladder:

  1. The stack's i18n.fallbackLocale. An app declares it precisely to name its fallback, and the email path ignores it.
  2. The recipient tag's language subtag. A zh-TW recipient never reaches a zh-CN row, and an en-GB recipient never reaches an en row.

Reach, measured through duly's live time-relative lead-time sweep against an en + zh-CN bundle (duly's shape through 18.2.0): recipients with en-US, en-GB, zh, zh-TW, fr-FR and ja all end with sys_notification_delivery status dead and TEMPLATE_NOT_FOUND. The defineStack build warning does flag a bundle with no en-US row. Even so, an app whose i18n.defaultLocale is en ships green through validate and build, and its English-speaking users get nothing.

duly's app-side fix (PR #159) retags its English rows en-US. With that, zh and zh-TW readers get English even though a zh-CN row exists.

Proposed ladder (for triage to decide)

  1. Exact match.
  2. Language-subtag fold: zh-TW → a zh-* row, en-GB → en / en-US.
  3. The stack's i18n.fallbackLocale.
  4. en-US.
  5. TEMPLATE_NOT_FOUND.

NotificationTemplateStore's DEFAULT_LOCALE ladder may raise the same question.

Seam: spec EmailTemplateDefinitionSchema.locale / I18nConfig.fallbackLocale → runtime EmailService.resolveAndRenderTemplate.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: p2 · domain:server · area:i18n · pm:queue · 2026-11-02T03:00Z

    ⛔ Not a claim.


    Generated by Claude Code

  2. added
    area:i18nThe customer's own language, across UI, metadata and notifications
    domain:serverDomain lane — seat card indexed by label:pm:seat
    pm:queueReady for the PM dispatch loop
    on Nov 2, 2026
  3. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt

    Claim: PM loop round 1 · card thread · 2026-11-02T03:32Z
    Session: session_01FEkQ5oc56xChD6Wrc4cGSt (the seat); the card thread is session_01FEkQ5kV6ptpTnU6Hjb2mE4 (the #28782 / #28844 / #28856 dev thread, continuing)
    Account: os-project-manager (the seat's linked user; the card's assignee)
    Branch: claude/project-thread-1minft (the card thread's harness-pinned branch, restarted from origin/main after PR #28882's squash merge deleted it; it names no issue, so this claim is the identity record)
    Worktree: the card thread's own container checkout
    Domain: domain:server
    Seat: domain:server#5
    Ruling-ref: 「定级你可以自己决定」 (Leehom, 2026-11-01T12:11Z) with triage's grade (p2) and this seat's ladder rulings in the order; pick announced to seats 1-3.
    File surface: packages/plugins/plugin-email/src/ (the service, the loader, their tests), the composition file that hands the service the i18n fallback, changesets. ⛔ No packages/spec/**, ⛔ no ui/**, ⛔ no service-messaging.
    Container & model: S, mode:thread, model and effort as the thread was opened
    Clause-②: no (expected; a Claim amendment says yes if the order's ruling 3 needs an optional TemplateLoader member)
    Metadata-protocol: none
    Responsibility: the code is the email template locale ladder · a recipient whose tag differs from a bundle row only by region, or whose stack declares a fallback, dead-letters with TEMPLATE_NOT_FOUND · reached by every templated email to a recipient whose locale is not an exact row
    Thread-read: 6568003052
    Premise: holds (read at origin/main 70aa4c79: resolveAndRenderTemplate tries the named tag, then en-US, folds no subtag and reads no declared fallback)
    Serial constraints cleared: none needed.
    Resume-from: none — fresh from origin/main

    State: pm:queue → pm:dispatched, assigned to os-project-manager, recorded by this claim. Order: pm-orders/server/order-28880.md in the project.

  4. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Nov 2, 2026
  5. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-project-manager · session_01FEkQ5kV6ptpTnU6Hjb2mE4

    os-dev-report

    {
      "issue": 28880,
      "status": "done",
      "branch": "claude/project-thread-1minft",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/28917",
      "session": "session_01FEkQ5kV6ptpTnU6Hjb2mE4",
      "premise_still_valid": true,
      "summary": "sendTemplate / renderTemplate with a named locale, over the built-in registry loader, now resolve: the tag folded against the template's locales by resolveBundleLocale (exact, case-insensitive, base language, variant expansion taking the first match in ascending tag order), then the declared fallback (i18n getFallbackLocale) folded the same way, then en-US, then TEMPLATE_NOT_FOUND (permanent, message unchanged). Placement (A) per the seat's ruling: the built-in loader carries its sorted bundle tags under a module-private symbol (TEMPLATE_BUNDLE_LOCALES, declared in email-service.ts, not re-exported, not on TemplateLoader); a replacement loader keeps the exact ladder, declared fallback included. EmailService.setTemplateFallbackLocale (class not exported) is wired in EmailServicePlugin.init (packages/plugins/plugin-email/src/email-plugin.ts, the composition file), reading the i18n service at each send. No-locale ladder unchanged (ruling 2 reading accepted). Built dist/index.d.ts unchanged against the prior build. No spec, ui or service-messaging edit.",
      "tests": "New template-locale-fold.test.ts (13, real service + real registry loader) and template-locale-fold.integration.test.ts (2, booted ObjectKernel + ObjectQLPlugin + app with the duly en + zh-CN bundle via manifest + real EmailServicePlugin). Base b15b634c: booted send throws TEMPLATE_NOT_FOUND: lf_lead_time (locale=en-GB) and (locale=ja); head sends [en], [zh-CN] for zh-TW, [zh-CN] for ja with fallback zh-CN. Unit file on base with only a no-op setTemplateFallbackLocale shim: 7 red, 6 guards green (exact hit, fallback-without-row, no fallback, not found, no-locale, replacement loader). Ablations (on disk, restored from saved copies): fold rung 7 red; declared-fallback rung 3 red; en-US rung 3 red; loader tag sort 1 red (variant order); loader symbol 9 red; plugin i18n wiring 1 red. plugin-email suite 47 files, 764/764 (the #7731 exact-loader pins unchanged).",
      "gates": "dispatch-gates --commands --repo objectstack-ai/objectstack derived 60; all 60 exit 0; --ran: 60 derived, 60 run, 0 NOT-MEASURED. spec check:error-code-provenance exit 0. pnpm --filter @objectstack/plugin-email typecheck first exit 2 (TS2353, symbol key on an object literal typed TemplateLoader), fixed by typing the local as an intersection, then exit 0. Scoped eslint over 5 changed .ts exit 0. Package build: dist/index.d.ts identical to the prior build, no new names. After the final main merge (9a9823d9, nothing under plugin-email or spec): template-locale* files green (38). PR check-runs not yet read at report time.",
      "dogfood_battery": "n/a — no `!` in the PR title and no (narrowing) arm",
      "line_budget": "+347 / -5 across 6 files (PR #28917 as GitHub counts it)",
      "files_changed": [
        ".changeset/28880-email-template-locale-fold.md",
        "packages/plugins/plugin-email/src/email-plugin.ts",
        "packages/plugins/plugin-email/src/email-service.ts",
        "packages/plugins/plugin-email/src/template-loader.ts",
        "packages/plugins/plugin-email/src/template-locale-fold.integration.test.ts",
        "packages/plugins/plugin-email/src/template-locale-fold.test.ts"
      ],
      "deviations": [
        "Placement measured and reported before building: no candidate list over load() can reach case-insensitive or variant expansion, and folding inside the built-in loader's load() would change the no-locale ladder; the seat ruled placement (A), Clause-② no.",
        "Base red for the unit file used a no-op setTemplateFallbackLocale shim so the file loads on base; the booted integration test needed no shim.",
        "The duly app itself was not run; the booted integration test is its shape (en + zh-CN bundle, en-GB / zh-TW recipients)."
      ],
      "mcp_calls": "3 — mcp__github__issue_read get (#28880), mcp__github__issue_read get_comments (#28880, claim check), mcp__github__pull_request_read get (#28917); no write tool",
      "api_writes": "3 — git push (claude/project-thread-1minft, new branch, plain); relay pr_create via scripts/pm/fleet-write/dispatch.mjs (#28917, draft, read-back identical, assignees none, no labels: changeset present); this report via scripts/pm/post-stamped.mjs",
      "open_questions": [],
      "out_of_scope_findings": [
        "spec docblocks SendTemplateInput.template/.locale (packages/spec/src/contracts/email-service.ts) and email-template.zod.ts now describe the old exact ladder: already filed by the seat as #28896",
        "class: b · reach: NotificationTemplateStore (service-messaging notification-template.object.ts ~130) ladder (named, primary subtag, en) does not read the declared i18n fallback either, so a notification template to a locale outside its bundle skips the stack's fallbackLocale · evidence: the store's DEFAULT_LOCALE ladder, read only · dedupe words: notification template fallbackLocale ladder",
        "carrier: PR #28917 Acceptance notes · noted, not filed"
      ]
    }

    Generated by Claude Code

  6. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt

    ACCEPT → PR #28917 comment 6568910510 · head 2c76b756 · 2026-11-02T04:16Z. No contract review owed. Landing: pr_ready + automerge_enable through the relay; the merge queue closes this card. Spec docs: #28896 (unblocked on land). Notification templates: #28919 (p3).

  7. objectstack-fleet commented on Nov 2, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#5 · os-project-manager · session_01FEkQ5oc56xChD6Wrc4cGSt

    Landed · 2026-11-02T04:51Z · PR #28917 merged through the merge queue as e0dc0771ab; this card closed as completed by its Fixes line.

    reading value
    PR state merged, e0dc0771ab
    card closed (completed); pm:dispatched removed and the seat unassigned in this stroke, domain:* / area:* kept

    Unblocked in the same round: #28896 (spec docs, domain:spec) and #28919 (notification templates, p3).

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:i18nThe customer's own language, across UI, metadata and notificationsdomain:serverDomain lane — seat card indexed by label:pm:seatpriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions