diff --git a/.changeset/send-template-input-org-retired.md b/.changeset/send-template-input-org-retired.md new file mode 100644 index 0000000000..ff4a3fee23 --- /dev/null +++ b/.changeset/send-template-input-org-retired.md @@ -0,0 +1,44 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): retire `SendTemplateInput.org` — the declared org-overlay resolution tenant id nothing ever read (#11832, ADR-0049 enforce-or-remove) + + + +**BREAKING** accept-set narrowing (compile-time), landing after the v17.0.0 cut +(the lockstep launch-window convention ships it as `minor`; the migration +prescription is registered under protocol major 18, where `os migrate meta` +users will look). + +**Removed member:** `SendTemplateInput.org` (`packages/spec/src/contracts/email-service.ts`). + +The member was declared as "Tenant id for org-overlay resolution (when +supported)" and no implementation ever read it: `@objectstack/plugin-email` — +the only `IEmailService` implementation — resolves templates on +`(name, locale)` only, so a caller passing `org` got no org-overlay resolution +and no error; the "(when supported)" hedge was the declaration admitting the +gap. After #11741 landed `organizationId` beside it, `SendTemplateInput` +carried two org-shaped keys of which one did nothing — exactly the shape that +invites an AI author to pick the wrong one. + +**FROM → TO:** `sendTemplate({ template, to, org: tenantId, … })` → +`sendTemplate({ template, to, … })` — delete the `org` key; it never changed +behaviour, so removing it changes none either. It is **NOT** replaced by +`organizationId`: that member is the delivery row's tenant stamp +(`sys_email.organization_id` pass-through, #11741) and opts into no template +overlay resolution. + +**What is refused:** authoring `org` in TypeScript is now an excess-property +`tsc` error (`SendTemplateInput` is a programmatic contracts interface with no +Zod surface, so the compiler is the enforcement channel — pinned in +`email-service.test.ts`). Runtime behaviour is unchanged: nothing ever read +the member, so a JavaScript caller still passing `org` keeps its exact +pre-removal outcome (the key is carried inert and ignored). + +**What stays:** `SendTemplateInput.organizationId` and +`SendEmailInput.organizationId` (#11741, Decision 2 of #11303) are untouched, +semantics included. `RenderTemplateInput` never carried `org`. D3 semantic +entry `send-template-input-org-retired`; no D2 conversion, because the key +only ever appeared in a call-time input bag — no metadata seam ever runs on it +(the `data.engine.update options.upsert` precedent). diff --git a/packages/spec/src/contracts/email-service.test.ts b/packages/spec/src/contracts/email-service.test.ts index 0a9c352f5b..d7fbb9b196 100644 --- a/packages/spec/src/contracts/email-service.test.ts +++ b/packages/spec/src/contracts/email-service.test.ts @@ -46,3 +46,34 @@ describe('Email Service Contract — organization widening (#11741)', () => { expect(widened.organizationId).toBe('org_apex'); }); }); + +/** + * #11832 (ADR-0049 enforce-or-remove) — `SendTemplateInput.org` is RETIRED. + * + * The member declared "Tenant id for org-overlay resolution (when supported)" + * and no implementation ever read it: template resolution keys on + * `(name, locale)` only, so a caller passing `org` got no overlay resolution + * and no error. This is a programmatic contracts interface (nothing parses it + * at runtime), so the enforcement channel is the COMPILER: the pin below is a + * compile-time assertion that authoring `org` is an excess-property error. + * Re-adding the member makes the `@ts-expect-error` directive unused, which is + * itself a compile error (TS2578) under `check:test-typecheck` — the pin fails + * loudly in both directions. + */ +describe('Email Service Contract — SendTemplateInput.org retired (#11832)', () => { + it('refuses `org` at compile time (excess property; organizationId is NOT an overlay opt-in)', () => { + const input: SendTemplateInput = { + template: 'auth.password_reset', + to: 'a@b.com', + // `org` was removed from SendTemplateInput (#11832); it never resolved + // any org overlay. There is no replacement key: `organizationId` is the + // delivery row's tenant stamp, not overlay resolution. + // @ts-expect-error #11832 — authoring `org` is an excess-property error + org: 'org_apex', + }; + // Runtime footnote only — the contract is type-level; the object literal + // above still carries the key at runtime, which is exactly the inertness + // the removal documents (nothing reads it). + expect(input.template).toBe('auth.password_reset'); + }); +}); diff --git a/packages/spec/src/contracts/email-service.ts b/packages/spec/src/contracts/email-service.ts index 375d775487..45b2961c6c 100644 --- a/packages/spec/src/contracts/email-service.ts +++ b/packages/spec/src/contracts/email-service.ts @@ -181,8 +181,15 @@ export interface SendTemplateInput { * behavior). Calendar-day `date` holes are unaffected (tz-naive). */ timezone?: string; - /** Tenant id for org-overlay resolution (when supported). */ - org?: string; + // `org` ("Tenant id for org-overlay resolution (when supported)") was REMOVED + // 2026-08-25 (#11832, ADR-0049 enforce-or-remove). No implementation ever read + // it — template resolution keys on `(name, locale)` only — so the key was + // silently inert from the day it was declared; the "(when supported)" hedge + // was the declaration admitting it. `organizationId` below is NOT a + // replacement: it is the delivery row's tenant stamp (pass-through to + // `sys_email.organization_id`, #11741) and does not opt into any overlay + // resolution. If org-overlay template rows ever become a measured business + // need, that is a new capability with its own ruling — not this key revived. /** Envelope sender override (otherwise template.fromOverride → service default). */ from?: EmailAddress; /** Carbon-copy recipients. */ @@ -205,9 +212,10 @@ export interface SendTemplateInput { * template send performs, and stamped from there onto * `sys_email.organization_id` (#11741). Same contract as * {@link SendEmailInput.organizationId}: pass-through only, optional, - * absent stays legal. Distinct from {@link SendTemplateInput.org}, which - * addresses template org-overlay *resolution*, not the delivery row's - * tenant stamp. + * absent stays legal. Distinct from the retired `org` member (removed + * 2026-08-25, #11832), which declared template org-overlay *resolution* + * and was never read — this member stamps the delivery row's tenant and + * opts into no overlay resolution. */ organizationId?: string; } diff --git a/packages/spec/src/migrations/entries/semantic/18.send-template-input-org-retired.ts b/packages/spec/src/migrations/entries/semantic/18.send-template-input-org-retired.ts new file mode 100644 index 0000000000..d71cf6da04 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.send-template-input-org-retired.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'send-template-input-org-retired', + surface: 'contracts.emailService.sendTemplate input.org', + replacement: + '(removed — never implemented; delete the key from the call. It is NOT replaced by ' + + '`organizationId`: that member is the delivery row\'s tenant stamp ' + + '(`sys_email.organization_id` pass-through, #11741) and opts into no template overlay ' + + 'resolution)', + reason: + 'ADR-0049 enforce-or-remove (#11832). `SendTemplateInput.org` was declared as "Tenant id ' + + 'for org-overlay resolution (when supported)" and no implementation ever read it: ' + + '`@objectstack/plugin-email` — the only IEmailService implementation — resolves templates ' + + 'on `(name, locale)` only, so a caller passing `org` got no org-overlay resolution and no ' + + 'error; the "(when supported)" hedge was the declaration admitting the gap. After #11741 ' + + 'landed `organizationId` beside it, the input carried two org-shaped keys of which one did ' + + 'nothing — exactly the shape that invites an AI author to pick the wrong one. There is no ' + + 'behaviour to preserve and nothing stored to rewrite: the key only ever appeared in a ' + + 'call-time input bag (the `data.engine.update options.upsert` precedent), which is why ' + + 'this is a D3 semantic entry with no D2 conversion — no metadata seam ever runs on it. ' + + 'Org-overlay template resolution, if it ever earns a measured business pull, is a new ' + + 'capability with its own ruling — not this key revived.', + acceptanceCriteria: + 'No caller passes `org` to `IEmailService.sendTemplate()`. The enforcement channel is the ' + + 'compiler: `SendTemplateInput` is a programmatic contracts interface with no Zod surface, ' + + 'so authoring `org` is an excess-property `tsc` error (pinned in ' + + '`packages/spec/src/contracts/email-service.test.ts`). Runtime behaviour is deliberately ' + + 'UNCHANGED: nothing ever read the member, so removing it removes no behaviour — a ' + + 'JavaScript caller still passing `org` keeps its exact pre-removal outcome (the key is ' + + 'carried inert and ignored). Template resolution still keys on `(name, locale)`, and ' + + '`organizationId` still stamps `sys_email.organization_id` without acquiring any overlay ' + + 'semantics.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 390c03117e..e372897c15 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6219,6 +6219,38 @@ const step18: MigrationStep = { + '`collapsible: true` explicitly — an unset key now defers to the renderer, which does ' + 'not collapse.', }, + { + id: 'send-template-input-org-retired', + surface: 'contracts.emailService.sendTemplate input.org', + replacement: + '(removed — never implemented; delete the key from the call. It is NOT replaced by ' + + '`organizationId`: that member is the delivery row\'s tenant stamp ' + + '(`sys_email.organization_id` pass-through, #11741) and opts into no template overlay ' + + 'resolution)', + reason: + 'ADR-0049 enforce-or-remove (#11832). `SendTemplateInput.org` was declared as "Tenant id ' + + 'for org-overlay resolution (when supported)" and no implementation ever read it: ' + + '`@objectstack/plugin-email` — the only IEmailService implementation — resolves templates ' + + 'on `(name, locale)` only, so a caller passing `org` got no org-overlay resolution and no ' + + 'error; the "(when supported)" hedge was the declaration admitting the gap. After #11741 ' + + 'landed `organizationId` beside it, the input carried two org-shaped keys of which one did ' + + 'nothing — exactly the shape that invites an AI author to pick the wrong one. There is no ' + + 'behaviour to preserve and nothing stored to rewrite: the key only ever appeared in a ' + + 'call-time input bag (the `data.engine.update options.upsert` precedent), which is why ' + + 'this is a D3 semantic entry with no D2 conversion — no metadata seam ever runs on it. ' + + 'Org-overlay template resolution, if it ever earns a measured business pull, is a new ' + + 'capability with its own ruling — not this key revived.', + acceptanceCriteria: + 'No caller passes `org` to `IEmailService.sendTemplate()`. The enforcement channel is the ' + + 'compiler: `SendTemplateInput` is a programmatic contracts interface with no Zod surface, ' + + 'so authoring `org` is an excess-property `tsc` error (pinned in ' + + '`packages/spec/src/contracts/email-service.test.ts`). Runtime behaviour is deliberately ' + + 'UNCHANGED: nothing ever read the member, so removing it removes no behaviour — a ' + + 'JavaScript caller still passing `org` keeps its exact pre-removal outcome (the key is ' + + 'carried inert and ignored). Template resolution still keys on `(name, locale)`, and ' + + '`organizationId` still stamps `sys_email.organization_id` without acquiring any overlay ' + + 'semantics.', + }, // Registered as D3 SEMANTIC and deliberately NOT as a D2 conversion, on the // D2 scope guard (lossless only — the `owd-legacy-read-aliases` / `'full'` // precedent): an authored theme has no lossless target. `app.branding` holds