Repository navigation
feat(notifications): the push channel — service-push (APNs + FCM) and its channel shell in service-messaging - #29794
Conversation
… 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
…annel.test.ts 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
|
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>
📓 Docs Drift CheckThis PR changes 4 package(s): 19 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 169 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
|
domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS Contract-review-claim: session_01JQwxbacSXJQqAztgt4UMrS · head 3aac0c5 · 2026-11-03T05:59Z |
|
domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS Contract reviewServed-tier: 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 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
② Semver levelThe changeset is ③ Boundary flags
④ Design judgment
No must-fix. Two should-fix items (④ 3 and 4), both carried to follow-ups. Implemented-by: VERDICT: PASS |
|
domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS 维护者速读
席位核对ACCEPT ·
|
|
domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS 席位核对 · holdHOLD ·
|
…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>
|
domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS Contract-review-claim: session_01JQwxbacSXJQqAztgt4UMrS · head b0503c1 · 2026-11-03T14:19Z |
|
domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS Contract reviewServed-tier: Round 3, a delta review on the round-1 PASS (6596752211, head Gate verdicts on the head at record time: 26 success, 7 skipped by design, 0 failed. Completed green: Lint & Repo Gates and every ① Derived judgments
② Semver levelThe changeset is ③ Boundary flags
④ Design judgment
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 Implemented-by: 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>
|
domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS Contract reviewServed-tier: Round 3b, a delta review on the round-3 PASS (6606491253, head The delta, verified against the tree and not the addendum: 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 ( ① Derived judgments
② Semver levelUnchanged: ③ Boundary flags
④ Design judgment
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: VERDICT: PASS |
|
domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS 维护者速读
席位核对ACCEPT ·
|
Requested by Warren · project thread
Fixes #29041
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
@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 不写。/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041 的验收条件。之前流程里选了推送也显示“运行成功”,实际一条推送都没有发出(已在 main 上复现)。transport: direct,不会出现以为能推送、实际什么都发不出去的情况;推送密钥只能通过引用(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 条全过:模拟的苹果和谷歌推送服务各收到一条带跳转的推送,被报失效的设备变为停用,同一用户的另一台手机照常收到。Head-sha: 5b7095572f3f1836f18a0ef62bd072485353edcd),请批准 ADR-0012(通知平台)的这次修订。本 PR 属于 Tier H 就是因为它,只改了状态行的Amended说明和 §4、§6 两处。What changes
@objectstack/service-push(packages/services/service-push, named per the maintainer's 2026-11-03 ruling: kernel services areservice-*).PushServicePluginregisters the kernel'spushservice, anIPushServicewith 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.p8key and reused for fifty minutes (APNs refuses one older than an hour and throttles refreshes under twenty).200is sent;BadDeviceToken/Unregistered/410isinvalid_token;ExpiredProviderTokenre-signs once;429/5xxare retryable; other refusals are not. A token that is not hex is answeredinvalid_tokenand 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(or404 NOT_FOUND) isinvalid_token; a401refreshes the token once;429/5xxare retryable.transport: 'direct', or a vendor block with notransport; the operator's own APNs key and Firebase project, for a white-label build). The hosted relay, the default ofPushServiceConfigSchemasince spec: push relay transport —transport: relay | directon PushServiceConfigSchema, and the server→relay wire shape (ruling cloud#3519) #29816 and so also an empty config, is refused at init withPUSH_RELAY_NOT_SERVED_MESSAGEand registers nothing, until service-push: send through the hosted relay transport (push-relay.zod.ts wire, cloud binding credential) #29920 ships its sender.configoption, parsed withPushServiceConfigSchemaat init, so an inline secret or a misspelt key fails the boot with the schema's guidance. Each secret is acredentialRef, resolved when the transport first signs throughresolveCredential(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: thepushchannel shell (push-channel.ts), mounted through the same lazy provider as the sms channel, so it is present exactly while apushservice 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 dataPushDeliveryDataSchemadeclares:notificationId, plusroutewhen the notification'sactionUrlpassesPushRouteSchema(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.invalidateDeviceToken(row kept,enabled: false,invalidatedAt); the user's other devices still get the push.invalid_recipient(suppressed); devices only on an unconfigured vendor, or a refusal no retry repairs ⇒permanent; timeout /429/5xxwith nothing delivered ⇒retryable; no push service ⇒ refusedtransport_not_configured, as the sms and email channels refuse it.isAvailableanswers from the push service's vendors, no I/O.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.tsandpush-device.zod.tsnow say the same.packages/spec: PLANNED flips and text only. The[PLANNED — not yet read]markers come offPushServiceConfigSchemaandPushDirectTransportConfigSchema(the APNs and FCM blocks carried none on main) and[PLANNED — not yet served]offPushDeliveryDataSchema(their reader and producer ship here).PushRelayTransportConfigSchemakeeps a marker,[PLANNED — not yet served], saying the push service refuses it at boot; one docblock paragraph inpush-config.zod.tssays what is served today; the two marker pins flip to "no PLANNED marker".push-config.zod.ts's docblock names@objectstack/service-pushwhere it namedplugin-notification-push.NotificationChannelSchema's JSDoc listspushamong 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: theNOT_FOUNDsynonym waiver's reason names service-messaging's provenance row;INotificationService's device comments say the push channel readslistDevicesand callsinvalidateDeviceToken(true as of this PR); the ownership sentence above. Reference pages regenerated (push-config.mdx,push-delivery.mdx). No key, shape or export changes.Amended (2026-11-03, #29041): push ships asservice-pushplus the channel shell in service-messaging, which ownssys_user_device, and everyplugin-notification-pushin 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 issystem/push-device.zod.ts; the amendment also saysservice-pushships 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 onmain: 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.content/docs/plugins/packages.mdx(service count 17 → 18, aservice-pushentry, the messaging line),CONTRIBUTING.mdpackage row,.changeset/config.jsonfixed group,packages/spec/llms.txtpackage count 73 → 74 (forced bycheck:llms-txt; prose in the spec tree, not a schema), README..changeset/29041-push-notification-channel.md:service-push,service-messagingandspecallpatch(Metadata-protocol: nonetakespatch, 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
notifynode deliver a push to a registered device through APNs/FCM, carryroute, and retire a token reported invalid. The protocol face (#29067, PR #29218) and the device registry (#29068, PR #29628) had landed; nothing sent.Tests
origin/main7b55ea95(a throwaway probe, not committed): anotifynode withchannels: ['push']over the realMessagingServicewith a registered APNs device and a local HTTP/2 server →run.success: true,summary.acted: 0,APNS requests received: 0; the probe'sexpect(received).toHaveLength(1)failed.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,routeandnotificationIdin both payloads; the source record's/OBJECT/IDrides asroutewhen no url is given; a token APNs answers410 Unregisteredis retired (enabled: false,invalidatedAtset), 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 onExpiredProviderToken, 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 againstPushDeliveryDataSchema, 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 apushservice exists, delivered through it once registered.git diff HEADempty;ablation-replace.mjsunder a trap): theinvalidateDeviceTokencall removed →push-channel.test.ts2 failed of 14 (the two retirement cases);PushRouteSchema.safeParse(actionUrl).successreplaced bytrue→ 1 failed of 14 (the off-server route case).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-push35/35. Ablation: the refusal line replaced by a comment → 4 failed of 12 (the four relay arms); restored,git diff HEADempty.push-config.test.tsandpush-delivery.test.tsmarker pins flip from "carries the PLANNED marker" to "carries no PLANNED marker".Local readings (tests at `1ebbde26b`; `b6f8119b` changes only the changeset level)
pnpm exec turbo run build --concurrency=2 --filter='./packages/**'under os-verify-lock, VERDICT command-exit 0 (6m18s). Spec rebuilt andcheck:generated --fixregeneratedreferences/api/push-delivery.mdxandreferences/system/push-config.mdx, re-checked green.pnpm --filter @objectstack/service-push test: Test Files 3 passed (3), Tests 30 passed (30);typecheckexit 0.pnpm --filter @objectstack/service-messaging test: Test Files 66 passed (66), Tests 801 passed (801);typecheckexit 0 (both programs).pnpm --filter @objectstack/service-automation typecheckexit 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 typecheckexit 0. The rest of the spec suite andtest:repoare CI's (Test Core).check-changeset-no-major.mjs --base origin/main --eventwith this body: exit 0 (level axisyes (widening)+none, every entrypatch).check-partof-closing-keyword.mjswith this body: exit 0.!in the title and no(narrowing)arm.Gates (dispatch-gates --commands)
b0503c1db(mergedorigin/maina7e984ab4, which carries spec: push relay transport —transport: relay | directon PushServiceConfigSchema, and the server→relay wire shape (ruling cloud#3519) #298161bb9536e): 135 derived commands, 135 of 135 exit 0,pnpm lintexit 0;--ran135 run, 0 NOT-MEASURED. Round 3b at5b7095572(four-file delta):check:doc-authoring,check:generated,check-adr-links,pnpm lint,tscexit 0;service-push35/35.b6f8119b(and before it at1ebbde26b): 134 derived commands, all 134 exit 0 (each run with stdin closed, exit captured before any pipe), pluspnpm lintexit 0.dispatch-gates --ran: 134 derived, 134 run, 0 NOT-MEASURED, 0 UNRUN;pnpm lintrecorded outside the derivation.391ab42b: five reds, each fixed in1ebbde26b:check:llms-txt(spec'sllms.txtsaid 73 packages, the workspace now publishes 74),check:engine-double-contract(the two new test doubles'findOne/update/deletenow route through the metadata-core predicates; pinned ledger rows written with--write),check:objectql-double-limit(the doubles hold the caller'slimit, 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), andcheck-plugin-teardown-shape.mjs --self-testexit 3 on the shallow clone (its pinned fixture commit fetched; no file change).Acceptance notes
new PushServicePlugin({ config }).os servehas nopushcapability (packages/spec/src/kernel/platform-capabilities.ts) and the stack definition noconfig.pushkey, 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 aresolvePushCapabilityArgbesideresolveSmsCapabilityArg).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.sys_notification_templateacceptsemailandsmsonly (TEMPLATE_CHANNELS), so there is no per-topic push template; widening it is not this card.Verification
Readings above. Round 3 merged
origin/maina7e984ab4(carries #298161bb9536e); head5b7095572, 135 derived gates. Contract review PASS 6606770732 on that head.Human Minimum
packages/specholds no runtime logic.pnpm lintpasses.main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01JQwxbWDV359nY4JGrtUq4P
Generated by Claude Code