Skip to content

feat(spec): push delivery data names the server that sent it — RegisterDeviceRequest, UserDevice and PushDeliveryData gain an optional origin - #29767

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

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-j4isiq

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

domain:spec#3 · os-support-ai · session_01WGufzeYAsmoHHYA59sCBZa

Requested by Yibo Wang · project thread
Fixes #29642

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: none — no file under skills/** teaches device registration or push delivery data (a git grep -i of skills/ for push, device, notifications/devices and window.location.origin names only unrelated lines: Array.push calls, query push-down, and the viewport and touch-device notes in skills/objectui/guides/mobile.md), so no taught line changes.

维护者速读

  • 改了什么:设备注册请求 RegisterDeviceRequest、设备记录 UserDevice、推送数据 PushDeliveryData 各加一个可选字段 origin,同一条规则:http(s):// + 小写主机 + 可选端口,不带路径。
  • 为什么改:App 里存了多台服务器时,点开 B 服务器发来的推送会在 A 上打开;按裁决 B,由 Console 注册时报上自己的来源,服务器存进设备记录,推送原样带回,App 按它选服务器。
  • 风险与回滚:纯放宽,三个字段都可选,不带它的请求照旧通过;唯一新增的拒收是注册时带了格式不对的 origin(今天没有客户端会发)。服务器暂不存、推送暂不带,设备侧两个字段标了 [PLANNED — not yet stored]。回滚即 revert。
  • 先看:packages/spec/src/system/push-server-origin.ts(共享的正则和拒收文案),再看 push-server-origin.test.ts(三个字段同一组接受/拒收用例)。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/system/push-server-origin.test.ts,预期 68 个测试全过。
  • 需要你做的:无,席位落地。

The ruling

「Ruling: batch #1 item 1 · letter B · maintainer 「同意」 2026-11-03T02:26Z」 (record 6593872191): Option B: "the Console sends the origin it was loaded from in RegisterDeviceRequest. The server stores it on the UserDevice row, and the push channel copies it into PushDeliveryDataSchema. All three are new optional members." #29218 has merged, so this is its own spec PR widening three optional members.

The name: origin

packages/spec already spells a URL origin origin: the action navigate template's ${ctx.origin} (ui/action.zod.ts, the navigate describe, "${ctx.*} (origin/apiBase/user/org/recordId/selection)"; the Console fills it with window.location.origin, ui/packages/app-shell/src/views/metadata-admin/MetadataTypeActions.tsx), and CORS origins (shared/http.zod.ts, values like https://app.example.com). serverOrigin has zero hits in packages/spec/src. The value is exactly window.location.origin, so the one name is origin on all three schemas.

What changes

  • system/push-server-origin.ts (new, internal). PUSH_SERVER_ORIGIN_PATTERN and PUSH_SERVER_ORIGIN_MESSAGE, the one rule the three members share. It follows the system/push-device-platform.ts precedent: no barrel re-exports it, so it adds no public name (api-surface/ is unchanged), and each schema builds its own z.string().regex(...) from it. The pattern accepts http:// or https://, a lowercase host (DNS labels, punycode included, or a bracketed IPv6 address) and an optional :PORT, and nothing after it. That is the serialized form window.location.origin answers, so the shell can compare it with a saved server byte for byte.
  • RegisterDeviceRequestSchema.origin (api/protocol.zod.ts): the Console writes it at registration. Describe opens with [PLANNED — not yet stored].
  • UserDeviceSchema.origin (system/push-device.zod.ts): the registration's value, kept on the row. Same marker.
  • PushDeliveryDataSchema.origin (api/push-delivery.zod.ts): the push channel copies it from the device; the shell opens the tap on the saved server with that origin and resolves route there. The schema-level [PLANNED — not yet served] marker stays, and its describe now names the origin. The file's docblock gains a "Which server sent it" paragraph.
  • Generated: authorable-surface/api.json (+api/PushDeliveryData:origin, +api/RegisterDeviceRequest:origin), authorable-surface/system.json (+system/UserDevice:origin), and the three content/docs/references/** pages (api/protocol.mdx, api/push-delivery.mdx, system/push-device.mdx), written by gen:docs.
  • Changeset: .changeset/29642-push-server-origin.md, @objectstack/spec minor, carrying both declaration lines.

Tests

packages/spec/src/system/push-server-origin.test.ts, 68 cases, describe.each over the three carriers so each member is held to the same rule:

  • optional: the body without origin parses unchanged (no origin key appears);
  • five valid origins round-trip: https://crm.example.com, http://localhost:3000, a punycode host with a port, an IPv4 host with a port, http://[::1]:3000;
  • sixteen refusals, each answering exactly one issue at path ['origin'] with code invalid_format and the one shared message: a trailing /, a path, a query, a fragment; ftp:, wss:, javascript:; a bare host, a bare host with a port, a protocol-relative //host; an upper-case scheme, an upper-case host, user info, an empty port, a leading space, the empty string;
  • one contract: all three JSON Schemas publish the same pattern and none lists origin as required;
  • the markers: both device-side members open with [PLANNED — not yet stored], and the push data keeps its schema-level [PLANNED — not yet served].

Ablation (fix committed first, scripts/ablation-replace.mjs on the pattern line of push-server-origin.ts, replaced with /[\s\S]*/;): Tests 48 failed | 20 passed (68). Every refusal case went red on all three carriers (16 × 3); the optional, valid and contract cases stayed green. Restored: blob equals HEAD (8c3209e4), git diff HEAD empty. The suite imports the schemas from source, so no dist/ was involved.

