Skip to content
Merged
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
6 changes: 3 additions & 3 deletions docs/adr/0012-notification-platform.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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

Expand Down
Loading