Skip to content

feat(notifications): the push channel — service-push (APNs + FCM) and its channel shell in service-messaging - #29794

Merged
os-zhuang merged 11 commits into
mainfrom
claude/project-thread-ld23bk
Nov 3, 2026
Merged

os-zhuang merged 11 commits into
mainfrom
claude/project-thread-ld23bk

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Warren · project thread
Fixes #29041

Clause-②: yes (widening)
Metadata-protocol: none

维护者速读

  1. 改了什么:通知现在能推送到手机。新增推送服务包 @objectstack/service-push(苹果 APNs、谷歌 FCM),消息服务新增“推送”渠道:流程里的通知节点选了推送,就发到收件人登记过的每一台手机,点开推送进入这条通知对应的页面;手机端应用已卸载(令牌失效)时,自动停用那台设备。同时修订 ADR-0012(通知平台):推送包名定为 service-push,§4 推送一行改为“已上线”,§6 写明失效令牌由推送渠道停用;ADR-0030 未改。推送服务目前只支持直连(运营方自己的 APNs 密钥和 Firebase 项目,适合白牌应用);cloud#3519 定下的默认方式“托管中继”还没有上线(service-push: send through the hosted relay transport (push-relay.zod.ts wire, cloud binding credential) #29920),配置成中继会在启动时被拒绝;中继本身的 ADR 改写由 ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817 负责,本 PR 不写。
  2. 为什么改:Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041 的验收条件。之前流程里选了推送也显示“运行成功”,实际一条推送都没有发出(已在 main 上复现)。
  3. 风险与回滚:纯新增。没有启用推送服务的部署,行为完全不变;启用了推送服务却没写配置、或配置为中继(relay)的部署,会在启动时报错并提示改用 transport: direct,不会出现以为能推送、实际什么都发不出去的情况;推送密钥只能通过引用(credentialRef)配置,写明文密钥会在启动时被拒绝;推送里带的跳转只会是本服务器内的路径,外站链接一律不带。回滚即还原本 PR。
  4. 先看:packages/services/service-messaging/src/push-channel.ts(发给哪些手机、失效设备停用、结果怎么分级),再看 docs/adr/0012-notification-platform.md 的修订处。
  5. 两分钟验证:pnpm --filter @objectstack/service-automation exec vitest run src/builtin/notify-push-delivery.integration.test.ts,预期 3 条全过:模拟的苹果和谷歌推送服务各收到一条带跳转的推送,被报失效的设备变为停用,同一用户的另一台手机照常收到。
  6. 需要你做的:席位契约复核已通过(评论 6606770732,Head-sha: 5b7095572f3f1836f18a0ef62bd072485353edcd),请批准 ADR-0012(通知平台)的这次修订。本 PR 属于 Tier H 就是因为它,只改了状态行的 Amended 说明和 §4、§6 两处。

