fix(plugin-email): a metadata-door email template edit survives the next boot - #21818
Conversation
…te, overlay included An email template edited through PUT /meta/email_template is stored as an org-scoped overlay on a deployment with a Default Organization, and boot hydration keeps org-scoped overlays out of the registry. The declared-template sweep read the registry, so it wrote the package wording back over the sending row on every boot while GET /meta kept serving the admin's wording. readDeclared now reads protocol.getMetaItems (the layered list the metadata door serves) in tenancy.defaultOrgId()'s organization: the Default Organization under single, none under a walled posture. A failed effective read projects nothing instead of falling back to the package layer. Seed-not-clobber is unchanged. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…sweep Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b31622da100e3484436841a61bdd1e070c106667 && git checkout b31622da100e3484436841a61bdd1e070c106667
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a3ffc4512df5d9ebc1c9e5dee70813d89bf549db bae6303d08b2d7fe3b257ebbd9ceac2ee9d075b5 && git checkout -B drift-repro a3ffc4512df5d9ebc1c9e5dee70813d89bf549db && git merge --no-ff bae6303d08b2d7fe3b257ebbd9ceac2ee9d075b5
node scripts/docs-audit/affected-docs.mjs --json a3ffc4512df5d9ebc1c9e5dee70813d89bf549db
|
…erlay-survives-boot Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…ls an internal effective sweep The effective-template boot sweep moves to a module-internal bootstrapEffectiveEmailTemplates, which EmailServicePlugin's boot wiring calls with the protocol and tenancy services. The exported bootstrapDeclaredEmailTemplates keeps its published four-parameter signature and delegates with no sources, reading the registry as before. The package entry exports no new symbol: EffectiveEmailTemplateSources is no longer re-exported. One sweep body, two entry points. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
…ed docblock The exported bootstrapDeclaredEmailTemplates docblock is carried into the published index.d.ts, where a link to the module-internal function named an unexported symbol. The delegation note moves into the function body. Claude-Session: https://claude.ai/code/session_011K3zqE8Pv1Evw5hc8tZCnN Co-authored-by: Claude <noreply@anthropic.com>
Fixes #21785
Clause-②: no
What was broken
An email template edited through
PUT /api/v1/meta/email_template/:name(the Studio editor's door) went back to the package wording insys_email_templateon the next boot.GET /metakept serving the admin's wording, so the admin saw one thing and the mail went out with another.Mechanism, measured
I measured on the real showcase, two cold boots on one database file, with a throwaway probe that is not committed. The admin's session shape decides the outcome:
orgContext: false, the harness default)orgContext: true, plugin-auth's production default)organization_id = org_…)email_templateisallowOrgOverride: true. On a deployment with a Default Organization, every Studio save is therefore an org-scoped overlay.loadMetaFromDbhydrates env-wide rows only, so that overlay never reaches the registryreadDeclaredread (H1).projectionApplied: { success: true }). The boot sweep then read the package entry and wrote the package wording back.protocol.getMetaItemwith no organization answers the package wording. With the overlay's organization it answers the admin's wording. Reusing the door's effective reader (H2) therefore works only once an organization is named, and the sweep has none of its own.kernel:readyon boot 2, the moment the sweep runs,getMetaItems({ type, organizationId })answered the admin's wording andgetMetaItems({ type })the package wording (H4). The effective item is available there; it only needs the organization.The change
readDeclarednow readsprotocol.getMetaItems, the layered listGET /meta/email_templateserves: org overlay over env-wide overlay over package, one item per(name, locale)slot. It reads intenancy.defaultOrgId()'s organization:singlethat is the Default Organization (ADR-0131: there the organization is the environment), so the admin's overlay is projected;null, and the read is env-wide.The organization comes from the platform's existing rule for an org-less reader of org-overridable metadata. The anonymous form doors read a form in
defaultOrgId()'s organization (anonymousFormOrganizationin@objectstack/rest, precedent: #21331). The sending row stays organization-agnostic: template resolution keys on(name, locale)only, and per-organization template rows are a capability no ruling has opened.stripReadDecorations, because the strict schema refuses_diagnostics. The plugin's single-item effective read already does the same.protocolservice keeps the registry read. It has no metadata door, so nothing can overlay a declaration there.upsertDeclaredEmailTemplateis unchanged. This adds no second "customized" marker and nopackages/specedit.bootstrapDeclaredEmailTemplatestakes an optional fifth argument{ protocol, tenancy }(EffectiveEmailTemplateSources, now exported). Every existing caller is unaffected; the only caller is this plugin.Tests
Unit, dogfood and typecheck ran at
3dcc8cd0. The head94479dd1adds only the changeset, so the code is byte-identical. The gates ran at94479dd1.pnpm --filter @objectstack/plugin-email exec vitest run --maxWorkers=2: 31 files, 514 tests passed. Four new pins underbootstrapDeclaredEmailTemplates — the effective template (#21785):defaultOrgId()isnull;pnpm --filter @objectstack/plugin-email typecheck: exit 0. That coverstsc --noEmitandcheck:test-typecheck; the test layer compiles undertsconfig.test.json.packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts. It runs the real showcase withorgContext: trueon one database file through three cold boots:GET /metaand a realsendTemplaterender all carry the admin's wording;PATCH(stampedcustomized: true) survives the next restart.test/email-template-materialization.dogfood.test.tsbeside it: 2 files, 5 tests passed.pnpm --filter @objectstack/dogfood typecheck: exit 0.dist/built at the base18c2ddc1, fix marker count 0): the new dogfood pin went 1 failed, 2 passed. Its failure is the card's symptom: the row read✅ Task done: {{title}}instead of the admin's wording after the restart.Ablation
Both legs ran on the committed tree through
scripts/ablation-replace.mjs, each with a restore trap.b0f455bdtocc6d6a3c. After a rebuild,ablation-dist-preflight.mjsfound the marker indist/index.jsanddist/index.mjs.#21785pins red (4 failed, 17 passed)."subject": "✅ Task done: {{title}}"received.b0f455bdequals HEAD andgit diff HEADis empty. After the rebuild, the--absentpreflight passed.{ seeded: 1 }received,{ seeded: 0 }expected), 1 failed and 20 passed. Restored: blob equals HEAD,git diff HEADis empty.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 68 commands at94479dd1against the merge base18c2ddc1.pnpm check:dual-build-cjs-loadsfirst exited 3 (PREREQUISITE NOT MET: eight packages outside this diff had nodist/). After building them it exited 0: 106 require entry points across 66 packages load.--ran:68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.--print-configon the changeset answersundefined;--format json: 5 files, 0 errors, 0 warnings;parserOptions.project), its rule plugins are per-file AST rules, and its only file reads at config load are two baseline JSONs this diff does not touch. The diff cannot move a verdict on any untouched file.Acceptance notes
packages/objectql/src/registry-i18n-bundle-key.test.tshas a comment saying the materializer readsregistry.listItems('email_template'). It stays true in substance, because that listing isgetMetaItems' base layer, so it is not edited (outside this PR's surface).content/docssentence became false.email-templates.mdxalready says "A reworded transactional mail survives your next deploy", which now holds for metadata-door edits too.Patch round 1 (head bae6303)
Appended by the seat (
domain:services#1,session_011K3zqE8Pv1Evw5hc8tZCnN) from the dev's patch-round report5989088536, after seat review5988470640.bootstrapEffectiveEmailTemplates, whichEmailServicePlugin's boot wiring calls with theprotocolandtenancyservices. The exportedbootstrapDeclaredEmailTemplateskeeps its published four-parameter signature and delegates with no sources, so a caller outside the plugin reads the registry exactly as before.EffectiveEmailTemplateSourcesis no longer re-exported. It is one sweep body with two entry points.bae6303d:src/index.tsis byte-identical to the base;dist/index.d.tsnames neither new identifier;exportsmap names only., so the module file is not separately addressable.patchwithClause-②: no.origin/main(18c7dfd2) with no conflict.bae6303d:dispatch-gates --commandslists 68 commands, all exit 0.--ran: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.Generated by Claude Code