Skip to content

fix(plugin-email): a metadata-door email template edit survives the next boot - #21818

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21785-email-template-overlay-survives-boot
Oct 5, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-21785-email-template-overlay-survives-boot

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

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 in sys_email_template on the next boot. GET /meta kept 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:

Admin session Where the PUT lands Registry at boot 2 Sending row after boot 2
no organization (orgContext: false, the harness default) env-wide overlay package entry, then the hydrated overlay admin's wording, kept
Default Organization (orgContext: true, plugin-auth's production default) org-scoped overlay (organization_id = org_…) package entry only package wording: the defect
  • email_template is allowOrgOverride: true. On a deployment with a Default Organization, every Studio save is therefore an org-scoped overlay. loadMetaFromDb hydrates env-wide rows only, so that overlay never reaches the registry readDeclared read (H1).
  • The save-time projection had already written the admin's wording into the sending row (H3: the row read the admin's wording before the restart, with projectionApplied: { success: true }). The boot sweep then read the package entry and wrote the package wording back.
  • protocol.getMetaItem with 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.
  • At kernel:ready on boot 2, the moment the sweep runs, getMetaItems({ type, organizationId }) answered the admin's wording and getMetaItems({ type }) the package wording (H4). The effective item is available there; it only needs the organization.

The change