What changes

  • New package @objectstack/service-push (packages/services/service-push, named per the maintainer's 2026-11-03 ruling: kernel services are service-*). PushServicePlugin registers the kernel's push service, an IPushService with one transport per configured vendor:
    • ApnsPushTransport: APNs over HTTP/2 (node:http2, one held session) with token-based auth. An ES256 provider token is signed with the .p8 key and reused for fifty minutes (APNs refuses one older than an hour and throttles refreshes under twenty). 200 is sent; BadDeviceToken / Unregistered / 410 is invalid_token; ExpiredProviderToken re-signs once; 429 / 5xx are retryable; other refusals are not. A token that is not hex is answered invalid_token and never reaches the request path.
    • FcmPushTransport: FCM HTTP v1. An RS256 assertion signed with the service account's key is exchanged for an OAuth access token, kept until a minute before expiry. UNREGISTERED (or 404 NOT_FOUND) is invalid_token; a 401 refreshes the token once; 429 / 5xx are retryable.
    • Transport: it serves the direct transport (transport: 'direct', or a vendor block with no transport; the operator's own APNs key and Firebase project, for a white-label build). The hosted relay, the default of PushServiceConfigSchema since spec: push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server→relay wire shape (ruling cloud#3519) #29816 and so also an empty config, is refused at init with PUSH_RELAY_NOT_SERVED_MESSAGE and registers nothing, until service-push: send through the hosted relay transport (push-relay.zod.ts wire, cloud binding credential) #29920 ships its sender.
    • Configuration is the plugin's config option, parsed with PushServiceConfigSchema at init, so an inline secret or a misspelt key fails the boot with the schema's guidance. Each secret is a credentialRef, resolved when the transport first signs through resolveCredential (default: the open tier's rule, the name of an environment variable; ADR-0143 D3). No error or log line carries a secret or a whole device token.
  • @objectstack/service-messaging: the push channel shell (push-channel.ts), mounted through the same lazy provider as the sms channel, so it is present exactly while a push service is registered. One delivery reaches every enabled device of the recipient on a vendor the service can reach. The push carries the notification's title and body (already in the recipient's language) and the delivery data PushDeliveryDataSchema declares: notificationId, plus route when the notification's actionUrl passes PushRouteSchema (an off-server, dot-segment or encoded path is left out; the delivery still goes). Title and body are cut to 256 / 2048 UTF-8 bytes so the payload stays under the vendors' 4 KB.
    • A token reported dead is retired through invalidateDeviceToken (row kept, enabled: false, invalidatedAt); the user's other devices still get the push.
    • Grades: one device pushed ⇒ delivered (a failure beside it is logged, not retried, so no device is pushed twice); every device dead, or no enabled device ⇒ invalid_recipient (suppressed); devices only on an unconfigured vendor, or a refusal no retry repairs ⇒ permanent; timeout / 429 / 5xx with nothing delivered ⇒ retryable; no push service ⇒ refused transport_not_configured, as the sms and email channels refuse it. isAvailable answers from the push service's vendors, no I/O.
  • Who owns sys_user_device (contract-review item 3 of 6593642547): service-messaging owns it; its push channel reads it and retires tokens in it; service-push never touches it. user-device.object.ts and push-device.zod.ts now say the same.
  • packages/spec: PLANNED flips and text only. The [PLANNED — not yet read] markers come off PushServiceConfigSchema and PushDirectTransportConfigSchema (the APNs and FCM blocks carried none on main) and [PLANNED — not yet served] off PushDeliveryDataSchema (their reader and producer ship here). PushRelayTransportConfigSchema keeps a marker, [PLANNED — not yet served], saying the push service refuses it at boot; one docblock paragraph in push-config.zod.ts says what is served today; the two marker pins flip to "no PLANNED marker". push-config.zod.ts's docblock names @objectstack/service-push where it named plugin-notification-push. NotificationChannelSchema's JSDoc lists push among the registered channels (Wanted 7 of spec(notification): the push device registry's protocol face and the push payload route (spec slice of #29041) #29067). The three items of 6593642547: the NOT_FOUND synonym waiver's reason names service-messaging's provenance row; INotificationService's device comments say the push channel reads listDevices and calls invalidateDeviceToken (true as of this PR); the ownership sentence above. Reference pages regenerated (push-config.mdx, push-delivery.mdx). No key, shape or export changes.
  • ADR-0012 (superseded by ADR-0030, which is unchanged): the status line gains Amended (2026-11-03, #29041): push ships as service-push plus the channel shell in service-messaging, which owns sys_user_device, and every plugin-notification-push in the ADR reads that way. §4's push row names the shipped packages; §6 records that the channel, not the dispatcher, retires the dead token (one delivery fans out to several devices) and that the shipped device shape is system/push-device.zod.ts; the amendment also says service-push ships the direct transport and that the hosted relay, the default per cloud#3519, is not yet served (service-push: send through the hosted relay transport (push-relay.zod.ts wire, cloud binding credential) #29920). §6's domestic-vendor sentence is left exactly as on main: ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817 rewrites §4 and §6 for the relay itself.
  • Docs and registration: content/docs/plugins/packages.mdx (service count 17 → 18, a service-push entry, the messaging line), CONTRIBUTING.md package row, .changeset/config.json fixed group, packages/spec/llms.txt package count 73 → 74 (forced by check:llms-txt; prose in the spec tree, not a schema), README.
  • Changeset .changeset/29041-push-notification-channel.md: service-push, service-messaging and spec all patch (Metadata-protocol: none takes patch, ruling A on changeset levels: minor only when the metadata protocol changes (added / narrowed / semantic), patch otherwise; release notes grouped by what the reader must do (maintainer ruling A, 2026-10-21) #25105).

Why

#29041 asks that a notify node deliver a push to a registered device through APNs/FCM, carry route, and retire a token reported invalid. The protocol face (#29067, PR #29218) and the device registry (#29068, PR #29628) had landed; nothing sent.

Tests

  • Red first, on origin/main 7b55ea95 (a throwaway probe, not committed): a notify node with channels: ['push'] over the real MessagingService with a registered APNs device and a local HTTP/2 server → run.success: true, summary.acted: 0, APNS requests received: 0; the probe's expect(received).toHaveLength(1) failed.
  • Acceptance service-automation/src/builtin/notify-push-delivery.integration.test.ts (3): the node delivers to an iOS and an Android device through mock APNs (HTTP/2) and mock Google (OAuth + FCM v1) with the real transports, route and notificationId in both payloads; the source record's /OBJECT/ID rides as route when no url is given; a token APNs answers 410 Unregistered is retired (enabled: false, invalidatedAt set), the other device still gets the push, and the retired device is not pushed again.
  • service-push (30): APNs request shape and an ES256 token the public key verifies, dead-token reasons, re-sign on ExpiredProviderToken, token reuse for fifty minutes, retryable vs not, unresolved and unusable credentials never echoed and never sent, a non-hex token never in the path; FCM assertion verified with the public key, message shape, UNREGISTERED, access-token reuse and 401 refresh, retryable vs not, bad service account, token-endpoint refusal; plugin config parse, inline-secret refusal at init, host transport override, masked token in logs, the env resolver.
  • service-messaging/src/push-channel.test.ts (14): device selection, data shape against PushDeliveryDataSchema, off-server routes left out, dead-token retirement with the other device still pushed, every grade above, byte truncation, isAvailable, and the plugin mount: refused before a push service exists, delivered through it once registered.
  • Ablations (each restored, blob equal to HEAD, git diff HEAD empty; ablation-replace.mjs under a trap): the invalidateDeviceToken call removed → push-channel.test.ts 2 failed of 14 (the two retirement cases); PushRouteSchema.safeParse(actionUrl).success replaced by true → 1 failed of 14 (the off-server route case).
  • Round 3 service-push/src/push-plugin.test.ts: direct boots both named and inferred from a vendor block; relay is refused four ways (no config, {}, transport: 'relay', a staging endpoint), each registering nothing; the message content. service-push 35/35. Ablation: the refusal line replaced by a comment → 4 failed of 12 (the four relay arms); restored, git diff HEAD empty.
  • Forced test edits: push-config.test.ts and push-delivery.test.ts marker pins flip from "carries the PLANNED marker" to "carries no PLANNED marker".
Local readings (tests at `1ebbde26b`; `b6f8119b` changes only the changeset level)
  • Closure build: pnpm exec turbo run build --concurrency=2 --filter='./packages/**' under os-verify-lock, VERDICT command-exit 0 (6m18s). Spec rebuilt and check:generated --fix regenerated references/api/push-delivery.mdx and references/system/push-config.mdx, re-checked green.
  • pnpm --filter @objectstack/service-push test: Test Files 3 passed (3), Tests 30 passed (30); typecheck exit 0.
  • pnpm --filter @objectstack/service-messaging test: Test Files 66 passed (66), Tests 801 passed (801); typecheck exit 0 (both programs).
  • pnpm --filter @objectstack/service-automation typecheck exit 0 (tsc + check:test-typecheck); notify-push-delivery.integration.test.ts + notify-node.test.ts + notify-both-channels-unheld-address.integration.test.ts: Test Files 3 passed (3), Tests 27 passed (27).
  • packages/spec: push-config.test.ts, push-delivery.test.ts, push-device.test.ts, contracts/notification-service.test.ts, api/error-code-ledger.test.ts: Test Files 5 passed (5), Tests 107 passed (107); pnpm --filter @objectstack/spec typecheck exit 0. The rest of the spec suite and test:repo are CI's (Test Core).
  • check-changeset-no-major.mjs --base origin/main --event with this body: exit 0 (level axis yes (widening) + none, every entry patch). check-partof-closing-keyword.mjs with this body: exit 0.
  • Dogfood battery: n/a, no ! in the title and no (narrowing) arm.
Gates (dispatch-gates --commands)
  • Round 3 at b0503c1db (merged origin/main a7e984ab4, which carries spec: push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server→relay wire shape (ruling cloud#3519) #29816 1bb9536e): 135 derived commands, 135 of 135 exit 0, pnpm lint exit 0; --ran 135 run, 0 NOT-MEASURED. Round 3b at 5b7095572 (four-file delta): check:doc-authoring, check:generated, check-adr-links, pnpm lint, tsc exit 0; service-push 35/35.
  • At the head b6f8119b (and before it at 1ebbde26b): 134 derived commands, all 134 exit 0 (each run with stdin closed, exit captured before any pipe), plus pnpm lint exit 0. dispatch-gates --ran: 134 derived, 134 run, 0 NOT-MEASURED, 0 UNRUN; pnpm lint recorded outside the derivation.
  • First pass at 391ab42b: five reds, each fixed in 1ebbde26b: check:llms-txt (spec's llms.txt said 73 packages, the workspace now publishes 74), check:engine-double-contract (the two new test doubles' findOne / update / delete now route through the metadata-core predicates; pinned ledger rows written with --write), check:objectql-double-limit (the doubles hold the caller's limit, after the filter and before the copy), check:test-source-alias (service-automation aliases @objectstack/service-push, service-push aliases @objectstack/spec/system, to source), and check-plugin-teardown-shape.mjs --self-test exit 3 on the shallow clone (its pinned fixture commit fetched; no file change).

Acceptance notes

  • No served door yet. Push is configured only where a host composes new PushServicePlugin({ config }). os serve has no push capability (packages/spec/src/kernel/platform-capabilities.ts) and the stack definition no config.push key, so a served deployment cannot switch push on from its configuration. Both are spec edits outside this card's surface; named for the seat to card (a capability token, a stack door and a resolvePushCapabilityArg beside resolveSmsCapabilityArg).
  • Text is the recipient's, not the device's. The push sends the notification's title and body as fan-out rendered them for the recipient (sys_user.locale, else the deployment default), the same text the inbox row shows. UserDeviceSchema.locale's description says pushes to the device are rendered in the device locale; nothing reads it. Reported to the seat.
  • No push template. sys_notification_template accepts email and sms only (TEMPLATE_CHANNELS), so there is no per-topic push template; widening it is not this card.
  • A delivery with one device pushed and another failing retryably is recorded delivered and not retried, on purpose: a retry would push the delivered devices again.
  • HMS / Xiaomi / OPPO / vivo stay out of scope (ADR-0012 §6), as does a hosted configuration surface (triage 6570782253).

Verification

Readings above. Round 3 merged origin/main a7e984ab4 (carries #29816 1bb9536e); head 5b7095572, 135 derived gates. Contract review PASS 6606770732 on that head.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English. (The 维护者速读 block is Chinese by the dispatch contract.)
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JQwxbWDV359nY4JGrtUq4P


Generated by Claude Code

… its channel shell in service-messaging

A notify node naming the push channel now reaches the recipient's registered
devices through APNs (HTTP/2, token auth) and FCM (HTTP v1); a token the vendor
reports dead is retired. Credentials only through credentialRef.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQwxbWDV359nY4JGrtUq4P
…ts, alias service-push and spec/system to source, and count service-push in spec's llms.txt

The engine doubles in push-channel.test.ts and notify-push-delivery.integration.test.ts
route findOne/update/delete through the metadata-core dispatch predicates and hold the
caller's find limit (pinned ledger rows written by check-engine-double-contract --write).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQwxbWDV359nY4JGrtUq4P
…tocol none takes patch (ruling A on #25105)

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQwxbWDV359nY4JGrtUq4P
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

Contract-review-claim: session_01JQwxbacSXJQqAztgt4UMrS · head b6f8119 · 2026-11-03T05:01Z

push-delivery.zod.ts resolved by hand: main's PushDeliveryData.origin member
and docblock paragraph are kept, and the schema describe takes this branch's
flip (no PLANNED marker) with main's wording naming the origin.
push-server-origin.test.ts pin for the schema-level marker flipped to
not.toMatch(/PLANNED/), matching push-delivery.test.ts. push-delivery.mdx
regenerated with gen:docs.

Co-Authored-By: Claude <noreply@anthropic.com>
…n into the push data

PushDeliveryDataSchema.origin (from main) says the push channel copies it from
the device row. Each device's send now carries its row's origin when the
schema admits it; a row without one, or with a malformed one, sends the data
without it. The registry does not store origin yet (UserDeviceSchema.origin
stays PLANNED), so today no push carries it.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:system tests tooling labels Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 4 package(s): @objectstack/service-automation, @objectstack/service-messaging, @objectstack/service-push, @objectstack/spec, touching 80 documentable anchor(s). ⚠️ 10 changed file(s) yielded no anchor (packages/services/service-automation/vitest.config.ts, packages/services/service-messaging/src/index.ts, packages/services/service-messaging/src/objects/user-device.object.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 72d7db037b3b78ec4b768df4d266eb6dff288876.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 10 changed file(s) yielded no anchor (packages/services/service-automation/vitest.config.ts, packages/services/service-messaging/src/index.ts, packages/services/service-messaging/src/objects/user-device.object.ts, …) — pages documenting those are invisible to this run
  • 27 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 103 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 169 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 72d7db037b3b78ec4b768df4d266eb6dff288876 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 9645826cc9b325d16c72d594642e4f22beeface4 — the merge of head 5b7095572f3f1836f18a0ef62bd072485353edcd into base 72d7db037b3b78ec4b768df4d266eb6dff288876, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 9645826cc9b325d16c72d594642e4f22beeface4 && git checkout 9645826cc9b325d16c72d594642e4f22beeface4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 72d7db037b3b78ec4b768df4d266eb6dff288876 5b7095572f3f1836f18a0ef62bd072485353edcd && git checkout -B drift-repro 72d7db037b3b78ec4b768df4d266eb6dff288876 && git merge --no-ff 5b7095572f3f1836f18a0ef62bd072485353edcd

node scripts/docs-audit/affected-docs.mjs --json 72d7db037b3b78ec4b768df4d266eb6dff288876

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 72d7db037b3b78ec4b768df4d266eb6dff288876 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

Contract-review-claim: session_01JQwxbacSXJQqAztgt4UMrS · head 3aac0c5 · 2026-11-03T05:59Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3aac0c5938b09d8b7a6cb7ffa88507dca5ec7cfb
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts, packages/spec/src/api/push-delivery.zod.ts, packages/spec/src/contracts/notification-service.ts, packages/spec/src/system/notification.zod.ts, packages/spec/src/system/push-config.zod.ts, packages/spec/src/system/push-device.zod.ts, docs/adr/0012-notification-platform.md + packages/services/service-push/src/push-plugin.ts, packages/services/service-push/src/push-service.ts, packages/services/service-push/src/credential.ts, packages/services/service-push/src/transports/apns.ts, packages/services/service-push/src/transports/fcm.ts, packages/services/service-messaging/src/push-channel.ts, packages/services/service-messaging/src/messaging-service-plugin.ts, packages/services/service-messaging/src/messaging-service.ts, packages/services/service-messaging/src/device-registry.ts, packages/services/service-messaging/src/backoff.ts, packages/services/service-messaging/src/dispatcher.ts, packages/services/service-messaging/src/objects/user-device.object.ts, packages/spec/src/system/push-server-origin.ts, packages/spec/src/api/push-delivery.test.ts, packages/spec/src/system/push-config.test.ts, packages/spec/src/system/push-server-origin.test.ts, packages/services/service-messaging/src/push-channel.test.ts, packages/services/service-automation/src/builtin/notify-push-delivery.integration.test.ts, packages/spec/src/kernel/platform-capabilities.ts, docs/adr/0030-notification-platform-convergence.md, docs/adr/0143-outbound-connector-auth-is-open-tier.md (D3), .changeset/29041-push-notification-channel.md, packages/spec/llms.txt. The rest of the diff is the lane seat's ACCEPT at TIER_DEFAULT.

Inputs read: card #29041 body and all 8 comments (triage 6570782253, seat split 6571021055, unblock 6586279808, the package-name question 6592963202 that claim 6594049406 records as ruling A, carried items 6593642547, dev reports 6595865935 and 6596612653); PR #29794 body, file list, net diff origin/main...pr29794 (merge-base b3d141505); check-runs on the head.

Gate verdicts on the head at record time: 26 success, 7 skipped by design, 0 failed. Still pending then: Build Core, Test Core 1–8, Type Check · workspace, Type Check · consumer gates, Build & E2E UI, Lint UI, Type Check UI, Test UI 1–16. This PASS stands only if those complete green.

① Derived judgments

  1. [PLANNED — not yet read] comes off PushServiceConfigSchema, ApnsTransportConfigSchema and FcmTransportConfigSchema. Right: PushServicePlugin.init parses its config with PushServiceConfigSchema and builds one transport per block.
  2. [PLANNED — not yet served] comes off PushDeliveryDataSchema. Right: push-channel.ts builds the data and hands it to the push service, and the acceptance test sees notificationId and route arrive at mock APNs and mock FCM.
  3. The three marker pins are flipped. The two device-side pins (RegisterDeviceRequestSchema.origin, UserDeviceSchema.origin stay [PLANNED — not yet stored]) are untouched. Right: device-registry.ts neither writes nor reads an origin column.
  4. notification.zod.ts: push is now among the channels service-messaging registers, mounted while its transport service is registered. Right: it mounts through lazyChannelMount, as sms and email do. "Fan-out refuses any message addressed to an unregistered channel" is right on the tree ([finding] The outbox fan-out writes a delivery row for a channel that is NOT registered — #17732's reported symptom, which ruling A does not reach #18050). "push — no template" is right: TEMPLATE_CHANNELS is email and sms.
  5. notification-service.ts: the invalidateDeviceToken docblock is now present tense. Right: the channel calls listDevices and invalidateDeviceToken, delegating to DeviceRegistry. Carried item 2 closed.
  6. error-code-ledger.zod.ts: the NOT_FOUND waiver names service-messaging's provenance row. Comment only; the row exists (notifications: build the device registry (sys_user_device, /notifications/devices routes, client methods) on #29067's contract (server slice 1 of #29041) #29068). Carried item 1 closed.
  7. push-device.zod.ts and user-device.object.ts both say service-messaging owns sys_user_device, its push channel reads and retires rows in it, and service-push never touches it. Carried item 3 closed.
  8. Public surface: a new package @objectstack/service-push. @objectstack/service-messaging adds createPushChannel, PushChannelOptions, PushSenderSurface and PushDeviceSurface. @objectstack/spec changes description and comment text only: no key, shape or export. The accept set widens: a notify node or emit() naming push now delivers where a host composes PushServicePlugin. With no push service, push is refused exactly as before.

② Semver level

The changeset is patch for service-push, service-messaging and spec, with Clause-②: yes (widening) and Metadata-protocol: none in the changeset and the PR body. none is right: no authorable key, value or metadata type is added, and the push enum value already existed. Under "none takes patch", the minor→patch regrade is the rule. service-automation gains only a devDependency and a test. .changeset/config.json adds service-push to the fixed group. Check Changeset: success. The level holds.

③ Boundary flags

④ Design judgment

  1. push-config.zod.ts against ADR-0143 D3, ruling A and its first consumer. Holds. Every docblock sentence now describes served code:
    • the plugin parses at init;
    • the transport resolves the credentialRef when it first signs;
    • the open tier resolves an environment variable;
    • an inline secret is refused at init;
    • the package is @objectstack/service-push.
      The only inline path is host composition, not authorable configuration.
  2. push-delivery.zod.ts, the route rule. Holds. actionUrl is admitted only when PushRouteSchema.safeParse succeeds and it is under 1024 bytes. https://evil.example/x, //evil.example/x and /a/../../api/v1/me are left out, and the delivery still goes. notificationId is present on both emit paths.
  3. push-delivery.zod.ts, the origin paragraph. Should-fix: with the marker gone, "copied here by the push channel" reads as served, but the registry stores no origin yet, so today every push goes without one. The member is optional and UserDeviceSchema.origin still says PLANNED, so the contract is not falsified. One clause on the member would close it, and it can ride the feat(spec): push delivery data names the server that sent it — RegisterDeviceRequest, UserDevice and PushDeliveryData gain an optional origin #29767 follow-up.
  4. push-device.zod.ts. The ownership paragraph holds. Should-fix, filed as a follow-up and not blocking: the locale description is a claim the tree does not have (③ out-of-scope 1).
  5. notification-service.ts and error-code-ledger.zod.ts. Hold.
    • Retirement: DeviceRegistry.invalidate(token) keeps the row and writes enabled: false and invalidated_at.
    • The acceptance test covers it: the 410 device is retired, the other device still gets the push, and the retired device is not pushed again.
    • Retiring by token is right. A throwing invalidateDeviceToken is caught and does not change the grade.
  6. notification.zod.ts. Holds.
  7. ADR-0012 against its first consumer, its execution plan and ADR-0030. Holds.
    • The status line gets a dated Amended (2026-11-03, #29041) clause (ADR-0053's shape). It covers the unedited plugin-notification-push rows.
    • The §4 push row reads Shipped.
    • §6 moves retirement to the channel and gives the grade rule: invalid_recipient only when every device died. That matches push-channel.ts and backoff.ts.
    • ADR-0030 is unchanged and consistent.
  8. Grading. When one device is sent and another fails retryably, the message is recorded delivered and not retried, so no device is pushed twice. A device on an unconfigured vendor grades permanent, not invalid_recipient. Right.

No must-fix. Two should-fix items (④ 3 and 4), both carried to follow-ups.

Implemented-by: claude/project-thread-ld23bk
Reviewed-by: session_01JQwxbacSXJQqAztgt4UMrS

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

维护者速读

  • 改了什么:通知现在能推送到手机。新增推送服务包 @objectstack/service-push(苹果 APNs、谷歌 FCM),消息服务新增“推送”渠道:流程里的通知节点选了推送,就发到收件人登记过的每一台手机,点开推送进入这条通知对应的页面;手机端应用已卸载(令牌失效)时,自动停用那台设备。同时修订 ADR-0012(通知平台):推送包名定为 service-push,§4 推送一行改为“已上线”,§6 写明失效令牌由推送渠道停用;ADR-0030 未改。
  • 为什么改:Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041 的验收条件。之前流程里选了推送也显示“运行成功”,实际一条推送都没有发出(已在 main 上复现)。
  • 风险与回滚:纯新增。没有启用推送服务的部署,行为完全不变;推送密钥只能通过引用(credentialRef)配置,写明文密钥会在启动时被拒绝;推送里带的跳转只会是本服务器内的路径,外站链接一律不带。回滚即还原本 PR。
  • 先看:packages/services/service-messaging/src/push-channel.ts(发给哪些手机、失效设备停用、结果怎么分级),再看 docs/adr/0012-notification-platform.md 的修订处。
  • 两分钟验证:pnpm --filter @objectstack/service-automation exec vitest run src/builtin/notify-push-delivery.integration.test.ts,预期 3 条全过:模拟的苹果和谷歌推送服务各收到一条带跳转的推送,被报失效的设备变为停用,同一用户的另一台手机照常收到。
  • 需要你做的:Tier H(docs/adr/**):席位契约复核已通过(评论 6596752211,Head-sha: 3aac0c5938b09d8b7a6cb7ffa88507dca5ec7cfb),请 GOVERNED_APPROVERS 之一批准 ADR-0012 的这次修订;批准后席位落地。
  • 席位意见:建议批准。包名按你的裁决 A 定为 service-push;ADR 修订只记录本 PR 落地的行为,不新作决定。还差两件事已另立卡:部署配置里还开不了推送(spec: give push a served door (a platform capability, a config.push stack key and a services.push docs page), so a deployment can turn it on without host code #29820),设备语言目前不参与推送文案(notifications: a push device's stored locale is never read, but the schema says pushes to it render in that locale #29818)。

席位核对

ACCEPT · domain:server#4 · head 3aac0c5938b09d8b7a6cb7ffa88507dca5ec7cfb · 2026-11-03T06:24Z · governed docs/adr/** (Tier H: check-governed-merges --pr 29794 names docs/adr/0012-notification-platform.md, one hit governs the whole PR; 41 files, +2461/−82 ≤ 3000); CONTRACT_REVIEW_TIER review PASS on this head (6596752211); final four-piece hand-off to the approvers.

  1. Shape: draft, base main, Fixes #29041, Clause-②: yes (widening), Metadata-protocol: none, changeset patch for service-push, service-messaging and spec. New package packages/services/service-push (named per ruling A, 6592963202 as recorded in claim 6594049406); push channel shell in service-messaging; spec edits are PLANNED-marker flips and text only; ADR-0012 gains a dated Amended clause plus its §4 and §6 rows. Inside the order's surface. PR body's maintainer block refreshed to this head by the seat (relay issue_patch, read-back identical).
  2. Premise measured: red first on origin/main 7b55ea95 (dev): a notify node naming push ran success with acted: 0 and no request reached the mock APNs. The acceptance test now sees one push per device on both vendors, route and notificationId in the data, the 410 device retired and the other device still pushed.
  3. Gates on this head: check-runs pinned to 3aac0c59: 66 runs, 59 success, 7 skipped by design, none failed; git merge-tree against origin/main clean. Dev: dispatch-gates --commands 134 derived, 134 exit 0, --ran 0 UNRUN / 0 NOT MEASURED, pnpm lint 0 (report 6596612653).
  4. Accepted deviations: minor→patch regrade under Metadata-protocol: none; llms.txt package count 73→74 forced by check:llms-txt; the origin copy in the push channel (04c9e75da), accepted in review ③.
  5. Out of scope: device locale unread and no push template → notifications: a push device's stored locale is never read, but the schema says pushes to it render in that locale #29818 (domain:server); no served door for push (capability, config.push, services.push docs page) → spec: give push a served door (a platform capability, a config.push stack key and a services.push docs page), so a deployment can turn it on without host code #29820 (domain:spec); the origin member reads as served while no device row stores one yet → closed by notifications: store the push device's origin on sys_user_device and copy it into push data (ruling B on #29642, server half) #29768. Skills-docs: not owed.
  6. Landing: Tier H ⇒ relay pr_request_reviewers to GOVERNED_APPROVERS and needs-user-decision on the PR in the same act; stays draft. On an approver's APPROVED: clear the label, pr_ready + automerge_enable, follow to MERGED, landing record on Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

席位核对 · hold

HOLD · domain:server#4 · head 3aac0c5938b09d8b7a6cb7ffa88507dca5ec7cfb · 2026-11-03T07:36Z · the hand-off in ACCEPT 6596988879 is withdrawn. This PR waits behind #29816, then gets a rework round.

  1. Why: spec seat 2 sent Notify: serial · #29041 behind #29816 (mailbox pm:mailbox — domain:server#4 · os-warren #29522, 6598055373). spec: push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server→relay wire shape (ruling cloud#3519) #29816 is p1 and carries the cloud#3519 ruling (1C 2A 3A 4B 5A 6B 7B). It turns PushServiceConfigSchema into a transport: relay | direct union with relay as the default, and refuses a direct block authored without its discriminator. This PR's PushServicePlugin parses today's { apns?, fcm? }, so on spec: push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server→relay wire shape (ruling cloud#3519) #29816's main it would read a shape the schema no longer admits.
  2. ADR-0012: ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817, blocked by spec: push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server→relay wire shape (ruling cloud#3519) #29816, rewrites the same §4 and §6 rows this PR amends (relay default, direct for white-label, vendor as a token type). Approving this PR's ADR text now would approve wording the next amendment replaces.
  3. Next: when spec: push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server→relay wire shape (ruling cloud#3519) #29816 merges, the dev merges main and makes the plugin read the union. direct serves today's APNs/FCM transports. relay is refused at init as not yet served, naming the follow-up, until the relay sender has its own card. The ADR-0012 edit is re-cut against ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817's order of landing. The seat then runs a delta contract review on the new head and posts a fresh ACCEPT with the hand-off.
  4. State: needs-user-decision comes off with this record. The review request stays open on the platform, but no approval is asked for until the fresh ACCEPT.

…29816)

push-config.zod.ts resolved by hand: main's two-transport docblock and the
relay | direct discriminated union are kept, with one paragraph on what is
served today. The direct block and the union lose [PLANNED — not yet read]
(PushServicePlugin reads them); the relay block keeps a marker, now
[PLANNED — not yet served], saying the push service refuses it at boot until
its sender ships (#29920). push-config.test.ts pins the relay marker and the
rest unmarked. push-config.mdx regenerated with check:generated --fix.

Co-Authored-By: Claude <noreply@anthropic.com>
…lay at init

PushServicePlugin parses the relay | direct union. transport: 'direct' (or a
vendor block with no transport) drives the APNs and FCM transports unchanged.
transport: 'relay', and the empty config that reads as it, is refused at init
with PUSH_RELAY_NOT_SERVED_MESSAGE, naming follow-up card #29920 and the
direct transport for a white-label build; nothing is registered. Pinned:
direct boots named and inferred, relay is refused four ways.

ADR-0012: the Amended status line, the section 4 push row and the section 6
note say service-push ships the direct transport and the hosted relay, the
default per cloud#3519, is not yet served (#29920). README and changeset
say the same.

Co-Authored-By: Claude <noreply@anthropic.com>
…-authoring)

The relay refusal message and the relay and union describes drop #29920; the
card stays named in the code comments, the ADR, the README and the changeset.
push-config.mdx regenerated.

Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

Contract-review-claim: session_01JQwxbacSXJQqAztgt4UMrS · head b0503c1 · 2026-11-03T14:19Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b0503c1db66b33e7b6f38c425fdcd65776e6a369
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts, packages/spec/src/api/push-delivery.zod.ts, packages/spec/src/contracts/notification-service.ts, packages/spec/src/system/notification.zod.ts, packages/spec/src/system/push-config.zod.ts, packages/spec/src/system/push-device.zod.ts, docs/adr/0012-notification-platform.md + packages/services/service-push/src/push-plugin.ts, packages/services/service-push/src/push-service.ts, packages/services/service-push/src/credential.ts, packages/services/service-push/src/index.ts, packages/services/service-push/src/transports/apns.ts, packages/services/service-push/src/transports/fcm.ts, packages/services/service-push/src/push-plugin.test.ts, packages/services/service-push/README.md, packages/services/service-messaging/src/push-channel.ts, packages/services/service-messaging/src/messaging-service-plugin.ts, packages/services/service-messaging/src/objects/user-device.object.ts, packages/spec/src/api/push-relay.zod.ts (main, unchanged by the PR), packages/spec/src/api/push-relay.test.ts (main), packages/spec/src/system/push-config.test.ts, packages/spec/src/api/push-delivery.test.ts, packages/spec/src/system/push-server-origin.test.ts, packages/spec/src/kernel/platform-capabilities.ts, content/docs/references/system/push-config.mdx (generated, read only), .changeset/29041-push-notification-channel.md, docs/adr/0116-plugin-ordering-declared-contract.md (D2), docs/adr/0143-outbound-connector-auth-is-open-tier.md (D3), scripts/check-doc-authoring.mjs (rules 2 and 3). The rest of the diff is the lane seat's ACCEPT at TIER_DEFAULT.

Round 3, a delta review on the round-1 PASS (6596752211, head 3aac0c59) under the HOLD terms (6598070931). Inputs read: card #29041 body and all 14 comments (the cloud#3519 scope change 6596739690, spec seat 2's serial and landed notes 6598066351 and 6601310426, the dev's round-3 report 6606198955 among them); PR #29794 body, its 41-file list (identical to the net diff's), the net diff origin/main...b0503c1d (merge-base a7e984ab, which carries #29886 at 1bb9536e; both merges from main verified as ancestors, the branch adds nothing outside the PR's file list); #29816, #29886, #29817 and #29920 as the tree names them; the check-runs on the head. Rendered by a Fable subagent of the seat session, which adopts it.

Gate verdicts on the head at record time: 26 success, 7 skipped by design, 0 failed. Completed green: Lint & Repo Gates and every Lint · job (CI/release/engine gates, ESLint, PM dispatch-gates, docs & code guards, repo & tooling gates, the three scoped ones), Type Check · source gates and · debt ledger, Build Docs, Build UI Deps, Live E2E UI, Check Changeset, Governed Surface Queue Guard, PR-body guards, Spec property liveness, Validate Package Dependencies, Check PR Size, Check Documentation Links, Flag docs affected, Auto Label, Dogfood Regression Gate, Plan Test Core shards, filter. Still pending: Build Core, Test Core 1–8, Type Check · workspace, Type Check · consumer gates, Build & E2E UI, Lint UI, Type Check UI, Test UI 1–16. This PASS stands only if those complete green.

① Derived judgments

  1. PushServiceConfigSchema is read: [PLANNED — not yet read] comes off it. Right. PushServicePlugin.init parses this.options.config ?? {} with it before anything else happens, and the result decides the boot.
  2. PushDirectTransportConfigSchema, ApnsTransportConfigSchema, FcmTransportConfigSchema carry no marker. Right. A parsed direct block builds one ApnsPushTransport / FcmPushTransport per vendor block; both transports resolve the block's credentialRef when they first sign (unchanged since the round-1 PASS: git diff 3aac0c59..b0503c1d on transports/, credential.ts, push-service.ts and service-messaging/src is empty).
  3. PushRelayTransportConfigSchema keeps a marker, respelt [PLANNED — not yet served], with "The push service refuses it at boot until the relay sender ships". Right, and the respelling is the honest one: spec seat 2 asked to keep not yet read on the relay member (6601310426), but the plugin does read a relay block (it parses it and acts on the result); what it does not do is serve it. The .describe() states exactly what the code does: config.transport !== 'direct' throws PUSH_RELAY_NOT_SERVED_MESSAGE before registerService, so nothing is registered. push-config.test.ts pins the marker and the "refuses it at boot" clause; push-plugin.test.ts pins the four refused arms (no config, {}, transport: 'relay', a staging endpoint) with services.has('push') === false.
  4. {} and no config read as the relay and are refused. Right: both members default transport and the union is unionFallback: true with relay first, so {} parses to { transport: 'relay' } (pinned on main by feat(spec): push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server-to-relay wire shape #29886) and meets the throw. A deployment can no longer believe it pushes while nothing would deliver.
  5. direct, named or inferred, drives APNs/FCM unchanged. Right: { transport: 'direct', apns } and { apns } both parse to transport: 'direct'; the transport construction, the host-transport override, the inline-secret refusal at init and the vendor log line are the round-1 code. Pinned ("boots the direct transport, named or read from a vendor block").
  6. PushDeliveryDataSchema served, the three marker pins (push-config, push-delivery, push-server-origin) flipped, the two device-side [PLANNED — not yet stored] pins untouched. Right, unchanged from round 2: push-channel.ts builds the data, withOrigin copies only a value the schema admits, and the registry still stores no origin.
  7. notification.zod.ts: push is among the channels service-messaging registers, "each but the inbox mounted while its transport service is registered". Right: messaging-service-plugin.ts mounts email, sms and push through the same lazyChannelMount, and a boot that refused the relay registers no push service, so the channel is simply not mounted and fan-out refuses push as it did before this PR.
  8. notification-service.ts: listDevices read by the push channel, invalidateDeviceToken called for a token the vendor reports dead. Right: push-channel.ts filters listDevices(recipient) to enabled rows and retires an invalid_token row through invalidateDeviceToken. error-code-ledger.zod.ts: NOT_FOUND sits in the @objectstack/service-messaging provenance row, so the waiver's new clause is true. push-device.zod.ts and user-device.object.ts name one owner. All three unchanged since round 1.
  9. Public surface: new package @objectstack/service-push (PushServicePlugin, PushService, IPushService, transports, envPushCredentialResolver, and new this round PUSH_RELAY_NOT_SERVED_MESSAGE); @objectstack/service-messaging adds createPushChannel, PushChannelOptions, PushSenderSurface, PushDeviceSurface; @objectstack/spec changes description and comment text only, no key, shape or export. The accept set widens: a notify node or emit() naming push delivers where a host composes PushServicePlugin with a direct block. Nothing published narrows: on main nothing reads PushServiceConfigSchema, so the relay refusal is the first reader's behaviour, not a capability taken away.
  10. Credentials only by credentialRef (ADR-0143 D3). Right: the schema admits no inline secret on either vendor block or the relay block, the transports resolve the reference through resolveCredential (open tier: an environment-variable name), and PUSH_RELAY_NOT_SERVED_MESSAGE and the credential errors name a reference or a transport, never a value. The relay block carries no credential by design; its binding credential is service-push: send through the hosted relay transport (push-relay.zod.ts wire, cloud binding credential) #29920's.

② Semver level

The changeset is patch for service-push, service-messaging and spec, with Clause-②: yes (widening) and Metadata-protocol: none in the changeset and the PR body. none is right: the diff adds no authorable key, value or metadata type; transport and its members came with #29886 (added, minor), this PR only reads them. Under "none takes patch" (ruling A on #25105) the level holds; service-push is unpublished, so its boot-time refusal of {} narrows nothing that exists. Check Changeset: success. Round-3 wording in the changeset names the served direct transport and the refused relay, which is what the tree does.

③ Boundary flags

④ Design judgment

  1. push-config.zod.ts against spec: push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server→relay wire shape (ruling cloud#3519) #29816 (ruling cloud#3519), ADR-0143 D3 and its first consumer. Holds. Every .describe() on the file was read against push-plugin.ts: the union ("direct drives the vendor transports, and relay is refused at boot"), the direct block ("read by the push service"), the two vendor blocks (no marker; transports built from them) and the relay block ([PLANNED — not yet served], refused at boot) each state what the code does. The docblock's "What reads it" paragraph is true on the tree: no push capability in platform-capabilities.ts, no config.push stack key, the only call site is the README example. The merge kept main's union untouched (the conflict resolution adds one paragraph and changes four describes; the diff against 5f5629830^2 shows no other spec change).
    Should-fix (cosmetic): the added paragraph's first line, **What is served today.** …, lacks the * prefix every other line of the docblock carries. It is still inside the comment, so nothing breaks, but the JSDoc is malformed on that line. One character.
  2. push-relay.zod.ts (cited by the docblock above, on main). The seven .describe() markers stay [PLANNED — not yet served] and stay true: nothing sends a relay request. Should-fix: its docblock sentence "No push channel delivers yet, so nothing sends this request today … until the push channel ships" is false after this PR (the channel ships; the relay sender does not). One sentence naming the relay sender as the event, here or carried to service-push: send through the hosted relay transport (push-relay.zod.ts wire, cloud binding credential) #29920, which flips those markers anyway. Not blocking: it is comment prose, not a projected describe.
  3. push-delivery.zod.ts, notification.zod.ts, notification-service.ts, error-code-ledger.zod.ts, push-device.zod.ts. Hold, unchanged since the round-1 PASS, re-read against the unchanged channel code (① 6–8). The round-1 should-fix on the origin member stays carried to notifications: store the push device's origin on sys_user_device and copy it into push data (ruling B on #29642, server half) #29768.
  4. ADR-0012 against its first consumer, spec: push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server→relay wire shape (ruling cloud#3519) #29816, and ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817's order of landing. Holds. The status line, the §4 push row and the §6 note record the tree: the direct transport ships as service-push, the channel retires dead tokens itself, and the relay, the schema's default on main since feat(spec): push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server-to-relay wire shape #29886, is refused at init and not served (service-push: send through the hosted relay transport (push-relay.zod.ts wire, cloud binding credential) #29920). Naming the relay as the default is required, not optional: with feat(spec): push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server-to-relay wire shape #29886 on main, an ADR that said only "push = direct APNs/FCM, shipped" would contradict the spec it governs. None of ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817's substance is written (what the relay fronts, vendor as a token type, privacy, air-gap, Non-Goals); each amended cell is dated and cites Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041, so ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817's rewrite of §4 and §6 supersedes it cleanly.
    Should-fix: the §6 domestic-vendor sentence is reworded from plugin-notification-push-hms etc. to "one package each, named by kind like service-push". That line is exactly the one ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817 rewrites (cloud#3519: the relay fronts the domestic vendors), so the rewording puts newly authored, already-overruled text in front of the approver, which the HOLD's point 2 asked this round to avoid. Restore main's sentence and leave it to ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817; the status line's "every plugin-notification-push below reads that way" already covers the coined name. Not a must-fix: it enacts no decision the code carries, and the pre-existing Non-Goals line ("Domestic vendors are plugin transports") is ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817's too.
  5. Grading and refusal design. A relay configuration fails the boot rather than registering an inert service. Right under ADR-0116 D2: providesServices = ['push'] is still unconditional on every path init completes, and a boot that stops with a named refusal is better than a mounted channel whose every push looks sent. The trade-off is stated in the constant's comment.

No must-fix. Three should-fix items (④ 1, 2, 4), none blocking. Note for the lane seat's ACCEPT, outside this review's scope: in push-plugin.ts the class docblock (with its @example) now sits directly above the PUSH_RELAY_NOT_SERVED_MESSAGE docblock, so it is attached to nothing and PushServicePlugin has no JSDoc; move the constant above the class docblock.

Implemented-by: claude/project-thread-ld23bk
Reviewed-by: session_01JQwxbacSXJQqAztgt4UMrS

VERDICT: PASS

…in has it, docblock gutter, JSDoc order

ADR-0012 §6: the domestic-vendor sentence reads exactly as on origin/main
(#29817 rewrites it). push-config.zod.ts: the served-today paragraph gains its
' * ' gutter (push-config.mdx regenerated). push-plugin.ts: the relay refusal
constant and its docblock sit above the class docblock, so each JSDoc attaches
to its own declaration.

Co-Authored-By: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 5b7095572f3f1836f18a0ef62bd072485353edcd
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts, packages/spec/src/api/push-delivery.zod.ts, packages/spec/src/contracts/notification-service.ts, packages/spec/src/system/notification.zod.ts, packages/spec/src/system/push-config.zod.ts, packages/spec/src/system/push-device.zod.ts, docs/adr/0012-notification-platform.md + packages/services/service-push/src/push-plugin.ts, packages/services/service-push/src/push-service.ts, packages/services/service-push/src/credential.ts, packages/services/service-push/src/index.ts, packages/services/service-push/src/transports/apns.ts, packages/services/service-push/src/transports/fcm.ts, packages/services/service-push/src/push-plugin.test.ts, packages/services/service-push/README.md, packages/services/service-messaging/src/push-channel.ts, packages/services/service-messaging/src/messaging-service-plugin.ts, packages/services/service-messaging/src/objects/user-device.object.ts, packages/spec/src/api/push-relay.zod.ts (main, unchanged by the PR), packages/spec/src/api/push-relay.test.ts (main), packages/spec/src/system/push-config.test.ts, packages/spec/src/api/push-delivery.test.ts, packages/spec/src/system/push-server-origin.test.ts, packages/spec/src/kernel/platform-capabilities.ts, content/docs/references/system/push-config.mdx (generated, read only), .changeset/29041-push-notification-channel.md, docs/adr/0116-plugin-ordering-declared-contract.md (D2), docs/adr/0143-outbound-connector-auth-is-open-tier.md (D3), scripts/check-doc-authoring.mjs (rules 2 and 3). The rest of the diff is the lane seat's ACCEPT at TIER_DEFAULT.

Round 3b, a delta review on the round-3 PASS (6606491253, head b0503c1d), which stands as the full reading of this PR; everything below that is not restated there is unchanged from it. Inputs read: card #29041 body and all 15 comments (the dev's round-3b addendum 6606701292 last); PR #29794 body (untouched since round 3, as the addendum says), its 41-file list (identical to round 3's and to the net diff's), the net diff origin/main...5b709557 (merge-base a7e984ab, unchanged); the one commit 5b709557 over b0503c1d (its parent, verified), read in full; the check-runs on the head. Rendered by a Fable subagent of the seat session, which adopts it.

The delta, verified against the tree and not the addendum: git diff --stat b0503c1d 5b709557 is exactly four files, 17 insertions, 17 deletions, no rebase, no merge. (1) docs/adr/0012-notification-platform.md: the §6 domestic-vendor sentence is restored; it is byte-identical to the line on origin/main, and the ADR's net diff against main is now the status line, the §4 push row and the §6 amended note only. (2) packages/spec/src/system/push-config.zod.ts: the "What is served today." line gains its * gutter; a scan of every docblock in the file finds no line without it. (3) content/docs/references/system/push-config.mdx: the one regenerated line, the stray * gone (check:generated is CI's, in the pending Lint · docs & code guards). (4) packages/services/service-push/src/push-plugin.ts: PUSH_RELAY_NOT_SERVED_MESSAGE and its docblock move above the class docblock; the diff is a pure move, no code or text changes, and PushServicePlugin now carries its JSDoc. push-relay.zod.ts is not touched, as the round-3 record allowed. Nothing else moved.

Gate verdicts on the head at record time (the run is young): 7 success, 3 skipped by design, 0 failed, 15 in progress; the Build Core, Test Core, Build & E2E UI, Lint UI, Type Check UI and Test UI matrices have not been planned yet (filter is still running). Green so far: Check Changeset, Governed Surface Queue Guard, PR-body guards, Spec property liveness, Check Documentation Links, Check PR Size, Auto Label. Pending: every Lint · job, the four Type Check · jobs, Validate Package Dependencies, Flag docs affected, filter, and the matrices behind it. This PASS stands only if those complete green; on this head the lint, type and test families answer a one-line docblock edit, a one-line generated line, a restored ADR sentence and a declaration move, each of which the round-3 head passed in substance.

① Derived judgments

  1. Unchanged from round 3, items 1–10: the markers on push-config.zod.ts and push-delivery.zod.ts state what push-plugin.ts and push-channel.ts do; {} and no config read as the relay and are refused at init with nothing registered; direct, named or inferred, drives the APNs and FCM transports unchanged; the respelt relay marker [PLANNED — not yet served] is the honest one; the public surface and the accept set are as named there. No .describe() changed in this delta (the gutter fix is JSDoc, not describe prose; the regenerated .mdx line follows the docblock, not a describe).
  2. The delta removes one derived claim rather than adding any: ADR-0012 no longer authors a domestic-vendor package-naming sentence, so the only ADR text this PR puts before the approver is what the tree enacts (status line, §4 row, §6 note). Right.

② Semver level

Unchanged: patch for service-push, service-messaging and spec, Clause-②: yes (widening), Metadata-protocol: none, in the changeset and the PR body. The delta publishes nothing (a JSDoc gutter, a generated line, an ADR sentence restored to main, a declaration reordered inside one module with the same exports). Check Changeset: success on this head.

③ Boundary flags

④ Design judgment

  1. push-config.zod.ts against spec: push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server→relay wire shape (ruling cloud#3519) #29816 (ruling cloud#3519), ADR-0143 D3 and its first consumer. Holds, as in round 3; the docblock is now well-formed on every line. The round-3 should-fix on this file is closed.
  2. push-relay.zod.ts (cited, on main). The seven [PLANNED — not yet served] describes stay true. Should-fix (carried, unchanged): the docblock sentence "No push channel delivers yet … until the push channel ships" is false after this PR; one sentence naming the relay sender as the event, carried to service-push: send through the hosted relay transport (push-relay.zod.ts wire, cloud binding credential) #29920.
  3. push-delivery.zod.ts, notification.zod.ts, notification-service.ts, error-code-ledger.zod.ts, push-device.zod.ts. Hold, unchanged since the round-1 PASS and re-read in round 3.
  4. ADR-0012 against its first consumer, spec: push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server→relay wire shape (ruling cloud#3519) #29816, and ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817's order of landing. Holds, and the round-3 should-fix is closed: with the §6 domestic-vendor sentence back to main's bytes, every line this PR writes into the ADR records what the tree does (direct shipped as service-push, the channel retires dead tokens, the relay is the schema's default on main and is refused at init until service-push: send through the hosted relay transport (push-relay.zod.ts wire, cloud binding credential) #29920), each cell dated and citing Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041. None of ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817's substance is written; the sentence ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817 rewrites is no longer this PR's.
  5. Grading and refusal design. Unchanged: refusing a relay configuration at init, before registerService, holds under ADR-0116 D2, and push-plugin.ts's declaration order now matches its documentation.

No must-fix. One should-fix remains open (④ 2), carried to #29920 and not blocking. The two round-3 should-fixes and the lane note are closed on this head.

Implemented-by: claude/project-thread-ld23bk
Reviewed-by: session_01JQwxbacSXJQqAztgt4UMrS

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

维护者速读

席位核对

ACCEPT · domain:server#4 · head 5b7095572f3f1836f18a0ef62bd072485353edcd · 2026-11-03T15:14Z · governed docs/adr/** (Tier H: one hit, docs/adr/0012-notification-platform.md, governs the whole PR; 41 files, +2531/−85 ≤ 3000); CONTRACT_REVIEW_TIER review PASS on this head (6606770732, a delta on round-3 PASS 6606491253 and round-1 PASS 6596752211); fresh four-piece hand-off replacing HOLD 6598070931.

  1. Shape: base main, Fixes #29041, Clause-②: yes (widening), Metadata-protocol: none, changeset patch for service-push, service-messaging and spec. Round 3 merged origin/main a7e984ab4 (carries spec: push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server→relay wire shape (ruling cloud#3519) #29816 1bb9536e), no rebase. PushServicePlugin.init parses the relay | direct union: direct (named or inferred from a vendor block) drives APNs/FCM unchanged; relay and {} are refused with PUSH_RELAY_NOT_SERVED_MESSAGE, registering nothing. Markers: union and direct block unmarked; relay block keeps [PLANNED — not yet served]. ADR-0012: status line, §4 row, §6 note; §6's domestic-vendor sentence byte-identical to main; none of ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817's text. PR body refreshed to this head by the seat (relay issue_patch, read-back identical).
  2. Premise measured: red first on origin/main 7b55ea95 (round 1): a notify node naming push ran success with acted: 0. Round 3: the relay refusal ablated → push-plugin.test.ts 4 failed of 12 (the four relay arms); restored, git diff HEAD empty.
  3. Gates on this head: check-runs pinned to 5b709557: 70 runs, 61 success, 9 skipped by design, none failed; git merge-tree against origin/main clean. Dev: round 3 dispatch-gates --commands 135 derived, 135 exit 0, --ran 0 NOT-MEASURED, pnpm lint 0 (6606198955); round 3b four-file delta, its gates exit 0 (6606701292).
  4. Accepted deviations: minor→patch regrade under Metadata-protocol: none; llms.txt 73→74 forced by check:llms-txt; the origin copy in the push channel (04c9e75da); service-push: send through the hosted relay transport (push-relay.zod.ts wire, cloud binding credential) #29920 kept out of the refusal message and the describes because check:doc-authoring refuses a tracker id in operator-facing text (it stays in the code comment, ADR, README and changeset).
  5. Out of scope: relay sender → service-push: send through the hosted relay transport (push-relay.zod.ts wire, cloud binding credential) #29920 (domain:server), which also carries spec seat 2's two push-relay.zod.ts items and its stale docblock sentence (6606506582); ADR-0012 relay rewrite → ADR-0012 amendment: the push channel's relay transport, and vendor as a token type rather than a plugin boundary (ruling cloud#3519) #29817; served door for push → spec: give push a served door (a platform capability, a config.push stack key and a services.push docs page), so a deployment can turn it on without host code #29820; device locale and push templates → notifications: a push device's stored locale is never read, but the schema says pushes to it render in that locale #29818. Skills-docs: not owed.
  6. Landing: Tier H ⇒ relay pr_request_reviewers to GOVERNED_APPROVERS and needs-user-decision on the PR in the same act. On an approver's APPROVED (or a maintainer queueing it): clear the label, automerge_enable, follow to MERGED, landing record on Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation protocol:system size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1"

2 participants