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
44 changes: 44 additions & 0 deletions .changeset/send-template-input-org-retired.md
Original file line numberDiff line numberDiff line change
@@ -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)

<!-- adr-0087: registered send-template-input-org-retired -->

**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).
31 changes: 31 additions & 0 deletions packages/spec/src/contracts/email-service.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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');
});
});
18 changes: 13 additions & 5 deletions packages/spec/src/contracts/email-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. */
Expand All@@ -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;
}
Expand Down
Original file line numberDiff line numberDiff line change
@@ -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.',
};
32 changes: 32 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading