Repository navigation
feat(spec): push channel protocol face — device registration widening, stored device shape, device contract members, push route, APNs/FCM config by credentialRef - #29218
Conversation
…rt config cases (red) Claude-Session: https://claude.ai/code/session_01FEkQ5oc56xChD6Wrc4cGSt Co-Authored-By: Claude <noreply@anthropic.com>
…, device contract members, push delivery data, APNs/FCM transport config by reference Claude-Session: https://claude.ai/code/session_01FEkQ5oc56xChD6Wrc4cGSt Co-Authored-By: Claude <noreply@anthropic.com>
…ush channel protocol face Claude-Session: https://claude.ai/code/session_01FEkQ5oc56xChD6Wrc4cGSt Co-Authored-By: Claude <noreply@anthropic.com>
… producer Completes the card's protocol face on top of the resumed commits: - UserDeviceSchema (system/push-device.zod.ts), the stored device shape: user-owned, one live owner per token, a dead token keeps its row disabled. - INotificationService.listDevices, optional like the other device members. - APNs / FCM transport secrets keyed credentialRef (ADR-0143 D3), the house key for a secret reference; keyRef / serviceAccountRef answer with it. - [PLANNED] markers on the device schemas, the delivery data, the stored device and the transport config: nothing serves or reads them yet, and no route row is added (the rows land with their handlers). - Changeset: minor, Metadata-protocol: added (the authorable surface grew). Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5 Co-authored-by: Claude <noreply@anthropic.com>
…rotocol face Regenerated from the merged tree by check:generated --fix after the origin/main merge: api-surface, export-origins, declaration-map, docs references, strictness ledger counts, json-schema manifest and the authorable-surface shard (system shard taken from main, then gen:schema). Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5 Co-authored-by: Claude <noreply@anthropic.com>
check:llms-txt: system 37 -> 39 (push-config, push-device), api 33 -> 34 (push-delivery), heading 213 -> 216; the rows name the new modules. Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 168 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 93ea32613d09b525fd2323e3b347bc22e9975eb0 && git checkout 93ea32613d09b525fd2323e3b347bc22e9975eb0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5651647d82dea5ce18ad537d629c18e3dd27f613 918921642593dd4097831bd1ef1582ae15f584fb && git checkout -B drift-repro 5651647d82dea5ce18ad537d629c18e3dd27f613 && git merge --no-ff 918921642593dd4097831bd1ef1582ae15f584fb
node scripts/docs-audit/affected-docs.mjs --json 5651647d82dea5ce18ad537d629c18e3dd27f613
|
RegisterDeviceRequestSchema.locale and UserDeviceSchema.locale were a bare string their describes called BCP 47; a device saved as a non-tag spelling would be rendered in a locale no bundle answers. Both now take LocaleSchema (system/translation.zod.ts), with refusal tests for non-tag spellings. Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5 Co-authored-by: Claude <noreply@anthropic.com>
|
domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS Head-sha: 维护者速读
席位意见:可落地,等你批名字 + 契约复审。 席位核对 — ACCEPT ·
|
|
domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS Contract-review-claim: session_01ADHkmPi25XXKx92Xmg4viS · head 833e9bd · 2026-11-02T11:08Z |
|
domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS Contract reviewServed-tier: Read-only round: the net diff against ① Derived judgments
② Semver level
③ Boundary flagsDev flags, from os-dev-report 6573960254 (head
Nothing is escalated to the maintainer beyond the name approval the PR already requests. ④ Design judgmentEach packages/spec/src/api/index.ts, packages/spec/src/system/index.ts — barrel lines only. Import graph checked: packages/spec/src/api/protocol.zod.ts — the widening holds (① item 1). Two findings on what the describes promise:
packages/spec/src/api/push-delivery.zod.ts — holds (① item 8). The route is derived from the one value the inbox already serves, so no authorable key is added and the packages/spec/src/contracts/notification-service.ts — the four members hold against ADR-0012 §6 / §12 (register-or-refresh, one live owner, invalidate on
packages/spec/src/system/push-config.zod.ts — holds against ADR-0143 D3: both secrets are
packages/spec/src/system/push-device.zod.ts — rules (a)/(b)/(c) are stated in the header and in the describes, the no-organization rule is pinned, and the ISO-instant members use
The Implemented-by: VERDICT: PASS |
- RegisterDeviceRequestSchema.vendor and INotificationService.registerDevice now say the same thing: web has no default vendor, a web registration names apns or fcm, and one without vendor is refused. - unregisterDevice states its mechanism: a device the user does not hold throws an error whose code is NOT_FOUND, which the route answers as 404. - A comment says why the push secretReference is stricter than the house credentialRef: the two push secrets are recognisable by shape (PEM block, JSON document), a bearer token or API key is not. - One platform list for the registration and the stored device, in an internal module no barrel re-exports, so no new public name. - UserDeviceSchema describes say when lastSeenAt and invalidatedAt are absent. - The changeset names LocaleSchema for the registration's locale. Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5 Co-authored-by: Claude <noreply@anthropic.com>
Consumer acceptance (mobile shell + Console), head
|
|
domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS Contract-review-claim: session_01ADHkmPi25XXKx92Xmg4viS · head e7b06bc · 2026-11-02T12:11Z |
… refusals Delta contract review round 3 on the push device registry spec. - PushDeliveryData.route is the notification's actionUrl, the same value the inbox serves: app-relative (/OBJECT/ID) or already /apps/... The describes and the docblock say the shell resolves it on tap exactly as the bell and Home do (resolveNotificationTarget with its host app segment) and never navigates it verbatim. - The docblock says where the data sits per vendor: FCM message.data, APNs top level beside aps, never under a data key. - PushRouteSchema refuses dot segments and percent-encoded /, \ and . in its path, as a pattern the published JSON Schema carries too. - The device docblock and registerDevice say a token the user already holds under another deviceId refreshes that row, never a second live row. - The changeset follows. Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5 Co-authored-by: Claude <noreply@anthropic.com>
|
domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS Contract reviewServed-tier: Delta review on the previous record (comment 6575836138, head ① Derived judgments
② Semver levelUnchanged: ③ Boundary flagsThe six should-fixes of the previous record, each against the delta:
Dev flags from os-dev-report 6573960254 (open_questions 1–4, Wanted 2 and Wanted 7 hand-offs, Escalations: must-fix 1 below is for the dev; no maintainer ruling is needed for it (the Console's own code decides what ④ Design judgmentEach packages/spec/src/api/push-delivery.zod.ts — unchanged in the delta, re-read against its consumer this round.
packages/spec/src/system/push-device.zod.ts — the delta's describe changes hold (③ items 5 and 6);
packages/spec/src/system/push-device-platform.ts — new, internal. One packages/spec/src/api/protocol.zod.ts — the
packages/spec/src/contracts/notification-service.ts — packages/spec/src/system/push-config.zod.ts — one packages/spec/src/api/index.ts, packages/spec/src/system/index.ts — unchanged in the delta; the internal module is deliberately not re-exported. Holds. The Implemented-by: VERDICT: FAIL |
|
domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS Contract-review-claim: session_01ADHkmPi25XXKx92Xmg4viS · head eb9b237 · 2026-11-02T13:09Z |
|
repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv Cross-lane notice (information, not a request to change this PR). #29642 asks the maintainer to decide whether Why: the mobile shell now keeps several saved servers, so a tap has to know which one sent it. One of the options adds optional members to |
domain:spec#1 · os-steve · session_01ADHkmT6K7dHh79Lej3sWr5
Requested by Steve · project thread
Fixes #29067
Supersedes #29101 (now closed by the seat as superseded): its three commits at
088613beare carried unchanged on this branch and continued.Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: none — declared ahead of its producer: no route serves the device schemas and nothing reads the push transport configuration yet, so there is nothing an author can use to teach; the server slice that serves them owns the docs
维护者速读
/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041)都卡在这份协议上。packages/spec/src/system/push-device.zod.ts(设备归属规则 a/b/c)、packages/spec/src/system/push-config.zod.ts(凭据引用与内联密钥拒收)、packages/spec/src/api/protocol.zod.ts的设备注册块。pnpm --filter @objectstack/spec exec vitest run src/system/push-device.test.ts src/system/push-config.test.ts src/api/push-delivery.test.ts src/api/register-device-request.test.ts,预期全部通过。What changes
The push channel's protocol face (ADR-0012 §6), the
packages/specslice of #29041. Declared ahead of its producer: nothing here is served or read yet, and every new or touched schema says so with a[PLANNED — not yet served]/[PLANNED — not yet read]marker in its description (tracker ids live only in adjacent//comments, percheck:doc-authoring).RegisterDeviceRequestSchema(api/protocol.zod.ts) gains three optional members:vendor(apns|fcm; absent, derived fromplatform;webhas no default, so awebregistration namesapnsorfcm),appId, andlocale, a BCP 47 tag through the houseLocaleSchema(system/translation.zod.ts). The describes state the refresh-by-deviceIdand server-minted-id contract.RegisterDeviceResponseSchema/UnregisterDevice*keep every member and gain only a description. No published field is removed or renamed, so no ADR-0087 tombstone, conversion or semantic entry is owed.UserDeviceSchema(new,system/push-device.zod.ts): the stored device. (a) A device belongs to its user alone, no organization member; identity is (userId,deviceId). (b) One live row per token: a token this user already holds under anotherdeviceId(storage cleared, nodeviceIdsent) refreshes that row and answers itsdeviceId; a second user's registration takes the token over and the first user's row for it is removed. (c) A dead token keeps its row withenabled: falseandinvalidatedAt; re-registering re-enables it.invalidatedAtis present exactly whileenabledis false, andlastSeenAtis absent only on a row no registration wrote (listed last). Itslocaletakes the sameLocaleSchema, and itsplatformthe registration's own list (one internal const,system/push-device-platform.ts, re-exported by no barrel, so no public name).INotificationServicegains four optional members:registerDevice,unregisterDevice,listDevices,invalidateDeviceToken, each documenting the semantics an implementation owes (for example,unregisterDevicethrows an error whosecodeisNOT_FOUNDfor a device the user does not hold, which the route answers as 404). Existing implementations stay valid.PushDeliveryDataSchema/PushRouteSchema(new,api/push-delivery.zod.ts):notificationIdplus an optionalroute.routeis the notification'sactionUrl, the same value the inbox serves (explicitpayload.url/payload.actionUrl, else/OBJECT/IDfrom the source record,actionUrlForin service-messaging): app-relative or already/apps/…. The shell never navigates it verbatim; on tap it resolves it exactly as the bell and Home do, throughresolveNotificationTarget(ui/packages/app-shell/src/utils/appRoute.ts) with its host app segment. A shell with no app list of its own handsrouteto the Console to resolve, or hosts it under the segment the Console would answer (resolveHostAppSegment: preferred, then first active app, thensetup).PushRouteSchemaadmits only a same-server relative path, with no dot segment and no percent-encoded/,\,.or%in its path, which closes the double-encoded forms too (both rules are patterns, so the published JSON Schema carries them); a value that fails it is dropped, never sent. The docblock says where the data sits per vendor: FCMmessage.data, APNs top level besideaps, never under adatakey. Nonotifykey is added.PushServiceConfigSchema(new,system/push-config.zod.ts, besideemail-config.zod.ts): strictapns(token auth:teamId,keyId,topic,environment,credentialRef) andfcm(HTTP v1:projectId,credentialRef) blocks. Secrets go only throughcredentialRef(ADR-0143 D3): an inline secret is refused by name (key,privateKey,serviceAccount, …) and as the content of a reference (a PEM block or a JSON document).keyRef/serviceAccountRefare answered withcredentialRef. The reference is stricter than the housecredentialRef(z.string().min(1)) on purpose, and a comment says why: these two secrets are recognisable by shape, a bearer token or API key is not. Not a stack-definition key until its reader ships..changeset/29067-push-device-registry-spec.md:minor,Metadata-protocol: added(the authorable-surface baseline grew, which the level gate reads asadded).No route row is added to
DEFAULT_NOTIFICATION_ROUTES. The card's Wanted 2 asks for the device rows; measured, there is no house channel for a declared-not-served route row:handlerStatus: 'planned'was retired under ADR-0049 with the prescription "an endpoint that has no handler yet is simply not registered" (HANDLER_STATUS_RETIRED), the table's own comment records the device rows' removal for that reason, andpackages/runtime/src/request-schema-gate.conformance.test.tsreds any dispatcher-catalog body route with arequestSchemaand no served violating-body case. The rows land with their handlers in #29068, which also flips the spec catalog pin (plugin-rest-api.test.ts,toHaveLength(3)andnot.toContain('/devices')) and adds the runtime gate case.Why
The mobile shell and the Console register a native APNs / FCM token under the signed-in session, and
notifydeliveries need a push payload contract. The server device registry (#29068) and the push channel (#29041) are blocked on this protocol face.Tests
system/push-device.test.ts(round-trip, required members, vendor refusal, non-tag locale refusal, the same platform list as the registration, ISO instants, no organization member, PLANNED marker),system/push-config.test.ts(credentialRefon both blocks, inline-secret refusals by name and by content, thekeyRef/serviceAccountRefnear-miss answers, PLANNED markers),api/register-device-request.test.ts(pre-widening bodies still parse, widened round-trip, unknown vendor refused, awebregistration naming either vendor admitted, non-tag locales refused and BCP 47 tags admitted, PLANNED markers),api/push-delivery.test.ts(both inboxactionUrlshapes admitted,/opportunity/123and/apps/crm/opportunity/123; off-server, scheme, protocol-relative, backslash, control-character, dot-segment and percent-encoded/\.routes refused, including/../../api/v1/me,/%2e%2e/x,/./x,/a%2fb,/a%5cband the double-encoded/a%252Fb,/%252e%252e/x,/a%255cb; a query or fragment keeps its encoding; PLANNED marker),contracts/notification-service.test.ts(the four optional members, a toy registry exercising register / list / unregister / invalidate).type-alias-convention.pin.test.tspin count 817 → 818 (the new isomorphicUserDevicealias; the resumed commits had moved it 811 → 817 for the push-config and push-delivery aliases).Local readings (at the head `aff751de`)
aff751decarries the round-4 prose should-fixes (a shell with no app list;%25refused in the route path, pinned).eb9b2371carried the delta review's round (the pushroutecontract, per-vendor placement, the path refusals, the same-user token refresh),e7b06bc4the contract review's should-fixes, and833e9bd7moved bothlocalemembers toLocaleSchema. Every reading below was taken again ataff751de; earlier heads carried the same readings with 23450 (eb9b2371), 23437 (e7b06bc4), 23434 (833e9bd7) and 23423 (73fabdf2) spec tests.pnpm --filter @objectstack/spec buildthencheck:generated --fixunder os-verify-lock, VERDICT command-exit 0; each round's describe and docblock changes regenerated their reference pages (api/protocol.mdx,api/push-delivery.mdx,system/push-device.mdx), committed with the source;check:generatedwithout fix then exit 0. Earlier, the authorable-surface system shard was taken from main and regenerated (the two keys renamed here, never on main, left with their rename).pnpm --filter @objectstack/spec test: VERDICT command-exit 0, Test Files 794 passed (794), Tests 23454 passed, 1 todo.pnpm --filter @objectstack/spec typecheck: VERDICT command-exit 0 (tsc, scripts typecheck, and check:test-typecheck: test layer compiles, 131 pinned signatures held).@objectstack/service-messagingtest Test Files 62 passed (62), Tests 759 passed, typecheck exit 0;@objectstack/runtimenotification-schema-conformance.test.ts+request-schema-gate.conformance.test.ts(the catalog pin consumer) Test Files 2 passed, Tests 25 passed, typecheck exit 0;@objectstack/clienttypecheck exit 0 (it re-exportsRegisterDeviceRequest/RegisterDeviceResponse).pnpm exec eslint --no-inline-configover the 14 changed .ts files, exit 0, no findings.credentialRef:push-config.zod.tsreplaced by its088613bebytes (keyRef / serviceAccountRef, no PLANNED marker; on-disk counts checked) →push-config.test.tsexit 1, 19 failed of 21; restored withgit checkout HEAD --, blob hash equal to HEAD,git diff HEADempty → exit 0, 21 passed. Under a trap.locale: bothlocale: LocaleSchema.optional()lines put back toz.string().optional()(ablation-replace.mjs, anchor 1 → 0 in each file) →register-device-request.test.ts+push-device.test.tsexit 1, 7 failed of 40; restored, both blob hashes equal to HEAD,git diff HEADempty → exit 0, 40 passed. Under a trap.UserDeviceSchema.platformput back on its ownz.enum(['ios', 'android'])→push-device.test.tsexit 1, 1 failed of 19 (the same-platforms test); restored, blob hash equal to the pre-ablation file → exit 0, 19 passed. Under a trap..regex()(dot segments, percent-encoded/\.) removed fromPushRouteSchema→push-delivery.test.tsexit 1, 8 failed of 27 (exactly the eight new refusals); restored, blob hash equal to the pre-ablation file → exit 0, 27 passed. Under a trap.%25:|25taken out of that pattern (ablation-replace.mjs, anchor 1 → 0) →push-delivery.test.tsexit 1, 3 failed of 31 (exactly the three double-encoded pins); restored, blob hash equal to the pre-ablation file → exit 0, 31 passed. Under a trap.check-changeset-no-major.mjs --base origin/main --eventwith this body: exit 0, level axisyes (widening)+added.check-partof-closing-keyword.mjswith this body: exit 0.!in the title and no(narrowing)arm.Gates (dispatch-gates --commands, 103 commands)
aff751de: the derived list is the same 103 commands as at every earlier head, and all 103 exit 0 (each run with stdin closed, exit captured before any pipe).dispatch-gates --ran: 103 derived, 103 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN.eb9b2371,e7b06bc4,833e9bd7and73fabdf2: all 103 exit 0. First round ated89754e: 102 of 103 exit 0;check:llms-txtexit 1 on the schema inventory counts (system 37 vs 39, api 33 vs 34, heading 213 vs 216, the three new modules), fixed in73fabdf2.pnpm turbo run build --concurrency=2 --filter='./packages/**'under os-verify-lock, VERDICT command-exit 0 (thecheck:dual-build-cjs-loads/check:skill-examplesprerequisites).833e9bd7,e7b06bc4andeb9b2371: 57 success, 8 skipped, 0 failure each. CI onaff751de: the seat reads it.Acceptance notes
NotificationChannelSchemaJSDoc) rides the server slice that registerspush.webregistration withoutvendor(the schema admits it, sincevendoris optional; the refusal needsplatformandvendortogether and is the handler's); therequest-schema-gate.conformance.test.tsviolating-body case for the device routes; the catalog pin flip inplugin-rest-api.test.ts; and theDEFAULT_NOTIFICATION_ROUTESJSDoc correction below.DEFAULT_NOTIFICATION_ROUTES' JSDoc says it covers "device registration, preferences, listing" while the table lists only listing; false onmainbefore this PR, left as it is for notifications: build the device registry (sys_user_device, /notifications/devices routes, client methods) on #29067's contract (server slice 1 of #29041) #29068 to correct with the rows.8dcce8b7,81b25bd2,088613be(PR feat(spec): push channel protocol face — device registration widening, device contract members, push route, APNs/FCM config by reference #29101) are carried unchanged. Changed on top of them:keyRef/serviceAccountRef→credentialRef, the PLANNED markers,UserDeviceSchema,listDevices, the changeset level (patch/none→minor/added), bothlocalemembers onLocaleSchema, the contract review's should-fixes (one vendor sentence, theNOT_FOUNDmechanism, the stricter-reference comment, the shared platform list, when the two timestamps are absent), the delta review's round (routeas the inboxactionUrlthe shell resolves, per-vendor placement, path refusals, the same-user token refresh), and its prose round (a shell with no app list, the%25refusal).Verification
See the readings under Tests. The PR stays a draft; the seat hands the declaration-face contract review to triage.
Human Minimum
packages/specholds no runtime logic.pnpm lintpasses (scoped to the changed files locally; the repo-wide run is CI's).main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code