diff --git a/docs/adr/0012-notification-platform.md b/docs/adr/0012-notification-platform.md index 162f2ed18ef..9adfccc03e6 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 hosted relay, the default transport per cloud#3519, and the direct APNs and FCM transports, behind the kernel's `push` service; amended 2026-11-06, #29920 / PR #30749: this parenthesis read "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. +**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 hosted relay, the default transport per cloud#3519, and the direct APNs and FCM transports, behind the kernel's `push` service; amended 2026-11-06, #29920 / PR #30749: this parenthesis read "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. · **Amended (2026-11-06, #30670, on ruling Q4 C of #29920)**: §6.1's privacy bullet chooses the generic visible line in the recipient's language (`sys_user.locale`, then the deployment default, else English), where it read "in the device locale"; and §6.1's "What `main` serves" bullet names the tree its reading was taken against again (objectstack-ai/objectstack at 97e42498, the PR #30749 merge), as the heading PR #30749 replaced did ("at 6a4737ab, 2026-11-03"). **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) @@ -386,9 +386,9 @@ Ruling objectstack-ai/cloud#3519 (**1C 2A 3A 4B 5A 6B 7B**, 2026-11-03) decides * **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). +* **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 recipient's language (`sys_user.locale`, then the deployment default, else English)), 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). *Amended 2026-11-06 (#30670, ruling Q4 C on #29920): the generic line's language read "in the device locale".* * **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** (amended 2026-11-06, #29920 / PR #30749). Both transports are served by `@objectstack/service-push`, behind the kernel's `push` service. `direct`: `ApnsPushTransport` (HTTP/2, token auth) and `FcmPushTransport` (HTTP v1), built from the direct block (#29041, PR #29794). `relay`, the default: `packages/services/service-push/src/transports/relay.ts#RelayPushSender` POSTs one ID-only `PushRelayRequestSchema` per push to the relay endpoint (the relay block's `endpoint`, else the control-plane URL plus `packages/services/service-push/src/transports/relay.ts#PUSH_RELAY_PATH`), with the bearer read on every send from the `cloud-binding` service that `@objectstack/cloud-connection` registers (`packages/cloud-connection/src/cloud-binding.ts#createCloudBinding`, the interface `packages/spec/src/contracts/cloud-binding-service.ts#ICloudBindingService`). While the server is not bound, the relay transport lists no vendor, so the push channel is suppressed as `transport_not_configured` and notifications reach the in-app inbox; the bind takes effect without a restart. A deployment that declared it has no control plane (`OS_CLOUD_URL=off`, `none`, `local`, `disabled`) has its relay block refused at init with `packages/services/service-push/src/push-plugin.ts#PUSH_RELAY_AIR_GAPPED_MESSAGE`, the air-gap rule above. 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. The relay schemas in `packages/spec/src/api/push-relay.zod.ts` no longer carry a `[PLANNED — not yet served]` marker. The phase-2 vendors and the end-to-end encryption are decided and planned, with no card yet. *Amended 2026-11-06 (#29920, PR #30749): this bullet was headed "What `main` serves at this amendment (objectstack-ai/objectstack at 6a4737ab, 2026-11-03)", and its relay half read "`relay` is decided and not served: `PushServicePlugin` refuses a relay block, the default `{}` included, at init with `PUSH_RELAY_NOT_SERVED_MESSAGE` (`push-plugin.ts`), 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 `push-relay.zod.ts` … carry a `[PLANNED — not yet served]` marker". That export is retired by PR #30749, so the quotation names it without its anchor.* +* **What `main` serves** (objectstack-ai/objectstack at 97e42498, 2026-11-06; amended 2026-11-06, #29920 / PR #30749). Both transports are served by `@objectstack/service-push`, behind the kernel's `push` service. `direct`: `ApnsPushTransport` (HTTP/2, token auth) and `FcmPushTransport` (HTTP v1), built from the direct block (#29041, PR #29794). `relay`, the default: `packages/services/service-push/src/transports/relay.ts#RelayPushSender` POSTs one ID-only `PushRelayRequestSchema` per push to the relay endpoint (the relay block's `endpoint`, else the control-plane URL plus `packages/services/service-push/src/transports/relay.ts#PUSH_RELAY_PATH`), with the bearer read on every send from the `cloud-binding` service that `@objectstack/cloud-connection` registers (`packages/cloud-connection/src/cloud-binding.ts#createCloudBinding`, the interface `packages/spec/src/contracts/cloud-binding-service.ts#ICloudBindingService`). While the server is not bound, the relay transport lists no vendor, so the push channel is suppressed as `transport_not_configured` and notifications reach the in-app inbox; the bind takes effect without a restart. A deployment that declared it has no control plane (`OS_CLOUD_URL=off`, `none`, `local`, `disabled`) has its relay block refused at init with `packages/services/service-push/src/push-plugin.ts#PUSH_RELAY_AIR_GAPPED_MESSAGE`, the air-gap rule above. 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. The relay schemas in `packages/spec/src/api/push-relay.zod.ts` no longer carry a `[PLANNED — not yet served]` marker. The phase-2 vendors and the end-to-end encryption are decided and planned, with no card yet. *Amended 2026-11-06 (#29920, PR #30749): this bullet was headed "What `main` serves at this amendment (objectstack-ai/objectstack at 6a4737ab, 2026-11-03)", and its relay half read "`relay` is decided and not served: `PushServicePlugin` refuses a relay block, the default `{}` included, at init with `PUSH_RELAY_NOT_SERVED_MESSAGE` (`push-plugin.ts`), 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 `push-relay.zod.ts` … carry a `[PLANNED — not yet served]` marker". That export is retired by PR #30749, so the quotation names it without its anchor.* ### 7. Topic + Preference matrix