Skip to content

fix(plugin-email): materialize a runtime email_template write without a restart (#7733) - #7878

Merged
huangyiirene merged 1 commit into
mainfrom
claude/issue-7733-email-template-runtime-write
Aug 12, 2026
Merged

fix(plugin-email): materialize a runtime email_template write without a restart (#7733)#7878
huangyiirene merged 1 commit into
mainfrom
claude/issue-7733-email-template-runtime-write

Conversation

@huangyiirene

@huangyiirenehuangyiirene commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Fixes#7733

Premise: confirmed, and the cross-lane fork is NOT forced

The card's own analysis placed 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". Measurement says the runtime-write path never reaches the metadata service at all, so there is nothing for packages/metadata* to fix:

  • PUT /api/v1/meta/:type/:name (packages/rest/src/rest-server.ts:5753) calls protocol.saveMetaItem.
  • saveMetaItem (packages/metadata-protocol/src/protocol.ts:9678) persists to sys_metadata via the overlay repo, calls applyRegistryWriteThrough — that is the [Registry] Registered email_template line the QA run saw, from packages/objectql/src/registry.ts:2006, the SchemaRegistry, not the MetadataManager — then announces on its own seam: runMutationProjector (awaited, ADR-0094) then emitMetadataMutation (fire-and-forget, Authored (Studio) hooks never execute their body — no bodyRunner on the metadata-service bind path #2588).
  • It never calls MetadataManager.register(). And notifyWatchers — the sole producer of metadataService.subscribe callbacks — has zero callers outside metadata-manager.ts.

So the plugin's watcher was armed against an event the authoring door does not emit. The boot log "subscribed to email_template metadata changes" was true; it just named the other door. This is the same shape as the sibling precedent #7742 / PR #7847 (connectors), and it takes the same services-side answer: subscribe to a source the runtime write actually refreshes. No packages/metadata* diff.

The fix

EmailServicePlugin now bridges both doors through one shared materializer:

  • metadataService.subscribe('email_template', …) — package ingest / artifact reload (unchanged behaviour).
  • the protocol's mutation seamPUT /meta, the Studio save behind it, publish, delete.

The awaited ADR-0094 registerMutationProjector is preferred, exactly as plugin-security's permission projection prefers it: the write itself carries the materialization, so the QA repro's PUT → query sys_email_template is consistent with no race window, and a failure is reported on the save's own projectionApplied instead of vanishing into a log. onMetadataMutation is the fallback for protocol implementations predating the projector. A kernel with no protocol service is a silent no-op — it has no runtime-authoring door, and the boot sweep still covers it.

Draft saves stay inert (ADR-0005 staging buffer). Both seams landing the same write is harmless and deliberately un-deduped: upsertDeclaredEmailTemplate is keyed on (name, locale) and idempotent.

Two things found while wiring the delete path

  • A delete is not automatically a withdrawal.DELETE /meta/:type/:name discards a customization overlay, so on an artifact-backed template it resets to the packaged declaration. Deactivating there would silently retire a template the package still ships. The bridge re-reads the effective item and re-materializes the revealed baseline; rows are deactivated only when nothing declares the name any more. A failed read is not an answer and deactivates nothing — a transient DB error must never be what stops a live template being sent.
  • A served item carries read decorations (_diagnostics from decorateMetadataItem, _packageId / _provenance), and EmailTemplateDefinitionSchema is a strictObject declaring no underscore key — so an unstripped body would reject the very baseline the reset exists to restore. Stripped before the upsert.

dispose() disarms the bridge explicitly: the projector seam is a per-type Map slot with no unregister verb, so without it a torn-down plugin would keep writing through an engine whose provenance hook is already unbound.

#7731 invariant preserved

The read side is untouched, and 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.

Reverse-verification

The new suite was run against origin/main in a separate worktree: 9 of the 14 new cases fail, exactly the way the QA run observed. The other 5 are never-regress guards that pass on both sides and are meant to — draft inert, other metadata types ignored, admin-authored rows never clobbered, boots without a protocol service, detaches on dispose. The 21 pre-existing test files are green on main, so the only red is the new file.

Gates

GateResult
pnpm --filter @objectstack/plugin-email... build (dep closure)
pnpm --filter @objectstack/plugin-email test✅ 22 files, 334 tests (320 baseline + 14 new)
pnpm --filter @objectstack/plugin-email typecheck
pnpm --filter @objectstack/cli typecheck (downstream consumer)
pnpm check:docs-audit-scope
.changeset/email-template-runtime-write.md✅ patch
Engine-double gate✅ the new fake's update routes through assertEngineUpdateDispatch

packages/spec/src/** is not touched, so no schema/docs regeneration applies. The full lint.yml farm was deliberately not run locally.

… a restart (#7733)
`PUT /api/v1/meta/email_template/:name` returned 200 and persisted the row, but
the template never reached `sys_email_template` — so it did not send until the
process restarted, at which point the boot sweep picked the persisted row up and
it worked. Neither of the live path's own log lines ever fired, while the boot
line "subscribed to email_template metadata changes" was present.
The bridge was armed against the wrong announcement. `bootDeclaredTemplates`
subscribed via `metadataService.subscribe('email_template', …)`, whose only
producer is `MetadataManager.register()` → `notifyWatchers()`. The REST save does
not go through there: it calls `protocol.saveMetaItem`, which persists to
`sys_metadata`, write-throughs to the ObjectQL SchemaRegistry (the
`[Registry] Registered email_template` line the QA run saw) and announces on its
own post-persistence seam. `notifyWatchers` has no caller outside
`MetadataManager`, so the watcher could not fire for a runtime write — the
subscription registered fine, just to the other door. The gap is NOT in the
metadata service's event delivery: the runtime-write path never reaches it.
Both doors are now bridged through one shared materializer:
* the existing metadata-service subscription — package ingest / artifact
reload; and
* the protocol's mutation seam — `PUT /meta`, the Studio save behind it,
publish and delete. The awaited ADR-0094 `registerMutationProjector` is
preferred, as plugin-security's permission projection prefers it, so the
write itself carries the materialization (a PUT followed by a read of
`sys_email_template` is consistent, with no race window) and a failure is
reported on the save's own `projectionApplied` instead of only in a log.
`onMetadataMutation` is the fallback for protocols predating the projector.
Draft saves stay inert (the ADR-0005 staging buffer), and both seams landing the
same write is harmless — the upsert is keyed on `(name, locale)`, which also
keeps #7731's invariant that a row's `locale` column holds the tag the loader
queries it by.
A delete is no longer read as a withdrawal on its own. `DELETE /meta/:type/:name`
discards a CUSTOMIZATION overlay, so on an artifact-backed template it resets to
the packaged declaration; the bridge re-reads the effective item (stripping the
underscore read decorations a served item carries, since the spec schema is a
strictObject) and re-materializes the revealed baseline, deactivating rows only
when nothing declares the name any more. A failed read is not an answer and
deactivates nothing.
The projector seam has no unregister verb, so `dispose()` disarms the bridge
explicitly rather than leaving a torn-down plugin writing through an engine whose
provenance hook is already unbound.
Reverse-verified: 8 of the 13 new cases fail on origin/main exactly as the QA run
observed; the other 5 are never-regress guards (draft inert, other types ignored,
admin rows never clobbered, boots without a protocol, detaches on dispose) that
pass on both sides.
Co-authored-by: Claude <noreply@anthropic.com>
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 1:43am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-email.

4 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via @objectstack/plugin-email)
  • content/docs/deployment/environment-variables.mdx(via @objectstack/plugin-email)
  • content/docs/plugins/packages.mdx(via @objectstack/plugin-email)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/plugin-email)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 12, 2026 01:56
@huangyiirene
huangyiirene added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 2ff87a2Aug 12, 2026
27 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7733-email-template-runtime-write branch August 12, 2026 02:08
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@huangyiirene@claude