Skip to content

email-template-render (c): a runtime email_template write (PUT /meta) never materializes without a restart — the live subscription callback never fires #7733

Description

@huangyiirene

Symptom

PUT /api/v1/meta/email_template/:name returns 200 and logs [Registry] Registered email_template, but the row never reaches sys_email_template. Neither of the subscription's own log lines fires — 0 occurrences of "materialized from a runtime write", 0 of "runtime email-template sync failed" — even though the boot line "subscribed to email_template metadata changes" is present. Decisive control: restarting on the same DB materializes it immediately ({seeded:1, skipped:1}), so the bridge itself works and only the live event path is dead.

Root cause

Not fully located by the run. The callback registered at packages/plugins/plugin-email/src/email-plugin.ts (~line 968) via metadataService.subscribe('email_template', ...) never runs on the PUT /meta path, so the upsertDeclaredEmailTemplate write inside it — and its two log lines (~lines 983/986) — never execute. The subscription registers without error (its boot log at ~line 991 prints), so the gap is upstream of the callback: the metadata service does not deliver an added/changed event for the email_template namespace on the runtime-write path that the plugin's own comment names ("Studio saves / PUT /meta land as added/changed events"). The declared-bootstrap and restart paths handle the item fine.

Stale-premise check: email-plugin.ts has zero diff between the 92f26f75 pin and origin/main — not fixed upstream.

Reproduction

  1. Boot with plugin-email; confirm "subscribed to email_template metadata changes" in the log.
  2. PUT /api/v1/meta/email_template/:name with a valid template body; observe 200 and [Registry] Registered email_template.
  3. Query sys_email_template for the name.

Expected: the row is materialized and a "materialized from a runtime write" line is logged. Actual: no row, neither callback log line fires; a restart on the same DB then materializes it.

Source

Extracted from the QA run #7690 (framework 92f26f7, console 09987b680).

