Skip to content

service-push: send through the hosted relay transport (push-relay.zod.ts wire, cloud binding credential) #29920

Open

Description

@objectstack-fleet

domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

Blocked-by: #29794
Related: #29816 (spec, landed), #29817 (ADR-0012 amendment), objectstack-ai/cloud#3519 (ruling)

Context

#29816 made transport: relay | direct the shape of PushServiceConfigSchema, with relay as the default ({} reads as relay), and added the server→relay wire in packages/spec/src/api/push-relay.zod.ts. PR #29794 (round 3) ships service-push reading only the direct arm. relay, including {}, is refused loudly at PushServicePlugin.init and names this card. The relay block keeps its refused-not-served marker until this card lands.

Ask

Implement the relay transport in service-push:

  • Send. POST a PushRelayRequestSchema body (one delivery per device token: token, vendor, ID-only payload, priority) to the relay endpoint, or the default endpoint when none is set.
  • Auth. The bearer is the server's cloud binding credential (cloud ADR-0008, the oscc_… binding). Resolve it through the binding, never inline config, and never log it. No binding means a loud init refusal that names the missing binding.
  • Answers. Map PushRelayResponseSchema per token: invalid retires the device row the same way the direct transports do; retry reuses the existing retry path. 401 is a loud, non-retryable failure that names the binding.
  • Privacy. The payload stays ID-only (phase 1). No title or body leaves the server.
  • Flip the refusal. Remove the init refusal, and turn the relay block and wire markers from not-served to served in the same PR, then regenerate.

Acceptance

  • Tests pin each arm: {}, an explicit relay endpoint, no binding, 401, and an invalid and a retry result.
  • Direct-transport behaviour is unchanged.
  • The changeset names the newly served relay transport.

Filed by the seat as the follow-up that PR #29794's relay refusal names.

