Skip to content

[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

Description

@os-zhuang

Measured while landing #9205 (PR #9224 — notify nodes reference email templates, resolved (name, recipient locale) at delivery). Filed unassigned — recording, not claiming.

The gap

A template-path notify node with channels: ['inbox', 'email'] now delivers a localized email and an inbox row whose title is the topic and whose body is empty (honest degraded rendering, deliberately not an empty string — see PR #9224's "Inbox channel: gap documented" section).

Root cause, measured on the #9224 head:

  • The inbox channel writes sys_inbox_message rows straight from the notification title/body; it is not wired to any template subsystem (NotificationTemplateStore is keyed by (topic, channel, locale) — a different subsystem from sys_email_template anyway).
  • IEmailService exposes only send/sendTemplate — there is NO render-only method. The locale ladder + {{var}} renderer (with ADR-0053 format filters) live inside plugin-email, unreachable without sending mail.
  • Wiring the inbox today would mean either a new IEmailService contract method or a duplicated resolver/renderer in service-messaging (a second de-facto contract that drifts) — the "forced new subsystem" shape 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's dispatch ruled out for that card.

Natural shape (from the landing analysis, for triage to price)

A small render-only method on IEmailService (e.g. renderTemplate({ template, locale, data }) → { subject, html/text }), implemented once in plugin-email next to the existing resolver, consumed by the inbox channel the same way the email channel consumes sendTemplate. Contract change on a service interface ⇒ if promoted, the implementing card is domain:spec-adjacent (contract face) and per standing tiering carries the fable floor.

Consumers waiting

hotcrm's 16 notify nodes (4 locales) unlock at #9205's landing for the email channel; their inbox half stays degraded until this seam exists.

Refs: #9205 / PR #9224 (measurement site) · #7646 (flows translation surface exclusion) · ADR-0053 (format filters).

Activity

  1. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    ContributorAuthor

    First-touch triage: promoted finding → needs-user-decision, type Feature, domain:spec. The proposed seam is a new method on IEmailService (packages/spec/src/contracts/email-service.ts, verified on origin/main) — a public contract expansion, which is the human floor by standing rule; no seat may adjudicate it.

    Decision requested: add a render-only method to IEmailService (e.g. renderTemplate({ template, locale, data }) → { subject, body }), implemented once in plugin-email beside the existing resolver and consumed by the inbox channel — versus accepting the documented degraded inbox rendering (topic-as-title).

    Four prisms:

    1. Platform long-term coherence — the seam removes the forced choice between "duplicate the resolver in service-messaging" (a second de-facto contract that drifts) and "inbox stays degraded". One resolver, two channels; shrinks special-casing.
    2. Measured business pull — real and present: hotcrm's 16 notify nodes × 4 locales get localized email at 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's landing but topic-as-title inbox rows until this seam exists. A concrete consumer, not speculative surface.
    3. AI-agent error-resistance — a declared contract method is the shape an AI consumer cannot get wrong; per-channel hand-rolled template resolution is the drift bed.
    4. Startup scope discipline — the against-axis: every contract method is a permanent obligation, and the degraded state is honest and documented. If inbox localization has no pull beyond hotcrm polish, defer is defensible.

    Recommendation: approve the render-only method, scoped strictly to rendering (no send-path change). If approved, the implementing card is domain:spec contract face ⇒ clause-② tier floor.


    Generated by Claude Code

  2. os-zhuang commented on Aug 17, 2026

    @os-zhuang
    ContributorAuthor

    Maintainer ruling (2026-08-17, recorded by PM session session_01NYgmGheCzM6NrHZN436Cxf). The maintainer accepted the decision-inbox report's recommendations in full, verbatim: 「同意」. For this card:

    Ruled: approved — IEmailService gains a render-only method (renderTemplate({ template, locale, data }) → { subject, body }), implemented once in plugin-email beside the existing locale ladder + {{var}} renderer, consumed by the inbox channel the way the email channel consumes sendTemplate.

    • Strictly render-only: zero send-path changes; the method must not touch delivery, queueing, or outbox behaviour.
    • Basis: one resolver, two channels (kills the forced choice between a duplicated resolver in service-messaging and a permanently degraded inbox); the consumer is concrete and present — hotcrm's 16 notify nodes × 4 locales get localized inbox rows the day this lands.
    • Contract expansion on packages/spec/src/contracts/email-service.ts ⇒ claude-fable-5 mandatory, spec lane; consumer-package fixture triage per standing rule.

    First-touch grading completes: label flipped needs-user-decision → pm:queue in the same stroke.


    Generated by Claude Code

  3. self-assigned this
    on Aug 17, 2026
  4. os-steve commented on Aug 17, 2026

    @os-steve
    Collaborator

    Claim: PM loop round 1
    Session: session_01Fs18A2DdXLVN2h8PaaFBcP
    Branch: claude/issue-9225-email-render-only-seam
    Worktree: objectstack-issue-9225
    Domain: domain:spec
    File surface: packages/spec/src/contracts/email-service.ts (contract method, region: IEmailService interface + input/result types), packages/plugins/plugin-email/** (single implementation beside the existing resolver), packages/services/service-messaging/** (inbox channel consumption only), tests + .changeset/ (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: fable — mandatory per the maintainer's 2026-08-17 ruling on this card (contract expansion on packages/spec/src/contracts/email-service.ts); this round's node scripts/pm/dispatch-gates.mjs --tier output flags the same path as clause-② suspect surface with no path-derived mandate — tier judged from card content, which is exactly the clause-② limb (public surface widens)
    Serial constraints cleared: #9228 in flight (packages/spec/src/data/** filter door, direct-dispatch channel) — disjoint; #9182 in flight (session session_01Y26DJEHSBhhAQ6wwfsHNza, references generator + generated content/docs/references/api/**) — file-disjoint but read-coupled if the new contract method regenerates reference pages; second lander runs scripts/pm/os-regen-merge.sh; #9157 / PR #9191 (metadata-protocol/protocol.ts) — disjoint; no open PR touches email-service.ts, plugin-email, or service-messaging (checked this round). No in-flight sibling's pins assert this card's surface.


    Generated by Claude Code

  5. os-steve commented on Aug 17, 2026

    @os-steve
    Collaborator
    {
      "issue": 9225,
      "status": "done",
      "branch": "claude/issue-9225-email-render-only-seam",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9248",
      "premise_still_valid": true,
      "summary": "Implemented the ruled render-only seam: IEmailService.renderTemplate({ template, data, locale, timezone }) -> { subject, html, text }, implemented once in plugin-email by extracting the resolver sendTemplate already used (ladder, active check, variables_json validation, row-locale render opts moved verbatim; sendTemplate now delivers what the shared resolver renders — zero send-path changes, existing sendTemplate suite passes unchanged). The inbox channel consumes it the way the email channel consumes sendTemplate: payload.template renders subject -> title, text -> body_md per recipient; missing service/method or renderer error codes fail the delivery loudly and classifyError grades TEMPLATE_NOT_FOUND/TEMPLATE_INACTIVE/MISSING_VARIABLES/TEMPLATE_UNSUPPORTED permanent. Return-shape divergence (dispatch assumption 1) measured and recorded in the PR body: ruling spelled { subject, body }, card body { subject, html/text }; sys_email_template carries subject/body_html/body_text? and the inbox needs title+body_md, so the honest shape is { subject, html, text }. Assumption 2 held: inbox write site consumed the rendered content with no new contract face. Consumer fixture triage (direction: downstream consumers of @objectstack/spec): repo-wide sweep for 'implements IEmailService' and ': IEmailService' found exactly one non-plugin-email implementer (a plugin-auth test fake), which gained the required method.",
      "tests": "All at 567619ff7, clean tree, after final commit. pnpm --filter @objectstack/spec test: 407 files / 10846 passed. plugin-email test: 26 files / 417 passed. service-messaging test: 22 files / 242 passed. plugin-auth test: 55 files / 1261 passed. typecheck (spec, plugin-email, service-messaging, plugin-auth, rest): clean — the new-contract-name imports passing typecheck proves the rebuilt spec .d.ts was read. Reverse verification from committed state: inbox-channel.ts restored to origin/main => 5 of 6 new template-path tests red ('1 failed | 21 passed', the sixth green vacuously on the old channel — direction as observed, not templated); restored, green. Gates re-derived from actual diff paths and all green: changeset-gate-self-tests, cross-package-test-inputs, merge-driver, objectui-changeset, spec-parsed-alias, test-source-alias, type-source-resolution, query-options-erasure, nul-bytes, spec empty-state/liveness/strictness-ledger/variant-docs, doc-formula-expressions, adr-0087-registration, changeset-no-major, empty-changeset, dev-prereqs, affected-docs, type-check-coverage, type-check-debt --re-measure (none above recorded), engine-double-contract, where-matcher, i18n. Re-derivation added beyond the dispatch list: the test-file convention gates and changeset gates (all run green); spec check:generated --fix regenerated exactly api-surface + export-origins (the two new interface exports).",
      "open_questions": [],
      "out_of_scope_findings": []
    }

    Generated by Claude Code


    Generated by Claude Code

  6. os-steve commented on Aug 17, 2026

    @os-steve
    Collaborator

    ACCEPT — PR #9248 (reviewer of record: spec seat, session session_01Fs18A2DdXLVN2h8PaaFBcP, round 1).

    Verified on GitHub against the review checklist, not from the report's self-description:

    • Ruling compliance: the maintainer-ruled seam is implemented exactly — renderTemplate on IEmailService, one resolver extracted in plugin-email (send path byte-for-byte unchanged; the existing sendTemplate suite passing unmodified is the zero-send-path evidence), inbox channel consumes it the way the email channel consumes sendTemplate.
    • Return-shape divergence handled through the pre-authorized mechanism-assumption channel: ruling spelled { subject, body }, card spelled { subject, html/text }; the dev measured sys_email_template's actual columns and implemented { subject, html, text }, with the reasoning recorded in the PR body. Accepted as the ruling's substance (render-only, one resolver) made concrete.
    • Behavioural note, judged in-intent: a template-path inbox delivery now fails LOUDLY (TEMPLATE_UNSUPPORTED, graded permanent) when no render-capable email service exists, instead of silently degrading to topic-as-title. Declared = enforced doctrine; the template path itself only landed with 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 yesterday, so blast radius is effectively zero.
    • Scope: 11 files, all within the claimed surface plus two disciplined increments — the plugin-auth test fake gaining the now-required method (consumer fixture triage; the only non-plugin-email IEmailService implementer in the sweep, direction stated) and the regenerated api-surface/export-origins pair (exactly the two new interface exports).
    • Clause-② enqueue gate: diff touches packages/spec/src/contracts/email-service.ts; dispatch tier was claude-fable-5 = CONTRACT_REVIEW_TIER (single-sourced from scripts/pm/dispatch-gates.mjs) — not below the floor, so the PR may enqueue without needs:contract-review.
    • Changeset: minor for the three published packages, present.
    • Evidence: real commands and counts per consumer package; reverse verification is directional (5/6 new tests red on the restored channel, the vacuous sixth reported honestly).

    Landing: ESLint and TypeScript Type Check conclusions at head 567619ff7 will be verified before flipping ready; this PR carries generated artifacts, so if main moves on them before merge, the landing runs scripts/pm/os-regen-merge.sh per standing procedure.


    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

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions