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 \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Global and Object Actions. Unique per scope, not per stack: the runtime keys every action by its owning object's name (or 'global' when object-less), a colon, then the action name, and defineStack refuses two declarations that resolve to one key — both here, both on one object's actions, or one in each position, identical twins included (an embedded action is keyed by the object it is written on, not by its own objectName). One global and one object-bound action may share a name; on that object's route the object's own actions take precedence for by-name readers. composeStacks runs the same key rule across its input stacks (counting distinct stacks, not sites) and names both source stacks on a collision. | | **flows** | `{ name: string; label: string; description?: string; successMessage?: string; … }[]` | optional | Screen Flows | | **jobs** | `{ name: string; label?: string; description?: string; schedule: object \| object \| object; … }[]` | optional | Background / Scheduled Jobs (run by IJobService on cron/interval/once schedules) | -| **emailTemplates** | `{ name: string; label: string; category?: Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>; locale?: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | +| **emailTemplates** | `{ name: string; label: string; locale?: string; subject: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | | **docs** | `{ name: string; label?: string; description?: string; content: string; … }[]` | optional | Package documentation — flat Markdown items compiled from src/docs/*.md (ADR-0046) | | **books** | `{ name: string; label?: string; description?: string; slug?: string; … }[]` | optional | Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6) | | **positions** | `{ name: string; label: string; description?: string; permissionSets?: string[]; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | @@ -371,7 +371,7 @@ Installed package row whose manifest is the assembled package body | **actions** | `{ name: string; label: string \| Record; description?: string \| Record; objectName?: string; … }[]` | optional | Global and Object Actions. Unique per scope, not per stack: the runtime keys every action by its owning object's name (or 'global' when object-less), a colon, then the action name, and defineStack refuses two declarations that resolve to one key — both here, both on one object's actions, or one in each position, identical twins included (an embedded action is keyed by the object it is written on, not by its own objectName). One global and one object-bound action may share a name; on that object's route the object's own actions take precedence for by-name readers. composeStacks runs the same key rule across its input stacks (counting distinct stacks, not sites) and names both source stacks on a collision. | | **flows** | `{ name: string; label: string; description?: string; successMessage?: string; … }[]` | optional | Screen Flows | | **jobs** | `{ name: string; label?: string; description?: string; schedule: object \| object \| object; … }[]` | optional | Background / Scheduled Jobs (run by IJobService on cron/interval/once schedules) | -| **emailTemplates** | `{ name: string; label: string; category?: Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>; locale?: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | +| **emailTemplates** | `{ name: string; label: string; locale?: string; subject: string; … }[]` | optional | Email Templates resolved by IEmailService.sendTemplate(`{ template, locale }`) | | **docs** | `{ name: string; label?: string; description?: string; content: string; … }[]` | optional | Package documentation — flat Markdown items compiled from src/docs/*.md (ADR-0046) | | **books** | `{ name: string; label?: string; description?: string; slug?: string; … }[]` | optional | Documentation navigation spines — ordered groups with derived membership (ADR-0046 §6) | | **positions** | `{ name: string; label: string; description?: string; permissionSets?: string[]; … }[]` | optional | Positions — flat capability-distribution groups (ADR-0090 D3) | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 1a67850a0ce..e12fce9770a 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1524 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1523 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -32,9 +32,9 @@ counts are sums of the rows they head. Regenerate with | [Security Protocol](/docs/references/security) | 5 | 30 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | -| [System Protocol](/docs/references/system) | 35 | 278 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | +| [System Protocol](/docs/references/system) | 35 | 277 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 166 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **196** | **1524** | 14 protocol modules | +| **Total** | **196** | **1523** | 14 protocol modules | --- @@ -317,7 +317,7 @@ Studio designer metadata — the authoring surfaces for the protocols above. ## System Protocol -**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **35 pages, 278 schemas** +**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **35 pages, 277 schemas** The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. @@ -334,7 +334,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a | [`disaster-recovery.zod.ts`](/docs/references/system/disaster-recovery) | `BackupConfig`, `BackupRetention`, `BackupStrategy`, `DisasterRecoveryPlan`, `FailoverConfig`, `FailoverMode`, `RPO`, `RTO` | | [`doc.zod.ts`](/docs/references/system/doc) | `Doc` | | [`email-config.zod.ts`](/docs/references/system/email-config) | `EmailAddressConfig`, `EmailProvider`, `EmailServiceConfig`, `StackEmailConfig`, `StackEmailProviderOptions` | -| [`email-template.zod.ts`](/docs/references/system/email-template) | `EmailTemplateDefinition`, `EmailTemplateDefinitionCategory`, `EmailTemplateDefinitionVariable` | +| [`email-template.zod.ts`](/docs/references/system/email-template) | `EmailTemplateDefinition`, `EmailTemplateDefinitionVariable` | | [`encryption.zod.ts`](/docs/references/system/encryption) | `EncryptionAlgorithm`, `EncryptionConfig`, `FieldEncryption`, `KeyManagementProvider`, `KeyRotationPolicy` | | [`environment-artifact.zod.ts`](/docs/references/system/environment-artifact) | `Sha256Digest` | | [`http-server.zod.ts`](/docs/references/system/http-server) | `MiddlewareConfig`, `MiddlewareType`, `RouteHandlerMetadata` | diff --git a/content/docs/references/system/email-template.mdx b/content/docs/references/system/email-template.mdx index 3a19ca702dc..196bffc8d42 100644 --- a/content/docs/references/system/email-template.mdx +++ b/content/docs/references/system/email-template.mdx @@ -30,8 +30,8 @@ resolved by `(name, locale)`; subject/body strings carry simple ## TypeScript Usage ```typescript -import { EmailTemplateDefinitionSchema, EmailTemplateDefinitionCategorySchema, EmailTemplateDefinitionVariableSchema } from '@objectstack/spec/system'; -import type { EmailTemplateDefinition, EmailTemplateDefinitionCategory, EmailTemplateDefinitionVariable } from '@objectstack/spec/system'; +import { EmailTemplateDefinitionSchema, EmailTemplateDefinitionVariableSchema } from '@objectstack/spec/system'; +import type { EmailTemplateDefinition, EmailTemplateDefinitionVariable } from '@objectstack/spec/system'; // Validate data const result = EmailTemplateDefinitionSchema.parse(data); @@ -47,7 +47,7 @@ const result = EmailTemplateDefinitionSchema.parse(data); | :--- | :--- | :--- | :--- | | **name** | `string` | ✅ | Template identifier (dotted snake_case) | | **label** | `string` | ✅ | Display label | -| **category** | `Enum<'auth' \| 'notification' \| 'workflow' \| 'marketing' \| 'custom'>` | optional (default: `"custom"`) | | +| **category** | `never` | optional | [REMOVED] `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. | | **locale** | `string` | optional (default: `"en-US"`) | BCP-47 locale (e.g. en-US, zh-CN) — the bundle key the resolver matches EXACTLY. A call that NAMES a locale gets exactly one retry rung, the literal `en-US`, with no language-subtag folding: `en` and `en-US` are different ROWS of one bundle and neither reaches the other, so a bundle with no `en-US` row has no fallback floor for those calls and every recipient locale it does not carry a row for raises TEMPLATE_NOT_FOUND, which is permanent — the delivery dead-letters with no retry. A call naming NO locale is the other case and does not dead-letter: it drops to the bundle's lowest locale tag and renders that silently. Your stack's own `i18n.defaultLocale` is the wrong tag here unless it is spelled `en-US`. | | **subject** | `string` | ✅ | Subject template | | **bodyHtml** | `string` | ✅ | HTML body template | @@ -56,7 +56,7 @@ const result = EmailTemplateDefinitionSchema.parse(data); | **fromOverride** | `{ name?: string; address: string }` | optional | | | **replyTo** | `string` | optional | | | **active** | `boolean` | optional (default: `true`) | | -| **isSystem** | `boolean` | optional (default: `false`) | | +| **isSystem** | `never` | optional | [REMOVED] `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. | | **description** | `string` | optional | | | **protection** | `{ lock: Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>; reason: string; docsUrl?: string }` | optional | Package author protection block — lock policy for this email template. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | @@ -85,19 +85,6 @@ const result = EmailTemplateDefinitionSchema.parse(data); | **docsUrl** | `string` | optional | Optional URL the Studio banner links to for more context. | ---- - -## EmailTemplateDefinitionCategory - -### Allowed Values - -* `auth` -* `notification` -* `workflow` -* `marketing` -* `custom` - - --- ## EmailTemplateDefinitionVariable diff --git a/examples/app-showcase/src/system/emails/index.ts b/examples/app-showcase/src/system/emails/index.ts index cbb9eb64538..3fb75375074 100644 --- a/examples/app-showcase/src/system/emails/index.ts +++ b/examples/app-showcase/src/system/emails/index.ts @@ -33,7 +33,6 @@ import { defineEmailTemplateDefinition } from '@objectstack/spec'; export const TaskDoneEmail = defineEmailTemplateDefinition({ name: 'showcase_task_done_email', label: 'Task Done Notification', - category: 'workflow', locale: 'en-US', subject: '✅ Task done: {{title}}', bodyHtml: '

The 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 Record; + expect(parsed).not.toHaveProperty('category'); + expect(parsed).not.toHaveProperty('isSystem'); + // CONTROL: the parse did materialize the live defaults, so the absence + // above is the retirement and not a parse that emitted nothing. + expect(parsed.locale).toBe('en-US'); + expect(parsed.active).toBe(true); + expect(lintLivenessProperties({ emailTemplates: [parsed] })).toEqual([]); + + // The parse owns an authored value now: refused, with the prescription. + for (const [key, value] of [['category', 'auth'], ['isSystem', true]] as const) { + const refused = EmailTemplateDefinitionSchema.safeParse({ ...base, [key]: value }); + expect(refused.success, key).toBe(false); + expect(refused.error?.issues[0]?.path, key).toEqual([key]); + expect(refused.error?.issues[0]?.message, key).toMatch( + new RegExp(`^\`email_template\\.${key}\` was removed in @objectstack/spec 17`), + ); + } }); // The advisory lint's job is the keys the PARSE still accepts. #4667 retired diff --git a/packages/lint/src/runtime-gate.inert-type-writes.test.ts b/packages/lint/src/runtime-gate.inert-type-writes.test.ts index 20c00aaa650..95c902351de 100644 --- a/packages/lint/src/runtime-gate.inert-type-writes.test.ts +++ b/packages/lint/src/runtime-gate.inert-type-writes.test.ts @@ -60,8 +60,10 @@ */ import { describe, expect, it } from 'vitest'; +import { getMetadataTypeSchema } from '@objectstack/spec/kernel'; + import { AUTHORING_COMMANDS, AUTHORING_RULES, runAuthoringRules } from './authoring-rules.js'; -import { LIVENESS_DEAD_PROPERTY, lintLivenessProperties } from './lint-liveness-properties.js'; +import { lintLivenessProperties } from './lint-liveness-properties.js'; import { REFERENCE_INTEGRITY_RULES } from './reference-integrity-suite.js'; import { buildRuntimeWriteSnapshots, @@ -133,17 +135,6 @@ const ruleIds = (findings: readonly { rule: string }[]) => findings.map((f) => f const dump = (r: { errors: readonly unknown[]; advisories: readonly unknown[] }) => JSON.stringify({ errors: r.errors, advisories: r.advisories }); -/** - * An advisory's identity — its rule id and the property path it names (the - * `sets \`\`` token, the same identity `lint-liveness-properties.test.ts`'s - * `ruleOf` reads) — never its wording. - */ -const advisoryIds = (advisories: readonly unknown[]) => - (advisories as Array<{ rule?: string; message?: string }>).map((a) => ({ - rule: a.rule, - property: /sets `([^`]+)`/.exec(a.message ?? '')?.[1], - })); - // ───────────────────────────────────────────────────────────────────────────── describe('#19542 — the five wired doors are declared, mapped and reachable', () => { @@ -748,21 +739,26 @@ describe('#19542 — group C: wired, dispatched, and silent by ledger (email_tem expect(result.advisories, dump(result)).toEqual([]); }); - it('`email_template` — the rule judges exactly the key the ledger grades dead: `category`, and nothing else', () => { - // The door lighting up with no second edit, as the case above foresaw. - // `category` went `dead` when ADR-0131 D6/D10 retired the - // `sys_email_template` row projection that was its only reader (templates - // resolve from the registry now); the liveness rule warns on a dead row by - // its verdict alone. #22876 retires the key in the next enforce-or-remove - // batch, which removes this advisory with it. Everything else this item - // authors is `live`, so the advisory is the only finding. - const item = { name: 'acme_welcome', label: 'Welcome', subject: 'Hi', bodyHtml: '

Hi

', 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: NonNullable = { label: { label: "Label" }, - category: { - label: "Category" - }, locale: { label: "Locale", helpText: "BCP-47 tag — e.g. en-US, zh-CN" @@ -2264,10 +2261,6 @@ export const enMetadataForms: NonNullable = { active: { label: "Active", helpText: "When unchecked, sendTemplate() returns TEMPLATE_INACTIVE." - }, - isSystem: { - label: "Is System", - helpText: "Built-in template; tenants may override but should not delete." } } }, diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 1348a2fdc6d..d0cccee4340 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -2230,9 +2230,6 @@ export const esESMetadataForms: NonNullable = label: { label: "Etiqueta" }, - category: { - label: "Categoría" - }, locale: { label: "Configuración regional", helpText: "Etiqueta BCP-47, p. ej. en-US, zh-CN" @@ -2264,10 +2261,6 @@ export const esESMetadataForms: NonNullable = active: { label: "Activo", helpText: "Si no está marcado, sendTemplate() devuelve TEMPLATE_INACTIVE." - }, - isSystem: { - label: "Integrado del sistema", - helpText: "Plantilla integrada; los tenants pueden sobrescribirla, pero no deberían eliminarla." } } }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 26506f90d02..3026c2a07af 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -2230,9 +2230,6 @@ export const jaJPMetadataForms: NonNullable = label: { label: "表示名" }, - category: { - label: "カテゴリ" - }, locale: { label: "ロケール", helpText: "BCP-47 タグ(例: en-US, zh-CN)" @@ -2264,10 +2261,6 @@ export const jaJPMetadataForms: NonNullable = active: { label: "有効", helpText: "未チェックの場合、sendTemplate() は TEMPLATE_INACTIVE を返します。" - }, - isSystem: { - label: "システム組み込み", - helpText: "組み込みテンプレート。テナントは上書きできますが、削除すべきではありません。" } } }, diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 60909f49bba..20af12203e3 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1186,7 +1186,11 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 670 since the field form offers `mailAssociation` on email fields, the // inbound-mail correspondent flag — one new row label, authored in all // three locales. - expect(translated.length, `${locale} positive control`).toBe(670); + // 668 since #22876: the email-template form's `category` select and + // `isSystem` checkbox left with their keys (both retired — nothing read + // either), taking their two labels — authored in all three locales — out + // of the catalog. + expect(translated.length, `${locale} positive control`).toBe(668); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index 91350090585..970e0320394 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -2230,9 +2230,6 @@ export const zhCNMetadataForms: NonNullable = label: { label: "显示名称" }, - category: { - label: "分类" - }, locale: { label: "语言区域", helpText: "BCP-47 标签,例如 en-US、zh-CN" @@ -2264,10 +2261,6 @@ export const zhCNMetadataForms: NonNullable = active: { label: "启用", helpText: "未勾选时,sendTemplate() 返回 TEMPLATE_INACTIVE。" - }, - isSystem: { - label: "系统内置", - helpText: "内置模板;租户可以覆盖,但不应删除。" } } }, diff --git a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts index 1b9e14cf757..5790392aad2 100644 --- a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts @@ -98,7 +98,6 @@ function template(over: AnyRecord = {}): AnyRecord { return { name: 'ops.digest', label: 'Digest', - category: 'notification', locale: 'en-US', subject: 'Digest for {{user.name}}', bodyHtml: '

Hi {{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: Record) => ({ + manifest: { id: 'com.example.email-template-retired', name: 'email_template_retired', version: '1.0.0', type: 'app' }, + emailTemplates: [template], + }); + let thrown: unknown; + try { + defineStack(stack(builtin17x()) as never); + } catch (e) { + thrown = e; + } + const refusal = thrown as { code?: string; status?: number; issues?: Array<{ path: PropertyKey[]; message: string }> }; + expect(refusal?.code).toBe('STACK_SCHEMA_INVALID'); + expect(refusal?.status).toBe(422); + expect(refusal.issues?.map((i) => i.path)).toEqual([ + ['emailTemplates', 0, 'category'], + ['emailTemplates', 0, 'isSystem'], + ]); + expect(refusal.issues?.[0]?.message).toMatch(CATEGORY_PRESCRIPTION); + expect(refusal.issues?.[1]?.message).toMatch(IS_SYSTEM_PRESCRIPTION); + // CONTROL: the same stack without the keys is accepted by the same door. + expect(() => defineStack(stack({ ...TEMPLATE }) as never)).not.toThrow(); + }); + + it('CONTROL: the same template without the keys passes, every live default intact, and grows neither key', () => { + const r = EmailTemplateDefinitionSchema.safeParse(TEMPLATE); + expect(r.success).toBe(true); + if (!r.success) return; + // Absence stays absence: a tombstone materializes nothing — which is what + // takes the dead-property advisory off every parsed template. + expect(r.data).not.toHaveProperty('category'); + expect(r.data).not.toHaveProperty('isSystem'); + // The live defaults still apply, so the empty reading above is the + // retirement and not a schema that stopped emitting. + expect(r.data.active).toBe(true); + expect(r.data.variables).toEqual([]); + }); + + it('the walked shape keeps both keys — the ledger rows and the authorable-surface rows stay reachable', () => { + const shape = (EmailTemplateDefinitionSchema as unknown as { shape?: Record }).shape; + expect(shape, 'EmailTemplateDefinitionSchema must expose a read-through shape').toBeDefined(); + expect(Object.keys(shape!)).toEqual(expect.arrayContaining(['category', 'isSystem'])); + expect(Object.keys(shape!), 'CONTROL: a live neighbour').toContain('active'); + }); + + it('the did-you-mean never offers a tombstone: a near-miss `categry` / `isSytem` is refused as unknown, not steered onto the retired key', () => { + for (const [typo, retired] of [['categry', 'category'], ['isSytem', 'isSystem']] as const) { + const r = EmailTemplateDefinitionSchema.safeParse({ ...TEMPLATE, [typo]: 'x' }); + expect(r.success, typo).toBe(false); + if (r.success) continue; + const issue = r.error.issues[0]!; + expect(issue.code).toBe('unrecognized_keys'); + expect(issue.message).toContain(`\`${typo}\``); + expect(issue.message).not.toContain(`\`${typo}\` → \`${retired}\``); + } + }); + + it('fails tsc at the authoring site: the input type of each key is the retired mark', () => { + const withCategory: EmailTemplateDefinition = { + ...TEMPLATE, + // @ts-expect-error — `category` is a retiredKey() tombstone: no value is assignable. + category: 'auth', + }; + const withIsSystem: EmailTemplateDefinition = { + ...TEMPLATE, + // @ts-expect-error — `isSystem` is a retiredKey() tombstone: no value is assignable. + isSystem: true, + }; + // The parse channel agrees with the type channel on the same literals. + expect(() => EmailTemplateDefinitionSchema.parse(withCategory)).toThrow(CATEGORY_PRESCRIPTION); + expect(() => EmailTemplateDefinitionSchema.parse(withIsSystem)).toThrow(IS_SYSTEM_PRESCRIPTION); + }); + + it('the `category` enum left with its key: `@objectstack/spec/system` exports neither the schema nor its name', () => { + expect('EmailTemplateDefinitionCategorySchema' in system).toBe(false); + // CONTROL: the barrel is the real one — its live neighbours are there. + expect('EmailTemplateDefinitionSchema' in system).toBe(true); + expect('EmailTemplateDefinitionVariableSchema' in system).toBe(true); + }); +}); + +describe('email template category / isSystem retirement — the D2 conversion', () => { + it('a STORED `email_template` row carrying both keys replays clean through the rehydration seam', () => { + // The seam wraps an `email_template` row as `{ emailTemplates: [row] }` and + // replays the full chain, retired entries included. + const notices: { conversionId?: string; path?: string; from?: string; to?: string }[] = []; + const rehydrated = applyConversionsToStoredItem('email_template', emitted17x(), { + onNotice: (n) => notices.push(n as { conversionId?: string; path?: string; from?: string; to?: string }), + }) as Record; + + expect(notices.map((n) => [n.conversionId, n.path, n.from, n.to])).toEqual([ + [CONVERSION_ID, 'emailTemplates[0](crm.welcome).category', 'category', '(removed)'], + [CONVERSION_ID, 'emailTemplates[0](crm.welcome).isSystem', 'isSystem', '(removed)'], + ]); + expect(rehydrated).not.toHaveProperty('category'); + expect(rehydrated).not.toHaveProperty('isSystem'); + // CONTROL: every live key on the same row survives byte-for-byte. + expect(rehydrated).toEqual({ ...TEMPLATE, variables: [], active: true }); + // And the rehydrated row is exactly what the write door accepts now. + expect(EmailTemplateDefinitionSchema.safeParse(rehydrated).success).toBe(true); + }); + + it('a persisted artifact is REFUSED at the boot door before the conversion and ACCEPTED after it', () => { + const artifact = () => ({ emailTemplates: [emitted17x(), builtin17x()] }); + const before = ObjectStackDefinitionSchema.safeParse(artifact()); + expect(before.success).toBe(false); + const text = JSON.stringify(before.error?.issues ?? []); + expect(text).toContain('`email_template.category` was removed'); + expect(text).toContain('`email_template.isSystem` was removed'); + + const healed = applyConversions(artifact(), { includeRetired: true }); + const after = ObjectStackDefinitionSchema.safeParse(healed); + expect( + after.success, + `expected the converted artifact to parse; got ${JSON.stringify(after.error?.issues ?? [])}`, + ).toBe(true); + }); + + it('LIT CONTROL — a template that was always wrong is refused on BOTH sides of the conversion', () => { + // `categry` is not a retired key, so the strip leaves it and the door still + // refuses — which is what makes the leg above a reading and not a tautology. + const artifact = () => ({ emailTemplates: [{ ...emitted17x(), categry: 'auth' }] }); + expect(ObjectStackDefinitionSchema.safeParse(artifact()).success).toBe(false); + const healed = applyConversions(artifact(), { includeRetired: true }); + expect(ObjectStackDefinitionSchema.safeParse(healed).success).toBe(false); + }); + + it('strips each key whatever it holds — one notice per KEY — and names each template', () => { + const input = { + emailTemplates: [ + { ...TEMPLATE, name: 'a.one', category: 'auth' }, + { ...TEMPLATE, name: 'b.two', isSystem: true }, + { ...TEMPLATE, name: 'c.three', category: 'custom', isSystem: false }, + { ...TEMPLATE, name: 'd.four', category: 7, isSystem: 'yes' }, + ], + }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + const mine = notices.filter((n) => n.conversionId === CONVERSION_ID); + expect(mine.map((n) => n.path)).toEqual([ + 'emailTemplates[0](a.one).category', + 'emailTemplates[1](b.two).isSystem', + 'emailTemplates[2](c.three).category', + 'emailTemplates[2](c.three).isSystem', + 'emailTemplates[3](d.four).category', + 'emailTemplates[3](d.four).isSystem', + ]); + for (const template of stack.emailTemplates as Record[]) { + expect(template).not.toHaveProperty('category'); + expect(template).not.toHaveProperty('isSystem'); + expect(EmailTemplateDefinitionSchema.safeParse(template).success).toBe(true); + } + }); + + it('strips only the templates that carry a key, and is idempotent by construction', () => { + const input = { + emailTemplates: [ + emitted17x(), + // A template already canonical: no notice, and handed back by reference. + { ...TEMPLATE, name: 'crm.follow_up' }, + ], + }; + const { stack, notices } = collectConversionNotices(input, { includeRetired: true }); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(2); + const templates = stack.emailTemplates as unknown[]; + expect(templates[1]).toBe(input.emailTemplates[1]); + + // Idempotence, measured: a second replay converts nothing and hands the + // input back by reference. + const replay = collectConversionNotices(stack, { includeRetired: true }); + expect(replay.notices).toHaveLength(0); + expect(replay.stack).toBe(stack); + }); + + it('is retired from the load path — a live author is refused at parse, never silently rewritten', () => { + const input = { emailTemplates: [builtin17x()] }; + const { stack, notices } = collectConversionNotices(input); + expect(notices.filter((n) => n.conversionId === CONVERSION_ID)).toHaveLength(0); + expect(stack).toEqual(input); + }); +}); + +describe('email template category / isSystem retirement — ADR-0087 registration', () => { + it('declares both keys under major 18, and the enum as a retired def', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toEqual(expect.arrayContaining([ + 'system/EmailTemplateDefinition:category', + 'system/EmailTemplateDefinition:isSystem', + ])); + const all = Object.values(RETIRED_KEYS_BY_MAJOR).flat(); + expect(all.filter((k) => k.startsWith('system/EmailTemplateDefinition:')).sort()).toEqual([ + 'system/EmailTemplateDefinition:category', + 'system/EmailTemplateDefinition:isSystem', + ]); + expect(RETIRED_DEFS_BY_MAJOR[18]).toContain('system/EmailTemplateDefinitionCategory'); + }); + + it('wires the D2 conversion into the step-18 chain as a retired, stamped, lossless strip', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toContain(CONVERSION_ID); + const conversion = ALL_CONVERSIONS.find((c) => c.id === CONVERSION_ID); + expect(conversion, 'the D2 conversion must be registered').toBeDefined(); + expect(conversion!.toMajor).toBe(18); + expect(conversion!.retiredFromLoadPath).toBe(true); + expect(conversion!.retiredAfter).toMatch(/^\d+\.\d+\.\d+$/); + expect(conversion!.surface).toBe('emailTemplates[].category / emailTemplates[].isSystem'); + }); + + it('carries ONE D3 entry for the family, naming its D2 conversion', () => { + const entries = MIGRATIONS_BY_MAJOR[18]!.semantic.filter((s) => s.id === D3_ID); + expect(entries, 'the family needs its own D3 entry').toHaveLength(1); + const [entry] = entries; + expect(entry!.reason).toContain(`\`${CONVERSION_ID}\``); + expect(entry!.replacement).toContain('delete both keys'); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — `retiredKey()` types both keys as the retired +// mark on `EmailTemplateDefinition`, so every TYPED authoring site fails to +// compile (the built-in auth templates and the showcase authored through it). +// The residue is what `tsc` never judges: JSON, YAML, MD/MDX code fences, +// untyped `.js`, and TS literals typed `any` / `unknown` / a record. This walk +// covers that residue across the five repo roots +// `scripts/cross-package-test-inputs.mjs` already declares for +// `@objectstack/spec#test` (mirrored in `turbo.json`), plus the example apps' +// own `src/` trees, declared there as `examples/*/src/**/*.ts`. +// +// The matcher judges the AUTHORING SHAPE, never a mention. `category` and +// `isSystem` are among the commonest keys in the tree (an object's `isSystem`, +// an execution context's `isSystem`, a dozen `category` enums), so a key alone +// proves nothing: an offence is either key at the TOP LEVEL of an object +// literal that also carries `bodyHtml` — the key no other metadata shape +// declares. In code, string and comment contents are masked first (a quoted +// key is kept, for JSON); in YAML, the sibling keys of a mapping that holds +// `bodyHtml`; in MD/MDX, each fenced block by its language. The bound, stated: +// a template assembled by spread, under computed keys, or with its `bodyHtml` +// in a different literal, and `docs/**`, `.claude/**`, `.github/**` and the +// repo-root files are outside what this walk sees. +describe('tree-scoped absence: nothing inside the declared radius still authors an email template category / isSystem', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + /** The walked roots — declared in `scripts/cross-package-test-inputs.mjs` under `@objectstack/spec`. */ + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + /** Under `examples/` the non-code extensions, plus `.ts` inside an app's own `src/` tree. */ + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + /** Either retired key in key position — never the tombstone declaration itself. */ + const RETIRED_KEY = /(^|[\s{,])["']?(category|isSystem)["']?\s*:(?!\s*retiredKey\()/g; + const BODY_HTML_KEY = /(^|[\s{,])["']?bodyHtml["']?\s*:/g; + + /** + * Code with string and comment CONTENTS blanked (same length, so offsets + * hold). A quoted string followed by `:` is a key and is kept — that is how + * JSON and quoted TS keys stay visible. A template literal is blanked whole, + * its `${…}` interpolations included: a template is never authored inside one. + */ + const maskCode = (src: string): string => { + const out = src.split(''); + const blank = (from: number, to: number) => { + for (let k = from; k < to; k += 1) if (out[k] !== '\n') out[k] = ' '; + }; + let i = 0; + while (i < src.length) { + const c = src[i]!; + const next = src[i + 1]; + if (c === '/' && next === '/') { + const end = src.indexOf('\n', i); + const stop = end === -1 ? src.length : end; + blank(i, stop); + i = stop; + } else if (c === '/' && next === '*') { + const end = src.indexOf('*/', i + 2); + const stop = end === -1 ? src.length : end + 2; + blank(i, stop); + i = stop; + } else if (c === '"' || c === "'") { + let j = i + 1; + while (j < src.length && src[j] !== c && src[j] !== '\n') j += src[j] === '\\' ? 2 : 1; + const close = Math.min(j, src.length - 1); + const isKey = /^\s*:/.test(src.slice(close + 1, close + 40)); + if (!isKey) blank(i + 1, close); + i = close + 1; + } else if (c === '`') { + let j = i + 1; + let depth = 0; + while (j < src.length) { + const d = src[j]!; + if (d === '\\') { j += 2; continue; } + if (depth === 0 && d === '`') break; + if (d === '$' && src[j + 1] === '{') { depth += 1; j += 2; continue; } + // A quoted string inside an interpolation: its braces are not code. + if (depth > 0 && (d === '"' || d === "'")) { + let q = j + 1; + while (q < src.length && src[q] !== d && src[q] !== '\n') q += src[q] === '\\' ? 2 : 1; + j = q + 1; + continue; + } + if (depth > 0 && d === '{') depth += 1; + else if (depth > 0 && d === '}') depth -= 1; + j += 1; + } + blank(i + 1, Math.min(j, src.length)); + i = j + 1; + } else { + i += 1; + } + } + return out.join(''); + }; + + /** The `{ … }` span enclosing `at` in masked code, or null. */ + const enclosingObject = (code: string, at: number): [number, number] | null => { + let depth = 0; + let start = -1; + for (let k = at - 1; k >= 0; k -= 1) { + const c = code[k]; + if (c === '}') depth += 1; + else if (c === '{') { + if (depth === 0) { start = k; break; } + depth -= 1; + } + } + if (start === -1) return null; + depth = 0; + for (let k = start; k < code.length; k += 1) { + const c = code[k]; + if (c === '{') depth += 1; + else if (c === '}') { + depth -= 1; + if (depth === 0) return [start, k]; + } + } + return null; + }; + + /** Nesting depth of `{[(` between `from` (exclusive) and `to`. */ + const depthBetween = (code: string, from: number, to: number): number => { + let depth = 0; + for (let k = from + 1; k < to; k += 1) { + const c = code[k]; + if (c === '{' || c === '[' || c === '(') depth += 1; + else if (c === '}' || c === ']' || c === ')') depth -= 1; + } + return depth; + }; + + const judgeCode = (src: string): string | null => { + const code = maskCode(src); + for (const m of code.matchAll(BODY_HTML_KEY)) { + const span = enclosingObject(code, m.index! + m[1]!.length); + if (!span) continue; + const body = code.slice(span[0], span[1] + 1); + for (const k of body.matchAll(RETIRED_KEY)) { + const keyAt = span[0] + k.index! + k[1]!.length; + if (depthBetween(code, span[0], keyAt) === 0) return `${k[2]} beside bodyHtml`; + } + } + return null; + }; + + /** A YAML mapping that holds `bodyHtml:` and, at the same indentation, either retired key. */ + const judgeYaml = (src: string): string | null => { + const lines = src.split(/\r?\n/); + const keyOf = (line: string) => /^([ \t]*(?:-[ \t]+)?)([A-Za-z_][\w]*)[ \t]*:/.exec(line); + for (let n = 0; n < lines.length; n += 1) { + const m = keyOf(lines[n]!); + if (!m || m[2] !== 'bodyHtml') continue; + const indent = m[1]!.length; + const sibling = (line: string): string | null => { + const s = keyOf(line); + return s && s[1]!.length === indent ? s[2]! : null; + }; + const ownIndent = (line: string) => /^[ \t]*/.exec(line)![0].length; + for (const step of [-1, 1]) { + for (let k = n + step; k >= 0 && k < lines.length; k += step) { + const line = lines[k]!; + if (line.trim() === '') continue; + const s = sibling(line); + if (s === 'category' || s === 'isSystem') return `${s} beside bodyHtml`; + // A list item opening this mapping (`- name: …` two columns left). + const item = /^([ \t]*)-[ \t]+([A-Za-z_]\w*)[ \t]*:/.exec(line); + if (item && item[1]!.length + 2 === indent) { + if (item[2] === 'category' || item[2] === 'isSystem') return `${item[2]} beside bodyHtml`; + if (step === -1) break; + } + if (ownIndent(line) < indent && !item) break; + if (step === 1 && item && item[1]!.length + 2 === indent) break; + } + } + } + return null; + }; + + const FENCE = /^```([\w-]*)[^\n]*\n([\s\S]*?)^```/gm; + const judgeMarkdown = (src: string): string | null => { + for (const m of src.matchAll(FENCE)) { + const lang = m[1]!.toLowerCase(); + const verdict = lang === 'yaml' || lang === 'yml' ? judgeYaml(m[2]!) : judgeCode(m[2]!); + if (verdict) return verdict; + } + return null; + }; + + const judge = (text: string, ext: string): string | null => { + if (ext === '.md' || ext === '.mdx') return judgeMarkdown(text); + if (ext === '.yaml' || ext === '.yml') return judgeYaml(text); + return judgeCode(text); + }; + + /** + * Structural exclusions — the retirement kit, each with its reason. ⛔ NOT an + * allowlist file (`spec-property-retirement` §4): every entry's JOB is to + * spell a retired key. + */ + const EXCLUDED = new Set([ + // This pin authors both keys to assert their refusal and their conversion. + THIS_FILE, + ]); + const EXCLUDED_PREFIXES = [ + // The D2 conversion's fixture authors the pre-retirement template on purpose. + 'packages/spec/src/conversions/', + // The liveness ledgers key one row per schema PROPERTY: the two rows are a + // classification of the shape, not an authoring, and the tombstone route + // requires them to STAY. + 'packages/spec/liveness/', + // Release-owned prose records the removal; never edited by a code PR. + 'content/docs/releases/', + // GITIGNORED build output (`packages/spec/json-schema/`), reached only + // because this is a FILESYSTEM walk. Its source is `email-template.zod.ts`. + 'packages/spec/json-schema/', + ]; + /** tsup's own bundle of `tsup.config.ts`, written and deleted mid-build. */ + const TSUP_BUNDLED_CONFIG = /\.bundled_[^./]+\.mjs$/; + + /** Tolerates ONLY a path that vanished mid-walk; every other read fault is re-raised. */ + const readIfPresent = (full: string): string | undefined => { + try { + return fs.readFileSync(full, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err; + return undefined; + } + }; + + it('the matcher recognises an authoring and ignores a mention, another shape\'s key and the tombstone (anti-vacuity)', () => { + // Offenders — the retired shape, in each syntax the walk reads. + expect(judge("defineEmailTemplateDefinition({ name: 'a.b', category: 'auth', bodyHtml: '

x

' })", '.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; +const EMAIL_TEMPLATE_CATEGORY_REMOVED = + '`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.'; + +const EMAIL_TEMPLATE_IS_SYSTEM_REMOVED = + '`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.'; export const EmailTemplateDefinitionVariableSchema = lazySchema(() => z.object({ name: z.string().describe('Variable name as referenced in placeholders (snake_case or dotted path)'), @@ -105,8 +136,9 @@ export const EmailTemplateDefinitionSchema = lazySchema(() => strictObject({ /** Human-readable label shown in Studio. */ label: z.string().describe('Display label'), - /** Logical grouping. */ - category: EmailTemplateDefinitionCategorySchema.default('custom'), + // `category` REMOVED (#22876, ADR-0049 enforce-or-remove) — nothing read it. + // See the note above `EMAIL_TEMPLATE_CATEGORY_REMOVED`. + category: retiredKey(EMAIL_TEMPLATE_CATEGORY_REMOVED), /** * IETF BCP-47 locale tag — the second half of the bundle key. @@ -188,12 +220,9 @@ export const EmailTemplateDefinitionSchema = lazySchema(() => strictObject({ /** When false, `sendTemplate()` returns an error (`TEMPLATE_INACTIVE`). */ active: z.boolean().default(true), - /** - * When true the template is provided by a plugin / platform and - * SHOULD NOT be deleted by tenants (overlay/edit still allowed). - * Mirrors the `isSystem` flag on object schemas. - */ - isSystem: z.boolean().default(false), + // `isSystem` REMOVED (#22876, ADR-0049 enforce-or-remove) — nothing enforced + // it. See the note above `EMAIL_TEMPLATE_IS_SYSTEM_REMOVED`. + isSystem: retiredKey(EMAIL_TEMPLATE_IS_SYSTEM_REMOVED), /** Free-form description shown in Studio. */ description: z.string().optional(), diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index deeb1292251..fe9af125760 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -215,7 +215,6 @@ import type * as M125 from './system/deploy-bundle.zod.js'; import type * as M126 from './system/disaster-recovery.zod.js'; import type * as M127 from './system/doc.zod.js'; import type * as M128 from './system/email-config.zod.js'; -import type * as M129 from './system/email-template.zod.js'; import type * as M130 from './system/encryption.zod.js'; import type * as M131 from './system/environment-artifact.zod.js'; import type * as M132 from './system/http-server.zod.js'; @@ -274,7 +273,7 @@ import type * as M187 from './shared/duration.zod.js'; import type * as M188 from './ai/build-progress.zod.js'; // --------------------------------------------------------------------------- -// 776 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 775 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1272,7 +1271,6 @@ export type Iso_system_emailConfig__EmailAddressConfigSchema = Assert, z.infer< typeof M128.EmailProviderSchema > >>; // system/email-template.zod.ts -export type Iso_system_emailTemplate__EmailTemplateDefinitionCategorySchema = Assert, z.infer< typeof M129.EmailTemplateDefinitionCategorySchema > >>; // system/encryption.zod.ts export type Iso_system_encryption__EncryptionAlgorithmSchema = Assert, z.infer< typeof M130.EncryptionAlgorithmSchema > >>; @@ -1674,7 +1672,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 776 isomorphic pins', () => { + it('still declares all 775 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2467,7 +2465,14 @@ describe('ADR-0122 type-alias convention', () => { // `unmanaged`) is a bare enum with no default and no transform, so it is // pinned here (Iso_kernel_manifest__InstallModeSchema) rather than given an // `InstallModeParsed` synonym. +1 added. - expect(pins).toHaveLength(776); + // + // 776 -> 775 is #22876: `EmailTemplateDefinitionCategorySchema` left with + // the retired `email_template.category` key it was the enum of (ADR-0049 + // enforce-or-remove), so its pin + // (Iso_system_emailTemplate__EmailTemplateDefinitionCategorySchema) leaves + // with it — and with it the module import M129, which no other pin read. + // -1 removed. + expect(pins).toHaveLength(775); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index c1bc81d5162..37d5c367d16 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -45,6 +45,7 @@ "src/shared/retired-key-migrate-sentence.test.ts", "src/system/compliance-families-retirement.test.ts", "src/system/constants/platform-object-names.test.ts", + "src/system/email-template-category-is-system-retirement.test.ts", "src/system/email-template-floor-locale-parity.pin.test.ts", "src/ui/action-requires-confirmation-docblock.pin.test.ts", "src/ui/element-flat-binding-retirement.test.ts", diff --git a/scripts/check-system-context-census.mjs b/scripts/check-system-context-census.mjs index dd29cbc4f71..9d1f1fb63f6 100644 --- a/scripts/check-system-context-census.mjs +++ b/scripts/check-system-context-census.mjs @@ -412,7 +412,7 @@ export const NON_READ_ANCHORS = [ { file: 'packages/spec/src/system/email-template.zod.ts', symbol: 'isSystem', - why: 'EmailTemplate.isSystem -- unrelated metadata field', + why: 'EmailTemplate.isSystem -- an unrelated metadata key, now a retiredKey() tombstone', }, // `Environment.isSystem` (`packages/spec/src/cloud/environment.zod.ts`) was the // fourth row until #16325 removed the `@objectstack/spec/cloud` subpath; the