Skip to content

feat(notifications): push device registry — sys_user_device, the two device routes and the client methods - #29628

Merged
objectstack-fleet[bot] merged 19 commits into
mainfrom
claude/project-thread-eqmkj9
Nov 3, 2026
Merged

objectstack-fleet[bot] merged 19 commits into
mainfrom
claude/project-thread-eqmkj9

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Steve, Warren · project thread

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. 为什么改:spec(notification): the push device registry's protocol face and the push payload route (spec slice of #29041) #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 feat(spec)!: refuse an object that declares titleFormat at save and os validate, with the nameField prescription #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 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 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 (feat(platform-objects): sys_personal_view, a reader's own named list views, with the owner's delete door (server step) #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 · body amended by the seat

claude added 15 commits November 2, 2026 19:30
…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>
… 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
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation protocol:system tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 5 package(s): @objectstack/cli, @objectstack/client, @objectstack/runtime, @objectstack/service-messaging, @objectstack/spec, touching 69 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/services/service-messaging/src/index.ts, packages/services/service-messaging/src/objects/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

55 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 4b06a54ad664abc832cdb56b66bfd42a34efaaf4.

⛔ 13 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/services/service-messaging/src/index.ts, packages/services/service-messaging/src/objects/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: sys_user (literal, 48 pages)
  • 19 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 218 client-bound route-ledger rows — the other 157 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 157: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 180 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4b06a54ad664abc832cdb56b66bfd42a34efaaf4 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from ff6056b47f5007d6a9ff53b88cb44ca29e40e250 — the merge of head 3cabcf9038f97b8208e57b6a1711cbc91683d6d1 into base 4b06a54ad664abc832cdb56b66bfd42a34efaaf4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 4b06a54ad664abc832cdb56b66bfd42a34efaaf4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head 82e68ce · 2026-11-02T22:37Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 82e68cec23420fd9aa3a66e17f0f87a1e3582467
Local-runs: none
Scope: packages/spec/src/api/plugin-rest-api.zod.ts, packages/spec/src/api/protocol.zod.ts, packages/spec/src/contracts/notification-service.ts, packages/spec/src/system/constants/platform-object-names.ts, packages/spec/src/system/push-device.zod.ts + packages/services/service-messaging/src/device-registry.ts, packages/services/service-messaging/src/objects/user-device.object.ts, packages/services/service-messaging/src/messaging-service.ts, packages/runtime/src/domains/notifications.ts, packages/runtime/src/dispatcher-plugin.ts, packages/runtime/src/route-ledger.ts, packages/client/src/index.ts, packages/spec/src/api/error-code-ledger.zod.ts (read for the red gate), packages/spec/src/api/push-delivery.zod.ts and packages/spec/src/system/push-config.zod.ts (read for the markers that stay), packages/runtime/src/api-exposure.ts and packages/core/src/security/effective-object-permissions.ts (read for the exposure claim), packages/types/src/unique-violation.ts (read for the retry claim)

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 main (push-device.zod.ts rules a/b/c, protocol.zod.ts RegisterDevice*/UnregisterDevice*, the INotificationService device members, .changeset/29067-push-device-registry-spec.md), and the check-runs on the head. The rest of the diff (service-messaging, runtime, client, tests, census, generated bundles and docs) is the lane seat's ACCEPT at TIER_DEFAULT on the declaration face, Clause-②: yes (widening), and is named below only where a hits hunk makes a claim about it.

① Derived judgments

