Skip to content

Commit 7de0265

Browse files
feat(plugin-email)!: email templates resolve from the registry; the built-in seed, the boot sweep and the live projector retire (ADR-0131 D6/D10, C4 stage 2) (#22875)
Fixes #15205 Clause-②: yes (narrowing) C4 stage 2, the registry loader half (ADR-0131 D6/D10, ruling C on §6 Q1, record 6020178017 on #22005). Claim 6109130514; dispatch from the `domain:services` seat 2. One cross-domain PR under triage's routing 6108741731 (the `packages/spec` ledger and describe text ride with the loader so `check:liveness` is never red on `main`). ## What changed - **`@objectstack/plugin-email` reads templates from the registry.** `createRegistryEmailTemplateLoader` (in `template-loader.ts`) resolves `email_template` by `(name, locale)` at every send through the protocol's layered execution list (`getMetaItemsForExecution` — the environment's item over each code package's declaration), or the ObjectQL registry on a host with no metadata door. The locale ladder is the row loader's, unchanged. Within one slot any other package's item, or a package-less environment item, wins over the built-in. A failed read propagates (never answered from the registry's package wording); a malformed item refuses the send with the schema's message instead of falling through to the built-in copy. - **The built-in auth templates are code-declared items** of the email plugin's own package (`com.objectstack.service.email`), registered through its manifest at `init`. They now appear in Studio's email-template list; an app declaring the same `(name, locale)` wins; a Studio save overrides at environment level. - **Retired:** `seedTemplates` (the boot seed), `bootstrap-declared-email-templates.ts` whole (`bootstrapDeclaredEmailTemplates`, `bootstrapEffectiveEmailTemplates`, `EffectiveEmailTemplateSources`, `upsertDeclaredEmailTemplate`, `deactivateDeclaredEmailTemplate`, `mapTemplateToRow`, `EMAIL_TEMPLATE_OBJECT`), the live projector (`wireTemplateMutationProjection`, `materializeTemplateMutation`, `readEffectiveTemplate`, the `metadata.subscribe` watcher), `createSysEmailTemplateLoader` and its two types, and the `seedTemplates` / `templates` plugin options. The changeset names each with its FROM → TO line. - **Kept:** the `sys_email_template` object, its rows and the closed organization door (stage 1). No row is promoted or deleted: the customized rows are C7's (#15211). `TemplateLoader` / `EmailTemplateRow` stay as the service's render seam. - **`packages/spec` (describe-only, no schema shape change):** the `email_template` liveness ledger is re-anchored by hand to the loader; the `IEmailService` contract docs, the notify node's `template` describe and refusal texts, and the `email-template.zod.ts` / `notification.zod.ts` prose name the registry, not rows. `gen:docs` regenerated two reference pages. - **`service-automation`:** the notify node's `template` help text. **`platform-objects`:** the `sys_email_template` header comment and provenance comment (no field help changed, so no translation churn). ## Premise re-measured first (dispatch zone 2, items 1, 2, 4) Scratch showcase measurement (never committed), `orgContext: true`, Default Organization bootstrapped, at `d8c7d3864`: - A Studio `PUT /meta/email_template/showcase_task_done_email` lands **environment-scoped** (`sys_metadata.organization_id` null). - Organization-free reads see it **live** (no restart) and **after a cold boot**: `SchemaRegistry.getItem`, `protocol.getMetaItem`, `protocol.getMetaItems` and `getMetaItemsForExecution` all serve the saved subject. `metadataService.get` / `list` answer nothing for the type. - **Which read:** the layered execution list. `SchemaRegistry.listItems` hands back the environment item beside the package entry it overrides, both wearing the package's `_packageId` / `_provenance` (measured), and `getItem` answers one bundle member per name. The list has already decided precedence, one item per `(package, name, locale)` slot. - **Live without a restart:** confirmed through the registry write-through; the projector had no job left and is gone (pinned: no projector, listener or subscription is armed; the `PUT` response carries no `projectionApplied`). ## Byte identity `builtin-auth-render-identity.test.ts` renders all 24 built-in templates (6 names x 4 locales) through `EmailService.renderTemplate` and pins subject (clear) plus HTML and text (SHA-256). At `924da8d8a` it ran the seeded-row path (rows via `mapTemplateToRow`, the row loader) and the registry path side by side: identical for all 24. Since the row path's deletion it boots `EmailServicePlugin` over a real ObjectQL and holds the registry path to those digests. ## Pins rewritten, not deleted (dispatch zone 2, item 7) - `bootstrap-declared-email-templates.test.ts` becomes `template-loader.test.ts`: every pin that asked whether the authored wording reaches the send is restated against the loader. Row-only mechanics (seed-not-clobber, provenance adoption, the bulk read) retired with the rows. - `email-plugin.template-runtime-write.test.ts`: a save sends on the very next send; no seam armed; no `sys_email_template` read or write, pre-existing rows byte-unchanged; the `#16152` envelope pins moved to the loader path. - `plugin-shutdown-detaches-template-bridge.test.ts`: shutdown releases the door (the only engine binding left). - Dogfood: `email-template-overlay-survives-boot` (save environment-wide and live, survives a cold boot, a legacy customized row keeps its bytes and is not what sends, the closed data door), `email-template-materialization` (ADR-0054 proof keeps its `@proof` tag: no row, authored override beats the built-in, the built-in serves zh-CN, a Studio save over it sends without a restart), `email-template-boot-sweep` (real engine + SQL driver: zero statements reach the table on boot and send). - `examples/app-showcase/test/email-template-locale.test.ts` runs through the registry loader (Q2 answer A). - `packages/spec/src/automation/io-node-config.test.ts`: the describe pin asserted `sys_email_template`; it now asserts the email templates and refuses the row wording. ## Ablations (each mutated, measured red, restored by blob equal to HEAD with an empty `git diff HEAD`) Through `scripts/ablation-replace.mjs`, src-level at `fcd3b610a`: A1 a failed read falls back to the registry (2 red); A2 built-in precedence dropped (4 red); A3 malformed items skipped (3 red); A4 per-load read cached (6 red); A5 the plugin writes a row (1 red); A6 a projector armed (1 red); A7 the read names an organization (1 red). Dist leg: A5 rebuilt into `dist/` (`ablation-dist-preflight` found the marker), the dogfood statement pin went 2 red, then restore, rebuild, `--absent`, 2 green. A8: the notify describe back to `sys_email_template` reddens the flipped spec pin. A9: the re-pointed swallow-census resolution control reddens with departure ONE removed (its first attempt was a no-op the tool refused; re-run in delete mode). ## Evidence at `1cc2da623` (after merging `origin/main` `55382dc02`) - Closure build (`--filter=@objectstack/dogfood^... --filter=@objectstack/example-showcase...`): 63/63, VERDICT command-exit 0. - `plugin-email`: 33 files / 515 tests pass; typecheck OK. `spec`: 648 files / 19,375 tests pass; typecheck OK. `service-automation` notify-node 16/16 and typecheck OK; `platform-objects` typecheck OK. - Dogfood email-template files 9/9; showcase locale test 6/6. - Gates: `dispatch-gates --commands` derived 136; `--ran` reconciles 136 run, 0 NOT-MEASURED. 135 exit 0. `check:platform-checklist` exits 1 with 8 findings in `docs/qa/platform-checklist/areas/*.json` naming `metadata-protocol` / `service-storage` symbols: the identical 8 on `origin/main` `69d421805` in a scratch worktree, so pre-existing and not from this branch. - Narrowed lint: `eslint --no-inline-config --format json` over the 33 changed source files: 33 files, 0 errors, 0 warnings, none ignored. The narrowing excludes nothing: `eslint.config.mjs` enables no type-aware linting, so this diff cannot move a verdict on an untouched file. ## Needs a decision: `email_template.category` and `.isSystem` Their only consumer was the row projection this PR retires (a Setup column, and the built-in seeder's stand-down). Measured: no reader in this repository, and at the `.objectui-sha` pin `0df67f23` objectui reads both only as editor inputs. The ledger grades both `dead` (honest measurement; `label` and `description` stay `live` on the Studio list's default columns). Consequence: `packages/lint`'s `runtime-gate.inert-type-writes.test.ts` has 2 red cases, because a dead `category` now draws a `liveness-dead-property` advisory, and `category` is schema-defaulted (`'custom'`), so every template carries it and the advisory cannot be cleared by the author. Not resolved here: retiring the keys is a schema shape change this dispatch fences out, and `packages/lint` is outside the file surface. Options and the four-axis analysis are in the report comment on #15205. ## Acceptance notes - Gate-forced edits outside the named surface, each the minimal answer to a gate naming retired code: `scripts/engine-double-contract.baseline.json` and `scripts/objectql-double-limit.baseline.json` (shrink-only rows for files now gone or clean), `scripts/isystem-census.mjs` (a `NON_ELEVATION_READS` row for the deleted module), `scripts/measure-durability-swallow-family.mjs` (a resolution control re-pointed by measurement: 21 departure-ONE sites remain, none write-shaped), the regenerated censuses (`content/docs/permissions/system-context.mdx`, `tenant-audit-census.mdx` with its hand-written figures, `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`), and `packages/spec/liveness/README.md` plus the generated `state-counts/email_template.md`. - Cross-lane docs edit, its own commit `94ab10722` (droppable): `content/docs/automation/email-templates.mdx` described the materialization and seed-not-clobber this PR retires. - Transitional, as ruled: a `sys_email_template` row an organization customized is no longer what sends until C7 promotes it. The changeset says so and names the re-save in Studio as the interim fix. - Pre-existing defect observed (not this card's): `sys_metadata` holds one overlay row per `(type, name)`, so a Studio save of a second locale member of one `email_template` name replaces the first member's overlay at rest; live reads still show both until a restart, then the first reverts to package wording. Measured on the showcase; the row path showed the same revert at boot. Reported for filing. - Comment-only references to retired names left outside the surface: `packages/objectql/src/registry.ts`, `registry-i18n-bundle-key.test.ts`, `packages/spec/src/stack-email-template-locale-floor.test.ts`, `packages/spec/scripts/liveness/proof-registry.mts` (class prose), and the dogfood fixture `fixtures/email-template-materialization-fixture.ts` (header and manifest description). - Changed lines: 4,490 (additions 1,517 + deletions 2,973, generated included). Over 3,000, so this lands the Tier H way. No governed surface in the file list. ## Patch round: the seat's answer B to Q1 The seat answered Q1 with B (`6111829211`): `email_template.category` and `.isSystem` land graded dead, and #22876 retires them in the next enforce-or-remove batch. - `packages/lint/src/runtime-gate.inert-type-writes.test.ts`: the group-C case is split. `mapping` keeps its assertions and title. `email_template` now pins exactly one advisory, rule `liveness-dead-property` on property `category`. The shipped-corpus control pins the same single advisory; its `isSystem: false` draws nothing. - `packages/lint/src/lint-liveness-properties.test.ts`: a template that sets neither key still warns about nothing. `category`, and `isSystem: true`, each draw one dead-property advisory. The comments cite #22876. - The tests assert the advisory's rule id and property path, not its wording. The lint rule, the ledger grading and the schema are untouched. - Ablations L1 to L4 each went red (1 failed of 142) and were restored by blob. - Merged `origin/main` `0253192f7` (`81ec7792b`) and regenerated the censuses (`33641fe78`). - At `33641fe78`: lint 6,461/6,461 and typecheck OK; plugin-email 515/515; dogfood 9/9. - Gates: 136 derived, all run, 0 NOT-MEASURED. 135 exit 0. `check:platform-checklist` fails with the same 8 findings already present on `origin/main` `69d421805`. - 4,575 changed lines against `0253192f7`. ## Patch round 3: the Clause-② line carries the narrowing arm The seat amended its claim (`6112650531`): this PR retires published exports and plugin options of `@objectstack/plugin-email`, a BREAKING narrowing of the package's public face that widens nothing, so the declaration reads `Clause-②: no (narrowing)`. The bare `no` was the seat's own error in the claim. - One commit, `4aa656e7a` on top of `33641fe78`, changes one line of `.changeset/15205-email-templates-resolve-registry.md`: `Clause-②: no` → `Clause-②: no (narrowing)`. The BREAKING banner, the `minor` grade for `@objectstack/plugin-email`, the two `patch` rows and the ADR-0087 marker `not-required (no-migration-prescription)` are unchanged. This body's second line carries the same arm. - `check-adr-0087-registration --base origin/main` exits 0 and reads the changeset as `[BREAKING+bang+clause-②-narrowing]`, accepting the unchanged disposition. `check-changeset-no-major` exits 0 in its local form and in CI's form (`--base` the merge base, `--event` a `pull_request` payload with this line 2): "the declared narrowing is accounted for: `@objectstack/plugin-email: minor`". `check-empty-changeset` exits 0. - `dispatch-gates --commands` for the changeset path: 20 derived, all 20 run at `4aa656e7a`, all exit 0; `--ran` 0 NOT-MEASURED, 0 UNRUN. No package build or test was owed. - Changed lines: unchanged at 4,575. ## Patch round 4: the at-tier review FAIL `6113872462` The seat amended its claim a second time (`6113893432`). The PR widens `@objectstack/plugin-email`'s public face (`createRegistryEmailTemplateLoader`, `EmailTemplateRegistrySources`) and also narrows it (the retirements), so the declaration is `Clause-②: yes (narrowing)`. That is on this body's second line, in the changeset and on the claim. The seat's earlier `no` and `no (narrowing)` were both its own error. - **`2c11a7675` (changeset):** - The `Clause-②` line now reads `yes (narrowing)`. The `minor` grade and the BREAKING banner stay. - The ADR-0087 marker keeps `not-required (no-migration-prescription)`. Its `why` gains one sentence: the FROM → TO block above is a code change the compiler carries, not a metadata migration for `objectstack migrate meta`. ADR-0087's vocabulary has no category for that shape exactly, so the maintainer is asked to rule the reading. - `check-adr-0087-registration --base origin/main` exits 0 and prints that sentence in its reason. - **`0f1906217` (`packages/spec/liveness/email_template.json`):** the cross-repo evidence is re-measured at the tree's `.objectui-sha` pin `4997995022f8` and restated with file:line. That covers the `label` and `description` evidence and the `category` and `isSystem` dead notes. - `ResourceListPage.tsx#defaultColumns` (:835, rendering `label` :838 and `description` :839), and its fallback at :421. - `default-schemas.ts`: `email_template` is registered at :336 with no `listColumns`. `category` (:310) and `isSystem` (:324) are editor inputs only. - No reading changed from `0df67f23`: none of the cited files is among the 80 files the 7 commits between the two pins touch. No grade moves. - **Gates at `0f1906217`, for the two changed paths:** 60 derived, 59 run, 1 NOT-MEASURED, 0 UNRUN; 57 exit 0, `check:liveness` among them. - `check:platform-checklist` exits 1 with the same 8 findings as on `origin/main` `45e206e49`. That is pre-existing; see the note below. - `check:dual-build-cjs-loads` is NOT MEASURED. It needs every package's `dist/`, and this round changes no build input, so CI runs it. - In CI mode, `check-changeset-no-major` with this line 2 reports "the declared narrowing is accounted for: `@objectstack/plugin-email: minor`". - Changed lines: unchanged at 4,575. ## Seat's acceptance notes after the review - **The `destroy()` drain-hook finding:** `EmailServicePlugin.destroy()` leaves the `sys_email` outbox drain hook bound on the engine after kernel shutdown. Carrier: none. It is noted here and not filed. Its only reach is an engine that outlives its kernel, measured on a test fake, which is below the filing gate's measured-reach bar. It is pre-existing and not this PR's. A semantic search for an open card on the class found none. - **`check:platform-checklist` red on `main`:** the 8 findings are the known red that triage closed `not_planned` twice, #22594 and #22758 (`6093524974` sets the direction). Neither card's re-entry line is met. The gate is kept out of per-PR CI by standing decision (`.github/workflows/checklist-status.yml`). This seat files nothing. - **For the maintainer, who approves this Tier H landing in any case:** ADR-0087's disposition vocabulary has no category that verifiably fits a compiler-carried removal of runtime exports in a diff that also touches `packages/spec`. This changeset and stage 1's (`15205-email-template-org-door-closed.md`) both hold `not-required (no-migration-prescription)` on that reading. The open choice: rule that reading into the ADR's addendum, or add a seventh category. The at-tier review `6113872462` raised it, and it does not block this PR's substance. ## Merge round: `origin/main` `3fdb92db0`, through `os-regen-merge.sh` The PR was conflicted against `main` (`mergeable_state: dirty`), so no pull_request CI ran on `0f1906217`. Two commits fix that; the head is now `78c3eed63` and the merge base `3fdb92db0`. - **`be55432f6`** merges `origin/main` `3fdb92db0` through `scripts/pm/os-regen-merge.sh`. The one conflict was the census figures table in `content/docs/permissions/system-context.mdx`, a MIXED `merge=os-regen` path, with no prose in the hunk. It was resolved to main's figures, and the PR's row-58 prose stayed. - **`78c3eed63`** re-runs the repo's own generators only: `gen:system-context-census` and `tenant-audit-census.mjs --write`. Spec `check:generated` is green, `check:docs` included. - **Gates at `78c3eed63`:** 136 derived, 136 run, 0 NOT-MEASURED, 0 UNRUN; 135 exit 0. `check:platform-checklist`'s 8 findings are identical at the merge base `3fdb92db0`. - **Pure regeneration** (`Regen-provenance` `6114859685`, re-run by the seat on the committed trees): the net diff covers the same 49 paths before and after. Exactly 3 paths' changes differ, all generator output: one census figure (main's base moved, the PR's −1 unchanged) and two measured-at stamps. The at-tier PASS `6114282872` carries to this head. - Changed lines: unchanged at 4,575. --- _Generated by [Claude Code](https://claude.ai/code/session_013LbZ9MhPp1iriZEtgJqioA)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9e9e6f9 commit 7de0265

49 files changed

Lines changed: 1581 additions & 2994 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/plugin-email': minor
3+
'@objectstack/spec': patch
4+
'@objectstack/service-automation': patch
5+
---
6+
7+
feat(plugin-email)!: email templates resolve from the registry; the built-in seed, the declared-template boot sweep and the live projector retire
8+
9+
Clause-②: yes (narrowing)
10+
11+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling or stored shape is removed, renamed or re-shaped, and no stored row is rewritten, converted or dropped, so there is nothing for `objectstack migrate meta` to rewrite. What changes is where @objectstack/plugin-email reads a template (the email_template metadata in the registry instead of the sys_email_template rows it used to copy there) and which runtime exports and plugin options exist. The retired names are runtime functions, two types, a string constant and two EmailServicePluginOptions members of @objectstack/plugin-email, each with its one-line fix below; measured consumers in this repository outside the package (a showcase test and three dogfood tests) are rewritten here. The legacy sys_email_template rows stay where they are, untouched: promoting the customized ones to environment-level templates is the v18 migration ceremony's (ADR-0131 C7), not this change's. The other categories are closed on facts: every bumped package publishes (not unpublished); no ADR-0087 id covers these paths and this diff adds none (not registered / already-registered); and the retired names include runtime functions, not only a type surface (not runtime-interface-only / type-surface-only). The body does carry a FROM → TO block for the retired runtime exports and plugin options, but that is a code change the compiler carries to each caller, not a metadata migration for `objectstack migrate meta`, and ADR-0087's disposition vocabulary has no category that fits this shape exactly, so the maintainer is asked to rule the reading. -->
12+
13+
**BREAKING** (retired exports and plugin options of `@objectstack/plugin-email`), graded `minor` on the v18 prerelease line: Changesets is in pre mode with the tag `next`, so this ships in an `18.0.0-next.N`. It carries ADR-0131 D6/D10 and the maintainer's ruling C on ADR-0131 §6 Q1.
14+
15+
**What changes.**
16+
17+
- **Templates resolve from the registry.** `sendTemplate` and `renderTemplate` read `email_template` metadata by `(name, locale)` at every send: the protocol's layered list (`GET /meta/email_template`'s items — the environment's item over each code package's declaration), or the ObjectQL registry on a host with no metadata door. The locale ladder is unchanged: the named locale exactly, then `en-US`, then — only for a call that named none — the bundle's lowest locale tag.
18+
- **The built-in auth templates are code-declared items** of the email plugin's own package (`com.objectstack.service.email`), registered at plugin init, so they now appear in Studio's email-template list. An app that declares the same `(name, locale)` wins over the built-in; the built-in still serves the locales the app leaves out; a Studio save overrides either at environment level. Every built-in renders byte-identical to the row it used to be seeded into, in every locale (pinned on the rendered subject, HTML and text).
19+
- **A Studio save is live on the next send**, with no projection and no restart. The `PUT /meta/email_template/:name` response no longer carries `projectionApplied`, because nothing is projected.
20+
- **No `sys_email_template` row is written at boot or read at send.** The table and its rows stay; the organization door stays closed.
21+
- **A malformed template fails its send loudly** with the schema's own message, instead of falling through to the built-in copy of the same slot.
22+
23+
**What moves for consumers.**
24+
25+
- FROM `createSysEmailTemplateLoader(engine, options?)` TO `createRegistryEmailTemplateLoader(sources)`, where `sources` is `{ protocol?, registry? }` (type `EmailTemplateRegistrySources`) or a function returning it. FROM the types `TemplateLoaderEngine` / `CreateTemplateLoaderOptions` TO `EmailTemplateRegistrySources`.
26+
- FROM `bootstrapDeclaredEmailTemplates(...)`, `upsertDeclaredEmailTemplate(...)` or `deactivateDeclaredEmailTemplate(...)` TO nothing: templates are not copied into rows any more. Delete the call.
27+
- FROM `mapTemplateToRow(tpl)` TO nothing: there is no row projection. To resolve a template the way the mailer does, call `createRegistryEmailTemplateLoader(...).load(name, locale)`.
28+
- FROM `EMAIL_TEMPLATE_OBJECT` TO the literal `'sys_email_template'`, only if you still address the legacy table. FROM the type `BootstrapDeclaredEmailTemplatesResult` TO nothing.
29+
- FROM `new EmailServicePlugin({ seedTemplates: false })` TO `new EmailServicePlugin()`: there is no seed to switch off. Delete the option.
30+
- FROM `new EmailServicePlugin({ templates: [...] })` TO the stack's `emailTemplates: [...]` (or a `*.email-template.ts` file): declared templates resolve from the registry. Delete the option.
31+
32+
**What a deployment observes.** A `sys_email_template` row an organization customized before its door closed is no longer what sends: the template's code declaration (or an environment-level Studio save) is. The v18 migration ceremony promotes those rows to environment-level templates; to make such an edit live before it runs, re-save the template in Studio.
33+
34+
**`@objectstack/spec`** (patch): the `email_template` liveness ledger is re-anchored to the registry loader, and `category` and `isSystem` are graded `dead` — their only consumer was the retired row projection. The `notify` node's `template` description and refusal messages, and the `IEmailService` contract's documentation, name the email templates rather than `sys_email_template` rows. No schema shape changes.
35+
36+
**`@objectstack/service-automation`** (patch): the notify node's `template` help text served at `GET /api/v1/automation/actions` names the email templates rather than `sys_email_template` rows.

