From c240c28235a980bb5c642cb3cca6a8c5c03bdef7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 07:54:25 +0000 Subject: [PATCH 1/2] feat(spec)!: retire the orphan notification-template vocabulary from ./system (#4616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0049 enforce-or-remove, resolved by REMOVE in the 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. Since then they have been reachable from no parent schema and from no metadata-type root — declared delivery capability the runtime never read. Three-repo consumer review (objectstack, cloud @ 5df2c69, objectui @ 785b8a5), by bare name rather than by import statement — which is what changed the answer: - SMSTemplate / PushNotification / InAppNotification: zero references outside their own declaration, their own unit tests and the generated artifacts. - EmailTemplateSchema: NOT consumer-free. objectui's metadata-admin registers it as the client-side validator for the `email_template` kind through a DYNAMIC import (app-shell/src/views/metadata-admin/clientValidation.ts:89), which an import-statement scan cannot see. That registration is itself the bug: the canonical schema for the kind is `EmailTemplateDefinitionSchema`, and validating an authored definition against the legacy shape reports its real keys as missing — the same defect spec 7.1.0 fixed on the framework side when it demoted `EmailTemplateSchema` to "an inline sub-shape inside `Notification`". Filed separately; removing the legacy export turns that silent mis-validation into a compile error at the one site that needs it. - ui `NotificationSeveritySchema`: REFUTED. The issue proposed retiring it in the same sweep on the premise that objectui pins only Type/Position/Action. objectui re-exports the type (packages/types), consumes it in 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 is untouched, and the pin below asserts it stays ./ui-owned so the claim is not re-litigated. Route: whole-def removal (#4650 route 3), not a `retiredKey()` tombstone — a tombstone lives on a surviving schema's shape and none survives here. gen:schema adjudicates the four defs itself ("def no longer emitted by this build; whole-schema removals are adjudicated by json-schema.manifest.json and check:api-surface"), which is why the 22 authorable-surface lines and the 4 manifest keys are deleted in this commit. No ADR-0087 D2 conversion, deliberately: a conversion rewrites authored or stored sources, and no metadata document was ever parsed against these defs, so `os migrate meta` would have nothing to match — same disposition as #4610 in this module, and as #4767/#4783. Also corrects `kernel/metadata-plugin.zod.ts`'s `email_template` comment, which named `EmailTemplateSchema` where the registry resolves `EmailTemplateDefinitionSchema`. That one parenthetical is the documented source of the confusion, including objectui's. The pin is a TypeScript compiler-API test over the package.json exports map (all 16 entries), asserting each removed name has ZERO holders anywhere — not merely that ./system omits it — with anti-vacuity guards on entry resolution and surface size. #4642 established that the conditional-type pin #4610 left here is a no-op (tsconfig excludes **/*.test.ts), so it is folded in rather than left as a gate that cannot fail. Co-Authored-By: Claude --- .../notification-orphan-template-schemas.md | 60 +++ .../docs/references/system/notification.mdx | 106 +--- packages/spec/api-surface.json | 8 - packages/spec/authorable-surface.json | 22 - packages/spec/json-schema.manifest.json | 4 - .../spec/src/kernel/metadata-plugin.zod.ts | 8 +- packages/spec/src/system/notification.test.ts | 455 ++++++++---------- packages/spec/src/system/notification.zod.ts | 242 ++-------- 8 files changed, 308 insertions(+), 597 deletions(-) create mode 100644 .changeset/notification-orphan-template-schemas.md 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 ad581008ca..428602baee 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 f7e089995c..59d240195a 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 5742c62456..9e72344cce 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; From e545fe2e87ecf8c8cd6266c8ae95816ca9dbd7d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 08:27:37 +0000 Subject: [PATCH 2/2] =?UTF-8?q?chore(spec):=20regenerate=20the=20three=20r?= =?UTF-8?q?atchets=20after=20merging=20#4789=20=E2=80=94=20its=20rename=20?= =?UTF-8?q?must=20not=20ride=20out=20on=20this=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `git merge origin/main` brought in #4789 (kernel PackageDependencySchema -> ResolvedPackageDependencySchema, dual-source C7), which touches the same three generated artifacts this branch does. The merge reported NO conflicts and left NO conflict markers, and it was wrong in all three: api-surface.json re-added `PackageDependency (type)` + `PackageDependencySchema (const)` and dropped `ResolvedPackageDependency(Schema)` authorable-surface.json re-added the 4 `kernel/PackageDependency:*` lines and dropped the 4 `kernel/ResolvedPackageDependency:*` ones json-schema.manifest.json re-added `kernel/PackageDependency` and dropped `kernel/ResolvedPackageDependency` json-schema.manifest.json is routed to the `merge=os-regen` driver, which defers rather than regenerates, so nothing textual signals this — the C17/C10 silent revert, reproduced. `git merge-tree` does not run external merge drivers and so cannot see it either. Resolved the way #4783 did, without any textual merge: reset all three (plus api-surface-signatures.json) to origin/main, re-applied ONLY this PR's own deletion to the two hand-ratcheted files, then re-ran the generators over the merged source. The 22 authorable-surface lines and 4 manifest keys are once again deliberate deletions, now anchored at merge base ffab8033b, which gen:schema re-proved under #4650 route 3 ("def no longer emitted by this build"). Delta vs origin/main is now pure deletion in all three, with #4789's rename intact: api-surface.json -8 (4 const + 4 type), 0 insertions authorable-surface.json -22 lines, 0 insertions json-schema.manifest.json -4 keys, 0 insertions check:dual-source-exports reports 0 accepted dual-source (#4789's shrink to [] survives; this branch does not carry it back to 2). renamed-defs.ts is byte-identical to main and keeps all 6 entries including #4789's. Co-Authored-By: Claude --- packages/spec/api-surface.json | 4 ++-- packages/spec/authorable-surface.json | 8 ++++---- packages/spec/json-schema.manifest.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 428602baee..da9f841cc9 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -1657,12 +1657,10 @@ "PackageArtifact (type)", "PackageArtifactInput (type)", "PackageArtifactSchema (const)", - "PackageDependency (type)", "PackageDependencyConflict (type)", "PackageDependencyConflictSchema (const)", "PackageDependencyResolutionResult (type)", "PackageDependencyResolutionResultSchema (const)", - "PackageDependencySchema (const)", "PackageStatus (type)", "PackageStatusEnum (const)", "PermissionAction (type)", @@ -1790,6 +1788,8 @@ "RequiredActionSchema (const)", "ResolvedDependency (type)", "ResolvedDependencySchema (const)", + "ResolvedPackageDependency (type)", + "ResolvedPackageDependencySchema (const)", "ResourceType (type)", "ResourceTypeSchema (const)", "RollbackPackageRequest (type)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index 59d240195a..795acd2521 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -4679,10 +4679,6 @@ "kernel/PackageArtifact:signature", "kernel/PackageArtifact:size", "kernel/PackageArtifact:version", - "kernel/PackageDependency:name", - "kernel/PackageDependency:resolvedVersion", - "kernel/PackageDependency:type", - "kernel/PackageDependency:versionConstraint", "kernel/PackageDependencyConflict:conflicts", "kernel/PackageDependencyConflict:package", "kernel/PackageDependencyConflict:resolution", @@ -4998,6 +4994,10 @@ "kernel/ResolvedDependency:requiredRange", "kernel/ResolvedDependency:resolvedVersion", "kernel/ResolvedDependency:status", + "kernel/ResolvedPackageDependency:name", + "kernel/ResolvedPackageDependency:resolvedVersion", + "kernel/ResolvedPackageDependency:type", + "kernel/ResolvedPackageDependency:versionConstraint", "kernel/RollbackPackageRequest:packageId", "kernel/RollbackPackageRequest:rollbackCustomizations", "kernel/RollbackPackageRequest:snapshotId", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 9e72344cce..a2c42c608d 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -980,7 +980,6 @@ "kernel/OpsFilePath", "kernel/OpsPluginStructure", "kernel/PackageArtifact", - "kernel/PackageDependency", "kernel/PackageDependencyConflict", "kernel/PackageDependencyResolutionResult", "kernel/PackageStatusEnum", @@ -1039,6 +1038,7 @@ "kernel/RealTimeNotificationConfig", "kernel/RequiredAction", "kernel/ResolvedDependency", + "kernel/ResolvedPackageDependency", "kernel/ResourceType", "kernel/RollbackPackageRequest", "kernel/RollbackPackageResponse",