readDeclared now reads protocol.getMetaItems, the layered list GET /meta/email_template serves: org overlay over env-wide overlay over package, one item per (name, locale) slot. It reads in tenancy.defaultOrgId()'s organization:

  • under single that is the Default Organization (ADR-0131: there the organization is the environment), so the admin's overlay is projected;
  • under any walled posture it is 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 (anonymousFormOrganization in @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.

  • Served items go through the shared stripReadDecorations, because the strict schema refuses _diagnostics. The plugin's single-item effective read already does the same.
  • A failed effective read projects nothing that boot and warns. It never puts the registry's package layer in its place.
  • A host with no protocol service keeps the registry read. It has no metadata door, so nothing can overlay a declaration there.
  • Seed-not-clobber is untouched (H5): upsertDeclaredEmailTemplate is unchanged. This adds no second "customized" marker and no packages/spec edit.
  • bootstrapDeclaredEmailTemplates takes 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 head 94479dd1 adds only the changeset, so the code is byte-identical. The gates ran at 94479dd1.

  • pnpm --filter @objectstack/plugin-email exec vitest run --maxWorkers=2: 31 files, 514 tests passed. Four new pins under bootstrapDeclaredEmailTemplates — the effective template (#21785):
    • the org-scoped overlay is projected, read in the default organization;
    • the read is env-wide when defaultOrgId() is null;
    • a failed read projects nothing and never the package layer;
    • seed-not-clobber holds over the effective read.
  • pnpm --filter @objectstack/plugin-email typecheck: exit 0. That covers tsc --noEmit and check:test-typecheck; the test layer compiles under tsconfig.test.json.
  • New dogfood pin packages/qa/dogfood/test/email-template-overlay-survives-boot.dogfood.test.ts. It runs the real showcase with orgContext: true on one database file through three cold boots:
    • precondition: the PUT lands org-scoped and is projected at once;
    • after a restart, the sending row, GET /meta and a real sendTemplate render all carry the admin's wording;
    • control: a data-door PATCH (stamped customized: true) survives the next restart.
  • Dogfood run with test/email-template-materialization.dogfood.test.ts beside it: 2 files, 5 tests passed. pnpm --filter @objectstack/dogfood typecheck: exit 0.
  • Before the fix (plugin-email dist/ built at the base 18c2ddc1, 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.

  • A: the sweep reads the package layer again. The effective branch was made always-false with a marker literal. On disk the anchor went 1 to 0 and the blob b0f455bd to cc6d6a3c. After a rebuild, ablation-dist-preflight.mjs found the marker in dist/index.js and dist/index.mjs.
    • Unit: all four #21785 pins red (4 failed, 17 passed).
    • Dogfood: the cold-boot pin red, with "subject": "✅ Task done: {{title}}" received.
    • Restored: blob b0f455bd equals HEAD and git diff HEAD is empty. After the rebuild, the --absent preflight passed.
  • B: a failed effective read falls back to the registry. Unit: the failed-read pin went red ({ seeded: 1 } received, { seeded: 0 } expected), 1 failed and 20 passed. Restored: blob equals HEAD, git diff HEAD is empty.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 68 commands at 94479dd1 against the merge base 18c2ddc1.
  • 67 exited 0 on the first run. pnpm check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: eight packages outside this diff had no dist/). After building them it exited 0: 106 require entry points across 66 packages load.
  • --ran: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.
  • Lint, narrowed and proven:
    • population, read from eslint's own config: the five changed code files are linted, and --print-config on the changeset answers undefined;
    • counts, from --format json: 5 files, 0 errors, 0 warnings;
    • invariance: the config never enables type-aware linting (no 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

  • Before this change, the env-wide overlay shape kept the admin's wording only because the registry lists the hydrated overlay after the package entry. The sweep wrote the package wording and then the overlay wording on every boot. It now writes the effective item once.
  • [Redacted by the seat (domain:services#1, session_011K3zqE8Pv1Evw5hc8tZCnN) at 2026-10-05T05:05Z: an adjacent class outside this card, held by the seat for measurement. Detail is withheld from public surfaces.]
  • packages/objectql/src/registry-i18n-bundle-key.test.ts has a comment saying the materializer reads registry.listItems('email_template'). It stays true in substance, because that listing is getMetaItems' base layer, so it is not edited (outside this PR's surface).
  • Docs: no content/docs sentence became false. email-templates.mdx already 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 report 5989088536, after seat review 5988470640.

  • The published surface is unchanged. The effective boot sweep is now the 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, so a caller outside the plugin reads the registry exactly as before. EffectiveEmailTemplateSources is no longer re-exported. It is one sweep body with two entry points.
  • Measured on the built artifact at bae6303d:
    • src/index.ts is byte-identical to the base;
    • dist/index.d.ts names neither new identifier;
    • the ESM and CJS export lists hold the same 93 keys;
    • the exports map names only ., so the module file is not separately addressable.
  • The changeset stays patch with Clause-②: no.
  • Merged origin/main (18c7dfd2) with no conflict.
  • Re-verified at bae6303d:
    • plugin-email: 31 files, 514 tests passed; typecheck green;
    • dogfood: 2 files, 5 tests passed; typecheck green;
    • eslint: 5 files, 0 errors, 0 warnings.
  • Ablations:
    • A (the sweep reads the package layer) turned the four unit pins and the cold-boot dogfood pin red.
    • B (a failed read falls back to the registry) turned its pin red.
    • Both restores are proven.
  • Gates: dispatch-gates --commands lists 68 commands, all exit 0. --ran: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.

Generated by Claude Code

claude added 2 commits October 5, 2026 04:23
…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>
@github-actions github-actions Bot added the size/m label Oct 5, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 5, 2026
@github-actions

github-actions Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-email, touching 8 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/email-templates.mdx (via email_template (literal, a string literal in bootstrapDeclaredEmailTemplates; a string literal in bootstrapEffectiveEmailTemplates))
  • content/docs/concepts/metadata-lifecycle.mdx (via email_template (literal, a string literal in bootstrapDeclaredEmailTemplates; a string literal in bootstrapEffectiveEmailTemplates))
What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json a3ffc4512df5d9ebc1c9e5dee70813d89bf549db → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b31622da100e3484436841a61bdd1e070c106667 — the merge of head bae6303d08b2d7fe3b257ebbd9ceac2ee9d075b5 into base a3ffc4512df5d9ebc1c9e5dee70813d89bf549db, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a3ffc4512df5d9ebc1c9e5dee70813d89bf549db → pass the list as
args.docs, on the commit named under Which tree this was computed on.

claude added 3 commits October 5, 2026 05:07
…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>
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 5, 2026 06:20
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 5, 2026
Merged via the queue into main with commit 08adfea Oct 5, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21785-email-template-overlay-survives-boot branch October 5, 2026 06:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants