Skip to content

feat(spec): push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server-to-relay wire shape - #29886

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

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-g5fdkn

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread
Fixes #29816

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: none — nothing under skills/** or the hand-written docs names the push transport configuration or the relay (git grep over both, the generated content/docs/references/ excepted, has zero hits); the transport is not authorable until its reader ships.

维护者速读

  • 改了什么:推送配置现在有两种传输方式:默认走我们托管的推送中继(服务器用云绑定凭据认证,配置里不写任何密钥),白标应用才走直连 APNs/FCM;同时定义了服务器发给中继的请求、中继的逐个设备回执,以及 401/429 的错误格式。
  • 为什么改:cloud#3519 裁定商店版 App 的推送证书留在我们手里,所有服务器(私有部署也一样)都经中继推送,中继只看到不含内容的 ID;发送方(Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041)和云端中继需要同一份契约来对齐。
  • 风险与回滚:只加 spec 契约,没有任何读取方或路由,今天写什么都不生效;以前合法的推送配置全部照旧解析(不写 transport 时,带厂商块按直连、空配置按中继)。回滚即还原本 PR。
  • 先看:packages/spec/src/system/push-config.zod.ts(PushServiceConfigSchema 联合与两处混合形态拒收)、packages/spec/src/api/push-relay.zod.ts(载荷只含 ID 与一行通用文案、三种回执、错误信封)、packages/spec/src/system/push-config.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/system/push-config.test.ts src/api/push-relay.test.ts,应看到 2 个文件全部通过(74 个用例)。
  • 需要你做的:无,席位落地。

What changes

PushServiceConfigSchema is a transport discriminated union (packages/spec/src/system/push-config.zod.ts), both members built with strictObject:

  • PushRelayTransportConfigSchema — { transport: 'relay', endpoint? }, the default. The hosted relay holds the store app's APNs and FCM credentials and authenticates the server by its cloud binding credential (cloud ADR-0008, the oscc_… bearer), so the block carries no credential. endpoint is for a staging relay only, an https URL (every request carries the binding); absent means the production relay. No production URL is written into the spec: that address is the cloud's to publish.
  • PushDirectTransportConfigSchema — { transport: 'direct', apns?, fcm? }, today's shape, for a white-label build. The APNs and FCM blocks are unchanged.
  • The two mixed shapes are refused when written:
    • a credential inside a relay block. A vendor block (apns, fcm) or a credential-shaped key (credentialRef, credential, token, apiKey, key, secret) is refused by name: unrecognized_keys at path [], message "Not authorable on the relay push transport. …", pointing at transport: 'direct'.
    • a direct block with no vendor block, so no credentialRef: a custom issue at path [], message "A direct push transport names at least one vendor block (apns or fcm), each with its credentialRef. …". The rule is the projectable requiredOneOf(['apns', 'fcm']), so the published JSON Schema carries it as anyOf required and dropped-refinements does not move. A vendor block missing its credentialRef was already refused at ['apns', 'credentialRef'] and still is.
  • endpoint on a direct block is refused by name, pointing at the relay.
  • A block without transport keeps parsing. Both members default their transport literal, and the union is built with unionFallback: true, so an absent tag is tried as the relay and then as direct: {} reads as the relay, and a block with an apns or fcm block reads as direct. A block that names transport is judged by that member alone, so its refusals carry that member's path and message.
  • Every [PLANNED — not yet read] marker stays, and the two new members carry one.
  • The docblock is rewritten: the hosted relay is the default and direct is for white-label builds. The sentence putting a hosted surface out of scope is gone.
  • PushServiceConfigParsed, PushRelayTransportConfigParsed and PushDirectTransportConfigParsed are declared: parsing fills in transport, so author and parsed shapes differ (ADR-0122). Iso_system_pushConfig__PushServiceConfigSchema leaves the pin file.

New packages/spec/src/api/push-relay.zod.ts, one wire shape for the sender (#29041) and the cloud relay:

  • PushRelayRequestSchema: { deliveries: [{ token, vendor, payload, priority }] }, at least one delivery, strict. The binding credential is the request's bearer; the body carries none.
  • PushRelayPayloadSchema (ID-only, ruling 2A): { data, text }. data is PushDeliveryDataSchema (notification id, same-server route, origin); text is one generic visible line. title, body, subtitle, message and content are refused by name. Opaque to the relay: it puts the payload in the vendor message unchanged.
  • PushRelayPrioritySchema: high or normal. vendor is the existing PushVendorSchema, still apns | fcm.
  • PushRelayResponseSchema: { results: [{ token, status }] }, one result per delivery in request order. PushRelayResultStatusSchema is ok, invalid (the vendor reports the token dead: stop delivering to it) or retry (transient: send again later). A vendor refusal is a result, never an error status.
  • PushRelayErrorResponseSchema: the error for a whole request.

What the error envelope reuses:

  • The house envelope ErrorResponseSchema (api/errors.zod.ts), with error narrowed to PushRelayErrorSchema.
  • That is a union on code of three EnhancedApiErrorSchema.extend(...) members, each using an existing StandardErrorCode. No new error code and no ledger row.
  • 401 is UNAUTHENTICATED, with details.reason set to binding_invalid or binding_revoked.
  • 429 request pacing is RATE_LIMIT_EXCEEDED. It requires retryAfterSeconds, the house rate-limit field that names the wait (the relay sends the same value in Retry-After).
  • 429 quota is QUOTA_EXCEEDED. retryAfterSeconds is optional there.
  • On both 429s the code is the reason. All three require helpText, the house "suggested actions" field, as the next step.

Not changed, per the card: PushVendorSchema stays apns | fcm, and RegisterDeviceRequestSchema gains no pushKey. The ADR-0012 amendment is #29817.

Generated artifacts are regenerated and committed: api-surface/, export-origins/, declaration-map/, json-schema.manifest/, authorable-surface/, authorable-defaults/, the strictness-ledger counts and content/docs/references/ (new api/push-relay.mdx).

Tests

  • packages/spec/src/system/push-config.test.ts pins:
    • the default: {} parses to { transport: 'relay' };
    • the relay with and without a staging endpoint, and the refusal of a non-https one;
    • direct with both vendors or either one alone;
    • the shape written before transport existed reading as direct;
    • each mixed-shape refusal, with its issue code, path and message's first sentence: the vendor blocks and the credential keys in a relay block, a direct block with no vendor, a direct vendor block without credentialRef, and endpoint on direct;
    • a transport outside the two; unknown keys;
    • the PLANNED marker on all five schemas.
  • packages/spec/src/api/push-relay.test.ts pins:
    • the request: both vendors and priorities, an empty batch refused, no credential in the body, each required delivery member;
    • the ID-only payload: title, body and subtitle refused by name, data judged by PushDeliveryDataSchema;
    • the response: each per-token result ok, invalid and retry, batch order, an unknown outcome refused;
    • the 401 envelope for both reasons, and both 429 envelopes;
    • each missing reason, wait or next step refused at its path; a code the relay does not answer with refused;
    • the PLANNED marker on seven schemas.
  • type-alias-convention.pin.test.ts: 822 → 828. The PushServiceConfigSchema pin leaves (-1) and the seven isomorphic relay schemas are pinned (+7). Both error schemas carry …Parsed, because the house envelope's retryable defaults.

Acceptance notes

  • Narrowing measurement (the order's mechanism assumption). Read on packages/spec/dist at 06321962. Every JSON document that parses on main still parses: {} reads as the relay, and { apns }, { fcm } and { apns, fcm } read as direct with their content unchanged. One value main accepts is refused here, and it is JavaScript-only, not JSON: a config whose vendor members are present as undefined with no vendor block at all, such as { apns: undefined }. main parsed it and it delivered to nothing. Here the strict relay block sees the key, and the direct block finds no vendor. { fcm: CONFIG, apns: undefined } still parses as direct. Nothing reads this schema ([PLANNED — not yet read]). Declaring apns and fcm as undefined-only keys on the relay block would avoid it, but would put phantom keys on the relay's authorable surface and published JSON Schema. So the declaration stays yes (widening) and the residue is named here for the seat's contract review.
  • {} changes its declared meaning. On main it read as "no vendor delivers"; here it reads as "the relay", the default the card rules. Nothing reads the shape, so no deployment's behaviour moves. Metadata-protocol: added is kept: check-changeset-no-major reads added from the moved shards.
  • Authorable-surface lines moved, not removed. system/PushServiceConfig:apns and :fcm left the baseline: a union has no top-level keys for the walk to list. They re-appear as system/PushDirectTransportConfig:apns and :fcm. The build's check (c) proved the deletion itself: the def is not reachable from the 33 metadata-type roots.
  • "ADR-0008" is cloud ADR-0008. In this repository ADR-0008 is the metadata repository and change log, so the code cites cloud ADR-0008, as packages/cloud-connection does. A cloud-hosted server reaches the control plane with OS_CLOUD_API_KEY, not an oscc_ bearer. Which credential such a server presents to the relay is the cloud relay's to state (carrier: cloud). This PR only says the binding credential is the bearer.
  • Overlap with feat(notifications): the push channel — service-push (APNs + FCM) and its channel shell in service-messaging #29794 (Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041's reader), ruled serial by the seat. That draft also edits push-config.zod.ts: it takes the [PLANNED — not yet read] markers off and reads the top-level apns / fcm blocks in @objectstack/service-push. The seat ruled this card lands first and holds Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041 behind it. So feat(notifications): the push channel — service-push (APNs + FCM) and its channel shell in service-messaging #29794 owes the transport narrowing in service-push when it merges (read the direct member; say what a relay config does there), and this PR does not touch service-push. On that merge the markers come off the direct member only: the relay member keeps its marker until something reads it.
  • Explicit-transport errors. An unknown transport value (for example 'hms') answers zod's invalid_union with both members' literal mismatches, because unionFallback tries every member. The house formatter picks the branch.

Verification

All readings at head 88fe2df6 (the merge of origin/main 4b71a82b) unless a line names another commit. Every command ran under scripts/pm/os-verify-lock.sh. Each verdict is the lock's VERDICT line or an exit code captured before any pipe.

Build, tests, generated artifacts
  • Full build: pnpm exec turbo run build --concurrency=2 at 726a5aa4: Tasks: 118 successful, 118 total, VERDICT command-exit 0.
  • After merging main: pnpm --filter @objectstack/spec check:generated --fix regenerated api-surface/system.json and export-origins/system.json; then check:generated exit 0, "All 14 generated artifacts are up to date".
  • pnpm --filter @objectstack/spec test && pnpm --filter @objectstack/spec typecheck: Test Files 808 passed (808), Tests 24107 passed and 1 todo; typecheck ran tsc --noEmit, check:scripts-typecheck and check:test-typecheck. VERDICT command-exit 0. At 726a5aa4, before the merge: 808 files, 24067 tests, exit 0.
  • pnpm --filter @objectstack/spec test:repo at 726a5aa4: Test Files 89 passed (89), Tests 1522 passed (1522), VERDICT command-exit 0.
  • pnpm exec eslint --no-inline-config on the six touched source and test files: exit 0.
  • The two new suites alone: Test Files 2 passed, Tests 74 passed.
Ablation of the mixed-shape refusals (one run, each leg restored)

Both legs used node scripts/ablation-replace.mjs --file packages/spec/src/system/push-config.zod.ts … -- vitest run src/system/push-config.test.ts. The tests import the module from src/, so no dist leg is needed. After each leg the tool restored the file: blob 6f536835 equal to HEAD, and git diff HEAD empty.

  • Leg A dropped the direct block's .refine(requiredOneOf(['apns', 'fcm']), …). The marker ABLATED_NO_VENDOR_RULE went 0 to 1 and the anchor 1 to 0. Result: 2 failed of 37, "refuses a direct block with no vendor block, so no credentialRef" and "judges each transport block on its own too".
  • Leg B dropped the relay block's apns and fcm guidance rows. The marker ABLATED_RELAY_VENDOR_GUIDANCE went 0 to 1 and the anchor 1 to 0. Result: 1 failed of 37, "refuses a vendor block inside a relay block, by name, pointing at direct". The key is still refused there, but without the prescription.
  • The first leg-A attempt used ) as the replacement, which is not a countable marker. The tool refused it (count moved by -2, not +1) and restored the file. That attempt was a no-op and is not counted.
Gates
  • node scripts/pm/dispatch-gates.mjs --commands derives 102 commands at 88fe2df6, the same list as at 726a5aa4. All 102 exit 0, run serially with stdin closed and the exit captured before any pipe. --ran reconciles 102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN; the event-body run of check-changeset-no-major is recorded outside the derivation.
  • First pass at 06321962: two reds. check:llms-txt (llms.txt counted 216 schemas and 34 for api) and check:variant-docs (the new error union on PushRelayErrorResponseSchema.error was undeclared). Both were fixed in 726a5aa4: the counts and the api row now name the relay module, and the union has a not-authorable (wire protocol) row in variant-docs.json.
  • Seven gates exited 3 until the full build existed: check:doc-formula-expressions, check:doc-security-posture, check:skill-examples, check:docs-transcript-drift, check:dual-build-cjs-loads and check:lean-entry-closure. The seventh, check-plugin-teardown-shape.mjs --self-test, needed its pinned fixture commit fetched into the shallow clone; there was no file change.
  • GITHUB_EVENT_NAME=pull_request node scripts/check-changeset-no-major.mjs --base origin/main --event FILE with this body: exit 0. The level axis reads Clause-②: yes (widening) and Metadata-protocol: added, and no moved package is graded patch. Control with Metadata-protocol: none: exit 1, "moves a metadata-protocol baseline".
  • Dogfood battery: n/a. The title has no ! and there is no (narrowing) arm.

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 on the touched files (CI runs the full lint).
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset (.changeset/29816-push-relay-transport.md), 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 维护者速读 section is the maintainer's Chinese summary).

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzjEE2HfFt4gfFWntQG


Generated by Claude Code

…erviceConfigSchema, and the server-to-relay wire shape

Claude-Session: https://claude.ai/code/session_01WGufzjEE2HfFt4gfFWntQG
Co-authored-by: Claude <noreply@anthropic.com>
…s, and its changeset

Claude-Session: https://claude.ai/code/session_01WGufzjEE2HfFt4gfFWntQG
Co-authored-by: Claude <noreply@anthropic.com>
…error union from the variant-doc ledger as wire protocol

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

github-actions Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 40 documentable anchor(s). ⚠️ 14 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/api-surface/system.json, packages/spec/authorable-defaults/system.json, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/client-sdk.mdx (via RATE_LIMIT_EXCEEDED (literal, a string literal in rateLimitError))
  • content/docs/api/error-catalog.mdx (via QUOTA_EXCEEDED (literal, a string literal in quotaError), RATE_LIMIT_EXCEEDED (literal, a string literal in rateLimitError))
  • content/docs/api/error-handling-client.mdx (via RATE_LIMIT_EXCEEDED (literal, a string literal in rateLimitError))
  • content/docs/api/wire-format.mdx (via RATE_LIMIT_EXCEEDED (literal, a string literal in rateLimitError))
  • content/docs/protocol/kernel/error-handling.mdx (via QUOTA_EXCEEDED (literal, a string literal in quotaError), RATE_LIMIT_EXCEEDED (literal, a string literal in rateLimitError))
  • content/docs/protocol/kernel/http-protocol.mdx (via RATE_LIMIT_EXCEEDED (literal, a string literal in rateLimitError))
  • content/docs/protocol/kernel/realtime-protocol.mdx (via RATE_LIMIT_EXCEEDED (literal, a string literal in rateLimitError))
What this run could not see
  • 14 changed file(s) yielded no anchor (packages/spec/api-surface/api.json, packages/spec/api-surface/system.json, packages/spec/authorable-defaults/system.json, …) — pages documenting those are invisible to this run
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 74fd4bf64abfd77fa611425b029aa26afcd6808a → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 74fd4bf64abfd77fa611425b029aa26afcd6808a

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:system tests tooling labels Nov 3, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-support-ai · session_01WGufzhJG3tKnksHVa1krBz

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 88fe2df6c30c8ce034b16617a7495cc6bb666b71
Local-runs: none
Scope: packages/spec/src/api/index.ts, packages/spec/src/api/push-relay.zod.ts, packages/spec/src/system/push-config.zod.ts + packages/spec/src/api/errors.zod.ts, packages/spec/src/api/push-delivery.zod.ts, packages/spec/src/system/push-device.zod.ts, packages/spec/src/shared/refinement-projection.ts, packages/spec/src/shared/strict-object.ts, packages/spec/src/shared/lazy-schema.ts, packages/spec/src/system/push-config.zod.ts on main (read, not reviewed: the two new test files, type-alias-convention.pin.test.ts, the changeset, variant-docs.json, the regenerated content/docs/references/** and packages/spec/{api-surface,authorable-*,declaration-map,export-origins,json-schema.manifest}/**). The rest of the diff is the lane seat's ACCEPT at TIER_DEFAULT.

① Derived judgments

Accept-set of PushServiceConfigSchema (system), judged against main's { apns?, fcm? } strict block and the card's ask 1:

  • {} parses to { transport: 'relay' } — right: the card rules the relay the default. On main it parsed as "no vendor delivers"; nothing reads the shape ([PLANNED — not yet read] kept on all five schemas) and no stack key or door reaches PushServiceConfigSchema anywhere under packages/ at this head (grep: only the spec module itself), so no deployment moves.
  • { apns }, { fcm }, { apns, fcm } with no transport parse as direct with transport: 'direct' filled in — right: the pre-relay shape keeps parsing, which is the widening the declaration claims. The mechanism holds on the code: both members default their transport literal, so input.transport absent is not in the discriminator map and unionFallback: true tries relay then direct; the relay strict block refuses the vendor key, the direct block accepts it.
  • { transport: 'relay', apns | fcm | credentialRef | credential | token | apiKey | key | secret } refused by name with the relay guidance pointing at transport: 'direct' — right (card: "credentials inside a relay block"). Strictness refuses every unknown key; the guidance rows only add the prescription, and the dev's ablation leg B measured that.
  • { transport: 'direct' } with no vendor block refused with one custom issue at [] — right (card: "direct without any credentialRef"). The rule is the projectable requiredOneOf(['apns', 'fcm']), so the published JSON Schema carries it as anyOf of required and the dropped-refinements ledger does not move (it is not in the diff). A vendor block without its credentialRef is still refused at ['apns', 'credentialRef'], as on main.
  • { transport: 'direct', endpoint } refused by name pointing at the relay — right, not asked by the card but the symmetric half of the mixed-shape rule.
  • { transport: 'relay', endpoint: 'http://…' } refused at ['endpoint'] — right: every request carries the binding bearer. No production URL is written into the spec; "absent means the production relay" is the describe text. The card said endpoint "defaults to the production relay"; declaring absence rather than a URL is the right reading, since the address is the cloud's to publish.
  • { transport: 'hms' } answers invalid_union — right; the card keeps PushVendorSchema at apns | fcm this phase, and it is unchanged.
  • The one value main accepts and this head refuses is JavaScript-only: { apns: undefined } with no vendor block (the strict relay block sees a present key, the direct block finds no vendor). No JSON document moves. Named by the dev in the PR body and the report; judged under ② below.
  • Published JSON Schema: the union projects to one schema per member; {} satisfies the relay member only (direct fails its anyOf required), { apns } the direct member only (relay has additionalProperties: false). So the published file and the runtime agree on every JSON document, with or without oneOf exclusivity.

Accept-set of the new api/push-relay.zod.ts, judged against the card's ask 2 and ruling 2A:

  • Request { deliveries: [{ token, vendor, payload, priority }] }, strict, min(1), no credential in the body — right. vendor is the existing PushVendorSchema; priority is high | normal with the APNs 10/5 and FCM high/normal mapping in the describe text.
  • Payload { data: PushDeliveryDataSchema, text }, strict, with title, body, subtitle, message, content refused by name — right at the payload level (see ④ for the data level).
  • Response { results: [{ token, status: ok | invalid | retry }] } in request order — right; invalid is tied to the UserDeviceSchema row semantics (enabled: false plus invalidatedAt), which exist on main as cited.
  • Error envelope: the house ErrorResponseSchema with error narrowed to a code union of three EnhancedApiErrorSchema.extend(...) members, UNAUTHENTICATED 401 with details.reason binding_invalid | binding_revoked, RATE_LIMIT_EXCEEDED 429 with required retryAfterSeconds, QUOTA_EXCEEDED 429 with it optional, helpText required on all three — right against the card ("401 bad or revoked binding, 429 quota or rate limit with reason and next step"); all three codes exist in StandardErrorCode on main, so no ledger row is owed. extend on the lazySchema facade is the first such use in src/, and the facade forwards it (the tests parse through it and Type Check · source gates is green).

Public-surface changes: api/index.ts adds one export *; nine relay schemas with their input types and two ...Parsed types; two transport members with input and ...Parsed types plus PushServiceConfigParsed (ADR-0122, since parsing fills transport in). The pin file moves 822 to 828 (the union loses its isomorphic pin, seven relay schemas gain one; the two enums PushRelayPrioritySchema and PushRelayResultStatusSchema carry no [PLANNED] marker, which follows PushVendorSchema on main). Generated artifacts: authorable-surface/system.json moves PushServiceConfig:apns|fcm to PushDirectTransportConfig:apns|fcm and adds transport and endpoint rows; authorable-defaults/system.json gains both transport defaults; json-schema.manifest gains eleven defs; variant-docs.json exempts the error union as wire protocol (not-authorable), which is the right row for a shape nobody authors; llms.txt counts 34 to 35 api modules. All consistent with the source.

Gate families on this head (the check-runs are the verdicts): concluded success — Check Changeset, Check PR Size, PR-body guards, Governed Surface Queue Guard, Spec property liveness, Lint · ESLint, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped (slot lookup & corpus, query-options erasure, tooling self-tests), Lint · PM dispatch-gates, Type Check · source gates, Build Docs, Build UI Deps, Dogfood Regression Gate, Check Documentation Links, Flag docs, Plan Test Core shards, filter, Auto Label. Skipped (opt-in or matrix placeholders): Packed-tarball smoke, 18.x harness, Temporal Conformance, UI Published Artifacts, Dogfood Verify CLI, Lint UI, Schedule gate. Still pending at review time: Build Core, Test Core 1-8, Test UI 1-16, Type Check · workspace, consumer gates, debt ledger and UI, Lint · CI release & engine gates, Lint & Repo Gates (queued), Build & E2E UI, Live E2E UI. The families judged here are the ones that concluded; the seat adopting this record confirms the pending ones are green before landing.

② Semver level

  • .changeset/29816-push-relay-transport.md: @objectstack/spec: minor — right. The diff publishes new exports and new authorable keys from @objectstack/spec; nothing an author could write before is removed or tombstoned.
  • Clause-②: yes (widening) on the PR body and the changeset — right. In the JSON domain every document main accepts ({}, { apns }, { fcm }, { apns, fcm }) this head accepts, and the published JSON Schema is a superset of the old one. The JS-only { apns: undefined } residue is a property of strictness meeting a present-but-undefined key and reaches no JSON author, no serialized config and no reader; a narrowing arm for it would declare BREAKING on a shape nothing reads, and the alternative (phantom apns/fcm undefined-only keys on the relay block) would put dead keys on the authorable surface. Answered under ③, open question 1: option A.
  • Metadata-protocol: added — accepted. The authorable-surface baseline moves by added rows (transport, endpoint, the relocated vendor rows) and the gate reads added from them (Check Changeset green). The semantic arm ("a changed meaning or default") was weighed: {} reads as the relay here where main's docblock read it as no vendor, but PushServiceConfigSchema is reachable from no metadata root at this head, so there is no authored {} whose meaning moves; the door that first makes it authorable is the change that owes the semantic reading if it keeps this default. Answered under ③, open question 2: option A.

③ Boundary flags

Open questions (os-dev-report 2026-11-03T09:37Z):

  1. Keep yes (widening) despite the JS-only { apns: undefined } refusal? A — keep widening, with the residue named in the PR body as it is. Reasoning in ②.
  2. {} changes its declared meaning; added or semantic? A — added. Reasoning in ②. The first reader/door PR (feat(notifications): the push channel — service-push (APNs + FCM) and its channel shell in service-messaging #29794 for Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041) carries the declaration for the meaning it makes real.

Deviations (three): the attribution link pointing at the card thread — accepted, provenance only; "cloud ADR-0008" in place of the card's "ADR-0008" — right, this repository's ADR-0008 is the metadata repository and change log and the code says cloud ADR-0008 as packages/cloud-connection does; no assignee, reviewer or activity subscription — accepted per the order.

Out-of-scope findings (two): which credential a cloud-hosted server (OS_CLOUD_API_KEY, no oscc_ bearer) presents to the relay is the relay's to state — agreed, carrier cloud, and this spec says only that the binding credential is the bearer; #29794 reads top-level apns/fcm and owes the transport narrowing in service-push when it merges after this card — agreed with the seat's serial ruling, and it also owes taking the marker off the direct member only.

Premise: the order's "no open PR edits push-config.zod.ts" was false (#29794); the seat ruled this card lands first. Not escalated: the ruling is on the card thread and this head does not touch service-push.

No security_findings. The relay carries no credential in any schema; the endpoint is https-only; the payload refuses content by name at its own level.

④ Design judgment

packages/spec/src/system/push-config.zod.ts — judged against the card (ruling cloud#3519, 1C 2A 3A 4B 5A 6B 7B), main's shape, and its first consumer (#29794's reader, held behind this card). Holds. Attempts to break it: a present-but-undefined transport with a vendor block ({ transport: undefined, fcm }) — the discriminator is a declared key on both members so it is not an unrecognized key, the default fills it, relay refuses fcm, direct accepts; { endpoint, fcm } with no transport — both members refuse and zod answers invalid_union with both members' issues, which the PR body names and the house formatter picks a branch for; apns: null on direct — refused at ['apns'] by the vendor schema, and requiredOneOf plus JSON Schema required both count null as present, so runtime and published file agree; the relay's .refine ordering — zod runs the custom check only when the base object has no issues, so the dev's "exactly one issue" tests describe the real behaviour. The docblock's three claims (relay default, direct for white-label, the no-transport reading) match the code, and the "hosted surface out of scope" sentence is gone as the card asked. No finding.

packages/spec/src/api/push-relay.zod.ts — judged against ruling 2A (ID-only), the house error envelope in api/errors.zod.ts, PushDeliveryDataSchema and UserDeviceSchema, and its two consumers (the push channel #29041 and the cloud relay). Holds, with two should-fixes:

  • should-fix — the ID-only rule is enforced one level deep. PushRelayPayloadSchema is strict and refuses title, body, subtitle, message, content by name, but data is judged by PushDeliveryDataSchema, a plain z.object on main that strips unknown keys. So payload.data.title is neither refused nor named: a parse strips it silently, while the describe text says the relay "puts it in the vendor message unchanged". A relay that forwards the raw body carries such a key to the device; one that forwards the parsed output drops it. Pick one and say it: either judge data strictly at the relay payload (a strict wrapper over the three PushDeliveryData keys, with the same content guidance), or state in PushRelayPayloadSchema's describe text that the relay forwards the parsed data, never the raw body. Not a must-fix: the only party that could put content under data is the operator's own sender, and the ruling protects the operator from the relay, not the relay from the operator.
  • should-fix — the 429 pacing member contradicts the house envelope's own example. RATE_LIMIT_EXCEEDED requires retryAfterSeconds and says "the delivery may be sent again", but inherits retryable: z.boolean().default(false) from EnhancedApiErrorSchema, so a relay answer written without retryable parses to { retryable: false, retryAfterSeconds: 30 }; the house @example Rate Limit Error in errors.zod.ts carries retryable: true and retryStrategy: 'retry_after'. The test passes retryable: true explicitly, so nothing pins the default. Fix retryable on the pacing member to z.literal(true).default(true) (and consider the same on QUOTA_EXCEEDED when retryAfterSeconds is present, or pin category: 'rate_limit'); both are Parsed-typed already, so no pin moves.

Also read and found sound: the response keyed by token is disambiguated by request order when one token appears twice in a batch (two notifications to one device), and the describe text says so; PushRelayResultSchema and PushRelayResponseSchema are intentionally non-strict (the relay may grow its answer), while the request is strict (the relay pins what it accepts), which is the right asymmetry for a wire the sender does not control; the cross-domain import of PushVendorSchema from system/ follows push-delivery.zod.ts importing system/push-server-origin. No batch-size ceiling is declared on deliveries; that is the relay's pacing rule and belongs with its 429, not here.

packages/spec/src/api/index.ts — one export *, placed beside push-delivery.zod; api-surface/api.json, export-origins/api.json and declaration-map/api.json carry the nine schemas. Holds. No finding.

No must-fix. Both should-fixes are on the relay wire shape, which no producer or consumer serves yet ([PLANNED — not yet served]), so they can land in the follow-up that first serves it or in this PR at the seat's call.

Implemented-by: claude/project-thread-g5fdkn
Reviewed-by: session_01WGufzhJG3tKnksHVa1krBz

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#2 · os-support-ai · session_01WGufzhJG3tKnksHVa1krBz

席位核对 — ACCEPT · domain:spec#2 · head 88fe2df6c30c8ce034b16617a7495cc6bb666b71

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: push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server→relay wire shape (ruling cloud#3519)

1 participant