Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
64d22bf
test(service-messaging): pin the push device registry over sys_user_d…
claude Nov 2, 2026
0f4867b
feat(service-messaging): the push device registry, sys_user_device an…
claude Nov 2, 2026
e2fb96f
test(runtime): pin the device registry faces on /notifications and th…
claude Nov 2, 2026
3c0d5be
feat(runtime,spec): serve POST/DELETE /api/v1/notifications/devices; …
claude Nov 2, 2026
edb33e1
test(client): pin notifications.registerDevice / unregisterDevice on …
claude Nov 2, 2026
2b00aed
feat(client): restore notifications.registerDevice / unregisterDevice…
claude Nov 2, 2026
9ca57be
test(dogfood): pin the data API's OBJECT_API_DISABLED answer on sys_u…
claude Nov 2, 2026
3d4e3b5
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 2, 2026
4ca0ae3
chore(rosters): count sys_user_device in the platform-object roster, …
claude Nov 2, 2026
9423498
test: the provisioning pins and the dispatcher mount pin name the dev…
claude Nov 2, 2026
d5023c2
docs(references): regenerate the device schema references without the…
claude Nov 2, 2026
46adb1f
Merge origin/main into claude/project-thread-eqmkj9
claude Nov 2, 2026
8f66d6c
fix(service-messaging): sys_user_device declares nameField only (titl…
claude Nov 2, 2026
74d7cfb
chore(census): tenant-audit census and route-ledger count for the dev…
claude Nov 2, 2026
82e68ce
Merge origin/main into claude/project-thread-eqmkj9
claude Nov 2, 2026
bf0b6d9
fix(service-messaging): a device registration is one engine transacti…
claude Nov 2, 2026
187d77e
fix(notifications): NOT_FOUND provenance row, the roster pin, and the…
claude Nov 2, 2026
fbfa604
Merge origin/main into claude/project-thread-eqmkj9 (after #29575)
claude Nov 2, 2026
3cabcf9
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/29068-push-device-registry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/service-messaging': patch
'@objectstack/runtime': patch
'@objectstack/client': patch
'@objectstack/spec': patch
---

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)

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

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.

- **`@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`.
- **`@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.
- **`@objectstack/client`**: `client.notifications.registerDevice(request)` and `client.notifications.unregisterDevice(deviceId)`, removed earlier because no server served them, are back.
- **`@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`.
44 changes: 22 additions & 22 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ are reported as `undecidable` rather than assumed either way.

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

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

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

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **256** |
| 175 write call sites | quoted in the merged changeset | **261** |
| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **177 of 256** decidable, **79** undecidable |
| 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 |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **182 of 261** decidable, **79** undecidable |
| 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 |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

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

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

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

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

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

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 194 |
| receiver carried a readable engine type | 199 |
| receiver erased, placed by the object NAME | 52 |
| receiver erased, placed by an `object: string` PARAMETER | 5 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 |

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

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

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

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

{/* END GENERATED: tenant-audit-census */}
8 changes: 4 additions & 4 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2761,7 +2761,7 @@ The authoring surface of a metadata write. Closed: `copilot` is an AI assistant'

## RegisterDeviceRequest

[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
Register or refresh a push device for the signed-in user

### Properties

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

## RegisterDeviceResponse

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

### Properties

Expand Down Expand Up @@ -3026,7 +3026,7 @@ Uninstall package response

## UnregisterDeviceRequest

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

### Properties

Expand All @@ -3039,7 +3039,7 @@ Uninstall package response

## UnregisterDeviceResponse

[PLANNED — not yet served] Answer to a device unregistration
Answer to a device unregistration

### Properties

Expand Down
43 changes: 2 additions & 41 deletions content/docs/references/system/push-device.mdx
Original file line number Diff line number Diff line change
@@ -1,50 +1,11 @@
---
title: Push Device schema — System Protocol reference
navTitle: Push Device
description: "Push Device Protocol — the stored device shape of the push device registry."
description: "Push Device schemas of the ObjectStack System Protocol: UserDevice — each property with its type, default and a TypeScript example."
---

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

Push Device Protocol — the stored device shape of the push device registry
(ADR-0012 §6, narrowed to what core ships).

One row per (user, app install): what a device registration
(`RegisterDeviceRequestSchema`, `api/protocol.zod.ts`) produces, what the
push channel reads to address a phone, and what the registry's listing
answers (`INotificationService.listDevices`).

**Declared ahead of its producer.** No table stores it and no route serves
it yet; the schema carries a `[PLANNED — not yet served]` marker in its
`.describe()` until the device registry lands (in the push channel plugin's
`sys_user_device`, ADR-0012 §11).

Three rules the registry owes, stated here because they decide the shape:

- **(a) A device belongs to a user, not to an organization.** The row has no
organization member. A phone signed in to one person receives that
person's notifications whichever organization they come from; the
organization travels on the notification, never on the device. The
identity of a row is (`userId`, `deviceId`).
- **(b) One live row per token.** A vendor token is delivered to one row
only. A token this user already holds under another `deviceId` (the app's
storage was cleared, so the registration carries no `deviceId`) refreshes
that row and answers its `deviceId`; it never makes a second live row.
When a second user registers a token another user's device holds, the
token moves to the second user and the first user's row for it is
removed, so a push meant for the phone's previous user never reaches its
new one.
- **(c) A dead token keeps its row.** When the vendor reports a token dead
(APNs `BadDeviceToken` / `Unregistered`, FCM `UNREGISTERED`), the row is
kept with `enabled: false` and `invalidatedAt` set, so "why did this phone
stop getting pushes" stays answerable. Nothing is delivered to a disabled
row, and a later registration of the same device re-enables it and clears
`invalidatedAt`.

Narrowed from ADR-0012 §6: `vendor` is the core pair (`apns` | `fcm`; the
domestic Android vendors plug in as alternate channel implementations) and
`platform` is the registration's own three (`ios` | `android` | `web`).

<Callout type="info">
**Source:** `packages/spec/src/system/push-device.zod.ts`
</Callout>
Expand All @@ -63,7 +24,7 @@ const result = UserDeviceSchema.parse(data);

## UserDevice

[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
A push device of one user: one row per app install

### Properties

Expand Down
25 changes: 14 additions & 11 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 256 |
| Object name statically decidable | 177 |
| Write call sites | 261 |
| Object name statically decidable | 182 |
| Object name chosen at run time | 79 |
| Against a tenancy-enabled object | 176 |
| Against an object declaring tenancy off | 1 |
| Threading a tenant context | 197 |
| Against an object declaring tenancy off | 6 |
| Threading a tenant context | 198 |
| Provably carrying none | 8 |
| …and decidably tenancy-enabled | 2 |
| Options argument unreadable | 51 |
| Options argument unreadable | 55 |
| …and decidably tenancy-enabled | 29 |
| Threading a decidably elevated context | 146 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 102 |
| Threading a context of undecidable elevation | 107 |

## Subtractions the census could NOT defend — enforced

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

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

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

## Every site

Expand Down Expand Up @@ -233,6 +233,9 @@ Measured on 2026-11-02 at `ac273cf07`.
| `packages/services/service-mailbox/src/ingest/mail-ingest-service.ts` | `insert` | `MAIL_MESSAGE_OBJECT` | undecidable | elevated | 1 |
| `packages/services/service-mailbox/src/ingest/mail-ingest-service.ts` | `update` | `MAIL_MESSAGE_OBJECT` | undecidable | elevated | 1 |
| `packages/services/service-mailbox/src/sync/mailbox-sync-service.ts` | `update` | `MAILBOX_OBJECT` | undecidable | elevated | 1 |
| `packages/services/service-messaging/src/device-registry.ts` | `delete` | `sys_user_device` | disabled | options unreadable | 2 |
| `packages/services/service-messaging/src/device-registry.ts` | `insert` | `sys_user_device` | disabled | context, elevation undecidable | 1 |
| `packages/services/service-messaging/src/device-registry.ts` | `update` | `sys_user_device` | disabled | options unreadable | 2 |
| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `objectName` | undecidable | context, elevation undecidable | 1 |
| `packages/services/service-messaging/src/inbox-channel.ts` | `insert` | `receiptObject` | undecidable | context, elevation undecidable | 1 |
| `packages/services/service-messaging/src/messaging-service.ts` | `insert` | `RECEIPT_OBJECT` | undecidable | context, elevation undecidable | 1 |
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/utils/organization-ownership-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,14 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [
),
{ object: 'sys_metadata_activation', census: P1, fate: 'column-drop', citation: `${D7}, D6, D14 (C0): the tenant-less activation ledger` },
{ object: 'sys_presence', census: P1, fate: 'column-drop', citation: `${CENSUS_MAIN}: systemFields.tenant: false; ${D7}` },
{
object: 'sys_user_device',
census: PMAIN,
fate: 'column-drop',
citation:
`${CENSUS_MAIN}: tenancy.enabled: false and no organization_id field; ${D7}: a push device belongs to its user, ` +
'never to an organization (spec UserDeviceSchema, rule a)',
},
{
object: 'sys_platform_setting',
census: PMAIN,
Expand Down
22 changes: 22 additions & 0 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,28 @@ describe('Notifications namespace', () => {
expect(url).toContain('/api/v1/notifications/read/all');
expect(opts.method).toBe('POST');
});

it('registers a push device with POST /notifications/devices (ADR-0012 §6)', async () => {
const { client, fetchMock } = createMockClient({
success: true,
data: { deviceId: 'install-1', success: true }
});
const result = await client.notifications.registerDevice({ token: 'tok', platform: 'ios', deviceId: 'install-1', locale: 'zh-CN' });
expect(result).toEqual({ deviceId: 'install-1', success: true });
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe('http://localhost:3000/api/v1/notifications/devices');
expect(opts.method).toBe('POST');
expect(JSON.parse(opts.body)).toEqual({ token: 'tok', platform: 'ios', deviceId: 'install-1', locale: 'zh-CN' });
});

it('unregisters a push device with DELETE /notifications/devices/:deviceId, the id encoded', async () => {
const { client, fetchMock } = createMockClient({ success: true, data: { success: true } });
const result = await client.notifications.unregisterDevice('install/1');
expect(result).toEqual({ success: true });
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe('http://localhost:3000/api/v1/notifications/devices/install%2F1');
expect(opts.method).toBe('DELETE');
});
});

describe('AI namespace (#3718)', () => {
Expand Down
Loading
Loading