Acceptance notes

  • Metadata-protocol: added, not none. All three schemas are listed on packages/spec/authorable-surface/, and adding the members moved two shards. check-changeset-no-major refuses none over a moved authorable-surface shard, so the kind is added and @objectstack/spec takes minor. The claim's none line needs amending to match.
  • No liveness rows. None of the three schemas is a governed metadata type (packages/spec/liveness/ has no row for any of them), and check:liveness is green with no change.
  • Iso_ pins hold unchanged. Iso_api_protocol__RegisterDeviceRequestSchema, Iso_api_pushDelivery__PushDeliveryDataSchema and Iso_system_pushDevice__UserDeviceSchema in type-alias-convention.pin.test.ts still assert input equals output: an optional string with no default or transform keeps them equal. No new export, so no new pin.
  • No refinement. The rule is a .regex(), which the JSON Schema projects, so dropped-refinements.baseline.json does not move.
  • The one new refusal. From this release, POST /api/v1/notifications/devices (packages/runtime/src/domains/notifications.ts, which safeParses the body with RegisterDeviceRequestSchema) refuses a registration whose origin is malformed. Before, an unknown origin key was stripped. No client sends one today: git grep for a registerDevice caller under ui/ finds none.
  • The producers are not in this PR (named for the domain:server follow-up):
  • packages/client registerDevice(request: RegisterDeviceRequest) widens with the type and needs no edit.

Verification

All on head 2aa2ae0a7 (base 9b4fa6cc; origin/main has since moved to 3eeb81d19, and none of its changed files is in this diff — the one shared directory, packages/spec/authorable-surface/, moved only data.json there — so no merge was made).

