diff --git a/.changeset/22876-email-template-category-is-system-retired.md b/.changeset/22876-email-template-category-is-system-retired.md new file mode 100644 index 00000000000..06df26f98bb --- /dev/null +++ b/.changeset/22876-email-template-category-is-system-retired.md @@ -0,0 +1,48 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-email': patch +'@objectstack/platform-objects': patch +--- + +feat(spec)!: retire an email template's `category` and `isSystem` — the grouping and the built-in flag nothing read (#22876) + +**BREAKING** — `category` and `isSystem` on an email template (`EmailTemplateDefinitionSchema`, the `email_template` metadata type) are now refused at parse. Nothing read either key once templates stopped being copied into `sys_email_template` rows: the Studio listing never offered the filter facet `category` promised, and no door refused deleting a template that set `isSystem`. Delete both keys. A template is found by its `name`, so group templates with a dotted namespace prefix (`auth.password_reset`, `crm.welcome`). A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. + +Clause-②: no (narrowing) + +Measured before removal: no reader of either key in `@objectstack/plugin-email`, `@objectstack/service-messaging`, `@objectstack/plugin-auth`, the metadata protocol, ObjectQL, the runtime or the REST layer, and none in the Studio console at its pinned commit beyond a fallback form input, which the console's own companion change removes. Both keys carried a schema default (`category: 'custom'`, `isSystem: false`), so every parsed template carried them and the liveness advisory fired on every template, with no way for an author to clear it. + +## FROM → TO + +| you wrote (17.7 and earlier) | write instead | +| --- | --- | +| `category: 'auth'` (or `'notification'`, `'workflow'`, `'marketing'`, `'custom'`) | nothing — delete the key; group by the `name` prefix | +| `isSystem: true` | nothing — delete the key | +| `isSystem: false` | nothing — delete the key | +| `import { EmailTemplateDefinitionCategorySchema } from '@objectstack/spec/system'` | nothing — the enum is removed with its key | +| `type EmailTemplateDefinitionCategory` | nothing — the type is removed with its key | + +**The one-line fix:** delete `category` and `isSystem` from every email template. + +**What an author who still writes either sees.** `tsc` fails at the authoring site (`EmailTemplateDefinition` types both keys as retired), and the parse — `defineEmailTemplateDefinition()`, `defineStack({ emailTemplates })`, `PUT /api/v1/meta/email_template/:name` — refuses each at its own path with the prescription: + +> `email_template.category` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing read it: the Studio listing never offered the filter facet it promised, and delivery never depended on it. Delete the key. A template is found by its `name`, so group templates with a dotted namespace prefix instead (`auth.password_reset`, `crm.welcome`). Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. + +> `email_template.isSystem` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — nothing enforced it: no door refused deleting a template that set it. Delete the key. A built-in template is a code package's declaration: a Studio save overrides it for the environment, and discarding that save restores the package's copy. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. + +## The retirement kit + +- **Two `retiredKey()` tombstones** on `EmailTemplateDefinitionSchema`, a `strictObject` — so the refusal carries the prescription rather than a bare unknown-key report, and `tsc` fails first. `RETIRED_KEYS_BY_MAJOR[18]`: `system/EmailTemplateDefinition:category`, `system/EmailTemplateDefinition:isSystem`. The `category` enum, `EmailTemplateDefinitionCategorySchema`, had no other consumer and leaves with it: `RETIRED_DEFS_BY_MAJOR[18]`: `system/EmailTemplateDefinitionCategory`. +- **The D2 conversion `email-template-category-is-system-removed`** (protocol 18, retired from the load path) deletes both keys from every `emailTemplates[]` entry, one notice per key, as a lossless delete. Because both keys carried a default, every built artifact and every stored `email_template` row written under 17.x carries them; both load through the rehydration seams, which replay this conversion. The retired-default residue stage is not adopted: the replayed conversion heals those populations, and an authored source that still writes either key meets the prescription. +- **The D3 entry `email-template-category-is-system-retired`** asks the author whether anything they built read either key — a report or integration that filtered templates by `category`, or one that treated `isSystem: true` as protection against deletion. Nothing ever enforced that. +- **Studio:** the email-template form drops the `category` select and the `isSystem` checkbox. +- **Ledger:** both rows stay `dead`, re-noted as tombstones (a tombstone keeps the key in the walked shape). +- **No deprecation window**, per the project's startup-stage posture. + +**`@objectstack/plugin-email`:** the six built-in auth templates (in every locale) no longer write `category: 'auth'` or `isSystem: true`. They render and send exactly as before. + +**`@objectstack/platform-objects`:** the metadata-form translation bundles drop the two retired form inputs' labels (`email_template.fields.category`, `email_template.fields.isSystem`). The `sys_email_template` object and its `category` / `is_system` columns are unchanged. + +⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, so this is breaking for consumers no telemetry was consulted for. + + diff --git a/content/docs/automation/email-templates.mdx b/content/docs/automation/email-templates.mdx index 27147c2ce73..7d9d5aac588 100644 --- a/content/docs/automation/email-templates.mdx +++ b/content/docs/automation/email-templates.mdx @@ -21,7 +21,6 @@ import { defineEmailTemplateDefinition } from '@objectstack/spec'; export const TaskDoneEmail = defineEmailTemplateDefinition({ name: 'crm.task_done', label: 'Task Done Notification', - category: 'workflow', locale: 'en-US', subject: 'Task done: {{task.title}}', bodyHtml: '
The task {{task.title}} on {{project.name}} was marked done.
', @@ -56,10 +55,14 @@ enforces dotted snake_case (`auth.password_reset`, `crm.large_deal_won`). Prefix it with your app or domain — the namespace is what keeps a tenant's templates from colliding with the built-in authentication mail. -`category` (`auth` | `notification` | `workflow` | `marketing` | `custom`, -default `custom`) is a filter facet in Studio listings, not a delivery -behaviour. `active: false` makes `sendTemplate` return `TEMPLATE_INACTIVE` -rather than silently sending nothing. +The namespace is also how you group templates: there is no separate category +key. `category` and `isSystem` were removed (ADR-0049 enforce-or-remove) because +nothing read either one, and the schema now refuses both with a migration note. +A built-in template is the email plugin's declaration, which a Studio save +overrides for the environment and discarding that save restores. + +`active: false` makes `sendTemplate` return `TEMPLATE_INACTIVE` rather than +silently sending nothing. ## Placeholders @@ -113,7 +116,6 @@ import { defineEmailTemplateDefinition } from '@objectstack/spec'; export const passwordResetEn = defineEmailTemplateDefinition({ name: 'auth.password_reset', label: 'Password Reset', - category: 'auth', locale: 'en-US', subject: 'Reset your password', bodyHtml: 'Use {{{reset_url}}} within {{ttl_minutes}} minutes.
', @@ -122,7 +124,6 @@ export const passwordResetEn = defineEmailTemplateDefinition({ export const passwordResetZh = defineEmailTemplateDefinition({ name: 'auth.password_reset', label: 'Password Reset', - category: 'auth', locale: 'zh-CN', subject: '重置您的密码', bodyHtml: '请在 {{ttl_minutes}} 分钟内使用 {{{reset_url}}}。
', diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 592ff0edfff..61d8db8a3f1 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -49,8 +49,8 @@ below, and it is left open deliberately rather than hidden — see ## Which `isSystem` this page is about Three unrelated declarations share the identifier. **This page documents only the -first.** The others are ordinary metadata fields on a stored document and have -nothing to do with elevation. (A fourth, `Environment.isSystem` — a +first.** The others are metadata keys on a stored document (one of them now +retired) and have nothing to do with elevation. (A fourth, `Environment.isSystem` — a platform-infrastructure environment, not user data — left this repo with the `@objectstack/spec/cloud` subpath in #16325 and is declared in the cloud repo.) @@ -58,7 +58,7 @@ platform-infrastructure environment, not user data — left this repo with the |:---|:---|:---:| | `ExecutionContext.isSystem` — `packages/spec/src/kernel/execution-context.zod.ts#isSystem` | The elevation flag on an operation's context | ✅ | | `Object.isSystem` — `packages/spec/src/data/object.zod.ts#isSystem` | Marks a **system object** (protected from deletion; defaults its org-wide sharing to `public` when no `sharingModel` is set) | ❌ | -| `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts#isSystem` | Built-in template; tenants may override but should not delete | ❌ | +| `EmailTemplate.isSystem` — `packages/spec/src/system/email-template.zod.ts#isSystem` | A retired key: a `retiredKey()` tombstone, which the parse refuses with a migration note | ❌ | The collision is a genuine hazard rather than a naming nit: `Object.isSystem` changes an object's **default sharing**, and `ExecutionContext.isSystem` changes diff --git a/content/docs/references/api/package-api-assembled.mdx b/content/docs/references/api/package-api-assembled.mdx index 335ca4fe67f..a9b2fb305ab 100644 --- a/content/docs/references/api/package-api-assembled.mdx +++ b/content/docs/references/api/package-api-assembled.mdx @@ -125,7 +125,7 @@ Installed package row whose manifest is the assembled package body | **actions** | `{ name: string; label: string \| RecordThe task {{title}} on project {{project}} was marked done.
', @@ -43,7 +42,6 @@ export const TaskDoneEmail = defineEmailTemplateDefinition({ { name: 'project', type: 'string', required: false, description: 'Project name' }, ], active: true, - isSystem: false, }); export const allEmails = [TaskDoneEmail]; diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index fe67205b24b..3e0cbaed042 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -4,6 +4,7 @@ import { afterAll, describe, it, expect } from 'vitest'; import { cpSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { EmailTemplateDefinitionSchema } from '@objectstack/spec/system'; import { ViewSchema } from '@objectstack/spec/ui'; import { authorWarnedProperties, @@ -403,25 +404,45 @@ describe('lintLivenessProperties', () => { // (webhook sits there the same way) — so that a future regression that // re-deadens a prop starts warning again on its own. // - // That is what happened, by design: ADR-0131 D6/D10 retired the + // That happened once, by design: ADR-0131 D6/D10 retired the // `sys_email_template` row projection, the only reader of `category` and - // `isSystem`, and the ledger grades both `dead`. A template that sets neither - // still warns about nothing; each one it sets draws one dead-property - // advisory (a boolean only when truthy). #22876 retires both keys in the next - // enforce-or-remove batch, which removes these advisories with them. - it('warns on an email_template only for the keys the ledger grades dead — `category`, and `isSystem` when truthy (#22876)', () => { + // `isSystem`, and the ledger graded both `dead`. Because `category` carried a + // schema default, every PARSED template carried it, so the dead-property + // advisory fired on every template and no author could clear it. + // + // #22876 retired both keys (ADR-0049 enforce-or-remove): `retiredKey()` + // tombstones, so the parse refuses either with the prescription and a parsed + // template carries neither. Their ledger rows stay `dead` (a tombstone keeps + // the key in the walked shape), so this lint would still warn on a RAW body + // that writes one — but the strict parse refuses that body first, which is + // the channel that reaches the author. What this pins is the advisory leaving + // the template a parse emits. + it('is silent on a parsed email_template — the retired `category` / `isSystem` never reach it (#22876)', () => { const base = { name: 'crm.welcome', label: 'Welcome', subject: 'Hi {{user.name}}', bodyHtml: 'Welcome
' }; expect(lintLivenessProperties({ emailTemplates: [base] })).toEqual([]); - expect(lintLivenessProperties({ emailTemplates: [{ ...base, isSystem: false }] })).toEqual([]); - - const withCategory = lintLivenessProperties({ emailTemplates: [{ ...base, category: 'auth' }] }); - expect(withCategory).toHaveLength(1); - expect(ruleOf(withCategory, 'category')).toBe(LIVENESS_DEAD_PROPERTY); - const withSystem = lintLivenessProperties({ emailTemplates: [{ ...base, isSystem: true }] }); - expect(withSystem).toHaveLength(1); - expect(ruleOf(withSystem, 'isSystem')).toBe(LIVENESS_DEAD_PROPERTY); + // The template a parse emits: every live default materialized, neither + // retired key. Before the retirement this same object carried + // `category: 'custom'` and drew the advisory. + const parsed = EmailTemplateDefinitionSchema.parse(base) as RecordHi
', category: 'workflow' }; + it('`email_template` — an item that authors no retired key draws nothing; one that does is the schema\'s to refuse (#22876)', () => { + // `category` and `isSystem` went `dead` when ADR-0131 D6/D10 retired the + // `sys_email_template` row projection, and #22876 retired both + // (`retiredKey()` tombstones, ADR-0049 enforce-or-remove). Their ledger + // rows stay `dead`, because a tombstone keeps the key in the walked shape, + // so this rule still names a raw body that writes one — but the door's + // schema refuses that body with the prescription before the write lands. + // An item that authors neither draws nothing: every other key is `live`. + const item = { name: 'acme_welcome', label: 'Welcome', subject: 'Hi', bodyHtml: 'Hi
' }; const result = runRuntimeAuthoringRules({ type: 'email_template', item, context: CONTEXT }); expect(result.errors, dump(result)).toEqual([]); - expect(advisoryIds(result.advisories), dump(result)).toEqual([ - { rule: LIVENESS_DEAD_PROPERTY, property: 'category' }, - ]); + expect(result.advisories, dump(result)).toEqual([]); + + const door = getMetadataTypeSchema('email_template'); + expect(door, 'no schema bound for `email_template`').toBeDefined(); + const refused = door!.safeParse({ ...item, category: 'workflow' }); + expect(refused.success).toBe(false); + expect(refused.error?.issues[0]?.path).toEqual(['category']); + expect(refused.error?.issues[0]?.message).toMatch(/^`email_template\.category` was removed in @objectstack\/spec 17/); }); it('[#21127] a `mapping` write that DOES author `connectorSource` is judged silent — not thrown on', () => { @@ -814,26 +810,20 @@ describe('#19542 — group C: wired, dispatched, and silent by ledger (email_tem item: { name: 'showcase_task_done_email', label: 'Task Done Notification', - category: 'workflow', locale: 'en-US', subject: 'Task done: {{title}}', bodyHtml: 'The task {{title}} was marked done.
', bodyText: 'The task {{title}} was marked done.', variables: [{ name: 'title', type: 'string', required: true, description: 'Task title' }], active: true, - isSystem: false, }, context: CONTEXT, }); + // Publishable and silent: the showcase template stopped writing the two + // keys #22876 retired (`category`, `isSystem`), so the one advisory it drew + // while they were `dead` and accepted is gone. expect(email.errors, dump(email)).toEqual([]); - // Publishable: no error. One advisory, on the one key the ledger grades - // `dead` that this template sets — `category` (its only reader, the - // retired `sys_email_template` row projection, is gone; #22876 retires the - // key). `isSystem: false` is dead too and draws nothing: a boolean warns - // only when set truthy. - expect(advisoryIds(email.advisories), dump(email)).toEqual([ - { rule: LIVENESS_DEAD_PROPERTY, property: 'category' }, - ]); + expect(email.advisories, dump(email)).toEqual([]); const mapping = runRuntimeAuthoringRules({ type: 'mapping', diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index 9d3b2096f16..41638c7296f 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -2230,9 +2230,6 @@ export const enMetadataForms: NonNullableHi {{user.name}}
', diff --git a/packages/plugins/plugin-email/src/template-loader.test.ts b/packages/plugins/plugin-email/src/template-loader.test.ts index 9406cdc7fc4..30dfa49cd7f 100644 --- a/packages/plugins/plugin-email/src/template-loader.test.ts +++ b/packages/plugins/plugin-email/src/template-loader.test.ts @@ -47,7 +47,6 @@ function declared(over: AnyRecord = {}): AnyRecord { return { name: 'auth.password_reset', label: 'Password Reset', - category: 'auth', subject: AUTHORED, bodyHtml: 'Click here
', variables: [{ name: 'url', type: 'string', required: true }], @@ -212,7 +211,7 @@ describe('createRegistryEmailTemplateLoader — which item wins one (name, local describe('createRegistryEmailTemplateLoader — the render input', () => { it('omits optional keys rather than nulling them, so the text part is derived only when no bodyText was authored', async () => { const loader = createRegistryEmailTemplateLoader({ - protocol: layeredProtocol([declared({ name: 'ops.digest', category: 'notification', variables: [] })]), + protocol: layeredProtocol([declared({ name: 'ops.digest', variables: [] })]), }); const found = await loader.load('ops.digest', 'en-US'); diff --git a/packages/plugins/plugin-email/src/templates/auth-templates.ts b/packages/plugins/plugin-email/src/templates/auth-templates.ts index c1b705dc604..eb917278ead 100644 --- a/packages/plugins/plugin-email/src/templates/auth-templates.ts +++ b/packages/plugins/plugin-email/src/templates/auth-templates.ts @@ -67,7 +67,6 @@ ${footerHtml} export const AUTH_PASSWORD_RESET_TEMPLATE: EmailTemplate = { name: 'auth.password_reset', label: 'Password Reset', - category: 'auth', locale: 'en-US', subject: 'Reset your {{appName}} password', bodyHtml: wrap('Reset your password', ` @@ -94,7 +93,6 @@ If you didn't request this, ignore this email.`, { name: 'appName', type: 'string', required: false, description: 'Product/app name (brand override)' }, ], active: true, - isSystem: true, description: 'Sent when a user requests a password reset via better-auth.', }; @@ -181,7 +179,6 @@ export const AUTH_PASSWORD_RESET_TEMPLATES: EmailTemplate[] = [ export const AUTH_VERIFY_EMAIL_TEMPLATE: EmailTemplate = { name: 'auth.verify_email', label: 'Verify Email Address', - category: 'auth', locale: 'en-US', subject: 'Verify your {{appName}} email address', bodyHtml: wrap('Verify your email', ` @@ -201,7 +198,6 @@ Please verify your email ({{user.email}}) by opening this link: { name: 'appName', type: 'string', required: false }, ], active: true, - isSystem: true, description: 'Sent when better-auth needs to verify a newly-registered email address.', }; @@ -270,7 +266,6 @@ export const AUTH_VERIFY_EMAIL_TEMPLATES: EmailTemplate[] = [ export const AUTH_MAGIC_LINK_TEMPLATE: EmailTemplate = { name: 'auth.magic_link', label: 'Magic Link Sign-In', - category: 'auth', locale: 'en-US', subject: 'Your {{appName}} sign-in link', bodyHtml: wrap('Sign in to {{appName}}', ` @@ -286,7 +281,6 @@ export const AUTH_MAGIC_LINK_TEMPLATE: EmailTemplate = { { name: 'appName', type: 'string', required: false }, ], active: true, - isSystem: true, description: 'Passwordless sign-in link sent by the magic-link plugin.', }; @@ -346,7 +340,6 @@ export const AUTH_MAGIC_LINK_TEMPLATES: EmailTemplate[] = [ export const AUTH_INVITATION_TEMPLATE: EmailTemplate = { name: 'auth.invitation', label: 'Organization Invitation', - category: 'auth', locale: 'en-US', subject: '{{inviter.name}} invited you to {{organization.name}}', bodyHtml: wrap('You have been invited', ` @@ -366,7 +359,6 @@ Accept: {{acceptUrl}}`, { name: 'appName', type: 'string', required: false }, ], active: true, - isSystem: true, description: 'Sent by better-auth organization plugin when a user is invited to an org.', }; @@ -429,7 +421,6 @@ export const AUTH_INVITATION_TEMPLATES: EmailTemplate[] = [ export const AUTH_TWO_FACTOR_OTP_TEMPLATE: EmailTemplate = { name: 'auth.two_factor_otp', label: 'Two-Factor Verification Code', - category: 'auth', locale: 'en-US', subject: 'Your {{appName}} verification code', bodyHtml: wrap('Your verification code', ` @@ -445,7 +436,6 @@ export const AUTH_TWO_FACTOR_OTP_TEMPLATE: EmailTemplate = { { name: 'appName', type: 'string', required: false }, ], active: true, - isSystem: true, description: 'Time-based OTP delivered for two-factor / email-OTP login.', }; @@ -545,7 +535,6 @@ const EMAIL_CHANGE_NOTICE_VARIABLES: EmailTemplate['variables'] = [ export const AUTH_EMAIL_CHANGE_NOTICE_TEMPLATE: EmailTemplate = { name: 'auth.email_change_notice', label: 'Email Change Notice (previous address)', - category: 'auth', locale: 'en-US', subject: 'Security notice: the email address on your {{appName}} account is being changed', bodyHtml: wrap( @@ -571,7 +560,6 @@ If you did not request this, your account may be compromised. Contact your to end other sessions.`, variables: EMAIL_CHANGE_NOTICE_VARIABLES, active: true, - isSystem: true, description: 'Sent to the PREVIOUS address when a change-email request is accepted, so a hijacked session cannot move the account identity unannounced. Notification only — never gates the change.', }; diff --git a/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts b/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts index 427e36babf8..6e3d0f75eef 100644 --- a/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/email-template-materialization-fixture.ts @@ -43,7 +43,6 @@ export const EtNote = ObjectSchema.create({ export const etPasswordReset = defineEmailTemplateDefinition({ name: 'auth.password_reset', label: 'ET Password Reset', - category: 'auth', subject: 'Authored reset for {{user.name}}', bodyHtml: 'Authored body: reset
', variables: [{ name: 'url', type: 'string', required: true }], diff --git a/packages/qa/downstream-contract/src/additional-domains.fixtures.ts b/packages/qa/downstream-contract/src/additional-domains.fixtures.ts index 9c25fa57b29..ac233e96d17 100644 --- a/packages/qa/downstream-contract/src/additional-domains.fixtures.ts +++ b/packages/qa/downstream-contract/src/additional-domains.fixtures.ts @@ -79,7 +79,6 @@ export const DcPermissionSet: PermissionSet = { export const DcEmail: EmailTemplateDefinition = { name: 'dc.welcome', label: 'DC Welcome', - category: 'marketing', locale: 'en-US', subject: 'Welcome {{contact.first_name}}', bodyHtml: 'Hi {{contact.first_name}}, welcome.
', diff --git a/packages/runtime/src/app-plugin-converted-collections.test.ts b/packages/runtime/src/app-plugin-converted-collections.test.ts index f7709eb06a7..d25a05776cd 100644 --- a/packages/runtime/src/app-plugin-converted-collections.test.ts +++ b/packages/runtime/src/app-plugin-converted-collections.test.ts @@ -20,12 +20,12 @@ * * The second type is `dashboard`, converted by the major-18 retired entry * `dashboard-refresh-interval-to-refresh-interval-seconds` (retiredAfter - * 17.3.0). It sits beside `emailTemplates`, the card's own type. That type has - * no conversion on this tree: its `category` / `isSystem` retirement is a - * separate change. So its legs below assert the contract (the registry - * holds the conversion's OUTPUT, and that output passes the email loader's own - * parse). That contract has no retired key to bite on here, and it does the - * day a retirement of that type lands. + * 17.3.0). It sits beside `emailTemplates`, the card's own type. Its legs + * below assert the contract (the registry holds the conversion's OUTPUT, and + * that output passes the email loader's own parse). The fixture template + * authors no retired key: the `category` / `isSystem` retirement (#22876) + * converts those two, and its tree-scoped absence pin keeps them out of + * authored literals. * * Out of scope, on purpose: the PARSE axis. The door strict-parses (schema * defaults, ADR-0122 transforms), and the registry copy does not. So the item @@ -59,15 +59,20 @@ const STAMPS = { _packageId: APP_ID, _packageVersion: '1.0.0', _provenance: 'pac /** A dashboard carrying the retired `refreshInterval` spelling. */ const RETIRED_DASHBOARD = { name: 'ops_board', label: 'Ops Board', widgets: [], refreshInterval: 300 }; -/** What a 17.x parse emitted for an email template: both defaulted keys present. */ +/** + * An email template in the artifact. It no longer carries the `category` / + * `isSystem` defaults a 17.x parse emitted: #22876 retires both keys, and its + * tree-scoped absence pin refuses an email-template literal that authors + * either one outside the retirement kit. The legs below still assert the + * contract for this type; the conversion of those two keys is pinned by that + * retirement's own tests. + */ const EMAIL_TEMPLATE = { name: 'probe.welcome', label: 'Welcome', locale: 'en-US', subject: 'Welcome', bodyHtml: 'Hi
', - category: 'custom', - isSystem: false, }; /** diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 98ec9a151e2..840f19e9b0c 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -216,8 +216,6 @@ "EmailServiceConfigParsed (type)", "EmailServiceConfigSchema (const)", "EmailTemplateDefinition (type)", - "EmailTemplateDefinitionCategory (type)", - "EmailTemplateDefinitionCategorySchema (const)", "EmailTemplateDefinitionParsed (type)", "EmailTemplateDefinitionSchema (const)", "EmailTemplateDefinitionVariable (type)", diff --git a/packages/spec/authorable-defaults/system.json b/packages/spec/authorable-defaults/system.json index 4f73427e46e..825bdbb7469 100644 --- a/packages/spec/authorable-defaults/system.json +++ b/packages/spec/authorable-defaults/system.json @@ -86,8 +86,6 @@ "system/EmailAndPasswordConfig:enabled = true", "system/EmailServiceConfig:provider = \"log\"", "system/EmailTemplateDefinition:active = true", - "system/EmailTemplateDefinition:category = \"custom\"", - "system/EmailTemplateDefinition:isSystem = false", "system/EmailTemplateDefinition:locale = \"en-US\"", "system/EmailTemplateDefinition:variables = []", "system/EmailTemplateDefinitionVariable:required = false", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 1e1f8beb211..0ac7ccec4ba 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -382,10 +382,10 @@ "system/EmailTemplateDefinition:active", "system/EmailTemplateDefinition:bodyHtml", "system/EmailTemplateDefinition:bodyText", - "system/EmailTemplateDefinition:category", + "system/EmailTemplateDefinition:category [RETIRED]", "system/EmailTemplateDefinition:description", "system/EmailTemplateDefinition:fromOverride", - "system/EmailTemplateDefinition:isSystem", + "system/EmailTemplateDefinition:isSystem [RETIRED]", "system/EmailTemplateDefinition:label", "system/EmailTemplateDefinition:locale", "system/EmailTemplateDefinition:name", diff --git a/packages/spec/declaration-map/system.json b/packages/spec/declaration-map/system.json index 8e9c821f02e..bb2f8859d71 100644 --- a/packages/spec/declaration-map/system.json +++ b/packages/spec/declaration-map/system.json @@ -145,8 +145,6 @@ "EmailServiceConfig": "system/EmailServiceConfig", "EmailServiceConfigSchema": "system/EmailServiceConfig", "EmailTemplateDefinition": "system/EmailTemplateDefinition", - "EmailTemplateDefinitionCategory": "system/EmailTemplateDefinitionCategory", - "EmailTemplateDefinitionCategorySchema": "system/EmailTemplateDefinitionCategory", "EmailTemplateDefinitionSchema": "system/EmailTemplateDefinition", "EmailTemplateDefinitionVariable": "system/EmailTemplateDefinitionVariable", "EmailTemplateDefinitionVariableSchema": "system/EmailTemplateDefinitionVariable", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 241dbee055f..183d397e158 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -210,8 +210,6 @@ "EmailServiceConfigParsed": "src/system/email-config.zod.ts#EmailServiceConfigParsed (type)", "EmailServiceConfigSchema": "src/system/email-config.zod.ts#EmailServiceConfigSchema (const)", "EmailTemplateDefinition": "src/system/email-template.zod.ts#EmailTemplateDefinition (type)", - "EmailTemplateDefinitionCategory": "src/system/email-template.zod.ts#EmailTemplateDefinitionCategory (type)", - "EmailTemplateDefinitionCategorySchema": "src/system/email-template.zod.ts#EmailTemplateDefinitionCategorySchema (const)", "EmailTemplateDefinitionParsed": "src/system/email-template.zod.ts#EmailTemplateDefinitionParsed (type)", "EmailTemplateDefinitionSchema": "src/system/email-template.zod.ts#EmailTemplateDefinitionSchema (const)", "EmailTemplateDefinitionVariable": "src/system/email-template.zod.ts#EmailTemplateDefinitionVariable (type)", diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json index 3aef2c59248..6e431524388 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -78,7 +78,6 @@ "system/EmailProvider", "system/EmailServiceConfig", "system/EmailTemplateDefinition", - "system/EmailTemplateDefinitionCategory", "system/EmailTemplateDefinitionVariable", "system/EmailVerificationConfig", "system/EncryptionAlgorithm", diff --git a/packages/spec/liveness/README.md b/packages/spec/liveness/README.md index 4d0f90fbe6b..a941585edc6 100644 --- a/packages/spec/liveness/README.md +++ b/packages/spec/liveness/README.md @@ -976,7 +976,7 @@ marker where the Notes cell goes, never a guess at what belongs there. | app | seeded 2026-08-01 (#4488). Dead 8 = the seven #4142 `retiredKey` tombstones (version/aria/objects/apis/sharing/embed/mobileNavigation — rows stay while the tombstones hold the keys in the walked shape) + `homePageId` (#4667 tombstone — the landing IS the first nav item; root landing follows `isDefault` routing). `areas.description` left that set on 2026-09-30 (#20299): the Studio app preview draws it (objectui#11027). RETIRED 17.0.0 (#4509, rows deleted — the selector schema is strict): selector `includeAll` (deliberately DISOBEYED, not merely unread — selectors are mandatory-scope and an "All" row would clear the scope, leaking system metadata through Studio's package filter; STUDIO_APP authored it against a renderer that ignored it) and `placement` (no renderer read it; "topbar" placed nothing). Nav walk covers the union's `object` variant; other variants hand-verified live, and the `actionDef` dispatch gap closed in #4509 **#4651**: the **fail-open area gates** `areas.visible` / `areas.requiredPermissions` — this ledger's most important app finding — are REMOVED, rows DELETED (strict removal; retained rows would report ORPHAN). They were not merely unread: `filterAppForUser` never reads `item.areas` at all and the shell renders every area, so a "hidden" or permission-gated area was served to everyone, while the identically named per-ITEM and per-APP keys ARE enforced. Route B (remove) over route A (enforce): enforcing needs semantics decided first (does filtering an area remove its items everywhere? does the server bind `user` for area CEL?), which the 17.0.0 window could not hold. Boundary unchanged and still recorded on `areas.navigation`: per-item gating inside an area is shell-side only. **#4667**: `homePageId` TOMBSTONED (row stays — retiredKey keeps it in the walked shape) and `areas.order` row DELETED (strict removal); `areas.order` read alive because the per-ITEM `order` really is sorted (NavigationRenderer.tsx:1154) while no renderer ever sorted areas. **The dead eight above reconcile exactly** — only `areas.description` has left that set since #4667, and nothing has joined it; what moved after it are two additions on the other side of the ledger. **#4829** (PR #6942) declares `_unpublished`, `live`: a MACHINE-MANAGED publish gate, never authored, written by the AI additive-materialization path and cleared by `POST /packages/:id/publish-drafts`, and server-enforced in `filterAppForUser`. It is declared on AppSchema rather than omitted because the write path validates against that schema, so the flip itself would otherwise be unwritable — a `live` row for a key no author may set, which is the inverse of every dead row here. **#4848** (PR #7253) gave this type its **first `planned`**: `navigation.runAction`, the contract-first half of the SDUI deep-link promotion. `planned` and not `dead` was load-bearing, the same distinction `api`'s two mapping transforms draw — the reference IS validated at authoring (framework `validateCrossReferences`, the lint's nav `runAction` arm), and it carried `authorWarn` saying the shell did not read the declared slot yet, while auto-run fired only via the transitional `?runAction=` query param. **#10068 FLIPS it `live` 2026-08-20** (objectui#5216 via objectui PR #5354, absorbed by the `.objectui-sha` pin `9a3daf8`, which postdates that merge — both pointers were read AT THE PIN this repo builds against). It is this table's clearest worked example of **why one pointer is not a call graph**: the work-list anticipated a single `NavigationRenderer` pointer, and that would have overstated one half and understated the other. The renderer is the **`producer`** (`NAV_RUN_ACTION_PARAM`, the wire name's one definition, and `withRunAction` applied in `resolveHref`'s object branch — list landings only, never the `recordId` branch): it WRITES the deep link and runs nothing. What makes authoring the key change runtime behaviour is the **`evidence`** side, `app-shell`'s `useNavRunAction` read-once/consume-once hook, wired at every object list plus the entitlement-gated environment toolbar — so a renderer-only pointer would have said the slot is live because something *emits* it. Read it beside `seed.env` in the `producer` section above: same shape, opposite outcome, because here the second input really is supplied. ⚠️ It also records an **enforcement ≠ consumption** measurement worth carrying: the published `@objectstack/spec@17.0.0` does not enforce the `runAction` × `recordId` exclusivity (the `objectNavTargetExclusivity` superRefine is on this repo's `main` but outside the GA build) and accepts `runAction: ''` — the merged-but-unpublished window, not a defect — which makes objectui's list-surface-only precedence load-bearing rather than defensive. **merged upstream ≠ published ≠ pinned downstream**, and unlike a missing key a missing *refinement* fails silent (objectui#5328). | | book | seeded 2026-08-01 (#4488). ADR-0046 §6 spine; `audience` is ENFORCED and fail-closed (tree 401/403 + per-doc effective-audience union on both list and tree). Dead 2 = BOTH inline `translations` maps (book-level and per-group): no resolver reads them and the bundle translator doesn't cover `book` — the trap is that `doc.translations` two files over works on every read path. Also recorded: the `include: { tag }` rule variant is live — `tags` was DECLARED on DocSchema in 17.0.0 (#4509, ADR-0049); see the `doc` row below for the fix's full history. **#4667**: both inline translation maps retired — book-level row DELETED (BookSchema is strictObject), group-level row KEPT as a tombstone (BookGroupSchema is a plain z.object with no .strict(), so a bare delete would have zod silently strip it). No resolver read either; the trap was proximity to `doc.translations`, which is live on every doc render path. | | doc | seeded 2026-08-01 (#4488). Fully live: the kernel stores `content` unparsed, but the REST read layer localizes (resolveDocLocale), audience-gates, list-strips `content`, and the book resolver consumes name/label/description/order/group — plus the objectui console portal renders it all. The schema's own "docs are inert data" header describes the kernel, not the type. **`tags` DECLARED in 17.0.0 (#4509)** — the enforce half of enforce-or-remove: the book resolver's `include: { tag }` matcher, the REST transport and `ResolverDoc.tags` all already existed, but DocSchema is strict and had no `tags` key, so authoring one was a parse error and the variant could never match. Live on arrival | -| email_template | this row read 8/–/13/– for one day (seeded 2026-08-01, #4488: "every authorable property is dead", the webhook shape on AUTH mail) and #4509 CLOSED it by ENFORCING — the second worked example, after `webhook`, that a dead verdict is a worklist entry rather than a tombstone. `bootstrapDeclaredEmailTemplates` materializes declared items into the `sys_email_template` rows `sendTemplate` reads, sharing `mapTemplateToRow` with the built-in seeder so the two doors cannot drift, and re-materializes on live metadata writes (`email_template` is `allowRuntimeCreate: true`, so boot-only would have left Studio saves inert). Three breaks had to close, not one: the engine never registered `emailTemplates:` into the registry, built-in seeds masqueraded as `managed_by: admin` and outranked declared templates, and nothing materialized. ADR-0054 proof bound on `subject` (`email-template-materialization`) **#15205** (2026-10-11, ADR-0131 D6/D10, ruling C on §6 Q1): the row path above RETIRED — the built-in seed, `bootstrapDeclaredEmailTemplates`, `mapTemplateToRow` and the live projector are gone, and `createRegistryEmailTemplateLoader` reads `email_template` from the registry by (name, locale) at every send, the built-in auth templates code-declared under the email plugin's own package. Every live citation was re-anchored by hand to the loader's `toRenderInput` and the service's reads; the proof now asserts the authored override, the built-in fallback locale and a Studio save live without a restart. Two keys re-grade `live` → `dead`, because the row projection was their only consumer: `category` (a Setup column; objectui reads it only as an editor input, never as the list facet the schema promises) and `isSystem` (the built-in seeder's stand-down). `label` and `description` stay `live` on the Studio metadata list's default columns (objectui pin 0df67f23). ⚠️ `category` is schema-DEFAULTED, so its dead verdict warns on every template — the unwarnable-default shape the `mapping` row retired keys over | +| email_template | this row read 8/–/13/– for one day (seeded 2026-08-01, #4488: "every authorable property is dead", the webhook shape on AUTH mail) and #4509 CLOSED it by ENFORCING — the second worked example, after `webhook`, that a dead verdict is a worklist entry rather than a tombstone. `bootstrapDeclaredEmailTemplates` materializes declared items into the `sys_email_template` rows `sendTemplate` reads, sharing `mapTemplateToRow` with the built-in seeder so the two doors cannot drift, and re-materializes on live metadata writes (`email_template` is `allowRuntimeCreate: true`, so boot-only would have left Studio saves inert). Three breaks had to close, not one: the engine never registered `emailTemplates:` into the registry, built-in seeds masqueraded as `managed_by: admin` and outranked declared templates, and nothing materialized. ADR-0054 proof bound on `subject` (`email-template-materialization`) **#15205** (2026-10-11, ADR-0131 D6/D10, ruling C on §6 Q1): the row path above RETIRED — the built-in seed, `bootstrapDeclaredEmailTemplates`, `mapTemplateToRow` and the live projector are gone, and `createRegistryEmailTemplateLoader` reads `email_template` from the registry by (name, locale) at every send, the built-in auth templates code-declared under the email plugin's own package. Every live citation was re-anchored by hand to the loader's `toRenderInput` and the service's reads; the proof now asserts the authored override, the built-in fallback locale and a Studio save live without a restart. Two keys re-grade `live` → `dead`, because the row projection was their only consumer: `category` (a Setup column; objectui reads it only as an editor input, never as the list facet the schema promises) and `isSystem` (the built-in seeder's stand-down). `label` and `description` stay `live` on the Studio metadata list's default columns (objectui pin 0df67f23). ⚠️ `category` was schema-DEFAULTED, so its dead verdict warned on every template — the unwarnable-default shape the `mapping` row retired keys over. **#22876** (2026-10-12, ADR-0049 enforce-or-remove) RETIRED both: `retiredKey()` tombstones on the `strictObject`, stripped from sources, stored rows and built artifacts by the protocol-18 conversion `email-template-category-is-system-removed`, the `category` enum leaving with its key. Both rows STAY `dead` (the tombstone keeps the key in the walked shape), so the count does not move; the advisory left every template because a parsed template no longer carries `category` | | job | seeded 2026-08-01 (#4488). The file-authored path is fully enforced: all three schedule shapes honored by the adapters, `retryPolicy`/`timeout` enforced since #3494 (this is the retryPolicy the datasource ledger warns about confusing with its dead namesake), `enabled: false` skips scheduling. Dead 3 = `id` (authorWarn — `name` is the identity everywhere) + label/description (docs-kept). The type-level gap CLOSED 2026-08-02 (#4509) by closing the door rather than bridging it: `handler` names a function in the compiled bundle's function table, which a runtime writer cannot name, so `allowRuntimeCreate` **and** `allowEnvironmentOverlay` are now false and `*.job.ts` / `defineStack({ jobs })` are the supported doors. The kind stays registered — its file loader is genuinely consumed (ADR-0088 admission test) **#4667**: `id` REMOVED (row deleted, strict removal) — nothing read it and its own describe() ("defaults to `name` when omitted") advertised an identity override that never existed; `name` is the scheduling key, the sys_job row key and the JobExecution.jobId stamp, so two jobs differing only in `id` were one job. **#7131** (PR #7425) takes the remaining two: `label` and `description` re-grade `dead` → `live` under the 2026-08-10 maintainer ruling that **designer previews count as consumers** — objectui's `JobPreview` had been reading `d.label` and `d.description` and rendering them as the preview card's title and subtitle the whole time, so the old "no runtime consumer" was a true statement about the *scheduler* and a false one about the system. **This row now has zero dead and the ADR-0033 exemption is still in force**, which is worth saying out loud because it is the first row in this table where those two facts hold together: the keys are still docs-shaped, still deliberately KEPT, still not `authorWarn`'d, and enforce-or-remove still has nothing to chase here. What changed is only that the exemption no longer has to carry the verdict — the measurement does. | | mapping | seeded 2026-08-01 (#4488) at 8/11 live; **0 dead since #4509** retired the three that were not. The import half (#2611) is loudly enforced — unsupported transforms/formats are 400s, `mode`/`upsertKey` default the request, the wizard picker renders `label`. RETIRED 17.0.0: `extractQuery` (authorWarn — "for export only" promised an export path no exporter implements) + `errorPolicy`/`batchSize`, which were dead AND **unwarnable** (schema defaults materialize at parse, so presence ≠ authored — `_authorWarnSkipped`, the non-boolean instance of the default(true) rule). That unwarnability is why they went out in the 17.0.0 window rather than after a deprecation cycle: removal was the only channel that could ever reach the author. Rows DELETED, not tombstoned — MappingSchema is strict, so the keys left the walked shape. `connectorSource` (2026-09-30) is this type's first `planned` row: the pull binding the connector-attached sync family MOVED here when the connector's `syncConfig` / `fieldMappings` retired to tombstones, seeded contract-first with `authorWarn` (its seven drilled rows — five children and `watermark`'s two — `planned` with it) because the pull executor is the next stage. **Live since 2026-10-01 (#20919)**: the connector sync executor reads every key. The container's `authorWarn` left with that re-grade (#21127) — kept at first, it made the author-side lint throw, so `os validate` / `os lint` exited 1 on every stack that authored the binding; the caveat it carried (nothing schedules a pull until the `job` stage lands) is on the key's description | | picklist | seeded 2026-09-30 (#19518) with every key `planned`, ahead of its runtime reader by ruling; **live since the runtime layer (#19519)**: the ObjectQL registry merges `picklistExtensions` into the list (additive only, a repeated value refused) and resolves it onto every field that names it, the write door judges against that set, and a field naming an undeclared list fails the boot. `field.picklist` left `authorWarn` with it. Display keys (`label`, `description`, `options.color`) are graded by their served form. | diff --git a/packages/spec/liveness/email_template.json b/packages/spec/liveness/email_template.json index bd0cd0718af..1e6bb6f9451 100644 --- a/packages/spec/liveness/email_template.json +++ b/packages/spec/liveness/email_template.json @@ -1,6 +1,6 @@ { "type": "email_template", - "_note": "EmailTemplateDefinitionSchema. THE WHOLE SURFACE WENT LIVE with the #4509 materializer bridge — this file previously recorded the webhook (#3461) disconnect shape, all 13 props dead, and it is kept as the worked example that a dead verdict is a worklist entry, not a tombstone: enforce-or-remove resolved this one by ENFORCING. What carries it NOW (2026-10-11, ADR-0131 D6/D10, ruling C on §6 Q1): the bridge that copied declared items into `sys_email_template` rows retired together with the built-in seed, the boot sweep and the live projector. `createRegistryEmailTemplateLoader` (packages/plugins/plugin-email/src/template-loader.ts) reads `email_template` metadata from the registry by (name, locale) at every send — the protocol's layered execution list, the environment's item over each code package's declaration — and the built-in auth templates are code-declared items of the email plugin's own package (`EmailServicePlugin.init` registers them through its manifest). Each candidate is validated through `EmailTemplateDefinitionSchema` (`safeParse` in the loader — the spec schema keeps a real consumer, defaults and all; a malformed item refuses the send instead of falling through to the built-in) and carried into the render input by `toRenderInput`; `EmailService.resolveAndRenderTemplate` renders it and `sendTemplate` keeps the envelope reads. A Studio save is in the layered list the moment it lands, so it is live on the next send with no subscription and no projection. The ADR-0054 proof (`email-template-materialization`) asserts that an app-authored override of a BUILT-IN auth template is what reaches the transport, that the built-in still serves the locales the app leaves out, and that a Studio save over the authored item sends without a restart. Two keys lost their only consumer with the rows: `category` and `isSystem` were read by nothing but the row projection (a Setup column and the built-in seeder's stand-down), and are `dead` from 2026-10-11 — see their rows. `label` and `description` are read by the Studio metadata list. `protection`/_lock*/_provenance are framework overlay fields, auto-live. 2026-08-28 (commit 8cb96ec41): every citation in this file was re-anchored to its consuming symbol.", + "_note": "EmailTemplateDefinitionSchema. THE WHOLE SURFACE WENT LIVE with the #4509 materializer bridge — this file previously recorded the webhook (#3461) disconnect shape, all 13 props dead, and it is kept as the worked example that a dead verdict is a worklist entry, not a tombstone: enforce-or-remove resolved this one by ENFORCING. What carries it NOW (2026-10-11, ADR-0131 D6/D10, ruling C on §6 Q1): the bridge that copied declared items into `sys_email_template` rows retired together with the built-in seed, the boot sweep and the live projector. `createRegistryEmailTemplateLoader` (packages/plugins/plugin-email/src/template-loader.ts) reads `email_template` metadata from the registry by (name, locale) at every send — the protocol's layered execution list, the environment's item over each code package's declaration — and the built-in auth templates are code-declared items of the email plugin's own package (`EmailServicePlugin.init` registers them through its manifest). Each candidate is validated through `EmailTemplateDefinitionSchema` (`safeParse` in the loader — the spec schema keeps a real consumer, defaults and all; a malformed item refuses the send instead of falling through to the built-in) and carried into the render input by `toRenderInput`; `EmailService.resolveAndRenderTemplate` renders it and `sendTemplate` keeps the envelope reads. A Studio save is in the layered list the moment it lands, so it is live on the next send with no subscription and no projection. The ADR-0054 proof (`email-template-materialization`) asserts that an app-authored override of a BUILT-IN auth template is what reaches the transport, that the built-in still serves the locales the app leaves out, and that a Studio save over the authored item sends without a restart. Two keys lost their only consumer with the rows: `category` and `isSystem` were read by nothing but the row projection (a Setup column and the built-in seeder's stand-down), went `dead` on 2026-10-11, and were RETIRED on 2026-10-12 (#22876, ADR-0049 enforce-or-remove) as `retiredKey()` tombstones, so their rows stay `dead` — see their rows. `label` and `description` are read by the Studio metadata list. `protection`/_lock*/_provenance are framework overlay fields, auto-live. 2026-08-28 (commit 8cb96ec41): every citation in this file was re-anchored to its consuming symbol.", "props": { "name": { "status": "live", @@ -18,8 +18,8 @@ }, "category": { "status": "dead", - "verifiedAt": "2026-10-11", - "note": "DEAD since 2026-10-11 (ADR-0131 D6/D10, ruling C on §6 Q1): the key's only consumer was the row projection into `sys_email_template.category` (a Setup column and index), which retired with the rows. Measured: no reader in this repository (plugin-email, service-messaging, plugin-auth), and at the `.objectui-sha` pin 4997995022f8 objectui reads it only as an editor input (`metadata-admin/default-schemas.ts:310`, the `email_template` form's `category` enum), never as the Studio list facet the schema's own doc promises (`ResourceListPage.tsx#defaultColumns:835` renders name/label/description). Tracked for enforce-or-remove (ADR-0049). ⚠️ The schema DEFAULTS it (`'custom'`), so presence is not authorship — the mapping precedent (`errorPolicy`/`batchSize`) is why removal, not a warning, is the channel that reaches an author." + "verifiedAt": "2026-10-12", + "note": "REMOVED 2026-10-12 (ADR-0049 enforce-or-remove, #22876) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources, stored rows and built artifacts by the protocol-18 conversion `email-template-category-is-system-removed` (the D3 entry `email-template-category-is-system-retired`). The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). Its `dead` verdict is carried here unchanged: the key's only consumer was the row projection into `sys_email_template.category` (a Setup column and index), which retired with the rows (ADR-0131 D6/D10, 2026-10-11). Re-measured 2026-10-12 at e357a441c6: no reader in packages/plugins/plugin-email, packages/services/service-messaging, packages/plugins/plugin-auth, the metadata protocol, objectql, the runtime or the REST layer, and at the `.objectui-sha` pin 4997995022f8 objectui reads it only as a fallback editor input (`metadata-admin/default-schemas.ts`, the `email_template` form's `category` enum), never as the Studio list facet the schema's doc promised; the objectui companion drops that input. The enum `EmailTemplateDefinitionCategorySchema` left with the key. Because the key carried a default (`'custom'`), every template a 17.x parse emitted carried it and the dead-property advisory fired on every template; a parsed template no longer carries it. Group templates by the dotted `name` namespace instead. The tombstone is packages/spec/src/system/email-template.zod.ts#EmailTemplateDefinitionSchema." }, "locale": { "status": "live", @@ -80,8 +80,8 @@ }, "isSystem": { "status": "dead", - "verifiedAt": "2026-10-11", - "note": "DEAD since 2026-10-11 (ADR-0131 D6/D10, ruling C on §6 Q1): its only consumers were the built-in seeder's stand-down (`EmailServicePlugin.upsertTemplate`: `if (existingRow.is_system === false) return;`) and the `sys_email_template.is_system` column, both retired with the rows. Measured: no reader in this repository, and at the `.objectui-sha` pin 4997995022f8 objectui reads it only as an editor input (`metadata-admin/default-schemas.ts:324`, the `email_template` form's `System Template` checkbox). The schema's own claim — a system template \"SHOULD NOT be deleted by tenants\" — is enforced by nothing: the built-ins are code-declared items, so the metadata door can only discard an environment item over them, whatever this key says. Tracked for enforce-or-remove (ADR-0049)." + "verifiedAt": "2026-10-12", + "note": "REMOVED 2026-10-12 (ADR-0049 enforce-or-remove, #22876) — tombstoned at the schema (retiredKey carries the prescription; authoring it is a tsc error and a parse error) and stripped from sources, stored rows and built artifacts by the protocol-18 conversion `email-template-category-is-system-removed` (the D3 entry `email-template-category-is-system-retired`). The entry stays because retiredKey keeps the key in the walked shape (the rls.priority precedent). Its `dead` verdict is carried here unchanged: its only consumers were the built-in seeder's stand-down (`EmailServicePlugin.upsertTemplate`: `if (existingRow.is_system === false) return;`) and the `sys_email_template.is_system` column, both retired with the rows (ADR-0131 D6/D10, 2026-10-11). Re-measured 2026-10-12 at e357a441c6: no reader in this repository, and at the `.objectui-sha` pin 4997995022f8 objectui reads it only as a fallback editor input (`metadata-admin/default-schemas.ts`, the `email_template` form's `System Template` checkbox); the objectui companion drops it. Its claim — a system template \"SHOULD NOT be deleted by tenants\" — was enforced by nothing: a built-in template is a code package's declaration, so a Studio save overrides it for the environment and discarding that save restores the package's copy, whatever this key said. The tombstone is packages/spec/src/system/email-template.zod.ts#EmailTemplateDefinitionSchema." }, "description": { "status": "live", diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 0736edfc1f6..96d69e4b513 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -8464,6 +8464,124 @@ function respellInlineGridColumns( return changed ? next : columns; } +/** + * `category` and `isSystem` on an email template — retired together (#22876, + * ADR-0049 enforce-or-remove). + * + * Nothing read either key once ADR-0131 D6/D10 retired the projection of + * declared templates into `sys_email_template` rows: `category` fed a Setup + * column there and `isSystem` the built-in seeder's stand-down. The tombstones + * on `EmailTemplateDefinitionSchema` refuse both at parse (see + * `EMAIL_TEMPLATE_CATEGORY_REMOVED` and `EMAIL_TEMPLATE_IS_SYSTEM_REMOVED` in + * `system/email-template.zod.ts`). + * + * ## Why a D2 strip + * + * Both keys carried a schema DEFAULT (`'custom'`, `false`), so a 17.x parse + * wrote both into every template it emitted, whether or not the author wrote + * them: every built artifact and every stored `email_template` row carries + * them. After the tombstones the boot door refuses such a template + * (`ObjectStackDefinitionSchema` spreads + * `emailTemplates: z.array(EmailTemplateDefinitionSchema)`), and so does the + * email plugin's loader, which re-parses every candidate at send time. Only + * the D2 table is replayed at the rehydration seams + * (`applyArtifactForwardConversions`, `applyConversionsToStoredItem`), so a + * built artifact or a stored row loads and sends only through this entry. The + * strip is lossless: nothing read either key, so no send renders differently. + * + * Each key leaves whatever it holds — the default, an authored value, or a + * value no longer of the declared type: the tombstone refuses every value, so + * a strip that kept any would leave a template that still cannot load. The + * emitted path NAMES the template, as `cube-refresh-key-removed` names its + * cube: an index into `emailTemplates[]` is a position, not a name. The D3 + * record is the semantic entry `email-template-category-is-system-retired`. + */ +const emailTemplateCategoryIsSystemRemoved: MetadataConversion = { + id: 'email-template-category-is-system-removed', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: 'emailTemplates[].category / emailTemplates[].isSystem', + summary: + "email template keys 'category' and 'isSystem' removed (ADR-0049 enforce-or-remove — nothing read " + + 'either: the Studio listing never offered the category facet, and no door refused deleting a ' + + "template that set isSystem. Delete both keys; a built-in template is a code package's declaration " + + 'that a Studio save overrides and a discard restores)', + apply(stack, emit) { + return mapCollection(stack, 'emailTemplates', (template, path) => { + // Name the template, not just its index: the notice is the only record + // an upgrading author gets of WHICH template lost the keys. + const where = typeof template.name === 'string' ? `${path}(${template.name})` : path; + return stripKeys(template, ['category', 'isSystem'], emit, where); + }); + }, + fixture: { + before: { + emailTemplates: [ + { + // The built-in auth templates' shape: both keys authored. + name: 'auth.password_reset', + label: 'Password Reset', + category: 'auth', + locale: 'en-US', + subject: 'Reset your password', + bodyHtml: '{{{resetUrl}}}
', + isSystem: true, + }, + { + // What a 17.x parse emitted for a template whose author wrote + // neither key: both defaults, materialized. + name: 'crm.welcome', + label: 'Welcome', + category: 'custom', + locale: 'en-US', + subject: 'Welcome', + bodyHtml: 'Welcome
', + isSystem: false, + }, + { + // Already canonical — rides through untouched. The fixture's own + // control: the strip dispatches on key presence, and copy-on-write + // keeps this reference. + name: 'crm.follow_up', + label: 'Follow-up', + locale: 'en-US', + subject: 'Following up', + bodyHtml: 'Following up
', + }, + ], + }, + after: { + emailTemplates: [ + { + name: 'auth.password_reset', + label: 'Password Reset', + locale: 'en-US', + subject: 'Reset your password', + bodyHtml: '{{{resetUrl}}}
', + }, + { + name: 'crm.welcome', + label: 'Welcome', + locale: 'en-US', + subject: 'Welcome', + bodyHtml: 'Welcome
', + }, + { + name: 'crm.follow_up', + label: 'Follow-up', + locale: 'en-US', + subject: 'Following up', + bodyHtml: 'Following up
', + }, + ], + }, + // Four notices, one per STRIPPED KEY: both keys on `auth.password_reset` + // and on `crm.welcome`, none for the canonical `crm.follow_up`. + expectedNotices: 4, + }, +}; + /** * `field.inlineColumns[]` / `field.relatedListColumns[]` — the mechanical half * of the #9227 strict-element narrowing (protocol 18). @@ -15740,6 +15858,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: elementFormRemoved, order: 5 }, { conversion: elementInputTargetVariableRemoved, order: 3 }, { conversion: elementTextVariantHeadingLevels, order: 59 }, + { conversion: emailTemplateCategoryIsSystemRemoved, order: 62 }, { conversion: fieldColumnListsCanonicalized, order: 6 }, { conversion: fieldMalformedScalePrecisionRemoved, order: 1 }, { conversion: fieldReferenceToAlias, order: 18 }, diff --git a/packages/spec/src/migrations/entries/retired-defs/18.system__EmailTemplateDefinitionCategory.ts b/packages/spec/src/migrations/entries/retired-defs/18.system__EmailTemplateDefinitionCategory.ts new file mode 100644 index 00000000000..c2be98999fc --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-defs/18.system__EmailTemplateDefinitionCategory.ts @@ -0,0 +1,10 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #22876 — `system/EmailTemplateDefinitionCategory` (the `category` enum: auth / +// notification / workflow / marketing / custom) left with +// `EmailTemplateDefinition.category`: that key was its ONLY consumer (the #3950 +// rule — an exported value schema with no consumer reads as a capability). The +// `sys_email_template` object spells its own column options and never imported +// it. See `retired-keys/18.system__EmailTemplateDefinition__category.ts` for the +// retirement record. +export const entry = 'system/EmailTemplateDefinitionCategory'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__EmailTemplateDefinition__category.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__EmailTemplateDefinition__category.ts new file mode 100644 index 00000000000..7a0814c6c47 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__EmailTemplateDefinition__category.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #22876 — ADR-0049 enforce-or-remove. `EmailTemplateDefinition.category` lost +// its only consumer when ADR-0131 D6/D10 retired the `sys_email_template` row +// projection (a Setup column); after that nothing read it, and the Studio +// listing never built the filter facet its doc promised. Measured: no reader in +// plugin-email, service-messaging, plugin-auth, the metadata protocol, objectql, +// the runtime or the REST layer, and none at the `.objectui-sha` pin. +// +// `retiredKey()` on a `strictObject`, for the prescription and the `tsc` +// channel. Its enum, `system/EmailTemplateDefinitionCategory`, left with it +// (`RETIRED_DEFS_BY_MAJOR[18]`). The key was DEFAULTED (`'custom'`), so a 17.x +// parse wrote it into every template it emitted; the +// `acceptRetiredDefaultResidue` stage is NOT adopted (the +// `ai/Agent:memory.longTerm.store` precedent): built artifacts and stored rows +// are healed by the replayed D2 conversion `email-template-category-is-system-removed`, +// and an authored source that still writes it meets the prescription. The D3 +// record is `email-template-category-is-system-retired`. +export const entry = 'system/EmailTemplateDefinition:category'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__EmailTemplateDefinition__isSystem.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__EmailTemplateDefinition__isSystem.ts new file mode 100644 index 00000000000..93903e3cf3d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__EmailTemplateDefinition__isSystem.ts @@ -0,0 +1,17 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #22876 — ADR-0049 enforce-or-remove. `EmailTemplateDefinition.isSystem` +// claimed a system template "SHOULD NOT be deleted by tenants", and nothing +// enforced it: its only consumers were the built-in seeder's stand-down and the +// `sys_email_template.is_system` column, both retired by ADR-0131 D6/D10. A +// built-in template is a code package's declaration, so an environment item +// over it can only be discarded, whatever this key says. Measured: no reader in +// this repository, and none at the `.objectui-sha` pin. +// +// `retiredKey()` on a `strictObject`, for the prescription and the `tsc` +// channel. The key was DEFAULTED (`false`), so a 17.x parse wrote it into every +// template it emitted; the `acceptRetiredDefaultResidue` stage is NOT adopted, +// for the reason `system/EmailTemplateDefinition:category` gives. The D2 +// conversion is `email-template-category-is-system-removed`; the D3 record is +// `email-template-category-is-system-retired`. +export const entry = 'system/EmailTemplateDefinition:isSystem'; diff --git a/packages/spec/src/migrations/entries/semantic/18.email-template-category-is-system-retired.ts b/packages/spec/src/migrations/entries/semantic/18.email-template-category-is-system-retired.ts new file mode 100644 index 00000000000..0239be82abb --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.email-template-category-is-system-retired.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #22876 — ADR-0049 enforce-or-remove — the D3 entry of the +// `email-template-category-is-system-removed` family (one D3 entry per +// retirement family, even when D2 is lossless). Registered keys: +// `system/EmailTemplateDefinition:category` and +// `system/EmailTemplateDefinition:isSystem`. The strip changes no send; what it +// cannot decide is whether something the author built read either key. +export const entry: SemanticMigration = { + id: 'email-template-category-is-system-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a code + // span AND a table cell. + surface: + 'emailTemplates[].category / emailTemplates[].isSystem — an email template\'s grouping and its built-in flag', + replacement: + 'Nothing: delete both keys. A template is found by its name, so group templates with a dotted ' + + 'namespace prefix (auth.password_reset, crm.welcome). A built-in template is a code package\'s ' + + 'declaration: a Studio save overrides it for the environment, and discarding that save restores ' + + 'the package\'s copy.', + reason: + 'The D2 conversion `email-template-category-is-system-removed` deletes `category` and `isSystem` ' + + 'from every template, and the delete is lossless: nothing in the platform read either key after ' + + 'templates stopped being copied into `sys_email_template` rows, so every send renders and ' + + 'delivers as before. What the conversion cannot check is whether something the author built read ' + + 'them — a report, a script or an integration that filtered templates by `category`, or treated ' + + '`isSystem: true` as protection against deletion. No door ever refused that deletion.', + acceptanceCriteria: + 'No email template carries `category` or `isSystem`, and the parse refuses either with the ' + + 'prescription. Every template sends as it did before the upgrade. Nothing the author maintains ' + + 'reads either key, or relies on `isSystem` to keep a template from being deleted.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index d148ac86944..9ac4f7cd37b 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -1782,6 +1782,17 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'the heading element each one always rendered, so the outline is unchanged and the heading ' + 'takes that level\'s style. The `body` default for an absent `variant` is unchanged.', }, + { + id: 'email-template-category-is-system-retired', + order: 94, + text: + 'It removes `category` and `isSystem` from the email template (ADR-0049 enforce-or-remove): ' + + 'nothing read either key once templates stopped being copied into `sys_email_template` rows — ' + + 'the Studio listing never offered the category facet, and no door refused deleting a template ' + + 'that set `isSystem`. The D2 conversion `email-template-category-is-system-removed` strips both ' + + 'keys from every template, which matters because both carried a default, so every template a ' + + '17.x parse emitted carries them.', + }, { id: 'field-inline-and-related-list-columns-closed', order: 9, diff --git a/packages/spec/src/system/email-template-category-is-system-retirement.test.ts b/packages/spec/src/system/email-template-category-is-system-retirement.test.ts new file mode 100644 index 00000000000..0faf811b5bc --- /dev/null +++ b/packages/spec/src/system/email-template-category-is-system-retirement.test.ts @@ -0,0 +1,668 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * An email template's `category` and `isSystem` RETIRED (#22876) — ADR-0049 + * enforce-or-remove, the seed of the release's enforce-or-remove batch. + * + * Measured before removal, recorded on the tombstones in `email-template.zod.ts` + * and on the ledger rows: both keys lost their only consumer when ADR-0131 + * D6/D10 retired the projection of declared templates into `sys_email_template` + * rows (`category` fed a Setup column, `isSystem` the built-in seeder's + * stand-down). Nothing in this repository read either afterwards, and the + * Studio console read neither beyond a fallback form input. + * + * Bookkeeping shapes, pinned below: + * 1. Two `retiredKey()` tombstones on `EmailTemplateDefinitionSchema`, a + * `strictObject` — so each refusal carries its prescription instead of a + * bare unknown-key verdict, and each key's input type is the retired mark + * for `tsc`. The `category` enum left with its key. + * 2. D2 conversion `email-template-category-is-system-removed` (step 18), a + * delete of both keys from every `emailTemplates[]` entry, retired from the + * load path: a live author is refused, a stored row or a built artifact + * replays clean. Both keys carried a DEFAULT, so every template a 17.x + * parse emitted carries them — the population this entry exists for. + * 3. `RETIRED_KEYS_BY_MAJOR[18]` carries both keys, `RETIRED_DEFS_BY_MAJOR[18]` + * the enum, and the family's D3 entry is + * `email-template-category-is-system-retired`. + * 4. The liveness rows STAY, `dead`, because a tombstone keeps the key in the + * walked shape (`check:liveness` is the judge of that half). + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues + * carry `code` and `path` but no ADR-0112 `status` — that envelope belongs to + * the authoring door, `defineStack`, which is pinned with its `code` and + * `status` below. Everywhere else: refusal, the issue `code`, the `path` + * naming the key, and the prescription text. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { applyConversions, collectConversionNotices } from '../conversions/apply'; +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { applyConversionsToStoredItem } from '../conversions/stored'; +import { getMetadataTypeSchema } from '../kernel/metadata-type-schemas'; +import { MIGRATIONS_BY_MAJOR, RETIRED_DEFS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { defineStack, ObjectStackDefinitionSchema } from '../stack.zod'; +import * as system from './index'; +import { + EmailTemplateDefinitionSchema, + defineEmailTemplateDefinition, + type EmailTemplateDefinition, +} from './email-template.zod'; + +const CONVERSION_ID = 'email-template-category-is-system-removed'; +const D3_ID = 'email-template-category-is-system-retired'; + +/** A well-formed template — the keys an author commonly writes, neither retired one. */ +const TEMPLATE = { + name: 'crm.welcome', + label: 'Welcome', + locale: 'en-US', + subject: 'Welcome, {{user.name}}', + bodyHtml: 'Welcome, {{user.name}}
', +} as const; + +// Unanchored, because a thrown `ZodError`'s message is the JSON of its issues; +// the key-first house convention is asserted on the issue message itself below. +const CATEGORY_PRESCRIPTION = + /`email_template\.category` was removed in @objectstack\/spec 17 \(ADR-0049 enforce-or-remove\) — nothing read it.*Delete the key\. A template is found by its `name`, so group templates with a dotted namespace prefix instead.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./s; +const IS_SYSTEM_PRESCRIPTION = + /`email_template\.isSystem` was removed in @objectstack\/spec 17 \(ADR-0049 enforce-or-remove\) — nothing enforced it.*Delete the key\. A built-in template is a code package's declaration.*Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand\./s; + +const RETIRED = [ + { key: 'category', prescription: CATEGORY_PRESCRIPTION, values: ['auth', 'notification', 'workflow', 'marketing', 'custom'] }, + { key: 'isSystem', prescription: IS_SYSTEM_PRESCRIPTION, values: [true, false] }, +] as const; + +/** + * A template as a 17.x parse emitted it: both defaults materialized, whether or + * not the author wrote them — the shape every built artifact and stored row of + * that line carries. + */ +const emitted17x = () => ({ ...TEMPLATE, category: 'custom', isSystem: false, variables: [], active: true }); +/** The built-in auth templates' authored shape before the retirement. */ +const builtin17x = () => ({ ...TEMPLATE, name: 'auth.password_reset', category: 'auth', isSystem: true }); + +describe('email template category / isSystem retirement — the tombstones, at every door that carries a template', () => { + for (const { key, prescription, values } of RETIRED) { + it(`the schema refuses \`${key}\` at its path, with the prescription`, () => { + const r = EmailTemplateDefinitionSchema.safeParse({ ...TEMPLATE, [key]: values[0] }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual([key]); + expect(issue.message).toMatch(prescription); + // House convention 1: the fully-qualified key, in backticks, opens it. + expect(issue.message.startsWith(`\`email_template.${key}\` was removed`)).toBe(true); + }); + + it(`refuses EVERY value of \`${key}\` — each it used to accept, its old DEFAULT included, and a value of another type`, () => { + // The default is refused like any other value: the retired-default + // residue stage is deliberately not adopted (see the retired-key entry), + // so a materialized default is healed by the D2 conversion, never + // tolerated at the parse. + for (const value of [...values, 'x', 0, null, {}]) { + const r = EmailTemplateDefinitionSchema.safeParse({ ...TEMPLATE, [key]: value }); + expect(r.success, `${key}: ${JSON.stringify(value)}`).toBe(false); + if (r.success) continue; + expect(r.error.issues).toHaveLength(1); + expect(r.error.issues[0]!.path).toEqual([key]); + expect(r.error.issues[0]!.message).toMatch(prescription); + } + }); + } + + it('a template carrying BOTH is refused at both paths — one issue per key, each with its own prescription', () => { + const r = EmailTemplateDefinitionSchema.safeParse(builtin17x()); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.map((i) => i.path)).toEqual([['category'], ['isSystem']]); + expect(r.error.issues[0]!.message).toMatch(CATEGORY_PRESCRIPTION); + expect(r.error.issues[1]!.message).toMatch(IS_SYSTEM_PRESCRIPTION); + }); + + it('the `email_template` write door (the registry binding) refuses both', () => { + // `getMetadataTypeSchema('email_template')` is what `saveMetaItem` validates + // a `PUT /api/v1/meta/email_template` body against; a rebinding to some + // other shape would pass the pins above and still accept the keys. + const door = getMetadataTypeSchema('email_template'); + expect(door, 'no schema bound for `email_template`').toBeDefined(); + expect(door).toBe(EmailTemplateDefinitionSchema); + const r = door!.safeParse(emitted17x()); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues.map((i) => i.path)).toEqual([['category'], ['isSystem']]); + }); + + it('`defineEmailTemplateDefinition()` refuses each with its prescription', () => { + expect(() => defineEmailTemplateDefinition({ ...TEMPLATE, category: 'auth' } as never)).toThrow(CATEGORY_PRESCRIPTION); + expect(() => defineEmailTemplateDefinition({ ...TEMPLATE, isSystem: true } as never)).toThrow(IS_SYSTEM_PRESCRIPTION); + }); + + it('the authoring door, defineStack, refuses them with the STACK_SCHEMA_INVALID envelope — never rewrites them', () => { + const stack = (template: Recordx
' })", '.ts')).not.toBeNull(); + expect(judge("const t = {\n name: 'a.b',\n bodyHtml: '{{x}}
',\n isSystem: true,\n};", '.js')).not.toBeNull(); + expect(judge('{ "emailTemplates": [{ "name": "a.b", "bodyHtml": "", "category": "custom" }] }', '.json')).not.toBeNull(); + expect(judge('emailTemplates:\n - name: a.b\n category: auth\n bodyHtml:x
\n', '.yaml')).not.toBeNull(); + expect(judge('name: a.b\nbodyHtml:x
\nisSystem: false\n', '.yml')).not.toBeNull(); + expect(judge("Prose.\n\n```ts\ndefineEmailTemplateDefinition({ bodyHtml: '', category: 'auth' });\n```\n", '.mdx')).not.toBeNull(); + expect(judge('Prose.\n\n```yaml\nname: a.b\nisSystem: true\nbodyHtml: x\n```\n', '.md')).not.toBeNull(); + // A `{` or a quote inside the body does not hide the sibling key. + expect(judge("({ bodyHtml: `{{user.name}}'s ${wrap('{')}
`, category: 'auth' })", '.ts')).not.toBeNull(); + // Neighbours that must NOT match. + // The tombstone declaration itself, beside the live `bodyHtml` declaration. + expect(judge("({ bodyHtml: z.string(), category: retiredKey(EMAIL_TEMPLATE_CATEGORY_REMOVED) })", '.ts')).toBeNull(); + // A nested object's key is not the template's. + expect(judge("({ bodyHtml: '', variables: [{ name: 'x', category: 'y' }] })", '.ts')).toBeNull(); + // Another shape's `isSystem` / `category`, with no `bodyHtml` beside it. + expect(judge("const SYSTEM_CTX = { isSystem: true, positions: [] };", '.ts')).toBeNull(); + expect(judge("fields: { category: Field.select({ options: [] }) }", '.ts')).toBeNull(); + // Mentions: comments, strings, and prose outside a fence. + expect(judge("// a template once wrote { bodyHtml: '', category: 'auth' }\nconst x = 1;", '.ts')).toBeNull(); + expect(judge("const s = \"{ bodyHtml: '', isSystem: true }\";", '.ts')).toBeNull(); + expect(judge('Delete `category` and `isSystem` from the template; `bodyHtml` stays.', '.mdx')).toBeNull(); + expect(judge('"system/EmailTemplateDefinition:category [RETIRED]",', '.json')).toBeNull(); + // A form section naming the inputs by value, not by key. + expect(judge("fields: [{ field: 'bodyHtml', required: true }, { field: 'category' }]", '.ts')).toBeNull(); + // YAML: a nested mapping's key is not the template's. + expect(judge('name: a.b\nbodyHtml: x\nvariables:\n - name: y\n category: z\n', '.yaml')).toBeNull(); + }); + + it('no email template category / isSystem authoring survives inside the declared radius outside the retirement kit', () => { + const offenders: string[] = []; + let visited = 0; + let exampleSources = 0; + let bodyHtmlFiles = 0; + const walk = (dir: string) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + const rel = path.relative(REPO_ROOT, full).split(path.sep).join('/'); + if (entry.isDirectory()) { + if (SKIPPED_DIRS.has(entry.name) || entry.name.startsWith('.')) continue; + walk(full); + continue; + } + if (!entry.isFile()) continue; + const ext = path.extname(entry.name); + const scanned = rel.startsWith('examples/') + ? EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel) + : SCANNED_EXT.has(ext); + if (!scanned) continue; + if (entry.name === 'CHANGELOG.md') continue; // release prose records the removal + if (EXCLUDED.has(rel) || EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue; + if (TSUP_BUNDLED_CONFIG.test(entry.name)) continue; + visited += 1; + if (EXAMPLE_APP_SRC_TS.test(rel)) exampleSources += 1; + const text = readIfPresent(full); + if (text === undefined || !text.includes('bodyHtml')) continue; + bodyHtmlFiles += 1; + const verdict = judge(text, ext); + if (verdict) offenders.push(`${rel}: ${verdict}`); + } + }; + for (const root of WALK_ROOTS) walk(path.join(REPO_ROOT, root)); + // Anti-vacuity: the walk really covered the tree, the example apps' + // sources were really read, and the templates the matcher judges were + // really reached (the built-in auth templates, the showcase, the plugin + // tests and docs all author `bodyHtml`). + expect(visited).toBeGreaterThan(1000); + expect(exampleSources).toBeGreaterThan(50); + expect(bodyHtmlFiles).toBeGreaterThan(20); + expect(offenders, 'an email template category / isSystem authoring means the retirement is being undone').toEqual([]); + }); +}); diff --git a/packages/spec/src/system/email-template.form.ts b/packages/spec/src/system/email-template.form.ts index 652ea7fd09c..374b9be1ad1 100644 --- a/packages/spec/src/system/email-template.form.ts +++ b/packages/spec/src/system/email-template.form.ts @@ -25,13 +25,8 @@ export const emailTemplateForm = defineForm({ fields: [ { field: 'name', required: true, colSpan: 1, helpText: 'Dotted snake_case (e.g. auth.password_reset, crm.welcome)' }, { field: 'label', required: true, colSpan: 1 }, - { field: 'category', type: 'select', colSpan: 1, options: [ - { label: 'Auth', value: 'auth' }, - { label: 'Notification', value: 'notification' }, - { label: 'Workflow', value: 'workflow' }, - { label: 'Marketing', value: 'marketing' }, - { label: 'Custom', value: 'custom' }, - ]}, + // No `category` input: the key is retired (#22876, ADR-0049) — nothing + // read it, and the schema now refuses it with a migration note. { field: 'locale', colSpan: 1, helpText: 'BCP-47 tag — e.g. en-US, zh-CN' }, { field: 'description', widget: 'textarea', colSpan: 2 }, ], @@ -82,7 +77,8 @@ export const emailTemplateForm = defineForm({ columns: 2, fields: [ { field: 'active', type: 'boolean', colSpan: 1, helpText: 'When unchecked, sendTemplate() returns TEMPLATE_INACTIVE.' }, - { field: 'isSystem', type: 'boolean', colSpan: 1, helpText: 'Built-in template; tenants may override but should not delete.' }, + // No `isSystem` input: the key is retired (#22876, ADR-0049) — nothing + // enforced it, and the schema now refuses it with a migration note. ], }, ], diff --git a/packages/spec/src/system/email-template.zod.ts b/packages/spec/src/system/email-template.zod.ts index 8c96734f7e7..35d8a54e826 100644 --- a/packages/spec/src/system/email-template.zod.ts +++ b/packages/spec/src/system/email-template.zod.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; import { strictObject } from '../shared/strict-object'; /** @@ -53,16 +54,46 @@ import { strictObject } from '../shared/strict-object'; export const EMAIL_TEMPLATE_FLOOR_LOCALE = 'en-US'; /** - * Logical grouping; surfaces as a filter facet in Studio listings. + * `category` and `isSystem` — RETIRED (#22876, ADR-0049 enforce-or-remove). + * + * Both lost their only consumer when ADR-0131 D6/D10 retired the projection of + * declared templates into `sys_email_template` rows: `category` fed a Setup + * column, and `isSystem` fed the built-in seeder's stand-down. Measured before + * removal: no reader anywhere in this repository (plugin-email, + * service-messaging, plugin-auth, the metadata protocol, objectql, the + * runtime, the REST layer), and at the `.objectui-sha` pin objectui read + * neither — the Studio listing never built the filter facet `category`'s doc + * promised, and nothing refused deleting a template that set `isSystem`. A + * built-in template is a code package's declaration, so an environment item + * over it can only be discarded, whatever the key said. Both keys carried a + * schema default, so every template carried them and the dead-property + * advisory fired on every template with no way for an author to clear it. + * + * `retiredKey()` tombstones on the `strictObject` below rather than strict + * deletions, so the refusal carries the prescription instead of a bare + * unknown-key verdict and a typed authoring site fails `tsc` first. The + * `category` enum (`EmailTemplateDefinitionCategorySchema`) left with its one + * key. The ADR-0087 D2 conversion `email-template-category-is-system-removed` + * strips both keys wherever the chain is replayed (stored rows and built + * artifacts, which a 17.x parse materialized both defaults into), and the D3 + * entry `email-template-category-is-system-retired` carries what the author + * still owes. */ -export const EmailTemplateDefinitionCategorySchema = lazySchema(() => z.enum([ - 'auth', // Password reset, email verification, magic link, invitation - 'notification', // System notifications, alerts - 'workflow', // Approval/flow generated mail - 'marketing', // Outbound campaigns - 'custom', // App-defined -])); -export type EmailTemplateDefinitionCategory = z.input