From cce992bf300b96b0c2d4d3ec633e3f0315dbddbb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 16:59:00 +0000 Subject: [PATCH 1/2] =?UTF-8?q?docs(adr):=20ADR-0012=20amended=20for=20the?= =?UTF-8?q?=20push=20relay=20=E2=80=94=20relay=20(hosted,=20default)=20|?= =?UTF-8?q?=20direct=20(APNs/FCM,=20white-label),=20vendor=20as=20a=20toke?= =?UTF-8?q?n=20type=20the=20relay=20routes=20on,=20ID-only=20then=20E2EE?= =?UTF-8?q?=20privacy,=20no=20system=20push=20air-gapped=20(ruling=20cloud?= =?UTF-8?q?#3519)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the header's Amended (2026-11-03, #29041) note for #29817: §4's push backend row, §6's domestic-vendor bullet and a new §6.1 (transports, wire, vendor as token type, privacy, air-gap, what main serves), the Goals and Non-Goals bullets that called the domestic push vendors plugin transports, the M3 milestone line, risk 5 and the Decision list. Every amended passage quotes the wording it replaces. Written to the shape main ships (#29886, #29794): the relay is decided and not yet served (#29920). ADR-0030 is unchanged. Claude-Session: https://claude.ai/code/session_01WGufzj5io3Zzyb3E6VKCtj Co-authored-by: Claude --- docs/adr/0012-notification-platform.md | 31 ++++++++++++++++++-------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/docs/adr/0012-notification-platform.md b/docs/adr/0012-notification-platform.md index 5124056b3f4..0e53b956a47 100644 --- a/docs/adr/0012-notification-platform.md +++ b/docs/adr/0012-notification-platform.md @@ -1,6 +1,6 @@ # ADR-0012: Messaging Platform — Outbound Notifications on a Generalized Outbox -**Status**: Draft (2026-05-25 · scope-revised 2026-05-26) · **Superseded by [ADR-0030](./0030-notification-platform-convergence.md)** (2026-06-01) — the M1 implementation drifted (two conflated inbox objects); ADR-0030 records the correction and realizes this layered model. · **Amended (2026-11-03, #29041)**: push ships as `@objectstack/service-push` (the direct APNs and FCM transports, the kernel's `push` service; the hosted relay, the default transport per cloud#3519, is not yet served, #29920) with its channel shell `push-channel.ts` in `@objectstack/service-messaging`, which also owns `sys_user_device`; every `plugin-notification-push` below reads that way. ADR-0030 is unchanged. +**Status**: Draft (2026-05-25 · scope-revised 2026-05-26) · **Superseded by [ADR-0030](./0030-notification-platform-convergence.md)** (2026-06-01) — the M1 implementation drifted (two conflated inbox objects); ADR-0030 records the correction and realizes this layered model. · **Amended (2026-11-03, #29041; extended the same day by #29817 on ruling objectstack-ai/cloud#3519 — 1C 2A 3A 4B 5A 6B 7B, 2026-11-03 — and the spec shape of #29816)**: push ships as `@objectstack/service-push` (the direct APNs and FCM transports, the kernel's `push` service; the hosted relay, the default transport per cloud#3519, is not yet served, #29920) with its channel shell `push-channel.ts` in `@objectstack/service-messaging`, which also owns `sys_user_device`; every `plugin-notification-push` below reads that way. The #29817 extension amends: the `push` backend in §4 (it read "APNs (HTTP/2, token auth) + FCM (HTTP v1)" alone; it is now `relay` (hosted, the default) or `direct` (APNs/FCM, white-label)); §6's domestic-vendor bullet (it read "alternate channel implementations"; `vendor` on `sys_user_device` is a token type the relay routes on, not a plugin boundary) and the new §6.1, which records the relay's privacy rule (ID-only, then end-to-end encryption with a device key) and the air-gap rule (no system push; the in-app inbox only); the Goals bullet "Plugin extension" and the Non-Goals bullet "Domestic mobile push vendors" (both read "domestic push vendors ship as plugins / plugin transports"); the M3 milestone line; risk 5; and the Decision list. Each amended passage quotes the wording it replaces. ADR-0030 is unchanged. **Authors**: Platform team — surfaced from GitHub Issue #1292 ("[P0] notification: no outbound notification channel") **Consumers**: `@objectstack/spec` (new `messaging/` domain), `@objectstack/service-messaging` (new), `@objectstack/plugin-notification-inbox` (new), `@objectstack/plugin-notification-email` (replaces `plugin-email`), `@objectstack/plugin-notification-webhook` (extracted from `plugin-webhooks`), `@objectstack/plugin-notification-push` (new), every flow that has a `notify` node, every template that ships notification rules **Sibling**: [ADR-0013 — Bidirectional Messaging & Conversational Channels](./0013-bidirectional-messaging.md) @@ -16,7 +16,7 @@ This ADR covers the **outbound half** of a broader Messaging Platform. We extrac This ADR proposes: 1. **Extract** the outbox/retry/lock/signing machinery from `plugin-webhooks` into `@objectstack/service-messaging`. -2. **Ship four built-in channels**: `inbox` (always on), `email`, `webhook` (the existing one, now a channel implementation), `push` (APNs + FCM). +2. **Ship four built-in channels**: `inbox` (always on), `email`, `webhook` (the existing one, now a channel implementation), `push` (the hosted push relay by default; direct APNs + FCM for a white-label build — amended 2026-11-03, cloud#3519, §6.1). 3. **Email becomes a transport sub-system**: SMTP is mandatory baseline (required for air-gapped / on-prem); SendGrid / SES / Resend / Postmark / Aliyun / Tencent register as `EmailTransport` plugins. 4. **Absorb `plugin-email`** into `plugin-notification-email`. Keep `ctx.email.send(...)` as a low-level API for special cases (OTP, password reset) that must bypass user preferences; wrap it as a `MessagingChannel` so flow `notify` nodes get retry/outbox for free. 5. **Other channels (Feishu / DingTalk / WeCom / Slack / SMS / Telegram) ship as plugins**, all implementing the same `MessagingChannel` interface — no special casing. **Bidirectional behaviour (inbound + session) is covered by ADR-0013, not here.** @@ -111,11 +111,11 @@ Pattern: the ones that ship to end-users (Salesforce, ServiceNow, Linear, Slack, ## Goals * **Fix #1292** — `notify` nodes in shipped templates actually deliver to humans, without per-template wiring. -* **Four built-in channels**: `inbox` (always on, in-app), `email` (SMTP + SaaS transports), `webhook` (B2B integration), `push` (APNs + FCM). +* **Four built-in channels**: `inbox` (always on, in-app), `email` (SMTP + SaaS transports), `webhook` (B2B integration), `push` (the hosted push relay by default; direct APNs + FCM for a white-label build — amended 2026-11-03, cloud#3519, §6.1). * **One delivery substrate** — outbox / retry / cluster-lock / dead-letter / signing live in `service-messaging`; every channel inherits them. * **Topic + Preference matrix** — users opt out per topic per channel (Slack/Linear pattern); platform-default policies for system-critical topics that cannot be muted (e.g. password reset). * **Renderer per channel** — same notification, channel-specific body (MJML for email, card JSON for Feishu, 4 KB JSON for push). Plain string is the fallback. -* **Plugin extension** — Feishu / DingTalk / WeCom / Slack / SMS / Telegram / domestic push vendors ship as `MessagingChannel` plugins with zero core change. +* **Plugin extension** — Feishu / DingTalk / WeCom / Slack / SMS / Telegram ship as `MessagingChannel` plugins with zero core change. *Amended 2026-11-03 (cloud#3519): this bullet read "… / Telegram / domestic push vendors ship as `MessagingChannel` plugins". The domestic push vendors (HMS / Honor / Xiaomi / OPPO / vivo) are not plugins: they are token types the hosted push relay routes on, §6.1.* * **Email transport sub-system** — SMTP is mandatory and ships in core (private deploy unblock); SendGrid is the SaaS baseline; SES / Resend / Postmark / Aliyun / Tencent register as `EmailTransport` plugins. * **Absorb `plugin-email`** — keep `ctx.email.send(...)` as a low-level escape hatch for system mail (OTP / password reset that must bypass preferences), but wrap it as a `MessagingChannel` so flow `notify` reuses outbox/retry. * **Operational parity with webhook** — every channel exposes the same observability surface (`sys_*_delivery` table, dead-letter UI, retry button, metrics). @@ -124,7 +124,8 @@ Pattern: the ones that ship to end-users (Salesforce, ServiceNow, Linear, Slack, * **Digest / quiet-hours / dedup** as a finished feature in M1. We define the `DeliveryMiddleware` interface and a stub registry, but only ship a no-op middleware. Real digest engines (hourly rollups, "don't ping me again about this") are M2+. * **Recipient resolver DSL** in M1. We define the interface (`role:approver`, `oncall:incident`, `owner_of:contract`) but only ship a trivial "explicit list + role lookup" resolver. Complex resolvers come from `service-permissions` / `service-oncall` later. -* **Domestic mobile push vendors** (HMS / Xiaomi / OPPO / VIVO). Schema reserves a `vendor` discriminator on `sys_user_device`, but only APNs and FCM ship in core. Domestic vendors are plugin transports. +* **Domestic mobile push vendors** (HMS / Honor / Xiaomi / OPPO / vivo) **in phase 1**. `vendor` on `sys_user_device` is the token-type discriminator the push transport routes on; core ships `apns` and `fcm` (`packages/spec/src/system/push-config.zod.ts#PushVendorSchema`, closed on `main`). In phase 2 the hosted push relay fronts the domestic vendors too, and `PushVendorSchema` widens in the same change that routes them (§6.1). *Amended 2026-11-03 (cloud#3519): this bullet read "Schema reserves a `vendor` discriminator on `sys_user_device`, but only APNs and FCM ship in core. Domestic vendors are plugin transports." They are neither plugin transports nor alternate channels.* +* **System push in an air-gapped deployment** (amended 2026-11-03, cloud#3519). The push relay is hosted in objectstack-ai/cloud and there is no self-hosted relay; a deployment with no route to it has no system push, and the direct transport does not restore one, because it needs APNs and FCM. Its users read notifications in the in-app inbox only (§6.1). * **Inbound email parsing** ("reply to this email to comment on the ticket"). The `EmailTransport` interface is send-only in M1. A parallel `EmailIngest` surface is a later ADR. * **SMS as a built-in channel.** SMS routes through `plugin-notification-webhook` to Twilio / Aliyun SMS / Tencent SMS, or as a community `plugin-notification-sms` if demand justifies it. Carriers vary too much by region to bake in. * **In-app real-time push to the browser.** Inbox writes rows; the existing `service-realtime` (websocket) layer decides when to ping the UI. That seam is unchanged. @@ -306,7 +307,7 @@ These cost ~5 columns and one empty table in M1. They are non-negotiable: introd | `inbox` | `plugin-notification-inbox` | Writes to `sys_inbox_message`; fires `service-realtime` event for online users | **MUST ship M1** | | `email` | `plugin-notification-email` (replaces `plugin-email`) | SMTP + transport plugins (SendGrid baseline) | **MUST ship M1** | | `webhook` | `plugin-notification-webhook` (extracted from `plugin-webhooks`) | HTTP POST with HMAC | **MUST ship M1** (port existing) | -| `push` | `service-push` (transports) + `push-channel.ts` in `service-messaging` (channel); amended 2026-11-03, #29041 | Direct: APNs (HTTP/2, token auth) + FCM (HTTP v1) with the operator's own credentials, for a white-label build. The hosted relay is the default transport (cloud#3519) and is not yet served (#29920) | Direct shipped (#29041); relay not yet served (#29920) | +| `push` | `service-push` (transports) + `push-channel.ts` in `service-messaging` (channel); amended 2026-11-03, #29041 and #29817 | `relay` (hosted, **the default**): the server POSTs an ID-only `PushRelayRequestSchema` to the hosted push relay in objectstack-ai/cloud, authenticated by its cloud binding credential; the relay holds the store-published app's APNs and FCM credentials and forwards each delivery to its vendor. `direct` (APNs HTTP/2 with token auth + FCM HTTP v1, the operator's own credentials): for a white-label build only. The two are the `transport` union on `PushServiceConfigSchema` (#29816); §6.1 has the rules. Before 2026-11-03 this cell read "APNs (HTTP/2, token auth) + FCM (HTTP v1)" alone | `direct` shipped (#29041, PR #29794). `relay` decided (cloud#3519), not yet served: `PushServicePlugin` refuses a relay block, the default `{}` included, at init until #29920 ships the sender | All four bind into `channel-registry` during `onEnable`. @@ -376,7 +377,18 @@ Lifecycle hooks: * Client SDK calls `POST /api/v1/notifications/devices` on login → `device-registry.upsert(...)`. * APNs / FCM `BadDeviceToken` / `Unregistered` response → channel returns `classifyError = 'invalid_recipient'` → dispatcher calls `device-registry.invalidate(token)` (sets `invalidated_at`, `enabled=false`) without dead-lettering the notification (it just routes around the dead token). > Amended (2026-11-03, #29041): the push channel (`push-channel.ts` in service-messaging) retires the dead token itself, through `INotificationService.invalidateDeviceToken`, because one delivery fans out to all of the recipient's devices and only the channel knows which token died; the other devices still get the push. The delivery is graded `invalid_recipient` (suppressed, not dead-lettered) only when every device was reported dead. The shipped device shape is `UserDeviceSchema` in `packages/spec/src/system/push-device.zod.ts`, narrowed to `apns` | `fcm` and `ios` | `android` | `web`. `service-push` ships the direct transport; the hosted relay, the default per cloud#3519, is not yet served (#29920), and `PushServicePlugin` refuses a relay configuration at init until it is. -* Domestic vendors (HMS / Xiaomi / OPPO / VIVO) plug in as **alternate channel implementations** (`plugin-notification-push-hms` etc.), not as transports — their on-device SDKs and quotas are too different to share a code path. +* **Domestic vendors are token types the hosted relay routes on, not a plugin boundary** (amended 2026-11-03, cloud#3519). `vendor` on `sys_user_device` says which vendor issued the token, so the sender knows where the delivery goes; it names no plugin and no channel. Core ships `apns` and `fcm` (`packages/spec/src/system/push-config.zod.ts#PushVendorSchema`, closed on `main` at this amendment; `packages/spec/src/system/push-device.zod.ts#UserDeviceSchema` narrows `vendor` to it). In phase 2 the relay fronts HMS / Honor / Xiaomi / OPPO / vivo as well: the server still sends one relay request and the relay routes each delivery by its `vendor`, and `PushVendorSchema` widens to those values in the same change that routes them (its docblock says a new value is a spec change made together with its transport, and #29816 kept it `apns | fcm` on purpose). No card tracks the phase-2 widening yet. *This bullet read: "Domestic vendors (HMS / Xiaomi / OPPO / VIVO) plug in as alternate channel implementations (`plugin-notification-push-hms` etc.), not as transports — their on-device SDKs and quotas are too different to share a code path."* + +### 6.1 Push transports — the hosted relay and direct delivery (amended 2026-11-03, cloud#3519) + +Ruling objectstack-ai/cloud#3519 (**1C 2A 3A 4B 5A 6B 7B**, 2026-11-03) decides how the push channel reaches a phone. The spec shape landed in #29816: `packages/spec/src/system/push-config.zod.ts#PushServiceConfigSchema` and `packages/spec/src/api/push-relay.zod.ts#PushRelayRequestSchema`. What `main` serves is the last bullet; everything else here is decided and planned. + +* **Two transports, discriminated by `transport`; the hosted relay is the default.** The store-published ObjectOS app is signed with ObjectStack's own APNs key and Firebase project, and those credentials never leave ObjectStack. So a server — a private deployment included — delivers to that app through the hosted push relay in objectstack-ai/cloud: `transport: 'relay'` (`packages/spec/src/system/push-config.zod.ts#PushRelayTransportConfigSchema`; an empty block reads as it). The relay authenticates the server by its cloud binding credential (cloud ADR-0008), so the relay block carries no credential; its one key is `endpoint`, for a staging relay, and the production relay's address is the cloud's to publish, never written into the spec. `transport: 'direct'` (`packages/spec/src/system/push-config.zod.ts#PushDirectTransportConfigSchema`) is for a white-label build: an app signed with the operator's own APNs key and Firebase project, which the relay cannot deliver to. It carries one block per vendor (`apns`, `fcm`), at least one, each naming its secret by `credentialRef`. The mixed shapes are refused when written: a credential inside a relay block, a direct block with no vendor block, `endpoint` on a direct block. A block without `transport` keeps parsing: no vendor block reads as the relay, a vendor block reads as direct. +* **The wire.** The server POSTs one `packages/spec/src/api/push-relay.zod.ts#PushRelayRequestSchema` — one delivery per device token: `token`, `vendor`, `payload`, `priority` — with the binding credential as its bearer and no credential in the body. The relay answers one result per delivery, in request order (`packages/spec/src/api/push-relay.zod.ts#PushRelayResponseSchema`): `ok`; `invalid`, the vendor reports the token dead, and the sender retires the device row exactly as the lifecycle bullets above do for a direct transport; or `retry`, a transient failure. It refuses a whole request with the house error envelope (`packages/spec/src/api/push-relay.zod.ts#PushRelayErrorResponseSchema`): 401 `UNAUTHENTICATED` for a bad or revoked binding, 429 `RATE_LIMIT_EXCEEDED` or `QUOTA_EXCEEDED`. One schema, pinned by both ends: the sender here and the relay in objectstack-ai/cloud. +* **Vendor is a token type the relay routes on.** `vendor` on the device row tells the sender, and through it the relay, which vendor the token belongs to, and nothing else. Phase 1 routes `apns` and `fcm`; phase 2 adds the domestic vendors (HMS / Honor / Xiaomi / OPPO / vivo) behind the same relay and widens `PushVendorSchema` with them. There is no per-vendor plugin and no per-vendor channel. +* **Privacy — the relay never receives plaintext content.** Phase 1 is ID-only: a relay delivery's payload (`packages/spec/src/api/push-relay.zod.ts#PushRelayPayloadSchema`) carries the push delivery data — the notification id, its same-server `route` and the sending server's `origin` — and one generic visible line (for example "You have a new notification", in the device locale), and nothing else. `title`, `body`, `subtitle`, `message` and `content` are refused by name; the shell reads the notification from the server after the tap, by its id; the relay treats the payload as opaque and puts it in the vendor message unchanged. Phase 2 is end-to-end encryption of the payload with a device key the app registers (the device registration gains a `pushKey` in that phase, which #29816 left out on purpose; no card yet). The direct transport is outside this rule: it reaches APNs and FCM under the operator's own credentials with no third party in between, and the push channel sends the notification's title and body through it today (PR #29794). +* **Air-gap — no system push; the in-app inbox only.** The relay is hosted in objectstack-ai/cloud and there is no self-hosted relay. A deployment that cannot reach it has no system push, and the direct transport does not restore one, since it needs APNs and FCM. Its users read notifications in the in-app `inbox` channel, which needs no external service. (Email's own air-gap rule, SMTP in core, is §5 and is unchanged.) +* **What `main` serves at this amendment** (objectstack-ai/objectstack at 6a4737ab, 2026-11-03). `direct` is served: `@objectstack/service-push` builds `ApnsPushTransport` (HTTP/2, token auth) and `FcmPushTransport` (HTTP v1) from the direct block and registers the kernel's `push` service; the `push` channel shell in `packages/services/service-messaging/src/push-channel.ts` fans one delivery out to the recipient's enabled devices and retires dead tokens (#29041, PR #29794). `relay` is decided and not served: `PushServicePlugin` refuses a relay block, the default `{}` included, at init with `packages/services/service-push/src/push-plugin.ts#PUSH_RELAY_NOT_SERVED_MESSAGE`, so no server is left believing it pushes when nothing would deliver; the relay sender is #29920, and every relay schema carries a `[PLANNED — not yet served]` marker until it ships. The phase-2 vendors and the end-to-end encryption are decided and planned, with no card yet. ### 7. Topic + Preference matrix @@ -555,7 +567,7 @@ Every channel gets the same Studio surface (built once in `service-messaging`): ### M3 — long-tail outbound channels * `plugin-notification-sms` with Twilio / Aliyun-SMS / Tencent-SMS transports. -* Domestic push: HMS / Xiaomi / OPPO / VIVO as channel plugins. +* Domestic push: HMS / Honor / Xiaomi / OPPO / vivo, fronted by the hosted push relay as token types (phase 2 of cloud#3519, §6.1). *Amended 2026-11-03: this line read "as channel plugins".* * Inbound email parsing (`EmailIngest`) — separate ADR. * Escalation policies + on-call schedules (depends on `service-oncall`). @@ -587,7 +599,7 @@ Every channel gets the same Studio surface (built once in `service-messaging`): 2. **`sys_email` schema drift.** Existing `plugin-email` writes a sent-log row directly. After absorption, writes route through outbox. We add `delivery_id` nullable and backfill nothing — old rows stay readable, new rows tie to delivery. 3. **MJML compile at runtime vs. at template-save time.** Lean toward save-time (cache compiled HTML in `sys_notification_template.compiled_html`) to avoid CPU per send. M1 can compile on send; M2 caches. 4. **Inbox cardinality.** A noisy app could write millions of `sys_inbox_message` rows. We add retention config per topic (`retain_days`, default 90) and a daily cleanup job. M1 ships retention column; cleanup job is M2. -5. **Push payload encryption (Web Push VAPID).** Out of scope for M1; FCM and APNs only. +5. **Push payload encryption (Web Push VAPID).** Out of scope for M1; FCM and APNs only. *Amended 2026-11-03 (cloud#3519): the relay payload is ID-only in phase 1 and end-to-end encrypted with a device key in phase 2 (§6.1); Web Push stays out of scope.* 6. **Multi-environment fan-out.** A notification emitted in env A must not leak to a user in env B. Resolver MUST scope by `environment_id`; preference rows are environment-local. Cross-env notifications require a future federation ADR. 7. **GDPR / CAN-SPAM.** Email channel must include `List-Unsubscribe` + physical address footer hook in templates. M1 includes the header column; M2 builds the one-click endpoint. @@ -598,6 +610,7 @@ Every channel gets the same Studio surface (built once in `service-messaging`): Adopt the design above. Specifically: * **Four built-in outbound channels**: `inbox`, `email`, `webhook`, `push` — all implementing `MessagingChannel`. +* **Push transports (amended 2026-11-03, cloud#3519)**: the hosted push relay is the default push transport and direct APNs/FCM delivery is for white-label builds (`transport: relay | direct`); `vendor` on `sys_user_device` is a token type the relay routes on, and the domestic vendors arrive behind the relay in phase 2, never as plugins; the relay never receives plaintext content (ID-only, then end-to-end encrypted with a device key); an air-gapped deployment has no system push, the in-app inbox only. §4, §6 and §6.1. * **One generalized dispatcher** in `@objectstack/service-messaging`, extracted from `plugin-webhooks`. * **Email transport sub-system**: `smtp` and `sendgrid` in core; others as `EmailTransport` plugins. * **`plugin-email` absorbed** into `plugin-notification-email` with a deprecated alias for one major. From 62ac6d3c9c5d58ef2cd3ae974d765a1e3d33cbf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 17:34:21 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(adr):=20ADR-0012=20push=20relay=20amen?= =?UTF-8?q?dment=20=E2=80=94=20quote=20the=20two=20push=20bullets'=20old?= =?UTF-8?q?=20wording,=20name=20which=20relay=20schemas=20carry=20the=20PL?= =?UTF-8?q?ANNED=20marker,=20and=20the=20relay=20block's=20transport=20key?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contract review round 1 on PR #29952: the status line now lists the TL;DR item and the Goals "Four built-in channels" bullet among the amended passages (both quote "push (APNs + FCM)") and names the three passages that are new; §6.1 says the relay transport block and the seven relay object schemas carry the [PLANNED — not yet served] marker and the two enums do not; the relay block's keys are transport and endpoint. Claude-Session: https://claude.ai/code/session_01WGufzj5io3Zzyb3E6VKCtj Co-authored-by: Claude --- docs/adr/0012-notification-platform.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/adr/0012-notification-platform.md b/docs/adr/0012-notification-platform.md index 0e53b956a47..b763e86f7b4 100644 --- a/docs/adr/0012-notification-platform.md +++ b/docs/adr/0012-notification-platform.md @@ -1,6 +1,6 @@ # ADR-0012: Messaging Platform — Outbound Notifications on a Generalized Outbox -**Status**: Draft (2026-05-25 · scope-revised 2026-05-26) · **Superseded by [ADR-0030](./0030-notification-platform-convergence.md)** (2026-06-01) — the M1 implementation drifted (two conflated inbox objects); ADR-0030 records the correction and realizes this layered model. · **Amended (2026-11-03, #29041; extended the same day by #29817 on ruling objectstack-ai/cloud#3519 — 1C 2A 3A 4B 5A 6B 7B, 2026-11-03 — and the spec shape of #29816)**: push ships as `@objectstack/service-push` (the direct APNs and FCM transports, the kernel's `push` service; the hosted relay, the default transport per cloud#3519, is not yet served, #29920) with its channel shell `push-channel.ts` in `@objectstack/service-messaging`, which also owns `sys_user_device`; every `plugin-notification-push` below reads that way. The #29817 extension amends: the `push` backend in §4 (it read "APNs (HTTP/2, token auth) + FCM (HTTP v1)" alone; it is now `relay` (hosted, the default) or `direct` (APNs/FCM, white-label)); §6's domestic-vendor bullet (it read "alternate channel implementations"; `vendor` on `sys_user_device` is a token type the relay routes on, not a plugin boundary) and the new §6.1, which records the relay's privacy rule (ID-only, then end-to-end encryption with a device key) and the air-gap rule (no system push; the in-app inbox only); the Goals bullet "Plugin extension" and the Non-Goals bullet "Domestic mobile push vendors" (both read "domestic push vendors ship as plugins / plugin transports"); the M3 milestone line; risk 5; and the Decision list. Each amended passage quotes the wording it replaces. ADR-0030 is unchanged. +**Status**: Draft (2026-05-25 · scope-revised 2026-05-26) · **Superseded by [ADR-0030](./0030-notification-platform-convergence.md)** (2026-06-01) — the M1 implementation drifted (two conflated inbox objects); ADR-0030 records the correction and realizes this layered model. · **Amended (2026-11-03, #29041; extended the same day by #29817 on ruling objectstack-ai/cloud#3519 — 1C 2A 3A 4B 5A 6B 7B, 2026-11-03 — and the spec shape of #29816)**: push ships as `@objectstack/service-push` (the direct APNs and FCM transports, the kernel's `push` service; the hosted relay, the default transport per cloud#3519, is not yet served, #29920) with its channel shell `push-channel.ts` in `@objectstack/service-messaging`, which also owns `sys_user_device`; every `plugin-notification-push` below reads that way. The #29817 extension amends: the `push` backend in §4 (it read "APNs (HTTP/2, token auth) + FCM (HTTP v1)" alone; it is now `relay` (hosted, the default) or `direct` (APNs/FCM, white-label)); §6's domestic-vendor bullet (it read "alternate channel implementations"; `vendor` on `sys_user_device` is a token type the relay routes on, not a plugin boundary) and the new §6.1, which records the relay's privacy rule (ID-only, then end-to-end encryption with a device key) and the air-gap rule (no system push; the in-app inbox only); the TL;DR item 2 and the Goals bullet "Four built-in channels" (both read "`push` (APNs + FCM)"); the Goals bullet "Plugin extension" and the Non-Goals bullet "Domestic mobile push vendors" (both read "domestic push vendors ship as plugins / plugin transports"); the M3 milestone line; and risk 5. Each amended passage quotes the wording it replaces; three passages are new and replace nothing: the Non-Goals bullet "System push in an air-gapped deployment", §6.1 and the Decision bullet "Push transports". ADR-0030 is unchanged. **Authors**: Platform team — surfaced from GitHub Issue #1292 ("[P0] notification: no outbound notification channel") **Consumers**: `@objectstack/spec` (new `messaging/` domain), `@objectstack/service-messaging` (new), `@objectstack/plugin-notification-inbox` (new), `@objectstack/plugin-notification-email` (replaces `plugin-email`), `@objectstack/plugin-notification-webhook` (extracted from `plugin-webhooks`), `@objectstack/plugin-notification-push` (new), every flow that has a `notify` node, every template that ships notification rules **Sibling**: [ADR-0013 — Bidirectional Messaging & Conversational Channels](./0013-bidirectional-messaging.md) @@ -16,7 +16,7 @@ This ADR covers the **outbound half** of a broader Messaging Platform. We extrac This ADR proposes: 1. **Extract** the outbox/retry/lock/signing machinery from `plugin-webhooks` into `@objectstack/service-messaging`. -2. **Ship four built-in channels**: `inbox` (always on), `email`, `webhook` (the existing one, now a channel implementation), `push` (the hosted push relay by default; direct APNs + FCM for a white-label build — amended 2026-11-03, cloud#3519, §6.1). +2. **Ship four built-in channels**: `inbox` (always on), `email`, `webhook` (the existing one, now a channel implementation), `push` (the hosted push relay by default; direct APNs + FCM for a white-label build — amended 2026-11-03, cloud#3519, §6.1; this item read "`push` (APNs + FCM)"). 3. **Email becomes a transport sub-system**: SMTP is mandatory baseline (required for air-gapped / on-prem); SendGrid / SES / Resend / Postmark / Aliyun / Tencent register as `EmailTransport` plugins. 4. **Absorb `plugin-email`** into `plugin-notification-email`. Keep `ctx.email.send(...)` as a low-level API for special cases (OTP, password reset) that must bypass user preferences; wrap it as a `MessagingChannel` so flow `notify` nodes get retry/outbox for free. 5. **Other channels (Feishu / DingTalk / WeCom / Slack / SMS / Telegram) ship as plugins**, all implementing the same `MessagingChannel` interface — no special casing. **Bidirectional behaviour (inbound + session) is covered by ADR-0013, not here.** @@ -111,7 +111,7 @@ Pattern: the ones that ship to end-users (Salesforce, ServiceNow, Linear, Slack, ## Goals * **Fix #1292** — `notify` nodes in shipped templates actually deliver to humans, without per-template wiring. -* **Four built-in channels**: `inbox` (always on, in-app), `email` (SMTP + SaaS transports), `webhook` (B2B integration), `push` (the hosted push relay by default; direct APNs + FCM for a white-label build — amended 2026-11-03, cloud#3519, §6.1). +* **Four built-in channels**: `inbox` (always on, in-app), `email` (SMTP + SaaS transports), `webhook` (B2B integration), `push` (the hosted push relay by default; direct APNs + FCM for a white-label build — amended 2026-11-03, cloud#3519, §6.1; this bullet read "`push` (APNs + FCM)"). * **One delivery substrate** — outbox / retry / cluster-lock / dead-letter / signing live in `service-messaging`; every channel inherits them. * **Topic + Preference matrix** — users opt out per topic per channel (Slack/Linear pattern); platform-default policies for system-critical topics that cannot be muted (e.g. password reset). * **Renderer per channel** — same notification, channel-specific body (MJML for email, card JSON for Feishu, 4 KB JSON for push). Plain string is the fallback. @@ -383,12 +383,12 @@ Lifecycle hooks: Ruling objectstack-ai/cloud#3519 (**1C 2A 3A 4B 5A 6B 7B**, 2026-11-03) decides how the push channel reaches a phone. The spec shape landed in #29816: `packages/spec/src/system/push-config.zod.ts#PushServiceConfigSchema` and `packages/spec/src/api/push-relay.zod.ts#PushRelayRequestSchema`. What `main` serves is the last bullet; everything else here is decided and planned. -* **Two transports, discriminated by `transport`; the hosted relay is the default.** The store-published ObjectOS app is signed with ObjectStack's own APNs key and Firebase project, and those credentials never leave ObjectStack. So a server — a private deployment included — delivers to that app through the hosted push relay in objectstack-ai/cloud: `transport: 'relay'` (`packages/spec/src/system/push-config.zod.ts#PushRelayTransportConfigSchema`; an empty block reads as it). The relay authenticates the server by its cloud binding credential (cloud ADR-0008), so the relay block carries no credential; its one key is `endpoint`, for a staging relay, and the production relay's address is the cloud's to publish, never written into the spec. `transport: 'direct'` (`packages/spec/src/system/push-config.zod.ts#PushDirectTransportConfigSchema`) is for a white-label build: an app signed with the operator's own APNs key and Firebase project, which the relay cannot deliver to. It carries one block per vendor (`apns`, `fcm`), at least one, each naming its secret by `credentialRef`. The mixed shapes are refused when written: a credential inside a relay block, a direct block with no vendor block, `endpoint` on a direct block. A block without `transport` keeps parsing: no vendor block reads as the relay, a vendor block reads as direct. +* **Two transports, discriminated by `transport`; the hosted relay is the default.** The store-published ObjectOS app is signed with ObjectStack's own APNs key and Firebase project, and those credentials never leave ObjectStack. So a server — a private deployment included — delivers to that app through the hosted push relay in objectstack-ai/cloud: `transport: 'relay'` (`packages/spec/src/system/push-config.zod.ts#PushRelayTransportConfigSchema`; an empty block reads as it). The relay authenticates the server by its cloud binding credential (cloud ADR-0008), so the relay block carries no credential; beside `transport`, its only key is `endpoint`, for a staging relay, and the production relay's address is the cloud's to publish, never written into the spec. `transport: 'direct'` (`packages/spec/src/system/push-config.zod.ts#PushDirectTransportConfigSchema`) is for a white-label build: an app signed with the operator's own APNs key and Firebase project, which the relay cannot deliver to. It carries one block per vendor (`apns`, `fcm`), at least one, each naming its secret by `credentialRef`. The mixed shapes are refused when written: a credential inside a relay block, a direct block with no vendor block, `endpoint` on a direct block. A block without `transport` keeps parsing: no vendor block reads as the relay, a vendor block reads as direct. * **The wire.** The server POSTs one `packages/spec/src/api/push-relay.zod.ts#PushRelayRequestSchema` — one delivery per device token: `token`, `vendor`, `payload`, `priority` — with the binding credential as its bearer and no credential in the body. The relay answers one result per delivery, in request order (`packages/spec/src/api/push-relay.zod.ts#PushRelayResponseSchema`): `ok`; `invalid`, the vendor reports the token dead, and the sender retires the device row exactly as the lifecycle bullets above do for a direct transport; or `retry`, a transient failure. It refuses a whole request with the house error envelope (`packages/spec/src/api/push-relay.zod.ts#PushRelayErrorResponseSchema`): 401 `UNAUTHENTICATED` for a bad or revoked binding, 429 `RATE_LIMIT_EXCEEDED` or `QUOTA_EXCEEDED`. One schema, pinned by both ends: the sender here and the relay in objectstack-ai/cloud. * **Vendor is a token type the relay routes on.** `vendor` on the device row tells the sender, and through it the relay, which vendor the token belongs to, and nothing else. Phase 1 routes `apns` and `fcm`; phase 2 adds the domestic vendors (HMS / Honor / Xiaomi / OPPO / vivo) behind the same relay and widens `PushVendorSchema` with them. There is no per-vendor plugin and no per-vendor channel. * **Privacy — the relay never receives plaintext content.** Phase 1 is ID-only: a relay delivery's payload (`packages/spec/src/api/push-relay.zod.ts#PushRelayPayloadSchema`) carries the push delivery data — the notification id, its same-server `route` and the sending server's `origin` — and one generic visible line (for example "You have a new notification", in the device locale), and nothing else. `title`, `body`, `subtitle`, `message` and `content` are refused by name; the shell reads the notification from the server after the tap, by its id; the relay treats the payload as opaque and puts it in the vendor message unchanged. Phase 2 is end-to-end encryption of the payload with a device key the app registers (the device registration gains a `pushKey` in that phase, which #29816 left out on purpose; no card yet). The direct transport is outside this rule: it reaches APNs and FCM under the operator's own credentials with no third party in between, and the push channel sends the notification's title and body through it today (PR #29794). * **Air-gap — no system push; the in-app inbox only.** The relay is hosted in objectstack-ai/cloud and there is no self-hosted relay. A deployment that cannot reach it has no system push, and the direct transport does not restore one, since it needs APNs and FCM. Its users read notifications in the in-app `inbox` channel, which needs no external service. (Email's own air-gap rule, SMTP in core, is §5 and is unchanged.) -* **What `main` serves at this amendment** (objectstack-ai/objectstack at 6a4737ab, 2026-11-03). `direct` is served: `@objectstack/service-push` builds `ApnsPushTransport` (HTTP/2, token auth) and `FcmPushTransport` (HTTP v1) from the direct block and registers the kernel's `push` service; the `push` channel shell in `packages/services/service-messaging/src/push-channel.ts` fans one delivery out to the recipient's enabled devices and retires dead tokens (#29041, PR #29794). `relay` is decided and not served: `PushServicePlugin` refuses a relay block, the default `{}` included, at init with `packages/services/service-push/src/push-plugin.ts#PUSH_RELAY_NOT_SERVED_MESSAGE`, so no server is left believing it pushes when nothing would deliver; the relay sender is #29920, and every relay schema carries a `[PLANNED — not yet served]` marker until it ships. The phase-2 vendors and the end-to-end encryption are decided and planned, with no card yet. +* **What `main` serves at this amendment** (objectstack-ai/objectstack at 6a4737ab, 2026-11-03). `direct` is served: `@objectstack/service-push` builds `ApnsPushTransport` (HTTP/2, token auth) and `FcmPushTransport` (HTTP v1) from the direct block and registers the kernel's `push` service; the `push` channel shell in `packages/services/service-messaging/src/push-channel.ts` fans one delivery out to the recipient's enabled devices and retires dead tokens (#29041, PR #29794). `relay` is decided and not served: `PushServicePlugin` refuses a relay block, the default `{}` included, at init with `packages/services/service-push/src/push-plugin.ts#PUSH_RELAY_NOT_SERVED_MESSAGE`, so no server is left believing it pushes when nothing would deliver; the relay sender is #29920; until it ships, the relay transport block (`PushRelayTransportConfigSchema`) and the seven relay object schemas in `packages/spec/src/api/push-relay.zod.ts` (`PushRelayPayloadSchema`, `PushRelayDeliverySchema`, `PushRelayRequestSchema`, `PushRelayResultSchema`, `PushRelayResponseSchema`, `PushRelayErrorSchema`, `PushRelayErrorResponseSchema`) carry a `[PLANNED — not yet served]` marker, and the two enums there, `PushRelayPrioritySchema` and `PushRelayResultStatusSchema`, carry none. The phase-2 vendors and the end-to-end encryption are decided and planned, with no card yet. ### 7. Topic + Preference matrix