Package readings
  • @objectstack/spec under scripts/pm/os-verify-lock.sh: test exit 0 (Test Files 804 passed (804), Tests 23860 passed, 1 todo); typecheck exit 0 (includes check:test-typecheck, which reads type-alias-convention.pin.test.ts and the new test).
  • check:generated (after build): 14 of 14 artifacts current. The first run named content/docs/references/** stale, regenerated with gen:docs, and a type error in the new test, fixed in the test (no ledger entry).
  • Spec consumers, public surface changed: pnpm --filter @objectstack/service-messaging --filter @objectstack/runtime --filter @objectstack/client run typecheck exit 0; @objectstack/service-messaging test exit 0 (65 files, 787 tests). Lock verdict command-exit 0.
  • test:repo (the vitest repo project) not run locally: the diff edits none of its tests and the derived list does not name it; CI Test Core runs it.
  • Full pnpm build (117 of 117 tasks, exit 0) for the built-output gates below.
Derived gates
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 102 families, each run and recorded as CMD :: exit N. --ran reconciles: "102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN".
  • 95 exited 0 on the first serial pass. Seven exited 3 (prerequisite not met, nothing measured): check-plugin-teardown-shape --self-test (shallow clone; its pinned fixture commit fetched), and check:doc-formula-expressions, check:doc-security-posture, check:skill-examples, check:docs-transcript-drift, check:dual-build-cjs-loads, check:lean-entry-closure (no full build). After the fetch and the full build, all seven exit 0.
  • Local-only families: check:dev-prereqs exit 0, check:skill-frame-freshness exit 0.
  • check-changeset-no-major with this body as the pull_request event: exit 0 ("LEVEL AXIS: this PR declares clause-② yes (widening) and metadata-protocol added, and no package whose published source … it moves is graded patch"). Control: the same body with Metadata-protocol: none exits 1.

Generated by Claude Code

…erDeviceRequest, UserDevice and PushDeliveryData gain an optional origin

The Console sends the origin it was loaded from when it registers a device;
the device row keeps it and the push data carries it back, so a mobile shell
with several saved servers opens a tap on the right one. One rule for all
three members: an http(s) origin, lowercase host, optional port, no path.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzeYAsmoHHYA59sCBZa
… the push origin members; changeset

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzeYAsmoHHYA59sCBZa
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation protocol:system tests tooling labels Nov 3, 2026
@github-actions

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

5 anchor(s) derived from 1 changed package(s); no hand-written page names any of them. ⚠️ 2 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json, packages/spec/authorable-surface/system.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/spec/authorable-surface/api.json, packages/spec/authorable-surface/system.json) — pages documenting those are invisible to this run
  • 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 — 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 cba12d22181588d89fbd45e3a746d5fd356057ec → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 92976c622486c14130b3229e9656175a0a758cad — the merge of head 2aa2ae0a722b39f8cf61eb220e3a263d0e40ffa1 into base cba12d22181588d89fbd45e3a746d5fd356057ec, 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 92976c622486c14130b3229e9656175a0a758cad && git checkout 92976c622486c14130b3229e9656175a0a758cad
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin cba12d22181588d89fbd45e3a746d5fd356057ec 2aa2ae0a722b39f8cf61eb220e3a263d0e40ffa1 && git checkout -B drift-repro cba12d22181588d89fbd45e3a746d5fd356057ec && git merge --no-ff 2aa2ae0a722b39f8cf61eb220e3a263d0e40ffa1

node scripts/docs-audit/affected-docs.mjs --json cba12d22181588d89fbd45e3a746d5fd356057ec

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Contract-review-claim: session_01WGufzfgvGexKn9vLHsy89K · head 2aa2ae0 · 2026-11-03T03:57Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2aa2ae0a722b39f8cf61eb220e3a263d0e40ffa1
Local-runs: none
Scope: packages/spec/src/api/protocol.zod.ts, packages/spec/src/api/push-delivery.zod.ts, packages/spec/src/system/push-device.zod.ts, packages/spec/src/system/push-server-origin.ts + packages/spec/src/system/push-server-origin.test.ts, packages/spec/src/system/push-device-platform.ts, packages/spec/src/shared/lazy-schema.ts, packages/spec/src/ui/action.zod.ts, ui/packages/app-shell/src/views/metadata-admin/MetadataTypeActions.tsx, packages/spec/src/shared/http.zod.ts, packages/runtime/src/domains/notifications.ts, packages/services/service-messaging/src/device-registry.ts, packages/services/service-messaging/src/objects/user-device.object.ts, packages/client/src/index.ts, packages/spec/src/api/package-lifecycle.zod.ts, packages/spec/scripts/liveness/check-liveness.mts, packages/spec/src/type-alias-convention.pin.test.ts, .changeset/29642-push-server-origin.md

Inputs read: card #29642 (body, triage 6591841690, ruling 6593872191, the seat's claims, os-dev-report 6595041258), card #29768, PR #29767 body and file list (11 files, +221 −3), the net diff origin/main...2aa2ae0a (no overlap with main's newer commits), and the check-runs on the head. The objectstack-ai/mobile tree is not readable from this session, so the shell's saved-server shape is taken from the card text alone.

① Derived judgments

  1. RegisterDeviceRequestSchema.origin: z.string().regex(PUSH_SERVER_ORIGIN_PATTERN, { message }).optional(). Right. Optional, no default, no transform, so today's bodies parse byte-identically and the three Iso_ pins stay satisfiable. The one new refusal, a malformed origin, is a located 400 at POST /api/v1/notifications/devices (notifications.ts:214-221): loud, never silent.
  2. UserDeviceSchema.origin: same leaf and optionality. Right. toUserDevice (device-registry.ts:286-303) builds no origin today, so every row the registry answers still parses.
  3. PushDeliveryDataSchema.origin: same leaf; schema-level [PLANNED — not yet served] kept. Right, the same declared-ahead shape as route in this file.
  4. PUSH_SERVER_ORIGIN_PATTERN / PUSH_SERVER_ORIGIN_MESSAGE (new internal module): right as an internal name. No barrel re-exports it (the only importers are the three schema files), mirroring push-device-platform.ts; api-surface/ is untouched.

Grammar read by hand: ^https?://, then dotted labels [a-z0-9](?:[a-z0-9-]*[a-z0-9])? or a bracketed [0-9a-f:.]+, then optional : + 1–5 digits, then end. Refused, rightly: uppercase host, trailing slash, path/query/fragment, userinfo, bare host, scheme-relative, non-http schemes, empty, the opaque "null". Accepted, rightly: DNS names, punycode IDN, localhost, IPv4, bracketed IPv6, single-label hosts, explicit non-default ports. Out of line with window.location.origin (see ④): http://my_svc:3000 and https://example.com. are refused although a browser can answer them; :443 on https, :08080, :99999 and [:::] are accepted although a browser never answers them.

Refusal shape: one .regex(), so one issue at ['origin'], code invalid_format, one shared message, pinned on all three carriers; the three JSON Schemas publish one identical pattern with origin in no required list, so dropped-refinements.baseline.json has no reason to move and did not.

② Semver level

  • .changeset/29642-push-server-origin.md: '@objectstack/spec': minor, Clause-②: yes (widening) and Metadata-protocol: added line-initial; the PR body agrees.
  • Kind measured: authorable-surface rows api/PushDeliveryData:origin, api/RegisterDeviceRequest:origin (api.json) and system/UserDevice:origin (system.json) ⇒ added, not none; the seat's latest claim agrees. AGENTS.md Post-Task Checklist step 3 ⇒ spec minor. Check Changeset success. (widening) is the right arm; no BREAKING banner; no other package publishes (packages/client registerDevice(request: RegisterDeviceRequest) widens with the type).

③ Boundary flags

  • Dev deviations (Metadata-protocol added vs the claim's none; one early run outside the lock; worktree kept): closed; the claim is amended and the readings were retaken under the lock.
  • Server half filed as notifications: store the push device's origin on sys_user_device and copy it into push data (ruling B on #29642, server half) #29768: its build list matches the tree (user-device.object.ts has no origin column; device-registry.ts:183-193 writes none; toUserDevice reads none; notifications.ts:222 already passes parsed.data). Nothing unowned.
  • Console producer rides Console: register the mobile shell's native push token after sign-in, unregister it on sign-out #29042. Carried to Console: register the mobile shell's native push token after sign-in, unregister it on sign-out #29042: in a sandboxed iframe or an about:blank / data: context window.location.origin answers "null", which this grammar refuses, and the refusal is a 400 on the whole registration body. The Console must send origin only when window.location.protocol is http: or https:.
  • [PLANNED — not yet stored] on the two device-side members plus the schema-level [PLANNED — not yet served]: acceptable under the house convention (api/package-lifecycle.zod.ts:42-46, push-delivery.zod.ts, push-config.zod.ts); check-liveness.mts:502-507 recognises the marker, none of the three is a liveness-governed type, Spec property liveness is success, and the test pins the markers so they cannot drop silently.
  • Check-runs at read time: every completed gate-carrying run success; 30 (Build Core, Test Core ×8, Type Check · workspace / consumer gates / UI, Test UI ×16, Build & E2E UI, Live E2E UI) still running; no failure. Landing waits for them.

④ Design judgment

packages/spec/src/system/push-server-origin.ts — holds against ruling B. Every form a deployment actually loads the Console from parses, so no must-fix. Three should-fix items on the same regex, none blocking:

  • should-fix (narrow): labels follow DNS-hostname grammar, narrower than a URL's domain: http://my_svc:3000 (a docker-compose service name in a LAN dev setup) and a trailing-dot FQDN are refused although window.location.origin can answer them, failing the whole registration with a 400. Allow _ in a label, or name the limit in the docblock.
  • should-fix (widen): (?::[0-9]{1,5})? accepts non-canonical ports (:443 on https, :80 on http, leading zeros, out of range) and \[[0-9a-f:.]+\] accepts [:::]; a hand-sent https://crm.example.com:443 would be stored and never match byte for byte. Harmless while the Console is the one producer; tighten or note it.
  • should-fix (prose): the docblock says "a DNS name or a bracketed IPv6 address"; IPv4 is accepted too (and tested), so say so.

packages/spec/src/api/protocol.zod.ts — holds. POST /devices already safeParses with this schema, so the accept set is what the route enforces from this release. Name precedent verified: ui/action.zod.ts:1210 names ctx.origin "(window.origin)", filled with window.location.origin in MetadataTypeActions.tsx:191; CORS spells origins (shared/http.zod.ts:122); serverOrigin has zero hits.

packages/spec/src/system/push-device.zod.ts — holds. The member sits with the registration-mirrored optionals and reads [PLANNED — not yet stored]; the registry is honest with it today by omitting it.

packages/spec/src/api/push-delivery.zod.ts — holds. For objectstack-ai/mobile#174: the shell must compare this value with each saved server's origin (new URL(saved).origin), not the saved URL text, which the docblock ("the saved server whose origin it equals") already says. Two saved servers on one origin that differ only by path are indistinguishable by this member; that is the limit ruling B accepted.

No must-fix. The three should-fix items are carried to #29768, which edits this module when the markers come off.

Implemented-by: claude/project-thread-j4isiq
Reviewed-by: session_01WGufzfgvGexKn9vLHsy89K

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K

维护者速读

席位意见:名字沿用仓内已有的 origin(ctx.origin / CORS origins),三个载体同一规则同一文案,未接线的部分标记诚实;复审的三条 should-fix 都不阻断,已转 #29768;同意落地。

席位核对 — ACCEPT · domain:spec#3 · head 2aa2ae0a722b39f8cf61eb220e3a263d0e40ffa1

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 3, 2026 04:20
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 3, 2026 04:20
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 3, 2026
Merged via the queue into main with commit 5c4e752 Nov 3, 2026
66 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-j4isiq branch November 3, 2026 04:42
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Decision] Push delivery data: name the server that sent a push, so a mobile shell with several saved servers opens a tap on the right one

1 participant