Skip to content

Commit 6f7df53

Browse files
docs(adr): ADR-0012 — the hosted push relay transport (default) beside direct APNs/FCM, and vendor as a token type the relay routes on (ruling cloud#3519) (#29952)
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBJTZue4ZJhiS1LdDkbec68N)_ Fixes #29817 Ruling-ref: objectstack-ai/cloud#3519 Clause-②: no (docs only, no spec shape moves) Metadata-protocol: none Skills-docs: none — nothing under `skills/**` or the hand-written `content/docs/**` names the push relay, the push transport selection or `PushVendorSchema` (`git grep -i -E "push relay|transport: ?'relay'|PushVendorSchema|PushRelayRequest|hosted relay" -- skills content/docs ':!content/docs/references'` at cce992b: zero hits). The ADR is the only document that moves. ## 维护者速读 - **改了什么**:ADR-0012(通知平台)按 cloud#3519 的裁决改写推送渠道的决定:推送默认经我们托管的推送中继,白标应用才直连 APNs/FCM;设备表上的 `vendor` 只是令牌类型(中继据此路由),不是插件边界;国内厂商(HMS/荣耀、小米、OPPO、vivo)第二阶段由同一个中继承接,不再是「插件」;中继永远看不到通知明文(第一阶段只传 ID,第二阶段用设备密钥端到端加密);离线(air-gap)部署没有系统推送,只有站内收件箱。每处修订都引用了被替换的原文。 - **为什么改**:ADR-0012 §4 只写了 APNs/FCM,§6、目标、非目标和 M3 里程碑仍把国内厂商写成插件或备选渠道,与 2026-11-03 的裁决及已落地的 spec 形状(#29816 → PR #29886;#29041 → PR #29794)相抵触。 - **风险与回滚**:只改一份 ADR 文本,不动任何代码、schema 或生成产物;ADR-0030 未改。回滚即还原本 PR。 - **先看**:`docs/adr/0012-notification-platform.md` 状态行的 Amended 说明(记录改了哪些段落)、新增的 §6.1(中继/直连、线上协议、隐私、离线、main 上已交付什么)、§4 的推送行。 - **两分钟验证**:`pnpm check:adr-index && pnpm check:adr-symbol-anchors && node scripts/check-adr-links.mjs`,预期三条都退出 0;再把 §6.1 第一条与 `packages/spec/src/system/push-config.zod.ts` 的 docblock 对照,应逐句一致。 - **需要你做的**:受管路径(`docs/adr/**`,Tier H):请审阅并手动合并;席位不合并、不排队、不改 draft。 ## What changes One file, `docs/adr/0012-notification-platform.md` (+22 / −9). Every passage is written to the shape `main` ships at 6a4737a (PR #29886 for #29816, PR #29794 for #29041), not to the card's wording where the two differ, and every amended passage quotes the wording it replaces. What is not served yet (the relay sender #29920, the phase-2 vendors, the end-to-end encryption) is worded as decided and planned, with its card where one exists. | Where | Before | After | |---|---|---| | **Status line** | `Amended (2026-11-03, #29041)`: push ships as `service-push`; the relay is the default per cloud#3519 and not yet served (#29920). | The same note, extended rather than a parallel status line: `… #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`, listing each passage the extension amends and what it read before. The status keyword stays `Draft` (first in the line), `Superseded by ADR-0030` stays. | | **TL;DR item 2, Goals "Four built-in channels"** | `push` (APNs + FCM) | `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 / bullet read "`push` (APNs + FCM)" | | **Goals "Plugin extension"** | … / Telegram / domestic push vendors ship as `MessagingChannel` plugins with zero core change. | The domestic push vendors leave the plugin list; an italic amendment quotes the old wording and says they are token types the hosted push relay routes on (§6.1). | | **Non-Goals "Domestic mobile push vendors"** | Schema reserves a `vendor` discriminator on `sys_user_device`, but only APNs and FCM ship in core. Domestic vendors are plugin transports. | … **in phase 1**: `vendor` is the token-type discriminator the push transport routes on; core ships `apns` and `fcm` (`PushVendorSchema`, closed on `main`); in phase 2 the relay fronts the domestic vendors too and `PushVendorSchema` widens in the same change that routes them. The old wording is quoted; "neither plugin transports nor alternate channels". | | **Non-Goals, new bullet** | — | **System push in an air-gapped deployment**: the relay is hosted in objectstack-ai/cloud and there is no self-hosted relay; no route to it means no system push, and direct does not restore one (it needs APNs and FCM); the in-app inbox only. | | **§4 push row** | Backend: "Direct: APNs (HTTP/2, token auth) + FCM (HTTP v1) …, for a white-label build. The hosted relay is the default transport (cloud#3519) and is not yet served (#29920)". | Backend: `relay` (hosted, **the default**: the server POSTs an ID-only `PushRelayRequestSchema` to the hosted relay, authenticated by its cloud binding credential; the relay holds the store app's APNs/FCM credentials) and `direct` (APNs HTTP/2 token auth + FCM HTTP v1, the operator's own credentials, white-label only); the `transport` union on `PushServiceConfigSchema` (#29816). Status: `direct` shipped (#29041, PR #29794); `relay` decided, not yet served: `PushServicePlugin` refuses a relay block, `{}` included, at init until #29920. The pre-2026-11-03 cell text is quoted. | | **§6 domestic-vendor bullet** | 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**: what `vendor` on the row means; `PushVendorSchema` is `apns` and `fcm`, closed on `main` at this amendment (`UserDeviceSchema` narrows `vendor` to it); in phase 2 the relay routes HMS / Honor / Xiaomi / OPPO / vivo by the same `vendor` and the schema widens in the same change that routes them (the schema's docblock already says so; #29816 kept it closed on purpose); no card tracks the widening yet. The old bullet is quoted. | | **§6.1 (new)** | — | *Push transports — the hosted relay and direct delivery (amended 2026-11-03, cloud#3519)*: the ruling and the #29816 schemas; the two transports and the mixed-shape refusals as `push-config.zod.ts` has them; the wire (`PushRelayRequestSchema` / `PushRelayResponseSchema` with `ok` / `invalid` / `retry`, `PushRelayErrorResponseSchema` 401 / 429); vendor as token type; **Privacy** (phase 1 ID-only: `data` + one generic visible line, `title` / `body` / … refused by name, opaque to the relay; phase 2 end-to-end encryption with a device key, `pushKey` left out of the registration by #29816 on purpose, no card yet; the direct transport is outside the rule and sends title and body today, PR #29794); **Air-gap** (no system push; the in-app inbox only); **What `main` serves at this amendment** (direct served by `service-push` + `push-channel.ts`; relay refused at init with `PUSH_RELAY_NOT_SERVED_MESSAGE`, sender #29920, `[PLANNED — not yet served]` markers; phase-2 vendors and E2EE planned, no card). | | **M3 milestone** | 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); the old line is quoted. | | **Risk 5** | Push payload encryption (Web Push VAPID). Out of scope for M1; FCM and APNs only. | Unchanged sentence, plus: 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. | | **Decision list, new bullet** | — | **Push transports (amended 2026-11-03, cloud#3519)**: relay default / direct white-label; `vendor` a token type the relay routes on, domestic vendors behind the relay in phase 2, never plugins; never plaintext (ID-only, then E2EE); air-gapped means no system push, inbox only. | Round 1 (contract review 6610988758, no must-fix; three accuracy items): the status line now lists the TL;DR item 2 and the Goals "Four built-in channels" bullet among the amended passages (both now quote "`push` (APNs + FCM)") and names the three passages that are new and replace nothing (the Non-Goals air-gap bullet, §6.1, the Decision bullet); §6.1 says which schemas carry the `[PLANNED — not yet served]` marker (the relay transport block and the seven relay object schemas; the two enums `PushRelayPrioritySchema` and `PushRelayResultStatusSchema` carry none); the relay block's keys are `transport` and `endpoint`. Not changed, on purpose: the §6 `UserDeviceSchema` proposal block (the historical proposal; the #29041 amendment note under it already records the shipped, narrowed shape), the §1 diagram, §3 / §10 / §11 package and file names (the status line's `plugin-notification-push` reading covers them), the M1 milestone and the #1292 acceptance table (what M1 delivered), and ADR-0030 (unchanged, as the card requires). ## Why cloud#3519 (ruled 1C 2A 3A 4B 5A 6B 7B on 2026-11-03) decided that the store-published app pushes through a hosted relay, that `vendor` is a token type the relay routes on, that the relay never sees plaintext, and that an air-gapped deployment has no system push. #29816 (PR #29886) landed the spec shape and #29041 (PR #29794) landed the direct transport with a header note saying the relay is the default and not yet served. ADR-0012 still carried the pre-ruling decisions in §4 (APNs/FCM as the only push backend), §6 (domestic vendors as alternate channel plugins), the Goals and Non-Goals (domestic vendors as plugin transports) and the M3 milestone. Prime Directive #13: a recorded decision is reversed by an ADR edit. ## Tests n/a — docs only. No code, schema, test or generated artifact moves; the ADR gates below are the checks that read this file. ## Acceptance notes - **ADR-0030 read for conflicting push-vendor or relay wording: none found.** Its push mentions are the L5 row `sys_user_device (push)` and item 3 `push (sys_user_device + APNs/FCM)`. Neither says the server reaches APNs/FCM itself and neither names a vendor plugin; the relay forwards to APNs and FCM and direct reaches them, so both readings hold. ADR-0030 is untouched. - **cloud#3519 was not readable from this session** (GitHub access is scoped to objectstack-ai/objectstack), so the card body binds, as the order says. The ADR cites the ruling as the card states it (1C 2A 3A 4B 5A 6B 7B) and attributes no decision to an individual letter beyond what #29816 attributes (2A: ID-only). - **`Clause-②` spelling.** The order says `Clause-②: n/a (docs only)`; the claim (6610076511) says `Clause-②: no (docs only, no spec shape moves)`. The body carries the claim's spelling: `scripts/pm/clause2-line.mjs` accepts only `yes` / `no` (`CLAUSE2_VALUES`), so `n/a` would read `malformed` to `check-changeset-no-major`. Reported as a deviation from the order's spelling. - **Beyond the sections the card names, same wording family**: the M3 milestone line ("as channel plugins"), risk 5 (payload encryption) and the Decision list are amended too, because each restated a decision the ruling reverses; the TL;DR and Goals "Four built-in channels" bullets gain the relay in parentheses so the ADR does not open by naming APNs + FCM as the push backend. - **Stale code comments, not this card's surface** (docs only; comments are not a card class under os-dev rule 3): `packages/spec/src/system/push-config.zod.ts` still says in two places that ADR-0012 §6 routes the other vendors to alternate channel plugins (the docblock's "Out of scope here" paragraph and the `PushVendorSchema` comment), and `packages/spec/src/system/push-device.zod.ts` says "the domestic Android vendors plug in as alternate channel implementations". Carrier: the phase-2 change that widens `PushVendorSchema` (no card yet); noted here, not filed. - **No card tracks the phase-2 vendors or the end-to-end encryption**; the ADR says "no card yet" for both rather than inventing a tracker. The relay sender is #29920. - **Harness steps not taken, per os-dev rule 2**: no `assignees` on `pr_create`, no reviewer request; the seat sets both at hand-over. The `pr_create` body carries no seat signature line: the relay's signature rule scopes to `comment_edit` / `issue_patch`, and the harness pins the attribution block to the body's first two lines (PR #29886, same seat, same day, is the precedent); the seat appends its trailer at hand-over. - `skip-changeset` is declared on `pr_create`: `docs/adr/**` publishes nothing. ## Verification All readings at head `62ac6d3c` on `claude/project-thread-geqk0x`, off `origin/main` `6a4737ab` (round 0 readings at `cce992bf` were identical: the same 21 commands, all exit 0) (which carries PR #29886 and PR #29794). Every exit code was captured before any pipe; the gate sweep ran serially from a runner script with stdin closed, after the formula/lint build under `scripts/pm/os-verify-lock.sh`. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack docs/adr/0012-notification-platform.md`: 21 commands (15 pnpm, 6 direct node; 13 matched by path, 8 whole-tree). **All 21 exit 0**, among them the ADR gates `check-adr-links` (and its `--self-test`), `check:adr-anchors`, `check:adr-index` ("docs/adr/README.md is current, every record parsed") and `check:adr-symbol-anchors` ("3075 anchors across 149 records resolve"; the new `path#symbol` anchors in the Non-Goals, §6 and §6.1 are among them), and the docs gates `check:doc-authoring`, `check:doc-formula-expressions`, `check:nul-bytes`, `check-closing-keyword-parity`, `check:pm-governed-merges` and `check:pm-prior-rulings`. - `node scripts/pm/dispatch-gates.mjs --ran RAN_FILE --repo objectstack-ai/objectstack` at `62ac6d3c`: "21 derived, 21 run, 0 NOT-MEASURED, 0 UNRUN"; change set 1 path, +22 / −9, under the 3000-line human-merge threshold. - `pnpm exec turbo run build --filter=@objectstack/formula --filter=@objectstack/lint --concurrency=2` under `os-verify-lock.sh`, the `check:doc-formula-expressions` prerequisite: `VERDICT command-exit 0`, held the lock 154 s. - `GITHUB_EVENT_NAME=pull_request node scripts/check-changeset-no-major.mjs --base origin/main --event EVENT_JSON` (a pull_request payload carrying this body and the skip-changeset label): exit 0; "LEVEL AXIS: this PR declares clause-② `no` and metadata-protocol `none`, and it introduces no changeset entry graded `minor` or above and moves no metadata-protocol baseline". - Dogfood battery: n/a — no `!` in the title and no `(narrowing)` arm. - CI: every family CI runs on the PR is read from the PR's check-runs at review, not re-run locally. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (No code moves.) - [x] 2. Every name I added uses the casing for its kind. (No names added.) - [x] 3. No formatter ran; `pnpm lint` passes. (A Markdown file; the derived ADR and doc gates ran instead, below.) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. (Publishes nothing: `skip-changeset`; both declaration lines are above.) - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (Not touched.) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (The 维护者速读 section is the maintainer's Chinese summary, by the dispatch contract.) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzj5io3Zzyb3E6VKCtj --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzj5io3Zzyb3E6VKCtj)_ Body refreshed by spec seat 3 (round 1, head `62ac6d3c`): 2026-11-03T17:44Z --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 18112bf commit 6f7df53

1 file changed

Lines changed: 22 additions & 9 deletions

File tree

0 commit comments

Comments
 (0)