Skip to content

Commit 7b55ea9

Browse files
feat(notifications): push device registry — sys_user_device, the two device routes and the client methods (#29628)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Steve, Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8Ku7jRvrz4ma8UUATyNH88K)_ Fixes #29068 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 1. 改了什么:手机推送设备登记上线了——新表 `sys_user_device`,新接口 `POST /api/v1/notifications/devices`(登记/刷新)和 `DELETE /api/v1/notifications/devices/:deviceId`(注销自己的设备),客户端恢复 `notifications.registerDevice` / `unregisterDevice`。 2. 为什么改:#29067 已把契约定好但标着“计划中、未提供”,推送渠道没有地方取设备令牌;这次把四个服务方法和两条路由真正接上,去掉 PLANNED 标记。 3. 风险与回滚:只新增,不改已有行为;设备表是系统表,通用数据接口读写都拒绝(404 OBJECT_API_DISABLED),未登录 401,删别人的设备 404 且不删;回滚即还原本 PR,新表无人引用。 4. 先看:`service-messaging/src/device-registry.ts`(令牌唯一、换人接管、失效后重新启用),再看 `runtime/src/domains/notifications.ts` 的设备分支。 5. 两分钟验证:`pnpm --filter @objectstack/service-messaging exec vitest run src/device-registry.integration.test.ts` 和 dogfood `test/push-device-registry.dogfood.test.ts`,两者全绿。 6. 需要你做的:无;三个实现选择已由合同评审 6590640619 全部定为 A,令牌长度上限(评审 S3)是收窄,另走 spec 卡。席位意见:第 3 轮契约复核 PASS(6593441550),CI 全绿,席位落地。 ## What changes - **`@objectstack/service-messaging`** - New `sys_user_device` (`src/objects/user-device.object.ts`): engine-owned, `tenancy: { enabled: false }` (a device belongs to its user, not to an organization, so no injected `organization_id`), `enable: { apiEnabled: false, apiMethods: [] }`, `user_id` a `sys_user` lookup with cascade delete, unique indexes on `(user_id, device_id)` and on `token`. `nameField: 'name'` only (no `titleFormat`, per #29499 which landed during this card). - New `DeviceRegistry` (`src/device-registry.ts`) behind `MessagingService`'s four `INotificationService` members: `registerDevice` (refresh a held device id, mint one when absent, move a token another device holds to the registering device and remove the previous holder's row, re-enable an invalidated device; the takeover delete of the previous holder and the registering write run in one engine transaction where the engine offers one, so a refused write rolls the delete back (the module docblock names the window on an engine without one); `REGISTER_ATTEMPTS` is 3, and a writer that still loses a cross-node unique-index race sees `409 UNIQUE_VIOLATION`, the stated ceiling), `unregisterDevice` (the caller's own device, else `NOT_FOUND` 404 and nothing removed), `listDevices` (the caller's, newest `last_seen_at` first), `invalidateDeviceToken` (row kept, `enabled: false` + `invalidated_at`). Vendor derives from platform (ios→apns, android→fcm); a `web` registration without `vendor` is refused `400 VALIDATION_FAILED` at `vendor`. - The plugin registers and provisions the table; translation bundles regenerated (zh-CN / ja-JP / es-ES translated by hand). - **`@objectstack/runtime`** - `/notifications` domain: `POST /devices` (body parsed with `RegisterDeviceRequestSchema`, owner always the session user, a body `userId` is never read) and `DELETE /devices/:deviceId` (segment decoded; a malformed escape is passed raw, i.e. an unknown id → 404). Both sit behind the domain's anonymous floor (401). Each face probes its own service member, so a provider without the device members answers 501 and one without an inbox still serves them. - `dispatcher-plugin.ts` mounts both on the standalone server, and the other verbs on the two paths refusal-only (the #24024 pattern the inbox faces use). - `route-ledger.ts`: the two SDK rows plus eight server-only refusal rows. The device refusal rows say 404 with or without an inbox and 501 only without a notification service, pinned in `notifications-devices.test.ts`. - **`@objectstack/client`**: `notifications.registerDevice(request)` and `notifications.unregisterDevice(deviceId)` (id `encodeURIComponent`-ed) are back. - **`@objectstack/spec`** (the four allowed hunks): `DEFAULT_NOTIFICATION_ROUTES` lists the two device routes (+ `methods` entries) with the comment rewritten; the `[PLANNED — not yet served]` markers and their "marker comes off" comments leave the four registration schemas and `UserDeviceSchema`; `PLATFORM_OBJECTS_BY_PACKAGE` gains `sys_user_device`; `error-code-ledger.zod.ts` lists `NOT_FOUND` under `@objectstack/service-messaging` with its wire path (`unregisterDevice`, `deviceNotFoundError`, `DELETE /api/v1/notifications/devices/:deviceId` answers 404 through `resolveThrownHttpError`), the hunk contract review 6590640619 allowed. Generated reference docs regenerated. - Census last: `organization-ownership-inventory.ts` row and `scripts/platform-object-tenancy-census.json` regenerated (sys_user_device: tenancy disabled, reach out). The tenant-audit census (`content/docs/permissions/tenant-audit-census.mdx` and `docs/audits/2026-08-tenant-audit-write-call-sites.counts.md`) is regenerated for the registry's five engine write sites (256 → 261 on the current main), with the page's hand-written figures moved to match; the platform-object tenancy census is registered 92 → 93, outOfReach 45 → 46, `tenancy.enabled: false` 2 → 3, and lists both `sys_personal_view` (#29575) and `sys_user_device`; the route-ledger census sentence moves 116 → 126. - No GET route (the card says so); `listDevices` is service-only for the push channel. ### Forced test edits - `packages/services/service-messaging/src/messaging-service-plugin.test.ts`: both OBJECTS lists gain `sys_user_device`. - `packages/services/service-messaging/src/translations/bundle-ownership.test.ts`: the owned-object list gains `sys_user_device`. - `packages/runtime/src/dispatcher-plugin.routes.test.ts`: the refusal-mount pin covers five faces × five verbs (count 15 → 25). - `packages/runtime/src/request-schema-gate.conformance.test.ts`: the ratchet demands a violating-body case for the new catalog row with a request schema; a `registerDevice` spy and the case are added. - `packages/spec/src/api/plugin-rest-api.test.ts`: the default notification routes pin flips to five endpoints including both device routes. - `packages/spec/src/system/constants/platform-object-names.test.ts`: the service-messaging roster pin names `sys_user_device` (forced by the roster hunk; review must-fix 2). - `packages/spec/src/api/register-device-request.test.ts`, `packages/spec/src/system/push-device.test.ts`: the "carries the PLANNED marker" pins flip to "carries no PLANNED marker". ## Tests New: `device-registry.integration.test.ts` (10, real ObjectQL + SqlDriver + `sys_user`; the tenth: a takeover whose write is refused rolls the previous holder's delete back), `objects/user-device.object.test.ts` (4), `runtime/src/domains/notifications-devices.test.ts` (7), two client tests, and `packages/qa/dogfood/test/push-device-registry.dogfood.test.ts` (7, the real showcase stack: anonymous 401 on both routes, register/refresh, non-owner unregister 404 with the row surviving, token takeover, invalidate then re-register, body/vendor 400, and the generic data API refused `404 OBJECT_API_DISABLED` on list/read/create/patch/delete for a member). Red first, on the base: service-messaging fails to resolve the registry; runtime 6 of 14 fail across the new device-face file and the conformance case (the two anonymous cases pass on base because the domain's floor already refuses anonymous; the A4 ablation is what pins them); client 2 fail plus `route-ledger-coverage` "every ledger entry naming a client method resolves". Ablations (each via `scripts/ablation-replace.mjs`, each restored with `git diff HEAD` empty): | # | Mutation | Red | |---|---|---| | A1 | `unregister` drops the `user_id` filter | non-owner unregister case | | A2 | takeover no longer deletes the previous holder | 3 registry cases | | A3 | `web` derives `apns` | vendor case | | A4 | the domain's anonymous floor skipped | both anonymous dispatcher cases | | A5 | owner read from the body `userId` | "registers for the session user" | | A6 | `apiEnabled` true, `apiMethods` removed; service-messaging rebuilt, `ablation-dist-preflight` marker present in dist | dogfood `[data-api]` (the permission layer then answers 403, not the exposure gate's 404); restored, rebuilt, preflight `--absent` ✓, file 7/7 | | A7 | the registration transaction removed | a takeover whose write is refused rolls the previous holder's delete back; restored with `git diff HEAD` empty | ## Acceptance notes and deviations - **Changesets are all `patch`**, not the minors the card's delta suggested: `Metadata-protocol` measures `none`, and the changeset gate refuses `minor` with `none`. - `packages/spec/src/contracts/notification-service.ts`: a comment-only change (the members are now implemented by service-messaging), outside the three named spec hunks; it would otherwise state the opposite of what ships. - The `methods` array entries in `DEFAULT_NOTIFICATION_ROUTES` ride with hunk 1's two route rows. - A fourth spec hunk: the `NOT_FOUND` row in `error-code-ledger.zod.ts`, allowed by review 6590640619. - Review S3 (`RegisterDeviceRequestSchema.token` unbounded while `sys_user_device.token` stores at most 512) stays out of this PR: a narrowing, for a spec follow-up card. - `packages/metadata-protocol/src/protocol.ts` is untouched. ## Open questions (ruled: A on all three, kept, by contract review 6590640619) 1. Cross-node token race: a unique index on `token` plus one retry on a unique violation, beside an in-process per-token lock. Options: A keep (the database is the arbiter across nodes); B add a distributed lock. Recommendation A. 2. `user_id` is a `sys_user` lookup with `deleteBehavior: 'cascade'`, so deleting a user removes their devices. Options: A keep; B `set_null` and sweep. Recommendation A: a device row without a user can never receive a push. 3. `DELETE /devices/:deviceId` with a malformed percent-escape answers 404 (the raw segment is an unknown id) rather than 400. Options: A keep; B refuse 400. Recommendation A: the caller learns nothing about other users' ids either way. ## Verification Head `3cabcf90` (origin/main merged at `e0187d0f`, clean, no overlap with the PR's 44 files; no authored change in round 3). - cli unit: 375/375 files, 5287 tests pass; service-messaging 787, client 695, spec `test:repo` 1522, runtime device files 31: all pass. - Dogfood device file `push-device-registry.dogfood.test.ts` 7/7. Dogfood battery under the verify lock: VERDICT 0; Test Files 400 passed / 3 skipped; Tests 3304 passed / 28 skipped. - `dispatch-gates --ran`: 122 derived, 122 run, 0 NOT-MEASURED (4 prerequisite refusals rerun green after their prerequisites, named in os-dev-report 6593261698). - eslint `--no-inline-config` on all 38 touched code files: exit 0. - CI on `3cabcf90`: 57 success, 8 skipped, 0 failure. - `packages/metadata-protocol/src/protocol.ts` is untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6)_ · body amended by the seat <!-- signed: domain:server#5 · os-warren · session_01JQwxbZUR3xvHyDPCjNL5P4 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0eb97fa commit 7b55ea9

44 files changed

Lines changed: 1829 additions & 155 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/service-messaging': patch
3+
'@objectstack/runtime': patch
4+
'@objectstack/client': patch
5+
'@objectstack/spec': patch
6+
---
7+
8+
feat(notifications): the push device registry is served: `sys_user_device`, `POST /api/v1/notifications/devices` and `DELETE /api/v1/notifications/devices/:deviceId`, and the client's `notifications.registerDevice` / `unregisterDevice` (ADR-0012 §6)
9+
10+
Clause-②: yes (widening)
11+
Metadata-protocol: none
12+
13+
Nothing a client or an operator sends today is refused, and no metadata an author writes changes: the routes and the client methods are new, and the spec change is two catalog rows and description text.
14+
15+
- **`@objectstack/service-messaging`** registers `sys_user_device` (one row per user and app install: `device_id`, `user_id`, `vendor`, `token`, `platform`, `app_id`, `locale`, `name`, `enabled`, `invalidated_at`, `last_seen_at`) and implements `INotificationService`'s four device members on `MessagingService`. The table is system-owned: the generic data API neither reads nor writes it (`apiEnabled: false`), and it has no organization column, because a device belongs to its user. `registerDevice` refreshes a device id the user already holds, mints one when the request carries none, and moves a token another device holds to the registering device (the previous holder's row is removed, so a push meant for a phone's previous user never reaches its new one). A token has one holder: a unique index on `token` refuses a second one, and a registration that loses a race re-reads and moves the token. `invalidateDeviceToken` keeps the row with `enabled: false` and `invalidated_at`, and the device's next registration re-enables it. A `web` registration without `vendor` is refused `400 VALIDATION_FAILED`.
16+
- **`@objectstack/runtime`** serves `POST /api/v1/notifications/devices` (body: `RegisterDeviceRequestSchema`; answers `{ deviceId, success }`) and `DELETE /api/v1/notifications/devices/:deviceId` (the caller's own device; another user's or an unknown id answers `404 NOT_FOUND` and nothing is removed), behind the domain's anonymous floor (`401 UNAUTHENTICATED`). A notification service without the device members answers `501`. The standalone server mounts both, with the other verbs on the two paths mounted refusal-only like the inbox faces.
17+
- **`@objectstack/client`**: `client.notifications.registerDevice(request)` and `client.notifications.unregisterDevice(deviceId)`, removed earlier because no server served them, are back.
18+
- **`@objectstack/spec`**: `DEFAULT_NOTIFICATION_ROUTES` lists the two device routes again, and the `[PLANNED — not yet served]` markers come off the four device registration schemas and `UserDeviceSchema`.

‎content/docs/permissions/tenant-audit-census.mdx‎

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.
132132

133133
The same holds twice over for the context. An options argument spelled as a
134134
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
135-
forwarding shim cannot, and **51 of the 256 sites are spelled that way**. A
135+
forwarding shim cannot, and **55 of the 261 sites are spelled that way**. A
136136
context resolved from an inline literal or a local `const` can be tested for
137137
`isSystem`; one arriving from a helper call cannot.
138138

@@ -160,8 +160,8 @@ now **0**: nothing on this surface threads a context that provably lacks the fla
160160

161161
**"No tenant context" counted sites it had not read.** An options argument the
162162
walker could not parse was folded into the same bucket as one it had read and
163-
found empty. That published **59 sites "carrying no tenant context at all"**
164-
when 8 said so and 51 were simply unread — an over-claim in the *alarming*
163+
found empty. That published **63 sites "carrying no tenant context at all"**
164+
when 8 said so and 55 were simply unread — an over-claim in the *alarming*
165165
direction, on the very figure this page tells other cards to cite. `carries` is
166166
now three-valued, and an unreadable argument can never contribute to the
167167
provable count.
@@ -197,10 +197,10 @@ reproduce them. Where it disagrees, it disagrees on the page:
197197

198198
| carried figure | where it survives | this census |
199199
| :--- | :--- | ---: |
200-
| 175 write call sites | quoted in the merged changeset | **256** |
200+
| 175 write call sites | quoted in the merged changeset | **261** |
201201
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
202-
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **177 of 256** decidable, **79** undecidable |
203-
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 146 decidably elevated, 0 decidably not, 102 undecidable |
202+
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **182 of 261** decidable, **79** undecidable |
203+
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 146 decidably elevated, 0 decidably not, 107 undecidable |
204204
| 141 and 132, two independent re-derivations | the card that filed this work | — |
205205

206206
**The differences are not reconciled, and deliberately so.** The old census's
@@ -211,17 +211,17 @@ at any commit.
211211

212212
Two structural facts do plausibly widen this reading against any hand or regex
213213
one, and both are counted in the generated tables below: the 62 sites reached
214-
through an erased (`any`) receiver, and the 61 that name their object through a
214+
through an erased (`any`) receiver, and the 66 that name their object through a
215215
`const` rather than inline. An instrument that read either the way a person does
216216
would report a smaller number and would not say so.
217217

218218
The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
219219
figure has no surviving corroboration anywhere in the tree.** This census reads
220-
146 of 256 (57%) as decidably elevated, with 102 more whose elevation is a
220+
146 of 261 (56%) as decidably elevated, with 107 more whose elevation is a
221221
run-time fact — so the claim is neither confirmed nor refuted, and the honest
222222
answer is that a static reading cannot settle it.
223223

224-
⇒ **Cite `2 / 256`, and say what it is**: the sites whose options argument was
224+
⇒ **Cite `2 / 261`, and say what it is**: the sites whose options argument was
225225
READ and holds no tenant context, against a decidably tenancy-enabled object.
226226
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
227227
without tenant context" — **29 further sites** have an options argument this
@@ -233,29 +233,29 @@ cannot read, and they are neither in nor out.
233233

234234
| what | count |
235235
| :--- | ---: |
236-
| write call sites on the application surface | **256** |
237-
| …whose object name is statically decidable | 177 |
236+
| write call sites on the application surface | **261** |
237+
| …whose object name is statically decidable | 182 |
238238
| …whose object name is chosen at run time | 79 |
239239
| …against an object with tenancy ENABLED | 176 |
240-
| …against an object that declares tenancy off | 1 |
241-
| threading a tenant context | 197 |
240+
| …against an object that declares tenancy off | 6 |
241+
| threading a tenant context | 198 |
242242
| PROVABLY carrying none (options read, no context key) | **8** |
243243
| …of those, against a decidably tenancy-enabled object | **2** |
244-
| options argument UNREADABLE — may or may not carry one | 51 |
244+
| options argument UNREADABLE — may or may not carry one | 55 |
245245
| …of those, against a decidably tenancy-enabled object | 29 |
246246
| threading a decidably ELEVATED (`isSystem`) context | 146 |
247247
| threading a context that is decidably NOT elevated | 0 |
248-
| threading a context whose elevation is a run-time fact | 102 |
248+
| threading a context whose elevation is a run-time fact | 107 |
249249

250250
| how the instrument reached the site | count |
251251
| :--- | ---: |
252-
| receiver carried a readable engine type | 194 |
252+
| receiver carried a readable engine type | 199 |
253253
| receiver erased, placed by the object NAME | 52 |
254254
| receiver erased, placed by an `object: string` PARAMETER | 5 |
255255
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |
256256

257257
| object name spelled inline | 116 |
258-
| object name spelled through a `const` | 61 |
258+
| object name spelled through a `const` | 66 |
259259
| object name is an `object: string` parameter | 7 |
260260
| object name is some other run-time expression | 72 |
261261

@@ -307,13 +307,13 @@ holds still. They are required to be HERE and to say WHEN they were true;
307307
their values are not compared. The reasoning, and the measurement behind it,
308308
are in `scripts/check-tenant-audit-census.mjs`.
309309

310-
Measured on 2026-11-02 at `ac273cf07`.
310+
Measured on 2026-11-02 at `187d77e7f`.
311311

312312
| corpus scale (not enforced) | count |
313313
| :--- | ---: |
314-
| tracked non-test sources scanned | 852 |
315-
| engine-shaped types recognised | 99 |
316-
| declared objects in the registry | 125 |
317-
| same-named calls subtracted as non-engine | 227 |
314+
| tracked non-test sources scanned | 854 |
315+
| engine-shaped types recognised | 101 |
316+
| declared objects in the registry | 126 |
317+
| same-named calls subtracted as non-engine | 228 |
318318

319319
{/* END GENERATED: tenant-audit-census */}

‎content/docs/references/api/protocol.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2761,7 +2761,7 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant'
27612761

27622762
## RegisterDeviceRequest
27632763

2764-
[PLANNED — not yet served] Register or refresh a push device for the signed-in user. No route serves it yet; the device routes land together with their handlers
2764+
Register or refresh a push device for the signed-in user
27652765

27662766
### Properties
27672767

@@ -2780,7 +2780,7 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant'
27802780

27812781
## RegisterDeviceResponse
27822782

2783-
[PLANNED — not yet served] Answer to a device registration: the device id the client keeps
2783+
Answer to a device registration: the device id the client keeps
27842784

27852785
### Properties
27862786

@@ -3026,7 +3026,7 @@ Uninstall package response
30263026

30273027
## UnregisterDeviceRequest
30283028

3029-
[PLANNED — not yet served] Unregister one of the signed-in user's push devices
3029+
Unregister one of the signed-in user's push devices
30303030

30313031
### Properties
30323032

@@ -3039,7 +3039,7 @@ Uninstall package response
30393039

30403040
## UnregisterDeviceResponse
30413041

3042-
[PLANNED — not yet served] Answer to a device unregistration
3042+
Answer to a device unregistration
30433043

30443044
### Properties
30453045

‎content/docs/references/system/push-device.mdx‎

Lines changed: 2 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,11 @@
11
---
22
title: Push Device schema — System Protocol reference
33
navTitle: Push Device
4-
description: "Push Device Protocol — the stored device shape of the push device registry."
4+
description: "Push Device schemas of the ObjectStack System Protocol: UserDevice — each property with its type, default and a TypeScript example."
55
---
66

77
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
88

9-
Push Device Protocol — the stored device shape of the push device registry
10-
(ADR-0012 §6, narrowed to what core ships).
11-
12-
One row per (user, app install): what a device registration
13-
(`RegisterDeviceRequestSchema`, `api/protocol.zod.ts`) produces, what the
14-
push channel reads to address a phone, and what the registry's listing
15-
answers (`INotificationService.listDevices`).
16-
17-
**Declared ahead of its producer.** No table stores it and no route serves
18-
it yet; the schema carries a `[PLANNED — not yet served]` marker in its
19-
`.describe()` until the device registry lands (in the push channel plugin's
20-
`sys_user_device`, ADR-0012 §11).
21-
22-
Three rules the registry owes, stated here because they decide the shape:
23-
24-
- **(a) A device belongs to a user, not to an organization.** The row has no
25-
organization member. A phone signed in to one person receives that
26-
person's notifications whichever organization they come from; the
27-
organization travels on the notification, never on the device. The
28-
identity of a row is (`userId`, `deviceId`).
29-
- **(b) One live row per token.** A vendor token is delivered to one row
30-
only. A token this user already holds under another `deviceId` (the app's
31-
storage was cleared, so the registration carries no `deviceId`) refreshes
32-
that row and answers its `deviceId`; it never makes a second live row.
33-
When a second user registers a token another user's device holds, the
34-
token moves to the second user and the first user's row for it is
35-
removed, so a push meant for the phone's previous user never reaches its
36-
new one.
37-
- **(c) A dead token keeps its row.** When the vendor reports a token dead
38-
(APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`), the row is
39-
kept with `enabled: false` and `invalidatedAt` set, so "why did this phone
40-
stop getting pushes" stays answerable. Nothing is delivered to a disabled
41-
row, and a later registration of the same device re-enables it and clears
42-
`invalidatedAt`.
43-
44-
Narrowed from ADR-0012 §6: `vendor` is the core pair (`apns` | `fcm`; the
45-
domestic Android vendors plug in as alternate channel implementations) and
46-
`platform` is the registration's own three (`ios` | `android` | `web`).
47-
489
<Callout type="info">
4910
**Source:** `packages/spec/src/system/push-device.zod.ts`
5011
</Callout>
@@ -63,7 +24,7 @@ const result = UserDeviceSchema.parse(data);
6324

6425
## UserDevice
6526

66-
[PLANNED — not yet served] A push device of one user: one row per app install. No table stores it yet; the device registry lands with the push channel
27+
A push device of one user: one row per app install
6728

6829
### Properties
6930

‎docs/audits/2026-08-tenant-audit-write-call-sites.counts.md‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.
3333

3434
| Measure | Value |
3535
|---|---:|
36-
| Write call sites | 256 |
37-
| Object name statically decidable | 177 |
36+
| Write call sites | 261 |
37+
| Object name statically decidable | 182 |
3838
| Object name chosen at run time | 79 |
3939
| Against a tenancy-enabled object | 176 |
40-
| Against an object declaring tenancy off | 1 |
41-
| Threading a tenant context | 197 |
40+
| Against an object declaring tenancy off | 6 |
41+
| Threading a tenant context | 198 |
4242
| Provably carrying none | 8 |
4343
| …and decidably tenancy-enabled | 2 |
44-
| Options argument unreadable | 51 |
44+
| Options argument unreadable | 55 |
4545
| …and decidably tenancy-enabled | 29 |
4646
| Threading a decidably elevated context | 146 |
4747
| Threading a decidably non-elevated context | 0 |
48-
| Threading a context of undecidable elevation | 102 |
48+
| Threading a context of undecidable elevation | 107 |
4949

5050
## Subtractions the census could NOT defend — enforced
5151

@@ -90,14 +90,14 @@ holds still. They are required to be HERE and to say WHEN they were true;
9090
their values are not compared. The reasoning, and the measurement behind it,
9191
are in `scripts/check-tenant-audit-census.mjs`.
9292

93-
Measured on 2026-11-02 at `ac273cf07`.
93+
Measured on 2026-11-02 at `187d77e7f`.
9494

9595
| corpus scale (not enforced) | count |
9696
| :--- | ---: |
97-
| tracked non-test sources scanned | 852 |
98-
| engine-shaped types recognised | 99 |
99-
| declared objects in the registry | 125 |
100-
| same-named calls subtracted as non-engine | 227 |
97+
| tracked non-test sources scanned | 854 |
98+
| engine-shaped types recognised | 101 |
99+
| declared objects in the registry | 126 |
100+
| same-named calls subtracted as non-engine | 228 |
101101

102102
## Every site
103103

@@ -233,6 +233,9 @@ Measured on 2026-11-02 at `ac273cf07`.
233233
| `packages/services/service-mailbox/src/ingest/mail-ingest-service.ts` | `insert` | `MAIL_MESSAGE_OBJECT` | undecidable | elevated | 1 |
234234
| `packages/services/service-mailbox/src/ingest/mail-ingest-service.ts` | `update` | `MAIL_MESSAGE_OBJECT` | undecidable | elevated | 1 |
235235
| `packages/services/service-mailbox/src/sync/mailbox-sync-service.ts` | `update` | `MAILBOX_OBJECT` | undecidable | elevated | 1 |
236+
| `packages/services/service-messaging/src/device-registry.ts` | `delete` | `sys_user_device` | disabled | options unreadable | 2 |
237+
| `packages/services/service-messaging/src/device-registry.ts` | `insert` | `sys_user_device` | disabled | context, elevation undecidable | 1 |
238+
| `packages/services/service-messaging/src/device-registry.ts` | `update` | `sys_user_device` | disabled | options unreadable | 2 |
236239
| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `objectName` | undecidable | context, elevation undecidable | 1 |
237240
| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `receiptObject` | undecidable | context, elevation undecidable | 1 |
238241
| `packages/services/service-messaging/src/messaging-service.ts` | `insert` | `RECEIPT_OBJECT` | undecidable | context, elevation undecidable | 1 |

‎packages/cli/src/utils/organization-ownership-inventory.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,14 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
352352
),
353353
{ object: 'sys_metadata_activation', census: P1, fate: 'column-drop', citation: `${D7}, D6, D14 (C0): the tenant-less activation ledger` },
354354
{ object: 'sys_presence', census: P1, fate: 'column-drop', citation: `${CENSUS_MAIN}: systemFields.tenant: false; ${D7}` },
355+
{
356+
object: 'sys_user_device',
357+
census: PMAIN,
358+
fate: 'column-drop',
359+
citation:
360+
`${CENSUS_MAIN}: tenancy.enabled: false and no organization_id field; ${D7}: a push device belongs to its user, ` +
361+
'never to an organization (spec UserDeviceSchema, rule a)',
362+
},
355363
{
356364
object: 'sys_platform_setting',
357365
census: PMAIN,

‎packages/client/src/client.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -937,6 +937,28 @@ describe('Notifications namespace', () => {
937937
expect(url).toContain('/api/v1/notifications/read/all');
938938
expect(opts.method).toBe('POST');
939939
});
940+
941+
it('registers a push device with POST /notifications/devices (ADR-0012 §6)', async () => {
942+
const { client, fetchMock } = createMockClient({
943+
success: true,
944+
data: { deviceId: 'install-1', success: true }
945+
});
946+
const result = await client.notifications.registerDevice({ token: 'tok', platform: 'ios', deviceId: 'install-1', locale: 'zh-CN' });
947+
expect(result).toEqual({ deviceId: 'install-1', success: true });
948+
const [url, opts] = fetchMock.mock.calls[0];
949+
expect(url).toBe('http://localhost:3000/api/v1/notifications/devices');
950+
expect(opts.method).toBe('POST');
951+
expect(JSON.parse(opts.body)).toEqual({ token: 'tok', platform: 'ios', deviceId: 'install-1', locale: 'zh-CN' });
952+
});
953+
954+
it('unregisters a push device with DELETE /notifications/devices/:deviceId, the id encoded', async () => {
955+
const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
956+
const result = await client.notifications.unregisterDevice('install/1');
957+
expect(result).toEqual({ success: true });
958+
const [url, opts] = fetchMock.mock.calls[0];
959+
expect(url).toBe('http://localhost:3000/api/v1/notifications/devices/install%2F1');
960+
expect(opts.method).toBe('DELETE');
961+
});
940962
});
941963

942964
describe('AI namespace (#3718)', () => {

0 commit comments

Comments
 (0)