Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/24923-sys-notification-subscription-retired.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: registered sys-notification-subscription-retired -->

**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`.
11 changes: 7 additions & 4 deletions docs/adr/0030-notification-platform-convergence.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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` |
Expand Down Expand Up @@ -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).
Expand Down
5 changes: 5 additions & 0 deletions docs/qa/platform-checklist/FOLLOW-UPS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions packages/cli/src/utils/organization-ownership-inventory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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([]);
Expand Down
22 changes: 19 additions & 3 deletions packages/cli/src/utils/organization-ownership-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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: [] }),
),
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
1 change: 0 additions & 1 deletion packages/platform-objects/src/apps/translations/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ export const esESSourceHashes: Readonly<Record<string, string>> = {
'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',
Expand Down
1 change: 0 additions & 1 deletion packages/platform-objects/src/apps/translations/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ export const jaJPSourceHashes: Readonly<Record<string, string>> = {
'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',
Expand Down
Loading
Loading