diff --git a/.changeset/notification-orphan-template-schemas.md b/.changeset/notification-orphan-template-schemas.md
new file mode 100644
index 0000000000..1356a1e575
--- /dev/null
+++ b/.changeset/notification-orphan-template-schemas.md
@@ -0,0 +1,60 @@
+---
+"@objectstack/spec": major
+---
+
+feat(spec)!: `@objectstack/spec/system` no longer exports the orphan notification-template vocabulary — `EmailTemplate(Schema)`, `SMSTemplate(Schema)`, `PushNotification(Schema)`, `InAppNotification(Schema)` (#4616)
+
+These four schemas existed **only** as the member shapes of the
+`NotificationConfigSchema.template` union, and #4610 (#4535 C3) deleted that
+union. Since then they have been reachable from no parent schema and from no
+metadata-type root: nothing in framework, cloud or objectui parsed a document
+against them, so they declared delivery capability the runtime never read
+(ADR-0049 enforce-or-remove, resolved by REMOVE in the v17 breaking window).
+
+Migration — one line each, and in every case the replacement already exists:
+
+- FROM `import { EmailTemplateSchema, type EmailTemplate } from '@objectstack/spec/system'` →
+ TO `import { EmailTemplateDefinitionSchema, type EmailTemplateDefinition } from '@objectstack/spec/system'`.
+ **Shape change** — this is a different, richer contract, not a rename:
+ `EmailTemplateDefinitionSchema` is keyed `name` + `locale` (not `id`), splits
+ the body into `bodyHtml` / `bodyText` (not `body` + `bodyType`), and adds
+ `label` / `category` / `active` / `fromOverride` / `replyTo`. It is also a
+ `strictObject`, so the old keys are rejected loudly rather than stripped.
+ This is the schema the `email_template` metadata kind has resolved to since
+ spec **7.1.0**, which demoted `EmailTemplateSchema` when it fixed that Prime
+ Directive #8 double-declaration and kept it "only as an inline sub-shape
+ inside `Notification`" — #4610 removed that holder, and #4616 finishes the
+ job. If your code registers a client-side or publish-time validator for
+ `email_template`, it must point at `EmailTemplateDefinitionSchema`;
+ `BUILTIN_METADATA_TYPE_SCHEMAS` (`kernel/metadata-type-schemas.ts`) is the
+ authority.
+- FROM `import { SMSTemplateSchema, type SMSTemplate } from '@objectstack/spec/system'` →
+ TO: no spec replacement, and none is needed. SMS templates are
+ `sys_notification_template` rows resolved by `(topic, 'sms', locale)`
+ (`service-messaging/src/sms-channel.ts`) and rendered by
+ `template-renderer.ts`; the provider-side template is Aliyun's pre-registered
+ `TemplateCode` in `service-sms` — a vendor API shape, never a spec constant.
+- FROM `import { PushNotificationSchema, type PushNotification } from '@objectstack/spec/system'`
+ and FROM `import { InAppNotificationSchema, type InAppNotification } from '@objectstack/spec/system'` →
+ TO: no replacement. Neither channel has a delivery implementation (#3197):
+ the dispatcher dead-letters any message addressed to them, so these payload
+ shapes advertised a capability nothing delivers. The live delivery ingress is
+ `NotificationService.emit` (`INotificationService`,
+ `@objectstack/spec/contracts`); the in-app bell reads `./api`'s
+ `Notification(Schema)` inbox row; the presentation vocabulary is
+ `@objectstack/spec/ui` (`NotificationTypeSchema`, `NotificationSeveritySchema`,
+ `NotificationPositionSchema`, `NotificationActionSchema` — all unchanged).
+
+Unchanged and explicitly NOT part of this removal:
+`@objectstack/spec/system`'s `NotificationChannel(Schema)` (live — re-exported
+by `@objectstack/spec/contracts`, consumed by `service-messaging`),
+`EmailTemplateDefinition*`, and every `@objectstack/spec/ui` notification
+export.
+
+No ADR-0087 D2 conversion accompanies this change, deliberately: a conversion
+rewrites authored or stored sources, and these defs were reachable from no
+metadata-type root, so `os migrate meta` would have nothing to match. The
+removal is a TypeScript export-surface break only — same disposition as #4610
+in this very module. `json-schema.manifest.json` loses 4 keys and
+`authorable-surface.json` loses their 22 lines; both deletions are adjudicated
+by `gen:schema`'s #4650 route-3 check ("def no longer emitted by this build").
diff --git a/content/docs/references/system/notification.mdx b/content/docs/references/system/notification.mdx
index 046cdc4878..9c3cc4b877 100644
--- a/content/docs/references/system/notification.mdx
+++ b/content/docs/references/system/notification.mdx
@@ -5,43 +5,23 @@ description: Notification protocol schemas
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
-Email Template Schema
+Notification Channel Enum
-Defines the structure and content of email notifications.
+Supported notification delivery channels.
-Supports variables for personalization and file attachments.
+⚠️ PARTIALLY ENFORCED — the delivery channels actually registered by
-@example
+`service-messaging` are `inbox`, `email`, and `sms` (#3197). `push`,
-```json
+`slack`, `teams`, and `webhook` have no delivery implementation, and the
-\{
+dispatcher dead-letters any message addressed to an unregistered channel.
-"id": "welcome-email",
+Note also the naming drift: this enum says `in-app` while the implemented
-"subject": "Welcome to \{\{company_name\}\}",
+channel registers as `inbox` (which this enum does not contain) —
-"body": "
Welcome \{\{user_name\}\}!
",
-
-"bodyType": "html",
-
-"variables": ["company_name", "user_name"],
-
-"attachments": [
-
-\{
-
-"name": "guide.pdf",
-
-"url": "https://example.com/guide.pdf"
-
-\}
-
-]
-
-\}
-
-```
+reconcile before wiring this enum into the runtime.
**Source:** `packages/spec/src/system/notification.zod.ts`
@@ -50,45 +30,13 @@ Supports variables for personalization and file attachments.
## TypeScript Usage
```typescript
-import { EmailTemplateSchema, InAppNotificationSchema, NotificationChannelSchema, PushNotificationSchema, SMSTemplateSchema } from '@objectstack/spec/system';
-import type { EmailTemplate, InAppNotification, NotificationChannel, PushNotification, SMSTemplate } from '@objectstack/spec/system';
+import { NotificationChannelSchema } from '@objectstack/spec/system';
+import type { NotificationChannel } from '@objectstack/spec/system';
// Validate data
-const result = EmailTemplateSchema.parse(data);
+const result = NotificationChannelSchema.parse(data);
```
----
-
-## EmailTemplate
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **id** | `string` | ✅ | Template identifier |
-| **subject** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Email subject — supports `{{var}`} interpolation |
-| **body** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Email body content — supports `{{var}`} interpolation |
-| **bodyType** | `Enum<'text' \| 'html' \| 'markdown'>` | optional | Body content type |
-| **variables** | `string[]` | optional | Template variables |
-| **attachments** | `{ name: string; url: string }[]` | optional | Email attachments |
-
-
----
-
-## InAppNotification
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **title** | `string` | ✅ | Notification title |
-| **message** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Notification message — supports `{{var}`} interpolation |
-| **type** | `Enum<'info' \| 'success' \| 'warning' \| 'error'>` | ✅ | Notification type |
-| **actionUrl** | `string` | optional | Action URL |
-| **dismissible** | `boolean` | optional | User dismissible |
-| **expiresAt** | `number` | optional | Expiration timestamp |
-
-
---
## NotificationChannel
@@ -108,33 +56,3 @@ Notification delivery channel (implemented today: inbox, email, sms — push/sla
---
-## PushNotification
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **title** | `string` | ✅ | Notification title |
-| **body** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | Notification body — supports `{{var}`} interpolation |
-| **icon** | `string` | optional | Notification icon URL |
-| **badge** | `number` | optional | Badge count |
-| **data** | `Record` | optional | Custom data |
-| **actions** | `{ action: string; title: string }[]` | optional | Notification actions |
-
-
----
-
-## SMSTemplate
-
-### Properties
-
-| Property | Type | Required | Description |
-| :--- | :--- | :--- | :--- |
-| **id** | `string` | ✅ | Template identifier |
-| **message** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }` | ✅ | SMS message content — supports `{{var}`} interpolation |
-| **maxLength** | `number` | optional | Maximum message length |
-| **variables** | `string[]` | optional | Template variables |
-
-
----
-
diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json
index e1d29a62bf..da9f841cc9 100644
--- a/packages/spec/api-surface.json
+++ b/packages/spec/api-surface.json
@@ -896,7 +896,6 @@
"EmailProviderSchema (const)",
"EmailServiceConfig (type)",
"EmailServiceConfigSchema (const)",
- "EmailTemplate (type)",
"EmailTemplateDefinition (type)",
"EmailTemplateDefinitionCategory (type)",
"EmailTemplateDefinitionCategorySchema (const)",
@@ -904,7 +903,6 @@
"EmailTemplateDefinitionSchema (const)",
"EmailTemplateDefinitionVariable (type)",
"EmailTemplateDefinitionVariableSchema (const)",
- "EmailTemplateSchema (const)",
"EmailVerificationConfig (type)",
"EmailVerificationConfigSchema (const)",
"EncryptionAlgorithm (type)",
@@ -950,8 +948,6 @@
"HttpServerConfigSchema (const)",
"ISettingsCapability (interface)",
"ISettingsClient (interface)",
- "InAppNotification (type)",
- "InAppNotificationSchema (const)",
"Incident (type)",
"IncidentCategory (type)",
"IncidentCategorySchema (const)",
@@ -1146,8 +1142,6 @@
"PlanSchema (const)",
"PresignedUrlConfig (type)",
"PresignedUrlConfigSchema (const)",
- "PushNotification (type)",
- "PushNotificationSchema (const)",
"QueueConfig (type)",
"QueueConfigInput (type)",
"QueueConfigSchema (const)",
@@ -1188,8 +1182,6 @@
"RowLevelIsolationStrategyInput (type)",
"RowLevelIsolationStrategySchema (const)",
"SETTINGS_CHANGE_EVENT (const)",
- "SMSTemplate (type)",
- "SMSTemplateSchema (const)",
"SamplingDecision (type)",
"SamplingStrategyType (type)",
"Schedule (type)",
diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json
index d1d42a6099..795acd2521 100644
--- a/packages/spec/authorable-surface.json
+++ b/packages/spec/authorable-surface.json
@@ -5903,12 +5903,6 @@
"system/EmailServiceConfig:persist",
"system/EmailServiceConfig:provider",
"system/EmailServiceConfig:retries",
- "system/EmailTemplate:attachments",
- "system/EmailTemplate:body",
- "system/EmailTemplate:bodyType",
- "system/EmailTemplate:id",
- "system/EmailTemplate:subject",
- "system/EmailTemplate:variables",
"system/EmailTemplateDefinition:_lock",
"system/EmailTemplateDefinition:_lockDocsUrl",
"system/EmailTemplateDefinition:_lockReason",
@@ -6010,12 +6004,6 @@
"system/HttpServerConfig:security",
"system/HttpServerConfig:static",
"system/HttpServerConfig:trustProxy",
- "system/InAppNotification:actionUrl",
- "system/InAppNotification:dismissible",
- "system/InAppNotification:expiresAt",
- "system/InAppNotification:message",
- "system/InAppNotification:title",
- "system/InAppNotification:type",
"system/Incident:affectedDataClassifications",
"system/Incident:affectedSystems",
"system/Incident:category",
@@ -6470,12 +6458,6 @@
"system/PresignedUrlConfig:operation",
"system/PresignedUrlConfig:responseContentDisposition",
"system/PresignedUrlConfig:responseContentType",
- "system/PushNotification:actions",
- "system/PushNotification:badge",
- "system/PushNotification:body",
- "system/PushNotification:data",
- "system/PushNotification:icon",
- "system/PushNotification:title",
"system/QueueConfig:autoScale",
"system/QueueConfig:concurrency",
"system/QueueConfig:deadLetterQueue",
@@ -6536,10 +6518,6 @@
"system/RowLevelIsolationStrategy:database",
"system/RowLevelIsolationStrategy:performance",
"system/RowLevelIsolationStrategy:strategy",
- "system/SMSTemplate:id",
- "system/SMSTemplate:maxLength",
- "system/SMSTemplate:message",
- "system/SMSTemplate:variables",
"system/SchemaChange:changeType",
"system/SchemaChange:entityName",
"system/SchemaChange:entityType",
diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json
index 83877e902a..a2c42c608d 100644
--- a/packages/spec/json-schema.manifest.json
+++ b/packages/spec/json-schema.manifest.json
@@ -1258,7 +1258,6 @@
"system/EmailAndPasswordConfig",
"system/EmailProvider",
"system/EmailServiceConfig",
- "system/EmailTemplate",
"system/EmailTemplateDefinition",
"system/EmailTemplateDefinitionCategory",
"system/EmailTemplateDefinitionVariable",
@@ -1280,7 +1279,6 @@
"system/HistogramBucketConfig",
"system/HttpDestinationConfig",
"system/HttpServerConfig",
- "system/InAppNotification",
"system/Incident",
"system/IncidentCategory",
"system/IncidentNotificationMatrix",
@@ -1372,7 +1370,6 @@
"system/PackagePublishResult",
"system/Plan",
"system/PresignedUrlConfig",
- "system/PushNotification",
"system/QueueConfig",
"system/QuotaEnforcementResult",
"system/RPO",
@@ -1387,7 +1384,6 @@
"system/RollbackPlan",
"system/RouteHandlerMetadata",
"system/RowLevelIsolationStrategy",
- "system/SMSTemplate",
"system/SamplingDecision",
"system/SamplingStrategyType",
"system/Schedule",
diff --git a/packages/spec/src/kernel/metadata-plugin.zod.ts b/packages/spec/src/kernel/metadata-plugin.zod.ts
index 5003c9d8a7..bf71ce21b8 100644
--- a/packages/spec/src/kernel/metadata-plugin.zod.ts
+++ b/packages/spec/src/kernel/metadata-plugin.zod.ts
@@ -113,7 +113,13 @@ export const MetadataTypeSchema = lazySchema(() => z.enum([
// code contributions: plugin `contributes.routes` + declarative `apis:`
// (router), `defineStack({ functions })` + `contributes.functions`
// (function), and the plugin/service registry itself (service).
- 'email_template', // Outbound email templates (EmailTemplateSchema)
+ // #4616: the canonical schema is `EmailTemplateDefinitionSchema`
+ // (`system/email-template.zod.ts`), which is what `BUILTIN_METADATA_TYPE_SCHEMAS`
+ // resolves this kind to. This comment used to name `EmailTemplateSchema` — the
+ // legacy sub-shape spec 7.1.0 demoted when it fixed that Prime Directive #8
+ // double-declaration, and removed outright in #4616 — which is exactly how
+ // consumers kept wiring the wrong one.
+ 'email_template', // Outbound email templates (EmailTemplateDefinitionSchema)
'doc', // Package documentation — flat Markdown items (DocSchema, ADR-0046)
'book', // Documentation navigation spine (BookSchema, ADR-0046 §6)
diff --git a/packages/spec/src/system/notification.test.ts b/packages/spec/src/system/notification.test.ts
index 7d1bf600cc..8fafd68d41 100644
--- a/packages/spec/src/system/notification.test.ts
+++ b/packages/spec/src/system/notification.test.ts
@@ -1,246 +1,5 @@
import { describe, it, expect } from 'vitest';
-import {
- EmailTemplateSchema,
- SMSTemplateSchema,
- PushNotificationSchema,
- InAppNotificationSchema,
- NotificationChannelSchema,
- type EmailTemplate,
- type SMSTemplate,
-} from './notification.zod';
-
-describe('EmailTemplateSchema', () => {
- it('should validate complete email template', () => {
- const validTemplate: EmailTemplate = {
- id: 'welcome-email',
- subject: 'Welcome to {{company_name}}',
- body: 'Welcome {{user_name}}!
',
- bodyType: 'html',
- variables: ['company_name', 'user_name'],
- attachments: [
- {
- name: 'guide.pdf',
- url: 'https://example.com/guide.pdf',
- },
- ],
- };
-
- expect(() => EmailTemplateSchema.parse(validTemplate)).not.toThrow();
- });
-
- it('should accept minimal email template', () => {
- const minimalTemplate = {
- id: 'simple-email',
- subject: 'Test Email',
- body: 'Simple text body',
- };
-
- expect(() => EmailTemplateSchema.parse(minimalTemplate)).not.toThrow();
- });
-
- it('should default bodyType to html', () => {
- const template = {
- id: 'test',
- subject: 'Test',
- body: 'Body',
- };
-
- const parsed = EmailTemplateSchema.parse(template);
- expect(parsed.bodyType).toBe('html');
- });
-
- it('should accept text bodyType', () => {
- const template = {
- id: 'text-email',
- subject: 'Plain Text',
- body: 'Plain text body',
- bodyType: 'text' as const,
- };
-
- expect(() => EmailTemplateSchema.parse(template)).not.toThrow();
- });
-
- it('should accept markdown bodyType', () => {
- const template = {
- id: 'markdown-email',
- subject: 'Markdown Email',
- body: '# Header\n\nContent',
- bodyType: 'markdown' as const,
- };
-
- expect(() => EmailTemplateSchema.parse(template)).not.toThrow();
- });
-
- it('should validate attachment URLs', () => {
- const invalidTemplate = {
- id: 'email-1',
- subject: 'Test',
- body: 'Body',
- attachments: [
- {
- name: 'file.pdf',
- url: 'not-a-url',
- },
- ],
- };
-
- expect(() => EmailTemplateSchema.parse(invalidTemplate)).toThrow();
- });
-});
-
-describe('SMSTemplateSchema', () => {
- it('should validate complete SMS template', () => {
- const validTemplate: SMSTemplate = {
- id: 'verification-sms',
- message: 'Your verification code is {{code}}',
- maxLength: 160,
- variables: ['code'],
- };
-
- expect(() => SMSTemplateSchema.parse(validTemplate)).not.toThrow();
- });
-
- it('should accept minimal SMS template', () => {
- const minimalTemplate = {
- id: 'simple-sms',
- message: 'Hello World',
- };
-
- expect(() => SMSTemplateSchema.parse(minimalTemplate)).not.toThrow();
- });
-
- it('should default maxLength to 160', () => {
- const template = {
- id: 'sms-1',
- message: 'Test message',
- };
-
- const parsed = SMSTemplateSchema.parse(template);
- expect(parsed.maxLength).toBe(160);
- });
-
- it('should accept custom maxLength', () => {
- const template = {
- id: 'long-sms',
- message: 'Long message',
- maxLength: 320,
- };
-
- const parsed = SMSTemplateSchema.parse(template);
- expect(parsed.maxLength).toBe(320);
- });
-});
-
-describe('PushNotificationSchema', () => {
- it('should validate complete push notification', () => {
- const validPush = {
- title: 'New Message',
- body: 'You have a new message from John',
- icon: 'https://example.com/icon.png',
- badge: 5,
- data: { messageId: 'msg_123' },
- actions: [
- { action: 'view', title: 'View' },
- { action: 'dismiss', title: 'Dismiss' },
- ],
- };
-
- expect(() => PushNotificationSchema.parse(validPush)).not.toThrow();
- });
-
- it('should accept minimal push notification', () => {
- const minimalPush = {
- title: 'Alert',
- body: 'Something happened',
- };
-
- expect(() => PushNotificationSchema.parse(minimalPush)).not.toThrow();
- });
-
- it('should validate icon URL', () => {
- const invalidPush = {
- title: 'Test',
- body: 'Body',
- icon: 'not-a-url',
- };
-
- expect(() => PushNotificationSchema.parse(invalidPush)).toThrow();
- });
-
- it('should accept custom data payload', () => {
- const push = {
- title: 'Order Update',
- body: 'Your order has shipped',
- data: {
- orderId: 'ord_123',
- trackingNumber: 'TRK456',
- status: 'shipped',
- },
- };
-
- expect(() => PushNotificationSchema.parse(push)).not.toThrow();
- });
-});
-
-describe('InAppNotificationSchema', () => {
- it('should validate complete in-app notification', () => {
- const validNotification = {
- title: 'System Update',
- message: 'New features are now available',
- type: 'info' as const,
- actionUrl: '/updates',
- dismissible: true,
- expiresAt: 1704067200000,
- };
-
- expect(() => InAppNotificationSchema.parse(validNotification)).not.toThrow();
- });
-
- it('should accept minimal in-app notification', () => {
- const minimalNotification = {
- title: 'Alert',
- message: 'Important message',
- type: 'warning' as const,
- };
-
- expect(() => InAppNotificationSchema.parse(minimalNotification)).not.toThrow();
- });
-
- it('should default dismissible to true', () => {
- const notification = {
- title: 'Test',
- message: 'Message',
- type: 'info' as const,
- };
-
- const parsed = InAppNotificationSchema.parse(notification);
- expect(parsed.dismissible).toBe(true);
- });
-
- it('should accept all notification types', () => {
- const types = ['info', 'success', 'warning', 'error'] as const;
-
- types.forEach((type) => {
- const notification = {
- title: 'Test',
- message: 'Message',
- type,
- };
-
- expect(() => InAppNotificationSchema.parse(notification)).not.toThrow();
- });
- });
-
- it('should reject invalid notification type', () => {
- const invalidNotification = {
- title: 'Test',
- message: 'Message',
- type: 'invalid',
- };
-
- expect(() => InAppNotificationSchema.parse(invalidNotification)).toThrow();
- });
-});
+import { NotificationChannelSchema } from './notification.zod';
describe('NotificationChannelSchema', () => {
it('should accept all valid channels', () => {
@@ -264,19 +23,203 @@ describe('NotificationChannelSchema', () => {
});
});
-// Pin: this module no longer declares the bare NotificationConfig names. The
-// pin is compile-time (typeof import is type-level only — no runtime barrel
-// load): if either bare name is re-added here, the conditional type flips to
-// `true` and the `false` assignment fails `tsc --noEmit`. The name left the
-// spec export surface entirely (#4610): its ./ui twin was removed in the same
-// change, and ADR-0030's delivery vocabulary (NotificationService.emit /
-// NotifyConfigSchema / sys_* objects) is the live contract.
-describe('NotificationConfig removal (#4610)', () => {
- it('does not re-expose the bare NotificationConfig names from ./system', () => {
- type SystemNotificationModule = typeof import('./notification.zod');
- const hasConfigSchema: 'NotificationConfigSchema' extends keyof SystemNotificationModule
- ? true
- : false = false;
- expect(hasConfigSchema).toBe(false);
+// ─── [#4616] the orphan notification-template vocabulary is gone ─────────────
+//
+// ADR-0049 enforce-or-remove, v17 breaking window. `EmailTemplateSchema`,
+// `SMSTemplateSchema`, `PushNotificationSchema` and `InAppNotificationSchema`
+// (+ their four type aliases) existed ONLY as the member shapes of the
+// `NotificationConfigSchema.template` union that #4610 (#4535 C3) deleted.
+// After #4610 they were reachable from no parent schema and from no
+// metadata-type root — declared capability the runtime never read.
+//
+// This is a whole-def removal (#4650 route 3: the defs stop being emitted, so
+// the authorable-surface deletion is adjudicated by json-schema.manifest.json
+// and check:api-surface), NOT a `retiredKey()` tombstone: a tombstone lives on
+// a surviving schema's shape, and there is no surviving shape here. No
+// ADR-0087 D2 conversion either — no metadata document was ever parsed against
+// these defs, so `os migrate meta` has nothing to rewrite (same disposition as
+// #4610 in this very file, and as #4767/#4783).
+//
+// WHY THIS PIN IS A COMPILER-API TEST. #4642 established that a conditional
+// type over `typeof import(...)` in this package is a NO-OP — `tsconfig.json`
+// excludes `**/*.test.ts` and vitest never enables `typecheck`, so nothing ever
+// evaluates it. The `NotificationConfig` pin #4610 left here was exactly that
+// shape; it is folded into the load-bearing test below rather than left as a
+// gate that cannot fail. Sabotage-verified in the PR: S1 re-declares a removed
+// const in notification.zod.ts, S2 re-exports it from another entry under the
+// bare name (the route a "./system does not export it" assertion would miss).
+describe('[#4616] notification-template orphan removal', () => {
+ /** Names that must not be exported by ANY public entry point. */
+ const REMOVED = [
+ // #4616 — this change.
+ 'EmailTemplateSchema',
+ 'EmailTemplate',
+ 'SMSTemplateSchema',
+ 'SMSTemplate',
+ 'PushNotificationSchema',
+ 'PushNotification',
+ 'InAppNotificationSchema',
+ 'InAppNotification',
+ // #4610 — the holder that made the four above orphans; pinned here so the
+ // whole cluster has one enforceable home.
+ 'NotificationConfigSchema',
+ 'NotificationConfig',
+ ];
+
+ it('resolves the export surface: no removed name survives on any entry, and the survivors keep their owners', async () => {
+ const ts = (await import('typescript')).default;
+ const { resolve, relative, dirname } = await import('node:path');
+ const { fileURLToPath } = await import('node:url');
+ const { readFileSync } = await import('node:fs');
+
+ const specDir = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
+ // Every public entry point, read from package.json's exports map so a
+ // future entry cannot silently escape the assertions below.
+ const pkg = JSON.parse(readFileSync(resolve(specDir, 'package.json'), 'utf8')) as {
+ exports: Record;
+ };
+ const entries: Record = {};
+ for (const sub of Object.keys(pkg.exports)) {
+ if (sub === '.') entries[sub] = resolve(specDir, 'src/index.ts');
+ else if (/^\.\/[a-z-]+$/.test(sub)) entries[sub] = resolve(specDir, `src/${sub.slice(2)}/index.ts`);
+ // './openapi.json' / './package.json' are not TypeScript entry points.
+ }
+ // Anti-vacuity: the enumeration must have found the real surface, including
+ // every entry these names could plausibly be re-exported from.
+ for (const needed of ['.', './system', './ui', './contracts', './api']) {
+ expect(Object.keys(entries), `exports map must include ${needed}`).toContain(needed);
+ }
+ expect(Object.keys(entries).length).toBeGreaterThan(10);
+
+ const program = ts.createProgram(Object.values(entries), {
+ module: ts.ModuleKind.ESNext,
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
+ skipLibCheck: true,
+ noEmit: true,
+ });
+ const checker = program.getTypeChecker();
+ const unalias = (s: import('typescript').Symbol) =>
+ s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s;
+
+ const exportsOf = (sub: string) => {
+ const sf = program.getSourceFile(entries[sub]);
+ const moduleSym = sf && checker.getSymbolAtLocation(sf);
+ // Without this guard a resolution failure would make every `not.toContain`
+ // below pass vacuously — the exact way a gate goes dormant (#4642).
+ expect(moduleSym, `${sub} module symbol must resolve`).toBeTruthy();
+ return checker.getExportsOfModule(moduleSym!);
+ };
+
+ const originOf = (sym: import('typescript').Symbol, label: string) => {
+ const decl = unalias(sym).declarations?.[0];
+ expect(decl, `${label} must have a declaration`).toBeTruthy();
+ const declFile = decl!.getSourceFile();
+ return `${relative(specDir, declFile.fileName)}:${
+ declFile.getLineAndCharacterOfPosition(decl!.getStart()).line + 1
+ }`;
+ };
+
+ /** Every entry that exports `name`, with each occurrence's declaration origin. */
+ const holdersOf = (name: string) => {
+ const out: Array<{ sub: string; origin: string }> = [];
+ for (const sub of Object.keys(entries)) {
+ for (const sym of exportsOf(sub).filter((e) => e.getName() === name)) {
+ out.push({ sub, origin: originOf(sym, `${sub} ${name}`) });
+ }
+ }
+ return out.sort((a, b) => a.sub.localeCompare(b.sub));
+ };
+
+ // Anti-vacuity: the entries we are about to prove things ABSENT from must
+ // each resolve a large, real surface first.
+ expect(exportsOf('./system').length, './system must export a non-trivial surface').toBeGreaterThan(400);
+ expect(exportsOf('./ui').length, './ui must export a non-trivial surface').toBeGreaterThan(200);
+ expect(exportsOf('./contracts').length, './contracts must export a non-trivial surface').toBeGreaterThan(100);
+
+ // 1. Every removed name is absent from EVERY entry — not merely from
+ // ./system. A re-export elsewhere under the bare name would keep the
+ // orphan authorable while looking like a clean removal of the
+ // declaration (the C14/C15/C17 lesson: a re-export can lie about the
+ // domain even when the symbol is honest).
+ for (const name of REMOVED) {
+ expect(holdersOf(name), `${name} must not be exported by any entry point`).toEqual([]);
+ }
+
+ // 2. The survivor in this module: `NotificationChannel(Schema)` is live —
+ // `./contracts` re-exports the TYPE and service-messaging consumes it —
+ // so both holders must resolve to the ONE declaration in this file.
+ const channelSchema = holdersOf('NotificationChannelSchema');
+ expect(channelSchema.map((h) => h.sub)).toEqual(['./system']);
+ expect(channelSchema[0].origin).toMatch(/^src\/system\/notification\.zod\.ts:\d+$/);
+ const channelType = holdersOf('NotificationChannel');
+ expect(channelType.map((h) => h.sub)).toEqual(['./contracts', './system']);
+ for (const h of channelType) {
+ expect(h.origin, 'both holders must share one declaration').toMatch(
+ /^src\/system\/notification\.zod\.ts:\d+$/,
+ );
+ }
+
+ // 3. The live email-template contract — what an author must use instead of
+ // the removed `EmailTemplateSchema`. `BUILTIN_METADATA_TYPE_SCHEMAS`
+ // resolves the `email_template` kind to this one; spec 7.1.0 already
+ // demoted the legacy shape here, and #4616 finished the job.
+ const definition = holdersOf('EmailTemplateDefinitionSchema');
+ expect(definition.map((h) => h.sub)).toEqual(['./system']);
+ expect(definition[0].origin).toMatch(/^src\/system\/email-template\.zod\.ts:\d+$/);
+
+ // 4. REFUTED SIBLING — #4616's issue body proposed retiring `./ui`'s
+ // `NotificationSeveritySchema` in the same sweep on the premise that
+ // objectui pins only Type/Position/Action. That is false: objectui
+ // re-exports the TYPE (`packages/types/src/index.ts`), consumes it in
+ // `packages/core/src/protocols/NotificationProtocol.ts`, pins the SCHEMA
+ // name in `packages/types/src/__tests__/spec-ui-schema-reexports.test.ts`,
+ // and types two severity→tone maps as `Record`
+ // precisely so a new spec severity fails type-check downstream. It stays
+ // live and ./ui-owned; this assertion exists so the claim is not
+ // re-litigated from the issue text.
+ for (const name of ['NotificationSeveritySchema', 'NotificationSeverity']) {
+ const holders = holdersOf(name);
+ expect(holders.map((h) => h.sub), `${name} is LIVE in objectui — must stay ./ui-owned`).toEqual(['./ui']);
+ expect(holders[0].origin).toMatch(/^src\/ui\/notification\.zod\.ts:\d+$/);
+ }
+ });
+
+ it('keeps the runtime namespaces consistent with the compiler view', async () => {
+ const system = await import('./index');
+ const ui = await import('../ui/index');
+
+ for (const name of REMOVED) {
+ expect(name in system, `./system must not export ${name} at runtime`).toBe(false);
+ expect(name in ui, `./ui must not export ${name} at runtime`).toBe(false);
+ }
+
+ // Anti-vacuity: the namespaces just probed are real, and the neighbours
+ // that must survive do.
+ expect('NotificationChannelSchema' in system).toBe(true);
+ expect('EmailTemplateDefinitionSchema' in system).toBe(true);
+ expect('NotificationSeveritySchema' in ui).toBe(true);
+
+ // The live email-template contract still parses a canonical authored item,
+ // and still rejects the removed legacy shape — so "use the definition
+ // schema instead" is a prescription that actually works.
+ const parsed = system.EmailTemplateDefinitionSchema.parse({
+ name: 'crm_welcome',
+ label: 'Welcome',
+ subject: 'Welcome to {{company}}',
+ bodyHtml: 'Welcome {{user}}
',
+ });
+ expect(parsed.name).toBe('crm_welcome');
+ expect(parsed.locale).toBe('en-US');
+ // The legacy shape's required keys (`id`, `body`) are not this contract's,
+ // and it is a strictObject — authoring the old shape fails loudly instead
+ // of being silently stripped.
+ expect(() =>
+ system.EmailTemplateDefinitionSchema.parse({
+ id: 'welcome-email',
+ subject: 'Welcome',
+ body: 'Welcome
',
+ bodyType: 'html',
+ }),
+ ).toThrow();
});
});
diff --git a/packages/spec/src/system/notification.zod.ts b/packages/spec/src/system/notification.zod.ts
index 5929732016..04c98b6c32 100644
--- a/packages/spec/src/system/notification.zod.ts
+++ b/packages/spec/src/system/notification.zod.ts
@@ -1,213 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
import { z } from 'zod';
-import { TemplateExpressionInputSchema } from '../shared/expression.zod';
-
-/**
- * Email Template Schema
- *
- * Defines the structure and content of email notifications.
- * Supports variables for personalization and file attachments.
- *
- * @example
- * ```json
- * {
- * "id": "welcome-email",
- * "subject": "Welcome to {{company_name}}",
- * "body": "Welcome {{user_name}}!
",
- * "bodyType": "html",
- * "variables": ["company_name", "user_name"],
- * "attachments": [
- * {
- * "name": "guide.pdf",
- * "url": "https://example.com/guide.pdf"
- * }
- * ]
- * }
- * ```
- */
import { lazySchema } from '../shared/lazy-schema';
-export const EmailTemplateSchema = lazySchema(() => z.object({
- /**
- * Unique identifier for the email template
- */
- id: z.string().describe('Template identifier'),
-
- /**
- * Email subject line (supports variable interpolation)
- */
- subject: TemplateExpressionInputSchema.describe('Email subject — supports {{var}} interpolation'),
-
- /**
- * Email body content
- */
- body: TemplateExpressionInputSchema.describe('Email body content — supports {{var}} interpolation'),
-
- /**
- * Content type of the email body
- * @default 'html'
- */
- bodyType: z.enum(['text', 'html', 'markdown']).optional().default('html').describe('Body content type'),
-
- /**
- * List of template variables for dynamic content
- */
- variables: z.array(z.string()).optional().describe('Template variables'),
-
- /**
- * File attachments to include with the email
- */
- attachments: z.array(z.object({
- name: z.string().describe('Attachment filename'),
- url: z.string().url().describe('Attachment URL'),
- })).optional().describe('Email attachments'),
-}));
-
-/**
- * SMS Template Schema
- *
- * Defines the structure of SMS text message notifications.
- * Includes character limits and variable support.
- *
- * @example
- * ```json
- * {
- * "id": "verification-sms",
- * "message": "Your code is {{code}}",
- * "maxLength": 160,
- * "variables": ["code"]
- * }
- * ```
- */
-export const SMSTemplateSchema = lazySchema(() => z.object({
- /**
- * Unique identifier for the SMS template
- */
- id: z.string().describe('Template identifier'),
-
- /**
- * SMS message content (supports variable interpolation)
- */
- message: TemplateExpressionInputSchema.describe('SMS message content — supports {{var}} interpolation'),
-
- /**
- * Maximum character length for the SMS
- * @default 160
- */
- maxLength: z.number().optional().default(160).describe('Maximum message length'),
-
- /**
- * List of template variables for dynamic content
- */
- variables: z.array(z.string()).optional().describe('Template variables'),
-}));
-
-/**
- * Push Notification Schema
- *
- * Defines mobile and web push notification structure.
- * Supports rich notifications with actions and badges.
- *
- * @example
- * ```json
- * {
- * "title": "New Message",
- * "body": "You have a new message from John",
- * "icon": "https://example.com/icon.png",
- * "badge": 5,
- * "data": {"messageId": "msg_123"},
- * "actions": [
- * {"action": "view", "title": "View"},
- * {"action": "dismiss", "title": "Dismiss"}
- * ]
- * }
- * ```
- */
-export const PushNotificationSchema = lazySchema(() => z.object({
- /**
- * Notification title
- */
- title: z.string().describe('Notification title'),
-
- /**
- * Notification body text
- */
- body: TemplateExpressionInputSchema.describe('Notification body — supports {{var}} interpolation'),
-
- /**
- * Icon URL to display with notification
- */
- icon: z.string().url().optional().describe('Notification icon URL'),
-
- /**
- * Badge count to display on app icon
- */
- badge: z.number().optional().describe('Badge count'),
-
- /**
- * Custom data payload
- */
- data: z.record(z.string(), z.unknown()).optional().describe('Custom data'),
-
- /**
- * Action buttons for the notification
- */
- actions: z.array(z.object({
- action: z.string().describe('Action identifier'),
- title: z.string().describe('Action button title'),
- })).optional().describe('Notification actions'),
-}));
-
-/**
- * In-App Notification Schema
- *
- * Defines in-application notification banners and toasts.
- * Includes severity levels and auto-dismiss settings.
- *
- * @example
- * ```json
- * {
- * "title": "System Update",
- * "message": "New features are now available",
- * "type": "info",
- * "actionUrl": "/updates",
- * "dismissible": true,
- * "expiresAt": 1704067200000
- * }
- * ```
- */
-export const InAppNotificationSchema = lazySchema(() => z.object({
- /**
- * Notification title
- */
- title: z.string().describe('Notification title'),
-
- /**
- * Notification message content
- */
- message: TemplateExpressionInputSchema.describe('Notification message — supports {{var}} interpolation'),
-
- /**
- * Notification severity type
- */
- type: z.enum(['info', 'success', 'warning', 'error']).describe('Notification type'),
-
- /**
- * Optional URL to navigate to when clicked
- */
- actionUrl: z.string().optional().describe('Action URL'),
-
- /**
- * Whether the notification can be dismissed by the user
- * @default true
- */
- dismissible: z.boolean().optional().default(true).describe('User dismissible'),
-
- /**
- * Timestamp when notification expires (Unix milliseconds)
- */
- expiresAt: z.number().optional().describe('Expiration timestamp'),
-}));
/**
* Notification Channel Enum
@@ -241,13 +35,37 @@ export const NotificationChannelSchema = lazySchema(() => z.enum([
// (single ingress, `@objectstack/spec/contracts`), the `notify` flow node
// (`NotifyConfigSchema`, `@objectstack/spec/automation`) and the sys_*
// notification objects. Its `./ui` twin was removed in the same change; the
-// bare name left the spec export surface entirely. The channel/template
-// vocabulary below stays: `NotificationChannel` is re-exported by
+// bare name left the spec export surface entirely.
+//
+// [#4616] `EmailTemplateSchema` / `EmailTemplate`, `SMSTemplateSchema` /
+// `SMSTemplate`, `PushNotificationSchema` / `PushNotification` and
+// `InAppNotificationSchema` / `InAppNotification` were removed from this
+// module too (ADR-0049 enforce-or-remove, v17 window). They existed ONLY as
+// the member shapes of the `NotificationConfigSchema.template` union #4610
+// deleted, so #4610 left them reachable from no parent schema and from no
+// metadata-type root — declared capability the runtime never read.
+//
+// What actually delivers, per channel, so the next reader does not re-derive
+// it (this vocabulary is where the confusion kept starting):
+// - email — the `email_template` metadata kind, whose ONE canonical schema
+// is `EmailTemplateDefinitionSchema` (`system/email-template.zod.ts`,
+// registered in `BUILTIN_METADATA_TYPE_SCHEMAS`), materialized into
+// `sys_email_template` rows by `plugin-email`. The `EmailTemplateSchema`
+// removed here was the *legacy* shape spec 7.1.0 explicitly demoted when
+// it resolved that Prime Directive #8 double-declaration, keeping it
+// "only as an inline sub-shape inside `Notification`" — the holder #4610
+// removed.
+// - sms — `sys_notification_template` rows resolved by (topic, 'sms',
+// locale) in `service-messaging/src/sms-channel.ts`, rendered by
+// `template-renderer.ts`; the provider-side template is Aliyun's
+// pre-registered `TemplateCode` in `service-sms` (`aliyun.ts`), a vendor
+// API shape, not a spec constant.
+// - push / in-app — no delivery implementation at all (#3197): the
+// dispatcher dead-letters them. Declaring their payload shapes here
+// advertised a capability nothing delivers.
+//
+// The channel vocabulary above stays: `NotificationChannel` is re-exported by
// `@objectstack/spec/contracts` and consumed by `service-messaging`.
// Type exports
export type NotificationChannel = z.infer;
-export type EmailTemplate = z.infer;
-export type SMSTemplate = z.infer;
-export type PushNotification = z.infer;
-export type InAppNotification = z.infer;