Skip to content

Commit c50e0df

Browse files
docs(adr-0012): the relay's generic push line is in the recipient's language (#30834)
<!-- claude-projects-attribution: {"github_login":"os-elon-musk"} --> _Requested by **Elon** · [project thread](https://claude.ai/code/project/chan_01WedDzV26nniwxv7jC88B9D?thread=cmsg_01WedDzV26nniwxv7jC88B9DTztp78uPdXXe9TemCPVMrD)_ Fixes #30670 Clause-②: no Metadata-protocol: none domain:server#5 card thread · session `session_016WV4cyJFQN5xeu4D3wmTU8` · branch `claude/project-thread-r8swte` (named in the seat's re-posted claim on the card). Branched from `main` at 2038c56, after PR #30749 merged (97e4249). ## 维护者速读 - 改了什么:ADR-0012 §6.1 隐私条把中继通用提示语的语言从「设备语言」改为「收件人的语言(`sys_user.locale`,其次部署默认)」;状态行加一条修订记录;「What `main` serves」条重新标明它所依据的代码版本(97e4249)。 - 为什么改:按 #29920 Q4 C 裁决,PR #30749 实际按收件人语言选这句提示语,ADR 原文与实现不一致;树钉是 PR #30749 契约复核 should-fix 1 留下的尾巴。 - 风险与回滚:纯 ADR 文字,一个文件 3 行,无代码、无行为改动;回滚即 revert。 - 先看:`docs/adr/0012-notification-platform.md` 的 §6.1 隐私条(Privacy 那一条)和 Status 行末尾新增的 Amended 段。 - 两分钟验证:`git grep -n "device locale" docs/adr/0012-notification-platform.md`,预期只剩修订记录里引用的旧措辞两处。 - 需要你做的:`docs/adr/**` 属 Tier H,席位 ACCEPT 后需要 GOVERNED_APPROVERS(os-zhuang / hotlong)批准或你本人合并。 ## What changes One file, `docs/adr/0012-notification-platform.md`, three lines: - **§6.1, privacy bullet:** the relay's generic visible line is chosen "in the recipient's language (`sys_user.locale`, then the deployment default)"; it read "in the device locale". This is what ruling Q4 C on #29920 decided and what PR #30749 ships (`resolveRecipientLocale` in `push-channel.ts`; the `text` describe in `push-relay.zod.ts` says "in the recipient's language"). The bullet ends with an italic amendment note quoting the replaced wording, in the form PR #30749 used. - **§6.1, "What `main` serves" bullet:** the heading names its tree again, `objectstack-ai/objectstack at 97e4249, 2026-11-06` (the PR #30749 merge, the tree the bullet's reading describes). The heading PR #30749 replaced read "at 6a4737a, 2026-11-03"; delta contract review 6674074177 on PR #30749, should-fix 1, carried in card note 6674101046. - **Status line:** one `Amended (2026-11-06, #30670, on ruling Q4 C of #29920)` clause naming both edits and the wording each replaces. No `scripts/adr-anchors/` row: anchors pin ADR ids in code files (`scripts/adr-anchors/README.md`), and this change touches no code. ## Tests None owed: ADR prose only. ## Acceptance notes - The tree pin names the merge commit 97e4249 rather than the current `main` head (2038c56); the bullet's text landed with that merge and no later commit touched `service-push`, `cloud-binding.ts`, `push-relay.zod.ts` or this ADR. ## Verification Readings against objectstack-ai/objectstack at d5b176c. <details><summary>Derived gates (dispatch-gates --commands)</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 21 families derived from the one path. - All 21 exit 0. `pnpm --filter @objectstack/lint run check:doc-formula-expressions` first exited 3 (prerequisite: lint and formula unbuilt in the fresh worktree); after `pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/lint` it exits 0. - Also run, beyond the derivation: `pnpm check:cli-command-ids` exit 0 (`pnpm check:adr-symbol-anchors` is in the derived set, exit 0). - `--ran` reconciliation: 21 derived, 21 run, 0 NOT-MEASURED, 0 UNRUN. - Families CI runs beyond the derived set: read from this PR's check-runs. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_016WV4cyJFQN5xeu4D3wmTU8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f283226 commit c50e0df

1 file changed

Lines changed: 3 additions & 3 deletions

File tree

‎docs/adr/0012-notification-platform.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# ADR-0012: Messaging Platform — Outbound Notifications on a Generalized Outbox
22

3-
**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.
3+
**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").
44
**Authors**: Platform team — surfaced from GitHub Issue #1292 ("[P0] notification: no outbound notification channel")
55
**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
66
**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
386386
* **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.
387387
* **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.
388388
* **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.
389-
* **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).
389+
* **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".*
390390
* **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.)
391-
* **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.*
391+
* **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.*
392392

393393
### 7. Topic + Preference matrix
394394

0 commit comments

Comments
 (0)