Activity

  1. huangyiirene commented on Aug 11, 2026

    @huangyiirene
    CollaboratorAuthor

    Deferral note (services seat, round 1, session session_01JVfWSQN9RDDGwdVSmMc29x) — NOT a claim. Two things the eventual dispatcher of this card should not rediscover:

    1. Serialized behind email-template-render (b): sendTemplate with no locale renders an arbitrary locale instead of the en-US default #7731 (same package plugin-email, in flight as of 2026-08-11 14:2xZ on branch claude/issue-7731-email-default-locale): that PR touches email-plugin.ts (:548 loader) and email-service.ts (:1121). This card's own anchor (~:968 subscribe callback, ~:983/:986/:991 log lines) is in the same file — after email-template-render (b): sendTemplate with no locale renders an arbitrary locale instead of the en-US default #7731 merges, re-verify all line anchors against the merged ref before dispatching (the widget-contract.mdx 整页 + quick-reference.mdx 一行仍在教 #5055 退役的 WidgetManifest 家族 —— 手写散文面,门禁不覆盖 #6413 class: pre-merge line numbers read fine and are wrong).
    2. Cross-lane risk in the root cause: the card's own analysis places the gap "upstream of the callback — the metadata service does not deliver an added/changed event for the email_template namespace on the runtime-write path". If the true fix lands in packages/metadata*, that is domain:metadata territory — the dispatch should carry an explicit STOP-and-report condition for that fork (transfer per the cross-seat protocol rather than patching the consumer side), leaving only a plugin-email-side fix (if any) in this lane.

    Generated by Claude Code

  2. self-assigned this
    on Aug 12, 2026
  3. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    Claim: PM loop round 3
    Session: session_01JVfWSQN9RDDGwdVSmMc29x
    Branch: claude/issue-7733-email-template-runtime-write
    Worktree: cloud session's own clone (mode:cloud)
    Domain: domain:services
    File surface: packages/plugins/plugin-email/src/email-plugin.ts (the metadataService.subscribe('email_template', …) callback and its wiring). ⚠️ If the true fix is in the metadata service's event delivery for the runtime-write path, that is domain:metadata — STOP-and-report fork, per the deferral note above.
    Container & model: M (event-delivery diagnosis with a cross-lane fork), mode:cloud, model: opus
    Serial constraints cleared: ✅ same-file predecessor #7731 (PR #7787) MERGED — serialization released. Line anchors re-verified on the merged ref and they MOVED: the card's ~:968 / :983 / :986 / :991 are now subscribe(...) at :960, "materialized from a runtime write" at :975, "runtime email-template sync failed" at :978, "subscribed to email_template metadata changes" at :983. The dispatch carries the corrected anchors and an instruction to work from origin/main. No other in-flight PR touches plugin-email.


    Generated by Claude Code

  4. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor
    {
      "issue": 7733,
      "status": "draft_pr_open",
      "premise_still_valid": true,
      "premise_correction": "The symptom reproduces exactly as filed, but the card's ROOT-CAUSE placement was wrong in a way that dissolves the cross-lane fork. The card said the metadata service fails to deliver an added/changed event for the email_template namespace on the runtime-write path. Measured: the runtime-write path never reaches the metadata service at all. PUT /api/v1/meta/:type/:name (packages/rest/src/rest-server.ts:5753) calls protocol.saveMetaItem (packages/metadata-protocol/src/protocol.ts:9678), which persists to sys_metadata, write-throughs to the ObjectQL SchemaRegistry (packages/objectql/src/registry.ts:2006 — the '[Registry] Registered email_template' line the QA run saw is the SchemaRegistry, NOT the MetadataManager), and then announces on its OWN post-persistence seam: runMutationProjector (awaited, ADR-0094) followed by emitMetadataMutation (fire-and-forget, #2588). It never calls MetadataManager.register(), and notifyWatchers — the sole producer of metadataService.subscribe callbacks — has zero callers outside packages/metadata/src/metadata-manager.ts. So the subscription registered fine (its boot log was honest) but was armed against an event the authoring door does not emit.",
      "cross_lane_fork": {
        "triggered": false,
        "reason": "The STOP condition was 'measurement confirms the fix belongs in the metadata service's event delivery'. It does not. There is no metadata-side event to fix, because saveMetaItem has no link to the metadata service on this path — it announces on the protocol's own seam, which every other consumer of runtime authoring already uses (packages/objectql/src/plugin.ts:253 hook/action rebind, packages/core/src/fallbacks/authored-translation-sync.ts:216, packages/plugins/plugin-security/src/permission-set-projection.ts:610). The fix is entirely plugin-email-side, matching the sibling precedent #7742/PR #7847, which solved the same shape services-side by reading a source the runtime write actually refreshes. Zero diff outside packages/plugins/plugin-email.",
        "metadata_side_change_needed": "none"
      },
      "pr": {
        "number": 7878,
        "url": "https://github.com/objectstack-ai/objectstack/pull/7878",
        "state": "draft",
        "branch": "claude/issue-7733-email-template-runtime-write",
        "base": "main",
        "head_sha_branch_commit": "cf336c6"
      },
      "change_summary": {
        "files": [
          "packages/plugins/plugin-email/src/email-plugin.ts",
          "packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts (new)",
          ".changeset/email-template-runtime-write.md (new, patch)"
        ],
        "approach": "EmailServicePlugin now bridges BOTH authoring doors through one shared materializer (materializeTemplateMutation). Door 1 is the existing metadataService.subscribe('email_template') — package ingest / artifact reload, behaviour unchanged. Door 2 is new: the protocol mutation seam, preferring the AWAITED ADR-0094 registerMutationProjector('email_template', ...) — the same preference plugin-security's permission projection expresses — so the write itself carries the materialization (PUT then read of sys_email_template is consistent, no race window) and a failure is reported on the save's own projectionApplied rather than only in a log; onMetadataMutation is the fallback for protocols predating the projector. A kernel with no 'protocol' service is a silent no-op.",
        "incidental_correctness_findings": [
          "DELETE /meta/:type/:name discards a CUSTOMIZATION overlay (ADR-0005), so on an artifact-backed template it RESETS to the packaged declaration rather than removing it. Wiring the delete path naively would have deactivated a template the package still ships. The bridge now re-reads the EFFECTIVE item (protocol.getMetaItem, falling back to metadataService.get) and re-materializes the revealed baseline; rows are deactivated only when nothing declares the name any more. A FAILED read is a third outcome that deactivates nothing — a transient DB error must never be what stops a live template being sent.",
          "protocol.getMetaItem returns a DECORATED item (_diagnostics from decorateMetadataItem, _packageId / _provenance from the registry and overlay row) while EmailTemplateDefinitionSchema is a strictObject declaring no underscore key — an unstripped body would have rejected the very baseline the reset exists to restore. Read decorations are stripped before the upsert (the read-side twin of the strip saveMetaItem does on the write side).",
          "The projector seam is a per-type Map slot with no unregister verb, so dispose() disarms the bridge explicitly (templateBridgeArmed) rather than leaving a torn-down plugin writing sys_email_template rows through an engine whose provenance hook is already unbound."
        ]
      },
      "invariants_held": {
        "issue_7731_locale_ladder": "Untouched on the read side. The write side still goes through mapTemplateToRow, so a row's `locale` column holds the tag the loader queries it by. Pinned explicitly in the first new test rather than left implicit.",
        "seed_not_clobber": "Both doors share one materializer, so admin-authored and `customized` rows are protected on the runtime-write path by exactly the same rules as the boot sweep. Pinned by a guard test.",
        "draft_staging_buffer": "state === 'draft' is inert, matching every other consumer of this seam."
      },
      "tests": {
        "new_file": "packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts",
        "new_cases": 14,
        "reverse_verification": "Run against origin/main (0dcbc11) in a separate detached worktree with only the new test file copied in: 9 of 14 FAIL, exactly the way the QA run observed (no row, no callback log). The remaining 5 are never-regress guards that pass on both sides by design — draft inert, other metadata types ignored, admin rows never clobbered, boots without a protocol service, detaches on dispose. All 21 pre-existing plugin-email test files are green on main, so the new file is the only red.",
        "doubles_note": "The protocol double mirrors ObjectStackProtocolImplementation's two registration seams and its MetadataMutationEvent shape ({type: singular metadata type, name, state: active|draft|deleted}, plus body on the projector), including runMutationProjector's catch-and-report-on-the-response behaviour — the same style of double every other consumer of this seam is tested against."
      },
      "gates": {
        "build_closure": "PASS — pnpm --filter @objectstack/plugin-email... build",
        "package_suite": "PASS — pnpm --filter @objectstack/plugin-email test: 22 files, 334 tests (320 baseline + 14 new)",
        "typecheck": "PASS — plugin-email; also pnpm --filter @objectstack/cli typecheck (the one downstream consumer) after building its closure",
        "check_docs_audit_scope": "PASS",
        "changeset": "PRESENT — .changeset/email-template-runtime-write.md (patch, @objectstack/plugin-email)",
        "engine_double_gate": "PASS — the new test fake's update verb routes through assertEngineUpdateDispatch from '@objectstack/objectql' (the export plugin-email's four existing tests already use for the delete twin); the fake has no delete verb",
        "spec_regeneration": "N/A — packages/spec/src/** not touched",
        "lint_farm": "NOT RUN LOCALLY, per dispatch instruction"
      },
      "open_questions": [
        "Not browser-verified against a live server. The repro needs a booted app plus a PUT with the manage_metadata capability; the fix was measured at source level (the announcement seams and their call sites) and pinned by a reverse-verified suite instead. If the PM wants a dogfood pass before landing, the observable is the new log line 'EmailServicePlugin: projecting email_template metadata mutations' at boot, then a row in sys_email_template immediately after the PUT — with no restart.",
        "Decided without asking (three-axes: correctness > blast radius > complexity): the projector RETHROWS after logging, so a failed materialization surfaces on the save's own projectionApplied instead of only in a log. Cost is a visible failure field on a write that did persist its metadata row; benefit is that an author whose template did not materialize learns it from the write rather than from a mail that never arrives. Reversible in one line if the PM prefers the quieter posture.",
        "Decided without asking: the two seams are NOT deduped when both announce the same write. The upsert is keyed on (name, locale) and idempotent, so the second delivery costs one indexed lookup and an update of the row it just wrote; deduping would need per-write state whose only job is to skip that. Pinned by a test asserting exactly one row."
      ]
    }

    Generated by Claude Code

  5. huangyiirene commented on Aug 12, 2026

    @huangyiirene
    CollaboratorAuthor

    ACCEPT — PR #7878 (services seat review, session session_01JVfWSQN9RDDGwdVSmMc29x).

    The dev disproved this card's root-cause placement, and that is the most valuable thing in the round. The card (and my dispatch, which adopted it) said the metadata service fails to deliver an event for the email_template namespace on the runtime-write path — I even armed a cross-lane STOP fork for it. Measured: the runtime-write path never reaches the metadata service at all. PUT /meta → protocol.saveMetaItem → persists to sys_metadata, write-throughs to the ObjectQL SchemaRegistry (which is what emitted the [Registry] Registered email_template line the QA run read as evidence of registration), then announces on the protocol's own seam. notifyWatchers — the sole producer of metadataService.subscribe callbacks — has zero callers outside metadata-manager.ts. So the subscription was honest and armed against a door the authoring path does not use. Fork correctly not triggered: zero diff outside plugin-email, no metadata-side change needed. Corrected premise recorded here so the next reader inherits it rather than the card's version.

    What shipped: both authoring doors bridged through one shared materializer — the existing metadata subscription (package ingest / artifact reload, unchanged) plus the protocol mutation seam, preferring the awaited ADR-0094 projector (as plugin-security's permission projection does) so the write carries the materialization and a failure lands on the save's own projectionApplied instead of only a log; onMetadataMutation is the fallback.

    Review notes — three incidental findings, each a real defect the naive fix would have introduced:

    • DELETE is not a withdrawal. DELETE /meta/:type/:name discards a customization overlay (ADR-0005), so on an artifact-backed template it resets to the packaged declaration. Wiring delete naively would have deactivated a template the package still ships. The bridge re-reads the effective item and re-materializes the revealed baseline; a failed read is a distinct third outcome that deactivates nothing — exactly the right call: a DB blip must never be what stops a live template being sent.
    • Read decorations vs. strictObject. getMetaItem returns _diagnostics/_packageId/_provenance; EmailTemplateDefinitionSchema declares no underscore key, so an unstripped body would have rejected the very baseline the reset restores. Stripping is the read-side twin of saveMetaItem's write-side strip.
    • Projector slot has no unregister verb ⇒ explicit templateBridgeArmed disarm on dispose, rather than a torn-down plugin writing rows through an unbound engine.

    Also verified: #7731's locale-ladder invariant explicitly pinned (row's locale holds the queried tag) rather than left implicit; seed-not-clobber shared with the boot sweep; draft inert. Reverse verification 9/14 red on origin/main, the 5 green-both-ways are by-design guards. All 26 checks green; changeset present; engine-double gate satisfied via assertEngineUpdateDispatch.

    Endorsed judgment calls: the projector rethrow (author learns from the write, not from a mail that never arrives — reversible in one line if the maintainer prefers the quiet posture) and the no-dedup decision (idempotent upsert keyed on (name, locale), pinned by a one-row test). The not-browser-verified note is honest and proportionate — source-level measurement plus a reverse-verified suite is the right evidence bar for this card; if a dogfood pass is wanted the observable is named in the report.

    Marking ready and arming auto-merge now.


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions