diff --git a/.changeset/24923-sys-notification-subscription-retired.md b/.changeset/24923-sys-notification-subscription-retired.md new file mode 100644 index 00000000000..434690037bd --- /dev/null +++ b/.changeset/24923-sys-notification-subscription-retired.md @@ -0,0 +1,25 @@ +--- +'@objectstack/service-messaging': minor +'@objectstack/spec': minor +'@objectstack/cli': minor +'@objectstack/platform-objects': patch +--- + +feat(service-messaging,spec,cli)!: `sys_notification_subscription` retires — no reader ever expanded a subscription into recipients (ADR-0030 L3, amended) + +Clause-②: yes (narrowing) + + + +**BREAKING**, shipped as `minor` under the repo's launch-window convention for breaking changes. It goes into 18.x because its ADR-0087 semantic entry, `sys-notification-subscription-retired`, carries the narrowing. + +ADR-0030 described `sys_notification_subscription` as Layer-3 audience expansion: "who is subscribed to a topic". That expansion was never wired. Every producer names its recipients explicitly (`EmitInput.audience` is required: a flow notification node's recipients, an approval's approvers, a mention's users), no audience member asks for subscribers, and nothing reads the table's rows. So an administrator could author subscriptions in Setup that made nobody receive anything. The maintainer ruled to retire the object rather than build the expansion. The ruling supersedes the earlier decision to keep it with a code comment saying it was not wired, and ADR-0030 is amended to match. + +**What moves for consumers, FROM → TO.** + +- **The object.** FROM `import { NotificationSubscription } from '@objectstack/service-messaging'` TO nothing: delete the import. `MessagingServicePlugin` neither registers nor provisions the object any more, so the generic data door no longer serves it. +- **Setup navigation.** FROM a Notification Subscriptions item (`nav_notification_subscriptions`) in the Setup app's Configuration group TO no item. Notification Preferences and Notification Templates stay. The item's labels leave the platform app translations, and the object's keys leave service-messaging's generated object bundles. +- **The name.** `PLATFORM_OBJECTS_BY_PACKAGE['service-messaging']` (`@objectstack/spec/system`) lists six objects, and `isPlatformProvidedObjectName` answers `false` for `sys_notification_subscription`. A stack that names it (a lookup target, a flow trigger, a permission entry, a navigation item) is now flagged as naming an unknown object. Fix: remove the reference. To control who gets a notification, name the audience on the producer. To mute a topic for a user, write a `sys_notification_preference` row, which is unchanged. +- **`os migrate organization-ownership` (`@objectstack/cli`).** The inventory row for `sys_notification_subscription` moves FROM fate 3 (`attribution`: organization-less rows went to the Default Organization under `single`) TO fate 4 (`report`: rows listed by id, never attributed, never deleted). The table is exempt from NOT NULL, because no declaration anchors tenancy on its column any more. The row stays, so a database that still holds the table plans instead of being refused as carrying an uninventoried platform table. The plan's `inventoryDigest` changes: a plan file written by an earlier CLI no longer matches, and `--apply --plan-file` refuses it with `plan-digest-mismatch`. Re-run `os migrate organization-ownership --out PLAN`, review it, and apply that. + +**Existing databases.** The table is left in place untouched: no backfill, no reaper, no migrate command. Schema sync is additive and never drops a table, so a database an earlier release provisioned keeps `sys_notification_subscription` and any rows an administrator wrote, and nothing reads them. Export any row worth keeping. Dropping the table is the operator's call, by hand: `DROP TABLE sys_notification_subscription`. diff --git a/docs/adr/0030-notification-platform-convergence.md b/docs/adr/0030-notification-platform-convergence.md index a95df5bbf8a..5a5269b5bae 100644 --- a/docs/adr/0030-notification-platform-convergence.md +++ b/docs/adr/0030-notification-platform-convergence.md @@ -1,6 +1,6 @@ # ADR-0030 — Notification Platform Convergence (single ingress, layered pipeline) -**Status**: Accepted (2026-06-01) — **P0–P3b2 shipped** (P3b-2 digest collapse landed); cross-repo objectui cut-over remains. See [§ Implementation status & remaining work](#implementation-status--remaining-work). · **Amended** (2026-09-20, #16194 — P0's idempotent data migration `migrateSysNotificationToEvent` is **retired**: it had zero production callers and no way to be run, and both ways of giving it one — an `os migrate` sub-command and a boot-time invoker — were refused. Pre-ADR-0030 `sys_notification` rows are therefore **not** carried by the platform on this line. ⛔ Nothing else moves: single ingress, the layered object model and the remaining objectui cut-over read exactly as accepted.) +**Status**: Accepted (2026-06-01) — **P0–P3b2 shipped** (P3b-2 digest collapse landed); cross-repo objectui cut-over remains. See [§ Implementation status & remaining work](#implementation-status--remaining-work). · **Amended** (2026-09-20, #16194 — P0's idempotent data migration `migrateSysNotificationToEvent` is **retired**: it had zero production callers and no way to be run, and both ways of giving it one — an `os migrate` sub-command and a boot-time invoker — were refused. Pre-ADR-0030 `sys_notification` rows are therefore **not** carried by the platform on this line. ⛔ Nothing else moves: single ingress, the layered object model and the remaining objectui cut-over read exactly as accepted.) · **Amended** (2026-10-20, maintainer ruling D on #24770, recorded 6293376907 — the L3 `sys_notification_subscription` object is **retired**: the subscription→recipient expansion was never wired, every producer names an explicit audience (`EmitInput.audience` is required), and the Setup grid let an administrator author subscriptions that made nobody receive anything. The ruling supersedes #9807's keep-with-a-comment decision. The object, its registration, its Setup nav item and its translations leave; existing tables are left in place untouched (ADR-0087 semantic entry `sys-notification-subscription-retired`). L3 is now preference-only; a topic-subscription capability, if real demand appears, returns as a new decision with a who-may-subscribe-whom permission model. ⛔ Nothing else moves.) **Supersedes / refines**: [ADR-0012 — Notification Platform](./0012-notification-platform.md) (Draft) **Related**: [ADR-0019 — Approval as a Flow Node](./0019-approval-as-flow-node.md), [ADR-0022 — Connectors vs Messaging Channels](./0022-connectors-vs-messaging-channels.md) **Build spec**: [docs/design/notification-platform-convergence.md](../design/notification-platform-convergence.md) @@ -28,7 +28,7 @@ Do **not** merge the two tables into one. **Un-conflate** them back into the ADR | Layer | Object | Role | |---|---|---| | L2 Event | `sys_notification` *(re-modeled to event)* | one row per `emit`: `topic`, `payload`(json), `severity`, **`dedup_key`** (idempotency), `source_object/id`, `actor_id`, `created_at`. **No recipient, no read-state.** | -| L3 Resolve + Preference | `sys_notification_subscription`, `sys_notification_preference` | audience expansion (`role:` / `owner_of:record` / `team:` / explicit ids; **email→id resolved here**), user×topic×channel toggles, quiet-hours, digest; mandatory topics bypass | +| L3 Resolve + Preference | `sys_notification_preference` *(`sys_notification_subscription` retired 2026-10-20 — see the status line)* | audience resolution of the producer's explicit audience (`role:` / `owner_of:record` / `team:` / explicit ids; **email→id resolved here**), user×topic×channel toggles, quiet-hours, digest; mandatory topics bypass. ~~Topic-subscription audience expansion~~ — never wired, retired with its object. | | L4 Delivery (outbox) | `sys_notification_delivery` | one row per (event × recipient × channel); state machine `pending→sent→failed/dead`; retry/backoff/dedup; the durable spine | | L5 Materialize + Receipt | `sys_inbox_message` (in-app), `sys_email`, `sys_user_device` (push), connector dispatch (webhook/Slack); `sys_notification_receipt` | each channel renders delivery into a consumable artifact; **the bell reads `sys_inbox_message`**; read/clicked/dismissed live in `sys_notification_receipt` | | Cross-cutting | `sys_notification_template` (topic×channel×locale) | rendering, with generic fallback to `title`/`body` | @@ -119,9 +119,12 @@ configurable daily send-hour (windows flush at local midnight / Monday 00:00). `connector_action` Slack path already works today). - **`defineTopic()`** declarative topic catalog (Studio discoverability for topics / templates / preferences) — the low-code backbone. -- **Subscription-driven fan-out**: expand a topic's `sys_notification_subscription` +- ~~**Subscription-driven fan-out**: expand a topic's `sys_notification_subscription` principals when a producer emits without an explicit audience (object exists; - expansion not wired). + expansion not wired).~~ **Withdrawn (2026-10-20, ruling D on #24770)** — the object + is retired instead (status line): zero pull, every producer names its audience, + and the expansion would need a permission model nobody asked for. Existing + tables are left in place untouched, read by nothing. - **MJML** compilation for email (P3a treats `mjml` format as raw HTML). - Quiet-hours **tz fallback** to a `sys_user` timezone field (currently `quiet_hours.tz` → UTC). diff --git a/docs/qa/platform-checklist/FOLLOW-UPS.md b/docs/qa/platform-checklist/FOLLOW-UPS.md index ca58ce76528..ba9cbc4b25c 100644 --- a/docs/qa/platform-checklist/FOLLOW-UPS.md +++ b/docs/qa/platform-checklist/FOLLOW-UPS.md @@ -515,6 +515,11 @@ resolve-at-load only). **11-ledger** universe) and integration-system.datasource-admin-lifecycle (same "unledgered" claim, plus its "always-available static catalog" wording predating the #9391/#9593 uniform auth floor). +- **§9c's `sys_notification_subscription` Setup grid row is RESOLVED** — retired by + #24923 (maintainer ruling D on #24770): the object, its Setup nav item and its + translations left service-messaging, its name left the platform-object registry, and + the table stays in place untouched (ADR-0087 semantic entry + `sys-notification-subscription-retired`; ADR-0030 amended 2026-10-20). ### 9e. Blocked-item re-audit diff --git a/packages/cli/src/utils/organization-ownership-inventory.test.ts b/packages/cli/src/utils/organization-ownership-inventory.test.ts index 9706824063e..5f76295d142 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.test.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.test.ts @@ -92,6 +92,14 @@ const CENSUS_2026_08_31_EXAMPLES = [ */ const RETIRED_BEFORE_CENSUS = { sys_scim_provider: 'scim-provider-object-retired' } as const; +/** + * Platform objects the first census counted that have since retired by an + * ADR-0087 entry leaving their table in place untouched: the registry no + * longer names them, yet an upgraded database still holds the table. Frozen, + * keyed to the entry each row's citation names. + */ +const RETIRED_AFTER_FIRST_CENSUS = { sys_notification_subscription: 'sys-notification-subscription-retired' } as const; + /** * #14570 and #15086, read in: each population's table has its own fate, citing * the record that read it in (a runtime string carries no tracker number). @@ -143,6 +151,24 @@ describe('ADR-0131 D10 inventory — every censused object has one fate and a ci ).toEqual([...retired].sort()); }); + it('counted by the first census, retired since with the table left in place: fate 4, citing its ADR-0087 entry', () => { + const counted = [...CENSUS_2026_08_31_PLATFORM_WITH_COLUMN, ...CENSUS_2026_08_31_PLATFORM_WITHOUT_COLUMN] as readonly string[]; + const registered = new Set(Object.values(PLATFORM_OBJECTS_BY_PACKAGE).flat()); + for (const [object, adr0087Entry] of Object.entries(RETIRED_AFTER_FIRST_CENSUS)) { + // The population's premise: the first census counted it and the tree no longer declares it. + expect(counted, object).toContain(object); + expect(registered.has(object), object).toBe(false); + // The entry the citation names exists, and its ruling is what licenses fate 4: the table stays untouched. + const semantic = Object.values(MIGRATIONS_BY_MAJOR).flatMap((step) => step.semantic).find((s) => s.id === adr0087Entry); + expect(semantic?.acceptanceCriteria, adr0087Entry).toContain('left in place untouched'); + const entry = inventoryEntryFor(object)!; + expect(entry, object).toMatchObject({ census: ['platform-census-2026-08-31'], fate: 'report' }); + expect(entry.citation, object).toContain(adr0087Entry); + expect(entry.citation, object).not.toMatch(/#\d/); + expect(entry.notNullExempt, object).toBeTruthy(); + } + }); + it('the cloud supplement: listed as cloud-carried, fate 4, never a guessed fate', () => { expect(CLOUD_PROVIDED_OBJECT_NAMES.length).toBeGreaterThan(0); expect(unlisted(CLOUD_PROVIDED_OBJECT_NAMES)).toEqual([]); diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index f7368d5b92a..5158881c418 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -17,8 +17,9 @@ * * - `platform-census-2026-08-31` — the 84 platform objects of the first * #13564 census (59 carrying the column, 25 not), measured at - * `00d8f6541b`. Three of them have since left the tree - * (`sys_view_definition`, `sys_saved_report`, `sys_report_schedule`); their + * `00d8f6541b`. Four of them have since left the tree + * (`sys_view_definition`, `sys_saved_report`, `sys_report_schedule`, + * `sys_notification_subscription`); their * physical tables can still exist on an upgraded database, so they keep a * row. * - `platform-census-main` — objects the gated census on `main` lists and @@ -473,6 +474,21 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ 'the column, and a connection scoped to no organization was a legitimate row', }, + // ── Platform: counted by the first census, retired since, table left in place (fate 4) ── + { + object: 'sys_notification_subscription', + census: P1_ONLY, + fate: 'report', + citation: + 'ADR-0087 semantic entry sys-notification-subscription-retired (packages/spec/src/migrations/entries/semantic/' + + '18.sys-notification-subscription-retired.ts): the ADR-0030 Layer-3 topic subscription no reader ever expanded ' + + 'into recipients, retired by maintainer ruling with its table left in place untouched. A physical table left ' + + 'by an older release is listed, its NULL rows reported, never attributed and never deleted', + notNullExempt: + 'the object is retired and its table is left in place untouched, by ruling: no declaration anchors tenancy on ' + + 'the column any more, and nothing reads its rows', + }, + // ── Platform: attribution through a parent anchor (fate 3) ── { object: 'sys_file', @@ -503,7 +519,7 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ { object: 'sys_notification', census: P1, fate: 'attribution', citation: `${D7} (the notification family is recipient-anchored); the source record carries the organization`, anchors: [subject('source_object', 'source_id')] }, { object: 'sys_notification_delivery', census: P1, fate: 'attribution', citation: `${C7_FATE_3}; the delivery's notification carries the organization`, anchors: [parent('notification_id', 'sys_notification')] }, { object: 'sys_notification_receipt', census: P1, fate: 'attribution', citation: `${D7} (recipient-anchored); the receipt's notification carries the organization`, anchors: [parent('notification_id', 'sys_notification')] }, - ...['sys_notification_preference', 'sys_notification_subscription', 'sys_inbox_message'].map( + ...['sys_notification_preference', 'sys_inbox_message'].map( (object): InventoryEntry => ({ object, census: P1, fate: 'attribution', citation: `${D7} (recipient-anchored); no anchor names the organization; ${D10_SINGLE}`, anchors: [] }), ), { diff --git a/packages/cli/src/utils/organization-ownership-plan.integration.test.ts b/packages/cli/src/utils/organization-ownership-plan.integration.test.ts index 949923f8bd2..0daaff3041a 100644 --- a/packages/cli/src/utils/organization-ownership-plan.integration.test.ts +++ b/packages/cli/src/utils/organization-ownership-plan.integration.test.ts @@ -283,6 +283,48 @@ describe('ADR-0131 D10 preflight — the per-table plan', () => { expect(r).toMatchObject({ reason: 'uninventoried-platform-table', table: 'sys_scim_provider' }); }); + it('a database still holding the retired sys_notification_subscription plans, listing it as fate 4', async () => { + // The table every database that booted before the retirement still holds, + // in its last declared shape (the 18.0.0 upgrade-harness fixture's DDL). + db.exec(` + CREATE TABLE sys_notification_subscription ( + id TEXT PRIMARY KEY, created_at TEXT, updated_at TEXT, organization_id TEXT, + created_by TEXT, updated_by TEXT, topic TEXT, principal TEXT, enabled INTEGER + ); + INSERT INTO sys_notification_subscription VALUES + ('sub_1', '2026-07-01', '2026-07-01', 'org_b', 'usr_1', 'usr_1', 'billing.invoice', 'role:admin', 1), + ('sub_2', '2026-07-02', '2026-07-02', NULL, NULL, NULL, 'crm.lead', 'user:usr_1', 1); + `); + + for (const posture of ['isolated', 'single']) { + const sub = table(await plan(posture), 'sys_notification_subscription'); + expect(sub, posture).toMatchObject({ + source: 'inventory', + fate: 'report', + physical: 'present', + organizationColumn: 'present', + rows: { total: 2, organizationNull: 1, stamped: 1 }, + fateCounts: { reportedRows: 1 }, + // Reported, never attributed — not even to the Default Organization under single. + unattributable: [{ id: 'sub_2', reason: 'fate 4: reported, never guessed' }], + notNull: { willReceive: false, reason: expect.stringContaining('left in place untouched') }, + }); + expect(sub.census, posture).toEqual(['platform-census-2026-08-31']); + expect(sub.citation, posture).toContain('sys-notification-subscription-retired'); + } + + // Control: without its row the same database is refused, by name. + const r = await refusal( + buildOrganizationOwnershipPlan({ + reader: reader(), + posture: 'isolated', + database: file, + inventory: ORGANIZATION_OWNERSHIP_INVENTORY.filter((e) => e.object !== 'sys_notification_subscription'), + }), + ); + expect(r).toMatchObject({ reason: 'uninventoried-platform-table', table: 'sys_notification_subscription' }); + }); + it('under single with no Default Organization (two organizations, none of them default), nothing is assigned and the reason says why', async () => { db.exec("UPDATE sys_organization SET slug = 'renamed' WHERE id = 'org_default'"); const p = await plan('single'); diff --git a/packages/platform-objects/src/apps/translations/en.ts b/packages/platform-objects/src/apps/translations/en.ts index 7f2626fce4f..3f7f4d92424 100644 --- a/packages/platform-objects/src/apps/translations/en.ts +++ b/packages/platform-objects/src/apps/translations/en.ts @@ -130,7 +130,6 @@ export const en: TranslationData = { nav_settings_feature_flags: { label: 'Feature Flags' }, // Notifications (contributed by @objectstack/service-messaging) nav_notification_preferences: { label: 'Notification Preferences' }, - nav_notification_subscriptions: { label: 'Notification Subscriptions' }, nav_notification_templates: { label: 'Notification Templates' }, // Diagnostics diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts index 20c00a60ecb..0e8d9999521 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts @@ -114,7 +114,6 @@ export const esESSourceHashes: Readonly> = { 'apps.setup.navigation.nav_settings_knowledge.label': '140348539a50d9d5', 'apps.setup.navigation.nav_settings_feature_flags.label': '4e7e9e879ea1bcc3', 'apps.setup.navigation.nav_notification_preferences.label': '8869f324c1bd545a', - 'apps.setup.navigation.nav_notification_subscriptions.label': '907cb60dda7b39c8', 'apps.setup.navigation.nav_notification_templates.label': '9e270c87ef2578df', 'apps.setup.navigation.nav_sessions.label': '673bde6e0c8a3ef9', 'apps.setup.navigation.nav_audit_logs.label': 'b2fad40733012b18', diff --git a/packages/platform-objects/src/apps/translations/es-ES.ts b/packages/platform-objects/src/apps/translations/es-ES.ts index 91760e4668d..69bcf3a2741 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.ts @@ -90,7 +90,6 @@ export const esES: TranslationData = { nav_settings_knowledge: { label: 'Conocimiento' }, nav_settings_feature_flags: { label: 'Indicadores de Funcionalidad' }, nav_notification_preferences: { label: 'Preferencias de Notificación' }, - nav_notification_subscriptions: { label: 'Suscripciones de Notificación' }, nav_notification_templates: { label: 'Plantillas de Notificación' }, nav_sessions: { label: 'Sesiones' }, diff --git a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts index 734adc15e22..d5baa430589 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts @@ -114,7 +114,6 @@ export const jaJPSourceHashes: Readonly> = { 'apps.setup.navigation.nav_settings_knowledge.label': '140348539a50d9d5', 'apps.setup.navigation.nav_settings_feature_flags.label': '4e7e9e879ea1bcc3', 'apps.setup.navigation.nav_notification_preferences.label': '8869f324c1bd545a', - 'apps.setup.navigation.nav_notification_subscriptions.label': '907cb60dda7b39c8', 'apps.setup.navigation.nav_notification_templates.label': '9e270c87ef2578df', 'apps.setup.navigation.nav_sessions.label': '673bde6e0c8a3ef9', 'apps.setup.navigation.nav_audit_logs.label': 'b2fad40733012b18', diff --git a/packages/platform-objects/src/apps/translations/ja-JP.ts b/packages/platform-objects/src/apps/translations/ja-JP.ts index 92f0c29b9c9..6177d2c0290 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.ts @@ -90,7 +90,6 @@ export const jaJP: TranslationData = { nav_settings_knowledge: { label: 'ナレッジ' }, nav_settings_feature_flags: { label: '機能フラグ' }, nav_notification_preferences: { label: '通知設定' }, - nav_notification_subscriptions: { label: '通知購読' }, nav_notification_templates: { label: '通知テンプレート' }, nav_sessions: { label: 'セッション' }, diff --git a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts index f8e0905a5aa..e39b4e77f93 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.source-hashes.ts @@ -114,7 +114,6 @@ export const zhCNSourceHashes: Readonly> = { 'apps.setup.navigation.nav_settings_knowledge.label': '140348539a50d9d5', 'apps.setup.navigation.nav_settings_feature_flags.label': '4e7e9e879ea1bcc3', 'apps.setup.navigation.nav_notification_preferences.label': '8869f324c1bd545a', - 'apps.setup.navigation.nav_notification_subscriptions.label': '907cb60dda7b39c8', 'apps.setup.navigation.nav_notification_templates.label': '9e270c87ef2578df', 'apps.setup.navigation.nav_sessions.label': '673bde6e0c8a3ef9', 'apps.setup.navigation.nav_audit_logs.label': 'b2fad40733012b18', diff --git a/packages/platform-objects/src/apps/translations/zh-CN.ts b/packages/platform-objects/src/apps/translations/zh-CN.ts index f15ce3d9348..b1fe4c4f0bc 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.ts @@ -97,7 +97,6 @@ export const zhCN: TranslationData = { nav_settings_knowledge: { label: '知识库' }, nav_settings_feature_flags: { label: '功能开关' }, nav_notification_preferences: { label: '通知偏好' }, - nav_notification_subscriptions: { label: '通知订阅' }, nav_notification_templates: { label: '通知模板' }, nav_sessions: { label: '会话' }, diff --git a/packages/services/service-messaging/scripts/i18n-extract.config.ts b/packages/services/service-messaging/scripts/i18n-extract.config.ts index ba53299533d..447aa408b15 100644 --- a/packages/services/service-messaging/scripts/i18n-extract.config.ts +++ b/packages/services/service-messaging/scripts/i18n-extract.config.ts @@ -41,7 +41,6 @@ import { NotificationReceipt, NotificationDelivery, NotificationPreference, - NotificationSubscription, NotificationTemplate, HttpDelivery, } from '../src/objects/index.js'; @@ -70,7 +69,6 @@ const config: ObjectStackDefinition = defineStack({ NotificationReceipt, NotificationDelivery, NotificationPreference, - NotificationSubscription, NotificationTemplate, HttpDelivery, ] as any, diff --git a/packages/services/service-messaging/src/index.ts b/packages/services/service-messaging/src/index.ts index a6045a9d051..83d36eff707 100644 --- a/packages/services/service-messaging/src/index.ts +++ b/packages/services/service-messaging/src/index.ts @@ -190,7 +190,6 @@ export { NotificationReceipt, NotificationDelivery, NotificationPreference, - NotificationSubscription, NotificationTemplate, HttpDelivery, SYS_HTTP_DELIVERY, diff --git a/packages/services/service-messaging/src/messaging-service-plugin.test.ts b/packages/services/service-messaging/src/messaging-service-plugin.test.ts index 285d2fdcaad..25c2e593d32 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.test.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.test.ts @@ -227,7 +227,7 @@ describe('MessagingServicePlugin — system table provisioning', () => { describe('MessagingServicePlugin — boot provisioning honours skipSchemaSync (#22984)', () => { const OBJECTS = [ 'sys_notification', 'sys_inbox_message', 'sys_notification_receipt', 'sys_notification_delivery', - 'sys_notification_preference', 'sys_notification_subscription', 'sys_notification_template', 'sys_http_delivery', + 'sys_notification_preference', 'sys_notification_template', 'sys_http_delivery', ]; const SKIP = 'NOT provisioned at boot'; @@ -354,7 +354,7 @@ describe('MessagingServicePlugin — boot provisioning honours skipSchemaSync (# describe('MessagingServicePlugin — a failed provision is an error, once per object (#22985)', () => { const OBJECTS = [ 'sys_notification', 'sys_inbox_message', 'sys_notification_receipt', 'sys_notification_delivery', - 'sys_notification_preference', 'sys_notification_subscription', 'sys_notification_template', 'sys_http_delivery', + 'sys_notification_preference', 'sys_notification_template', 'sys_http_delivery', ]; /** Every DDL succeeds except the named object's, which the driver refuses. */ diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index 83e111fe594..80a88527867 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -18,7 +18,6 @@ import { NotificationReceipt, NotificationDelivery, NotificationPreference, - NotificationSubscription, NotificationTemplate, HttpDelivery, } from './objects/index.js'; @@ -276,9 +275,13 @@ export class MessagingServicePlugin implements Plugin { ctx.registerService('notification', service); // Register the messaging objects so their rows can be written. The - // preference/subscription objects (ADR-0030 P2) are Studio-configurable, - // so contribute them to the Setup app's Configuration slot (ADR-0029 D7) - // — they appear in nav only when this plugin is installed. + // preference and template objects are Studio-configurable, so + // contribute them to the Setup app's Configuration slot (ADR-0029 D7) + // — they appear in nav only when this plugin is installed. ADR-0030's + // `sys_notification_subscription` is retired (ADR-0087 semantic entry + // `sys-notification-subscription-retired`): no reader ever expanded a + // subscription into recipients, so it is neither registered nor + // offered in nav; an existing database keeps the table untouched. ctx.getService<{ register(m: unknown): void }>('manifest').register({ id: 'com.objectstack.service.messaging', name: 'Messaging Service', @@ -291,7 +294,6 @@ export class MessagingServicePlugin implements Plugin { NotificationReceipt, NotificationDelivery, NotificationPreference, - NotificationSubscription, NotificationTemplate, HttpDelivery, ], @@ -302,7 +304,6 @@ export class MessagingServicePlugin implements Plugin { priority: 120, items: [ { id: 'nav_notification_preferences', type: 'object', label: 'Notification Preferences', objectName: 'sys_notification_preference', icon: 'bell-ring', requiresObject: 'sys_notification_preference' }, - { id: 'nav_notification_subscriptions', type: 'object', label: 'Notification Subscriptions', objectName: 'sys_notification_subscription', icon: 'rss', requiresObject: 'sys_notification_subscription' }, { id: 'nav_notification_templates', type: 'object', label: 'Notification Templates', objectName: 'sys_notification_template', icon: 'file-text', requiresObject: 'sys_notification_template' }, ], }, @@ -514,7 +515,6 @@ export class MessagingServicePlugin implements Plugin { NotificationReceipt, NotificationDelivery, NotificationPreference, - NotificationSubscription, NotificationTemplate, HttpDelivery, ]; diff --git a/packages/services/service-messaging/src/objects/admin-copy-vocabulary.test.ts b/packages/services/service-messaging/src/objects/admin-copy-vocabulary.test.ts index 59b7f803aac..5e0f6e5012e 100644 --- a/packages/services/service-messaging/src/objects/admin-copy-vocabulary.test.ts +++ b/packages/services/service-messaging/src/objects/admin-copy-vocabulary.test.ts @@ -4,8 +4,8 @@ * Admin-facing copy of the messaging objects carries no design-record * vocabulary. * - * Setup opens four of these objects from its nav (Notification Preferences, - * Subscriptions and Templates, and HTTP Deliveries under Integrations); the + * Setup opens three of these objects from its nav (Notification Preferences + * and Templates, and HTTP Deliveries under Integrations); the * sentence under each page title is the object's `description`, a field's * help tooltip is the field's `description`, and the generated bundles carry * both into every locale. An ADR number, a decision id ("D2"), a @@ -97,7 +97,7 @@ describe('messaging objects: admin-facing copy carries no design-record vocabula const objects: ObjectLike[] = (Object.values(MessagingObjects) as unknown[]).filter(isObjectSchema); const names = objects.map((o) => o.name); for (const name of [ - 'sys_notification_preference', 'sys_notification_subscription', 'sys_notification_template', + 'sys_notification_preference', 'sys_notification_template', 'sys_http_delivery', 'sys_notification_delivery', ]) { expect(names, `${name} is in the population`).toContain(name); diff --git a/packages/services/service-messaging/src/objects/index.ts b/packages/services/service-messaging/src/objects/index.ts index fd92c2f7c42..fe72cde6f0e 100644 --- a/packages/services/service-messaging/src/objects/index.ts +++ b/packages/services/service-messaging/src/objects/index.ts @@ -4,6 +4,5 @@ export { InboxMessage } from './inbox-message.object.js'; export { NotificationReceipt } from './notification-receipt.object.js'; export { NotificationDelivery } from './notification-delivery.object.js'; export { NotificationPreference } from './notification-preference.object.js'; -export { NotificationSubscription } from './notification-subscription.object.js'; export { NotificationTemplate } from './notification-template.object.js'; export { HttpDelivery, SYS_HTTP_DELIVERY } from './http-delivery.object.js'; diff --git a/packages/services/service-messaging/src/objects/managed-by-system-data.test.ts b/packages/services/service-messaging/src/objects/managed-by-system-data.test.ts index f891a0fbe3b..5ab00cd9da0 100644 --- a/packages/services/service-messaging/src/objects/managed-by-system-data.test.ts +++ b/packages/services/service-messaging/src/objects/managed-by-system-data.test.ts @@ -5,7 +5,7 @@ * `'system-data'` equivalence pin. See the sibling file in `plugin-security` for * the full rationale; the contract asserted here is identical. * - * These three are the clearest members of the bucket the rename creates: the + * These two are the clearest members of the bucket the rename creates: the * SCHEMA ships with the platform (an admin cannot add a column to * `sys_notification_template`), while the DATA is authored by an admin — or, for * `sys_notification_preference`, by the end user muting their own topics — through @@ -16,7 +16,6 @@ import { describe, expect, it } from 'vitest'; import { resolveCrudAffordances } from '@objectstack/spec/data'; import { NotificationTemplate } from './notification-template.object.js'; -import { NotificationSubscription } from './notification-subscription.object.js'; import { NotificationPreference } from './notification-preference.object.js'; const V16_EXPECTED = { create: true, import: false, edit: true, delete: true, exportCsv: true }; @@ -47,7 +46,6 @@ const asV16 = (obj: { userActions?: unknown }) => ({ const OBJECTS = [ ['sys_notification_template', NotificationTemplate], - ['sys_notification_subscription', NotificationSubscription], ['sys_notification_preference', NotificationPreference], ] as const; diff --git a/packages/services/service-messaging/src/objects/notification-display-title.test.ts b/packages/services/service-messaging/src/objects/notification-display-title.test.ts index aaae125e24c..92a01ce0943 100644 --- a/packages/services/service-messaging/src/objects/notification-display-title.test.ts +++ b/packages/services/service-messaging/src/objects/notification-display-title.test.ts @@ -1,10 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * The record title of the four notification objects that declared a + * The record title of the notification objects that declared a * `titleFormat` and no title pointer (#20044): `sys_notification_delivery`, - * `sys_notification_preference`, `sys_notification_subscription` and - * `sys_notification_receipt`. + * `sys_notification_preference` and `sys_notification_receipt` (the fourth, + * `sys_notification_subscription`, is retired). * * ADR-0079 resolves a record's title as `nameField ?? displayNameField ?? * derivation`, and an explicit `nameField` takes precedence over the @@ -15,7 +15,7 @@ * A renderer honouring the order therefore drew the raw id as the record * page's H1. * - * The three composite titles now point at `display_title`, a text formula over + * The two composite titles now point at `display_title`, a text formula over * the columns `titleFormat` names. `sys_notification_receipt`'s title is one * column (`{state}`), so its `nameField` names that column directly — the * describe's own migration for a single-field title. An explicit pointer is @@ -49,7 +49,6 @@ import { resolveDisplayField } from '@objectstack/spec/data'; import { NotificationDelivery } from './notification-delivery.object.js'; import { NotificationPreference } from './notification-preference.object.js'; import { NotificationReceipt } from './notification-receipt.object.js'; -import { NotificationSubscription } from './notification-subscription.object.js'; const SYS = { context: { isSystem: true } } as any; const NOW = new Date('2026-09-25T00:00:00Z'); @@ -93,14 +92,6 @@ const CASES: Case[] = [ // `topic` / `channel` default to the '*' wildcard (ADR-0030 Layer 3). defaulted: { topic: 'usr_alice · * · email', channel: 'usr_alice · billing.invoice · *' }, }, - { - schema: NotificationSubscription, - pointer: 'display_title', - row: () => ({ topic: 'billing.invoice', principal: 'role:sales_manager', created_at: NOW }), - title: 'role:sales_manager · billing.invoice', - refused: ['principal', 'topic'], - defaulted: {}, - }, { schema: NotificationReceipt, pointer: 'state', diff --git a/packages/services/service-messaging/src/objects/notification-keyed-text-bounds.test.ts b/packages/services/service-messaging/src/objects/notification-keyed-text-bounds.test.ts index 9d04b2deec6..b93e75582ba 100644 --- a/packages/services/service-messaging/src/objects/notification-keyed-text-bounds.test.ts +++ b/packages/services/service-messaging/src/objects/notification-keyed-text-bounds.test.ts @@ -18,7 +18,6 @@ import { SysEmailTemplate, SysNotification } from '@objectstack/platform-objects import { NotificationDelivery } from './notification-delivery.object.js'; import { NotificationPreference } from './notification-preference.object.js'; import { NotificationReceipt } from './notification-receipt.object.js'; -import { NotificationSubscription } from './notification-subscription.object.js'; import { NotificationTemplate } from './notification-template.object.js'; /** @@ -49,7 +48,6 @@ describe('sys_notification_* keyed-text bounds carry their producers’ widths ( // Vacuity control: the producer itself must be a real declared bound. expect(typeof eventTopic).toBe('number'); expect(bound(NotificationPreference, 'topic')).toBe(eventTopic); - expect(bound(NotificationSubscription, 'topic')).toBe(eventTopic); expect(bound(NotificationTemplate, 'topic')).toBe(eventTopic); }); @@ -75,12 +73,4 @@ describe('sys_notification_* keyed-text bounds carry their producers’ widths ( expect(typeof emailLocale).toBe('number'); expect(bound(NotificationTemplate, 'locale')).toBe(emailLocale); }); - - it('principal covers the widest declared selector arm: owner_of::', () => { - // 'owner_of:' (9) + object API name (<= 255, storage-owned by - // `sys_metadata.name`, commit 3a04b0125) + ':' (1) + record id (<= 255, the physical - // id width above). Every other arm of the grammar commit 44738f7af documented - // is narrower (an email is <= 254; 'user:' + id is 260). - expect(bound(NotificationSubscription, 'principal')).toBe(9 + 255 + 1 + PHYSICAL_ID_WIDTH); - }); }); diff --git a/packages/services/service-messaging/src/objects/notification-subscription.object.ts b/packages/services/service-messaging/src/objects/notification-subscription.object.ts deleted file mode 100644 index e89233dc689..00000000000 --- a/packages/services/service-messaging/src/objects/notification-subscription.object.ts +++ /dev/null @@ -1,170 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { ObjectSchema, Field } from '@objectstack/spec/data'; -import { F } from '@objectstack/spec'; - -/** - * `sys_notification_subscription` — who is subscribed to a topic (ADR-0030 - * Layer 3). - * - * Declares standing interest in a `topic` by a `principal` (see that field for - * the accepted selector forms). - * - * ⚠️ [commit 44738f7af] The subscription→recipient expansion is **NOT WIRED in this - * repo**: `AudienceSpec` (`messaging-service.ts`) has no `'subscribers'` - * member, `EmitInput.audience` is REQUIRED, and no `RecipientResolver` branch - * expands a topic's subscriptions — so nothing here reads these rows at - * runtime. Every delivery today comes from the explicit `audience` a producer - * passes to `emit()`, which makes the Setup "Notification Subscriptions" grid - * admin-authored data, NOT a live routing control. The ADR-0030 Layer-3 - * expansion is future work, deferred on measured zero pull; ADR-0012/0030 - * describe that target state, not today's behaviour. - * - * Distinct from `sys_notification_preference`: a subscription says "include me - * for this topic"; a preference says "but mute it on this channel". - * - * Belongs to `service-messaging`. - */ -export const NotificationSubscription = ObjectSchema.create({ - name: 'sys_notification_subscription', - label: 'Notification Subscription', - pluralLabel: 'Notification Subscriptions', - icon: 'rss', - isSystem: true, - // [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: - // authored from the Setup "Notification Subscriptions" grid. The bucket - // default is full CRUD, so no `userActions` block is needed. - managedBy: 'system-data', - description: 'Standing subscription of a principal (role/team/user) to a notification topic.', - // [ADR-0079] The record title is `display_title`, a text formula over the - // same two columns `titleFormat` names. With no pointer declared, the - // registry's designate-only pass stamped `nameField: 'id'` (the first - // title-eligible field), so a renderer honouring ADR-0079's order (an - // explicit `nameField` wins over `titleFormat`) drew the raw id as the - // record page's H1. `titleFormat` stays for renderers that still read it - // first; `notification-display-title.test.ts` holds the two to the same text. - displayNameField: 'display_title', - nameField: 'display_title', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField) - titleFormat: '{principal} · {topic}', - highlightFields: ['topic', 'principal', 'enabled', 'created_at'], - - // The one list view this object ships, declared for its `emptyState`: with - // no view at all the console synthesizes an "All records" tab and draws - // the generic "Nothing here yet" under it, which says nothing about what - // belongs on this page. `columns: []` declares no projection on purpose — - // the spec's "left to the renderer" form — so the grid keeps exactly the - // columns it derived from `highlightFields` before this view existed, and - // the view adds no filter, sort or page size of its own. - listViews: { - all: { - type: 'grid', - name: 'all', - label: 'All', - data: { provider: 'object', object: 'sys_notification_subscription' }, - columns: [], - emptyState: { - title: 'No subscriptions yet', - message: 'A subscription records that a role, a team or a user follows a notification topic.', - }, - }, - }, - - fields: { - id: Field.text({ label: 'Subscription ID', required: true, readonly: true }), - - // [ADR-0079] The record title (`nameField` above). A formula is computed - // on read and has no stored column. `principal` and `topic` are both - // required, so the expression needs no null guard. - display_title: Field.formula({ - label: 'Title', - returnType: 'text', - expression: F`record.principal + ' · ' + record.topic`, - description: 'Record title: the subscribing principal and the topic (computed on read)', - }), - - topic: Field.text({ - label: 'Topic', - required: true, - searchable: true, - // [commit e4902d2b9] Sibling-declaration bound (route A, ruling 2026-08-24): subscribed - // topics are matched against the event's `sys_notification.topic` - // (maxLength: 200 there), so a longer stored topic could never - // match an event the platform can store. - maxLength: 200, - description: 'Notification topic this principal subscribes to.', - }), - - principal: Field.text({ - label: 'Principal', - required: true, - searchable: true, - // [commit 44738f7af] Kept in step with what `RecipientResolver.resolveOne()` - // really accepts for a string spec, so this does not under-describe the day - // the expansion above is wired: an email-shaped value is matched against - // `sys_user` (kept verbatim when no user matches), and anything otherwise - // unrecognized falls through as a bare user id. - // [commit e4902d2b9] Derived bound (route A, ruling 2026-08-24) over the declared - // selector grammar: the widest arm is `owner_of:object:id` = - // 'owner_of:' (9) + object API name (≤ 255 — storage-owned by - // `sys_metadata.name`, maxLength: 255, commit 3a04b0125) + ':' (1) + record - // id (≤ 255 — the physical id column, varchar(255)) = 520. Every - // other arm is narrower: an email ≤ 254 (RFC 5321) and - // `sys_user.email` is a string-family varchar(255); 'user:' + id - // = 260; 'role:'/'team:' + a per-org name. - maxLength: 520, - description: - "Subscriber selector: 'role:x' | 'team:x' | 'user:id' | 'owner_of:object:id' | an email | a bare user id.", - }), - - enabled: Field.boolean({ - label: 'Enabled', - defaultValue: true, - description: 'When false, the subscription is inactive.', - }), - - created_at: Field.datetime({ label: 'Created At', readonly: true }), - }, - - indexes: [ - // [#8577] Scope spelled EXPLICITLY (ADR-0120 D1). On a DECLARED index - // bare `unique: true` is the positional spelling of `'global'` — the - // listed columns VERBATIM — so `(topic, principal)` was an - // installation-wide key on a tenant-scoped object. Direct sibling of - // `sys_notification_preference` (#8554): same package, same directory, - // same ADR-0030 Layer 3, same archetype. - // Measured live before the fix (real SqlDriver, better-sqlite3, - // OS_TENANCY_POSTURE=isolated, this shipped declaration): - // org_jia creates (billing.invoice, role:sales_manager) 201 / org_yi - // the SAME pair 409 UNIQUE_VIOLATION / org_yi (billing.invoice, - // role:only_yi) 201 / org_yi (crm.lead, role:sales_manager) 201 / - // org_yi (billing.invoice, user:u1) 201 / org_yi's own GET on the - // colliding pair 0 rows. - // - // ⚠️ [commit 2074b2651, correcting this note] `principal` names are per-organization: - // `role:x` resolves against `sys_member` (tenant-scoped org-membership - // rows — the org-administration tier that is the sole ADR-0090 D3 - // "role" exception) and `team:x` against `sys_team_member` (tenant-scoped - // via its `team_id` lookup into `sys_team`, itself per-organization) — - // per `RecipientResolver.resolveRole` / `.resolveTeam` - // (`recipient-resolver.ts`, the sole reader of this selector). NOT - // `sys_permission_set` / `sys_position` as an earlier version of this - // note claimed — this selector has no `permission_set:` or - // `position:` spelling at all; `sys_position` names business roles - // like `sales_manager` elsewhere on the platform, unrelated to - // `role:`/`team:` here. So `role:admin` denotes a DIFFERENT set of - // members in each organization (`sys_member.role` is the closed - // owner/admin/delegated_admin/member vocabulary — see - // `BUILTIN_MEMBERSHIP_ROLE_OPTIONS`) while colliding on one - // installation-wide key. And a user who belongs to two organizations - // could not subscribe to the same topic in both — the symptom #8323 - // measured on `sys_user_preference`. - // - // ⚠️ `managedBy: 'system-data'` is NOT a reason to exempt this object; - // the already-ruled `sys_user_preference` is `system-data` too. The - // ruling's phrase is ADMIN-AUTHORED CONTENT — the provenance of the - // rows (this object's own header: authored from the Setup - // "Notification Subscriptions" grid), not the management mode. - { fields: ['topic', 'principal'], unique: 'organization' }, - { fields: ['topic'] }, - ], -}); diff --git a/packages/services/service-messaging/src/objects/notification-subscription.organization-unique.test.ts b/packages/services/service-messaging/src/objects/notification-subscription.organization-unique.test.ts deleted file mode 100644 index a45214a56dc..00000000000 --- a/packages/services/service-messaging/src/objects/notification-subscription.organization-unique.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -import { describe, it, expect } from 'vitest'; -import { IndexSchema } from '@objectstack/spec/data'; -import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; -// Explicit `.js` extension: this package resolves under NodeNext, where an -// extensionless relative import does not typecheck (its sibling suites all -// spell it this way). -import { NotificationSubscription } from './notification-subscription.object.js'; - -/** - * #8577 — `sys_notification_subscription`'s declared uniqueness is - * organization-scoped. - * - * ## What the bare spelling cost - * - * A DECLARED index's `unique: true` is the positional spelling of `'global'` - * (the listed columns verbatim), so the composite `(topic, principal)` was an - * installation-wide key on a tenant-scoped object. Measured live on a real - * engine BEFORE the fix, driving this shipped declaration through `SqlDriver` - * under `OS_TENANCY_POSTURE=isolated`: - * - * ``` - * CREATE UNIQUE INDEX uniq_sys_notification_subscription_topic_principal - * on sys_notification_subscription (topic, principal) - * - * org_jia POST (billing.invoice, role:sales_manager) → 201 - * org_yi POST the SAME → 409 UNIQUE_VIOLATION - * org_yi POST an unused → 201 ← the control that makes it an ORACLE - * org_yi GET the key → total 0 ← refused by a row it cannot see - * ``` - * - * ## Why per-organization is the CORRECT boundary, not merely the safe one - * - * This is the direct sibling of `sys_notification_preference`, one of #8554's - * five: same package, same directory, same ADR-0030 Layer 3, same archetype, - * and its own header records that the rows are authored from the Setup - * "Notification Subscriptions" grid — admin-authored content by the ruling's - * own phrase. - * - * `principal` is `role:x` / `team:x` / `user:id` / a bare user id, and role and - * position names have been per-organization since #8461 / #8556 — so - * `role:sales_manager` denoted a DIFFERENT principal in each organization while - * colliding on one installation-wide key. The measured symptom is #8323's: a - * user who belongs to two organizations could not subscribe to the same topic - * in both. - * - * ⚠️ `managedBy: 'system-data'` is NOT a reason to exempt this object. The - * already-ruled `sys_user_preference` is `system-data` too; the ruling's phrase - * is ADMIN-AUTHORED CONTENT — the provenance of the ROWS, not the management - * mode of the object. - * - * ⚠️ The replacement index name is HASH-SUFFIXED - * (`uniq_sys_notification_subscription_799a483c`): its un-truncated form is 66 - * characters, past `INDEX_NAME_MAX = 60`. The card flagged only its sibling - * object as landing on that path; this one lands there too. Pinned driver-side. - * - * The materialized shape, the anti-vacuity twin (a SAME-organization duplicate - * must still be refused), and the migration of an installation that already - * carries the old index are pinned driver-side in - * `driver-sql/src/sql-driver-8577-tenant-scoped-declared-unique.test.ts`. This - * test pins the declaration that suite's fixture copies. - */ -describe('sys_notification_subscription — declared uniqueness is organization-scoped (#8577)', () => { - const uniqueIndexes = (NotificationSubscription.indexes ?? []).filter((i: any) => i.unique); - - it('declares exactly one unique index, on (topic, principal)', () => { - expect(uniqueIndexes).toHaveLength(1); - expect((uniqueIndexes[0] as any).fields).toEqual(['topic', 'principal']); - }); - - it("spells the scope 'organization' — NOT bare `true`", () => { - // ⛔ Bare `true` here is `'global'` — the installation-wide key that made - // the 409 an oracle over other tenants' rows. Asserted by EQUALITY, never - // by truthiness: a truthy check accepts the very spelling that was the bug, - // which is how the equivalent pins on #8556 and #8554 stayed green under - // their own ablations. - expect((uniqueIndexes[0] as any).unique).toBe('organization'); - }); - - it('is a valid IndexSchema — the spec accepts the explicit vocabulary', () => { - expect(IndexSchema.parse(uniqueIndexes[0])).toMatchObject({ - fields: ['topic', 'principal'], - unique: 'organization', - }); - }); - - it('matches the fixture the driver suite copies, entry for entry', () => { - // The driver suite hand-copies this declaration to keep the package - // boundary. This assertion catches ONE direction of drift: - // - // caught — the shipped declaration changes and the driver fixture - // does not. This test goes red. - // NOT caught — the DRIVER fixture is edited and this declaration is not. - // Nothing compares the two copies directly; they are only - // ever checked against this third spelling. - // - // Asserted on the BUILT value, which is what a driver is handed: - // `ObjectSchema.create` normalizes an authored `{ fields: [...] }` into - // `{ fields: [...], unique: false }`. - expect(NotificationSubscription.indexes).toEqual([ - { fields: ['topic', 'principal'], unique: 'organization' }, - { fields: ['topic'], unique: false }, - ]); - }); - - it('takes no tenancy opt-out, so organization_id is injected (the scope has a column)', () => { - // Derived from the BUILT value, not a regex over source. If this ever goes - // false the `'organization'` spelling has no column to key on and the whole - // fix is silently inert — a failure the index assertions above cannot see. - const plan = resolveInjectedSystemColumns(NotificationSubscription); - expect(NotificationSubscription.tenancy).toBeUndefined(); - expect(plan.tenant).toBe(true); - expect(plan.names.has('organization_id')).toBe(true); - }); -}); diff --git a/packages/services/service-messaging/src/objects/setup-list-empty-states.test.ts b/packages/services/service-messaging/src/objects/setup-list-empty-states.test.ts index 9a8a838ca81..df68fe8c244 100644 --- a/packages/services/service-messaging/src/objects/setup-list-empty-states.test.ts +++ b/packages/services/service-messaging/src/objects/setup-list-empty-states.test.ts @@ -1,10 +1,10 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Setup's Notification Preferences, Subscriptions and Templates lists draw - * their OWN empty state. + * Setup's Notification Preferences and Templates lists draw their OWN empty + * state. * - * These three objects shipped no list view at all, so the console synthesized + * These objects shipped no list view at all, so the console synthesized * an "All records" tab and drew the generic "Nothing here yet — Create your * first record" under it. A list view's `emptyState` is the only place the * spec carries per-list empty copy, so each object now ships one view, `all`, @@ -23,7 +23,7 @@ */ import { describe, expect, it } from 'vitest'; -import { NotificationPreference, NotificationSubscription, NotificationTemplate } from './index.js'; +import { NotificationPreference, NotificationTemplate } from './index.js'; import { enObjects } from '../translations/en.objects.generated.js'; import { zhCNObjects } from '../translations/zh-CN.objects.generated.js'; import { jaJPObjects } from '../translations/ja-JP.objects.generated.js'; @@ -43,7 +43,7 @@ interface ObjectLike { listViews?: Record; } -const OBJECTS = [NotificationPreference, NotificationSubscription, NotificationTemplate] as unknown as ObjectLike[]; +const OBJECTS = [NotificationPreference, NotificationTemplate] as unknown as ObjectLike[]; const BUNDLES = { en: enObjects, 'zh-CN': zhCNObjects, 'ja-JP': jaJPObjects, 'es-ES': esESObjects } as const; diff --git a/packages/services/service-messaging/src/plugin-declares-event-object.test.ts b/packages/services/service-messaging/src/plugin-declares-event-object.test.ts index 56f78a714bf..5ac38a239d4 100644 --- a/packages/services/service-messaging/src/plugin-declares-event-object.test.ts +++ b/packages/services/service-messaging/src/plugin-declares-event-object.test.ts @@ -24,8 +24,13 @@ import { MessagingServicePlugin } from './messaging-service-plugin.js'; import { NOTIFICATION_EVENT_OBJECT } from './messaging-service.js'; /** Capture what the plugin hands the `manifest` service during `init()`. */ -async function collectManifest(): Promise<{ objects: Array<{ name: string }> }> { - let captured: { objects?: Array<{ name: string }> } | undefined; +interface CapturedManifest { + objects: Array<{ name: string }>; + navigationContributions: Array<{ items?: Array<{ id?: string; objectName?: string }> }>; +} + +async function collectManifest(): Promise { + let captured: Partial | undefined; const ctx = { logger: { info() {}, warn() {}, error() {}, debug() {} }, registerService() {}, @@ -34,7 +39,7 @@ async function collectManifest(): Promise<{ objects: Array<{ name: string }> }> // undefined and the plugin's own optional-service guards handle it. getService(name: string) { if (name === 'manifest') { - return { register(m: { objects?: Array<{ name: string }> }) { captured = m; } }; + return { register(m: Partial) { captured = m; } }; } return undefined; }, @@ -42,7 +47,7 @@ async function collectManifest(): Promise<{ objects: Array<{ name: string }> }> }; await new MessagingServicePlugin().init(ctx as never); - return { objects: captured?.objects ?? [] }; + return { objects: captured?.objects ?? [], navigationContributions: captured?.navigationContributions ?? [] }; } describe('MessagingServicePlugin declares the object it writes (#4154)', () => { @@ -66,10 +71,24 @@ describe('MessagingServicePlugin declares the object it writes (#4154)', () => { 'sys_notification_receipt', 'sys_notification_delivery', 'sys_notification_preference', - 'sys_notification_subscription', 'sys_notification_template', ]) { expect(names).toContain(name); } }); + + // ADR-0087 semantic entry `sys-notification-subscription-retired`: no reader + // ever expanded a subscription into recipients (`EmitInput.audience` is + // required), so the object is neither registered nor offered in Setup nav. + // The positive halves above are the vacuity control: the same capture sees + // the surviving siblings and the surviving nav items. + it('registers no sys_notification_subscription and offers no nav item for it', async () => { + const { objects, navigationContributions } = await collectManifest(); + expect(objects.map((o) => o?.name)).not.toContain('sys_notification_subscription'); + + const items = navigationContributions.flatMap((c) => c.items ?? []); + expect(items.map((i) => i.objectName)).toContain('sys_notification_preference'); + expect(items.map((i) => i.objectName)).not.toContain('sys_notification_subscription'); + expect(items.map((i) => i.id)).not.toContain('nav_notification_subscriptions'); + }); }); diff --git a/packages/services/service-messaging/src/translations/bundle-ownership.test.ts b/packages/services/service-messaging/src/translations/bundle-ownership.test.ts index a90156fec88..b1450d53076 100644 --- a/packages/services/service-messaging/src/translations/bundle-ownership.test.ts +++ b/packages/services/service-messaging/src/translations/bundle-ownership.test.ts @@ -21,7 +21,6 @@ const OWNED_OBJECTS = new Set([ 'sys_notification_receipt', 'sys_notification_delivery', 'sys_notification_preference', - 'sys_notification_subscription', 'sys_notification_template', 'sys_http_delivery', ]); diff --git a/packages/services/service-messaging/src/translations/en.objects.generated.ts b/packages/services/service-messaging/src/translations/en.objects.generated.ts index 692178fea29..ac7df1fe52c 100644 --- a/packages/services/service-messaging/src/translations/en.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/en.objects.generated.ts @@ -245,44 +245,6 @@ export const enObjects: NonNullable = { } } }, - sys_notification_subscription: { - label: "Notification Subscription", - pluralLabel: "Notification Subscriptions", - description: "Standing subscription of a principal (role/team/user) to a notification topic.", - fields: { - id: { - label: "Subscription ID" - }, - display_title: { - label: "Title", - help: "Record title: the subscribing principal and the topic (computed on read)" - }, - topic: { - label: "Topic", - help: "Notification topic this principal subscribes to." - }, - principal: { - label: "Principal", - help: "Subscriber selector: 'role:x' | 'team:x' | 'user:id' | 'owner_of:object:id' | an email | a bare user id." - }, - enabled: { - label: "Enabled", - help: "When false, the subscription is inactive." - }, - created_at: { - label: "Created At" - } - }, - _views: { - all: { - label: "All", - emptyState: { - title: "No subscriptions yet", - message: "A subscription records that a role, a team or a user follows a notification topic." - } - } - } - }, sys_notification_template: { label: "Notification Template", pluralLabel: "Notification Templates", diff --git a/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts b/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts index 48f64af8bf6..57b7a1e7f07 100644 --- a/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/es-ES.objects.generated.ts @@ -245,44 +245,6 @@ export const esESObjects: NonNullable = { } } }, - sys_notification_subscription: { - label: "Notification Subscription", - pluralLabel: "Notification Subscriptions", - description: "Standing subscription of a principal (role/team/user) to a notification topic.", - fields: { - id: { - label: "Subscription ID" - }, - display_title: { - label: "Title", - help: "Record title: the subscribing principal and the topic (computed on read)" - }, - topic: { - label: "Topic", - help: "Notification topic this principal subscribes to." - }, - principal: { - label: "Principal", - help: "Selector de suscriptor: 'role:x' | 'team:x' | 'user:id' | 'owner_of:object:id' | un correo electrónico | un id de usuario sin prefijo." - }, - enabled: { - label: "Enabled", - help: "When false, the subscription is inactive." - }, - created_at: { - label: "Created At" - } - }, - _views: { - all: { - label: "Todas", - emptyState: { - title: "Aún no hay suscripciones", - message: "Una suscripción registra que un rol, un equipo o un usuario sigue un tema de notificación." - } - } - } - }, sys_notification_template: { label: "Notification Template", pluralLabel: "Notification Templates", diff --git a/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts index 2d5368bfddd..629e98df8d6 100644 --- a/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts +++ b/packages/services/service-messaging/src/translations/es-ES.source-hashes.generated.ts @@ -133,18 +133,6 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_notification_receipt.fields.user_id.label": "0fd8db52eea0eb8f", "objects.sys_notification_receipt.label": "c12ae2509f823fbf", "objects.sys_notification_receipt.pluralLabel": "405256a600be0880", - "objects.sys_notification_subscription.description": "00de4abdcb0d43a8", - "objects.sys_notification_subscription.fields.created_at.label": "1f02d416befb595b", - "objects.sys_notification_subscription.fields.display_title.help": "1100bd46caf9cb17", - "objects.sys_notification_subscription.fields.display_title.label": "70f7aadecce647a5", - "objects.sys_notification_subscription.fields.enabled.help": "37dd96827553974c", - "objects.sys_notification_subscription.fields.enabled.label": "41fb04f20e64dc4f", - "objects.sys_notification_subscription.fields.id.label": "9dd5d9fded88d363", - "objects.sys_notification_subscription.fields.principal.label": "e86ee0cdf997afed", - "objects.sys_notification_subscription.fields.topic.help": "ca33ba660ca4eb04", - "objects.sys_notification_subscription.fields.topic.label": "819afdb3853e9d80", - "objects.sys_notification_subscription.label": "cb96f976a7b36f34", - "objects.sys_notification_subscription.pluralLabel": "907cb60dda7b39c8", "objects.sys_notification_template.fields.body.help": "af87336e946106ce", "objects.sys_notification_template.fields.body.label": "d1753a024d6a9bed", "objects.sys_notification_template.fields.channel.label": "979883fe73ee88cb", diff --git a/packages/services/service-messaging/src/translations/index.ts b/packages/services/service-messaging/src/translations/index.ts index 7f49ef86d0a..0291eba52cf 100644 --- a/packages/services/service-messaging/src/translations/index.ts +++ b/packages/services/service-messaging/src/translations/index.ts @@ -6,8 +6,7 @@ * Object label/field/view/action translations for the sys_* objects this * service owns (sys_inbox_message / sys_notification_receipt / * sys_notification_delivery / sys_notification_preference / - * sys_notification_subscription / sys_notification_template / - * sys_http_delivery). Loaded at runtime via the plugin's `kernel:ready` + * sys_notification_template / sys_http_delivery). Loaded at runtime via the plugin's `kernel:ready` * hook (`i18n.loadTranslations`). Regenerate with `os i18n extract` * against `scripts/i18n-extract.config.ts`. */ diff --git a/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts b/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts index f6a4b98ee8d..595026a41e1 100644 --- a/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/ja-JP.objects.generated.ts @@ -245,44 +245,6 @@ export const jaJPObjects: NonNullable = { } } }, - sys_notification_subscription: { - label: "Notification Subscription", - pluralLabel: "Notification Subscriptions", - description: "Standing subscription of a principal (role/team/user) to a notification topic.", - fields: { - id: { - label: "Subscription ID" - }, - display_title: { - label: "Title", - help: "Record title: the subscribing principal and the topic (computed on read)" - }, - topic: { - label: "Topic", - help: "Notification topic this principal subscribes to." - }, - principal: { - label: "Principal", - help: "サブスクライバーセレクター: 'role:x' | 'team:x' | 'user:id' | 'owner_of:object:id' | メールアドレス | プレフィックスなしのユーザー ID。" - }, - enabled: { - label: "Enabled", - help: "When false, the subscription is inactive." - }, - created_at: { - label: "Created At" - } - }, - _views: { - all: { - label: "すべて", - emptyState: { - title: "まだ通知の購読がありません", - message: "購読は、ロール、チーム、またはユーザーが通知トピックをフォローしていることを記録します。" - } - } - } - }, sys_notification_template: { label: "Notification Template", pluralLabel: "Notification Templates", diff --git a/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts index 52f7f672167..0b142a8718f 100644 --- a/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts +++ b/packages/services/service-messaging/src/translations/ja-JP.source-hashes.generated.ts @@ -132,18 +132,6 @@ export const jaJPGeneratedSourceHashes: Readonly> = { "objects.sys_notification_receipt.fields.user_id.label": "0fd8db52eea0eb8f", "objects.sys_notification_receipt.label": "c12ae2509f823fbf", "objects.sys_notification_receipt.pluralLabel": "405256a600be0880", - "objects.sys_notification_subscription.description": "00de4abdcb0d43a8", - "objects.sys_notification_subscription.fields.created_at.label": "1f02d416befb595b", - "objects.sys_notification_subscription.fields.display_title.help": "1100bd46caf9cb17", - "objects.sys_notification_subscription.fields.display_title.label": "70f7aadecce647a5", - "objects.sys_notification_subscription.fields.enabled.help": "37dd96827553974c", - "objects.sys_notification_subscription.fields.enabled.label": "41fb04f20e64dc4f", - "objects.sys_notification_subscription.fields.id.label": "9dd5d9fded88d363", - "objects.sys_notification_subscription.fields.principal.label": "e86ee0cdf997afed", - "objects.sys_notification_subscription.fields.topic.help": "ca33ba660ca4eb04", - "objects.sys_notification_subscription.fields.topic.label": "819afdb3853e9d80", - "objects.sys_notification_subscription.label": "cb96f976a7b36f34", - "objects.sys_notification_subscription.pluralLabel": "907cb60dda7b39c8", "objects.sys_notification_template.fields.body.help": "af87336e946106ce", "objects.sys_notification_template.fields.body.label": "d1753a024d6a9bed", "objects.sys_notification_template.fields.channel.label": "979883fe73ee88cb", diff --git a/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts b/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts index 7c5d86345b5..98f47a53056 100644 --- a/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts +++ b/packages/services/service-messaging/src/translations/zh-CN.objects.generated.ts @@ -245,44 +245,6 @@ export const zhCNObjects: NonNullable = { } } }, - sys_notification_subscription: { - label: "通知订阅", - pluralLabel: "通知订阅", - description: "主体(角色/团队/用户)对通知主题的长期订阅。", - fields: { - id: { - label: "订阅 ID" - }, - display_title: { - label: "Title", - help: "Record title: the subscribing principal and the topic (computed on read)" - }, - topic: { - label: "主题", - help: "该主体订阅的通知主题。" - }, - principal: { - label: "主体", - help: "订阅者选择器:'role:x' | 'team:x' | 'user:id' | 'owner_of:object:id' | 邮箱地址 | 裸用户 ID。" - }, - enabled: { - label: "启用", - help: "为 false 时,订阅不生效。" - }, - created_at: { - label: "创建时间" - } - }, - _views: { - all: { - label: "全部", - emptyState: { - title: "尚无通知订阅", - message: "订阅记录某个角色、团队或用户关注某个通知主题。" - } - } - } - }, sys_notification_template: { label: "通知模板", pluralLabel: "通知模板", diff --git a/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts b/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts index a69a6811b02..cf80d98e3d5 100644 --- a/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts +++ b/packages/services/service-messaging/src/translations/zh-CN.source-hashes.generated.ts @@ -25,6 +25,4 @@ export const zhCNGeneratedSourceHashes: Readonly> = { "objects.sys_notification_delivery.fields.display_title.label": "70f7aadecce647a5", "objects.sys_notification_preference.fields.display_title.help": "13aff17d09067e30", "objects.sys_notification_preference.fields.display_title.label": "70f7aadecce647a5", - "objects.sys_notification_subscription.fields.display_title.help": "1100bd46caf9cb17", - "objects.sys_notification_subscription.fields.display_title.label": "70f7aadecce647a5", }; diff --git a/packages/spec/src/data/object.zod.ts b/packages/spec/src/data/object.zod.ts index 8a99a208797..01a03df6aea 100644 --- a/packages/spec/src/data/object.zod.ts +++ b/packages/spec/src/data/object.zod.ts @@ -2291,7 +2291,7 @@ const ObjectSchemaBase = strictObject( * DATA**: the RBAC link tables (`sys_user_position`, * `sys_user_permission_set`, `sys_position_permission_set`, governed by * the `DelegatedAdminGate`), `sys_user_preference`, the messaging config - * grids (`sys_notification_subscription`, `_template`, `_preference`). Two + * grids (`sys_notification_template`, `_preference`). Two * boundaries define it: the SCHEMA is the platform's (versioned with the * release, not tenant-modelled — that is `platform`), while the DATA is the * admin's or the user's, written through a governed path (not the engine's — diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-notification-subscription-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-notification-subscription-retired.ts new file mode 100644 index 00000000000..4f35958cf3b --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.sys-notification-subscription-retired.ts @@ -0,0 +1,60 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0030 L3 (amended) — a platform-object RETIREMENT, not a spec-key +// retirement: no authorable spec key moves, so nothing lands in +// RETIRED_KEYS_BY_MAJOR and no D2 conversion exists to pair with (the +// scim-provider-object-retired and sys-view-definition-retired shape). The +// reader census behind the verdict is cited in the reason. +export const entry: SemanticMigration = { + id: 'sys-notification-subscription-retired', + // No backticks in `surface` — build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'the sys_notification_subscription platform object (NotificationSubscription, exported by ' + + '@objectstack/service-messaging and its objects barrel), its registration and boot ' + + 'provisioning by MessagingServicePlugin, the Setup navigation item Notification ' + + 'Subscriptions (nav_notification_subscriptions) the plugin contributed to the Configuration ' + + 'group, its generated object translations, and its name in PLATFORM_OBJECTS_BY_PACKAGE ' + + '(@objectstack/spec system constants)', + replacement: + 'nothing replaces it — the recipients of a notification are the explicit audience its ' + + 'producer passes to `emit()` (`EmitInput.audience` is required): a flow notification ' + + 'node\'s recipients, an approval\'s approvers, a mention\'s mentioned users. Per-user ' + + 'muting stays on `sys_notification_preference`, which is unchanged. Delete any import of ' + + '`NotificationSubscription`. A stack that names `sys_notification_subscription` (a lookup ' + + 'target, a flow trigger, a permission entry, a navigation item) removes the reference: the ' + + 'name no longer resolves to a platform object', + reason: + 'ADR-0030 described a topic subscription as Layer-3 audience expansion, and that expansion ' + + 'was never wired: no audience member asks for subscribers, every producer names its ' + + 'recipients explicitly, and no recipient-resolver branch reads a topic\'s subscriptions. ' + + 'Census at commit e4d604efd3 of this repository\'s main branch, glob pathspec over ' + + 'packages/**/src without tests and generated bundles: 6 files name the object (its own ' + + 'declaration, the plugin\'s registration and nav item, a translation-bundle comment, the ' + + 'platform-object registry, a doc comment in the object schema and the CLI ownership ' + + 'inventory), none of them a reader of its rows; the control word ' + + 'sys_notification_preference names 8 files there, two of them its readers (the preference ' + + 'resolver and the fan-out system context). The Studio repository at the pinned console ' + + 'commit cf153eb2e5 names it 0 times (2 files for the control word). So the Setup grid ' + + 'let an administrator author rows that never made anyone receive anything — declared and ' + + 'not delivered. The maintainer ruled to retire it rather than build the expansion, which ' + + 'would also need a who-may-subscribe-whom permission model nothing asked for; that ruling ' + + 'supersedes the earlier decision to keep the object with a code comment saying it was not ' + + 'wired, a comment no administrator could see.', + acceptanceCriteria: + 'No code imports NotificationSubscription from @objectstack/service-messaging (TS2305 after ' + + 'upgrade). isPlatformProvidedObjectName answers false for sys_notification_subscription, ' + + 'so a stack referencing the name is flagged as a probable typo rather than resolved. ' + + 'MessagingServicePlugin neither registers nor provisions the object, and the Setup app\'s ' + + 'Configuration group offers Notification Preferences and Notification Templates but no ' + + 'Notification Subscriptions item. Existing databases: the table is left in place ' + + 'untouched, by ruling — no backfill, no reaper, no migrate command. Schema sync is ' + + 'additive and never drops a table, so a database an earlier release provisioned keeps ' + + 'sys_notification_subscription and any rows an administrator wrote, and nothing reads ' + + 'them; os migrate organization-ownership keeps an inventory row for the table (fate 4: ' + + 'its rows listed, never attributed), so a database still holding it plans instead of ' + + 'being refused as carrying an uninventoried platform table. Export any row worth keeping; ' + + 'dropping the table is the operator\'s call, by hand.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index ceab8253a6c..e3bdf5d4ffc 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -2775,6 +2775,18 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'values are confirmed in `tenant_id`. The D3 record is the ' + '`sys-audit-log-organization-column-retired` semantic entry.', }, + { + id: 'sys-notification-subscription-retired', + order: 106, + text: + 'It also retires the `sys_notification_subscription` platform object (ADR-0030 Layer 3, ' + + 'amended): no reader ever expanded a topic subscription into recipients — every producer ' + + 'names its audience explicitly — so the Setup grid recorded subscriptions that made nobody ' + + 'receive anything. The object, its registration and boot provisioning, its Setup navigation ' + + 'item and its translations leave, and its name leaves the platform-object registry. Nothing ' + + 'in stack metadata is rewritten; an existing database keeps the table, which no platform ' + + 'path drops. The D3 record is the `sys-notification-subscription-retired` semantic entry.', + }, { id: 'sys-setting-global-rung-moved', order: 87, diff --git a/packages/spec/src/system/constants/platform-object-names.test.ts b/packages/spec/src/system/constants/platform-object-names.test.ts index f087699f58d..2b34e49e9cd 100644 --- a/packages/spec/src/system/constants/platform-object-names.test.ts +++ b/packages/spec/src/system/constants/platform-object-names.test.ts @@ -163,6 +163,24 @@ describe('platform-object predicates', () => { expect(hasPlatformObjectPrefix('sys_view_definition')).toBe(true); }); + it('no longer resolves the retired sys_notification_subscription (ADR-0030 L3, ADR-0087)', () => { + // Retired as inert: no runtime reader ever expanded a subscription into + // recipients — every producer names its audience explicitly. The table is + // left in deployed databases; the name no longer resolves as a platform + // object (semantic entry `sys-notification-subscription-retired`). + expect(PLATFORM_OBJECTS_BY_PACKAGE['service-messaging']).toEqual([ + 'sys_http_delivery', + 'sys_inbox_message', + 'sys_notification_delivery', + 'sys_notification_preference', + 'sys_notification_receipt', + 'sys_notification_template', + ]); + expect(isPlatformProvidedObjectName('sys_notification_subscription')).toBe(false); + expect(isPlatformProvidedObjectName('sys_notification_preference')).toBe(true); + expect(hasPlatformObjectPrefix('sys_notification_subscription')).toBe(true); + }); + it('treats an unprefixed name as neither', () => { expect(hasPlatformObjectPrefix('crm_lead')).toBe(false); expect(isPlatformProvidedObjectName('user')).toBe(false); diff --git a/packages/spec/src/system/constants/platform-object-names.ts b/packages/spec/src/system/constants/platform-object-names.ts index abf7978f0c7..6752871e0b8 100644 --- a/packages/spec/src/system/constants/platform-object-names.ts +++ b/packages/spec/src/system/constants/platform-object-names.ts @@ -125,7 +125,6 @@ export const PLATFORM_OBJECTS_BY_PACKAGE: Readonly