Skip to content

Commit e026730

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24997-webhook-capability-required
2 parents 07aeabc + 63085c3 commit e026730

41 files changed

Lines changed: 264 additions & 544 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/service-messaging': minor
3+
'@objectstack/spec': minor
4+
'@objectstack/cli': minor
5+
'@objectstack/platform-objects': patch
6+
---
7+
8+
feat(service-messaging,spec,cli)!: `sys_notification_subscription` retires — no reader ever expanded a subscription into recipients (ADR-0030 L3, amended)
9+
10+
Clause-②: yes (narrowing)
11+
12+
<!-- adr-0087: registered sys-notification-subscription-retired -->
13+
14+
**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.
15+
16+
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.
17+
18+
**What moves for consumers, FROM → TO.**
19+
20+
- **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.
21+
- **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.
22+
- **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.
23+
- **`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.
24+
25+
**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`.

‎docs/adr/0030-notification-platform-convergence.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0030 — Notification Platform Convergence (single ingress, layered pipeline)
22

3-
**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.)
3+
**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.)
44
**Supersedes / refines**: [ADR-0012 — Notification Platform](./0012-notification-platform.md) (Draft)
55
**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)
66
**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
2828
| Layer | Object | Role |
2929
|---|---|---|
3030
| 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.** |
31-
| 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 |
31+
| 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. |
3232
| L4 Delivery (outbox) | `sys_notification_delivery` | one row per (event × recipient × channel); state machine `pending→sent→failed/dead`; retry/backoff/dedup; the durable spine |
3333
| 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` |
3434
| 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).
119119
`connector_action` Slack path already works today).
120120
- **`defineTopic()`** declarative topic catalog (Studio discoverability for topics /
121121
templates / preferences) — the low-code backbone.
122-
- **Subscription-driven fan-out**: expand a topic's `sys_notification_subscription`
122+
- ~~**Subscription-driven fan-out**: expand a topic's `sys_notification_subscription`
123123
principals when a producer emits without an explicit audience (object exists;
124-
expansion not wired).
124+
expansion not wired).~~ **Withdrawn (2026-10-20, ruling D on #24770)** — the object
125+
is retired instead (status line): zero pull, every producer names its audience,
126+
and the expansion would need a permission model nobody asked for. Existing
127+
tables are left in place untouched, read by nothing.
125128
- **MJML** compilation for email (P3a treats `mjml` format as raw HTML).
126129
- Quiet-hours **tz fallback** to a `sys_user` timezone field (currently
127130
`quiet_hours.tz` → UTC).

‎docs/qa/platform-checklist/FOLLOW-UPS.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,11 @@ resolve-at-load only).
515515
**11-ledger** universe) and integration-system.datasource-admin-lifecycle (same
516516
"unledgered" claim, plus its "always-available static catalog" wording predating the
517517
#9391/#9593 uniform auth floor).
518+
- **§9c's `sys_notification_subscription` Setup grid row is RESOLVED** — retired by
519+
#24923 (maintainer ruling D on #24770): the object, its Setup nav item and its
520+
translations left service-messaging, its name left the platform-object registry, and
521+
the table stays in place untouched (ADR-0087 semantic entry
522+
`sys-notification-subscription-retired`; ADR-0030 amended 2026-10-20).
518523

519524
### 9e. Blocked-item re-audit
520525

‎packages/cli/src/utils/organization-ownership-inventory.test.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,14 @@ const CENSUS_2026_08_31_EXAMPLES = [
9292
*/
9393
const RETIRED_BEFORE_CENSUS = { sys_scim_provider: 'scim-provider-object-retired' } as const;
9494

95+
/**
96+
* Platform objects the first census counted that have since retired by an
97+
* ADR-0087 entry leaving their table in place untouched: the registry no
98+
* longer names them, yet an upgraded database still holds the table. Frozen,
99+
* keyed to the entry each row's citation names.
100+
*/
101+
const RETIRED_AFTER_FIRST_CENSUS = { sys_notification_subscription: 'sys-notification-subscription-retired' } as const;
102+
95103
/**
96104
* #14570 and #15086, read in: each population's table has its own fate, citing
97105
* 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
143151
).toEqual([...retired].sort());
144152
});
145153

154+
it('counted by the first census, retired since with the table left in place: fate 4, citing its ADR-0087 entry', () => {
155+
const counted = [...CENSUS_2026_08_31_PLATFORM_WITH_COLUMN, ...CENSUS_2026_08_31_PLATFORM_WITHOUT_COLUMN] as readonly string[];
156+
const registered = new Set(Object.values(PLATFORM_OBJECTS_BY_PACKAGE).flat());
157+
for (const [object, adr0087Entry] of Object.entries(RETIRED_AFTER_FIRST_CENSUS)) {
158+
// The population's premise: the first census counted it and the tree no longer declares it.
159+
expect(counted, object).toContain(object);
160+
expect(registered.has(object), object).toBe(false);
161+
// The entry the citation names exists, and its ruling is what licenses fate 4: the table stays untouched.
162+
const semantic = Object.values(MIGRATIONS_BY_MAJOR).flatMap((step) => step.semantic).find((s) => s.id === adr0087Entry);
163+
expect(semantic?.acceptanceCriteria, adr0087Entry).toContain('left in place untouched');
164+
const entry = inventoryEntryFor(object)!;
165+
expect(entry, object).toMatchObject({ census: ['platform-census-2026-08-31'], fate: 'report' });
166+
expect(entry.citation, object).toContain(adr0087Entry);
167+
expect(entry.citation, object).not.toMatch(/#\d/);
168+
expect(entry.notNullExempt, object).toBeTruthy();
169+
}
170+
});
171+
146172
it('the cloud supplement: listed as cloud-carried, fate 4, never a guessed fate', () => {
147173
expect(CLOUD_PROVIDED_OBJECT_NAMES.length).toBeGreaterThan(0);
148174
expect(unlisted(CLOUD_PROVIDED_OBJECT_NAMES)).toEqual([]);

‎packages/cli/src/utils/organization-ownership-inventory.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
*
1818
* - `platform-census-2026-08-31` — the 84 platform objects of the first
1919
* #13564 census (59 carrying the column, 25 not), measured at
20-
* `00d8f6541b`. Three of them have since left the tree
21-
* (`sys_view_definition`, `sys_saved_report`, `sys_report_schedule`); their
20+
* `00d8f6541b`. Four of them have since left the tree
21+
* (`sys_view_definition`, `sys_saved_report`, `sys_report_schedule`,
22+
* `sys_notification_subscription`); their
2223
* physical tables can still exist on an upgraded database, so they keep a
2324
* row.
2425
* - `platform-census-main` — objects the gated census on `main` lists and
@@ -473,6 +474,21 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
473474
'the column, and a connection scoped to no organization was a legitimate row',
474475
},
475476

477+
// ── Platform: counted by the first census, retired since, table left in place (fate 4) ──
478+
{
479+
object: 'sys_notification_subscription',
480+
census: P1_ONLY,
481+
fate: 'report',
482+
citation:
483+
'ADR-0087 semantic entry sys-notification-subscription-retired (packages/spec/src/migrations/entries/semantic/' +
484+
'18.sys-notification-subscription-retired.ts): the ADR-0030 Layer-3 topic subscription no reader ever expanded ' +
485+
'into recipients, retired by maintainer ruling with its table left in place untouched. A physical table left ' +
486+
'by an older release is listed, its NULL rows reported, never attributed and never deleted',
487+
notNullExempt:
488+
'the object is retired and its table is left in place untouched, by ruling: no declaration anchors tenancy on ' +
489+
'the column any more, and nothing reads its rows',
490+
},
491+
476492
// ── Platform: attribution through a parent anchor (fate 3) ──
477493
{
478494
object: 'sys_file',
@@ -503,7 +519,7 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
503519
{ 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')] },
504520
{ 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')] },
505521
{ 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')] },
506-
...['sys_notification_preference', 'sys_notification_subscription', 'sys_inbox_message'].map(
522+
...['sys_notification_preference', 'sys_inbox_message'].map(
507523
(object): InventoryEntry => ({ object, census: P1, fate: 'attribution', citation: `${D7} (recipient-anchored); no anchor names the organization; ${D10_SINGLE}`, anchors: [] }),
508524
),
509525
{

‎packages/cli/src/utils/organization-ownership-plan.integration.test.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,48 @@ describe('ADR-0131 D10 preflight — the per-table plan', () => {
283283
expect(r).toMatchObject({ reason: 'uninventoried-platform-table', table: 'sys_scim_provider' });
284284
});
285285

286+
it('a database still holding the retired sys_notification_subscription plans, listing it as fate 4', async () => {
287+
// The table every database that booted before the retirement still holds,
288+
// in its last declared shape (the 18.0.0 upgrade-harness fixture's DDL).
289+
db.exec(`
290+
CREATE TABLE sys_notification_subscription (
291+
id TEXT PRIMARY KEY, created_at TEXT, updated_at TEXT, organization_id TEXT,
292+
created_by TEXT, updated_by TEXT, topic TEXT, principal TEXT, enabled INTEGER
293+
);
294+
INSERT INTO sys_notification_subscription VALUES
295+
('sub_1', '2026-07-01', '2026-07-01', 'org_b', 'usr_1', 'usr_1', 'billing.invoice', 'role:admin', 1),
296+
('sub_2', '2026-07-02', '2026-07-02', NULL, NULL, NULL, 'crm.lead', 'user:usr_1', 1);
297+
`);
298+
299+
for (const posture of ['isolated', 'single']) {
300+
const sub = table(await plan(posture), 'sys_notification_subscription');
301+
expect(sub, posture).toMatchObject({
302+
source: 'inventory',
303+
fate: 'report',
304+
physical: 'present',
305+
organizationColumn: 'present',
306+
rows: { total: 2, organizationNull: 1, stamped: 1 },
307+
fateCounts: { reportedRows: 1 },
308+
// Reported, never attributed — not even to the Default Organization under single.
309+
unattributable: [{ id: 'sub_2', reason: 'fate 4: reported, never guessed' }],
310+
notNull: { willReceive: false, reason: expect.stringContaining('left in place untouched') },
311+
});
312+
expect(sub.census, posture).toEqual(['platform-census-2026-08-31']);
313+
expect(sub.citation, posture).toContain('sys-notification-subscription-retired');
314+
}
315+
316+
// Control: without its row the same database is refused, by name.
317+
const r = await refusal(
318+
buildOrganizationOwnershipPlan({
319+
reader: reader(),
320+
posture: 'isolated',
321+
database: file,
322+
inventory: ORGANIZATION_OWNERSHIP_INVENTORY.filter((e) => e.object !== 'sys_notification_subscription'),
323+
}),
324+
);
325+
expect(r).toMatchObject({ reason: 'uninventoried-platform-table', table: 'sys_notification_subscription' });
326+
});
327+
286328
it('under single with no Default Organization (two organizations, none of them default), nothing is assigned and the reason says why', async () => {
287329
db.exec("UPDATE sys_organization SET slug = 'renamed' WHERE id = 'org_default'");
288330
const p = await plan('single');

‎packages/platform-objects/src/apps/translations/en.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,6 @@ export const en: TranslationData = {
130130
nav_settings_feature_flags: { label: 'Feature Flags' },
131131
// Notifications (contributed by @objectstack/service-messaging)
132132
nav_notification_preferences: { label: 'Notification Preferences' },
133-
nav_notification_subscriptions: { label: 'Notification Subscriptions' },
134133
nav_notification_templates: { label: 'Notification Templates' },
135134

136135
// Diagnostics

‎packages/platform-objects/src/apps/translations/es-ES.source-hashes.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,6 @@ export const esESSourceHashes: Readonly<Record<string, string>> = {
114114
'apps.setup.navigation.nav_settings_knowledge.label': '140348539a50d9d5',
115115
'apps.setup.navigation.nav_settings_feature_flags.label': '4e7e9e879ea1bcc3',
116116
'apps.setup.navigation.nav_notification_preferences.label': '8869f324c1bd545a',
117-
'apps.setup.navigation.nav_notification_subscriptions.label': '907cb60dda7b39c8',
118117
'apps.setup.navigation.nav_notification_templates.label': '9e270c87ef2578df',
119118
'apps.setup.navigation.nav_sessions.label': '673bde6e0c8a3ef9',
120119
'apps.setup.navigation.nav_audit_logs.label': 'b2fad40733012b18',

‎packages/platform-objects/src/apps/translations/es-ES.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,6 @@ export const esES: TranslationData = {
9090
nav_settings_knowledge: { label: 'Conocimiento' },
9191
nav_settings_feature_flags: { label: 'Indicadores de Funcionalidad' },
9292
nav_notification_preferences: { label: 'Preferencias de Notificación' },
93-
nav_notification_subscriptions: { label: 'Suscripciones de Notificación' },
9493
nav_notification_templates: { label: 'Plantillas de Notificación' },
9594

9695
nav_sessions: { label: 'Sesiones' },

‎packages/platform-objects/src/apps/translations/ja-JP.source-hashes.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,6 @@ export const jaJPSourceHashes: Readonly<Record<string, string>> = {
114114
'apps.setup.navigation.nav_settings_knowledge.label': '140348539a50d9d5',
115115
'apps.setup.navigation.nav_settings_feature_flags.label': '4e7e9e879ea1bcc3',
116116
'apps.setup.navigation.nav_notification_preferences.label': '8869f324c1bd545a',
117-
'apps.setup.navigation.nav_notification_subscriptions.label': '907cb60dda7b39c8',
118117
'apps.setup.navigation.nav_notification_templates.label': '9e270c87ef2578df',
119118
'apps.setup.navigation.nav_sessions.label': '673bde6e0c8a3ef9',
120119
'apps.setup.navigation.nav_audit_logs.label': 'b2fad40733012b18',

0 commit comments

Comments
 (0)