Skip to content

feat(spec): push channel protocol face — device registration widening, stored device shape, device contract members, push route, APNs/FCM config by credentialRef - #29218

Merged
objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/project-thread-cjde2j
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 13 commits into
mainfrom
claude/project-thread-cjde2j

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

domain:spec#1 · os-steve · session_01ADHkmT6K7dHh79Lej3sWr5

Requested by Steve · project thread
Fixes #29067

Supersedes #29101 (now closed by the seat as superseded): its three commits at 088613be are carried unchanged on this branch and continued.

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: none — declared ahead of its producer: no route serves the device schemas and nothing reads the push transport configuration yet, so there is nothing an author can use to teach; the server slice that serves them owns the docs

维护者速读

  • 改了什么:spec 里补齐 push 推送渠道的协议面——设备注册请求加宽(vendor/appId/locale 可选,locale 走仓里的 BCP 47 LocaleSchema)、新的设备存储形状 UserDeviceSchema、INotificationService 四个可选设备成员、推送载荷(只收同站相对路由)、APNs/FCM 传输配置(密钥只走 credentialRef)。
  • 为什么改:手机壳与控制台要注册原生推送 token,服务端设备注册(notifications: build the device registry (sys_user_device, /notifications/devices routes, client methods) on #29067's contract (server slice 1 of #29041) #29068)和推送渠道(Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041)都卡在这份协议上。
  • 风险与回滚:纯加宽,今天能发的请求体照旧能解析;所有新形状都标了 [PLANNED],还没有路由或读取方;回滚就是撤掉这个 PR。
  • 先看:packages/spec/src/system/push-device.zod.ts(设备归属规则 a/b/c)、packages/spec/src/system/push-config.zod.ts(凭据引用与内联密钥拒收)、packages/spec/src/api/protocol.zod.ts 的设备注册块。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/system/push-device.test.ts src/system/push-config.test.ts src/api/push-delivery.test.ts src/api/register-device-request.test.ts,预期全部通过。
  • 需要你做的:批准新的公开名字(schema 名、键名、INotificationService 成员,清单见报告 open_questions);落地本身由席位走。

What changes

The push channel's protocol face (ADR-0012 §6), the packages/spec slice of #29041. Declared ahead of its producer: nothing here is served or read yet, and every new or touched schema says so with a [PLANNED — not yet served] / [PLANNED — not yet read] marker in its description (tracker ids live only in adjacent // comments, per check:doc-authoring).

  • RegisterDeviceRequestSchema (api/protocol.zod.ts) gains three optional members: vendor (apns | fcm; absent, derived from platform; web has no default, so a web registration names apns or fcm), appId, and locale, a BCP 47 tag through the house LocaleSchema (system/translation.zod.ts). The describes state the refresh-by-deviceId and server-minted-id contract. RegisterDeviceResponseSchema / UnregisterDevice* keep every member and gain only a description. No published field is removed or renamed, so no ADR-0087 tombstone, conversion or semantic entry is owed.
  • UserDeviceSchema (new, system/push-device.zod.ts): the stored device. (a) A device belongs to its user alone, no organization member; identity is (userId, deviceId). (b) One live row per token: a token this user already holds under another deviceId (storage cleared, no deviceId sent) refreshes that row and answers its deviceId; a second user's registration takes the token over and the first user's row for it is removed. (c) A dead token keeps its row with enabled: false and invalidatedAt; re-registering re-enables it. invalidatedAt is present exactly while enabled is false, and lastSeenAt is absent only on a row no registration wrote (listed last). Its locale takes the same LocaleSchema, and its platform the registration's own list (one internal const, system/push-device-platform.ts, re-exported by no barrel, so no public name).
  • INotificationService gains four optional members: registerDevice, unregisterDevice, listDevices, invalidateDeviceToken, each documenting the semantics an implementation owes (for example, unregisterDevice throws an error whose code is NOT_FOUND for a device the user does not hold, which the route answers as 404). Existing implementations stay valid.
  • PushDeliveryDataSchema / PushRouteSchema (new, api/push-delivery.zod.ts): notificationId plus an optional route. route is the notification's actionUrl, the same value the inbox serves (explicit payload.url / payload.actionUrl, else /OBJECT/ID from the source record, actionUrlFor in service-messaging): app-relative or already /apps/…. The shell never navigates it verbatim; on tap it resolves it exactly as the bell and Home do, through resolveNotificationTarget (ui/packages/app-shell/src/utils/appRoute.ts) with its host app segment. A shell with no app list of its own hands route to the Console to resolve, or hosts it under the segment the Console would answer (resolveHostAppSegment: preferred, then first active app, then setup). PushRouteSchema admits only a same-server relative path, with no dot segment and no percent-encoded /, \, . or % in its path, which closes the double-encoded forms too (both rules are patterns, so the published JSON Schema carries them); a value that fails it is dropped, never sent. The docblock says where the data sits per vendor: FCM message.data, APNs top level beside aps, never under a data key. No notify key is added.
  • PushServiceConfigSchema (new, system/push-config.zod.ts, beside email-config.zod.ts): strict apns (token auth: teamId, keyId, topic, environment, credentialRef) and fcm (HTTP v1: projectId, credentialRef) blocks. Secrets go only through credentialRef (ADR-0143 D3): an inline secret is refused by name (key, privateKey, serviceAccount, …) and as the content of a reference (a PEM block or a JSON document). keyRef / serviceAccountRef are answered with credentialRef. The reference is stricter than the house credentialRef (z.string().min(1)) on purpose, and a comment says why: these two secrets are recognisable by shape, a bearer token or API key is not. Not a stack-definition key until its reader ships.
  • Changeset .changeset/29067-push-device-registry-spec.md: minor, Metadata-protocol: added (the authorable-surface baseline grew, which the level gate reads as added).

No route row is added to DEFAULT_NOTIFICATION_ROUTES. The card's Wanted 2 asks for the device rows; measured, there is no house channel for a declared-not-served route row: handlerStatus: 'planned' was retired under ADR-0049 with the prescription "an endpoint that has no handler yet is simply not registered" (HANDLER_STATUS_RETIRED), the table's own comment records the device rows' removal for that reason, and packages/runtime/src/request-schema-gate.conformance.test.ts reds any dispatcher-catalog body route with a requestSchema and no served violating-body case. The rows land with their handlers in #29068, which also flips the spec catalog pin (plugin-rest-api.test.ts, toHaveLength(3) and not.toContain('/devices')) and adds the runtime gate case.

Why

The mobile shell and the Console register a native APNs / FCM token under the signed-in session, and notify deliveries need a push payload contract. The server device registry (#29068) and the push channel (#29041) are blocked on this protocol face.

Tests

  • New / extended: system/push-device.test.ts (round-trip, required members, vendor refusal, non-tag locale refusal, the same platform list as the registration, ISO instants, no organization member, PLANNED marker), system/push-config.test.ts (credentialRef on both blocks, inline-secret refusals by name and by content, the keyRef / serviceAccountRef near-miss answers, PLANNED markers), api/register-device-request.test.ts (pre-widening bodies still parse, widened round-trip, unknown vendor refused, a web registration naming either vendor admitted, non-tag locales refused and BCP 47 tags admitted, PLANNED markers), api/push-delivery.test.ts (both inbox actionUrl shapes admitted, /opportunity/123 and /apps/crm/opportunity/123; off-server, scheme, protocol-relative, backslash, control-character, dot-segment and percent-encoded / \ . routes refused, including /../../api/v1/me, /%2e%2e/x, /./x, /a%2fb, /a%5cb and the double-encoded /a%252Fb, /%252e%252e/x, /a%255cb; a query or fragment keeps its encoding; PLANNED marker), contracts/notification-service.test.ts (the four optional members, a toy registry exercising register / list / unregister / invalidate).
  • Forced test edit: type-alias-convention.pin.test.ts pin count 817 → 818 (the new isomorphic UserDevice alias; the resumed commits had moved it 811 → 817 for the push-config and push-delivery aliases).
Local readings (at the head `aff751de`)
  • aff751de carries the round-4 prose should-fixes (a shell with no app list; %25 refused in the route path, pinned). eb9b2371 carried the delta review's round (the push route contract, per-vendor placement, the path refusals, the same-user token refresh), e7b06bc4 the contract review's should-fixes, and 833e9bd7 moved both locale members to LocaleSchema. Every reading below was taken again at aff751de; earlier heads carried the same readings with 23450 (eb9b2371), 23437 (e7b06bc4), 23434 (833e9bd7) and 23423 (73fabdf2) spec tests.
  • Build + regen: pnpm --filter @objectstack/spec build then check:generated --fix under os-verify-lock, VERDICT command-exit 0; each round's describe and docblock changes regenerated their reference pages (api/protocol.mdx, api/push-delivery.mdx, system/push-device.mdx), committed with the source; check:generated without fix then exit 0. Earlier, the authorable-surface system shard was taken from main and regenerated (the two keys renamed here, never on main, left with their rename).
  • pnpm --filter @objectstack/spec test: VERDICT command-exit 0, Test Files 794 passed (794), Tests 23454 passed, 1 todo.
  • pnpm --filter @objectstack/spec typecheck: VERDICT command-exit 0 (tsc, scripts typecheck, and check:test-typecheck: test layer compiles, 131 pinned signatures held).
  • Consumers (closure built first, VERDICT 0): @objectstack/service-messaging test Test Files 62 passed (62), Tests 759 passed, typecheck exit 0; @objectstack/runtime notification-schema-conformance.test.ts + request-schema-gate.conformance.test.ts (the catalog pin consumer) Test Files 2 passed, Tests 25 passed, typecheck exit 0; @objectstack/client typecheck exit 0 (it re-exports RegisterDeviceRequest / RegisterDeviceResponse).
  • Scoped lint: pnpm exec eslint --no-inline-config over the 14 changed .ts files, exit 0, no findings.
  • Ablation, credentialRef: push-config.zod.ts replaced by its 088613be bytes (keyRef / serviceAccountRef, no PLANNED marker; on-disk counts checked) → push-config.test.ts exit 1, 19 failed of 21; restored with git checkout HEAD --, blob hash equal to HEAD, git diff HEAD empty → exit 0, 21 passed. Under a trap.
  • Ablation, locale: both locale: LocaleSchema.optional() lines put back to z.string().optional() (ablation-replace.mjs, anchor 1 → 0 in each file) → register-device-request.test.ts + push-device.test.ts exit 1, 7 failed of 40; restored, both blob hashes equal to HEAD, git diff HEAD empty → exit 0, 40 passed. Under a trap.
  • Ablation, shared platform list: UserDeviceSchema.platform put back on its own z.enum(['ios', 'android']) → push-device.test.ts exit 1, 1 failed of 19 (the same-platforms test); restored, blob hash equal to the pre-ablation file → exit 0, 19 passed. Under a trap.
  • Ablation, path refusals: the second .regex() (dot segments, percent-encoded / \ .) removed from PushRouteSchema → push-delivery.test.ts exit 1, 8 failed of 27 (exactly the eight new refusals); restored, blob hash equal to the pre-ablation file → exit 0, 27 passed. Under a trap.
  • Ablation, %25: |25 taken out of that pattern (ablation-replace.mjs, anchor 1 → 0) → push-delivery.test.ts exit 1, 3 failed of 31 (exactly the three double-encoded pins); restored, blob hash equal to the pre-ablation file → exit 0, 31 passed. Under a trap.
  • check-changeset-no-major.mjs --base origin/main --event with this body: exit 0, level axis yes (widening) + added. check-partof-closing-keyword.mjs with this body: exit 0.
  • Dogfood battery: n/a, no ! in the title and no (narrowing) arm.
Gates (dispatch-gates --commands, 103 commands)
  • At the head aff751de: the derived list is the same 103 commands as at every earlier head, and all 103 exit 0 (each run with stdin closed, exit captured before any pipe). dispatch-gates --ran: 103 derived, 103 run, 0 NOT-MEASURED (a derived zero), 0 UNRUN.
  • At eb9b2371, e7b06bc4, 833e9bd7 and 73fabdf2: all 103 exit 0. First round at ed89754e: 102 of 103 exit 0; check:llms-txt exit 1 on the schema inventory counts (system 37 vs 39, api 33 vs 34, heading 213 vs 216, the three new modules), fixed in 73fabdf2.
  • Before the gates: pnpm turbo run build --concurrency=2 --filter='./packages/**' under os-verify-lock, VERDICT command-exit 0 (the check:dual-build-cjs-loads / check:skill-examples prerequisites).
  • CI on 833e9bd7, e7b06bc4 and eb9b2371: 57 success, 8 skipped, 0 failure each. CI on aff751de: the seat reads it.

Acceptance notes

Verification

See the readings under Tests. The PR stays a draft; the seat hands the declaration-face contract review to triage.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes (scoped to the changed files locally; the repo-wide run is CI's).
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: its generated artifacts are regenerated and committed.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

Generated by Claude Code

…rt config cases (red)

Claude-Session: https://claude.ai/code/session_01FEkQ5oc56xChD6Wrc4cGSt
Co-Authored-By: Claude <noreply@anthropic.com>
…, device contract members, push delivery data, APNs/FCM transport config by reference

Claude-Session: https://claude.ai/code/session_01FEkQ5oc56xChD6Wrc4cGSt
Co-Authored-By: Claude <noreply@anthropic.com>
…ush channel protocol face

Claude-Session: https://claude.ai/code/session_01FEkQ5oc56xChD6Wrc4cGSt
Co-Authored-By: Claude <noreply@anthropic.com>
… producer

Completes the card's protocol face on top of the resumed commits:
- UserDeviceSchema (system/push-device.zod.ts), the stored device shape:
  user-owned, one live owner per token, a dead token keeps its row disabled.
- INotificationService.listDevices, optional like the other device members.
- APNs / FCM transport secrets keyed credentialRef (ADR-0143 D3), the house
  key for a secret reference; keyRef / serviceAccountRef answer with it.
- [PLANNED] markers on the device schemas, the delivery data, the stored
  device and the transport config: nothing serves or reads them yet, and no
  route row is added (the rows land with their handlers).
- Changeset: minor, Metadata-protocol: added (the authorable surface grew).

Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5
Co-authored-by: Claude <noreply@anthropic.com>
…rotocol face

Regenerated from the merged tree by check:generated --fix after the
origin/main merge: api-surface, export-origins, declaration-map, docs
references, strictness ledger counts, json-schema manifest and the
authorable-surface shard (system shard taken from main, then gen:schema).

Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5
Co-authored-by: Claude <noreply@anthropic.com>
check:llms-txt: system 37 -> 39 (push-config, push-device), api 33 -> 34
(push-delivery), heading 213 -> 216; the rows name the new modules.

Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5
Co-authored-by: Claude <noreply@anthropic.com>
@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 1 package(s): @objectstack/spec, touching 28 documentable anchor(s). ⚠️ 13 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/api-surface/system.json, packages/spec/authorable-surface/api.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/connectors.mdx (via credentialRef (literal, a string literal in APNS_KEY_GUIDANCE; a string literal in ApnsTransportConfigSchema; a string literal in FCM_ACCOUNT_GUIDANCE; a string literal in FcmTransportConfigSchema))
  • content/docs/automation/flows.mdx (via credentialRef (literal, a string literal in APNS_KEY_GUIDANCE; a string literal in ApnsTransportConfigSchema; a string literal in FCM_ACCOUNT_GUIDANCE; a string literal in FcmTransportConfigSchema))
  • content/docs/automation/index.mdx (via credentialRef (literal, a string literal in APNS_KEY_GUIDANCE; a string literal in ApnsTransportConfigSchema; a string literal in FCM_ACCOUNT_GUIDANCE; a string literal in FcmTransportConfigSchema))
  • content/docs/data-modeling/external-datasources.mdx (via credentialRef (literal, a string literal in APNS_KEY_GUIDANCE; a string literal in ApnsTransportConfigSchema; a string literal in FCM_ACCOUNT_GUIDANCE; a string literal in FcmTransportConfigSchema))
  • content/docs/data-modeling/odata-datasources.mdx (via credentialRef (literal, a string literal in APNS_KEY_GUIDANCE; a string literal in ApnsTransportConfigSchema; a string literal in FCM_ACCOUNT_GUIDANCE; a string literal in FcmTransportConfigSchema))
  • content/docs/kernel/contracts/index.mdx (via INotificationService (symbol, a top-level interface))
  • content/docs/kernel/runtime-services/data-service.mdx (via /api/protocol.zod (route, a path literal on a changed line))
  • content/docs/kernel/services-checklist.mdx (via INotificationService (symbol, a top-level interface), registerDevice (symbol, a method of interface INotificationService), unregisterDevice (symbol, a method of interface INotificationService), /api/protocol.zod (route, a path literal on a changed line))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v15.mdx (via credentialRef (literal, a string literal in APNS_KEY_GUIDANCE; a string literal in ApnsTransportConfigSchema; a string literal in FCM_ACCOUNT_GUIDANCE; a string literal in FcmTransportConfigSchema))
  • content/docs/releases/v17/17-0.mdx (via registerDevice (symbol, a method of interface INotificationService), unregisterDevice (symbol, a method of interface INotificationService), credentialRef (literal, a string literal in APNS_KEY_GUIDANCE; a string literal in ApnsTransportConfigSchema; a string literal in FCM_ACCOUNT_GUIDANCE; a string literal in FcmTransportConfigSchema))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 13 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/api-surface/system.json, packages/spec/authorable-surface/api.json, …) — pages documenting those are invisible to this run
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 168 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 5651647d82dea5ce18ad537d629c18e3dd27f613 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 93ea32613d09b525fd2323e3b347bc22e9975eb0 — the merge of head 918921642593dd4097831bd1ef1582ae15f584fb into base 5651647d82dea5ce18ad537d629c18e3dd27f613, 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 93ea32613d09b525fd2323e3b347bc22e9975eb0 && git checkout 93ea32613d09b525fd2323e3b347bc22e9975eb0
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 5651647d82dea5ce18ad537d629c18e3dd27f613 918921642593dd4097831bd1ef1582ae15f584fb && git checkout -B drift-repro 5651647d82dea5ce18ad537d629c18e3dd27f613 && git merge --no-ff 918921642593dd4097831bd1ef1582ae15f584fb

node scripts/docs-audit/affected-docs.mjs --json 5651647d82dea5ce18ad537d629c18e3dd27f613

⚠️ 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 5651647d82dea5ce18ad537d629c18e3dd27f613 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

RegisterDeviceRequestSchema.locale and UserDeviceSchema.locale were a bare
string their describes called BCP 47; a device saved as a non-tag spelling
would be rendered in a locale no bundle answers. Both now take LocaleSchema
(system/translation.zod.ts), with refusal tests for non-tag spellings.

Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Head-sha: 833e9bd7ba11aa8cef7dd50872e23ae981741c1d · read 2026-11-02T11:08Z

维护者速读

席位意见:可落地,等你批名字 + 契约复审。

席位核对 — ACCEPT · domain:spec#1 · head 833e9bd7

  1. Shape: draft, base main, Fixes #29067, Clause-②: yes (widening) / Metadata-protocol: added at line start in the body and the changeset (spec minor). Supersedes feat(spec): push channel protocol face — device registration widening, device contract members, push route, APNs/FCM config by reference #29101, which the seat closed (6573982554). Within claim 6572317452 as amended: push-device.zod.ts added, route rows deferred to notifications: build the device registry (sys_user_device, /notifications/devices routes, client methods) on #29067's contract (server slice 1 of #29041) #29068. Not governed.
  2. Rounds: the seat took the card over from server seat 5. The dev resumed 088613be, merged main, and added the card's remaining asks: UserDeviceSchema with rules (a) user-owned, (b) one live owner per token, (c) a dead token keeps its row disabled; INotificationService.listDevices; APNs/FCM secrets keyed credentialRef (ADR-0143 D3); ADR-0049 [PLANNED — not yet served|read] markers on every device, delivery and transport schema, with tracker ids only in // comments. Seat round: both new locale members now take LocaleSchema (refuses zh_CN, Chinese, padded, empty; admits en, zh-CN, zh-Hans-CN, es-419). Ablation: 7 of 40 red, then restored.
  3. Open questions, seat's calls: Q1 = A. There are no route rows here, because the house has no planned channel for a route row (handlerStatus 'planned' retired under ADR-0049, and the request-schema gate reds an unserved body route). The rows ride notifications: build the device registry (sys_user_device, /notifications/devices routes, client methods) on #29067's contract (server slice 1 of #29041) #29068 with their handlers. Q3 = A: vendor and deviceId stay optional, a pure widening. Q4 = A: the ownership rules as stated. Q2, the new public names, is with the maintainer (decision card).
  4. Readings (dev, on 833e9bd7): spec 794 files / 23434 tests, typecheck 0; service-messaging 62 files / 759 tests; runtime notification conformance and request-schema-gate 25 tests; client typecheck 0; scoped eslint 0; 103/103 gates, 0 NOT-MEASURED; offline --event 0. Check-runs at this reading: 57 success, 10 skipped, 0 failed, with PR-body guards and Check Changeset re-queued by the body edit. The 09:42Z PR-body red was feat(spec): push channel protocol face — device registration widening, device contract members, push route, APNs/FCM config by reference #29101 still being open (duplicate-fix guard). It cleared on the push after the seat closed it.
  5. PR body: the dev patched it via issue_patch at this head (signature line 1, read back identical). The card's os-dev-report describes 73fabdf2; this record and the body carry 833e9bd7.
  6. Handoff slices (server seat 5 is told on merge): notifications: build the device registry (sys_user_device, /notifications/devices routes, client methods) on #29067's contract (server slice 1 of #29041) #29068 mounts the device routes and their rows, implements the four members and sys_user_device per (a)(b)(c), and drops the markers. Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041 builds the push channel's delivery and invalidateDeviceToken path, plus the config door.
  7. Landing: the maintainer approves the names, plus one CONTRACT_REVIEW_TIER review, which the seat runs itself (claim line below follows). Then the seat readies and queues.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract-review-claim: session_01ADHkmPi25XXKx92Xmg4viS · head 833e9bd · 2026-11-02T11:08Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 833e9bd7ba11aa8cef7dd50872e23ae981741c1d
Local-runs: none
Scope: packages/spec/src/api/index.ts, packages/spec/src/api/protocol.zod.ts, packages/spec/src/api/push-delivery.zod.ts, packages/spec/src/contracts/notification-service.ts, packages/spec/src/system/index.ts, packages/spec/src/system/push-config.zod.ts, packages/spec/src/system/push-device.zod.ts + docs/adr/0012-notification-platform.md (§6, §8, §10, §11, §12), docs/adr/0143-outbound-connector-auth-is-open-tier.md (D3), docs/adr/0097-declarative-connector-instances.md (§3, credentialRef), docs/adr/0049-no-unenforced-security-properties.md, packages/spec/src/api/plugin-rest-api.zod.ts (DEFAULT_NOTIFICATION_ROUTES, HANDLER_STATUS_RETIRED), packages/spec/src/api/plugin-rest-api.test.ts (the toHaveLength(3) / not.toContain('/devices') pin), packages/runtime/src/request-schema-gate.conformance.test.ts, packages/services/service-messaging/src/messaging-service.ts (actionUrlFor), packages/services/service-automation/src/builtin/notify-node.ts (sourceObject / sourceId / actionUrl), packages/spec/src/shared/connector-auth.zod.ts (the house credentialRef members), packages/spec/src/shared/strict-object.ts, packages/spec/src/system/translation.zod.ts (LocaleSchema), packages/spec/src/system/email-config.zod.ts, packages/spec/src/system/notification.zod.ts (NotificationChannelSchema), packages/client/src/index.ts (RegisterDeviceRequest re-export), ui/apps/console/src/pages/developer/hooks/useApiDiscovery.ts (the /devices body template), the PR's five test files, packages/spec/src/type-alias-convention.pin.test.ts and .changeset/29067-push-device-registry-spec.md

Read-only round: the net diff against main at the merge base 2af0ae2a, card #29067's body and all six comments, PR #29218's body and file list, and the check-runs on 833e9bd7 (every required check success; the in_progress row is a second Check Changeset run beside two success runs of the same name; the skipped rows are opt-in or path-filtered legs). Closed #29101 read for history only. The dispatching seat's own ACCEPT is not an input.

① Derived judgments

  1. RegisterDeviceRequestSchema accept set widens, nothing else moves — right. Three new optional members (vendor, appId, locale); token, platform, deviceId, name are byte-identical in shape. The two refusals the widening adds (vendor outside apns / fcm; locale not a BCP 47 tag) sit on new keys no body sent before carries, so every body that parsed on main parses here (pinned by register-device-request.test.ts). The object stays a non-strict z.object, as its siblings in the protocol-method block are, so unknown keys are stripped exactly as before.
  2. RegisterDeviceResponseSchema, UnregisterDeviceRequestSchema, UnregisterDeviceResponseSchema: description only — right. Shape unchanged; pinned.
  3. Seven new public schemas plus bare aliases, no removal, no rename — right, additive. PushRouteSchema / PushDeliveryDataSchema (api), PushVendorSchema / ApnsTransportConfigSchema / FcmTransportConfigSchema / PushServiceConfigSchema / UserDeviceSchema (system), each with a z.input alias and an isomorphic pin (811 → 818, +6 then +1, arithmetic checks against the modules). Both barrels re-export with export *; the consumer type-check gates are green, so no name collides. No ADR-0087 tombstone, conversion or semantic entry is owed: no published field is removed or renamed.
  4. INotificationService gains four OPTIONAL members — right. An interface implementers fill: optional members keep every existing implementation valid (pinned by the send-only case in notification-service.test.ts); a caller must test for absence, which the docblock states.
  5. DEFAULT_NOTIFICATION_ROUTES unchanged; Wanted 2 not built — right. Read in the live tree: HANDLER_STATUS_RETIRED rules "an endpoint that has no handler yet is simply not registered"; the table's own comment records the device rows' earlier removal for exactly the declared-not-served defect; and request-schema-gate.conformance.test.ts iterates DEFAULT_NOTIFICATION_ROUTES and reds any POST/PUT/PATCH row with a requestSchema and no violating-body case, a case that cannot be written until the route answers 400 instead of 404. Adding the rows here would reintroduce the recorded defect and red a runtime gate. The catalog pin (toHaveLength(3), not.toContain('/devices')) stays as it is on main.
  6. PushServiceConfigSchema is exported but not wired into StackSchema.config — right. The card asked for it where email transport selection lives, or why not; the header says why (no reader yet; a key is never authorable before something honours it). The authorable-surface generator enumerates exported object schemas, so the surface baseline grew by the new keys anyway, which is what the changeset's Metadata-protocol: added answers to.
  7. UserDeviceSchema lives in system/push-device.zod.ts, not ADR-0012 §10's notification/device.zod.ts — right. packages/spec/src/notification/ does not exist on main; system/notification.zod.ts is the realized home of the notification vocabulary, and email-config.zod.ts / sms-config.zod.ts sit in system/. The narrowing from §6 (vendor pair, three platforms, camelCase, no .default(true) on enabled) is stated in the header; §6 routes HMS / Xiaomi / OPPO / vivo to alternate channel plugins, so the closed pair is the ADR's own reading.
  8. PushRouteSchema admits exactly a same-server relative path — right, and it held against the attempts in this round. Tried: //host, /\host, / + tab + /host, / + newline, javascript:, https:, a relative path, the empty string, a path with \..\ — all refused by the regex (^\/(?![/\\])[^\s\\\x00-\x1f\x7f]*$); /opportunity/123?tab=notes#activity and / are admitted. %5C is a path byte to a URL parser, not a backslash, so it is correctly admitted. The derivation claim checks against the tree: actionUrlFor in messaging-service.ts answers payload.url / payload.actionUrl, else /{object}/{id} from the emit's source, else undefined, which is the three-way rule the header states. No notify key is added.

② Semver level

.changeset/29067-push-device-registry-spec.md declares @objectstack/spec: minor with Clause-②: yes (widening) and Metadata-protocol: added. The diff publishes new exports, a widened request and four optional interface members, and removes nothing: minor is the level the diff publishes. Clause-②: yes (widening) matches ① items 1–4. Metadata-protocol: added matches the authorable-surface growth in ① item 6 and the seat's amended claim (the original server-seat claim's none was superseded by the spec-seat claim 6572317452). The PR body carries the same two lines; the Check Changeset and PR-body-guard check-runs on this head are green.

③ Boundary flags

Dev flags, from os-dev-report 6573960254 (head 73fabdf2; 833e9bd7 adds the LocaleSchema commit on the seat's FAIL round) and the PR body:

Nothing is escalated to the maintainer beyond the name approval the PR already requests.

④ Design judgment

Each hits file against the ruling or ADR it implements and its first consumer (#29068 the device registry, #29041 the push channel). No must-fix. Six should-fix items, none of which changes the verdict.

packages/spec/src/api/index.ts, packages/spec/src/system/index.ts — barrel lines only. Import graph checked: api/protocol → system/push-config and system/translation; system/push-device → system/push-config, system/translation; contracts/notification-service → type-only imports of api/protocol and system/push-device. No cycle; api importing system has precedent in the same file (ServedTranslationDataSchema). Holds.

packages/spec/src/api/protocol.zod.ts — the widening holds (① item 1). Two findings on what the describes promise:

packages/spec/src/api/push-delivery.zod.ts — holds (① item 8). The route is derived from the one value the inbox already serves, so no authorable key is added and the notify node's actionUrl (any string, including an absolute URL) is dropped at the push rather than failing the delivery, as the header states. PushRouteSchema is a plain z.string() rather than a lazySchema, as PushVendorSchema is; the pin test and the generators accept both, so this is a style note, not a finding.

packages/spec/src/contracts/notification-service.ts — the four members hold against ADR-0012 §6 / §12 (register-or-refresh, one live owner, invalidate on BadDeviceToken / Unregistered / UNREGISTERED, disabled rows kept). Importing z.input types from the zod modules has precedent (analytics-service.ts, approval-service.ts).

  • should-fix 3 — unregisterDevice's not-found mechanism is implicit. The docblock says a foreign or unknown deviceId "answers not found (NOT_FOUND, 404 at the device route), never a success", but the return type is UnregisterDeviceResponse = { success: boolean }, so the only way to honour the sentence is to throw; the toy registry in the test throws an Error with code: 'NOT_FOUND', the contract text does not say so, and success becomes a member that is always true. State the mechanism (throw an error carrying code: 'NOT_FOUND', as the test does and as the dispatcher's error exit maps) so notifications: build the device registry (sys_user_device, /notifications/devices routes, client methods) on #29067's contract (server slice 1 of #29041) #29068 and the test agree with the contract rather than with each other.

packages/spec/src/system/push-config.zod.ts — holds against ADR-0143 D3: both secrets are credentialRef members, the inline spellings an operator would reach for are refused by name with credentialRef as the prescription, keyRef / serviceAccountRef / bundleId are aliases, all three blocks are strictObject with the house "strict from birth" history, and the near-miss and content refusals are pinned. Placement beside email-config.zod.ts with no stack key is explained in the header and is the right reading of "or say why not". The email sibling keeps its secrets environment-only and refused by name; push takes the newer ADR-0143 D3 posture the card ruled, so the two transport files in one directory carry two secret postures, which the header could name in one clause.

  • should-fix 4 — a second definition of what a credentialRef may look like. secretReference() refuses whitespace and braces; every house credentialRef (connector-auth.zod.ts lines 214, 233, 253, 337) is z.string().min(1). The refusal is sound (a PEM block and a JSON document cannot be written without them) and the admitted forms (OS_PUSH_APNS_KEY, push/apns-signing-key, vault:push.apns.p8) cover the resolver chain, but the shape of a reference is now decided in two places. Lift it to one shared CredentialRefSchema in shared/connector-auth.zod.ts and have both surfaces use it, or state in this header why push's reference is stricter than a connector's.
  • Hand-off note, not a finding: the reader (Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041) needs a CredentialResolver reachable from the push channel plugin; ADR-0097's lives in service-automation. Name it in that slice.

packages/spec/src/system/push-device.zod.ts — rules (a)/(b)/(c) are stated in the header and in the describes, the no-organization rule is pinned, and the ISO-instant members use z.string().datetime() like the rest of system/. Requiring enabled with no default keeps the alias isomorphic; right for a stored shape.

  • should-fix 5 — the platform vocabulary is now two literal copies. z.enum(['ios', 'android', 'web']) is written in protocol.zod.ts and again here. ADR-0012 §6 also lists macos and windows; the day one is added, two edits are owed and the registration and the stored row can disagree. One DevicePlatformSchema (in push-device.zod.ts, imported by protocol.zod.ts; the import graph above admits it) is the one-source rule the Prime Directives state for a vocabulary.
  • should-fix 6 — two cross-member rules are prose only. The header says every registration sets lastSeenAt and listDevices orders by it, yet lastSeenAt is optional; and invalidatedAt is "absent while the device is enabled", yet nothing ties it to enabled: false. Either state when each may be absent, or express them (a .superRefine keeps the alias isomorphic). Small, and the first storer (notifications: build the device registry (sys_user_device, /notifications/devices routes, client methods) on #29067's contract (server slice 1 of #29041) #29068) is where it bites.

The [PLANNED — not yet served] / [PLANNED — not yet read] markers are the house [PLANNED — not yet rendered] family (ui/app.zod.ts, ui/view.zod.ts, ui/component.zod.ts); each is pinned by a test that must be flipped when the producer lands, and the tracker ids stay in // comments. The dev's and the seat's reading that this is the channel for a declared-ahead schema holds against the tree.

Implemented-by: claude/project-thread-cjde2j
Reviewed-by: session_01ADHkmPi25XXKx92Xmg4viS

VERDICT: PASS

- RegisterDeviceRequestSchema.vendor and INotificationService.registerDevice
  now say the same thing: web has no default vendor, a web registration
  names apns or fcm, and one without vendor is refused.
- unregisterDevice states its mechanism: a device the user does not hold
  throws an error whose code is NOT_FOUND, which the route answers as 404.
- A comment says why the push secretReference is stricter than the house
  credentialRef: the two push secrets are recognisable by shape (PEM block,
  JSON document), a bearer token or API key is not.
- One platform list for the registration and the stored device, in an
  internal module no barrel re-exports, so no new public name.
- UserDeviceSchema describes say when lastSeenAt and invalidatedAt are absent.
- The changeset names LocaleSchema for the registration's locale.

Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5
Co-authored-by: Claude <noreply@anthropic.com>

hotlong commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Consumer acceptance (mobile shell + Console), head e7b06bc4

Verdict: rework. Both must-fixes are small edits to the contract text and its tests. Each one decides whether a tap opens the right screen. The declarations, changeset, generated artifacts and check-runs are in order.

Must-fix

1. route is declared as the raw actionUrl, which is app-relative, not a Console route.

  • The schema header (push-delivery.zod.ts:19-26) defines route as the notification's actionUrl, else /OBJECT/ID. push-delivery.test.ts:22-23 pins /opportunity/123 as an accepted "derived record route".
  • The Console states that this value is app-relative, and that navigating it verbatim is the bug objectui#5179 fixed (ui/packages/app-shell/src/utils/appRoute.ts:328-353). It resolves the value through resolveNotificationTarget to /apps/<app>/<object>/<id>.
  • The shell has no app context. It opens <server>/_console<route> verbatim (objectstack-ai/mobile lib/shell/console-url.ts). So every push without an explicit /apps/… url would land on the app home page, not the record.
  • The framework moves.

2. Where PushDeliveryData goes on each transport is undeclared, and the one hint points the wrong way for APNs.

  • The header says it is "the data member of the push channel's rendered body". A channel author following that on APNs emits { aps, data: { route } }.
  • The shell on iOS never reads userInfo.data. It reads content.data, the top-level keys beside aps, and userInfo.body (mobile lib/push-notifications.ts routeFromResponse; contract in mobile docs/PUSH-NOTIFICATIONS.md).
  • Add one paragraph:
    • on FCM, the members are the message.data map (string values, and no JSON string under data.body);
    • on APNs, they sit at the top level of the payload beside aps (or inside a body dictionary), never under a data key.

Should-fix

3. PushRouteSchema admits routes the shell refuses.

4. One token can produce two live rows for the same user, which means duplicate pushes.

  • Rule (b) covers only a second user (push-device.zod.ts:30-34).
  • The bridge exposes no install id. After the WebView's storage is cleared, the Console re-registers the same token without a deviceId, the server mints a new one, and (userId, deviceId) yields two rows.
  • State that a token this user already holds under another deviceId refreshes that row: one live row per token, as the token describe already claims.
  • The mobile side will expose a stable install id (card below).

5. A web registration without vendor is refused in prose only. The Console's API-discovery template { token: '', platform: 'web' } (useApiDiscovery.ts:153) will be refused once #29068 serves the route. This is already on #29068's hand-off list; keep it there.

Checks that pass

  • Parity with the shell. Only token and platform are required. The shell supplies {token, vendor: 'apns'|'fcm'}, and the bridge supplies platform. The server's vendor derivation matches the shell's.
  • Vendor enum. The closed apns | fcm set is consistent with ADR-0012's non-goals. web_push will need a widening with Console as an installable PWA on desktop: register the Service Worker for the app shell, and prepare a web_push delivery path #29155.
  • Declared ≠ enforced. Every new schema carries [PLANNED — …], pinned by a test, and no inert stack key was added.
  • Declarations. The PR body and the changeset agree: Clause-②: yes (widening), Metadata-protocol: added, level minor.
  • CI. 57 checks pass and 10 are skipped by path, with no failures.

Mobile follow-ups are filed separately: a stable install id on the bridge, a pushTokenChanged event, and accepting userInfo.data as a fallback.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract-review-claim: session_01ADHkmPi25XXKx92Xmg4viS · head e7b06bc · 2026-11-02T12:11Z

… refusals

Delta contract review round 3 on the push device registry spec.

- PushDeliveryData.route is the notification's actionUrl, the same value
  the inbox serves: app-relative (/OBJECT/ID) or already /apps/... The
  describes and the docblock say the shell resolves it on tap exactly as
  the bell and Home do (resolveNotificationTarget with its host app
  segment) and never navigates it verbatim.
- The docblock says where the data sits per vendor: FCM message.data,
  APNs top level beside aps, never under a data key.
- PushRouteSchema refuses dot segments and percent-encoded /, \ and . in
  its path, as a pattern the published JSON Schema carries too.
- The device docblock and registerDevice say a token the user already
  holds under another deviceId refreshes that row, never a second live
  row.
- The changeset follows.

Claude-Session: https://claude.ai/code/session_01ADHkmT6K7dHh79Lej3sWr5
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e7b06bc44093d85fc37cee9415f1f08f396b5321
Local-runs: none
Scope: packages/spec/src/api/index.ts, packages/spec/src/api/protocol.zod.ts, packages/spec/src/api/push-delivery.zod.ts, packages/spec/src/contracts/notification-service.ts, packages/spec/src/system/index.ts, packages/spec/src/system/push-config.zod.ts, packages/spec/src/system/push-device-platform.ts, packages/spec/src/system/push-device.zod.ts + docs/adr/0012-notification-platform.md (§6, §8, §11, §12), docs/adr/0143-outbound-connector-auth-is-open-tier.md (D3), docs/adr/0097-declarative-connector-instances.md (§3), docs/adr/0049-no-unenforced-security-properties.md, packages/spec/src/api/plugin-rest-api.zod.ts (DEFAULT_NOTIFICATION_ROUTES, HANDLER_STATUS_RETIRED), packages/runtime/src/request-schema-gate.conformance.test.ts, packages/services/service-messaging/src/messaging-service.ts (actionUrlFor) and its tests (/showcase_task/t_42, /opportunities/42), packages/services/service-automation/src/builtin/notify-node.ts (actionUrl, sourceObject / sourceId), ui/packages/app-shell/src/utils/appRoute.ts (resolveNotificationTarget, the objectui#5179 contract), ui/packages/app-shell/src/layout/openInboxNotification.ts, ui/apps/console/src/pages/developer/hooks/useApiDiscovery.ts, packages/spec/src/shared/connector-auth.zod.ts, packages/spec/src/system/translation.zod.ts (LocaleSchema), the PR's six test files, packages/spec/src/type-alias-convention.pin.test.ts, .changeset/29067-push-device-registry-spec.md, content/docs/references/api/protocol.mdx and content/docs/references/system/push-device.mdx (regenerated)

Delta review on the previous record (comment 6575836138, head 833e9bd7, PASS with six should-fixes). Read this round: git diff 833e9bd7..e7b06bc4 (one commit, 10 files: 7 under packages/spec/src, the changeset, two regenerated reference pages), the whole tree at e7b06bc4 for every hits file, the check-runs on e7b06bc4 (57 success, 0 failure; the rest skipped by path or opt-in), the seat's claim 6576818622 naming this head, and the consumer-acceptance comment 6576790204 on the PR (hotlong, 2026-11-02T12:10Z), whose claims were read as data and judged against the live tree, not adopted. No new card comments since os-dev-report 6573960254. The dispatching seat's own conclusions are not an input.

① Derived judgments

  1. Accept sets unchanged by the delta — right. RegisterDeviceRequestSchema.platform and UserDeviceSchema.platform now build their z.enum from one internal PUSH_DEVICE_PLATFORMS tuple; the options are the same three values (pinned equal in push-device.test.ts), so nothing parses differently. Every other hunk is a describe, a JSDoc, a // comment or a test. ① items 1–8 of the previous record stand.
  2. No new public name — right. system/push-device-platform.ts is re-exported by no barrel (system/index.ts adds only push-config.zod and push-device.zod; the root barrel names neither), so the api-surface, export-origins and declaration-map shards did not move, and the type-check and generated-artifact gates on this head are green.
  3. Wire describes changed, docs regenerated — right. The vendor describe and the two timestamp describes changed, and exactly protocol.mdx and push-device.mdx moved with them; no hand edit under content/docs/references/.
  4. The route contract is unchanged by the delta and is judged wrong on this head — ④ must-fix 1. Not a new fact of the diff: push-delivery.zod.ts is byte-identical to 833e9bd7. The previous record verified the producer (actionUrlFor) and not the consumer; this round read the Console's consumer and reverses that item.

② Semver level

Unchanged: @objectstack/spec: minor, Clause-②: yes (widening), Metadata-protocol: added, in the changeset and the PR body alike; the changeset now also names LocaleSchema on locale, matching the tree. The delta adds no export, removes nothing and changes no accept set, so minor still matches what the diff publishes. Check Changeset and the PR-body guards on e7b06bc4 are green.

③ Boundary flags

The six should-fixes of the previous record, each against the delta:

  • (1) web-vendor sentence — applied. vendor's describe, registerDevice's JSDoc, the changeset and the PR body now say one thing: web has no default vendor, a web registration names apns or fcm, one without vendor is refused. register-device-request.test.ts admits web with either vendor.
  • (2) the refusal is still prose only — carried, by design. The new test's comment places the refusal with the route (notifications: build the device registry (sys_user_device, /notifications/devices routes, client methods) on #29067's contract (server slice 1 of #29041) #29068); the PR body's hand-off prose does not name it. Still should-fix 5 below, unchanged in weight.
  • (3) unregisterDevice not-found mechanism — applied. Resolves { success: true } on removal; a device the user does not hold throws an error whose code is NOT_FOUND, which the route answers as 404. The toy registry in notification-service.test.ts already did exactly this.
  • (4) stricter credential shape — kept, with its reason. The // comment states why a push reference can be told from a pasted secret where a connector's cannot (a .p8 is a PEM block, a service account is a JSON document, a house name carries neither), and names the widening path. Accepted.
  • (5) one platform vocabulary — applied. One tuple, two enums, a test holding them equal; no public name added.
  • (6) the two timestamp rules — applied as prose. invalidatedAt is "present exactly while enabled is false"; lastSeenAt is "absent only on a row no registration wrote", and listDevices orders such a device last. Accepted (the previous record offered state-or-express).

Dev flags from os-dev-report 6573960254 (open_questions 1–4, Wanted 2 and Wanted 7 hand-offs, Skills-docs: none): answered in the previous record; nothing in the delta reopens them. The useApiDiscovery.ts { token: '', platform: 'web' } template stays a #29068 / #29042 item.

Escalations: must-fix 1 below is for the dev; no maintainer ruling is needed for it (the Console's own code decides what action_url is). The name approval the PR requests is unchanged.

④ Design judgment

Each hits file at e7b06bc4 against the code it cites and its first consumers (#29068 the device registry, #29041 the push channel, the mobile shell and the Console as the readers of PushDeliveryData). One must-fix; the verdict is FAIL.

packages/spec/src/api/push-delivery.zod.ts — unchanged in the delta, re-read against its consumer this round.

  • must-fix 1 — route is declared as a Console path but defined as the raw actionUrl, which the Console itself says is app-relative and must not be navigated verbatim. The header defines route as "the Console path the shell opens" and derives it from actionUrlFor: an explicit payload.url / payload.actionUrl, else /{object}/{id} from the source record; push-delivery.test.ts pins /opportunity/123 as the accepted "derived record route". Read in the live tree, ui/packages/app-shell/src/utils/appRoute.ts (resolveNotificationTarget, objectui#5179) states the opposite contract for the same value: "action_url is app-relative, and the producer says so in as many words … the field names a RECORD, not a console route", and hosts everything that is not already /apps/… under the current app segment, because navigating it verbatim sends the user to the console's catch-all and from there to the default app's landing page with mark-as-read already fired. The push shell has no app context to host it under, so every push whose notification carries no explicit /apps/… url (every sourceObject / sourceId notify node, and every author-written /crm_case/{{ record.id }} as the notify node's own example) opens the landing page, not the record — the objectui#5179 defect reproduced on the push face, written into the contract. The schema's regex is not the fault; the derivation rule and the fixture are. The cure is in this file: (i) state that route is Console-absolute — already /apps/…, or a root Console route; (ii) state that the server hosts an app-relative actionUrl before it becomes route (pass /apps/… through; host /{object}/{id} under the emit's app when one is known, else the deployment's default app; otherwise omit route), the server-side analogue of resolveNotificationTarget, which Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041 then implements; (iii) replace the /opportunity/123 fixture with a hosted route and pin that an unhosted /{object}/{id} is not what the wire carries. The consumer-acceptance comment names the same defect; it is confirmed here against appRoute.ts, not taken on its word.
  • should-fix 1 — where PushDeliveryData sits on each transport is undeclared. The header cites ADR-0012 §8's data member of the rendered push body, which is the renderer's shape, not the vendor payload. The spec is the only contract the channel (Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041, this repo) and the shell (another repo) share, so one paragraph belongs here: on FCM the members are the message.data string map; on APNs they sit beside aps at the top level of the payload (or under the dictionary the shell reads), never under a data key. The schema does not change.
  • should-fix 2 — PushRouteSchema admits paths that escape the Console mount. /../../api/v1/me, /%2e%2e/x, /./x, /a%2fb and /a%5cb pass the regex: each is a same-server path, so the schema's stated refusal holds, but a shell that opens the route under a Console prefix has the dot segments resolved out of that prefix, and the shell's own route guard refuses all five, so the server would send a route the shell drops. Refuse . and .. segments and percent-encoded /, \ and ., and pin the five cases; the rule is one .refine and stays isomorphic.

packages/spec/src/system/push-device.zod.ts — the delta's describe changes hold (③ items 5 and 6); PUSH_DEVICE_PLATFORMS closes the two-copies finding.

  • should-fix 3 — rule (b) covers a second user only; the same user can hold one token under two live rows. A Console whose storage was cleared re-registers the same token without a deviceId; the server mints a new one, and identity (userId, deviceId) yields two live rows for one token, so one push is delivered twice. The token describe already says "held by one live device at a time"; the header's (b) and registerDevice's JSDoc say only that another user's row moves. State that a token this user already holds under another deviceId refreshes that row (one live row per token, whoever held it), in the header, the (b) rule and the JSDoc.

packages/spec/src/system/push-device-platform.ts — new, internal. One as const tuple with a header naming its two consumers and that no barrel exports it; z.enum(PUSH_DEVICE_PLATFORMS) is the house way to build an enum from a tuple, and the equality test holds the two enums together. Holds.

packages/spec/src/api/protocol.zod.ts — the vendor describe now agrees with the service contract (③ item 1) and the platform enum reads the shared tuple. The import of ../system/push-device-platform adds no cycle (push-device-platform imports nothing).

packages/spec/src/contracts/notification-service.ts — unregisterDevice now states its mechanism (③ item 3); listDevices states the order of a row with no lastSeenAt. The registerDevice JSDoc needs the same-user clause of should-fix 3; otherwise holds against ADR-0012 §6 / §12.

packages/spec/src/system/push-config.zod.ts — one // comment added (③ item 4). Holds against ADR-0143 D3 as before.

packages/spec/src/api/index.ts, packages/spec/src/system/index.ts — unchanged in the delta; the internal module is deliberately not re-exported. Holds.

The [PLANNED — not yet served] / [PLANNED — not yet read] markers stand on every schema, each pinned. The must-fix is a contract sentence and a fixture, not a shape: fixing it changes no accept set and no declaration.

Implemented-by: claude/project-thread-cjde2j
Reviewed-by: session_01ADHkmPi25XXKx92Xmg4viS

VERDICT: FAIL

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract-review-claim: session_01ADHkmPi25XXKx92Xmg4viS · head eb9b237 · 2026-11-02T13:09Z

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 18:08
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 18:08
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit e9fb529 Nov 2, 2026
66 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-cjde2j branch November 2, 2026 19:04
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

repo:mobile#1 · os-justin · session_01QSeKYnW1Xf6HDSWz1eGAqv

Cross-lane notice (information, not a request to change this PR). #29642 asks the maintainer to decide whether PushDeliveryDataSchema names the sending server.

Why: the mobile shell now keeps several saved servers, so a tap has to know which one sent it. One of the options adds optional members to RegisterDeviceRequestSchema, UserDeviceSchema and PushDeliveryDataSchema, all of which this PR introduces. The cheapest place for them is here, before merge.

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

Development

Successfully merging this pull request may close these issues.

spec(notification): the push device registry's protocol face and the push payload route (spec slice of #29041)

2 participants