Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/15205-email-template-org-door-closed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@objectstack/plugin-email': minor
'@objectstack/plugin-auth': minor
'@objectstack/platform-objects': patch
---

feat(plugin-email,plugin-auth)!: an organization can no longer create or edit an email template row; the template provenance stamp and the auth SMS template seed retire

Clause-②: no (narrowing)

<!-- 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 read, rewritten, converted or dropped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime write door (an organization's create and update of a sys_email_template row are refused) and a boot seed (no sys_notification_template row is written). The three retired names are runtime functions and a string constant of @objectstack/plugin-email with no metadata surface and no replacement: a direct caller meets the compiler's missing-export error and deletes the call. Measured consumers in this repository outside the package: one audit script, updated here. 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 are functions and a constant, not a type surface (not runtime-interface-only / type-surface-only). -->

**BREAKING** (an accept-set narrowing and three retired exports), shipped as `minor` under the launch-window convention for breaking changes. It carries ADR-0131 D6 and the maintainer's ruling C on ADR-0131 §6 Q1: email templates are not overridden per organization.

**What stops being accepted.**

- **The `sys_email_template` organization door is closed.** An organization's create and update of a template row are refused with `403 PERMISSION_DENIED`, and the message names the closed door: every engine `insert` / `update` whose context names a caller and is not system-elevated. That covers `POST` / `PATCH /api/v1/data/sys_email_template`, the Studio record editor, batch and import routes, and scripts and flows that run as a user or a service principal. System-context writes still pass: the built-in seed, the declared-template boot sweep, the live projection of a Studio `email_template` save into its row, and the v18 migration ceremony's promotion. Delete is not part of this door.
- **The template provenance stamp is retired.** It marked a package- or platform-seeded row `customized: true` when a non-system caller updated it. With the door closed no such update reaches the engine's write, so nothing marks a row any more. Rows already marked keep their mark and are still never overwritten by the boot seeders; they are the population the v18 migration ceremony promotes to environment-level Studio templates.
- **Retired exports of `@objectstack/plugin-email`:** `bindEmailTemplateProvenanceStamp`, `unbindEmailTemplateProvenanceStamp` and `EMAIL_TEMPLATE_PROVENANCE_PACKAGE`. They have no replacement. `EmailServicePlugin` closes the door itself, and there is no stamp left to bind, so a direct call is deleted, not rewritten.
- **The auth SMS template seed is retired.** A boot with phone sign-in on no longer writes the built-in OTP and invitation texts into `sys_notification_template` as rows. It was internal to `@objectstack/plugin-auth` and exported nothing.

**What renders unchanged.**

- Every email template renders as before: the template loader still reads `sys_email_template`, and a Studio edit of an `email_template` still reaches the mail at once and survives a restart.
- Every auth SMS text renders byte for byte as the seeded store rendered it, for every built-in text and every recipient locale. Each locale rung renders an operator's active row when one exists, and the built-in text where no row exists at that locale. A deactivated or blank row passes its rung on, as it did before.
- A `sys_notification_template` row an operator already has still wins, and no existing row is touched.

**What changes for you.** To change what an email template sends, edit the `email_template` in Studio. A script or integration that wrote `sys_email_template` rows through the data API now receives `403 PERMISSION_DENIED`. `@objectstack/platform-objects` corrects the `is_system` and `customized` field help on `sys_email_template`, which said an organization may edit a row.
13 changes: 9 additions & 4 deletions content/docs/automation/email-templates.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,17 @@ schema first, so a malformed template is a warning rather than a broken boot.
Runtime saves are materialized on the same seam, so a Studio edit takes effect
without a restart.

Change a template's wording in **Studio** — the `email_template` metadata. The
`sys_email_template` rows themselves are closed to organization writes: a create
or an edit through the data API is refused `403 PERMISSION_DENIED`, and only the
platform writes them, from the built-in templates and the `email_template`
metadata.

Materialization is **seed-not-clobber**. Declared templates carry package
provenance and are re-seeded on every boot, but a row an administrator created
or edited is never overwritten. A reworded transactional mail survives your next
deploy — which is the intended behaviour, and also the reason a source change
that "does not take effect" is usually a customized row winning, not a failed
seed.
or edited before that door closed is never overwritten. That is also the reason
a source change that "does not take effect" is usually such a customized row
winning, not a failed seed.

`bodyText` is optional: when you omit it the service derives a plain-text
alternative by stripping tags from the rendered HTML. Authoring one explicitly
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ The largest single consumer — **17 of the 115 sites**.
| 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` |
| 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume) and the two operator run-lifecycle writes 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 | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite` |
| 57 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertTenantAdmin` |
| 58 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-provenance.ts#bindEmailTemplateProvenanceStamp`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down
32 changes: 16 additions & 16 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **54 of the 233 sites are spelled that way**. A
forwarding shim cannot, and **54 of the 232 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand Down Expand Up @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **233** |
| 175 write call sites | quoted in the merged changeset | **232** |
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **155 of 233** decidable, **78** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 123 decidably elevated, 0 decidably not, 102 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **154 of 232** decidable, **78** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 122 decidably elevated, 0 decidably not, 102 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All @@ -201,17 +201,17 @@ at any commit.

Two structural facts do plausibly widen this reading against any hand or regex
one, and both are counted in the generated tables below: the 48 sites reached
through an erased (`any`) receiver, and the 51 that name their object through a
through an erased (`any`) receiver, and the 50 that name their object through a
`const` rather than inline. An instrument that read either the way a person does
would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
123 of 233 (53%) as decidably elevated, with 102 more whose elevation is a
122 of 232 (53%) as decidably elevated, with 102 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `2 / 233`, and say what it is**: the sites whose options argument was
⇒ **Cite `2 / 232`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **31 further sites** have an options argument this
Expand All @@ -223,29 +223,29 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **233** |
| …whose object name is statically decidable | 155 |
| write call sites on the application surface | **232** |
| …whose object name is statically decidable | 154 |
| …whose object name is chosen at run time | 78 |
| …against an object with tenancy ENABLED | 154 |
| …against an object with tenancy ENABLED | 153 |
| …against an object that declares tenancy off | 1 |
| threading a tenant context | 171 |
| threading a tenant context | 170 |
| PROVABLY carrying none (options read, no context key) | **8** |
| …of those, against a decidably tenancy-enabled object | **2** |
| options argument UNREADABLE — may or may not carry one | 54 |
| …of those, against a decidably tenancy-enabled object | 31 |
| threading a decidably ELEVATED (`isSystem`) context | 123 |
| threading a decidably ELEVATED (`isSystem`) context | 122 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 102 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 185 |
| receiver carried a readable engine type | 184 |
| receiver erased, placed by the object NAME | 28 |
| receiver erased, placed by an `object: string` PARAMETER | 15 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |

| object name spelled inline | 104 |
| object name spelled through a `const` | 51 |
| object name spelled through a `const` | 50 |
| object name is an `object: string` parameter | 19 |
| object name is some other run-time expression | 59 |

Expand Down Expand Up @@ -297,12 +297,12 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-10-07 at `e1171ca48`.
Measured on 2026-10-07 at `a93579f45`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 612 |
| engine-shaped types recognised | 70 |
| engine-shaped types recognised | 69 |
| declared objects in the registry | 116 |
| same-named calls subtracted as non-engine | 160 |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -812,6 +812,19 @@ overlay axis is retired (D6). One remains:
with a release note? Depends on whether any deployment relies on the feature — this record cannot
see that; the maintainer rules it when the C4 card is cut.

**Ruled 2026-10-06: C** ([#22005](https://github.com/objectstack-ai/objectstack/issues/22005), ruling
record `6020178017`, the maintainer's 「同意」). At the v18 upgrade the migration ceremony (C7) promotes
every customer-edited email template to an environment-level Studio template — both populations: the
`sys_email_template` rows stamped `customized: true`, and the organization-scoped `email_template`
overlays Studio saved under `single`. Under `single` the meaning is unchanged; on a deployment with more
than one organization a name conflict is listed in the plan and the operator chooses per row (D10 fate 4,
never guessed). After a verified promotion the customized rows are mirrors (D10 fate 2) and the row table
retires under D13 with an ADR-0087 entry. Not taken: A (a kept `(organization, name, locale)` resolution,
the door D6 closed) and B (deleting customer rows, which D10 does not sanction). Email templates only:
notification templates have no metadata type, so their seed retires and no type is added. C4's retirement
list is rewritten for "promote to environment level" and gains `bootstrapEffectiveEmailTemplates` /
`EffectiveEmailTemplateSources`.


## 7. Verification notes

Expand Down
15 changes: 7 additions & 8 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 233 |
| Object name statically decidable | 155 |
| Write call sites | 232 |
| Object name statically decidable | 154 |
| Object name chosen at run time | 78 |
| Against a tenancy-enabled object | 154 |
| Against a tenancy-enabled object | 153 |
| Against an object declaring tenancy off | 1 |
| Threading a tenant context | 171 |
| Threading a tenant context | 170 |
| Provably carrying none | 8 |
| …and decidably tenancy-enabled | 2 |
| Options argument unreadable | 54 |
| …and decidably tenancy-enabled | 31 |
| Threading a decidably elevated context | 123 |
| Threading a decidably elevated context | 122 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 102 |

Expand Down Expand Up @@ -90,12 +90,12 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-10-07 at `e1171ca48`.
Measured on 2026-10-07 at `a93579f45`.

| corpus scale (not enforced) | count |
| :--- | ---: |
| tracked non-test sources scanned | 612 |
| engine-shaped types recognised | 70 |
| engine-shaped types recognised | 69 |
| declared objects in the registry | 116 |
| same-named calls subtracted as non-engine | 160 |

Expand Down Expand Up @@ -143,7 +143,6 @@ Measured on 2026-10-07 at `e1171ca48`.
| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `delete` | `objectName` | undecidable | PROVABLY NONE | 5 |
| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `insert` | `objectName` | undecidable | options unreadable | 2 |
| `packages/plugins/plugin-auth/src/objectql-adapter.ts` | `update` | `objectName` | undecidable | options unreadable | 5 |
| `packages/plugins/plugin-auth/src/phone-sms-texts.ts` | `insert` | `sys_notification_template` | enabled | elevated | 1 |
| `packages/plugins/plugin-auth/src/reconcile-membership.ts` | `insert` | `sys_member` | enabled | context, elevation undecidable | 1 |
| `packages/plugins/plugin-auth/src/scim-connection-service.ts` | `insert` | `sys_scim_connection_credential` | enabled | PROVABLY NONE | 1 |
| `packages/plugins/plugin-auth/src/session-tombstone.ts` | `update` | `objectName` | undecidable | options unreadable | 1 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2602,7 +2602,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
is_system: {
label: "System Template",
help: "Provided by a plugin / platform; tenants may edit but should not delete"
help: "Provided by a plugin / platform"
},
variables_json: {
label: "Variables (JSON)",
Expand All @@ -2619,7 +2619,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
},
customized: {
label: "Customized",
help: "Set when an admin edits a package-declared template; boot seeding will no longer overwrite the row (a reworded password-reset mail survives redeploys). Meaningless on admin rows."
help: "Set on a package-declared template an admin edited before organization-level template editing closed; boot seeding never overwrites such a row. Nothing sets it any more. Meaningless on admin rows."
},
created_at: {
label: "Created At"
Expand Down
Loading
Loading