Repository navigation
feat(spec): push relay transport — transport: relay | direct on PushServiceConfigSchema, and the server-to-relay wire shape - #29886
Conversation
…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>
Claude-Session: https://claude.ai/code/session_01WGufzjEE2HfFt4gfFWntQG Co-authored-by: Claude <noreply@anthropic.com>
…main Claude-Session: https://claude.ai/code/session_01WGufzjEE2HfFt4gfFWntQG Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 7 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 168 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
|
domain:spec#2 · os-support-ai · session_01WGufzhJG3tKnksHVa1krBz Contract reviewServed-tier: ① Derived judgmentsAccept-set of
Accept-set of the new
Public-surface changes: 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
③ Boundary flagsOpen questions (os-dev-report 2026-11-03T09:37Z):
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 Out-of-scope findings (two): which credential a cloud-hosted server (OS_CLOUD_API_KEY, no 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 ④ Design judgment
Also read and found sound: the response keyed by
No must-fix. Both should-fixes are on the relay wire shape, which no producer or consumer serves yet ( Implemented-by: VERDICT: PASS |
|
domain:spec#2 · os-support-ai · session_01WGufzhJG3tKnksHVa1krBz 席位核对 — ACCEPT · domain:spec#2 · head
|
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 grepover both, the generatedcontent/docs/references/excepted, has zero hits); the transport is not authorable until its reader ships.维护者速读
/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041)和云端中继需要同一份契约来对齐。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
PushServiceConfigSchemais atransportdiscriminated union (packages/spec/src/system/push-config.zod.ts), both members built withstrictObject: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, theoscc_…bearer), so the block carries no credential.endpointis 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.apns,fcm) or a credential-shaped key (credentialRef,credential,token,apiKey,key,secret) is refused by name:unrecognized_keysat path[], message "Not authorable on the relay push transport. …", pointing attransport: 'direct'.credentialRef: acustomissue at path[], message "A direct push transport names at least one vendor block (apnsorfcm), each with itscredentialRef. …". The rule is the projectablerequiredOneOf(['apns', 'fcm']), so the published JSON Schema carries it asanyOfrequiredanddropped-refinementsdoes not move. A vendor block missing itscredentialRefwas already refused at['apns', 'credentialRef']and still is.endpointon a direct block is refused by name, pointing at the relay.transportkeeps parsing. Both members default theirtransportliteral, and the union is built withunionFallback: true, so an absent tag is tried as the relay and then as direct:{}reads as the relay, and a block with anapnsorfcmblock reads as direct. A block that namestransportis judged by that member alone, so its refusals carry that member's path and message.[PLANNED — not yet read]marker stays, and the two new members carry one.PushServiceConfigParsed,PushRelayTransportConfigParsedandPushDirectTransportConfigParsedare declared: parsing fills intransport, so author and parsed shapes differ (ADR-0122).Iso_system_pushConfig__PushServiceConfigSchemaleaves 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 }.dataisPushDeliveryDataSchema(notification id, same-server route, origin);textis one generic visible line.title,body,subtitle,messageandcontentare refused by name. Opaque to the relay: it puts the payload in the vendor message unchanged.PushRelayPrioritySchema:highornormal.vendoris the existingPushVendorSchema, stillapns | fcm.PushRelayResponseSchema:{ results: [{ token, status }] }, one result per delivery in request order.PushRelayResultStatusSchemaisok,invalid(the vendor reports the token dead: stop delivering to it) orretry(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:
ErrorResponseSchema(api/errors.zod.ts), witherrornarrowed toPushRelayErrorSchema.codeof threeEnhancedApiErrorSchema.extend(...)members, each using an existingStandardErrorCode. No new error code and no ledger row.UNAUTHENTICATED, withdetails.reasonset tobinding_invalidorbinding_revoked.RATE_LIMIT_EXCEEDED. It requiresretryAfterSeconds, the house rate-limit field that names the wait (the relay sends the same value inRetry-After).QUOTA_EXCEEDED.retryAfterSecondsis optional there.codeis the reason. All three requirehelpText, the house "suggested actions" field, as the next step.Not changed, per the card:
PushVendorSchemastaysapns | fcm, andRegisterDeviceRequestSchemagains nopushKey. 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 andcontent/docs/references/(newapi/push-relay.mdx).Tests
packages/spec/src/system/push-config.test.tspins:{}parses to{ transport: 'relay' };transportexisted reading as direct;credentialRef, andendpointon direct;PLANNEDmarker on all five schemas.packages/spec/src/api/push-relay.test.tspins:title,bodyandsubtitlerefused by name,datajudged byPushDeliveryDataSchema;ok,invalidandretry, batch order, an unknown outcome refused;PLANNEDmarker on seven schemas.type-alias-convention.pin.test.ts: 822 → 828. ThePushServiceConfigSchemapin leaves (-1) and the seven isomorphic relay schemas are pinned (+7). Both error schemas carry…Parsed, because the house envelope'sretryabledefaults.Acceptance notes
packages/spec/distat06321962. Every JSON document that parses onmainstill parses:{}reads as the relay, and{ apns },{ fcm }and{ apns, fcm }read as direct with their content unchanged. One valuemainaccepts is refused here, and it is JavaScript-only, not JSON: a config whose vendor members are present asundefinedwith no vendor block at all, such as{ apns: undefined }.mainparsed 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]). Declaringapnsandfcmas 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 staysyes (widening)and the residue is named here for the seat's contract review.{}changes its declared meaning. Onmainit 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: addedis kept:check-changeset-no-majorreadsaddedfrom the moved shards.system/PushServiceConfig:apnsand:fcmleft the baseline: a union has no top-level keys for the walk to list. They re-appear assystem/PushDirectTransportConfig:apnsand:fcm. The build's check (c) proved the deletion itself: the def is not reachable from the 33 metadata-type roots.cloud ADR-0008, aspackages/cloud-connectiondoes. A cloud-hosted server reaches the control plane withOS_CLOUD_API_KEY, not anoscc_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./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 editspush-config.zod.ts: it takes the[PLANNED — not yet read]markers off and reads the top-levelapns/fcmblocks 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 thetransportnarrowing 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.transportvalue (for example'hms') answers zod'sinvalid_unionwith both members' literal mismatches, becauseunionFallbacktries every member. The house formatter picks the branch.Verification
All readings at head
88fe2df6(the merge oforigin/main4b71a82b) unless a line names another commit. Every command ran underscripts/pm/os-verify-lock.sh. Each verdict is the lock'sVERDICTline or an exit code captured before any pipe.Build, tests, generated artifacts
pnpm exec turbo run build --concurrency=2at726a5aa4:Tasks: 118 successful, 118 total, VERDICT command-exit 0.pnpm --filter @objectstack/spec check:generated --fixregeneratedapi-surface/system.jsonandexport-origins/system.json; thencheck:generatedexit 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 rantsc --noEmit,check:scripts-typecheckandcheck:test-typecheck. VERDICT command-exit 0. At726a5aa4, before the merge: 808 files, 24067 tests, exit 0.pnpm --filter @objectstack/spec test:repoat726a5aa4: Test Files 89 passed (89), Tests 1522 passed (1522), VERDICT command-exit 0.pnpm exec eslint --no-inline-configon the six touched source and test files: exit 0.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 fromsrc/, so no dist leg is needed. After each leg the tool restored the file: blob6f536835equal to HEAD, andgit diff HEADempty..refine(requiredOneOf(['apns', 'fcm']), …). The markerABLATED_NO_VENDOR_RULEwent 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".apnsandfcmguidance rows. The markerABLATED_RELAY_VENDOR_GUIDANCEwent 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.)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 --commandsderives 102 commands at88fe2df6, the same list as at726a5aa4. All 102 exit 0, run serially with stdin closed and the exit captured before any pipe.--ranreconciles 102 derived, 102 run, 0 NOT-MEASURED, 0 UNRUN; the event-body run ofcheck-changeset-no-majoris recorded outside the derivation.06321962: two reds.check:llms-txt(llms.txtcounted 216 schemas and 34 forapi) andcheck:variant-docs(the new error union onPushRelayErrorResponseSchema.errorwas undeclared). Both were fixed in726a5aa4: the counts and theapirow now name the relay module, and the union has anot-authorable(wire protocol) row invariant-docs.json.check:doc-formula-expressions,check:doc-security-posture,check:skill-examples,check:docs-transcript-drift,check:dual-build-cjs-loadsandcheck: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 FILEwith this body: exit 0. The level axis readsClause-②: yes (widening)andMetadata-protocol: added, and no moved package is gradedpatch. Control withMetadata-protocol: none: exit 1, "moves a metadata-protocol baseline".!and there is no(narrowing)arm.Human Minimum
packages/specholds no runtime logic.pnpm lintpasses on the touched files (CI runs the full lint).main, one pull request..changeset/29816-push-relay-transport.md), and both declaration lines are filled in here and in the changeset.packages/spectouched: its generated artifacts are regenerated and committed.content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.🤖 Generated with Claude Code
https://claude.ai/code/session_01WGufzjEE2HfFt4gfFWntQG
Generated by Claude Code