Accept-set and public-surface changes the diff implies, each judged:

  1. Two served routes, POST /api/v1/notifications/devices and DELETE /api/v1/notifications/devices/:deviceId — right. Both rows sit in DEFAULT_NOTIFICATION_ROUTES with handler names that are also in the registration's methods array, public: false, authRequired: true inherited, requestSchema: 'RegisterDeviceRequestSchema' bound on the POST row, no request schema on the DELETE row (the id is a path segment; UnregisterDeviceRequestSchema is body-shaped and would have demanded a violating-body case for a route with no body), and responseSchema on both. The methods entries are part of the same table the ruling names; they ride with hunk 1 and are inside the allowance.
  2. The PLANNED markers come off exactly the schemas now served — right. Five descriptions lose the marker: the four RegisterDevice* / UnregisterDevice* schemas and UserDeviceSchema; the three "marker comes off when notifications: build the device registry (sys_user_device, /notifications/devices routes, client methods) on #29067's contract (server slice 1 of #29041) #29068…" comments go with them. push-delivery.zod.ts ([PLANNED — not yet served]) and push-config.zod.ts ([PLANNED — not yet read]) keep theirs: nothing in this PR serves a push or reads the transport config, and those belong to Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041. The Spec property liveness check-run is green on the head. The spec test pins (register-device-request.test.ts, push-device.test.ts, the plugin-rest-api.test.ts 3→5 route count) are the forced edits of those hunks and say the true thing.
  3. PLATFORM_OBJECTS_BY_PACKAGE gains sys_user_device under service-messaging — right: that is the package that registers and provisions it (messaging-service-plugin.ts adds UserDevice to both the objects list and provisionSystemTables), and bundle-ownership.test.ts, the i18n extract config and the four generated bundles follow it.
  4. contracts/notification-service.ts comment-only edit — outside the three named hunks, and accepted: no member, type or shape moves; the paragraph it replaces ("no provider implements them and no route calls them yet") would be false on this head. A comment that contradicts the tree is worse than a fourth prose hunk. Named as a deviation, not a breach.
  5. Served shapes match the spec — right. The POST body is RegisterDeviceRequestSchema.safeParse(body ?? {}) on the catalog row's own schema (unknown keys such as a body userId are stripped, pinned by notifications-devices.test.ts); the answer is { deviceId, success } with the server-minted randomUUID() when the request carried none; DELETE answers { success: true }. Vendor derives ios→apns, android→fcm; a web registration without vendor is refused VALIDATION_FAILED at field vendor, never stored with a guess — exactly the contract's third bullet.
  6. Rule (a), a device belongs to a user — right. tenancy: { enabled: false }, no organization_id, identity index (user_id, device_id); the census JSON and the CLI ownership inventory both record it as out of tenancy reach with the reason. For a multi-org user this means one row per app install regardless of which organization the session is in, which is what rule (a) asks. An organization admin has no door to another member's devices: no GET /devices route exists (the card says none), listDevices(userId) is service-only, and the data API is closed (point 8). Right by the card; Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041's push channel will read listDevices as the system.
  7. Rule (b), one live row per token, the first user's row removed — right. registerOnce reads the token's holder, deletes that row when it is not the row being refreshed (another user's device, or this user's stale device id), then updates or inserts; (token) carries a unique: 'global' index so a second holder can never be stored. Pinned by the integration test (holders of tok-shared is one row, Alice's alice-phone gone, her tablet kept) and the dogfood [takeover] case on the real stack.
  8. The data API is closed, and no other door opens sys_user_device — right. enable.apiEnabled: false, apiMethods: [] is the credential-table posture (sys-connector-credential.object.ts); canServeApiOperation judges apiEnabled === false FIRST for every operation, so the REST data door answers 404 OBJECT_API_DISABLED for every verb to every principal, an organization or platform admin included (the admin-bypass concern the Personal views, first version: a reader saves a list as 「我的视图」, a user-owned row only they see, then renames, deletes or sets it as their default (#28971 ruling C) #29443 memory records applies to an apiEnabled object; it does not reach this one). Dogfood [data-api] pins list/read/create/patch/delete for a member. searchable: false keeps it out of /search; trackHistory: false keeps it out of the history table; export rides the same enforceApiAccess gate as the CRUD routes; the engine emits no data.record.* events on main (the only producer is service-knowledge), so realtime relays nothing; the MCP bridge reads the same exposure gate. What remains reachable is the object's metadata document under /meta (schema, not rows), the same as every credential table.
  9. Rule (c) — right, see ④.
  10. Anonymous floor first — right. handleNotificationRequest reads context.executionContext.userId and answers 401 UNAUTHENTICATED before the body is read and before the slot is resolved; the devices branch sits after that floor and after isServiceServeable, and probes its own member (501 for an inbox-only provider; served for a registry-only provider), ahead of the listInbox probe. The dispatcher plugin mounts both faces plus the other verbs refusal-only on the standalone server, so every verb on the two paths reaches the floor (pinned 15→25 in dispatcher-plugin.routes.test.ts).
  11. Client methods and the ledger — right. notifications.registerDevice(request) → POST {route}/devices, unregisterDevice(deviceId) → DELETE {route}/devices/${encodeURIComponent(id)}; the two sdk rows name notifications.registerDevice / notifications.unregisterDevice; eight server-only refusal rows; the census comment 116→126 is tool-written.
  12. New exports — UserDevice and USER_DEVICE_OBJECT from @objectstack/service-messaging, the four device members on MessagingService — a widening, named in the changeset.
  13. Metadata-protocol: none — right. Nothing an author writes gains or loses a key, value or type; route rows and .describe() text are catalog and prose, not authored metadata. The Spec property liveness run is green and packages/metadata-protocol/src/protocol.ts is untouched (seat 5 freeze, [maintainer] refactor(metadata-protocol): split src/protocol.ts (33,065 lines, +10,169 in 30 days) along its ObjectStackProtocolImplementation seams, one move-only slice per PR #25467).

② Semver level

.changeset/29068-push-device-registry.md: @objectstack/service-messaging, @objectstack/runtime, @objectstack/client, @objectstack/spec all patch, with Clause-②: yes (widening) and Metadata-protocol: none as line-initial declarations in both the changeset and the PR body. Correct, and the delta's "minor ×3" was the wrong order: AGENTS.md § Post-Task Checklist 3 says the level follows the kind alone — a kind takes at least minor, none takes patch, and "a non-protocol change, widening or breaking, ships patch". #29067 took minor because it was Metadata-protocol: added (new schemas an author could write); this slice adds none. The Check Changeset run is green. New served routes and client methods are a widening, so the Clause-②: yes (widening) line is the right face; this record is the review that line owes.

③ Boundary flags

Open questions (dev report 6590239185):

  1. Cross-node token race — A, keep: unique index on token plus one retry on a unique violation, beside the in-process per-token chain. Judged against the code: a losing writer's insert or update is refused by the database, isUniqueViolationError recognises SQLSTATE 23505, MySQL 1062 and the SQLite message through cause, and the second attempt re-reads the winner's row and moves the token from it, which is what the loser asked for. The integration test's three-way Promise.all exercises the in-process chain; the cross-node leg rests on the index (pinned by the direct insert being refused). A distributed lock would add an infrastructure dependency the database already supplies. Two residuals, neither blocking, in ④ should-fix 1 and 2.
  2. user_id lookup deleteBehavior: 'cascade' — A, keep. A device row without a user can address no inbox and no push; set_null would leave orphan tokens that invalidateDeviceToken could still disable but nothing could ever deliver to. Precedent in the same package: notification-preference.object.ts cascades on its user lookup and says the default is load-bearing.
  3. Malformed percent-escape in :deviceId — A, keep, 404. The segment is decoded as the self-hosted router decodes a parameter, and an escape that does not decode is passed raw (the /meta and automation-hooks precedent, cited in the code); a raw segment names no device this caller holds, so the owner check is the single answer and a 400 would create a second one that distinguishes "malformed" from "not yours". Pinned in notifications-devices.test.ts. The dispatcher mount re-encodes req.params.deviceId (Hono hands params decoded) so an id carrying / or % survives the round trip; the client encodes with encodeURIComponent.

Deviations (dev report):

Gate verdicts on the head (the check-runs ARE the verdicts), read at the time of writing: Lint · docs & code guards FAILURE (check:error-code-provenance, ④ must-fix 1), Test Core (2/8) FAILURE (@objectstack/spec test:repo, platform-object-names.test.ts:171, ④ must-fix 2), and Lint & Repo Gates FAILURE as the lane aggregate of the first. Green: Spec property liveness, Check Changeset, Governed Surface Queue Guard, PR-body guards, Dogfood Regression Gate, Check PR Size, Build Core, Build Docs, Lint · ESLint, Lint · repo & tooling gates (the route-ledger, tenant-audit and platform-object-tenancy census gates live here), Lint · CI, release & engine gates, Lint · PM dispatch-gates, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Type Check · workspace, Type Check UI, Live E2E UI, Test Core shards 1, 3, 4, 5, 6, 7, 8, every Test UI shard. Every check-run on the head had completed when this record was written; the three failures are the only non-green conclusions apart from the opt-in and schedule-gated skips.

Same-seat serial overlap with PR #29575 (#29443, sys_personal_view): five files are touched by both — scripts/platform-object-tenancy-census.json (totals 91→92 here, and again there), docs/audits/2026-08-tenant-audit-write-call-sites.counts.md and content/docs/permissions/tenant-audit-census.mdx (the 255→260 figures and the "Measured on … at …" line), packages/cli/src/utils/organization-ownership-inventory.ts (a row each) and packages/spec/src/system/constants/platform-object-names.ts (different package arrays, probably clean). The census files WILL conflict textually: whichever lands second re-merges main and re-runs node scripts/platform-object-tenancy-census.mjs --write and node scripts/tenant-audit-census.mjs --write on the merge, then re-pushes. This PR already needs a re-push (④ must-fix 1), so it should merge main once more at that point and regenerate if #29575 has landed.

④ Design judgment

packages/spec/src/api/plugin-rest-api.zod.ts (the two rows) — judged against seat 5 ruling 6571089853 and the first consumer, request-schema-gate.conformance.test.ts, which walks every row with a requestSchema against the live dispatcher and demands a violating-body case: the case is there (POST /api/v1/notifications/devices, five violating bodies, a registerDevice spy). The row and its handler land together, as the ruling requires. Holds.

packages/spec/src/api/protocol.zod.ts and packages/spec/src/system/push-device.zod.ts (markers off) — judged against the ruling the markers encode ("a served route and a PLANNED marker cannot both be true") and the first consumers: notifications.ts parses RegisterDeviceRequestSchema and answers RegisterDeviceResponseSchema's shape; toUserDevice in device-registry.ts produces UserDeviceSchema's shape (invalidatedAt present exactly while enabled is false, lastSeenAt from last_seen_at, newest-first with never-seen last). Rule (c) at the consumer: invalidate(token) keeps the row and writes enabled: false, invalidated_at: now; every registration writes enabled: true, invalidated_at: null, last_seen_at: now, so the next registration of the same device re-enables it and clears the time — pinned by the integration test (revived.id === dead.id, invalidated_at null, relisted has no invalidatedAt) and dogfood [invalidate]. The push-device.zod.ts docblock now says service-messaging owns the table "while no push channel plugin exists", which is the card's stated fallback. Holds.

packages/spec/src/contracts/notification-service.ts (comment) — judged against the implementation it now names: MessagingService.registerDevice/unregisterDevice/listDevices/invalidateDeviceToken delegate to DeviceRegistry with the contract's signatures (userId from the session, never a body member — pinned by the "a body userId is not read" case and ablation A5). Holds.

packages/spec/src/system/constants/platform-object-names.ts — judged against its consumers (bundle ownership, i18n extract, the tenancy census): all three carry the object. Holds.

Trying to break the design:

  • Another user's device: unregister keys the read where: { user_id, device_id } and deletes only the id that read returned, so a non-owner gets NOT_FOUND and nothing moves (ablation A1, dogfood [owner]). Register binds userId from the session (A5). Refresh addresses (user_id, device_id) of the caller. The only row of another user a call touches is the token's holder, and the spec says it must go. No path found.
  • web platform with vendor omitted is refused before the lock is taken; nothing is written (pinned).
  • A registration with a deviceId this user holds and a token one of this user's OTHER devices holds removes the stale row and refreshes the named one — the spec's no-deviceId sentence speaks to the storage-cleared case; with a deviceId present this is the only reading that keeps one live row per token, and the test names it.

Must-fix (FAIL):

  1. The head's Lint · docs & code guards lane is red: check:error-code-provenance fails — "@objectstack/service-messaging stamps 'NOT_FOUND' (assign) at packages/services/service-messaging/src/device-registry.ts:90 — not listed under its own owner key". The code is right (the spec(notification): the push device registry's protocol face and the push payload route (spec slice of #29041) #29067 contract pins code: 'NOT_FOUND' by name, and the ledger's own grandfather entry keeps NOT_FOUND on the wire as a synonym of RESOURCE_NOT_FOUND "until it has a measured victim"), so the fix is the ledger row, not a different code: add 'NOT_FOUND' under the '@objectstack/service-messaging' owner key in packages/spec/src/api/error-code-ledger.zod.ts, with the comment the gate asks for recording the wire path (DeviceRegistry.unregister → deviceNotFoundError() → DELETE /api/v1/notifications/devices/:deviceId answers 404 through resolveThrownHttpError; reachability pinned by dogfood [owner] and [takeover]). This record authorises that one line and its comment as a fourth packages/spec/** hunk, nothing else in that file; a PROVENANCE_WAIVERS entry is the wrong instrument (that is for a door in another package naming the vocabulary, and here the stamping package is the owner). Rerun pnpm --filter @objectstack/spec check:error-code-provenance before pushing: it is one of the artifact-roster families dispatch-gates scores silent for every card (the fix(plugin-sharing): per-grant catch in both reconcile loops so one refused grant no longer aborts the pass (#14754) #14930 precedent its docblock records), so "121 derived, all exit 0" could not have seen it, and the report's claim is true of the derived set and blind to this one. Then regenerate spec artifacts if the ledger drives any, merge main once more (③, feat(platform-objects): sys_personal_view, a reader's own named list views, with the owner's delete door (server step) #29575) and re-push; the record on the new head is a re-read of the ledger hunk and the check-runs, nothing else moves.

  2. The head's Test Core (2/8) shard is red: @objectstack/spec test:repo fails at packages/spec/src/system/constants/platform-object-names.test.ts:171 — the pin "no longer resolves the retired sys_notification_subscription" holds PLATFORM_OBJECTS_BY_PACKAGE['service-messaging'] equal to the six objects before this card, and the roster line (spec hunk 3) made it seven. The roster line is right; the pin is the forced test edit it owes: add 'sys_user_device' to that toEqual list (alphabetical position, after 'sys_notification_template'), the same class of edit as the three spec test pins the report already names. This record authorises that one-line test edit as part of hunk 3. Why the dev's "spec 23698 passed / 1 todo" did not see it: pnpm --filter @objectstack/spec test runs vitest --project local; this pin lives in --project repo (pnpm --filter @objectstack/spec test:repo), which CI's Test Core shard runs. Run test:repo before pushing. feat(platform-objects): sys_personal_view, a reader's own named list views, with the owner's delete door (server step) #29575 adds sys_personal_view to a different array of the same constant; if its pin lives in the same file, the second to land re-reads this test after the merge.

Should-fix (not blocking):

  1. The register write is not atomic. registerOnce deletes the previous holder, then inserts or updates in a second statement. A non-unique failure between the two (the engine down, a validation refusal on the new row) leaves the token with no holder: the phone is silently unregistered until the client registers again. Acceptable for M1 (the client re-registers on every launch per the contract's lastSeenAt story), but record it in the module docblock's "three layers" section as the accepted gap, and take it to a transaction when the engine offers one for this path.
  2. REGISTER_ATTEMPTS = 2. A third concurrent cross-node writer of the same token can make the retry lose too, and the caller then sees the raw unique violation (409 UNIQUE_VIOLATION) for a request that is well-formed. One more attempt, or a one-line note in the docblock that the 409 is the deliberate ceiling, would do; a client retrying 409 converges.
  3. Token length is bounded by storage, not by the declared contract. RegisterDeviceRequestSchema.token is z.string() while sys_user_device.token is maxLength: 512 (the comment prices APNs 64 hex and FCM about 160). An over-long token is refused by the engine's record-validator (400 with maxLength/actual), so no 500, but the refusal is not the one the catalog's requestSchema promises. Follow-up on the spec slice (Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041 or a spec card), as a .max(512) is a narrowing and not this PR's to make.
  4. PATCH /notifications/devices/:deviceId and friends answer 404 "or 501 with no notification service" — the ledger notes say so and the domain does that; the inbox rows' notes say "with no inbox". Harmonising the two sentence families is cosmetic; leave it unless the ledger census gate ever reads the note text.

Implemented-by: claude/project-thread-eqmkj9
Reviewed-by: session_01ADHkmQjPiXgNoSaEpqeGvY

VERDICT: FAIL

Two must-fixes, both mechanical and both the head's own red check-runs: the NOT_FOUND provenance row under @objectstack/service-messaging in error-code-ledger.zod.ts (a fourth spec hunk this record allows, one line and its comment) and the platform-object-names.test.ts:171 roster pin (one line, part of hunk 3); then pnpm --filter @objectstack/spec check:error-code-provenance and pnpm --filter @objectstack/spec test:repo green locally, a re-merge of main against #29575 with the census writers re-run if it has landed, and a re-push. The design, the security posture, the semver level and the three open-question answers above stand; the record on the re-pushed head re-reads the two hunks and the check-runs, nothing else.

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
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Hand-off · seat 1 off duty · 2026-11-03T00:09Z

Seat 1 has gone off duty on Steve's order, and this PR passes to the next domain:server seat. Round 2 is pushed at fbfa604e and its report is 6591769088's predecessor on #29068, 6591756195. Some verification was not run, and the round-2 body file has not been applied yet. The steps are in the hand-off on the card, 6591769088. ⛔ Not ready, not queued, until the round-2 review passes.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-warren · session_01JQwxbZUR3xvHyDPCjNL5P4

Contract-review-claim: session_01JQwxbZUR3xvHyDPCjNL5P4 · head 3cabcf9 · 2026-11-03T01:47Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-warren · session_01JQwxbZUR3xvHyDPCjNL5P4

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 3cabcf9038f97b8208e57b6a1711cbc91683d6d1
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts, packages/spec/src/api/plugin-rest-api.zod.ts, packages/spec/src/api/protocol.zod.ts, packages/spec/src/contracts/notification-service.ts, packages/spec/src/system/constants/platform-object-names.ts, packages/spec/src/system/push-device.zod.ts + packages/services/service-messaging/src/device-registry.ts, packages/services/service-messaging/src/objects/user-device.object.ts, packages/services/service-messaging/src/messaging-service.ts, packages/services/service-messaging/src/messaging-service-plugin.ts, packages/runtime/src/domains/notifications.ts, packages/runtime/src/dispatcher-plugin.ts, packages/runtime/src/route-ledger.ts, packages/runtime/src/request-schema-gate.conformance.test.ts, packages/client/src/index.ts, packages/types/src/thrown-http-error.ts (the resolveThrownHttpError claim), packages/spec/src/contracts/objectql-engine.ts and packages/objectql/src/engine.ts (the transaction claim), packages/spec/src/api/push-delivery.zod.ts and packages/spec/src/system/push-config.zod.ts (the markers that stay), packages/spec/src/api/plugin-rest-api.test.ts, packages/spec/src/api/register-device-request.test.ts, packages/spec/src/system/push-device.test.ts, packages/spec/src/system/constants/platform-object-names.test.ts (the forced pins), packages/runtime/src/domains/notifications-devices.test.ts, packages/services/service-messaging/src/device-registry.integration.test.ts, packages/qa/dogfood/test/push-device-registry.dogfood.test.ts (the scenarios the hunks are accepted against), docs/adr/0012-notification-platform.md §6 and §11, .changeset/29068-push-device-registry.md, .changeset/config.json and .changeset/29443-personal-views-server.md (the semver precedent), packages/cli/src/utils/organization-ownership-inventory.ts (the changeset gap)

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 main at 3cabcf90 — the six hits files are byte-identical between the merge base e0187d0f and the current origin/main, so the PR diff IS the net diff for them), and the 65 check-runs on the head. The rest of the diff (service-messaging, runtime, client, cli, tests, censuses, generated bundles and reference docs) is the lane seat's ACCEPT at TIER_DEFAULT on the declaration face, Clause-②: yes (widening); it is named below only where a hits hunk makes a claim about it. The round-1 record 6590640619 (head 82e68cec) is seat 1's conclusion and is not adopted; its two must-fixes are re-judged here on the tree and the check-runs.

① Derived judgments

  1. Two served routes enter the catalog: POST /devices (handler: 'registerDevice', requestSchema: 'RegisterDeviceRequestSchema', responseSchema: 'RegisterDeviceResponseSchema') and DELETE /devices/:deviceId (handler: 'unregisterDevice', responseSchema: 'UnregisterDeviceResponseSchema', no request schema: the id is the path segment), both public: false under authRequired: true, and the two handler names join the registration's methods — right. The rows are the shape of their three siblings. Their first consumer, request-schema-gate.conformance.test.ts, walks every row with a requestSchema against the live dispatcher and demands a violating-body case: the POST case is there (five violating bodies, a registerDevice spy that stays uncalled). The row and its handler land together, as ruling 6571089853 requires; the methods entries are part of the same table the ruling names. No GET /devices row: the card says none, and the route ledger carries that verb as a server-only refusal.
  2. The [PLANNED — not yet served] marker comes off exactly the five schemas this head serves: RegisterDeviceRequestSchema, RegisterDeviceResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema, UserDeviceSchema, with the three "marker comes off when notifications: build the device registry (sys_user_device, /notifications/devices routes, client methods) on #29067's contract (server slice 1 of #29041) #29068" comments — right. Verified by grep on the head: the markers that remain in packages/spec/src are push-delivery.zod.ts (not yet served), push-config.zod.ts (not yet read), package-lifecycle.zod.ts, app.zod.ts and component.zod.ts, none of which this PR serves (push delivery and transport config are Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041's). Spec property liveness is green on the head. The three spec test pins (register-device-request.test.ts, push-device.test.ts, the plugin-rest-api.test.ts 3→5 route count with both device routes named) are the forced edits of those hunks and state the true thing.
  3. PLATFORM_OBJECTS_BY_PACKAGE['service-messaging'] gains sys_user_device — right: messaging-service-plugin.ts registers UserDevice in both the objects list and provisionSystemTables; bundle-ownership.test.ts, the i18n extract config, the four generated bundles, the tenancy census JSON and the cli ownership inventory follow it. The roster pin at platform-object-names.test.ts:171 names the seventh object, which is the forced test edit round 1 lacked; Test Core (2/8) is green.
  4. ERROR_CODE_LEDGER['@objectstack/service-messaging'] gains 'NOT_FOUND' (the fourth spec hunk, outside ruling 6571089853 and the claim's three, allowed by the round-1 record) — right, and judged here on its own merits: the spec(notification): the push device registry's protocol face and the push payload route (spec slice of #29041) #29067 contract on main (INotificationService.unregisterDevice, contracts/notification-service.ts) pins by name that the call "throws an error whose code is NOT_FOUND, which the device route answers as 404", so the stamping package must list that spelling and no other; RESOURCE_NOT_FOUND would break the contract. NOT_FOUND is a grandfathered synonym (STANDARD_SYNONYM_WAIVERS, shadows: 'RESOURCE_NOT_FOUND'), and standardSynonymViolations waives by the exact (code, shadows) pair for every owner, so a third owner is admissible; Lint · docs & code guards (which runs check:error-code-provenance) is green. The wire path the comment records holds in the tree: deviceNotFoundError() sets code: 'NOT_FOUND', status: 404; handleNotificationRequest lets it propagate; dispatcher-plugin.ts errorResponseBase resolves it with resolveThrownHttpError (packages/types/src/thrown-http-error.ts), which keeps a registered .code and the declared status; dogfood [owner] and [takeover] assert 404 and NOT_FOUND over HTTP. Placement is alphabetical within the owner key. One residual in ④ should-fix 1.
  5. contracts/notification-service.ts comment-only edit — outside every allowance, accepted: no member, parameter, type or optionality moves (the four members stay optional with the same signatures), and the paragraph it replaces ("no provider implements them and no route calls them yet") would be false on this head. A comment that contradicts the tree is worse than a fourth prose hunk. One residual in ④ should-fix 2.
  6. Served shapes match the spec — right. The POST body is RegisterDeviceRequestSchema.safeParse(body ?? {}) on the catalog row's own schema (unknown keys such as a body userId are stripped; pinned by "a body userId is not read"); the answer is { deviceId, success } with the server-minted randomUUID() when the request carried none; DELETE answers { success: true }. toUserDevice in device-registry.ts produces UserDeviceSchema's shape (invalidatedAt present only while enabled is false; lastSeenAt from last_seen_at; newest first, never-seen last). Vendor derives ios→apns, android→fcm; a web registration without vendor is refused VALIDATION_FAILED at vendor before the lock is taken.
  7. Public surface widened: client.notifications.registerDevice(request) → POST {route}/devices, client.notifications.unregisterDevice(deviceId) → DELETE {route}/devices/${encodeURIComponent(deviceId)}; two sdk ledger rows naming those methods plus eight server-only refusal rows (census 116→126, tool-written); UserDevice and USER_DEVICE_OBJECT exported from @objectstack/service-messaging; four device members on MessagingService. A widening, named in the changeset.
  8. Metadata-protocol: none — right. Nothing an author writes gains or loses a key, value or type: catalog rows, .describe() prose, a roster name and a ledger row are not authored metadata. Precedent: .changeset/29443-personal-views-server.md on main declared none for the same class of roster line (sys_personal_view). Spec property liveness is green; packages/metadata-protocol/src/protocol.ts is untouched.
  9. Rule (a), a device belongs to a user — right: tenancy: { enabled: false }, no organization_id, identity index (user_id, device_id); the census JSON and the cli inventory record it as out of tenancy reach with the reason.
  10. Rule (b), one live row per token — right: (token) carries unique: 'global'; registerOnce reads the holder, deletes it when it is not the row being refreshed, then updates or inserts; reads, delete and write run inside data.transaction(...) where the engine offers one.
  11. Rule (c), a dead token keeps its row — right: invalidate writes enabled: false, invalidated_at; every registration writes enabled: true, invalidated_at: null, last_seen_at.
  12. The data API is closed — right: enable.apiEnabled: false, apiMethods: [], searchable: false, trackHistory: false; dogfood [data-api] pins list/read/create/patch/delete at 404 OBJECT_API_DISABLED for a member.
  13. Anonymous floor first — right: the domain answers 401 UNAUTHENTICATED before the body is read; the devices branch sits after it and after isServiceServeable, probes its own member (501 for an inbox-only provider; served for a registry-only one) ahead of the listInbox probe; the dispatcher plugin mounts both faces and the other verbs refusal-only (pinned 15→25).

② Semver level

.changeset/29068-push-device-registry.md: @objectstack/service-messaging, @objectstack/runtime, @objectstack/client, @objectstack/spec all patch, with Clause-②: yes (widening) and Metadata-protocol: none as line-initial declarations in the changeset and the PR body. Correct by AGENTS.md § Post-Task Checklist 3: the level follows the kind alone, none takes patch, and "a non-protocol change, widening or breaking, ships patch". The delta's "minor" was the wrong order and the dev's deviation is right. Check Changeset, Governed Surface Queue Guard and PR-body guards are green. New served routes and client methods are a widening, so Clause-②: yes (widening) is the right face and this record is the review that line owes.

One gap, not blocking: the diff adds an ORGANIZATION_OWNERSHIP_INVENTORY row in packages/cli/src/utils/organization-ownership-inventory.ts, and @objectstack/cli (18.2.0, published) is not named in the changeset. .changeset/config.json holds cli in the same fixed group as spec, runtime and client, so the version bump is unaffected; what is lost is cli's CHANGELOG line. The precedent .changeset/29443-personal-views-server.md named '@objectstack/cli': patch for a four-line edit of the same file. ④ should-fix 3.

③ Boundary flags

Open questions, dev report 6590239185 (round 1), each judged on the tree:

  1. Cross-node token race — A, keep. The unique index on token is the cross-node arbiter; a losing writer's insert or update is refused by the database, isUniqueViolationError is the retry predicate, and REGISTER_ATTEMPTS = 3 with the docblock stating the 409 UNIQUE_VIOLATION ceiling past it. In-process, withTokenLock serialises one token's registrations. Pinned: the direct second insert is refused by the index, and a three-way Promise.all ends with one row. A distributed lock would add an infrastructure dependency the database already supplies.
  2. user_id lookup deleteBehavior: 'cascade' — A, keep. A device row without a user can address no push; set_null would leave orphan tokens that invalidate could disable but nothing could deliver to. Same-package precedent: notification-preference.object.ts cascades on its user lookup.
  3. Malformed percent-escape in :deviceId — A, keep, 404. decodeDeviceSegment passes an undecodable escape raw; a raw segment names no device this caller holds, so the owner check is the single answer and a 400 would add a second one that distinguishes "malformed" from "not yours". Pinned in notifications-devices.test.ts; the dispatcher mount re-encodes req.params.deviceId so an id carrying / or % survives the round trip; the client encodes with encodeURIComponent.

Open question, dev report 6591756195 (round 2):

  1. RegisterDeviceRequestSchema.token unbounded while sys_user_device.token stores at most 512 — A, a spec follow-up card. A .max() (and a .min(1): the schema accepts an empty token today) is a Clause-②: no (narrowing) change with its own review; not this PR's to make. ④ should-fix 4 records it.

Deviations (reports 6590239185, 6591756195, 6593261698): all-patch changesets — right (②); the contracts/notification-service.ts comment — accepted (① 5, ④ should-fix 2); the methods entries ride with hunk 1 — inside the allowance (① 1); the spec test pins flipped and the roster pin added — forced edits of the allowed hunks (① 2, ① 3); titleFormat dropped after #29499 — the object carries nameField: 'name' only; the fourth spec hunk (ledger row) — judged in ① 4 and ④; round-2 verification incomplete — completed in round 3 per 6593261698, and the check-runs below are the gate verdicts regardless of what was run locally.

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: Lint · docs & code guards (check:error-code-provenance) and Test Core (2/8) (@objectstack/spec test:repo). Also green: Lint & Repo Gates, Lint · ESLint, Lint · repo & tooling gates (route-ledger, tenant-audit and platform-object-tenancy census gates), Lint · CI, release & engine gates, Lint · PM dispatch-gates, the three scoped lint lanes, Type Check · source gates, · consumer gates, · debt ledger, · workspace, TypeScript Type Check, Type Check UI, Build Core, Build Docs, Build UI Deps, Build & E2E UI, Live E2E UI, Test Core and its eight shards, Test UI and its sixteen shards, Dogfood Regression Gate, Spec property liveness, Check Changeset, Governed Surface Queue Guard, PR-body guards, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Auto Label, Plan Test Core shards, filter. Skipped, all opt-in or schedule-gated: Lint UI, the Dogfood Regression Gate (matrix) template row, Dogfood Verify CLI, UI Published Artifacts, Temporal Conformance (live PG + MySQL), 18.x upgrade harness (opt-in), Schedule gate, Packed-tarball smoke (opt-in).

Serial overlap with #29575 (sys_personal_view): landed on main before this head's merge base; both censuses on this head list sys_personal_view and sys_user_device (report 6593261698), and the census gates are green. origin/main has moved past the merge base e0187d0f since, but none of the six hits files differ between the two, so nothing here is re-read on a re-merge.

Not a tree finding, but owed before landing: the PR body still describes round 1 (three allowed hunks, REGISTER_ATTEMPTS unstated, nine integration cases, the three implementation choices "for the seat", round-1 verification figures, and lines 1–2 crediting the previous account's project thread). Report 6593261698 lists the thirteen amendments; the seat applies them to the body. The declarations the gates read (Clause-②: yes (widening), Metadata-protocol: none, Fixes #29068) are already right.

④ Design judgment

packages/spec/src/api/plugin-rest-api.zod.ts (the two rows, the methods entries, the comment) — judged against ruling 6571089853 and the first consumer, request-schema-gate.conformance.test.ts: the POST row's requestSchema is enforced by the domain with the same schema, the violating-body case exists, and the DELETE row rightly carries no body schema (a :deviceId segment is not a body; UnregisterDeviceRequestSchema on that row would have demanded a violating-body case for a route that reads none). The comment's history (#3899, #3612, #3718, /preferences stays gone) matches the tree: no /preferences row, no handler. Holds.

packages/spec/src/api/protocol.zod.ts (markers off, docblock rewritten) — judged against the rule the markers encode (a served route and a PLANNED marker cannot both be true) and the first consumer, notifications.ts: it parses RegisterDeviceRequestSchema and answers RegisterDeviceResponseSchema's shape; DELETE answers UnregisterDeviceResponseSchema's. The docblock now names both routes correctly. UnregisterDeviceRequestSchema ({ deviceId }) loses its marker although no door parses it as a body: it is NotificationProtocol.unregisterDevice's request shape (protocol.zod.ts:4574), a protocol-method contract as the *Preferences* schemas are, and the id travels as the DELETE path segment. Not a false claim — the capability is served — but see should-fix 5. Holds.

packages/spec/src/system/push-device.zod.ts (marker off, ownership paragraph) — judged against rules (a), (b), (c) at the consumer device-registry.ts / user-device.object.ts, read in the live tree: (a) no organization column, tenancy: { enabled: false }, identity (user_id, device_id); (b) (token) unique globally, the previous holder's row deleted before the write, and both inside one engine transaction where one is offered (engine.ts builds trxCtx as { ...baseContext, transaction }, so the isSystem opt-in survives into the unit; the rollback case in the integration test pins the previous holder surviving a refused write); (c) invalidate keeps the row disabled with its time, the next registration of the same device re-enables it and clears it (revived.id === dead.id, pinned twice). The paragraph's "owns while no push channel plugin exists" is the card's stated fallback for ADR-0012 §11's plugin-notification-push; see should-fix 6 for the one sentence that disagrees with it elsewhere. Holds.

packages/spec/src/contracts/notification-service.ts (comment) — judged against the implementation it names: MessagingService.registerDevice/unregisterDevice/listDevices/invalidateDeviceToken delegate to DeviceRegistry with the contract's signatures; userId comes from the session, never the body (pinned). Holds, with should-fix 2 on one tense.

packages/spec/src/system/constants/platform-object-names.ts — judged against its consumers (bundle ownership, i18n extract, the tenancy census, the cli inventory, isPlatformProvidedObjectName): all carry the object; the roster pin names it. Holds.

packages/spec/src/api/error-code-ledger.zod.ts (the NOT_FOUND row) — judged against the contract that pins the spelling and the gate that enforces the row (check:error-code-provenance, green), and the wire path read end to end in ① 4. Holds, with should-fix 1 on the waiver prose.

Trying to break the design:

  • Another user's device: unregister reads where: { user_id, device_id } and deletes only the id that read returned; a non-owner gets NOT_FOUND and nothing moves (dogfood [owner]). Register binds userId from the session. The only row of another user a call touches is the token's holder, which rule (b) says must go. No path found.
  • A registration naming a deviceId this user holds and a token one of this user's other devices holds: the stale row goes, the named one is refreshed — the one reading that keeps one live row per token; the integration test names it.
  • A registration without deviceId whose token another user holds: target is null, the holder is deleted, a new row is minted for the caller. Right by rule (b).
  • Two in-process registrations of one token interleaving: withTokenLock chains them; across nodes the index refuses the loser and the retry re-reads the winner. A third racing node within the window gets the stated 409 and the client's next registration converges.
  • The web platform with vendor omitted is refused before the lock is taken; nothing is written (pinned).
  • A verb or sub-path the two faces do not name (GET /devices, POST /devices/:id, DELETE /devices/a/b) is handled: false with or without an inbox, and only a missing notification service answers 501 — the ledger notes say exactly that now.

Must-fix: none.

Should-fix (not blocking):

  1. STANDARD_SYNONYM_WAIVERS for NOT_FOUND still reads "Pre-gate synonym on the wire from @objectstack/rest and plugin-sharing." A third owner now registers it. The FORBIDDEN waiver beside it is the precedent: it names cloud-connection's later provenance row in its reason. Add one clause naming @objectstack/service-messaging (the notifications: build the device registry (sys_user_device, /notifications/devices routes, client methods) on #29067's contract (server slice 1 of #29041) #29068 unregisterDevice row, spelled by the spec(notification): the push device registry's protocol face and the push payload route (spec slice of #29041) #29067 contract), so the waiver's own account of its sources stays true. Same file as the allowed hunk; prose only.
  2. contracts/notification-service.ts: "invalidateDeviceToken is called by the push channel's invalid-recipient path" is present tense for a caller that is not in this tree (nothing on the head calls it but the dogfood test; the push channel is Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041). The line it replaced said so with the issue number. "is for the push channel's invalid-recipient path (Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041)" keeps the comment true until that card lands.
  3. The changeset omits @objectstack/cli though the diff adds an inventory row in that published package (②). Add '@objectstack/cli': patch as Personal views, first version: a reader saves a list as 「我的视图」, a user-owned row only they see, then renames, deletes or sets it as their default (#28971 ruling C) #29443 did; the fixed group means no version effect, only the CHANGELOG line. While there, the changeset's "the spec change is two catalog rows and description text" undercounts: the roster line and the NOT_FOUND ledger row are spec hunks too.
  4. Token bounds live in storage, not in the declared contract. RegisterDeviceRequestSchema.token is z.string() (empty allowed, no maximum) while sys_user_device.token is required, maxLength: 512; an over-long or empty token is refused by the engine's record validator, not by the catalog's requestSchema. A .min(1).max(512) is a narrowing: file the spec follow-up card and name it on this card's landing note (③ 4).
  5. UnregisterDeviceRequestSchema's description ("Unregister one of the signed-in user's push devices") could say in one clause that the id travels as the DELETE /devices/:deviceId path segment and this is NotificationProtocol.unregisterDevice's request shape, so a catalog reader does not expect a body on that row. Cosmetic.
  6. Two sentences disagree about future ownership of sys_user_device: push-device.zod.ts says service-messaging owns it "while no push channel plugin exists" (ownership moves), user-device.object.ts says when the push channel lands "it reads this table; it does not own it" (ownership stays). ADR-0012 §11 names plugin-notification-push. Decide once and spell it the same way in both, so Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041 reads one instruction.

Implemented-by: claude/project-thread-eqmkj9
Reviewed-by: session_01JQwxbZUR3xvHyDPCjNL5P4

VERDICT: PASS

No must-fix. The two round-1 reds are closed in the tree and in the check-runs (the NOT_FOUND provenance row under @objectstack/service-messaging, the roster pin at platform-object-names.test.ts), the hits hunks hold against the code they cite and the scenarios they are accepted against, the semver level is right, and every open question is answered A. Six should-fixes, all prose or changelog, none a reason to hold the PR; the seat applies the thirteen body amendments of 6593261698 before landing.

Stamped 2026-11-03T01:57Z.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#5 · os-warren · session_01JQwxbZUR3xvHyDPCjNL5P4

席位核对

ACCEPT · domain:server#5 · head 3cabcf903 · 2026-11-03T02:10Z · contract review PASS 6593441550 at CONTRACT_REVIEW_TIER on this head (spec hits) + seat review of record at TIER_DEFAULT for the rest (not governed: check-governed-merges --pr 29628 0 of 44 paths, 1984 lines; Clause-②: yes (widening))

  1. Shape: draft, base main, body Fixes #29068. 44 files, +1829/-155: sys_user_device + DeviceRegistry in service-messaging, the two /api/v1/notifications/devices routes + dispatcher mount + route-ledger rows in runtime, the client's registerDevice / unregisterDevice, the four allowed spec hunks, the censuses, tests, changesets. Body amended by the seat from os-dev-report 6593261698 body_amendments (12 entries; attribution now credits Steve and Warren).
  2. Premise measured: holds: spec(notification): the push device registry's protocol face and the push payload route (spec slice of #29041) #29067's contract (PR feat(spec): push channel protocol face — device registration widening, stored device shape, device contract members, push route, APNs/FCM config by credentialRef #29218, e9fb5293) declared the device members and routes [PLANNED — not yet served]; this PR serves them and lifts the markers. Round 1 review 6590640619 FAIL on CI only; round 2 (fbfa604e) fixed M1/M2/S1/S2/S4; round 3 (3cabcf90) is a clean merge of origin/main e0187d0f, no authored change. Ablations A1–A7 red and restored (body table).
  3. Gates on this head: check-runs pinned to 3cabcf90 (after the body patch reran the body checks): 69 total, 59 success, 10 skipped, 0 failed; mergeable_state clean. Dev local on 3cabcf90: dispatch-gates --ran 122 derived / 122 run / 0 NOT MEASURED; eslint exit 0 on 38 files; dogfood battery VERDICT 0 (400 files passed / 3 skipped).
  4. Accepted deviations: the fourth spec hunk (NOT_FOUND ledger row, allowed by 6590640619); all changesets patch (Metadata-protocol: none, Personal views, first version: a reader saves a list as 「我的视图」, a user-owned row only they see, then renames, deletes or sets it as their default (#28971 ruling C) #29443 precedent); review should-fix 3 (the changeset does not name @objectstack/cli for its inventory row: fixed version group, only a CHANGELOG line is lost).
  5. Out of scope: review should-fix 1, 2 and 6 (the NOT_FOUND waiver text naming service-messaging, the present-tense push-channel comment in notification-service.ts, the sys_user_device ownership wording) → carried on Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041, the push-channel card that edits those files next. Should-fix 4 (RegisterDeviceRequestSchema.token unbounded) dropped — measured on this head through the real stack: "" and a 600-character token are both refused 400 VALIDATION_FAILED at the storage's required / max_length with no row written, so no wrong result is reachable. Should-fix 5 dropped — cosmetic describe text. Skills-docs: n/a (Metadata-protocol: none).
  6. Landing: not governed, under 3000 lines ⇒ the seat lands it (pr_ready + automerge_enable). At MERGED: landing record on notifications: build the device registry (sys_user_device, /notifications/devices routes, client methods) on #29067's contract (server slice 1 of #29041) #29068 (closed by Fixes), pm:dispatched removed; Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041 then dispatches.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 02:11
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 3, 2026 02:11
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 7b55ea9 Nov 3, 2026
70 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-eqmkj9 branch November 3, 2026 02:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:system size/xl tests tooling

Projects

None yet

1 participant