Activity

  1. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

    Path: none

    Triage: first-touch audit · p2 · domain:server kept · pm:triage → pm:blocked · 2026-11-03T12:55Z

    ⛔ Not a claim.


    Generated by Claude Code

  2. added
    enhancementNew feature or request
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    and removed on Nov 3, 2026
  3. objectstack-fleet commented on Nov 3, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#4 · os-warren · session_01JQwxbacSXJQqAztgt4UMrS

    Carried into this card's scope (2026-11-03T14:31Z), from the PR #29794 round-3 contract review (6606491253 ③ and ④):

    1. Strict data on PushRelayPayloadSchema (spec seat 2's should-fix, Push channel: build the device registry routes (/notifications/devices) and the APNs + FCM transport that ADR-0012 marks "MUST ship M1" #29041 6601310426, addressed to "the first producer of the relay wire", which is this card).
    2. retryable: true on RATE_LIMIT_EXCEEDED in the relay's error answer (same source).
    3. The push-relay.zod.ts docblock still says "No push channel delivers yet … until the push channel ships". After feat(notifications): the push channel — service-push (APNs + FCM) and its channel shell in service-messaging #29794 the channel ships; only the relay sender does not. Fix that sentence in the same PR that flips the markers to served.

    All three land with the relay sender, not before.

  4. added
    pm:queueReady for the PM dispatch loop
    pm:dispatchedDispatched to a dev agent by /pm-dispatch
    and removed
    pm:blockedBlocked by another issue/PR — body carries Blocked-by: #N
    pm:queueReady for the PM dispatch loop
    on Nov 3, 2026
  5. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

    Claim: PM loop round 1
    Session: session_01Utp6CZMkHvuDeVYgTD73qF (seat) · dev card thread session_01Utp6CZX77joBRgcruRQSMN
    Account: os-bill
    Branch: claude/project-thread-tk5wos (the card thread's designated push branch, the recorded platform exception to claude/issue-<n>-<slug>)
    Worktree: objectstack-issue-29920
    Domain: domain:server
    Seat: domain:server#4
    File surface: packages/services/service-push/** (the relay transport, the init refusal) and its tests, one changeset; narrow spec exception: the relay block's and wire's served/not-served markers in packages/spec/src/system/push-config.zod.ts and packages/spec/src/api/push-relay.zod.ts, the carried-in strict data on PushRelayPayloadSchema, and what those markers regenerate. ⛔ any other packages/spec/**, docs/adr/**, .claude/**, repo-root AGENTS.md, ui/**, skills/** (stop on breach; explain in the report)
    Container & model: L, mode:thread, model: default judgment tier (opus) — dispatch-gates --tier: no path-derived mandate, effort: default
    Clause-②: yes (widening) — transport: relay (and {}) configs refused at init today now serve; the dev names any narrowing arm (strict data on the wire) and declares the PR BREAKING if one holds
    Metadata-protocol: none
    Responsibility: PushServicePlugin.init refuses transport: relay, the spec's default, so a server configured {} sends no push | none: relay and {} configs refused loudly at init since PR #29794 | any deployment using the hosted push relay through its cloud binding
    Thread-read: 6606506582
    Premise: holds (Blocked-by #29794 is closed; the dev re-reads the init refusal on main)
    Serial constraints cleared: seat 2 yielded the card; seats 3 and 5 confirmed no overlap; no card in flight edits service-push.

    Dispatch order: /mnt/project-files/server-seat4/dispatch-29920.md (project shared folder). Ruling section: the card, triage first-touch 6604349195 (p2) and the carried-in scope 6606506582 (strict data, retryable: true on RATE_LIMIT_EXCEEDED). If packages/cloud-connection exposes no way to read the binding credential, the dev stops and reports. Fixes #29920.

    Posted 2026-11-05T12:19Z.

  6. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF
    os-dev-report

    Dev card thread session_01Utp6CZX77joBRgcruRQSMN, branch claude/project-thread-tk5wos (the Claim's). Status: needs_decision. No code, no PR. The order's stop condition holds: packages/cloud-connection exposes no way for a service to read the binding credential. Readings taken against objectstack-ai/objectstack at c5800818 (origin/main).

    What was measured

    • CloudConnectionPlugin registers no service. The only registerService calls in packages/cloud-connection/src are seed-datasets and seed-replayer (install-local plugin).
    • The effective credential rule is a closure inside CloudConnectionPlugin.start(), in its kernel:ready hook (cloud-connection-plugin.ts:343): platform-managed runtimes present OS_CLOUD_API_KEY / the controlPlaneApiKey option (through resolveControlPlaneApiKey, packages/core/src/stack-auth.ts:133); self-hosted runtimes present the oscc_ bearer kept by ConnectionCredentialStore (default .objectstack/cloud-connection.json, overridable by the plugin's credentialPath). It is read per request on purpose, so a bind that completes while the runtime is up takes effect without a restart.
    • The package exports the ConnectionCredentialStore class (a file reader) and no accessor for the effective credential.
    • @objectstack/service-push depends on @objectstack/core and @objectstack/spec only; @objectstack/cloud-connection depends on runtime, objectql, metadata-core, metadata-protocol and observability.
    • No other reader of the credential file or of oscc_ exists under packages/** outside cloud-connection and spec.

    Decisions owed

    Q1. How does service-push get the binding credential?

    • A. cloud-connection registers a binding-credential service (shape to be ruled, for example credential() and bound()), and service-push reads it lazily on each send. One owner for the rule (Route and surface ownership, rule 1); sees a bind done at runtime. Cost: widens the file surface to packages/cloud-connection/**; may need a cross-package interface in packages/spec/src/contracts/, outside this order's narrow spec exception. About one extra day.
    • B. service-push imports ConnectionCredentialStore and resolveControlPlaneApiKey and spells the rule a second time. Cost: a second copy of the rule beside its owner, the shape ruling 6341690661 forbids for isCloudManaged; pulls the cloud-connection dependency closure into service-push; misses a host's credentialPath and controlPlaneApiKey options.
    • C. The host injects a resolver (PushServicePluginOptions gains one), and resolvePushCapabilityArg supplies the default. Cost: the default still needs A or B.
    • Recommendation: A, because the rule keeps one owner and the read is lazy.

    Q2. What does "no binding means a loud init refusal" do to a fresh self-hosted boot? A self-hosted server is unbound on its first boot by construction (it binds through the running Setup page), and {} reads as the relay. An init refusal therefore fails every fresh self-hosted boot whose stack declares requires: ['push']. It also records a verdict at init about state another plugin owns (AGENTS.md, Startup registry reads).

    • (i) Refuse at init only when a binding is impossible (for example OS_CLOUD_URL=off). Otherwise register the service, answer each send failed, non-retryable, naming the missing binding, and log it once at warn.
    • (ii) Refuse at init as the card says. Fresh self-hosted boots with requires: ['push'] fail until the server is bound and restarted.
    • Recommendation: (i), because the binding is resolved per request everywhere else, and (ii) blocks the boot that has to happen before a bind is possible.

    Q3. Default relay endpoint. ADR-0012 section 6.1: the production relay address is the cloud's to publish and is never written into the spec. No constant exists anywhere in the tree. A path under resolveCloudUrl() (DEFAULT_CLOUD_URL, OS_CLOUD_URL) is a plausible home, but the path belongs to objectstack-ai/cloud#3519, which this session cannot read. This cannot be decided in this repository; it waits on the cloud side.

    Q4 (minor). The generic visible line "in the device locale". PushMessage carries no locale. The device row has locale, but the channel (packages/services/service-messaging/src/push-channel.ts) does not pass it, and service-messaging is outside the order's file surface.

    • A. service-push ships one fixed English line.
    • B. The channel passes locale and service-push picks the line (a service-messaging edit).
    • Recommendation: B, because the wire says the line is in the device locale.

    Carried-in scope (no blocker, unchanged)

    • Strict data on PushRelayPayloadSchema: PushDeliveryDataSchema is a plain object schema that strips unknown keys. The relay payload would use a strict variant. This narrows the wire, so the PR would be BREAKING.
    • retryable: true on RATE_LIMIT_EXCEEDED: a new key on the 429 schema in push-relay.zod.ts.
    • The docblock sentence (comment 6606506582, item 3) sits in the same file as the markers.
    {
      "issue": 29920,
      "status": "needs_decision",
      "branch": "claude/project-thread-tk5wos",
      "pr": null,
      "session": "session_01Utp6CZX77joBRgcruRQSMN",
      "premise_still_valid": true,
      "summary": "Read-only premise check; the order's stop condition holds. packages/cloud-connection registers no service and exports no accessor for the effective binding credential: the rule is a closure in CloudConnectionPlugin.start() (OS_CLOUD_API_KEY when platform-managed, else the oscc_ bearer in ConnectionCredentialStore). No code written, no worktree created, no PR. Four decisions owed: credential seam (A/B/C), the init refusal on an unbound self-hosted boot, the default relay endpoint (cloud side), the locale of the generic line.",
      "tests": "none run: no code written",
      "gates": "none run: no changed paths",
      "dogfood_battery": "n/a — no PR",
      "mcp_calls": "3 — mcp__github__issue_read (get, get_comments x2); no write tool",
      "api_writes": "1 — this comment, POST issues/29920/comments through the fleet-write relay (post-stamped)",
      "open_questions": [
        { "question": "Q1: how does service-push read the cloud binding credential?", "options": ["A: cloud-connection registers a binding-credential service, read lazily per send", "B: service-push re-spells the rule with ConnectionCredentialStore and resolveControlPlaneApiKey", "C: host-injected resolver, default from A or B"], "recommendation": "A, because the rule keeps one owner and a bind done at runtime is seen" },
        { "question": "Q2: unbound self-hosted boot with requires push and the relay default", "options": ["(i) refuse at init only when binding is impossible; else serve and answer each send failed, non-retryable, naming the missing binding, warn once", "(ii) refuse at init whenever unbound"], "recommendation": "(i), because (ii) blocks the boot that must happen before a bind" },
        { "question": "Q3: the default relay endpoint", "options": ["wait for objectstack-ai/cloud to publish the relay path"], "recommendation": "wait for the cloud side; not decidable in this repository" },
        { "question": "Q4: locale of the generic visible line", "options": ["A: one fixed English line in service-push", "B: the channel passes the device locale (service-messaging edit)"], "recommendation": "B, because the wire says the line is in the device locale" }
      ],
      "security_findings": "none",
      "out_of_scope_findings": []
    }

    Report taken 2026-11-05T12:22Z.


    Generated by Claude Code

  7. objectstack-fleet commented on Nov 5, 2026

    @objectstack-fleet
    ContributorAuthor

    domain:server#4 · os-bill · session_01Utp6CZMkHvuDeVYgTD73qF

    Seat hold · awaiting the maintainer's ruling · 2026-11-05T12:23Z

    ⛔ Not a landing. The claim stands; no code is written until the ruling.

    • The dev's premise check (needs_decision report 6657878085) hit the dispatch order's stop condition: packages/cloud-connection exposes no service that reads the binding credential. It also found that the card's "no binding → loud init refusal" would refuse every fresh self-hosted boot, because {} reads as relay and the bind happens at runtime. And the default relay endpoint is the cloud's to publish (ADR-0012 §6.1), not in this tree.
    • Questions Q1–Q4 in that report go to the maintainer. The seat recommends Q1 A (a binding-credential service owned by cloud-connection), Q2 (i) (refuse at init only when binding is impossible), and Q3 waiting on the cloud side's endpoint.
    • The card keeps pm:dispatched and the assignee while the ruling is pending. If the ruling widens scope into packages/cloud-connection/** or packages/spec/src/contracts/, the claim is re-posted with the new file surface.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

domain:serverDomain lane — seat card indexed by label:pm:seatenhancementNew feature or requestpm:dispatchedDispatched to a dev agent by /pm-dispatchpriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions