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. added
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    domain:specDomain lane — seat card indexed by label:pm:seat
    and removed
    findingRecorded observation — held, not dispatchable until the findings triage round grades it
    on Aug 17, 2026
  2. 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

  3. 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

  4. added
    pm:queueReady for the PM dispatch loop
    and removed
    needs-user-decisionBlocked on a maintainer decision — do not dispatch; decision cards only, never on a PR
    on Aug 17, 2026
  5. added
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:queueReady for the PM dispatch loop
    on Aug 17, 2026
  6. self-assigned this
    on Aug 17, 2026
  7. 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

  8. 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

  9. 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

Labels

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

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions