Repository navigation
Commit e9fb529
feat(spec): push channel protocol face — device registration widening, stored device shape, device contract members, push route, APNs/FCM config by credentialRef (#29218)
domain:spec#1 · os-steve · session_01ADHkmT6K7dHh79Lej3sWr5
<!-- claude-projects-attribution: {"github_login":"os-steve"} -->
_Requested by **Steve** · [project
thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi9Aggkc5wTqF5ygEUSkDJtkT)_
Fixes #29067
Supersedes #29101 (now closed by the seat as superseded): its three
commits at `088613be` are 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
## 维护者速读
- 改了什么:spec 里补齐 push 推送渠道的协议面——设备注册请求加宽(vendor/appId/locale 可选,locale
走仓里的 BCP 47 LocaleSchema)、新的设备存储形状 UserDeviceSchema、INotificationService
四个可选设备成员、推送载荷(只收同站相对路由)、APNs/FCM 传输配置(密钥只走 credentialRef)。
- 为什么改:手机壳与控制台要注册原生推送 token,服务端设备注册(#29068)和推送渠道(#29041)都卡在这份协议上。
- 风险与回滚:纯加宽,今天能发的请求体照旧能解析;所有新形状都标了 [PLANNED],还没有路由或读取方;回滚就是撤掉这个 PR。
- 先看:`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`,预期全部通过。
- 需要你做的:批准新的公开名字(schema 名、键名、INotificationService 成员,清单见报告
open_questions);落地本身由席位走。
## What changes
The push channel's protocol face (ADR-0012 §6), the `packages/spec`
slice 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, per
`check:doc-authoring`).
- **`RegisterDeviceRequestSchema`** (`api/protocol.zod.ts`) gains three
optional members: `vendor` (`apns` | `fcm`; absent, derived from
`platform`; `web` has no default, so a `web` registration names `apns`
or `fcm`), `appId`, and `locale`, a BCP 47 tag through the house
`LocaleSchema` (`system/translation.zod.ts`). The describes state the
refresh-by-`deviceId` and 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 another `deviceId` (storage cleared, no
`deviceId` sent) refreshes that row and answers its `deviceId`; 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 with `enabled: false` and
`invalidatedAt`; re-registering re-enables it. `invalidatedAt` is
present exactly while `enabled` is false, and `lastSeenAt` is absent
only on a row no registration wrote (listed last). Its `locale` takes
the same `LocaleSchema`, and its `platform` the registration's own list
(one internal const, `system/push-device-platform.ts`, re-exported by no
barrel, so no public name).
- **`INotificationService`** gains four optional members:
`registerDevice`, `unregisterDevice`, `listDevices`,
`invalidateDeviceToken`, each documenting the semantics an
implementation owes (for example, `unregisterDevice` throws an error
whose `code` is `NOT_FOUND` for 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`): `notificationId` plus an optional `route`.
`route` is the notification's `actionUrl`, the same value the inbox
serves (explicit `payload.url` / `payload.actionUrl`, else `/OBJECT/ID`
from the source record, `actionUrlFor` in 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, through
`resolveNotificationTarget`
(`ui/packages/app-shell/src/utils/appRoute.ts`) with its host app
segment. A shell with no app list of its own hands `route` to the
Console to resolve, or hosts it under the segment the Console would
answer (`resolveHostAppSegment`: preferred, then first active app, then
`setup`). `PushRouteSchema` admits 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: FCM `message.data`, APNs top level beside `aps`, never under a
`data` key. No `notify` key is added.
- **`PushServiceConfigSchema`** (new, `system/push-config.zod.ts`,
beside `email-config.zod.ts`): strict `apns` (token auth: `teamId`,
`keyId`, `topic`, `environment`, `credentialRef`) and `fcm` (HTTP v1:
`projectId`, `credentialRef`) blocks. Secrets go only through
`credentialRef` (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` /
`serviceAccountRef` are answered with `credentialRef`. The reference is
stricter than the house `credentialRef` (`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 `.changeset/29067-push-device-registry-spec.md`: `minor`,
`Metadata-protocol: added` (the authorable-surface baseline grew, which
the level gate reads as `added`).
**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,
and `packages/runtime/src/request-schema-gate.conformance.test.ts` reds
any dispatcher-catalog body route with a `requestSchema` and 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)` and `not.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 `notify` deliveries need a push payload
contract. The server device registry (#29068) and the push channel
(#29041) are blocked on this protocol face.
## Tests
- New / extended: `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` (`credentialRef` on both blocks,
inline-secret refusals by name and by content, the `keyRef` /
`serviceAccountRef` near-miss answers, PLANNED markers),
`api/register-device-request.test.ts` (pre-widening bodies still parse,
widened round-trip, unknown vendor refused, a `web` registration naming
either vendor admitted, non-tag locales refused and BCP 47 tags
admitted, PLANNED markers), `api/push-delivery.test.ts` (both inbox
`actionUrl` shapes admitted, `/opportunity/123` and
`/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%5cb` and 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).
- **Forced test edit:** `type-alias-convention.pin.test.ts` pin count
817 → 818 (the new isomorphic `UserDevice` alias; the resumed commits
had moved it 811 → 817 for the push-config and push-delivery aliases).
<details><summary>Local readings (at the head `aff751de`)</summary>
- `aff751de` carries the round-4 prose should-fixes (a shell with no app
list; `%25` refused in the route path, pinned). `eb9b2371` carried the
delta review's round (the push `route` contract, per-vendor placement,
the path refusals, the same-user token refresh), `e7b06bc4` the contract
review's should-fixes, and `833e9bd7` moved both `locale` members to
`LocaleSchema`. Every reading below was taken again at `aff751de`;
earlier heads carried the same readings with 23450 (`eb9b2371`), 23437
(`e7b06bc4`), 23434 (`833e9bd7`) and 23423 (`73fabdf2`) spec tests.
- Build + regen: `pnpm --filter @objectstack/spec build` then
`check:generated --fix` under 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:generated`
without 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).
- Consumers (closure built first, VERDICT 0):
`@objectstack/service-messaging` test Test Files 62 passed (62), Tests
759 passed, typecheck exit 0; `@objectstack/runtime`
`notification-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/client` typecheck exit 0 (it re-exports
`RegisterDeviceRequest` / `RegisterDeviceResponse`).
- Scoped lint: `pnpm exec eslint --no-inline-config` over the 14 changed
.ts files, exit 0, no findings.
- Ablation, `credentialRef`: `push-config.zod.ts` replaced by its
`088613be` bytes (keyRef / serviceAccountRef, no PLANNED marker; on-disk
counts checked) → `push-config.test.ts` exit 1, 19 failed of 21;
restored with `git checkout HEAD --`, blob hash equal to HEAD, `git diff
HEAD` empty → exit 0, 21 passed. Under a trap.
- Ablation, `locale`: both `locale: LocaleSchema.optional()` lines put
back to `z.string().optional()` (`ablation-replace.mjs`, anchor 1 → 0 in
each file) → `register-device-request.test.ts` + `push-device.test.ts`
exit 1, 7 failed of 40; restored, both blob hashes equal to HEAD, `git
diff HEAD` empty → exit 0, 40 passed. Under a trap.
- Ablation, shared platform list: `UserDeviceSchema.platform` put back
on its own `z.enum(['ios', 'android'])` → `push-device.test.ts` exit 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.
- Ablation, path refusals: the second `.regex()` (dot segments,
percent-encoded `/` `\` `.`) removed from `PushRouteSchema` →
`push-delivery.test.ts` exit 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.
- Ablation, `%25`: `|25` taken out of that pattern
(`ablation-replace.mjs`, anchor 1 → 0) → `push-delivery.test.ts` exit 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 --event` with this
body: exit 0, level axis `yes (widening)` + `added`.
`check-partof-closing-keyword.mjs` with this body: exit 0.
- Dogfood battery: n/a, no `!` in the title and no `(narrowing)` arm.
</details>
<details><summary>Gates (dispatch-gates --commands, 103
commands)</summary>
- At the head `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.
- At `eb9b2371`, `e7b06bc4`, `833e9bd7` and `73fabdf2`: all 103 exit 0.
First round at `ed89754e`: 102 of 103 exit 0; `check:llms-txt` exit 1 on
the schema inventory counts (system 37 vs 39, api 33 vs 34, heading 213
vs 216, the three new modules), fixed in `73fabdf2`.
- Before the gates: `pnpm turbo run build --concurrency=2
--filter='./packages/**'` under os-verify-lock, VERDICT command-exit 0
(the `check:dual-build-cjs-loads` / `check:skill-examples`
prerequisites).
- CI on `833e9bd7`, `e7b06bc4` and `eb9b2371`: 57 success, 8 skipped, 0
failure each. CI on `aff751de`: the seat reads it.
</details>
## Acceptance notes
- Card vs house: Wanted 2 route rows are not in this PR (above). Wanted
7 (`NotificationChannelSchema` JSDoc) rides the server slice that
registers `push`.
- Hand-off to #29068 (the server device-registry slice): the device
route rows and their handlers; the handler's refusal of a `web`
registration without `vendor` (the schema admits it, since `vendor` is
optional; the refusal needs `platform` and `vendor` together and is the
handler's); the `request-schema-gate.conformance.test.ts` violating-body
case for the device routes; the catalog pin flip in
`plugin-rest-api.test.ts`; and the `DEFAULT_NOTIFICATION_ROUTES` JSDoc
correction below.
- `DEFAULT_NOTIFICATION_ROUTES`' JSDoc says it covers "device
registration, preferences, listing" while the table lists only listing;
false on `main` before this PR, left as it is for #29068 to correct with
the rows.
- Resume: the previous dev's commits `8dcce8b7`, `81b25bd2`, `088613be`
(PR #29101) are carried unchanged. Changed on top of them: `keyRef` /
`serviceAccountRef` → `credentialRef`, the PLANNED markers,
`UserDeviceSchema`, `listDevices`, the changeset level (`patch` / `none`
→ `minor` / `added`), both `locale` members on `LocaleSchema`, the
contract review's should-fixes (one vendor sentence, the `NOT_FOUND`
mechanism, the stricter-reference comment, the shared platform list,
when the two timestamps are absent), the delta review's round (`route`
as the inbox `actionUrl` the shell resolves, per-vendor placement, path
refusals, the same-user token refresh), and its prose round (a shell
with no app list, the `%25` refusal).
## Verification
See the readings under Tests. The PR stays a draft; the seat hands the
declaration-face contract review to triage.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes (scoped to the changed
files locally; the repo-wide run is CI's).
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed.
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 2789ba7 commit e9fb529
37 files changed
Lines changed: 1388 additions & 24 deletions
File tree
- .changeset
- content/docs/references
- api
- system
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages/spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- src
- api
- contracts
- system
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
| 36 | + | |
36 | 37 | | |
37 | 38 | | |
38 | 39 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
37 | 37 | | |
38 | 38 | | |
39 | 39 | | |
| 40 | + | |
40 | 41 | | |
41 | 42 | | |
42 | 43 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
2761 | 2761 | | |
2762 | 2762 | | |
2763 | 2763 | | |
| 2764 | + | |
| 2765 | + | |
2764 | 2766 | | |
2765 | 2767 | | |
2766 | 2768 | | |
2767 | 2769 | | |
2768 | 2770 | | |
2769 | 2771 | | |
2770 | | - | |
| 2772 | + | |
2771 | 2773 | | |
| 2774 | + | |
| 2775 | + | |
| 2776 | + | |
2772 | 2777 | | |
2773 | 2778 | | |
2774 | 2779 | | |
2775 | 2780 | | |
2776 | 2781 | | |
2777 | 2782 | | |
| 2783 | + | |
| 2784 | + | |
2778 | 2785 | | |
2779 | 2786 | | |
2780 | 2787 | | |
| |||
3019 | 3026 | | |
3020 | 3027 | | |
3021 | 3028 | | |
| 3029 | + | |
| 3030 | + | |
3022 | 3031 | | |
3023 | 3032 | | |
3024 | 3033 | | |
| |||
3030 | 3039 | | |
3031 | 3040 | | |
3032 | 3041 | | |
| 3042 | + | |
| 3043 | + | |
3033 | 3044 | | |
3034 | 3045 | | |
3035 | 3046 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
0 commit comments