Repository navigation
Commit 6f7df53
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
0 commit comments