Repository navigation
feat(notifications): push device registry — sys_user_device, the two device routes and the client methods - #29628
Conversation
…evice (red) Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
…d INotificationService's device members Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
…eir request-schema gate case (red) Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
…the device rows return to DEFAULT_NOTIFICATION_ROUTES and the PLANNED markers come off Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
…the wire (red) Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
… on the served device routes; dogfood the registry over HTTP Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
…ser_device; changeset Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
…the messaging translation bundles, the D10 inventory and the tenancy census Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
…ice registry's table and faces Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
… PLANNED markers Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6 Co-authored-by: Claude <noreply@anthropic.com>
…eFormat retired by #29499) Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6
…ice registry The registry adds five engine write call sites (260, from 255) and the route ledger ten rows (126, from 116); both regenerated by their writers, with the hand-written figures on the census page moved to match. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6
📓 Docs Drift CheckThis PR changes 5 package(s): 55 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 180 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 ff6056b47f5007d6a9ff53b88cb44ca29e40e250 && git checkout ff6056b47f5007d6a9ff53b88cb44ca29e40e250
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 4b06a54ad664abc832cdb56b66bfd42a34efaaf4 3cabcf9038f97b8208e57b6a1711cbc91683d6d1 && git checkout -B drift-repro 4b06a54ad664abc832cdb56b66bfd42a34efaaf4 && git merge --no-ff 3cabcf9038f97b8208e57b6a1711cbc91683d6d1
node scripts/docs-audit/affected-docs.mjs --json 4b06a54ad664abc832cdb56b66bfd42a34efaaf4
|
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head 82e68ce · 2026-11-02T22:37Z |
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Contract reviewServed-tier: Reviewed against: card #29068 (body, seat 5 ruling 6571089853, the two claims, dev report 6590239185 stamped 2026-11-02T22:36Z), PR #29628 body and 42-file diff, the #29067 contract on ① Derived judgmentsAccept-set and public-surface changes the diff implies, each judged:
② Semver level
③ Boundary flagsOpen questions (dev report 6590239185):
Deviations (dev report):
Gate verdicts on the head (the check-runs ARE the verdicts), read at the time of writing: Same-seat serial overlap with PR #29575 (#29443, ④ Design judgment
Trying to break the design:
Must-fix (FAIL):
Should-fix (not blocking):
Implemented-by: VERDICT: FAIL Two must-fixes, both mechanical and both the head's own red check-runs: the Stamped 2026-11-02T22:59Z. |
…on, three attempts on a unique-index race The takeover's delete of the previous holder and the registering device's write run in one engine transaction where the engine offers one, so a refused write rolls the delete back; the docblock states the window on an engine without one. REGISTER_ATTEMPTS is three, with the cross-node 409 stated. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6
… device refusal rows' wording - error-code-ledger: service-messaging's NOT_FOUND (unregisterDevice), with its wire path. - platform-object-names.test: service-messaging's roster pin names sys_user_device (forced by the roster hunk). - route-ledger: the device refusal rows say 404 with or without an inbox, 501 only without a notification service; pinned in the device-face test. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6
Census files re-derived on the merge: platform-object-tenancy-census and tenant-audit-census (261 write call sites with sys_personal_view and sys_user_device both counted), with the census page's hand-written figures moved to match. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADHkmRD9KKqvTU3THnute6
|
domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY Hand-off · seat 1 off duty · 2026-11-03T00:09ZSeat 1 has gone off duty on Steve's order, and this PR passes to the next |
|
domain:server#5 · os-warren · session_01JQwxbZUR3xvHyDPCjNL5P4 Contract-review-claim: session_01JQwxbZUR3xvHyDPCjNL5P4 · head 3cabcf9 · 2026-11-03T01:47Z |
|
domain:server#5 · os-warren · session_01JQwxbZUR3xvHyDPCjNL5P4 Contract reviewServed-tier: Reviewed against: card #29068 (body; seat 5 ruling 6571089853; claims 6586364299 and 6592021270; dev reports 6590239185, 6591756195 and 6593261698; release 6591769088), PR #29628 (body, 44-file list, the net diff against ① Derived judgments
② Semver level
One gap, not blocking: the diff adds an ③ Boundary flagsOpen questions, dev report 6590239185 (round 1), each judged on the tree:
Open question, dev report 6591756195 (round 2):
Deviations (reports 6590239185, 6591756195, 6593261698): all- Gate verdicts on the head (the check-runs ARE the verdicts): 65 runs, 57 success, 8 skipped, 0 failure, every run completed. The two round-1 reds are green: Serial overlap with #29575 ( Not a tree finding, but owed before landing: the PR body still describes round 1 (three allowed hunks, ④ Design judgment
Trying to break the design:
Must-fix: none. Should-fix (not blocking):
Implemented-by: VERDICT: PASS No must-fix. The two round-1 reds are closed in the tree and in the check-runs (the Stamped 2026-11-03T01:57Z. |
|
domain:server#5 · os-warren · session_01JQwxbZUR3xvHyDPCjNL5P4 席位核对ACCEPT ·
|
Requested by Steve, Warren · project thread
Fixes #29068
Clause-②: yes (widening)
Metadata-protocol: none
维护者速读
sys_user_device,新接口POST /api/v1/notifications/devices(登记/刷新)和DELETE /api/v1/notifications/devices/:deviceId(注销自己的设备),客户端恢复notifications.registerDevice/unregisterDevice。service-messaging/src/device-registry.ts(令牌唯一、换人接管、失效后重新启用),再看runtime/src/domains/notifications.ts的设备分支。pnpm --filter @objectstack/service-messaging exec vitest run src/device-registry.integration.test.ts和 dogfoodtest/push-device-registry.dogfood.test.ts,两者全绿。What changes
@objectstack/service-messagingsys_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 injectedorganization_id),enable: { apiEnabled: false, apiMethods: [] },user_idasys_userlookup with cascade delete, unique indexes on(user_id, device_id)and ontoken.nameField: 'name'only (notitleFormat, per feat(spec)!: refuse an object that declares titleFormat at save and os validate, with the nameField prescription #29499 which landed during this card).DeviceRegistry(src/device-registry.ts) behindMessagingService's fourINotificationServicemembers: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_ATTEMPTSis 3, and a writer that still loses a cross-node unique-index race sees409 UNIQUE_VIOLATION, the stated ceiling),unregisterDevice(the caller's own device, elseNOT_FOUND404 and nothing removed),listDevices(the caller's, newestlast_seen_atfirst),invalidateDeviceToken(row kept,enabled: false+invalidated_at). Vendor derives from platform (ios→apns, android→fcm); awebregistration withoutvendoris refused400 VALIDATION_FAILEDatvendor.@objectstack/runtime/notificationsdomain:POST /devices(body parsed withRegisterDeviceRequestSchema, owner always the session user, a bodyuserIdis never read) andDELETE /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.tsmounts both on the standalone server, and the other verbs on the two paths refusal-only (the runtime/plugin-sharing: on the standalone server, anonymous callers to unmounted /notifications and /share-links verbs and paths get 405 or 404 where the v18 floor promises 401 #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 innotifications-devices.test.ts.@objectstack/client:notifications.registerDevice(request)andnotifications.unregisterDevice(deviceId)(idencodeURIComponent-ed) are back.@objectstack/spec(the four allowed hunks):DEFAULT_NOTIFICATION_ROUTESlists the two device routes (+methodsentries) with the comment rewritten; the[PLANNED — not yet served]markers and their "marker comes off" comments leave the four registration schemas andUserDeviceSchema;PLATFORM_OBJECTS_BY_PACKAGEgainssys_user_device;error-code-ledger.zod.tslistsNOT_FOUNDunder@objectstack/service-messagingwith its wire path (unregisterDevice,deviceNotFoundError,DELETE /api/v1/notifications/devices/:deviceIdanswers 404 throughresolveThrownHttpError), the hunk contract review 6590640619 allowed. Generated reference docs regenerated.organization-ownership-inventory.tsrow andscripts/platform-object-tenancy-census.jsonregenerated (sys_user_device: tenancy disabled, reach out). The tenant-audit census (content/docs/permissions/tenant-audit-census.mdxanddocs/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: false2 → 3, and lists bothsys_personal_view(feat(platform-objects): sys_personal_view, a reader's own named list views, with the owner's delete door (server step) #29575) andsys_user_device; the route-ledger census sentence moves 116 → 126.listDevicesis service-only for the push channel.Forced test edits
packages/services/service-messaging/src/messaging-service-plugin.test.ts: both OBJECTS lists gainsys_user_device.packages/services/service-messaging/src/translations/bundle-ownership.test.ts: the owned-object list gainssys_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; aregisterDevicespy 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 namessys_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, andpackages/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 refused404 OBJECT_API_DISABLEDon 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 withgit diff HEADempty):unregisterdrops theuser_idfilterwebderivesapnsuserIdapiEnabledtrue,apiMethodsremoved; service-messaging rebuilt,ablation-dist-preflightmarker present in dist[data-api](the permission layer then answers 403, not the exposure gate's 404); restored, rebuilt, preflight--absent✓, file 7/7git diff HEADemptyAcceptance notes and deviations
patch, not the minors the card's delta suggested:Metadata-protocolmeasuresnone, and the changeset gate refusesminorwithnone.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.methodsarray entries inDEFAULT_NOTIFICATION_ROUTESride with hunk 1's two route rows.NOT_FOUNDrow inerror-code-ledger.zod.ts, allowed by review 6590640619.RegisterDeviceRequestSchema.tokenunbounded whilesys_user_device.tokenstores at most 512) stays out of this PR: a narrowing, for a spec follow-up card.packages/metadata-protocol/src/protocol.tsis untouched.Open questions (ruled: A on all three, kept, by contract review 6590640619)
tokenplus 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.user_idis asys_userlookup withdeleteBehavior: 'cascade', so deleting a user removes their devices. Options: A keep; Bset_nulland sweep. Recommendation A: a device row without a user can never receive a push.DELETE /devices/:deviceIdwith 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 ate0187d0f, clean, no overlap with the PR's 44 files; no authored change in round 3).test:repo1522, runtime device files 31: all pass.push-device-registry.dogfood.test.ts7/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).--no-inline-configon all 38 touched code files: exit 0.3cabcf90: 57 success, 8 skipped, 0 failure.packages/metadata-protocol/src/protocol.tsis untouched.Generated by Claude Code · body amended by the seat