‎content/docs/automation/email-templates.mdx‎

Lines changed: 23 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -168,11 +168,11 @@ the localizable path:
168168
```
169169

170170
- `template` names the bundle. The delivery path resolves `(name, locale)`
171-
against `sys_email_template` at delivery time and renders subject/body from
172-
that row. **The locale is resolved per recipient, after fan-out**: the
171+
among the email templates at delivery time and renders subject/body from
172+
that member. **The locale is resolved per recipient, after fan-out**: the
173173
recipient's own `sys_user.locale` when set, else the deployment default
174174
(`II18nService.getDefaultLocale()`) — so two recipients of one notification
175-
whose `locale` columns differ receive different rows of the same bundle
175+
whose `locale` columns differ receive different members of the same bundle
176176
(maintainer ruling, 2026-09-01). A recipient whose column is empty or unset
177177
always falls back to the deployment default; no value ever dead-letters a
178178
delivery. A `payload.locale` set by the producer — the pre-ruling single
@@ -212,24 +212,26 @@ instead of duplicating it.
212212

213213
Worth knowing, because it explains what an administrator can and cannot change.
214214

215-
`sendTemplate` resolves rows of the `sys_email_template` platform object, not
216-
your source files. At boot the email plugin **materializes** every declared
217-
`email_template` into that object — validating each one through the canonical
218-
schema first, so a malformed template is a warning rather than a broken boot.
219-
Runtime saves are materialized on the same seam, so a Studio edit takes effect
220-
without a restart.
221-
222-
Change a template's wording in **Studio** — the `email_template` metadata. The
223-
`sys_email_template` rows themselves are closed to organization writes: a create
224-
or an edit through the data API is refused `403 PERMISSION_DENIED`, and only the
225-
platform writes them, from the built-in templates and the `email_template`
226-
metadata.
227-
228-
Materialization is **seed-not-clobber**. Declared templates carry package
229-
provenance and are re-seeded on every boot, but a row an administrator created
230-
or edited before that door closed is never overwritten. That is also the reason
231-
a source change that "does not take effect" is usually such a customized row
232-
winning, not a failed seed.
215+
`sendTemplate` resolves the `email_template` **metadata** itself, by
216+
`(name, locale)`, at every send — the same item `GET /api/v1/meta/email_template`
217+
serves. Three populations live there: the built-in auth templates the email
218+
plugin declares, the templates your app declares, and the ones saved in Studio,
219+
which land at environment level and override a declaration of the same
220+
`(name, locale)`. A template your app declares overrides the built-in copy of
221+
its `(name, locale)`; the built-in still serves the locales your app leaves out.
222+
Each template is validated through the canonical schema when it is resolved, so
223+
a malformed one fails its send loudly instead of falling through to the
224+
built-in copy.
225+
226+
Change a template's wording in **Studio** — the `email_template` metadata. A
227+
save is live on the very next send, with no restart.
228+
229+
The `sys_email_template` platform object is no longer read or written by the
230+
send path. It keeps the rows earlier releases wrote, and it is closed to
231+
organization writes: a create or an edit through the data API is refused
232+
`403 PERMISSION_DENIED`. A row an administrator customized before that door
233+
closed is not what sends; the v18 upgrade promotes such rows to
234+
environment-level templates.
233235

234236
`bodyText` is optional: when you omit it the service derives a plain-text
235237
alternative by stripping tags from the rendered HTML. Authoring one explicitly

‎content/docs/permissions/system-context.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ The largest single consumer — **17 of the 124 sites**.
183183
| 55 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` |
184184
| 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` |
185185
| 57 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` |
186-
| 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6), so the seeds, the boot sweep and the projection of a Studio save are the only writers. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` |
186+
| 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` |
187187
| 59 | **Automation flow data nodes re-add the `owner_id` stamp** (the one place row 2's gap is compensated inline) | service-automation | Get: a flow-authored INSERT under system elevation still lands owned, when the run resolved a user. Fill-only — flow-authored values win | `packages/services/service-automation/src/runtime-identity.ts#stampSystemInsertOwner`, called from `packages/services/service-automation/src/builtin/crud-nodes.ts#registerCrudNodes` |
188188
| 60 | Inbox caller refusal names `isSystem` as what was carried | service-messaging | Get: nothing — the refusal still fires. The flag only shapes the diagnostic, because privilege is not an authorization subject | `packages/services/service-messaging/src/inbox-caller.ts#resolveInboxRecipient` |
189189
| 61 | **`organization_id` is not auto-stamped on INSERT** — the organization-axis twin of the `owner_id` gap above | organizations | Get: an elevated write may name another organization deliberately, which is what the per-organization seed replay, the orphan-row claim, imports and migrations all rely on. Lose: the fill-only stamp, so an elevated insert that names no organization lands `organization_id = NULL` and the wall hides it. ⛔ Neither path rewrites a supplied `organization_id`: the non-elevated path fills only an absent one, and a supplied value meets the Layer 0 write wall, which refuses a forged one `403 PERMISSION_DENIED` exactly as it refuses an update re-pointing a row. Elevation skips the stamp and the wall alike, which is why it is the seam the legitimate cross-organization writers use | `packages/plugins/organizations/src/organizations-plugin.ts#start` |
@@ -359,10 +359,10 @@ still holds equal to the census on every pull request:
359359
| Appearances of the bare identifier `isSystem` in non-test sources | 813 | — |
360360
| — parsed as a declaration | 28 | ✅ |
361361
| — parsed as an object-literal / type key (producers and option objects) | 310 | — |
362-
| — parsed as a property **read** | 130 | ✅ |
362+
| — parsed as a property **read** | 129 | ✅ |
363363
| — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ |
364364
| — the remainder: text inside comments and string literals | 358 | — |
365-
| Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ |
365+
| Of those reads: reads of one of the unrelated metadata fields | 5 | ✅ |
366366
| Of those reads: reads of `ExecutionContext.isSystem` | **124** | ✅ |
367367
| — behaviour-bearing (rows 1–61 above) | 121 | ✅ |
368368
| — carry the flag onward only (rows 62–64 above) | 3 | ✅ |

0 commit comments

Comments
 (0)