# Run402 — HTTP API reference > Wayfinder: https://run402.com/llms.txt > SDK reference (recommended): https://run402.com/llms-sdk.txt > CLI reference: https://run402.com/llms-cli.txt > MCP reference: https://run402.com/llms-mcp.txt > OpenAPI: https://run402.com/openapi.json > Source of truth for this file: site/llms-full.txt in github.com/kychee-com/run402-private This is the canonical, machine-readable reference for the Run402 HTTP API at `https://api.run402.com`. **Use this only when you can't use the SDK / CLI / MCP** — those are the recommended surfaces for coding agents. ## First-class people and agents Run402 is agent-first because agents are first-class participants, not because people disappear. A person or agent acts through its own Run402 **principal** and authenticator; actions remain attributable to the participant that took them. An agent does not need to borrow a human login, shared cloud account, or invisible automation identity. Equal standing does not mean identical credentials or permissions. **Identity answers who acted.** Organization memberships and roles, project grants, delegates, freshness, and spend policy answer what that principal may do. An autonomous founder agent may remain owner of the org-of-one it creates. A person may join through explicit co-ownership. An agent entering an organization founded by somebody else receives bounded authority instead of a broad borrowed role. ## Pick the SDK first Run402 ships four wrappers around the same HTTP API. In order of preference for coding agents: 1. **`@run402/sdk`** ⭐ — typed TypeScript / JavaScript kernel. Every method is typed, every error is a typed subclass of `Run402Error`, never throws on network shape. **Use this if you're authoring code.** → 2. **`run402` CLI** — JSON in / JSON out, exit code on failure. For scripted shells. → 3. **`run402-mcp`** — MCP tools auto-loaded into Claude Desktop / Cursor / Cline / Claude Code. → . A hosted remote (streamable-http, free discovery tools only — quickstart, x402 price check, live experiment scoreboard) is at `https://mcp.run402.com/mcp`; it never handles funds, so paid tools require the local server. 4. **HTTP API (this file)** — only if the language has no SDK or you're integrating at a layer below. The CLI and MCP server are thin shims over the SDK, and the SDK is a thin wrapper over this HTTP API. If you can use the SDK, do — fewer process boundaries, typed errors, identical behavior. This file exists for completeness; it is not the recommended path. ## API base & host rules `https://api.run402.com` is the API. **Never POST to `https://run402.com`** — that's the static docs site (returns 405 on writes). - Single AWS region (us-east-1), 2 AZs. Aurora Postgres 16 + ECS Fargate + S3 + CloudFront. Operator: Kychee, Inc. - Health: `GET /health`. Status: `GET /status`. x402 discovery: `GET /.well-known/x402`. - Project rate limit: **100 req/sec** per project. Exceeding returns 429 with `retry_after`. - CORS: `Access-Control-Allow-Origin: *` (intentional — for browser-side x402 / MPP clients). Allowed request headers: `apikey`, `Content-Type`, `Authorization`, `Prefer`, `Accept-Profile`, `Content-Profile`, `Idempotency-Key`, `SIGN-IN-WITH-X`, `X-Confirm-Drain`, `X-Confirm-Delete`. - Node client metadata: recent SDK/CLI releases may send `Run402-Client: surface="cli", version="3.7.14", sdk="3.7.14"` so gateway error envelopes can add compatibility hints. This header is advisory only, bounded, never used for auth/billing/trust, and is intentionally not in the browser CORS allow-list yet. ## Authentication surfaces Authentication shapes, never mixed on the same request: | Header / shape | Where it works | Provided by | |---|---|---| | `X-402-Payment: ` | Paid endpoints (`POST /tiers/v1/:tier`, `POST /generate-image/v1`, `POST /contracts/v1/call`, `POST /contracts/v1/signers`, `POST /contracts/v1/signers/:signer_id/drain`, `POST /faucet/v1`) | `@x402/fetch` client; the SDK does this for you. | | `SIGN-IN-WITH-X: ` | Wallet-level free actions (`POST /projects/v1`, all `/apply/v1/*` writes, `POST /fork/v1`, `POST /feedback/v1`, `GET /ping/v1`, `POST /agent/v1/contact`, `GET /tiers/v1/status`) | `@x402/extensions/sign-in-with-x` helpers. EVM (EIP-191) or Solana (Ed25519). | | `Authorization: Bearer ` | Wherever a SIWX wallet principal is accepted, subject to org membership/grants and step-up rules. Includes project reads, transfers, org management, and portable archive export/status/download. | Control-plane login (passkey / email magic-link / Google / GitHub). Token has `token_use === "control_plane_session"`. | | `Authorization: Bearer ` | Scoped delegate actions only, e.g. portable archive export/status/download when the delegate includes `project.archives.export` for that project. Never owner-only operations. | Owner-issued delegate (`POST /projects/v1/:project_id/delegates`) returned once. | | `Authorization: Bearer ` | Project-admin/data-plane operations (`/projects/v1/admin/:project_id/*`, `/contracts/v1/signers/*`, `/mailboxes/v1/*`, `/subdomains/v1` writes, `/ai/v1/translate`, `/ai/v1/moderate`, `/ai/v1/usage`) | Returned by `POST /projects/v1`. Never expires. **Never embed in browser code.** | | `apikey: ` *(or ``)* | All client-facing surfaces: `/rest/v1/*`, `/auth/v1/*`, `/storage/v1/*`, `/content/v1/*`, `/functions/v1/:name`, `GET /apply/v1/operations/:operation_id`, `GET /apply/v1/operations/:operation_id/events`, `GET /apply/v1/operations/:operation_id/edge-coherence`, `POST /apply/v1/operations/:operation_id/resume` (read paths) | `anon_key` returned by `POST /projects/v1`. RLS applies. | | `apikey + Authorization: Bearer ` | Per-user actions: `GET /auth/v1/user`, `POST /auth/v1/logout`, `POST /auth/v1/user/password`, RLS-scoped writes on `/rest/v1/*` | `access_token` from `POST /auth/v1/token`. | | `Authorization: Bearer ` | CI-callable deploy routes: all `/content/v1/plans*` writes, all `/apply/v1/plans*` writes, `GET /apply/v1/operations/:operation_id` + `/events` + `/edge-coherence`, `POST /apply/v1/operations/:operation_id/resume`. ALSO the own-project error-read surface `GET /projects/v1/:project_id/errors` + `/:fingerprint_id` (deploy→watch loop, keyless promote gate). Token has `token_use === "ci_session"`. | Returned by `POST /ci/v1/token-exchange` after exchanging a GitHub Actions OIDC JWT. 15-min TTL bounded by binding expiry. See "OIDC federation for CI/CD" below. | | `Authorization: Bearer ` | Read-only operator-console reads (`GET /agent/v1/operator/overview`, `GET /agent/v1/operator/projects/:project_id/contents`, `POST /agent/v1/operator/session/refresh`). Token has `token_use === "operator_session"`, `scope ["operator.read"]`, `aud "run402.agent.v1.operator"`. Rejected at every mutating route. | Minted by `POST /agent/v1/operator/session/email(/verify)` (magic-link) or `/passkey/options`+`/passkey/verify` (WebAuthn login). 30-min TTL, 12h absolute cap, revocable. See "Operator console" below. | | Admin-key (operator only) | `POST /ai/v1/addons`, `DELETE /ai/v1/addons`, `POST /mailboxes/v1/:mailbox_id/status`, `GET /admin/v1/operator/overview` | Run402 platform operators. Not agent-facing. | `anon_key` and `service_key` never expire. Lease enforcement is server-side. Access tokens (`/auth/v1/token`) expire in 1h; refresh tokens expire in 30d, single-use. **Ownership & authorization (v1.77 — org-owned control plane).** A wallet address **authenticates**, it does not own. SIWX resolves your wallet to a control-plane *principal*; a project is owned by an **org (organization)** via `projects.organization_id`, and what a principal may do is decided by its org *membership* role (`owner > admin > developer > billing > viewer`) or a per-project *grant* (used for agent/CI principals that aren't broad members) — never by `wallet_address == signer`. Effects: project objects expose `org_id` (owning org) + `created_by` (provisioning principal) instead of `wallet_address`; control-plane denials return `403 FORBIDDEN` (never 404 — project existence isn't leaked); high-stakes ops (delete, transfer-of-ownership, membership change) require an active **owner** membership regardless of principal type. **Agent-first cold-start is unchanged:** a fresh wallet that subscribes + provisions becomes the legitimate owner of its own org-of-one automatically (no email/passkey/claim/approval) — the org/membership layer is invisible until a second principal joins, and independent agent ownership does not require later human takeover. Co-ownership and individual revocation (one membership row, no key rotation) follow directly. The `run402` CLI / MCP / SDK surface for `org`/membership management ships as a fast-follow; SIWX auth itself is unchanged. ## Public Nostr identity links — attribution, never authority A Run402 principal may have multiple active public Buzz/Nostr identity links. Each Nostr subject may be actively linked to only one effective Run402 principal at a time. Links are public attribution metadata only: they are never authenticators and are never consulted by organization membership, project grants, delegates, billing, payment, or transfer authorization. An `npub` cannot own a project or receive a transfer. Revoking a link preserves its public proof and does not remove organization membership; removing membership does not revoke the link. The proof shape is protocol-discriminated: - `run402.identity-link.nostr.v1` is the agent EOA + Nostr dual-signature protocol. Begin with `POST /identity-links/v1/challenges`, sign the exact RFC 8785 public payload with the Run402 EOA, publish the canonical wrapper as a standalone kind-1 Buzz/Nostr event, then complete at `POST /identity-links/v1` under the same fresh non-delegated EOA SIWX. The released managed-agent path uses `buzz social publish` and `buzz social event`; it does not use the desktop owner-binding route. - `run402.identity-link.nostr.human.v1` is the human passkey + released-Buzz consent protocol. The canonical experience is `https://console.run402.com/identity-links/connect`: a directly signed-in Run402 human accepts the public-correlation disclosure, completes a fresh passkey check, compares the six-digit code, and approves in released Buzz. Buzz returns only the signed public kind-24243 event through `browser_fragment_v1`; the callback fragment is scrubbed before network work and delivered to the code tab. No terminal, clipboard, raw-event, resource-id, or credential handling is part of the human flow. Human challenge creation is `POST /human-identity-link-challenges/v1` with `Idempotency-Key` and `{ "nostr_pubkey": "", "visibility": "public", "disclosure_accepted": true }` under a direct control-plane session plus fresh passkey. Completion is `POST /human-identity-links/v1` with the returned `human_identity_link_challenge_id` and exact public `nostr_event`, under the same human session lineage plus fresh passkey. Both are five-minute, single-use, and create no adoption or membership. The public proof states which fields are independently verifiable and which Run402 session/passkey checks Run402 attests. | Endpoint | Method | Auth | Result | |---|---|---|---| | `/identity-links/v1/challenges` | POST | fresh, non-delegated EOA SIWX | Create/idempotently recover a five-minute public challenge. Maximum three live challenges; 10 creates/10m per principal and authenticator, 30/10m per IP. 429 includes `Retry-After`. | | `/identity-links/v1` | POST | the same fresh EOA SIWX | Complete or idempotently recover the exact proof. Strict UTF-8/JSON; 32 KiB request limit; 20 attempts/10m per principal and 60/10m per IP. | | `/human-identity-link-challenges/v1` | POST | direct human control-plane session + fresh passkey | Create/idempotently recover the five-minute released-Buzz human consent challenge after explicit public-correlation disclosure. | | `/human-identity-links/v1` | POST | same direct human session lineage + fresh passkey | Verify the exact released-Buzz event and create/idempotently recover the public human link. No membership or adoption side effect. | | `/identity-links/v1?limit=&cursor=` | GET | principal auth | List all links effectively controlled by the principal, including revoked rows and each proof protocol. Principal merges retain immutable proved-principal attribution while projecting current effective control. | | `/identity-links/v1/:identity_link_id` | DELETE | linked agent via fresh EOA SIWX, or linked human via direct session + fresh passkey | Revoke attribution, limited to 10/hour per principal. Proof bytes, completed adoption receipts, and memberships remain unchanged. | | `/identity-links/v1/admin/:identity_link_id` | DELETE | platform admin key | Emergency security revocation with a required `reason_code` of `credential_compromise` or `security_response`. This audited route can only revoke; it cannot create, move, or reactivate a link. | | `/identity-link-proofs/v1/:identity_link_id` | GET | none | Public, `Cache-Control: no-store` dual-signature proof, limited to 120/minute per IP. Unknown ids return the same 404 shape regardless of caller auth. | `GET /agent/v1/whoami` adds `active_authenticator` and current `linked_identities`. Each list row carries the common lifecycle shape plus `proof_protocol`; public proof reads carry immutable `proved_principal`, merge-aware `effective_principal`, public Nostr evidence, and the Run402 verification statement for human proofs. Project creation, apply plans, actual deploy commits/operations/releases, and transfer initiation capture immutable `actor`/`creator`/`initiated_by` snapshots with principal, authenticator, linked identities active at that instant, and the independent authority source. Old records return `null`; links added later never rewrite history, and revocation changes current status without erasing prior action-time attribution. Protocol failures never create a link and conflicts never reveal another principal. A wrong EOA, wrong human/session lineage, stale passkey, wrong Nostr key, replayed/expired challenge, duplicate JSON key, unknown field, secret-shaped field, non-canonical payload, malformed signature, or wrong event id fails closed with a stable lifecycle, validation, auth, or subject-conflict code. Fixed-window limits are durable across gateway tasks and persist only keyed hashes of principal/authenticator/IP subjects. Challenge creation, completion and categorized rejection/conflict/replay, principal/platform-security revocation, and rate limiting are durably audited without logging proof bytes or signatures. Before any proof exists, operators may independently disable challenge creation, completion, public reads, or principal revocation; disabled capabilities return `503 IDENTITY_LINK_CAPABILITY_UNAVAILABLE`. Once the first public proof exists, routine controls can still disable create/complete but the gateway ignores public-read/principal-revoke shutdown flags so proof inspection and recovery remain durable. Authenticated list and platform-security revoke are never behind those write flags. ## Buzz community ↔ Run402 control plane Buzz and Run402 share a participant model without sharing an authorization system. In Buzz, people and agents are peer Nostr identities whose signed events form the collaboration record. In Run402, people and agents are first-class principals whose authenticators and authority govern infrastructure actions. Buzz is authoritative for the signed collaboration evidence; Run402 is authoritative for organizations, grants, deploys, leases, billing, delivery attempts, and runtime receipts. Public identity links and receipts connect the records. Buzz proof is evidence, never Run402 authentication or authorization. Buzz integration has four independent states. Do not collapse them: - **Skill installation:** shared software capability installed for the Buzz community. It is inert and non-authoritative. Another agent can already have the Run402 skill while having no Run402 principal or authority. - **Community installation:** an explicit, dual-consent association between one Buzz community and one Run402 organization. The Run402 owner starts it; a current Buzz owner/admin publishes Run402's exact approval JSON as an ordinary signed Nostr kind-1 event. Run402 verifies that event plus the relay's released NIP-11 `self` and NIP-43 kind-13534 membership snapshot, then publishes the safe descriptor itself. No org membership or project grant is created by installation. - **Human adoption (human co-ownership):** the Buzz human owner signs in to Run402 as a human principal and becomes a co-owner of the agent-created organization. The original agent owner remains an owner. - **Agent enrollment:** each Buzz agent uses its own Run402 wallet principal + public identity link and receives only bounded, expiring grants to named existing projects. Enrollment never makes the agent an org member, never creates projects, and never grants payment/delegate/owner authority. The ordinary independent founder-agent path remains available. A fresh wallet used on normal provisioning still gets its org-of-one. The identity-link and enrollment discovery routes deliberately resolve a fresh wallet to an org-less `agent` principal first, so an agent can ask to join the community organization without creating a throwaway org. If no installation is selected, or enrollment is declined, denied, stale, expired, or revoked, the agent may continue with the unchanged cold-start flow. ### First-party skill install and Buzz preflight The canonical human instruction is only `Please install the run402.com skill.` The apex router at `https://run402.com/install.txt` recognizes managed Buzz context, selects `run402-buzz`, and sends the pre-install agent to the detailed bootstrap. In managed Buzz this is the complete install-and-connect request, not a files-only request. The install remains agent-executed from the user-home `.buzz` workspace; the human does not know a bootstrap URL, choose a runtime target, paste a terminal command, or repeat a setup request. The pre-install agent then selects its actual runtime target and prefers passing execution fields separately: ```text working_directory: /.buzz environment: { "DO_NOT_TRACK": "1" } command: npx --yes skills@latest add https://run402.com -s run402-buzz -a codex -y ``` If only a shell string is available, the agent detects the shell and renders the environment natively: `DO_NOT_TRACK=1 npx ...` for POSIX sh/Bash/zsh, `$env:DO_NOT_TRACK = '1'; npx ...` for Windows PowerShell, or `set "DO_NOT_TRACK=1" && npx ...` for Windows cmd.exe. It never sends POSIX assignment-prefix syntax to Windows. Use `-a claude-code` for Claude Code, `-a goose` for Goose, `-a universal` only for a confirmed `.agents/skills` consumer, and `-a claude-code codex` when both runtimes use the workspace. Do not use `-a claude`; `universal` is one shared target path, not every agent. The generic installer's first invocation can still require npm. The Agent Skills Discovery index and immutable digest-verified archive are both served from `run402.com`; GitHub `buzz/` is an availability-only fallback after classified DNS/TLS/timeout/connection/unavailable-HTTP failure. A digest, archive, path/link, package-identity, or index disagreement is an integrity failure: stop with `mutation_state: "not_started"` and never change source. skills.sh is discovery-only. A successful receipt must report the observed `run402_first_party` digest, exact runtime target, and verified workspace-relative path; a GitHub source or runtime-global directory must never be mislabeled as the first-party managed-workspace install. The file-copy stage is inert. After verifying the package and workspace path, the pre-install agent directly reads the installed `SKILL.md` and continues in the same turn; it does not stop at the receipt, defer to next-turn discovery, or ask whether to set up Run402. Only an explicit files-only or no-setup/no-connect constraint stops there. Before setup mutation, the skill requires Run402 4.17.2 or newer because that is the first client with warning-only safe relay availability for founder setup. A missing or older user-global CLI is automatically upgraded through `npm install -g run402@latest`, the actual executable version is verified, and the complete preflight reruns in the same turn; a stale client's relay verdict is never shown as a human repair. On Windows, setup resolves npm's `npm-cli.js` and the user-global Run402 package's `cli.mjs`, executes both through the exact managed Node runtime with argument arrays and `shell: false`, and re-resolves Run402 after install/upgrade rather than spawning `.cmd` shims. A residual `EINVAL`/`ENOEXEC` refusal returns `BUZZ_PREFLIGHT_RUNTIME_SPAWN_REFUSED` and a managed-runtime repair, never an npm-reinstall instruction. The skill then runs its zero-mutation bootstrap and `run402 --wallet doctor --buzz --buzz-agent `. The doctor checks the configured shell, Node 22+, Run402 CLI, released Buzz CLI capabilities, exact managed-agent target, Run402 API, Run402 console, current public-safe relay, and dedicated wallet profile. Buzz capability fragments pin semantic commands/options rather than the rendered program token, so released help using `buzz.exe` on Windows passes the same fixture as `buzz` on POSIX. Every blocked/warning check has a stable code and exactly one destination-specific `shell`, `buzz_chat`, or `buzz_settings` repair with a pasteable command; shell repairs also carry tokenized `argv`. An unsafe relay blocks. A safe relay availability failure warns and permits only founder-agent/org-of-one setup; community discovery and enrollment remain unavailable until live relay proof succeeds. TLS handshake failures tell the community/Buzz operator to repair the exact public hostname or certificate route rather than reconnecting the same broken URL. A passing doctor proves environment readiness only. Setup and public identity-link verification must still finish before Run402 is called connected. Doctor telemetry is best-effort, never gates or changes output/exit/latency/mutation, and is disabled with `RUN402_TELEMETRY=0`. `POST /client-diagnostic-events/v1` accepts only the allowlisted fields `event_id,event_at,schema_version,flow_version,event,check_name,code,os_family,node_major,run402_major,run402_minor,buzz_fixture_id,install_context`. It stores a short-lived random event-id dedup row plus 180-day aggregate counters keyed by day/event/check/code/coarse versions/context. It stores no raw event, principal, wallet, Buzz subject, installation id, relay/domain/path, command, free text, IP, or credential. ### Fizz installs; Honey enrolls Fizz is the first Buzz agent. Fizz installs the inert skill, passes preflight, links Fizz's own Nostr identity, and reports readiness with no deployment. The canonical conversation is demo-first: ```text Human: @Fizz please install the run402.com skill. Fizz: Done—Run402 is connected to my Buzz identity. Would you like me to build and deploy a church bulletin board as a quick demo? Human: Yes. Fizz: Done—I built it, deployed it, and verified it at https://church.run402.com. Would you also like to become a co-owner of the Run402 organization that owns this deployment? [Become an owner](https://console.run402.com/buzz/adoptions/buzzhao_…) Human: [clicks, approves the Buzz consent, and completes the passkey prompt] Fizz: Done—you’re now a co-owner. I remain the founder-agent owner. Whenever you want me to deploy something to Run402, just say “deploy.” ``` The application is contextual rather than bundled. Fizz waits for approval, automatically uses the Base Sepolia faucet/prototype path, builds, deploys, and independently verifies the live endpoint before creating the offer. Ordinary Buzz conversation contains no testnet price/cost/funding prompt; expanded receipts still record network, faucet, tier, release, project/org, source, and verification provenance. If the verified owner explicitly asks for adoption before a demo, Fizz honors that request through the same HTTPS offer without making deployment a prerequisite. The sole-agent-owner can create or reuse one durable inert `buzzhao_…` offer only when its active identity-link proof contains the fixture-valid NIP-OA Buzz-owner subject. The normal HTTPS link is not a bearer credential and reveals no organization/member detail before login. After direct human login and fresh passkey enrollment/step-up, Run402 revalidates eligibility and creates a five-minute `buzzha_…` attempt bound to the exact target human principal and browser session lineage. The page shows the six-digit code and explicitly opens the already-released `buzz://nostr-bind` `return=browser_fragment_v1` flow. Buzz signs as the desktop owner and opens the exact same-origin callback with `#buzz_bind=v1.`; the console scrubs the fragment before decoding, never persists/logs it or the session, and completes in the bound code-tab session. Fizz polls the offer and never infers completion from a click. One successful transaction creates or reuses the human's public `idlnk_…`, adds the ordinary `org_memberships(role='owner')` row, and marks the adoption and offer `completed`, with separate `identity_link.complete`, `org.member.add`, and `buzz.human_adoption.complete` audit rows grouped by one operation id. The completed adoption is a terminal consent receipt, not an authorization relation. Membership alone grants organization authority; identity-link and membership revocation are independent and neither rewrites the completed receipt. Fizz remains owner and no organization ownership, project, or credential moves. Existing direct adoption remains an explicitly advanced compatibility path. The Run402 owner then starts community installation with a normalized subject such as `buzz:community:acme.communities.buzz.xyz` and the current Buzz owner/admin public key. The installation remains `pending` and returns canonical `authority_proof_content.approval_content`. A current Buzz owner/admin publishes that exact JSON using Buzz's existing `buzz social publish --content` kind-1 path (the CLI needs `BUZZ_PRIVATE_KEY`; a human owner's key is available in Buzz Desktop under Settings → Profile → Identity, and on macOS in the keychain under service `buzz-desktop`, item `identity`). The pending payload also carries `notification_pubkey` with `relay_member_add_instruction`: the owner adds that pubkey as a relay member in the same sitting, because membership-gated relays only serve the activation evidence to admitted members. Activation supplies the signed event and relay URL. Run402 independently reads the relay's NIP-11 `self`, requires released NIP-43 support, verifies the relay-signed kind-13534 membership snapshot, checks that the signer currently has the `owner` or `admin` role, and then records the installation as active. Buzz itself needs no protocol, UI, CLI, or release change. Nostr keys are never accepted by Run402. New installations always activate with `default_for_enrollment: false`; Run402 never infers the default from creation or discovery order. The Run402 owner can update the complete policy and marker with optimistic revision control. To replace an existing default, first clear the old installation and then select the new one. The second step fails without mutation while the old default remains. Run402 publishes every safe descriptor revision on its own public discovery surface, so clients never infer priority from discovery order. Honey already inherited the software skill from the Buzz community, but Honey still has no Run402 identity. Honey authenticates its own EOA through the org-less principal path, creates its own public identity link, discovers the current Run402 descriptor by normalized community subject, explicitly selects it, and requests an enrollment. Run402 reports only `can_select_community_installation`; selection remains a separate client decision, so the server never claims request readiness. The Run402 integration offers enrollment only after exactly one usable descriptor is selected and suppresses a second offer while an enrollment is pending or active. Run402 reads the current relay-signed kind-13534 membership snapshot and binds Honey's own Nostr public key—never Fizz's—to the request. Manual policy returns `pending` until an owner approves a complete narrowing subset. Automatic policy is opt-in only and requires current community membership, an explicit capability allowlist, and a finite TTL cap. Successful approval creates project grants only; Honey never joins the organization. Every descriptor exposes a complete `safe_policy_summary`: `mode`, mandatory current-community-membership enforcement, the exact effective `allowed_capabilities`, and the exact effective `max_grant_ttl_seconds`. The last two are explicit `null` when no allowlist or cap is configured; clients never infer hidden restrictions from their omission. All relay reads use one hardened transport boundary. For each NIP-11 HTTPS request and Nostr WebSocket connection, Run402 resolves the relay anew, rejects literal/private/reserved or mixed public/private destinations, pins the validated address set into the actual TLS dial, disables redirects, and bounds time and response size. A DNS check is never followed by an unpinned second resolution. ### Routes Every mutation requires `Idempotency-Key`. Owner-session mutations require a fresh passkey; SIWX is inherently fresh. Protected ids return the same `403 BUZZ_RESOURCE_FORBIDDEN` for unknown and unauthorized resources. The descriptor route is the only public read. Buzz owns no organizations of its own: `org_id` here is the ordinary Run402 organization id, passed and returned in exactly the form `GET /agent/v1/whoami`, `GET /orgs/v1` and `GET /projects/v1` hand back (a UUID such as `01234567-89ab-4def-8123-456789abcdef`) — never transform it. The one exception is inside the published community descriptor, where the same id appears resource-qualified as `org_<32-hex>` because that document is content-hashed and read outside Run402, where a bare UUID carries no type. | Endpoint | Method | Auth | Result | |---|---|---|---| | `/client-diagnostic-events/v1` | POST | none | Best-effort allowlisted Buzz-doctor funnel event; returns 204 and stores aggregate counters only. | | `/buzz-human-adoption-offers/v1` | POST | eligible sole founder-agent via SIWX | Create/reuse one durable inert `available` offer from `org_id`, `identity_link_id`, and an optional verified `deployment_context`; returns normal HTTPS `handoff_url`. `deployment_context` takes exactly these five non-empty strings and no others — `project_id`, `release_id`, `live_url` (public HTTPS origin, no credentials or fragment), `source_revision`, `verified_at` (ISO-8601, not in the future); the gateway checks them against the org's active release and its claimed subdomain / custom domain / deployment host. | | `/buzz-human-adoption-offers/v1/:buzz_human_adoption_offer_id` | GET | initiating agent SIWX or signed-in target human session | Authoritative `available|completed|cancelled|ineligible` offer state; authorize before reveal. | | `/buzz-human-adoption-offers/v1/:buzz_human_adoption_offer_id` | DELETE | initiating agent SIWX | Cancel an available offer and only its exact pending attempt; no membership change. | | `/buzz-human-adoption-offers/v1/:buzz_human_adoption_offer_id/attempts` | POST | direct human session + fresh passkey | Revalidate eligibility, bind exact human/session, and create/recover the five-minute browser-fragment adoption attempt from the exact console callback URL. | | `/buzz-human-adoptions/v1` | POST | sole agent owner via SIWX | Begin adoption from `org_id` + the agent's active `identity_link_id`; returns pending public owner consent. | | `/buzz-human-adoptions/v1?org_id=...` | GET | org owner SIWX/session | List retained adoption lifecycle rows. | | `/buzz-human-adoptions/v1/:buzz_human_adoption_id` | GET | initiating agent or org owner | Read the safe adoption resource. | | `/buzz-human-adoptions/v1/:buzz_human_adoption_id/milestones` | POST | exact target human control-plane session | Idempotently append one allowlisted observational handoff stage (`buzz_open_requested`, `callback_received`, `manual_proof_submitted`, or `completion_error_observed`) without fresh passkey or authority effect; proof, credential, identity/session, browser/network, and arbitrary-text fields are forbidden. | | `/buzz-human-adoptions/v1/:buzz_human_adoption_id/complete` | POST | exact human control-plane session + fresh passkey | Verify `owner_proof_event`; atomically create/reuse the public human link, add the ordinary human owner membership, complete the terminal consent receipt/offer, retain the agent owner, and emit three correlated generic audit events. | | `/buzz-human-adoptions/v1/:buzz_human_adoption_id` | DELETE | initiating agent SIWX | Cancel only while pending. | | `/buzz-community-installations/v1` | POST | org owner SIWX/session + freshness | Begin installation from org, normalized community subject, authority pubkey, and optional complete policy. | | `/buzz-community-installations/v1?org_id=...` | GET | org owner SIWX/session | List the organization's many installations. | | `/buzz-community-installations/v1/:buzz_community_installation_id` | GET | org owner SIWX/session | Protected installation, evidence ids, revisions, policy, freshness, drift. | | `/buzz-community-installations/v1/:buzz_community_installation_id/activate` | POST | org owner SIWX/session + freshness | Verify `{buzz_community_subject,approval_event,relay_url}` against NIP-11 `self` and the current relay-signed NIP-43 membership snapshot, then activate. | | `/buzz-community-installations/v1/:buzz_community_installation_id` | PATCH | org owner SIWX/session + freshness | Update this installation's complete policy/default marker with optimistic policy revision. Selecting `true` requires no other active default; clear the old default first. | | `/buzz-community-installations/v1/:buzz_community_installation_id` | DELETE | org owner SIWX/session + freshness | Revoke the Run402 installation; do not mutate existing enrollments or grants. | | `/buzz-community-installation-descriptors/v1?buzz_community_subject=...` | GET | none | Discover active Run402 descriptors for one normalized Buzz community subject. | | `/buzz-community-installation-descriptors/v1/:buzz_community_installation_id` | GET | none | Public safe Run402 descriptor plus approval and membership evidence; never returns protected org/member/project fields or credentials. | | `/buzz-agent-enrollments/v1` | POST | requesting agent SIWX, no eager org | Request named existing-project grants using the agent's own `identity_link_id`; returns pending or policy-active. | | `/buzz-agent-enrollments/v1?org_id=...&status=...` | GET | owner when org supplied; otherwise requesting agent | Caller-curated enrollment list. | | `/buzz-agent-enrollments/v1/:buzz_agent_enrollment_id` | GET | requester or org owner | Safe enrollment, exact evidence/revisions/grants, advisory drift. | | `/buzz-agent-enrollments/v1/:buzz_agent_enrollment_id/approve` | POST | org owner SIWX/session + freshness | Approve a complete narrowing `approved_grants[]` subset and atomically create linked project grants. | | `/buzz-agent-enrollments/v1/:buzz_agent_enrollment_id/deny` | POST | org owner SIWX/session + freshness | Deny with no grants. | | `/buzz-agent-enrollments/v1/:buzz_agent_enrollment_id` | DELETE | requester or org owner | Pending requester cancellation, or active enrollment revocation; only linked grants are revoked. | Creation example for community installation: ```http POST /buzz-community-installations/v1 SIGN-IN-WITH-X: Idempotency-Key: install-acme-run402-v1 Content-Type: application/json {"org_id":"01234567-89ab-4def-8123-456789abcdef","buzz_community_subject":"buzz:community:acme.communities.buzz.xyz","buzz_community_authority_subject":"<64-lowercase-hex>","enrollment_policy":{"mode":"manual","requires_current_community_membership":true}} ``` Honey's manual enrollment request: ```http POST /buzz-agent-enrollments/v1 SIGN-IN-WITH-X: Idempotency-Key: honey-acme-enrollment-v1 Content-Type: application/json {"buzz_community_installation_id":"buzzci_0123456789abcdef0123456789abcdef","identity_link_id":"idlnk_0123456789abcdef0123456789abcdef","requested_grants":[{"project_id":"my-existing-project","capability":"deploy","policy":{},"expires_at":"2026-08-06T12:00:00.000Z"}],"expires_at":"2026-08-06T12:00:00.000Z"} ``` Responses expose at most one highest-probability `next_actions` entry, always with `type`, optional `method`/`path` and exact rejected `field`, `auth`, `why`, `safe_to_auto_execute`, `requires_approval`, `destructive`, `idempotent`, and zero-USD `spend_impact`. Offer creation returns `open_buzz_human_adoption_handoff`; later agent reads return `poll_buzz_human_adoption_offer`. Opening the link does not change state. Intermediate attempt state says `pending`; it never claims adoption before activation. Due attempts are synchronously persisted as `expired` with `expired_at` and audit before response; the durable offer stays `available` and releases the target-bound attempt slot for a fresh retry. Due installations and enrollments converge similarly. Offer reads include a bounded `latest_attempt_receipt` with the attempt reference, authoritative lifecycle timestamps, allowlisted browser/server stages, deterministic diagnosis code, and the last available gateway `trace_id`. The receipt never includes the signed proof/event id, credential, email, principal/session identifier, browser fingerprint, user agent, network-address payload, URL fragment, or arbitrary client text. Client milestones are observations only and cannot change authority. `GET /agent/v1/whoami` reports offer capability/state plus independent skill, adoption, installation, enrollment, eligibility, and drift summaries. Stable additive offer/attempt codes are `BUZZ_ADOPTION_OFFER_INELIGIBLE`, `BUZZ_ADOPTION_OFFER_NOT_ACTIVE`, `BUZZ_ADOPTION_ATTEMPT_EXPIRED`, `BUZZ_ADOPTION_TARGET_MISMATCH`, and `BUZZ_ADOPTION_CALLBACK_INVALID`; they join the existing Buzz code set. Each maps to one code-specific repair: inspect eligibility, read terminal offer state, create a fresh attempt from the same offer, return to the original signed-in browser session, or use the exact console callback. Wrong signer remains `BUZZ_OWNER_PROOF_INVALID`; replay/idempotency/step-up/rate/transient recovery retains its existing exact code. There is no generic `edit_request` fallback. Target or proof theft never changes membership, and terminal completion replays only for the same target/session lineage. Revoking installation removes it from discovery but leaves existing enrollment grants untouched. Revoking enrollment affects exactly the grants created for that enrollment. ### Project-event routing into a Buzz channel An org owner can push selected project events into a Buzz community channel. A route is an explicit installation-scoped destination: one active community installation, an explicit set of 1–50 named projects owned by the installed organization (future or transferred-away projects are never included automatically), reviewed event filters, one NIP-29 `buzz_channel_id`. Run402's project-event feed stays authoritative — Buzz relay state never creates, acknowledges, or advances a Run402 event. Delivery is signed by a Run402-controlled notification principal: one dedicated Nostr keypair per installation, envelope-encrypted under KMS, scoped only to relay posting. No human session, agent wallet, delegate, project service key, or community-authority key can sign a delivery, and no read ever returns the secret — the `notification_pubkey` and `signing_generation` are the only credential material any response exposes. Every mutation requires `Idempotency-Key` and fresh `buzz.event_route` step-up (SIWX is inherently fresh). The workflow is configure → authorize → test → live. Create returns `201` with the route plus an `authorization` block. The installation's first route is `pending_authorization` and the block carries the one non-secret handoff: a Buzz community owner or admin adds the `notification_pubkey` as a relay member using already-released surfaces (`buzz-admin add-member `, `./run.sh add-member `, or a kind:9030 admin event). Then `POST …/test` — it re-checks the NIP-43 membership (activating the route when it landed), queues one signed test message, and returns `202` + `Retry-After: 2` with a poll path; the single-consumer tick publishes it. From then on matching events flow as NIP-29 kind:9 channel messages. Routes deliver new events only (`start_after_event_id` is the outbox floor captured at creation), and the relay must advertise the frozen notification capability (NIPs 29 + 42 + 43 with a stable 64-hex `self`) or creation fails `503 BUZZ_NOTIFICATION_CAPABILITY_UNSUPPORTED`. Only three reviewed event types are routable: `deploy_activated`, `error_fingerprints_observed`, and `platform_incident`. Each has a reviewed projector that emits a concise safe summary — `project_events.payload` is never serialized generically — and the classes `security`, `billing_critical`, `destructive_lifecycle`, `verification`, and `recovery` may never be routed. `event_types`/`event_classes` omitted or null means every registered routable type / non-forbidden class; an explicit `[]` is rejected (`422 BUZZ_ROUTE_FILTER_INVALID`) rather than silently matching everything or nothing. Delivery is at-least-once with exactly one row per `(route, event)` — the row is simultaneously the dedup key, the identical-byte retry store (the stored signed envelope is republished byte-identically, so the relay converges on one Nostr event id), and the signing-generation fence. Retries back off 1m/5m/30m/2h/12h to a ceiling of 8 attempts or 48 hours, then the row becomes `dead_letter` — visible in the deliveries read; there is no separate dead-letter ledger. Ten consecutive hard publish failures auto-pause the route (`pause_reason: "delivery_failures"`) and fire the mandatory `buzz_route_auto_paused` operator notification. Buzz is never a deadman channel. A route has no primary/required/acknowledgement authority over operator-health email, Telegram, or any future human channel; mandatory notification classes continue through their human paths regardless of Buzz route state, and a Buzz success acknowledges nothing. | Endpoint | Method | Auth | Result | |---|---|---|---| | `/buzz-project-event-routes/v1` | POST | org owner SIWX/session + freshness | Create a route from `org_id`, active `buzz_community_installation_id`, unique `route_name`, `buzz_channel_id`, `project_ids[]`, optional `event_types[]`/`event_classes[]`; `201` returns the route plus `authorization` (may be `pending_buzz_authorization` with the non-secret connect handoff). | | `/buzz-project-event-routes/v1?org_id=...` | GET | org owner SIWX/session | List the organization's routes, including retained revoked ones. | | `/buzz-project-event-routes/v1/:buzz_project_event_route_id` | GET | org owner SIWX/session | Route plus honest `health` (derived from route + credential state, never from queue emptiness), counts by delivery state, oldest pending time, and the shared consumer cursor. | | `/buzz-project-event-routes/v1/:buzz_project_event_route_id/deliveries?limit=&cursor=&delivery_id=` | GET | org owner SIWX/session | Keyset newest-first history `{buzz_project_event_deliveries, has_more, next_cursor}` — dead letters included, the signed envelope never. | | `/buzz-project-event-routes/v1/:buzz_project_event_route_id` | PATCH | org owner SIWX/session + freshness | Update name/channel/project scope/filters with `expected_revision`; a stale revision fails `409 BUZZ_ROUTE_REVISION_STALE` without mutation. | | `/buzz-project-event-routes/v1/:buzz_project_event_route_id/pause` | POST | org owner SIWX/session + freshness | Stop matching new events; retained non-terminal deliveries freeze, and events occurring while paused are never retroactively delivered. | | `/buzz-project-event-routes/v1/:buzz_project_event_route_id/resume` | POST | org owner SIWX/session + freshness | Re-arm delivery and reset the hard-failure counter; requires a live signing credential, else `503 BUZZ_NOTIFICATION_SIGNING_UNAVAILABLE`. | | `/buzz-project-event-routes/v1/:buzz_project_event_route_id/test` | POST | org owner SIWX/session + freshness | Reconcile pending Buzz authorization, then queue one signed test delivery; `202` + `Retry-After: 2`, poll the returned deliveries path. | | `/buzz-project-event-routes/v1/:buzz_project_event_route_id/rotate` | POST | org owner SIWX/session + freshness | Stage the NEXT signing generation (`202` with the next pubkey + authorize hint); the swap activates only after the new pubkey's own NIP-43 membership verifies. | | `/buzz-project-event-routes/v1/:buzz_project_event_route_id` | DELETE | org owner SIWX/session + freshness | Revoke: cancel queued deliveries, keep sanitized history readable, and destroy the installation's notification credential only when this was its last live route (`notification_credential_destroyed`). | Additive route codes are `BUZZ_ROUTE_NOT_FOUND` (the uniform in-org 404 — unknown and foreign ids stay `403 BUZZ_RESOURCE_FORBIDDEN`), `BUZZ_ROUTE_SCOPE_INVALID`, `BUZZ_ROUTE_FILTER_INVALID`, `BUZZ_ROUTE_REVISION_STALE`, `BUZZ_ROUTE_STATE_INVALID`, `BUZZ_ROUTE_NAME_CONFLICT`, `BUZZ_INSTALLATION_NOT_ACTIVE`, `BUZZ_NOTIFICATION_CAPABILITY_UNSUPPORTED`, `BUZZ_NOTIFICATION_SIGNING_UNAVAILABLE`, and `BUZZ_RATE_LIMITED`; they join the existing Buzz code set. Revoking a community installation immediately blocks all attached routes without mutating project events, enrollments, memberships, or grants. ## Operator console — read-only organization overview An API-first, **read-only** surface for the human operator (or the agent acting on their behalf). It authenticates by **email control** — no signing wallet, no custody — and grants read of every wallet whose verified `agent_contacts` email matches the session email. The reference web console is just a consumer of these endpoints. **Endpoints:** | Endpoint | Method | Auth | Purpose | |---|---|---|---| | `/agent/v1/operator/session/email` | POST | none (rate-limited) | Send a magic-link sign-in (no account-existence oracle) | | `/agent/v1/operator/session/email/verify` | POST | magic-link token | Exchange the fragment token → operator session | | `/agent/v1/operator/session/passkey/options` | POST | none | WebAuthn login options | | `/agent/v1/operator/session/passkey/verify` | POST | WebAuthn assertion | Exchange assertion → operator session | | `/agent/v1/operator/session/passkey/enroll/options` | POST | operator session | WebAuthn registration options (enroll a passkey) | | `/agent/v1/operator/session/passkey/enroll/verify` | POST | operator session | Verify registration → persist the passkey | | `/agent/v1/operator/session/refresh` | POST | operator session | Rotate + re-issue the session token | | `/agent/v1/operator/session/device` | POST | none (email-less) | Start a CLI device-authorization (RFC 8628); returns `device_code`, `user_code`, verification URIs | | `/agent/v1/operator/session/device/approve` | POST | operator session (recent auth) | Approve/deny a pending `user_code` (binds the decision; mints no session) | | `/agent/v1/operator/session/device/token` | POST | none (`device_code` is the credential) | Poll for the session; RFC-8628 error body (`authorization_pending` / `slow_down` / `access_denied` / `expired_token`) until approved | | `/agent/v1/operator/session/revoke` | POST | operator session | Sign out — revoke the current session (its `jti`); idempotent `204` | | `/agent/v1/operator/overview` | GET | operator session **or** control-plane session **or** SIWX | Tier-A account summary (counts only) | | `/agent/v1/operator/projects` | GET | operator session **or** SIWX | Named cross-account project inventory (`projects list --all`): name + site_url + custom domains + owning org | | `/agent/v1/operator/projects/{project_id}/contents` | GET | operator session (passkey) **or** SIWX | Tier-B inventory names (never values) | **Auth: the operator session.** A read-scoped HS256 bearer (`typ: "run402.operator-session+jwt"`, `aud: "run402.agent.v1.operator"`, `scope: ["operator.read"]`, `token_use: "operator_session"`), signed with a dedicated secret (asserted distinct from the tenant-JWT and CI-session secrets). Minted two ways; both return `{ operator_session_token, token_type: "Bearer", expires_in, absolute_expires_at, email, wallets[] }`: - **Magic-link:** `POST /agent/v1/operator/session/email {email}` returns an **identical** 200 whether or not the email has an account (no account-existence oracle); a single-use link (token in the URL **fragment**) is emailed only to an email controlling ≥1 verified wallet. Exchange the fragment token via `POST /agent/v1/operator/session/email/verify {token}`. - **Passkey-login:** `POST /agent/v1/operator/session/passkey/options {email}` → `POST /agent/v1/operator/session/passkey/verify {email, response}` (standard WebAuthn authentication against the operator's enrolled passkey; no PRF, no signing wallet). - **Refresh:** `POST /agent/v1/operator/session/refresh` (with the operator-session bearer) rotates the token. 30-min access TTL; 12h absolute re-auth cap; server-side revocable. - **CLI device login (RFC 8628):** `POST /agent/v1/operator/session/device` (no email) returns a `device_code` + `user_code` + `verification_uri`/`verification_uri_complete`; the operator opens the URL and `POST /agent/v1/operator/session/device/approve {user_code, decision?}` (operator-session bearer authenticated within the last few minutes) binds the approve/deny decision without minting a session; the CLI polls `POST /agent/v1/operator/session/device/token {device_code}` and receives the session on approval (an RFC-8628 `{ error }` body — `authorization_pending` / `slow_down` / `access_denied` / `expired_token` — until then). - **Sign out:** `POST /agent/v1/operator/session/revoke` (operator-session bearer) revokes the current session by its `jti`; idempotent `204`, effective on the next request. **Tier-A summary read:** `GET /agent/v1/operator/overview` — accepts an operator-session bearer (union across the email's verified wallets), a **control-plane session** bearer (the console's write-capable sign-in reading its OWN footprint: union across the principal's verified emails plus its siwx wallet authenticators; `scope.kind` is `"principal"`), **or** a `SIGN-IN-WITH-X` header (that wallet's slice). The bearer is classified by its `token_use` claim — no silent fallback. Service/admin keys are NOT accepted here, and a control-plane session is Tier-A only (it is NOT accepted on the Tier-B contents read). Returns a multi-organization shape `{ scope, operator, rollup, organizations[], wallets[], advisories[] }` with **counts only** (functions/secrets/domains/mailboxes) — never inventory names. **Cross-account project inventory (`projects list --all`):** `GET /agent/v1/operator/projects` — the named, domain-aware project inventory across the operator's verified-email wallet union (operator-session bearer), or a single wallet's slice (`SIGN-IN-WITH-X`). Returns `{ projects: [{ project_id, name, tier, site_url, custom_domains, status, org_id, created_by, created_at }], scope }` — the same row shape as `GET /projects/v1`, so one renderer covers both. Soft-deleted (tombstone) projects are never listed; archived hidden by default — opt in with `?include=archived` (any other value → 400). Names + public addressing only; no secret/key value. Service/admin keys are NOT accepted here. Authority parity with the overview: a project appears iff one of the union's wallets resolves to a principal holding an active org membership on its owning org (or an active project grant). **Tier-B inventory read:** `GET /agent/v1/operator/projects/:project_id/contents` — secret **names** (never values), function names, domains, subdomains, mailbox slugs. An operator session needs **fresh proof** (passkey-login, or a magic-link auth within the last 5 min); SIWX is inherently fresh. The project must be owned by a wallet in the caller's set (fresh DB read). **Disclosure ceiling:** read-only by construction — the operator session 401s at every mutating route (enforced by a central route auth/effect manifest + a CI gate, not by convention). No secret value, private key, or token appears in any tier. Support/debug reads go through the admin-only, audited `GET /admin/v1/operator/overview` (mandatory `email|wallet|org_id` target). Later phases add email-safe action-scoped mutations and a browser signing wallet; Phase 0 (this) is strictly read-only. ## Control-plane console — hosted owner login (write-capable session) Distinct from the read-only operator session above. The **control-plane session** is the human owner's **write-capable** login for org management (members, invites, audit, project handoffs). It is a different actor, token class, and data scope from the tenant end-user hosted auth UI (`/auth/sign-in`, the `@run402/astro` `` surface, which logs in a *project's* end-users) — the two `/auth/*`-shaped flows are never conflated. The hosted browser pages live on the console origin (`console.run402.com`); the gateway owns the auth endpoints (the console holds no privileged secret — it is a pure-static consumer). Tokens are held **in memory only** (never `localStorage`). **Endpoints (`/agent/v1/control-plane/*`):** | Endpoint | Method | Auth | Purpose | |---|---|---|---| | `/agent/v1/control-plane/session/email` | POST | none (rate-limited) | Send a magic-link sign-in (no account-existence oracle) | | `/agent/v1/control-plane/session/email/verify` | POST | magic-link token | Exchange the fragment token → control-plane session | | `/agent/v1/control-plane/session/passkey/options` | POST | none | WebAuthn login options | | `/agent/v1/control-plane/session/passkey/verify` | POST | WebAuthn assertion | Exchange assertion → control-plane session | | `/agent/v1/control-plane/oauth/{provider}/start` | GET | none | 302 to the provider (`google` / `github`). `503` until OAuth creds are provisioned | | `/agent/v1/control-plane/oauth/{provider}/callback` | GET | provider redirect | Resolve identity **by verified email** → mint → land on `CONSOLE_ORIGIN#cp_session` (or `#oauth_needs_confirmation` on an email conflict) | | `/agent/v1/control-plane/session` | GET | control-plane session | Whoami — principal, `amr`, active memberships | | `/agent/v1/control-plane/session/refresh` | POST | control-plane session | Rotate + re-issue the session token | | `/agent/v1/control-plane/session/revoke` | POST | control-plane session | Sign out — revoke the current session (its `jti`) | | `/agent/v1/control-plane/session/browser` | POST | control-plane bearer + same-origin browser request | Exchange the current live bearer-backed session for an opaque `__Host-Http-r402_cp_session` cookie; response returns metadata only. | | `/agent/v1/control-plane/session/browser/refresh` | POST | control-plane browser cookie + same-origin browser request | Rotate the cookie handle and backing session generation without returning a bearer. | | `/agent/v1/control-plane/session/browser/revoke` | POST | control-plane browser cookie + same-origin browser request | Revoke the cookie and backing control-plane session; clearing is idempotent. | | `/agent/v1/control-plane/passkey/enroll/options` | POST | control-plane session | WebAuthn registration options (enroll a passkey) | | `/agent/v1/control-plane/passkey/enroll/verify` | POST | control-plane session (step-up) | Verify registration → persist the passkey. Also claims any pending org invites deferred on the passkey requirement (response carries `invites_claimed`) | | `/agent/v1/control-plane/step-up/options` | POST | control-plane session | Fresh-WebAuthn step-up options for a high-stakes op class | | `/agent/v1/control-plane/step-up/verify` | POST | control-plane session | Record the fresh passkey elevation (then retry the op) | | `/agent/v1/control-plane/recovery/issue` | POST | control-plane session (step-up) | (Re)issue recovery codes — shown once | | `/agent/v1/control-plane/recovery/consume` | POST | recovery code | Recovery ceremony → session (`amr: ["recovery_code"]`, never satisfies step-up) | | `/agent/v1/control-plane/authenticators` | GET | control-plane session | List active authenticators | | `/agent/v1/control-plane/authenticators/{authenticator_id}` | DELETE | control-plane session (step-up) | Revoke one authenticator (last-owner-passkey guarded) | | `/agent/v1/control-plane/cli/authorize/{flow_id}` | GET | control-plane session | CLI loopback-PKCE: load the pending flow for the approve page | | `/agent/v1/control-plane/cli/approve` | POST | control-plane session (step-up) | Approve the CLI flow → return the loopback redirect URL with the auth code | | `/agent/v1/control-plane/oauth/{provider}/link` | POST | control-plane session (step-up) | Start an OAuth flow in LINK mode → `{ auth_url }`; the callback attaches the identity to the CURRENT principal (completes `needs_confirmation`) | | `/agent/v1/control-plane/emails` | POST | control-plane session (step-up) | Request attaching an additional email (generic response — no account-existence oracle) | | `/agent/v1/control-plane/write-auth/challenges` | POST | control-plane session | Open a passkey write-intent ceremony for one action+target → `{ challenge_id, confirm_url, expires_at }` | | `/agent/v1/control-plane/write-auth/verify` | POST | WebAuthn assertion | Verify the write-intent assertion → mint the target-scoped write-auth session (opaque token, returned once). In CLI loopback mode the mint is deferred — returns `{ delivery: "cli_loopback", redirect_to }` instead | | `/agent/v1/control-plane/write-auth/cli/token` | POST | claim code + PKCE | Headless-CLI delivery: exchange the loopback claim `code` + `code_verifier` + `state` → mint the write-auth session, return the token once. Open the challenge with `cli_redirect_uri` + `code_challenge` (S256) + `state` to use this leg | | `/agent/v1/control-plane/write-auth/sessions` | GET | control-plane session | List active write-auth sessions bound to this session | | `/agent/v1/control-plane/write-auth/sessions/{write_auth_session_id}/revoke` | POST | control-plane session | Revoke one write-auth session | | `/agent/v1/control-plane/write-auth/revoke-all` | POST | control-plane session | Revoke every write-auth session for the principal | | `/agent/v1/control-plane/emails/verify` | POST | control-plane session + attach token | Confirm the attach (inbox + same-session proof) → verified email on this principal; `409 EMAIL_BELONGS_TO_OTHER_PRINCIPAL` → merge | | `/agent/v1/control-plane/merge` | POST | control-plane session (step-up, passkey) | Merge a duplicate principal into this one — body `{ source_session_token, source_principal_id? }`; Phase 1 needs an authority-empty source | **Auth: the control-plane session.** A bearer JWT `typ: "run402.control-plane-session+jwt"`, signed with `CONTROL_PLANE_SESSION_SECRET` (asserted distinct from `JWT_SECRET`, `CI_SESSION_SECRET`, and `OPERATOR_SESSION_SECRET` — the fourth token-class-confusion guard). Login mints it; `resolveAuthContext` turns it into the AuthContext the org policy matrix authorizes against. High-stakes ops (delete, transfer-of-ownership, membership/invite change, authenticator change, CLI approve) require a fresh **passkey** step-up — a recent password/magic-link proof does NOT satisfy it; the gateway returns `STEP_UP_REQUIRED` and the client re-runs the step-up ceremony then retries. OAuth identities **link by verified email** (an OAuth account whose email already belongs to an owner is *not* auto-linked — the callback lands on `#oauth_needs_confirmation`; sign in as that owner, then complete the attach with the explicit link flow: `POST /agent/v1/control-plane/oauth/{provider}/link` from account settings). One human with identities split across two principals (e.g. a GitHub login keyed to a different email) consolidates them with `POST /agent/v1/control-plane/merge` — both-sides possession proof, atomic credential re-pointing, source disabled with audit traceability. **Hosted browser legs (on `console.run402.com`):** the sign-in / sign-up pages drive the email / passkey / OAuth endpoints; an OAuth-handoff landing reads the `#cp_session=` (or `#oauth_needs_confirmation=1&email=…&provider=…`) URL fragment, scrubs it via `replaceState`, and establishes the in-memory session; a CLI loopback approve / passkey-ceremony page completes `run402 operator login` end-to-end (the gateway 302s the browser there from the CLI loopback authorize, the user runs the passkey ceremony + approves, and the browser is sent to the `127.0.0.1` loopback redirect with the auth code). The public SDK/CLI client surface (`r.operator.session.*`, nested `r.org.*`, `StepUpRequiredError`, operator loopback-PKCE login) shipped in `run402` v2.40.0 / v2.41.0. **OAuth provisioning runbook (deploy-time, no code change).** `services/control-plane-oauth.ts` fails closed with `503 "Control-plane OAuth is not configured"` whenever `CONTROL_PLANE_{GOOGLE,GITHUB}_CLIENT_ID/SECRET` are empty. To go live: (1) register the Google + GitHub OAuth apps with redirect URI = `CONTROL_PLANE_OAUTH_REDIRECT_BASE` + `/agent/v1/control-plane/oauth/:provider/callback`; (2) provision `CONTROL_PLANE_GOOGLE_CLIENT_ID/SECRET` + `CONTROL_PLANE_GITHUB_CLIENT_ID/SECRET` in AWS Secrets Manager and wire them into the gateway task definition (alongside `CONTROL_PLANE_SESSION_SECRET`); (3) redeploy the gateway and verify `/oauth/{google,github}/start` no longer 503, the provider redirect fires, and the callback links by verified email and completes the `CONSOLE_ORIGIN#cp_session` handoff. CORS for the `Authorization`-bearing control-plane + operator routes allowlists the console origin (not `*`). ## Tiers, projects & lifecycle | Tier | Cost | Lease | Storage | Vault (gitvault) | API calls | Functions | Max function bundle | |------|------|-------|---------|-------------------|-----------|-----------|---------------------| | Prototype | **FREE** (testnet USDC, $0.10 to verify x402 setup, ONCE) | **perpetual — never expires, never scheduler-advanced** | 250 MB | 1 GB | 500K | 15 | 1 MB | | Hobby | $5 / 30 days | leased | 1 GB | 10 GB | 5M | 50 | 5 MB | | Team | $20 / 30 days | leased | 10 GB | 50 GB | 50M | 1500 | 25 MB | **Prototype is a one-time payment, not a lease (kygit-handoff D4).** `POST /tiers/v1/prototype` grants perpetual prototype: `lease: "perpetual"`, `lease_days: null`, `lease_expires_at: null`, and the organization's lifecycle ladder never advances past `active` on this tier — there is no 7-day cliff to renew before. A re-purchase of prototype by an organization that already holds it short-circuits BEFORE the x402 paywall: `200 { status: "already_active", deduplicated: true, tier: "prototype", lease: "perpetual", lease_days: null, lease_expires_at: null, active: true }` — no charge attempted, no payment verified or settled, no ledger row written. Upgrading to hobby/team charges normally and sets a real, renewable lease (`lease: "leased"`); downgrading a paid tier back to prototype is unsupported directly — it happens automatically via the lifecycle ladder (see "Organization lifecycle" below) or is reachable by simply letting a paid lease lapse. **Free is 1 GB of encrypted vaults, unlimited repos — never expires, never deleted for non-payment.** `sourceBytes` (the gitvault vault-bytes quota) is a SEPARATE org-pooled limit from `storageBytes` (app/project storage) — see "gitvault" below for the two-limit split and the prototype egress allowance. Real-money tiers: USDC on Base (chain `eip155:8453`) or pathUSD on Tempo (MPP) or Stripe credits. Testnet is Base Sepolia (`eip155:84532`). Same wallet key works on both rails. ``` POST /tiers/v1/:tier # x402; auto-detects subscribe / renew / upgrade; prototype re-purchase short-circuits to already_active with no charge GET /tiers/v1 # tier pricing, no auth — lease_days/lease/source_bytes per tier GET /tiers/v1/status # current tier, lease, lease_days, downgraded_at, organization_lifecycle_state, lease_perpetual, pool_usage (incl. gitvault_source_bytes/source_bytes_limit/gitvault_egress_bytes_used/gitvault_egress_bytes_limit), non-terminal projects (SIWX) POST /projects/v1/quote # estimate provisioning cost (free, no auth) ``` **Tier payments are rail-wide idempotent.** After x402, allowance, or MPP settlement, Run402 derives a canonical rail-qualified payment identity from verified payment fields—not raw header bytes—and binds it to the organization, requested tier, amount, asset, destination, method, and resource. The entitlement mutation, prorated refund, ledger entry, and retained outcome commit in one transaction. A lost-response or re-encoded replay returns the original `action`, `previous_tier`, `committed_lease_started_at`, and `committed_lease_expires_at`, plus `payment_replay:true` and `funds_moved:false`; it never extends the lease or refunds twice. A payment identity presented for a different request contract fails closed with `TIER_PAYMENT_IDENTITY_REUSED`. Reconcile the original outcome—do not authorize a replacement payment merely because the response was lost. ``` POST /projects/v1 # SIWX; optional { org_id }; returns { project_id, org_id, anon_key, service_key, schema_slot } GET /projects/v1 # SIWX, control-plane session (operator console; membership-scoped), or admin; server inventory: name + site_url + custom_domains + owning org (org_id) + status; ?limit=50&after=cursor&org_id= (org filter is authorize-before-reveal). Soft-deleted (tombstone) projects are never listed; archived hidden by default — opt in with ?include=archived (any other include value → 400) GET /projects/v1/:project_id # control-plane session or SIWX (viewer+ on owning org, or admin); authoritative single-project read - identity, org_id, tier, lifecycle, site_url + custom_domains, last_deploy, mailbox, usage vs tier limits; no secret values; authorize-before-reveal PATCH /projects/v1/:project_id # control-plane session or SIWX; rename — body { name }; admin+ on owning org (or project:write grant); authorize-before-reveal POST /projects/v1/:project_id/repo-name # control-plane session or SIWX; claim/rename the project's address-form repo name (repo-first-onramp D6) — body { name } → { project_id, repo_name, previous_repo_name }. No fee. Same auth tier as PATCH rename (project.rename). Also acquired implicitly via push-to-create (POST /gitvault/v1/vaults with {org_slug, repo_name}) DELETE /projects/v1/:project_id # service_key/admin, or owner via session (passkey step-up) / SIWX (fresh signature); cascade purge. If the project had a gitvault vault the response ALSO carries gitvault{repo_id,retained,source_bytes,counts_against_org_storage,removable_by_client:false} + a gitvault_vault_retained warning + next_actions — the vault SURVIVES the delete GET /wallets/v1/:address/projects # SIWX (signer must equal :address) or admin; lists projects owned by orgs the address's principal is an active member of GET /wallets/v1/:address/label # public; { address, label } — server-side display name for a wallet (null if unset) POST /wallets/v1/:address/label # SIWX (signer must equal :address); body { label }; null|"" clears. Display metadata only ``` **Org members & project grants (v1.77 authority graph — the owner-gated management door).** SIWX authenticates → a control-plane *principal*; ownership/authorization is org membership (role lattice `owner > admin > developer > billing > viewer`) or per-project grant. These routes manage them. Member mutations require an active **owner** membership on the org; grant mutations require **owner** on the project's owning org; every mutation is audited and the org always keeps ≥1 active owner (last-owner guard). ``` GET /agent/v1/whoami # SIWX/session/DELEGATE; { principal, memberships:[{org_id,display_name,role,status}], encryption_key: {encryption_key_id, ek_fingerprint, custody_scheme, state, signing_fingerprint, signing_possession_verified_at} | null }. `encryption_key` (gitvault-agent-envelopes) is the principal's CURRENT gitvault directory key — metadata only; the SDK's enroll-if-absent step keys off it. `signing_fingerprint`/`signing_possession_verified_at` (gitvault-multi-writer rev 47) are the directory's OPTIONAL signing half — null/null until published via `…/encryption-key` below; the raw `signing_pubkey` is never exposed here, mirroring the encryption half's own minimalism (the caller holds its own keys locally) PATCH /agent/v1/me # SIWX/session/DELEGATE (self); { display_name } → the whoami body. Sets the principal's name: promotion `credited_as`, `up`'s room presence, audit. `run402 init --name` / `whoami --set-name` / `up`'s detected default land here. 400 INVALID_DISPLAY_NAME. POST /agent/v1/whoami/encryption-key # SIWX/session (self; `human` OR `agent` principals — a `ci` caller is refused NOT_A_RECIPIENT_PRINCIPAL); publish the caller's own gitvault directory entry { public_key, ek_fingerprint, suite?, custody_scheme?, key_id?, wrappers?, replace_current?, signing_pubkey?, signing_fingerprint?, possession_signature? } → 201 { principal_id, encryption_key_id, ek_fingerprint, custody_scheme, state, activation: {challenge_id, epk, expires_at} | null, wrapper_activations, signing_fingerprint, signing_possession_verified_at, next_actions } or 200 { …, deduplicated: true } when the submitted key already IS the current key. The private key never reaches the gateway. `custody_scheme: keystore_v1` (gitvault-agent-envelopes) is the CLI keystore's own X25519 key — no wrappers, born PENDING, activated by the ECDH possession proof on `…/activate` (a pending key is a recipient nowhere). `wrappers[]` (gitvault-recovery-custody) commits the key row and its first wrapper(s) in ONE transaction. ROTATION IS NEVER AUTOMATIC: a publish that differs from the current key → 409 KEY_ROTATION_REQUIRED naming both fingerprints + remedies (restore the keystore backup; an org owner revokes the stale key; a sole-member principal may pass `replace_current: true`, else 403 KEY_ROTATION_NOT_SELF_AUTHORIZABLE). The SDK enrolls automatically on a keystore's first gitvault operation — there is no enrollment verb. `signing_pubkey`/`signing_fingerprint`/`possession_signature` (gitvault-multi-writer rev 47) are OPTIONAL and ALL-OR-NONE — the vault-writer signing half, published in the SAME call as the (unrelated) encryption half; `possession_signature` is verified SYNCHRONOUSLY (no challenge round-trip — a signature already IS possession) over `JCS{domain:"r402s/v0/signing-key-possession/v1", principal_id, signing_pubkey, encryption_pubkey}`, refused `400 VALIDATION_FAILED` naming `possession_signature` on mismatch. The signing half is NEVER rotation-gated (no `KEY_ROTATION_REQUIRED` analog) — publishing a new signing key grants nothing by itself; becoming a vault writer still needs an existing writer's cooperation or a handoff grant+acceptance (see the `add_writer_key`/handoffs routes below) POST /agent/v1/whoami/encryption-key/activate # SIWX/session (self); answer the keystore possession challenge { challenge_id, proof } → { principal_id, encryption_key_id, ek_fingerprint, state: "active", next_actions }. proof = base64url(HMAC-SHA256(X25519(keystore_private, epk), "r402s/v0/keystore-key-possession/v1\n" + JCS{challenge_id, encryption_key_id, public_key})) — computable only by the holder of the private half; the server stored sha256(expected). 403 POSSESSION_PROOF_INVALID, 409 KEY_NOT_PENDING, 410 ACTIVATION_EXPIRED (republish for a fresh challenge). Activation syncs the key into every org's desired-recipient state; a key-holder's next gitvault operation wraps each vault to it DELETE /orgs/v1/:org_id/members/:principal_id/encryption-key # SIWX/session (OWNER + step-up); revoke a member's current gitvault key { reason? } → { status: "revoked"|"no_key", principal_id, ek_fingerprint, next_actions }. gitvault-agent-envelopes D3: the independent-credential rotation path — a member whose keystore was lost/rebuilt cannot self-rotate (the asking credential is the one a thief would hold); an owner revokes, the member's next gitvault operation enrolls afresh. Withdrawn from every org's desired state, audited, org-feed row + mandatory security notification `gitvault_encryption_key_revoked`. Non-member target → 404|code_verifier_hash?}] } → { principal_id, ek_fingerprint, suite, created_at }. gitvault-human-envelopes; the private key never reaches the gateway. `wrappers[]` (gitvault-recovery-custody) commits the key row and its first wrapper(s) in ONE transaction — the required shape for a random-key (wrapper-custody) enrollment, so a published key can never exist without a usable wrapper GET /orgs/v1/:org_id/encryption-keys # SIWX/session (any active member); the org's directory of envelope-capable principals { org_id, recovery_posture:{applicable, vault_count, control_plane_configured, source_wrapper_configured, source_backup_configured, custody_legacy_present, ...counts}, keys: [{principal_id, display_name, public_key, ek_fingerprint, suite, created_at, wrappers:[{kind, created_at}], signing_pubkey, signing_fingerprint, signing_possession_verified_at}] }. Posture is EVIDENCE ("configured" names what the platform verified — it cannot observe off-platform retention). Wrapper entries are METADATA only (kinds + timestamps — never ciphertext). `signing_pubkey`/`signing_fingerprint`/`signing_possession_verified_at` (gitvault-multi-writer rev 47) are null/null/null for a principal that never published a signing half; unlike `whoami`'s own minimalism, the directory exposes the FULL `signing_pubkey` here — a client deciding who to add as a vault writer needs the real key, not just its fingerprint POST /agent/v1/source-access/wrappers # SIWX/session (self); add a PENDING source-access wrapper for the caller's own encryption key { wrapper_id (client-minted uuid), kind: webauthn_prf|recovery_code, wrapper_ciphertext (swrap2), elevation_id? (required for webauthn_prf — from a gitvault.wrapper.add step-up verify; the server derives the credential from the VERIFIED assertion) } → 201 { wrapper_id, state: pending, activation: { challenge_id, epk, expires_at } }. Nothing pending counts until activation. 403 ELEVATION_REQUIRED; 409 NO_ACTIVE_ENCRYPTION_KEY | WRAPPER_EXISTS. gitvault-recovery-custody POST /agent/v1/source-access/wrappers/:wrapper_id/activate # SIWX/session (self); prove possession → activate. { proof: b64url HMAC-SHA256(X25519(member_scalar, epk), possession_message), replace_wrapper_id? } — read the STORED blob back, unwrap those bytes, full-public-key-compare, answer the challenge. Atomic with a pending key's activation; a first wrapper on a legacy key flips custody to wrapped_legacy_scalar_v1. 403 POSSESSION_PROOF_INVALID (a stolen bearer can never activate); 409 WRAPPER_NOT_PENDING; 410 ACTIVATION_EXPIRED GET /agent/v1/source-access/wrappers # SIWX/session (self); the caller's own wrappers (active + own pending, states marked), ciphertext INCLUDED (the unwrap ceremony needs the blobs) + the key identity with its EXPLICIT custody_scheme (readers select the key path from the scheme, never wrapper count; wrapped custody never falls back to legacy). No surface anywhere returns another principal's blobs GET /agent/v1/source-access/recovery-bundle # SIWX/session (self); the versioned member recovery bundle (r402s-member-recovery-bundle/v1): key identity + every ACTIVE wrapper ciphertext, for the member's OWN storage — kept SEPARATELY from the source recovery code. A server-side wrapper row is NOT offline backup; server-free recovery needs this bundle + the vault mirror + its recovery-receipt pin + the code. Stamps the wrapper-export posture evidence DELETE /agent/v1/source-access/wrappers/:wrapper_id # SIWX/session (self) + step-up (gitvault.wrapper.revoke); revoke a wrapper. Scheme-aware last-wrapper rule: wrapped_random_v1 can NEVER reach zero wrappers while active (pass { deactivate_key: true } to revoke the KEY atomically with it); wrapped_legacy_scalar_v1 allows { confirm_last_wrapper: true } (the original enrollment passkey can still derive the scalar — rotation is the only cryptographic detach). Foreign/missing id → the same 403 (no existence oracle) POST /agent/v1/control-plane/session/viewer-token # session (self); mint the bridge-scoped gitvault VIEWER token the console key bridge seals to git.run402.com — gitvault reads + whoami/org/project lists ONLY, bound to the parent session (sign-out kills it), 30-min, non-refreshable. Never the full control-plane bearer GET /orgs/v1 # SIWX; orgs the caller is an active member of, with role + display_name POST /orgs/v1 # SIWX/session + step-up; create an empty direct org on prototype { display_name? } → { org_id, display_name, tier, lease_started_at, lease_expires_at }. Creator = owner; tier input is rejected; soft per-owner free-org cap (FREE_ORG_OWNER_LIMIT_EXCEEDED) GET /orgs/v1/:org_id # SIWX/session (any active member); one org { org_id, display_name, tier, lease_started_at, lease_expires_at, role }. Authorize-before-reveal PATCH /orgs/v1/:org_id # SIWX/session (owner) + step-up; rename org { display_name } (null/"" clears), returns current tier/lease state. Authorize-before-reveal POST /orgs/v1/:org_id/slug # SIWX/session (owner); claim or rename the org's address-form slug { slug } → { org_id, slug, previous_slug, created, fee_usd_micros }. 201 genesis claim, 200 rename (old slug releases into ~90-day cooldown). Requires Idempotency-Key. An org's first-ever slug claim is FREE (no wallet required); every later claim/rename debits a one-time claim fee — fee_usd_micros names the real price of the call (0 or the fee). Refused: SLUG_RESERVED, SLUG_PROTECTED (manual review), SLUG_TAKEN, SLUG_RELEASED (names the successor) GET /orgs/v1/:org_id/members # SIWX (any active member); list members { principal_id, type, role, status, wallet } POST /orgs/v1/:org_id/members # SIWX (owner); add a member { wallet, role } → 201 { status, principal_id, role, envelope: {state: enrolled_pending_wrap|not_enrolled|not_applicable, ek_fingerprint}, next_actions }. New wallet → provisioned as a human; existing wallets added by their principal (type is informational). gitvault-agent-envelopes: custody follows membership for human AND agent principals — the member is a desired envelope recipient on every present/future vault; `not_enrolled` means the MEMBER's next gitvault operation enrolls its keystore key (nothing is asked of the owner). Cap: 422 MAX_EPOCH_ROTATION_ENVELOPES_EXCEEDED counts custody-eligible memberships, keyed or not. `next_actions` (gitvault-multi-writer rev 47) additionally carries a `sync_writers` entry, naming the org's `repo_id`s where this new member is NOT yet an active, non-gateway-blocked writer — the client-side signal to run `add_writer_key{"writer"}` (or `repos access sync`) on the member's behalf; omitted entirely when the org has no vaults or the member is already covered everywhere. No server-side writer admission ever happens here — the client always holds the keys. gitvault-byo-primary-bucket D11: a developer+ add is refused `409 GITVAULT_BYO_SINGLE_WRITER` when the org owns a bring-your-own-bucket vault — a BYO vault admits exactly one writer in v1 (its key envelopes are payload, so they live only in the customer's bucket, whose credentials never reach run402), and `details` names the vault `{ storage_profile: "byo", vault_id, org_id }`. Refused before any write, so no member is ever left half-admitted; a viewer/billing add is unaffected (those roles confer no writer eligibility) PATCH /orgs/v1/:org_id/members/:principal_id # SIWX (owner); change role { role }. Last-owner guarded DELETE /orgs/v1/:org_id/members/:principal_id # SIWX (owner); revoke member (status=revoked, one row, no key rotation). Last-owner guarded POST /projects/v1/:project_id/grants # SIWX (owner of project's org); issue a grant { wallet, capability, policy?, expires_at? } to an agent/ci principal DELETE /projects/v1/:project_id/grants/:grant_id # SIWX (owner of project's org); revoke a grant GET /orgs/v1/:org_id/audit # SIWX/session (admin+); control-plane audit trail (?limit=&before=) GET /orgs/v1/:org_id/invites # SIWX/session (any member); list pending email invites POST /orgs/v1/:org_id/invites # SIWX/session (owner) + step-up; invite by email { email, role, invite_ttl_hours? } → claimed at first login. Sends a notification email (re-POST = resend, rate-limited to 1 send per org+email per 5 min); 201 carries email_sent: true|false for pending invites DELETE /orgs/v1/:org_id/invites/:principal_id # SIWX/session (owner) + step-up; revoke a pending invite PATCH /orgs/v1/:org_id/payout-wallet # SIWX/session (admin+) + step-up; set/clear tenant route payout wallet { wallet_address: "0x..." | null } → { status, org_id, default_payout_wallet, previous_default_payout_wallet, recovery } POST /agent/v1/operator/claim-wallet-org/challenge # session (no step-up — step-up is on the claim); issue a single-use nonce to claim a wallet-owned org { wallet } → { nonce } POST /agent/v1/operator/claim-wallet-org # session + step-up + SIGN-IN-WITH-X wallet proof (signs the nonce); become owner of the wallet-agent's org (ownership transfer; wallet stays on agent; agent→developer). { org_id?, display_name? }. Multi-org → { status:select_org, selectable_orgs } ``` Direct `POST /orgs/v1` orgs receive perpetual prototype (kygit-handoff D4 — one x402 payment, never expires, `lease_expires_at: null`, `lease: "perpetual"`) immediately. SIWX cold-start wallet placeholder orgs created before a tier purchase may still have `tier = null` and `lease_expires_at = null` until the wallet subscribes through the tier flow. **Tenant route payout wallet.** Priced web routes settle tenant-paid x402 USDC to the organization's default payout wallet. `PATCH /orgs/v1/:org_id/payout-wallet` is org admin/owner gated plus step-up/fresh SIWX because it controls revenue destination. `wallet_address` must be either `null` to clear or an active wallet already linked to the same organization; arbitrary external addresses are rejected with `PAYOUT_WALLET_NOT_LINKED` and recovery actions to link/select a wallet. The response `recovery` preview reports `status:"ready"` with `mode:"default"|"single_active_wallet"` and `wallet_address`, or an actionable code such as `PAYOUT_WALLET_REQUIRED` / `PAYOUT_WALLET_AMBIGUOUS` plus canonical `next_actions[]` entries shaped as `{type, method, path, auth, why}`. Every organization can define priced routes only when payout resolution is ready: one active org wallet may be inferred, otherwise set the default payout wallet explicitly. **Control-plane login + session + step-up (v1.78 — passkey-principals-onboarding).** A person logs in (passkey / email magic-link / Google / GitHub) → a principal-bound **control-plane session** bearer (distinct from tenant `internal.sessions`), WRITE-capable subject to org role + **step-up**. High-stakes ops (delete / transfer / membership / invite) require a fresh passkey (a recent magic-link does NOT satisfy a passkey requirement; recovery codes NEVER satisfy step-up). The control-plane session bearer is sent as `Authorization: Bearer ` and is accepted everywhere a SIWX wallet is. ``` POST /agent/v1/control-plane/session/email # public; send a magic link (rate-limited; generic response, no oracle) POST /agent/v1/control-plane/session/email/verify # public; consume token → mint session (amr=email) { control_plane_session_token, expires_in, principal_id } POST /agent/v1/control-plane/session/passkey/options # public; WebAuthn login options for an email's passkeys POST /agent/v1/control-plane/session/passkey/verify # public; verify assertion → mint session (amr=passkey) GET /agent/v1/control-plane/session # control-plane session; whoami { principal, memberships, amr, amr_times } POST /agent/v1/control-plane/session/refresh # control-plane session; rotate the access token POST /agent/v1/control-plane/session/revoke # control-plane session; sign out POST /agent/v1/control-plane/session/browser # live bearer + same-origin browser request; set opaque HttpOnly cookie, return metadata only POST /agent/v1/control-plane/session/browser/refresh # same-origin cookie request; rotate handle + generation, no bearer response POST /agent/v1/control-plane/session/browser/revoke # same-origin cookie request; revoke and clear cookie idempotently POST /agent/v1/control-plane/passkey/enroll/options # control-plane session + step-up; register a passkey (options) POST /agent/v1/control-plane/passkey/enroll/verify # control-plane session + step-up; register a passkey (verify) POST /agent/v1/control-plane/step-up/options # control-plane session; step-up WebAuthn options for a high-stakes op POST /agent/v1/control-plane/step-up/verify # control-plane session; verify step-up → freshness + action-bound elevation POST /agent/v1/control-plane/recovery/issue # control-plane session + step-up; (re)issue recovery codes (shown once) POST /agent/v1/control-plane/recovery/consume # public; recovery ceremony → session (amr=recovery_code; must enrol a passkey before admin actions) GET /agent/v1/control-plane/authenticators # control-plane session; list my active authenticators (no secrets) DELETE /agent/v1/control-plane/authenticators/:authenticator_id # control-plane session + step-up; revoke one (owner-passkey policy enforced) GET /agent/v1/control-plane/cli/authorize/:flow_id # control-plane session; read a CLI loopback-PKCE flow (client + loopback port) POST /agent/v1/control-plane/cli/approve # control-plane session + step-up (passkey); approve a CLI login → { redirect_url } POST /agent/v1/control-plane/cli/token # public; exchange { code, code_verifier, state } → session (provenance=loopback_pkce) POST /agent/v1/control-plane/oauth/:provider/link # control-plane session + step-up; start OAuth in LINK mode → { auth_url } (attach the identity to the CURRENT principal) POST /agent/v1/control-plane/emails # control-plane session + step-up; request attaching an additional email (generic response, no oracle) POST /agent/v1/control-plane/emails/verify # control-plane session; confirm the attach (inbox + same-session proof); 409 EMAIL_BELONGS_TO_OTHER_PRINCIPAL → merge POST /agent/v1/control-plane/merge # control-plane session + step-up (passkey); merge a duplicate principal { source_session_token, source_principal_id? } POST /agent/v1/control-plane/write-auth/challenges # control-plane session; open a passkey write-intent ceremony { action, org_id|project_id } → { challenge_id, confirm_url } POST /agent/v1/control-plane/write-auth/verify # public (the WebAuthn assertion IS the proof); mint → { write_auth_token } (once), OR in CLI mode → { delivery:"cli_loopback", redirect_to } POST /agent/v1/control-plane/write-auth/cli/token # public; headless-CLI loopback delivery: { code, code_verifier, state } (PKCE S256) → { write_auth_token } (once) GET /agent/v1/control-plane/write-auth/sessions # control-plane session; list active write-auth sessions bound to this session POST /agent/v1/control-plane/write-auth/sessions/:write_auth_session_id/revoke # control-plane session; revoke one POST /agent/v1/control-plane/write-auth/revoke-all # control-plane session; revoke all for the principal ``` **Passkey write-auth (human-write-auth, v1.85).** A signed-in human (email/passkey — no wallet) gains WRITE authority on provision + deploy via a **passkey signed intent**: open a challenge for ONE action on ONE target (`org.project.create` on an org, or `project.deploy` on a project), approve it on the gateway-rendered confirmation page (`confirm_url`, console origin, raw WebAuthn — the action sheet is server-rendered so page JS can't rewrite it), and receive an opaque **write-auth token**. Send it as `X-Run402-Write-Auth: Bearer ` ALONGSIDE the control-plane session bearer on `POST /projects/v1`, `POST /apply/v1/plans[/:plan_id/commit]`, `POST /apply/v1/operations/:operation_id/resume`, and `POST /content/v1/plans[/:plan_id/commit]`. The session is target-scoped (never platform-wide), 30 min idle / 4 h absolute, dies with the cp session, and covers **routine** slices only — destructive/membership/payment/secret-reveal surfaces always require a SIWX wallet or fresh step-up. Authorization (org membership/grant) is re-checked live on every write; the write-auth session adds the signed-intent requirement, it never replaces authz. SIWX wallet auth on these routes is unchanged. Passkeys authenticate; they never pay — paid operations keep the existing x402/Stripe rails. **CLI write-login (loopback-PKCE, RFC 8252).** A headless CLI gets a write-capable control-plane session without a stored secret: it starts a `127.0.0.1:PORT` server, opens the browser to `GET /agent/v1/control-plane/cli/authorize?redirect_uri=&code_challenge=&state=&nonce=` (302 → the console runs the passkey ceremony + approves), receives the auth code on the loopback redirect, then exchanges it at `/cli/token` (PKCE S256 + state) for a session minted with `provenance=loopback_pkce`. A `device_flow` session is read/low-stakes only — high-stakes ops return `STEP_UP_REQUIRED` with `details.reason="device_flow_forbidden"`. **Email-addressed transfers (folded into the unified transfer noun).** To hand a project to someone by **email**, address the unified initiate route with `to_email` instead of `to_wallet`: `POST /projects/v1/:project_id/transfers { to_email, message?, retain_collaborator?: { role: "developer" } }` (owner/admin SIWX or control-plane session + step-up). The recipient claims it via `POST /agent/v1/transfers/:transfer_id/claim` into an org they own (or a new wallet-less one); preview/cancel/inbox use the same kind-agnostic `/agent/v1/transfers/*` endpoints. Reuses the transfer engine's atomic ownership flip + authority re-home (grants + CI delegates revoked) + audit. See the Project Transfers table below. **Org-addressed same-actor transfers (v1.96).** To move a project into another org you already own, use the same unified route with `to_org_id`: `POST /projects/v1/:project_id/transfers { to_org_id, message? }`. This completes synchronously only when the authenticated principal is an active `owner` of both the source org and destination org. It inserts an accepted `recipient_kind:'org'` history row, flips `projects.organization_id`, revokes grants/delegates, stamps the secrets-rotation advisory, and returns the moved project's keys. This deliberately does NOT bridge wallet and human/email/operator principals: if the caller owns the source but not the destination org, the route returns `403 FORBIDDEN` and creates no pending row in this release. The `to_org_id` shape is reserved so a future non-same-actor org move can use the same API and continue interactively. **Retain-the-builder on handoff (v1.91, add-project-handoff-retain-collaborator).** A handoff normally severs the sender's access entirely (authority is re-homed to the new org). The sender MAY instead offer to stay on by building the project for the new owner: pass `retain_collaborator: { role: "developer" }` on init (the subject is always the initiating owner — you can only offer to keep *yourself* on; `developer` is the only accepted role, capped so a sender can never retain owner/admin of the recipient's org). The recipient sees a `retain_collaborator` block in the preview (who, the role, and that the access spans the WHOLE org) and must **explicitly** pass `accept_retained_collaborator: true` at claim — opt-in; omitting it severs exactly as before. On acceptance the sender's principal becomes a plain `developer` member of the new org (created in the claim transaction, after the authority wipe), with no expiry; the new owner can remove them anytime via the member-revoke route. Tradeoff: a membership is org-wide, so if the new owner later adds other projects to that same org the retained developer can reach them too (until removed) — surfaced in the preview block. **Delegates (v1.78 — the credential an agent actually holds).** A *delegate* is a scoped, capped, expiring, revocable credential an owner mints for an agent/ci principal so the thing the agent holds equals the authority its grant was given. A delegate only ever NARROWS a grant (authz = grant ∩ scope ∩ cap ∩ expiry), is NEVER an owner (cannot delete/transfer/manage members), fails closed if the project is transferred to another org, and is killed instantly by one revoke. Per-rail kinds: `run402_agent_key` (control-plane bearer, returned once), `ci_oidc` (the GitHub-OIDC CI federation), `tempo_access_key` + `base_disposable_eoa` (payment rails, spend-cap enforced). Owner-gated, audited. ``` POST /projects/v1/:project_id/delegates # SIWX (owner of project's org); issue { grant_id, kind, scope:{v:1,capabilities[],projects?}, spend_cap?, expires_at? }. run402_agent_key → token returned ONCE GET /projects/v1/:project_id/delegates # SIWX (owner of project's org); list delegates (names/scope/cap only — never secret material) DELETE /projects/v1/:project_id/delegates/:delegate_id # SIWX (owner of project's org); revoke (immediate; base rail stops refills + sweeps) POST /projects/v1/:project_id/delegates/:delegate_id/rotate # SIWX (owner of project's org); rotate (revoke + reissue same scope/cap; fresh token for run402_agent_key) ``` **Project credentials (named, revocable — the replacement for derive-on-demand keys).** A project credential is a ROW, not a derivation: it can be named, listed, expired, revoked, and replaced. Several may be live per kind at once — that overlap is exactly how you rotate with no downtime. The secret is returned EXACTLY ONCE at creation and is never recoverable; reads return metadata only. Two authority tiers, and the split is the point: creating or revoking a LONG-LIVED credential needs owner membership plus fresh step-up and a delegate can NEVER do it (a scoped agent credential must not be able to escalate itself into a permanent root), while minting a SHORT-LIVED token is delegate-capable with no human present — that is the cold-restart recovery path for an agent that lost its local state. ``` POST /projects/v1/:project_id/credentials # SIWX/session (owner of project's org) + step-up; create { kind: "anon"|"service", name, expires_at? }. Secret returned ONCE, Cache-Control: no-store GET /projects/v1/:project_id/credentials # SIWX/session (project.read); list metadata only (never the secret or its hash). ?include_revoked=true DELETE /projects/v1/:project_id/credentials/:credential_id # SIWX/session (owner of project's org) + step-up; revoke immediately { reason? }. Frees the name for reuse POST /projects/v1/:project_id/credentials/:credential_id/rotate # SIWX/session (owner of project's org) + step-up; mint replacement + revoke old in one tx (same name, records replacement_of). New secret ONCE POST /projects/v1/:project_id/tokens # SIWX/session/DELEGATE; mint a short-lived token { kind? } → { secret, expires_in }. The unattended recovery path GET /projects/v1/:project_id/credential-status # SIWX/session (project.read); am I on the retiring key? → { state: "legacy"|"rotatable", rotatable_credentials, retirement.gated_on[], next_actions[] } ``` Your project's `anon_key` / `service_key` are DERIVED from the platform signing key and carry no expiry, so they live in `.env` files and CI indefinitely. That key is being retired. A response authenticated with one carries `X-Run402-Key-Rotation: legacy; …` on owner-facing surfaces (`/admin/v1/rest`, never `/rest/v1` — an end user's browser must not see the owner's credential posture), and `GET /projects/v1/:project_id/credential-status` is the surface to poll deliberately. There is deliberately **no deadline**: retirement is gated on conditions (every tenant migrated, 30 consecutive days of zero legacy-key use, explicit operator approval), not a date, so nothing here invents a commitment to plan against. Create a credential to stop depending on the derived keys. Never persist a credential-bearing response to a result cache, tmp file, or expansion handle — it is secret-bearing, like `provision` and project keys. ``` GET /projects/v1/admin/:project_id/usage # service_key; usage vs limits GET /projects/v1/admin/:project_id/schema # service_key; tables, columns, RLS policies POST /projects/v1/admin/:project_id/sql # service_key + lifecycleGate; runs DDL/DML/SELECT against the project schema. Returns { rows, row_count }. (Platform-ADMIN-key calls to this route are engine-enforced read-only: any write returns 403 ADMIN_SQL_READ_ONLY.) POST /projects/v1/expose/validate # SIWX; validate manifest without live project schema POST /projects/v1/admin/:project_id/expose/validate # service_key; validate manifest against project schema + optional migration SQL POST /projects/v1/admin/:project_id/expose # service_key + lifecycleGate; apply RLS manifest (see "Expose manifest") GET /projects/v1/admin/:project_id/expose # service_key; current manifest, with source: "applied" | "introspected" POST /projects/v1/admin/:project_id/promote-user # service_key + lifecycleGate; { email } -> grants project_admin POST /projects/v1/admin/:project_id/demote-user # service_key + lifecycleGate ``` ### Lifecycle (~104-day grace, ending in a downgrade — kygit-handoff D4) After a LEASED tier's lease expires (hobby/team — **prototype is perpetual and never enters this ladder at all**, see the tier table above), an organization moves through `active → past_due → frozen → dormant → prototype`: | State | Day | Control plane | Data plane | Scheduled fns | Subdomain | |-------|-----|---------------|------------|---------------|-----------| | `active` | — | read+write | read+write | running | claimed | | `past_due` | 0 | read+write | read+write | running | claimed | | `frozen` | +14 | **403** | read+write | running | reserved | | `dormant` | +44 | **403** | read+write | **paused** | reserved | | downgraded to `prototype` | +104 | read+write (at prototype's limits) | read+write | running (excess paused, oldest resumes) | reserved (the org's again) | **Nothing is deleted; excess is frozen in place.** The ladder's terminal transition is no longer a scheduled purge — `transitionDormantToPrototype` downgrades the organization to perpetual prototype instead: `tier = 'prototype', lease_perpetual = true, lease_expires_at = NULL, lifecycle_state = 'active'`, and every timer clears. Functions/secrets over prototype's cap are KEPT (no new ones admitted); scheduled functions beyond prototype's `maxScheduledFunctions` (1) stay paused, the oldest resumes; storage over `storageBytes` keeps serving reads and refuses writes with `QUOTA_EXCEEDED`; gitvault vaults are unaffected either way (see below — vault writes are never lifecycle-gated in the first place). `organization_downgrade_warning` fires 24h before the downgrade (replacing the old `organization_purge_final_warning` on this path); `organization_downgraded_to_prototype` (mandatory, `destructive_lifecycle`) fires on the transition itself, with a feed-visible per-project mirror `project_org_downgraded` carrying a `renew_tier` next_action — poll it if your agent has no verified email. Upgrading to hobby/team at any point (even mid-ladder) restores full capacity immediately. The data plane keeps serving end users throughout grace AND after the downgrade. Only owner control-plane writes (deploys, SQL execution, expose apply, secret rotation, mailbox and KMS signer mutations, subdomain/domain claims, function upload, and auth/admin writes) are gated by `lifecycleGate` — **with one standing exception: `/gitvault/v1/*` vault write routes are never lifecycle-gated at all** (kygit-handoff D4). A vault's only bound is its own `sourceBytes` quota; custody operations (rotation, revocation, repair, mint/claim/revoke a handoff) must never be refused for billing state — a frozen org rotating a compromised key, or recovering its own work via a handoff, is exactly the case that matters. Read endpoints remain available where they do not mutate project state. Tier renewal at any point reactivates the organization and clears all timers in one transaction. `purging`/`purged` remain in the schema but are reachable ONLY through the platform-admin route `POST /orgs/v1/admin/:org_id/purge` (`platformAdminAuth`, mandatory `reason`, audited — not in this doc's routes or in openapi.json) — abuse takedown, never a non-payment consequence. Per-project `DELETE /projects/v1/:project_id` (user-driven soft-delete) and `POST /projects/v1/admin/:project_id/archive` (operator moderation) are unrelated to organization lifecycle and unchanged. Lifecycle gating returns **HTTP 403** with `code: PROJECT_PAST_DUE` / `PROJECT_FROZEN` / `PROJECT_DORMANT` and `details: { lifecycle_state, entered_state_at, next_transition_at }`. (HTTP 402 is reserved for x402 payment challenges from the `@x402/express` middleware — overloading 402 caused `@x402/fetch` to mis-parse Run402 envelopes as x402 challenges.) ## Provisioning (typed example) ```typescript import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; import { ExactEvmScheme } from "@x402/evm/exact/client"; import { toClientEvmSigner } from "@x402/evm"; import { createSIWxPayload, encodeSIWxHeader } from "@x402/extensions/sign-in-with-x"; import { privateKeyToAccount } from "viem/accounts"; import { createPublicClient, http } from "viem"; import { baseSepolia } from "viem/chains"; import { randomBytes } from "node:crypto"; const account = privateKeyToAccount(process.env.PRIVATE_KEY as `0x${string}`); const publicClient = createPublicClient({ chain: baseSepolia, transport: http() }); const signer = toClientEvmSigner(account, publicClient); const x402 = new x402Client(); x402.register("eip155:84532", new ExactEvmScheme(signer)); // testnet — register the SPECIFIC chain, not eip155:* const fetchPaid = wrapFetchWithPayment(fetch, x402); // 1) Subscribe to prototype tier await fetchPaid("https://api.run402.com/tiers/v1/prototype", { method: "POST" }); // 2) Provision a project (SIWX-authenticated) const siwxPayload = await createSIWxPayload({ domain: "api.run402.com", uri: "https://api.run402.com/projects/v1", statement: "Sign in to Run402", version: "1", nonce: randomBytes(16).toString("hex"), issuedAt: new Date().toISOString(), expirationTime: new Date(Date.now() + 5 * 60_000).toISOString(), chainId: "eip155:84532", type: "eip191", }, account); const res = await fetch("https://api.run402.com/projects/v1", { method: "POST", headers: { "Content-Type": "application/json", "SIGN-IN-WITH-X": encodeSIWxHeader(siwxPayload) }, body: JSON.stringify({ name: "my-app" }), }); const { project_id, anon_key, service_key } = await res.json(); ``` `anon_key` and `service_key` are permanent project identifiers; lease enforcement is server-side. **Don't embed `service_key` in browser code** — CORS is intentionally open (`*`), so a leaked service_key is exploitable from any origin. ## Runtime config on every host (`/_run402/config.js`) A static page never needs a key copied into it. Every host that serves a project's site — managed subdomain, branch host, custom domain — answers `GET /_run402/config.js` with `window.RUN402 = { project_id, api_base, anon_key };` and `GET /_run402/config.json` with the same object, for the project that host resolves to **at request time**: a branch host answers for the branch, a transferred project answers with its rotated anon key, and the HTML never changes. `Cache-Control: public, max-age=60`. The service key is never served here. ```html ``` `/_run402/` is the platform's reserved prefix on tenant hosts: a route pattern, static alias, public path, or site file under it is rejected at plan time (`INVALID_ROUTE_SPEC` / `INVALID_SPEC`, naming the prefix), and the platform answers `/_run402/*` before any user route, static lookup, or SPA fallback. ## REST API (PostgREST proxy) | Path | Method | Auth | Description | |------|--------|------|-------------| | `/rest/v1/:table` | GET | apikey | Read rows | | `/rest/v1/:table` | POST | apikey | Insert rows (single object or array for bulk) | | `/rest/v1/:table` | PATCH | apikey | Update rows (chain with `?col=eq.val`) | | `/rest/v1/:table` | DELETE | apikey | Delete rows | PostgREST query syntax: `?select=`, `?col=eq.value`, `?col=gt.5`, `?order=col.desc`, `?limit=N`, `?offset=N`. `Prefer: return=representation` returns the inserted/updated row. `apikey` is auto-forwarded as `Authorization: Bearer` to PostgREST. Pick: - `apikey: ` → role `anon` (RLS applies) - `apikey: ` → role `service_role` (bypasses RLS, server-side only) - `apikey: ` + `Authorization: Bearer ` → role `authenticated` (RLS scoped to user) ## Expose manifest — dark-by-default tables **Tables you create are unreachable via `/rest/v1/*` until your manifest declares them with `expose: true`.** This eliminates the "agent created a table, forgot RLS, data leaked" footgun. The manifest is the single source of truth; convergent (applying the same manifest twice is a no-op; items removed between applies have their policies, grants, triggers, and views dropped). JSON Schema: . Validate before applying: - `POST /projects/v1/expose/validate` uses SIWX wallet auth (active tier not required) and validates against an empty project schema plus optional `migration_sql`. - `POST /projects/v1/admin/:project_id/expose/validate` uses the project `service_key` and validates against the current project schema plus optional `migration_sql`. Both validation endpoints are non-mutating: they do not apply the manifest, execute SQL, write deploy plans, upload content, commit releases, update `internal.project_manifest`, or reload PostgREST. Body: ```json { "manifest": { "version": "1", "tables": [], "views": [], "rpcs": [] }, "migration_sql": "optional CREATE TABLE/VIEW/FUNCTION or ALTER TABLE ADD COLUMN SQL, parsed only" } ``` They return `200 OK` when validation runs, even when `has_errors: true`: ```json { "has_errors": false, "errors": [], "warnings": [] } ``` Issues use `{ "type", "severity", "detail", "fix?" }`. Manifest shape failures are returned as `schema-shape` errors in the same envelope. Statements in `migration_sql` that look like DDL but are not understood by the lightweight parser produce `validation-inconclusive` warnings; SQL is never executed. ```bash curl -X POST https://api.run402.com/projects/v1/admin/$PROJECT_ID/expose \ -H "Authorization: Bearer $SERVICE_KEY" \ -H "Content-Type: application/json" \ -d '{ "$schema": "https://run402.com/schemas/manifest.v1.json", "version": "1", "tables": [ { "name": "items", "expose": true, "policy": "user_owns_rows", "owner_column": "user_id", "force_owner_on_insert": true }, { "name": "audit", "expose": false } ], "views": [ { "name": "leaderboard", "base": "items", "select": ["user_id", "score"], "expose": true } ], "rpcs": [ { "name": "compute_streak", "signature": "(user_id uuid)", "grant_to": ["authenticated"] } ] }' ``` Built-in policies: | Policy | Allows | |---|---| | `user_owns_rows` | Rows where `owner_column = auth.uid()`. With `force_owner_on_insert: true`, a BEFORE INSERT trigger sets it automatically. **Default for user-scoped data.** | | `public_read_authenticated_write` | Anyone reads. Any authenticated user writes any row. | | `public_read_write_UNRESTRICTED` | Fully open. Requires `i_understand_this_is_unrestricted: true`. | | `custom` | Provide `custom_sql` with `CREATE POLICY` statements. | Views run with `security_invoker=true` — they inherit the underlying table's RLS. RPCs need explicit listing in `rpcs[]` (a database event trigger revokes PUBLIC EXECUTE on every newly-created function; views and RPCs not in the manifest are unreachable). `GET /projects/v1/admin/:project_id/expose` returns the live state with `source: "applied"` (came from a prior apply) or `"introspected"` (no manifest applied; reconstructed from DB state). **Preferred path: ship the manifest as `database.expose` in your `ApplySpec`.** The gateway validates it against the migration SQL atomically with the rest of the apply — see "Unified apply" below. ## SQL guardrails `POST /projects/v1/admin/:project_id/sql` with your project **service key** runs DDL/DML/SELECT against your project's own schema — this is the direct-SQL migration path (MCP `run_sql`, `run402 projects sql`, in-function `adminDb().sql()`). A pattern blocklist rejects platform-reaching statements (`CREATE EXTENSION`, `COPY ... PROGRAM`, `ALTER SYSTEM`, `SET search_path`, `CREATE/DROP SCHEMA`, `GRANT/REVOKE`, `CREATE/DROP ROLE`, and writes naming `internal.*` tables); use the expose manifest for access control instead of `GRANT`. The structured alternative for migrations is `POST /apply/v1/plans` (`spec.database`). Calls authenticated with the **platform admin key** (operator diagnostics) run inside a PostgreSQL read-only transaction (`SET TRANSACTION READ ONLY`) — any write, in any spelling or quoting, is refused by the database engine itself with `403 ADMIN_SQL_READ_ONLY`. Each project lives in its own Postgres schema. Cross-schema access is blocked. The PostgREST `db-pre-request` hook validates JWT claims match the project. Schema names use the form `pNNNN`. ### Idempotent migrations `CREATE TABLE IF NOT EXISTS` only handles "already exists" — it won't add new columns. For evolving schemas, wrap `ALTER TABLE` in a `DO` block: ```sql CREATE TABLE IF NOT EXISTS items (id serial PRIMARY KEY, title text NOT NULL); DO $$ BEGIN ALTER TABLE items ADD COLUMN priority int DEFAULT 0; EXCEPTION WHEN duplicate_column THEN NULL; END $$; ``` Safe to re-run on every deploy. ## Portable project archives — Cloud export to Core import Portable archives are the vendor-lock-in escape hatch. Cloud is the easiest place to start, not the only place the supported application can run. This is separate from allowance/spend-cap financial-risk controls. Archive v1 exports the supported Run402 Core runtime slice of a Cloud project, not an entire Cloud project. Recommended agent path uses CLI/SDK/MCP first: ```bash run402 cloud archives create prj_... \ --scope portable-runtime-v1 \ --auth stubs \ --consistency pause-writes \ --wait \ --output ./project.r402ar \ --json run402 archives inspect ./project.r402ar --json run402 archives verify ./project.r402ar --json # Create ./required.env from required_secrets or secrets/required.env.template. run402 core projects import ./project.r402ar --name imported-project --env-file ./required.env --json ``` HTTP routes: | Path | Method | Auth | Cost | Description | |------|--------|------|------|-------------| | `/projects/v1/:project_id/archives` | POST | SIWX wallet, control-plane session, or `run402_agent_key` delegate with `project.archives.export` | Free with tier | Create an operation-backed archive export. Body `{ scope?, auth?, consistency?, idempotency_key? }`; v1 supports `scope:"portable-runtime-v1"`, `auth:"stubs"|"none"` (default `stubs`), and `consistency:"pause-writes"`/`"cloud_write_pause_v1"`. `Idempotency-Key` header wins over body. Returns a `ProjectArchive` status resource; 201 when created, 200 for same-key replay. | | `/projects/v1/:project_id/archives/:archive_id` | GET | same | Free with tier | Read archive export status. Authorization is checked every time and is authorize-before-reveal. Ready archives return `download_url` and refresh a short-lived `download_authorized_until` timestamp. | | `/projects/v1/:project_id/archives/:archive_id/download` | GET | same | Free with tier | Download `.r402ar` tar bytes. Authorization is checked every time; v1 does not issue shareable signed URLs. Expired archives return `ARCHIVE_EXPIRED`; non-ready archives return a conflict. | `ProjectArchive` fields: `archive_id`, `operation_id` (same as `archive_id` in v1), `project_id`, `status` (`running|ready|failed|expired`), `format_version:"run402-project-archive.v1"`, `scope`, `auth_export`, `consistency_mode:"cloud_write_pause_v1"`, `active_release_id`, `consistency`, `export_report`, `portability_report`, `error`, `byte_count`, `sha256`, `content_type`, `download_url`, `download_authorized_until`, `expires_at`, `started_at`, `completed_at`, `created_at`, `updated_at`, and `next_action`. All timestamps are ISO strings. Consistency model: export uses `cloud_write_pause_v1`. Cloud pauses supported project mutations, pins the active release, captures database/storage/runtime state, verifies no supported-slice mutation occurred, then resumes mutations. If that invariant cannot be established, export fails before downloadable archive creation with `EXPORT_CONSISTENCY_UNAVAILABLE`. Included v1 state: active release/spec/state, supported Postgres schema/data using phased `pre-data.sql` + table `COPY` payloads + sequences + post-data, storage/static bytes, function artifacts, Astro SSR artifacts, route/static metadata, portable auth/RLS behavior, disabled auth subject stubs, and value-free secret requirements. Excluded v1 state: secret values, password hashes, sessions, refresh/access/OAuth tokens, MFA secrets, signed URLs, logs, diagnostics exports, billing/allowance/spend state, provider account ids, tenant ids, Aurora/S3/CloudWatch/fleet identifiers, abuse/support/operator notes, Cloud import, managed production operations, and existing-project merge import. Diagnostics are optimized for coding agents. Archive reports and client surfaces use stable fields: `code`, `severity` (`info|warning|blocking`), `resource_type`, `resource_id`, `path`, `message`, `next_action`, `retryable`, and safe `context`. Stable codes include `EXPORT_CONSISTENCY_UNAVAILABLE`, `EXPORT_SCOPE_UNSUPPORTED`, `ARCHIVE_EXPIRED`, `ARCHIVE_DIGEST_MISMATCH`, `ARCHIVE_UNSUPPORTED_VERSION`, `ARCHIVE_UNSUPPORTED_REQUIRED_CAPABILITY`, `ARCHIVE_PATH_UNSAFE`, `ARCHIVE_SIZE_LIMIT_EXCEEDED`, `DATABASE_EXTENSION_UNSUPPORTED`, `DATABASE_SCHEMA_UNSAFE`, `STORAGE_OBJECT_DIGEST_MISMATCH`, `AUTH_CREDENTIALS_NOT_EXPORTED`, `AUTH_SUBJECT_STUBS_IMPORTED`, `SECRET_VALUES_REQUIRED`, `CLOUD_ONLY_FEATURE_EXCLUDED`, `PROJECT_ALREADY_EXISTS`, `IMPORT_VERIFY_FAILED`, and `IMPORT_CONFORMANCE_FAILED`. Recovery guidance: - Checksum/path/capability failures: run `run402 archives verify ./project.r402ar --json`, discard corrupted archives, and create a fresh export if needed. - Missing secrets: read `required_secrets` or `secrets/required.env.template`, create an env file, and retry `run402 core projects import ... --env-file ./required.env`. Secret values are never exported. - Auth stubs: imported subjects are disabled placeholders. No credentials, sessions, OAuth identities, or MFA material are exported. - Unsupported Cloud-only resources: remove/defer the resource or keep it on Cloud; v1 does not silently claim Core parity for managed operations. ## gitvault — the encrypted Git remote (`/gitvault/v1`) gitvault is a client-encrypted Git remote for ANY coding agent (and the humans they work with) — a standalone product, not a platform feature: the vault-only track (`run402 repos create ` → `git push`) needs nothing else run402 does. The motivating asymmetry: a host that can READ private repositories can index them, train models on them, or hand them to someone who will — GitVault is the remote that structurally cannot, and therefore also the natural encrypted SECOND remote beside a GitHub/GitLab primary (keep your workflow; add one remote; the provider-unreadable copy exists from the first push). Your repository's history is encrypted **client-side** and stored as a chain of signed heads; for projects that ALSO deploy, the same vault doubles as the deploy-time source-of-truth. **Run402 cannot decrypt your gitvault or repository history. Deployment artifacts remain a disclosed plaintext custody boundary.** In the vault lane, source payload and repository-history content are ciphertext-only; the substrate retains only enumerated plaintext metadata and holds zero vault keys — the gateway allocates the vault, hands out create-only presigned PUT URLs, verifies what storage actually holds, admits heads, and serves the chain back. **The vault protects source history from host-side loss while a principal keystore survives**: in V0-A, whole-machine or whole-keystore loss is terminal for vault history until human envelopes ship (`VAULT_UNRECOVERABLE` — no principal keystore survives; restore deployed artifacts from CAS), so back up `~/.config/run402/gitvault`. The customer-owned mirror is the recommended default, not a power-user extra — the recommended shape is three copies: your working clone, the platform's replicated vault, and a mirror in storage you own (`run402 repos mirror `; entirely client-side, so the gateway never learns whether a mirror exists, and doctor + `repos view` carry a standing, informational `vault_unmirrored` finding until a first mirror write or sync succeeds — see the CLI reference at /llms-cli.txt for the full mirror surface). Wire protocol `r402s/v0`; every id is `_<32 hex>` and every timestamp is RFC 3339 UTC with milliseconds. **Authorization (protocol §9.1).** Seven distinct capabilities, and the split is the point: | Capability | Who | Covers | |---|---|---| | `gitvault.create` | owner | allocate the project's vault; reclaim a stalled allocation | | `gitvault.writer` | owner, developer, or a `deploy`-scoped delegate | read heads/ciphertext, upload, publish — the everyday push path | | `gitvault.compact` | owner | maintenance leases (checkpoint/compaction cycles) | | `gitvault.prune` | owner | retention prune | | `gitvault.repair` | owner **+ step-up** | supersede generations (also enforced at admission on repair-descriptor presence) | | `gitvault.policy.manage` | owner **+ step-up** | the ONLY path to `gitvault_policy: grandfathered` | | `gitvault.override_unvaulted` | owner **+ step-up**, or an explicit emergency grant | deploy without a capture | A `deploy` delegate can push and read the base it builds on; it can never compact, prune, repair, change policy, or override. **Unauthorized, nonexistent, and wrong-project are ONE envelope** — `403 GITVAULT_ACCESS_DENIED` — so no request reveals whether a vault exists. When the caller's OWN request already resolved a real vault (an existing `:vault_id` it named, or a `project_id` it supplied), the refusal's `details` echoes back that same `{project_id, repo_id}` — never anything a guessed id would newly confirm — plus a `reason` (`not_a_member` / `insufficient_capability` / `delegate_scope_excluded` / `credential_not_accepted` / `owner_required` / `project_unresolved`) and a `next_actions[]` entry naming the actual fix (check the `--project`/`--repo` you targeted, or use a principal that already holds the access) instead of a bare re-authenticate hint. A malformed or genuinely nonexistent vault id still gets the fully opaque `{}` shape, unchanged. ``` GET /gitvault/v1/repos?cursor=&limit= # SIWX/session; principal-scoped enumeration (gitvault-web-viewer task 1.1) — every vault-backed repo on an org where the caller holds an active membership. { repos: [{org_id, org_slug, repo_id, repo_name, project_id, gitvault_policy, latest_generation, created_at}], has_more, next_cursor } POST /gitvault/v1/vaults # gitvault.create (owner SIWX/session) OR org membership (developer+) for push-to-create; allocate { project_id, client_creation_id, creator_signing_pubkey, creator_encryption_pubkey } -> signed allocation record. Idempotent on client_creation_id (200 + deduplicated:true on replay). Push-to-create form (repo-first-onramp D6): { org_slug, repo_name, ... } instead of project_id — atomically creates the project + repo-name claim + vault allocation in one call when the name does not resolve yet. A losing concurrent push gets typed REPO_CREATION_CONFLICT naming the winner's project_id + a next_actions re-push entry; a slug in cooldown gets SLUG_RELEASED naming the successor GET /gitvault/v1/vaults?project_id=prj_... # gitvault.writer; the cold-restart lookup — learn your repo_id from a project you are authorized on. Alternative form: ?repo=/ (repo-first-onramp D6), resolved to project_id before authorization runs (same authorize-before-reveal guarantee either way). THIRD form: ?org_id= (repo-surface-consolidation task 2.4) — bulk vaults-by-org, org membership (any active member; viewer role or above) rather than gitvault.writer, since a delegate is project-scoped and has no "list this org's vaults" concept. Kills repos list's client-side N+1 AND repo-first-onramp 6.1's missing repo_name read in one call: { vaults: [{repo_id, project_id, project_name, repo_name (null when unclaimed), org_slug (null when unclaimed), gitvault_policy, newest_generation, source_bytes (the BILLED per-vault figure — GITVAULT_VAULT_BILLED_BYTES_SQL, never the raw ledger accumulator), genesis_admitted_at, created_at}], has_more, next_cursor }. Keyset-paginated over (created_at, repo_id) via ?limit=(1-200, default 50)&cursor= (opaque, store and echo, never parse) — the shared encodeKeysetCursor/decodeKeysetCursor codec per docs/agent-response-design.md's cursor rule. Filters to live projects (deleted_at IS NULL AND archived_at IS NULL) — a deleted project's vault keeps counting against pooled storage but does not resurface here. A nonexistent org_id and a real org the caller isn't a member of return the identical 403 FORBIDDEN POST /gitvault/v1/vaults/:vault_id/allocations/reclaim # gitvault.create (owner); supersede a stalled allocation with fresh creator keys. Refused once a genesis passed its fence GET /gitvault/v1/vaults/:vault_id # gitvault.writer; the vault record — policy, allocation, chain tip, storage + maintenance state, project_deleted_at, warnings[], writer_set: {version, sha256, writers: [{writer_key_id, signing_pubkey, principal_id}]}, pending_writers: [{principal_id, display_name, signing_pubkey}], ineligible_members: [{principal_id, display_name, reason}], read_only_terminal. (gitvault-multi-writer rev 47) `writer_set` is the vault's chain-admitted writer set as of the latest commit — the SAME projection `add_writer_key`/`rotate_epoch{writer_set_update}` advance; `writers[]` ordered by admitted_generation, never insertion order. `pending_writers[]` lists active org members (role developer+) with a published, possession-verified signing key that is NOT yet in `writer_set` — exactly the set the `gitvault_writer_pending` feed event (see "Project events" below) is telling an existing writer about; drive `add_writer_key{"writer"}` to admit one. `pending_writers_skipped` (gitvault-byo-primary-bucket D11) names why that list is EMPTY on a vault that cannot admit a second writer at all — `"byo_single_writer"` on a bring-your-own-bucket vault (its key envelopes live only in the customer's bucket, so a reconcile skips it instead of driving an `add_writer_key` the admission door refuses `409 GITVAULT_BYO_SINGLE_WRITER`), `null` otherwise. `ineligible_members[]` names WHY a given member cannot become a writer right now (`reason` ∈ membership_revoked | role_below_developer | encryption_key_revoked | gateway_blocked_pending_removal), derived at read time, never persisted. `read_only_terminal` (bool) — true only once a `rotate_epoch{writer_set_update, reason:"writer_key_revoked"}` explicitly forced the vault's last writer out (the only path to zero writers); the vault still SERVES reads, but nothing can push until a new genesis-equivalent recovery path exists # NOTE (gitvault-batched-head-reads): the three COLD-START reads — GET /gitvault/v1/vaults?project_id=, GET /gitvault/v1/vaults/:vault_id, and GET .../state — stamp a standard Server-Timing response header carrying per-phase durations (auth, the vault/record/state assembly phase, total, PLUS an additional delta phase on .../state whenever ?since= was supplied — gitvault-delta-fetch — and a restore_plan phase whenever ?restore=1 was supplied — gitvault-restore-recipe; parseable as name;dur=, with total at least as large as each named phase). A pin-less clone spends most of its startup inside these reads, and the CLIENT is the party that needs the attribution. Diagnostic only: no body field changes, no phase name carries secret material, and absence of the header on any other route implies nothing. GET /gitvault/v1/vaults/:vault_id/state?since=&restore=1 # gitvault.writer; the composite pin-current read (gitvault-composite-state-read D1) — ONE call returns { vault, newest_generation, head: {stored_bytes (base64url, byte-identical to the stored head), stored_bytes_sha256} | null, carriers: {ref_state, retention_roots} | null } where each carrier is EITHER {inline} (base64url ciphertext, at or under the 256 KiB per-carrier cap) OR {presigned_url, expires_at} (above the cap) OR, on a storage_profile:"byo" vault, {byo_key} (the carrier's relative key under the vault's byo_destination — run402 holds no payload copy to inline or presign; read it from your own bucket with your own credential) — never more than one arm; a BYO vault also omits the delta and restore_plan arms below (their packs live only in your bucket; the object-reads walk names each pack's byo_key). Stored bytes only, hash-addressed by the SAME receipts the per-object reads use; nothing here is server-derived and no key-envelope material is ever returned. Verify exactly as you would walking the paginated heads listing: chain link from your pin against head.stored_bytes, carrier hashes against the head's own embedded ciphertext_sha256, the signature. head/carriers are null while the vault has no ORDINARY admitted generation yet — both the freshly-allocated window (newest_generation null) and the genesis-only window (newest_generation equal to the genesis generation 0000000000000000; genesis is a vault_genesis object and never rides head). This route does not know your pin — if it is more than one generation behind newest_generation, ignore head/carriers here and fall back to the paginated listing walk below (unchanged). Never lifecycle-gated. Cache-Control: private, no-store (freshness is the point; cache the immutable pieces client-side instead). The presigned_url arm additionally carries edge_url (gitvault-read-edge-cache Phase B) when the platform's optional edge cache is configured — see the object-reads route below for the shared edge_url contract; both carriers here are edge-eligible kinds. # since= (OPTIONAL — gitvault-delta-fetch, the one-to-few-commits fast path): name your OWN materialized pin. When the span from since to newest_generation is small (a fixed bound, currently 4 generations), crosses no checkpoint- or transition-bearing head, and since is behind (never at-or-ahead-of) newest_generation, the response ADDITIONALLY carries delta: { since, heads: [{generation, stored_bytes (base64url, the SAME exact-stored-bytes encoding as head-reads), stored_bytes_sha256}] (chain order, since EXCLUSIVE, newest_generation INCLUSIVE), packs: [{object_kind:"wal_pack", object_id, inline (base64url)} | {object_kind:"wal_pack", object_id, presigned_url, expires_at, edge_url?}] (every WAL pack the span's heads reference, flat, keyed by object_id; inline at or under a fixed per-pack cap AND a fixed total-response inline budget — order 256 KiB / 1 MiB — presigned references above either) }. EVERY disqualification — no since, a span past the bound, a checkpoint/transition crossing, an unrecognized or ahead-of/equal-to-newest since, or the freshly-allocated/genesis-only window above — OMITS delta silently: never a refusal, and an older gateway that has never heard of since is indistinguishable from a disqualified span. Nothing in delta is server-verified or re-serialized — verify it exactly as you would the unbatched heads/head-reads/object-reads walk: chain-link each delta head against its predecessor, each pack's receipt hash before opening it, apply in chain order. Any lie, mismatch, or missing byte simply fails your own verification; fall back to the paginated walk, byte-identically. since discloses nothing you could not already read through the routes above — same authz, same never-lifecycle-gated posture. # restore=1 (OPTIONAL, ORTHOGONAL to since — gitvault-restore-recipe, the wholesale-restore fast path): declare restore intent; the literal string "1" is treated as true, anything else (including absence) as false. When true and the newest checkpoint-bearing generation at or below newest_generation can be located (or genesis, when the whole chain is within a fixed generation bound — GITVAULT_RESTORE_PLAN_MAX_GENERATIONS, sized at the heads-listing page cap — and carries no checkpoint), the response ADDITIONALLY carries restore_plan: { boundary_generation (hex16, or 0000000000000000 for the genesis fallback), heads: [{generation, stored_bytes (base64url, the SAME exact-stored-bytes encoding as head-reads), stored_bytes_sha256}] (ascending, boundary_generation THROUGH newest_generation, BOTH INCLUSIVE), checkpoint: null | {claim_set: {object_id, stored_bytes (base64url — the exact stored plaintext, writer-signed claim set)}, manifest: {object_id, stored_bytes (base64url — the exact stored ciphertext frame)}} (null exactly at the genesis fallback; both members are ALWAYS inline, never routed through object-reads' shared inline budget — plan-critical and small, so a large sibling pack can never starve them), packs: [{object_kind:"checkpoint_pack"|"wal_pack", object_id, inline (base64url)} | {object_kind:"checkpoint_pack"|"wal_pack", object_id, presigned_url, expires_at, edge_url?}] (every checkpoint pack the boundary's claim set names, then every WAL pack the span's heads reference — checkpoint packs first in claim-set order, then WAL packs in chain order, a courtesy only since you re-derive order from your own verified data; the IDENTICAL object-reads entry shape, so the same settled-fetch machinery applies unchanged) }. The boundary is located by walking STORED HEAD JSON backward from newest — never by trusting the admission ledger's checkpoint_role column, which is stamped only for ordinary maintenance-cycle checkpoints and stays NULL for a repair-lane checkpoint even though its head still carries one; the ledger only bounds how far back the one batched head read needs to reach. EVERY disqualification — no locatable boundary within the bound, a transition-bearing head anywhere in the eventual span, an unreadable stored object, or a combined checkpoint+WAL pack count above 1000 — OMITS restore_plan silently: never a refusal, and an older gateway that has never heard of restore is indistinguishable from a disqualified vault. Nothing in restore_plan is server-verified or re-serialized — verify it exactly as you would the unbatched backward walk: chain-link each plan head backward from newest, the claim set's writer signature and its cross-field equality with the manifest, each pack's receipt hash before opening it (AEAD under the carrying head's own epoch), apply in chain order, then your usual coverage check. Any lie, mismatch, or missing byte simply fails your own verification; fall back to the backward walk, byte-identically — already-verified plan heads are cache hits there, so a failed plan costs no extra round trips over today. A response may carry delta, restore_plan, both, or neither — the two arms qualify independently. restore discloses nothing you could not already read through the listing, head-read, and object-read routes above — same authz, same never-lifecycle-gated posture. GET /gitvault/v1/vaults/:vault_id/heads?after_generation=&limit=&cursor= # gitvault.writer; paginated ascending head listing above your pin. after_generation + limit REQUIRED; echo next_cursor unchanged GET /gitvault/v1/vaults/:vault_id/heads/:generation # gitvault.writer; the EXACT stored head bytes (verify the signature yourself) POST /gitvault/v1/vaults/:vault_id/head-reads # gitvault.writer; the BATCHED head read (gitvault-batched-head-reads). Body { generations: [hex16...] } — 1 to 1000 entries, STRICTLY ASCENDING (VALIDATION_FAILED otherwise, before any storage read) — response { format, repo_id, heads: [{generation, stored_bytes (base64url, byte-identical to GET .../heads/:generation), stored_bytes_sha256}] } in REQUEST order. Exists because heads are generation-addressed and therefore cannot ride the carrier-only object-reads batch below, so a cold chain walk otherwise pays one round trip per generation (overlapped, but still ~G/6 sequenced waves) for bytes that are ~1.2 KB each; one POST carries a whole listing page. ALL-OR-NOTHING: any requested generation with no stored head refuses the WHOLE batch with RESOURCE_NOT_FOUND naming the FIRST absent one in request order — a partial page would push absence-handling policy into every consumer, and the per-generation route already yields per-item absence for callers that want it. Nothing here is server-verified or re-serialized: hash what was served, exactly as on every other stored-bytes read. Never lifecycle-gated. Cache-Control: private, no-store (a POST body is not an HTTP-cacheable address; the per-generation GET keeps its immutable header) GET /gitvault/v1/vaults/:vault_id/admissions/:generation # gitvault.writer; the EXACT signed admission-record bytes for that generation POST /gitvault/v1/vaults/:vault_id/object-reads # gitvault.writer; presigned GETs for stored objects. key_envelope reads are RECIPIENT-ONLY. On a storage_profile:"byo" vault, an entry for one of the six payload kinds (wal_pack, ref_state, retention_roots, checkpoint_manifest, checkpoint_pack, key_envelope) carries byo_key — the object's relative key under the vault's byo_destination, the SAME key the upload session handed the writer — INSTEAD of url/expires_at/edge_url/inline, because run402 holds no payload copy to presign; the client reads byo_key from the customer bucket with its own locally configured credential (a machine with no local config for that vault refuses GITVAULT_BYO_NOT_CONFIGURED by name), and every gate here (ledger readability, the recipient-only envelope rule) applies unchanged. Non-payload kinds on a BYO vault presign as usual. Five immutable ciphertext kinds (wal_pack, ref_state, retention_roots, checkpoint_manifest, checkpoint_pack) additionally carry edge_url alongside url, at the SAME expires_at, whenever the platform's optional edge cache is configured (gitvault-read-edge-cache Phase B) — omitted otherwise, never null. edge_url is ADDITIVE, never a replacement: url is always present unchanged (the S3 presign), so an existing client that ignores edge_url keeps working byte-for-byte; prefer edge_url when present, fall back to url on any failure (4xx/5xx/timeout), silently — both name the identical hash-verified bytes. Edge-served bytes are ciphertext only, verified client-side exactly like the S3 presign; the edge is never part of the trust model. A pruned/purged object stops serving from BOTH paths within a bounded window: the origin 404s immediately post-deletion, and the edge cache's own TTL (1 hour) is the declared worst-case bound on a stale edge hit. Every entry additionally MAY carry inline (base64url of the object's EXACT stored bytes, identical to what url serves) when size_bytes is at or under a fixed per-object cap AND the response's running total inline budget is not yet spent, decided in request order — url/edge_url stay present regardless, size-gated never kind-gated (key_envelope inlines too, once past its recipient-only check above), and omission (over-cap, budget spent, or a gateway-side storage read failure on that entry) is indistinguishable from an older gateway. POST /gitvault/v1/vaults/:vault_id/object-reads/bytes # gitvault.writer; a payload kind of a storage_profile:"byo" vault is refused 409 GITVAULT_BYO_PAYLOAD_NOT_HELD (run402 holds no copy to stream — use object-reads' byo_key). Otherwise: gateway-STREAMED bytes for ONE object (body = one object-reads[] entry, response = raw bytes + X-Run402-Stored-Bytes-Sha256). The browser-safe path — the gitvault bucket has no CORS and cannot get any, so a browser can never fetch object-reads' presigned URL directly; use this route instead when running in a browser. SDK/CLI keep using object-reads (Node fetch is not subject to CORS). Same recipient-only rule on key_envelope bytes. GET /gitvault/v1/vaults/:vault_id/objects?cursor=&limit= # gitvault.writer; keyset-paginated listing of EVERY stored object — client-uploaded + server-authored kinds, AND the head chain (every COMMITTED generation's head/vault_genesis object + its admission_record, incl. genesis at 0000000000000000) — as { key, object_kind, sha256, size_bytes } plus has_more, next_cursor, and total (exact decimal-string count across the WHOLE vault, not just this page — every page materializes the full set before slicing, so it's never an estimate and never null). object_kind "head"/"vault_genesis" entries fetch via GET .../heads/:generation; "admission_record" entries fetch via GET .../admissions/:generation — NOT via POST .../object-reads. sha256/size_bytes on head-chain entries are the ledger's exact stored-bytes hash/length, never estimated or null. NOT a protocol object (no format field). Excludes DELETING/DELETED/PRUNE_AUTHORIZED_PRESENT — listed implies fetchable. cursor is opaque + vault-bound (malformed/cross-vault -> INVALID_CURSOR) GET /gitvault/v1/vaults/:vault_id/envelope-recipients # gitvault.writer; FINGERPRINTS ONLY (never envelope bytes) of the principals currently covered by this vault's key_envelope objects, PLUS `desired[]` — the org's desired-recipient state for this vault (repo-surface-consolidation task 2.1/2.3: membership-driven, versioned, audited; the server owns this state but never wraps a key), each entry carrying `public_key`/`ek_fingerprint`/`suite`/`covered` inline so a key-holding client can fulfill in ONE read, plus `desired_state_version` (the substrate's watermark) — what a coverage diff and the org directory (below) compare against POST /gitvault/v1/vaults/:vault_id/upload-sessions # gitvault.writer; open a session with the expected manifest -> per-object create-only presigned PUTs (returned ONCE). INLINE UPLOAD (gitvault-composite-state-read D2): when every object also carries `bytes` (base64url, at or under 256 KiB/object and 1 MiB/request total), the gateway verifies each declared checksum against the received bytes BEFORE any write and performs the storage writes itself — same create-only semantics, same checksum binding, same source_bytes accounting as the presigned path — and the response IS the finalize response ({receipts, charged_bytes, finalized_at, deduplicated}) instead of presigned PUTs, replacing session + N PUTs + finalize with one call. Mixing inline and presigned entries in one request refuses VALIDATION_FAILED; an object over either cap uses the presigned shape unchanged GET /gitvault/v1/vaults/:vault_id/upload-sessions/:upload_session_id # gitvault.writer; session state, REDACTED (never a presigned URL) POST /gitvault/v1/vaults/:vault_id/upload-sessions/:upload_session_id/finalize # gitvault.writer; verify storage's authoritative checksum + length, charge, return the per-kind receipts heads carry DELETE /gitvault/v1/vaults/:vault_id/upload-sessions/:upload_session_id # gitvault.writer; abort = redact — tombstones whatever you already stored so it is reaped POST /gitvault/v1/vaults/:vault_id/admissions # gitvault.writer; THE push — admit a signed head (or the genesis). { generation, stored_bytes (base64url of the EXACT bytes), stored_bytes_sha256 } -> 201 { admission_record_sha256, capture_receipt } | 202 record write unresolved (poll, never re-push) POST /gitvault/v1/vaults/:vault_id/retention-cutoffs # gitvault.writer; mint the server-timed retention_cutoff ticket a checkpoint receipts. { base_head_sha256 } -> { ticket, receipt } POST /gitvault/v1/vaults/:vault_id/activation-tokens # gitvault.writer; exchange a capture_receipt for the activation_token an apply commit consumes. { operation_id, capture_receipt } POST /gitvault/v1/vaults/:vault_id/maintenance-leases # gitvault.compact (owner); acquire the vault's maintenance lease + headroom. holder_token returned ONCE POST /gitvault/v1/vaults/:vault_id/maintenance-leases/:maintenance_lease_id/heartbeat # gitvault.compact; renew (never past the hard deadline) DELETE /gitvault/v1/vaults/:vault_id/maintenance-leases/:maintenance_lease_id # gitvault.compact; release the lease early POST /gitvault/v1/vaults/:vault_id/compaction-grant # gitvault.writer; open a bounded, TTL'd compaction headroom grant (~1h, hard-coded) so a near-quota org can still run the compact+prune cycle. Body { requested_bytes? } — the server caps at the vault's OWN measured billed source_bytes regardless of what (if anything) you request. -> 201 { granted_bytes, expires_at, pool_used_bytes, pool_limit_bytes, effective_pool_limit_bytes } (byte counts as decimal strings). A second open while one is active refuses 409 GITVAULT_COMPACTION_GRANT_ACTIVE naming details.expires_at/details.granted_bytes — an expired-but-unswept grant releases the slot on this SAME call instead. At most one active grant per project. Never lifecycle-gated: an owner in billing grace must still be able to run the one remedy that keeps clone/fetch fast DELETE /gitvault/v1/vaults/:vault_id/compaction-grant # gitvault.writer; idempotent close — { closed: true } once, { closed: false } on a repeat or when none was active (Faithful, never a pretend close). Never lifecycle-gated POST /gitvault/v1/vaults/:vault_id/maintenance/open # gitvault.compact (owner); open a maintenance cycle. Read as EXACT bytes (Content-Type: application/json). Idempotent on client_open_id — the same id returns the SAME signed issuance (minted:false); the same id under different bindings -> 409 CLIENT_OPEN_ID_CONFLICT POST /gitvault/v1/vaults/:vault_id/maintenance/abort # gitvault.compact (owner); { maintenance_cycle_id } — owner abort, legal ONLY before the first checkpoint is admitted POST /gitvault/v1/vaults/:vault_id/prune-intents # gitvault.prune (owner); a storage_profile:"byo" vault is refused 409 GITVAULT_BYO_PRUNE_NOT_SUPPORTED (run402 cannot delete from a bucket it holds no credential to; client-executed deletion has not shipped — compaction still works, superseded history stays in your bucket). Otherwise: submit the signed prune intent. Read as EXACT bytes (Content-Type: application/json) GET /gitvault/v1/vaults/:vault_id/prune-intents/:prune_intent_object_id # gitvault.prune (owner); intent state + the signed completion's per-object outcomes once it lands POST /gitvault/v1/vaults/:vault_id/repair-attempts # gitvault.repair (owner + step-up); open the bounded repair resource lane PATCH /gitvault/v1/vaults/:vault_id/policy # gitvault.policy.manage (owner + step-up); { gitvault_policy: "required"|"grandfathered", reason? } -> { gitvault_policy, gitvault_policy_version, changed, warnings }. Idempotent: a no-op returns changed:false and writes no audit row POST /gitvault/v1/vaults/:vault_id/override-completions # gitvault.writer; close an unvaulted-deploy advisory. { operation_id, capture_receipt } — FULL-FIELD equality against the journal; any mismatch names details.mismatched_field and the advisory persists POST /gitvault/v1/vaults/:vault_id/rotation-attempts # gitvault.writer (protocol rev 42 D195/D199 — the descriptor carries no reason, so the reason's authority is enforced at the rotate_epoch head's admission: member_removed needs gitvault.writer only, every other reason gitvault.rotate = owner + step-up); the FIRST write of any epoch rotation attempt, BEFORE any key_envelope upload session for it. Body is the complete writer-signed rotation_attempt_descriptor -> 201 { rotation_id, descriptor, deduplicated:false } on a fresh create, 200 + deduplicated:true on a byte-identical replay. A byte-distinct descriptor at the SAME rotation_id refuses 409 ATTEMPT_DESCRIPTOR_CONFLICT — the create-only CAS that closes the same-rotation_id-different-key complementary-path mosaic BEFORE any key material reaches storage POST /gitvault/v1/vaults/:vault_id/recipients/:principal_id/confirm # gitvault.rotate (owner + step-up); first-seen pin confirmation — { new_fingerprint } -> the signed recipient_confirmation_receipt (purpose:"first_pin"), single-use, cited by the matching recipient_pin_manifest entry the rotation eventually admits. Refuses 409 RECIPIENT_SET_MISMATCH if this principal already has a currently-admitted pin (use /repin instead). IDEMPOTENT (D206, rev 43): a repeated call for the SAME (principal, new_fingerprint, base manifest, recipient_state_version) tuple returns 200 with the SAME previously-issued receipt, never a second distinct one; a genuinely new mint is 201 POST /gitvault/v1/vaults/:vault_id/recipients/:principal_id/repin # gitvault.rotate (owner + step-up); re-pin ceremony — { old_ek_fingerprint, new_fingerprint } -> the signed recipient_confirmation_receipt (purpose:"repin"). old_ek_fingerprint MUST equal the principal's currently-admitted pin exactly, or 409 RECIPIENT_SET_MISMATCH names what is actually pinned. IDEMPOTENT (D206, rev 43) on the SAME tuple, same 200-vs-201 convention as /confirm GET /gitvault/v1/vaults/:vault_id/recipients/:principal_id # gitvault.rotate (owner + step-up); D206, rev 43 — the principal's pin_status ("pinned"|"unpinned") + pinned_fingerprint, current_base_pin_manifest_sha256/current_recipient_state_version (the values a FRESH /confirm or /repin call would bind right now), and up to 20 outstanding (unconsumed) recipient_confirmation_receipts in (issued_at DESC, object_id DESC) order, each classified "applicable" (an admission citing it would succeed right now) or "superseded" (stale — call /confirm or /repin again, which is safe to do freely) POST /gitvault/v1/vaults/:vault_id/recipients/:principal_id/proof-of-open # gitvault.read_envelope + STRICT principal self-match (the caller must BE :principal_id — never overridable, admin keys included); D210, rev 44 — submit fsck's own { ek_fingerprint, chain_verified_to_generation, decryptable_to_generation, reader_entrypoint } as evidence the recipient opened its envelope through the real reader path -> the signed recipient_open_receipt (evidence, never authorization: no consumed state, admission never reads it). Gateway checks ledger consistency only (live envelope for the fingerprint; decryptable <= newest committed generation; chain >= decryptable) — the decryption claim itself is HONEST-CLIENT EVIDENCE the server cannot verify. Refuses 409 OPEN_PROOF_MISMATCH on ledger disagreement. IDEMPOTENT on (principal, fingerprint, decryptable_to_generation): exact replay returns 200 with the SAME receipt, fresh mint is 201. NEVER lifecycle-gated or transfer-frozen POST /gitvault/v1/vaults/:vault_id/recipients/:principal_id/key-revocation # gitvault.rotate (owner + step-up); declares reason:"recipient_key_revoked" admissible — advances the vault's ORGANIZATION-level recipient-revocation watermark (the SAME watermark a member removal advances) -> { recipient_state_version, recipient_revocation_version } POST /gitvault/v1/vaults/:vault_id/epoch-secret-exposure # gitvault.rotate (owner + step-up); declares reason:"epoch_secret_exposed" admissible. VAULT-scoped, not org-wide — one vault's leaked secret is not evidence a sibling vault is compromised -> { epoch_secret_exposure_version } POST /gitvault/v1/vaults/:vault_id/writer-authority/declare-unavailable # gitvault.rotate (owner + step-up); explicit, audited fact that the writer signing key is known gone (the platform cannot itself observe this) — feeds the WRITER_AUTHORITY_UNAVAILABLE bridge state. Idempotent — re-declaring re-stamps declared_at/declared_by -> { declared_at, declared_by } POST /gitvault/v1/vaults/:vault_id/handoffs # handoff.mint (developer+ SIWX/session/admin — NO delegate: a delegate is never a member, and a handoff confers membership); mint a single-use Handoff Key claim row. Body { handoff_id (client-generated UUID — it salts the HKDF derivation, so it must exist before mint; malformed -> 400 VALIDATION_FAILED; a collision -> 409 HANDOFF_ID_CONFLICT), role? (<= the minter's own fresh role; default = minter's role), expires_in_seconds? (60..86400, default 3600), checkpoint: { generation (a non-negative integer OR the canonical zero-padded generation string every gitvault surface renders — either form is accepted), snapshot_oid_hmac }, sealed_envelope, envelope_kind, auth_hash, writer_admission_grant } -> 201 { handoff_id, kind: "handoff", role, expires_at, repo_id, org_id, project_id, checkpoint, warning, warnings: [{ code: "HANDOFF_KEY_CONFERS_ROLE", message }], next_actions: [revoke_handoff] }. The `kgh1_` key itself is assembled ENTIRELY CLIENT-SIDE and never sent to the gateway — the gateway's whole view of a handoff is a hash, a ciphertext, a pin, and a state. `warning` is the verbatim blast-radius sentence: "Whoever claims this key first becomes an owner of this org and a writer of this vault, permanently. The key works once and expires at ." (gitvault-multi-writer rev 47 amended the sentence — "a writer of this vault" joined the disclosure since a claimed handoff now confers WRITE authority on the vault, not just org membership); `warnings[]` carries the same sentence under the machine-readable code HANDOFF_KEY_CONFERS_ROLE, which is what a client keys on. Bounds (hard-coded): 20 open handoffs per vault (409 HANDOFF_OPEN_LIMIT), 100 mints per org per UTC day (429 RATE_LIMITED). Never lifecycle-gated. `writer_admission_grant` (gitvault-multi-writer rev 47, REQUIRED — no observe mode) is the minter's own signed grant proving THEY are currently an active vault writer and binding the mint (`repo_id`, `handoff_id`, `auth_hash`, `checkpoint_generation`/`_head_sha256`, `grantor_writer_key_id`, `handoff_admission_pubkey`, `minted_role`, `claim_not_after`) — verified synchronously under the minter's own `signing_pubkey` from the org directory. The minter must currently be an active, non-gateway-blocked writer or the mint is refused `403 HANDOFF_MINT_REQUIRES_WRITER` (`next_actions` names admitting yourself as a writer first, via an existing writer's `add_writer_key{"writer"}` or a prior handoff). A vault whose `storage_profile` is `"byo"` refuses the mint outright with `409 GITVAULT_BYO_SINGLE_WRITER` (gitvault-byo-primary-bucket D11) — a bring-your-own-bucket vault admits exactly one writer in v1, because its key envelopes live only in the customer's bucket, so a claimant would resume into writer authority it cannot use; `details: { storage_profile: "byo", vault_id }`, and nothing is written GET /gitvault/v1/vaults/:vault_id/handoffs # gitvault.writer (developer+ SIWX/session, or a deploy-scoped delegate); list this vault's handoffs -> { handoffs: [{handoff_id, kind, state: issued|claimed|expired|revoked, role, minted_by, expires_at, claimed_by}] } — NEVER the hash or the sealed envelope DELETE /gitvault/v1/vaults/:vault_id/handoffs/:handoff_id # gitvault.writer at the route, then minter-OR-an-active-owner inside the service; idempotent revoke -> 200 { handoff_id, state } (repeating on an already-terminal handoff is a no-op naming its current state, never an error). Never lifecycle-gated POST /gitvault/v1/handoffs/:handoff_id/claim # SIWX WALLET ONLY — a control-plane session, delegate, or service key is refused 403 HANDOFF_CLAIM_REQUIRES_WALLET naming `kygit resume`, because the keystore key this call publishes is what makes the recipient a real key-holder; tier-free (principalAuth resolves-or-provisions the caller's own org-of-one, no active tier required). Body { auth_secret, writer_acceptance } -> 200 { handoff_id, kind, deduplicated, sealed_envelope, envelope_kind, repo_id, org_id, project_id, checkpoint, membership: {org_id, role, status: "active"}, members[], expires_at, writer_admission_grant, writer_activation: {state: "pending"|"active"}, next_actions: [push_repo, remove_member?] }. ONE transaction, no durable lease, first-writer-wins. Hash verification precedes every disclosure: a wrong auth_secret for an existing OR nonexistent id is the IDENTICAL 403 HANDOFF_KEY_INVALID. Only after the hash verifies: 410 HANDOFF_KEY_EXPIRED, 410 HANDOFF_KEY_REVOKED (includes the fail-closed authority-drift fences — the minter's membership went inactive/insufficient, or the vault's project moved to a different org), 409 HANDOFF_KEY_ALREADY_CLAIMED (details.claimed_at names when; a claim by the SAME principal before expiry is instead 200 deduplicated:true with the ORIGINAL sealed envelope, safe because only the key holder can derive wrap_key — never a pretend re-claim). `next_actions` carries `push_repo` always, plus `remove_member` when the minter is STILL an active owner (the zombie-sender case — nothing auto-retires the sender). Response header `Cache-Control: no-store`; never cached. Never lifecycle-gated. `writer_acceptance` (gitvault-multi-writer rev 47, REQUIRED — no observe mode) is the claimant's own double-signed acceptance of the mint-time `writer_admission_grant` — `acceptance_signature` verified under the grant's own `handoff_admission_pubkey`, `possession_signature` verified under the claimant's own submitted `signing_pubkey` (both over the identical preimage — genuinely independent proofs, not one signature checked twice); a claim under a DIFFERENT `writer_key_id` than an earlier successful claim by the SAME principal refuses `409 HANDOFF_CLAIM_WRITER_KEY_MISMATCH` rather than silently deduplicating. On success the claimant's directory signing half is published possession-verified in the SAME transaction (a brand-new principal gets both halves active immediately; an existing row with the identical encryption key gets its signing columns refreshed in place; a DIFFERENT active encryption key on file is left untouched, never overwritten, never failing the claim). `writer_admission_grant` in the response is the ORIGINAL grant echoed back byte-for-byte (for the client to persist/replay); `writer_activation.state` is `"pending"` on a FRESH claim, always. On a REPLAYED claim (`deduplicated: true` — the same principal re-claiming before expiry) it reflects the row's CURRENT state instead, which may already read `"active"` if the claimant's `add_writer_key{"handoff"}` push admitted the key on-chain in the meantime — the one case where this response can observe the pending→active flip, since no GET route re-exposes it directly. The unconditional way to confirm admission is the same as for any writer: the added `writer_key_id` appears in `writer_set.writers[]` on `GET /gitvault/v1/vaults/:vault_id` (above) POST /gitvault/v1/vaults/:vault_id/invites # invite.mint (developer+ SIWX/session/admin — NO delegate: minting CONFERS MEMBERSHIP, and a delegate is never a member); mint a single-use Invite Key claim row (kygit-invite, the SECOND claim kind — one shared claim engine, parameterized by kind, not a fork). Body { invite_id (client-generated UUID, same coordinator amendment as handoff_id — salts the HKDF derivation; malformed -> 400 VALIDATION_FAILED; a collision -> 409 INVITE_ID_CONFLICT), role? (<= the minter's own fresh role; DEFAULT IS "developer" — unlike a handoff, NOT the minter's own role), expires_in_seconds? (60..86400, default 3600), room_key? (a lowercase slug; omitted defaults to the vault's OWN project id — its default room), inviter_presence_id? (the minter's OWN prs_ id in that room, best-effort — absent stays null, never blocks the mint), checkpoint: { generation (integer or the canonical zero-padded generation string), snapshot_oid_hmac }, sealed_envelope, envelope_kind, auth_hash, writer_admission_grant } -> 201 { invite_id, kind: "invite", role, room: {org_id, room_key}, expires_at, repo_id, org_id, project_id, checkpoint, writer_admission_grant_sha256, warning, warnings: [{ code: "INVITE_KEY_CONFERS_ROLE", message }], next_actions: [revoke_invite] }. `warning` is the SAME verbatim blast-radius sentence the handoff mint uses, with the invite's role: "Whoever claims this key first becomes a developer of this org and a writer of this vault, permanently. The key works once and expires at ." The `kgi1_` key itself is assembled ENTIRELY CLIENT-SIDE (kind-domain-separated HKDF — `kygit/invite/{auth,wrap,writer-admission}/v1`, so an invite secret never verifies a handoff hash and vice versa) and never sent to the gateway. `writer_admission_grant` (gitvault-multi-writer rev 47, REQUIRED) is the minter's own signed grant proving THEY are currently an active vault writer and binding this mint — the minter must be an active, non-gateway-blocked writer or the mint is refused `403 INVITE_MINT_REQUIRES_WRITER` (`next_actions: request_writer_sync`, naming `run402 repos access sync`) before any row, placeholder membership, or audit row is written. A BYO vault refuses the invite mint the SAME way the handoff mint is refused — `409 GITVAULT_BYO_SINGLE_WRITER`, one code from the one shared claim engine (gitvault-byo-primary-bucket D11). The grant rides the SAME frozen chain object the handoff door uses (its `handoff_id` field carries the INVITE id) — one admission door, two product doors in front of it. Bounds are SHARED with handoffs — one population, not two independent caps: 20 open (409 INVITE_OPEN_LIMIT — the refusal names this vault's own count of BOTH kinds combined), 100 mints per org per UTC day (429 RATE_LIMITED, likewise combined). Never lifecycle-gated GET /gitvault/v1/vaults/:vault_id/invites # gitvault.writer (developer+ SIWX/session, or a deploy-scoped delegate); list this vault's invites -> { invites: [{invite_id, kind, state: issued|claimed|expired|revoked, role, minted_by, room_key, expires_at, claimed_by}] } — NEVER the hash or the sealed envelope DELETE /gitvault/v1/vaults/:vault_id/invites/:invite_id # gitvault.writer at the route, then minter-OR-an-active-owner inside the service; idempotent revoke -> 200 { invite_id, state } (repeating on an already-terminal invite is a no-op naming its current state, never an error). Never lifecycle-gated POST /gitvault/v1/invites/:invite_id/claim # SIWX WALLET ONLY — a control-plane session, delegate, or service key is refused 403 INVITE_CLAIM_REQUIRES_WALLET naming `kygit join`; tier-free (the claimant's own cold-start tier purchase is a SEPARATE client-side step this route never blocks on). Body { auth_secret, writer_acceptance } -> 200 { invite_id, kind, deduplicated, sealed_envelope, envelope_kind, repo_id, org_id, project_id, checkpoint, membership: {org_id, role, status: "active"}, members[], room: {org_id, room_key}, inviter: {presence_id, name, program, model, state, last_active} | null, live_presences[], cursor, expires_at, writer_admission_grant, writer_activation: {state: "pending"|"active"}, next_actions: [push_repo, wait_room, send_room_message?] }. ONE transaction, no durable lease, first-writer-wins — the identical machinery a handoff claim runs, kind-bound. Hash verification precedes every disclosure: a wrong auth_secret, a nonexistent id, OR A HANDOFF ROW'S REAL ID AT THIS ROUTE are the IDENTICAL 403 INVITE_KEY_INVALID (the cross-kind refusal is symmetric — `kgh1_` at `join` and `kgi1_` at `resume` are both refused by name, pointing at the OTHER verb). Only after the hash verifies: 410 INVITE_KEY_EXPIRED, 410 INVITE_KEY_REVOKED (the same fail-closed authority-drift fences as handoff), 409 INVITE_KEY_ALREADY_CLAIMED (details.claimed_at; a same-principal replay before expiry is 200 deduplicated:true with the ORIGINAL envelope; a replay presenting a DIFFERENT writer key is 409 INVITE_CLAIM_WRITER_KEY_MISMATCH). `writer_acceptance` (gitvault-multi-writer rev 47, REQUIRED) is the claimant's own double-signed acceptance of the mint-time grant — verified against the stored grant, recorded with `writer_activation.state: "pending"`, and echoed back alongside `writer_admission_grant` so the joiner's own client completes an `add_writer_key` transition and pushes under its OWN key while the inviter keeps pushing under theirs. The claim also registers the claimant in the org's desired-recipient map, so later envelope reconciles can cover it. `inviter` resolves the minting presence LIVE from the room (name/program/model/state/last_active) — null when the inviter never registered a presence or its resolution best-effort-fails; this NEVER blocks or fails the claim. `live_presences[]` and `cursor` (the room's catch-up cursor — so the joiner's first `messages wait` needs no flags) ride every claim response. `next_actions` NEVER carries `remove_member` for this kind, under any condition — closing the shipped handoff-only gap where the gateway checked owner but not kind. Response header `Cache-Control: no-store`; never cached. Never lifecycle-gated ``` **Handoff — "Handoff. Resume." (kygit-handoff).** An agent hands its exact repository state — dirty tree included — to a DIFFERENT agent, on a different machine, model, account, or vendor, with a note about what happened and what remains: `kygit handoff` (or `run402 repos handoff`) captures the working tree stash-shaped (tracked, staged, unstaged, deleted, and untracked changes distinctly — ignored files never transferred; a fixed sensitive-file denylist — `.env`, `*.pem`, `id_rsa*`, `.ssh/**`, `.aws/**`, `.npmrc`, `*.tfstate*`, and similar — is excluded from the untracked set and reported as `capture.sensitive_excluded[]`), mints the `kgh1_` Handoff Key (69 chars, HKDF-derived `auth_secret`/`wrap_key`, assembled and printed EXACTLY ONCE, client-side — the gateway never sees it), and pastes the key to the next agent. That agent runs `kygit resume kgh1_…` (or `run402 repos resume`): on a wallet with no active tier the CLI first folds the cold-start chain (allowance → faucet → one x402 prototype payment, announced; `--no-init` opts out) so the resuming agent arrives as a paid-up run402 wallet of its own, while the claim itself needs no active tier and never waits on that chain (a failure is reported as `cold_start.error` + a `renew_tier` next action), the claim verifies `auth_secret` against the stored hash and flips the recipient into an ACTIVE org member at the stored role (never wider than the sender's own role at mint time), the sealed envelope is decrypted client-side, and the working tree is restored into a fresh directory (`git clone` at the base HEAD, then `git stash apply --index` on the captured checkpoint commit) with the note rendered as Markdown. One key, one use, one hour by default (60s..86400s configurable), revocable, structurally superseded by an ordinary principal envelope within minutes of use (the recipient's own keystore key is what the platform's existing agent-envelope reconcile wraps a real envelope for on its next touch). **Threat model, in one sentence:** the Handoff Key reintroduces a bearer secret over a chat transcript ON PURPOSE for the cross-vendor case where no shared directory or credential already exists — mitigated by one hour, one use, atomic claim, revocable, minter-drift fail-closed, and structural supersession after use. Nothing auto-retires the sender: an owner→owner handoff leaves two owners, and the claim response's `remove_member` next_action is the documented cleanup once the sender's environment is confirmed gone for good. No MCP tool exists for either verb (a bearer secret; a membership + working-tree mutation — the same law that keeps `repos create`/`delete` off MCP); both are CLI/SDK-only. The `run402::` and `kygit::` remote-address spellings are interchangeable — the door decides which one is rendered, never the gateway (the canonical internal form and every `address`/next_action stays scheme-less `run402::`); an unrecognized scheme is a client-side parse error, never a gateway concern. **Invite — "Invite. Join." (kygit-invite).** Handoff solves continuity (one agent stops, another continues); Invite solves COLLABORATION — a working agent keeps working while a SECOND agent joins the exact same state, with its own principal and a shared room. `kygit invite` (or `run402 repos invite [--room ]`) captures the CURRENT working tree the same stash-shaped way a handoff does (dirty by default, same sensitive-file denylist), registers the inviter's own presence in the target room (the project's default room unless `--room` names another), mints the `kgi1_` Invite Key (kind-domain-separated HKDF — never verifies as a handoff key or vice versa) at role **developer by default** (never the inviter's own role, still attenuated to it), posts ONE source-plane fact to the room from the inviter's own presence — "Invited another agent from checkpoint (invite , expires )." — and prints the key exactly once. The inviter's own tree and work are UNTOUCHED; nothing about inviting stops or hands off anything. The recipient runs `kygit join kgi1_…` (or `run402 repos join`): on a wallet with no active tier the SAME cold-start fold `resume` uses runs first (allowance → faucet → one x402 prototype payment, announced — the joiner's first platform act is a real transaction, never blocking the claim), the claim materializes membership at the invite's stored role, the sealed envelope opens client-side, the tree restores exactly as a handoff's does (`git clone` at base HEAD, `git stash apply --index`), local git config pins `r402.room` to the invite's own room so `run402 messages wait` needs zero flags, `.run402/` is appended to `.git/info/exclude` (idempotent — the ignore file itself is captured tree state and must not be touched), and the joiner posts its OWN one fact — "Joined as from checkpoint ." — before rendering the note (Markdown), the restored figures, the inviter's name and liveness, and the room's recent messages. `run402 messages wait` (see "Agent messaging" below) is the coordination primitive that makes the shared room usable from inside a harness with no human relaying: block until a message lands or a bounded timeout elapses, silence still answers "who is still here." Both product-posted facts are ordinary attributed room messages (never a system sender), idempotent per invite id (a crash-and-retry posts once), and are the ONLY two sentences the product ever says in an agent's voice. Bounds, revocation, and the sweep are the SAME shared population as handoffs (one open-row cap, one daily-mint cap, one 90-day terminal prune) — a vault does not get two independent quotas by having two kinds. `next_actions` NEVER carries `remove_member` for an invite claim, under any condition (an invite never "zombies" a sender the way an owner→owner handoff can, because the inviter never stopped working). Each invite re-captures the inviter's tree at THAT moment — three participants invited apart start from three separate checkpoints, by design; `--from ` reuse is deliberately deferred. No MCP tool exists for either verb, the same law as handoff (a bearer secret; a membership + working-tree mutation); both are CLI/SDK-only. The `run402::` / `kygit::` remote-address interchangeability is unchanged from handoff. **Pushing a head.** `POST …/admissions` carries the head as `stored_bytes` — canonical base64url of the EXACT signed object — plus your own `stored_bytes_sha256`, which is verified before the protocol machine sees anything. The generation is won by a create-only compare-and-swap on the admission record, so exactly one push can own it: a loser gets `409 HEAD_CAS_CONFLICT` with `details.winner = { generation, stored_bytes_sha256 }` and **only** those hashes — re-read the winner from `GET …/admissions/:generation`, verify it yourself, rebase, and push again. A `201` means the generation is yours; a `202` means the record write is not yet resolved and the platform's own reconciler is driving it to completion — poll, never re-push. A head carrying a `repair` descriptor additionally needs `gitvault.repair` (owner + step-up) or the admission refuses `403 REPAIR_NOT_AUTHORIZED`, decided from the signed bytes rather than from the URL. When the head declares a capture, the response carries the signed `capture_receipt` you exchange at `POST …/activation-tokens`. **Exact-bytes routes.** `POST …/maintenance/open` and `POST …/prune-intents` read the request **body as bytes**: both are protocol objects that are strict-parsed (no numbers, no duplicate members, byte-identical to their canonical form), and the prune intent is signature-verified over exactly what you sent. Send them with `Content-Type: application/json` and do not let a client library re-serialize them. **Uploads are create-only, single-part, and pre-committed.** Every presigned PUT is signed with `If-None-Match: *`, an exact `Content-Length`, and an exact `x-amz-checksum-sha256` — send the returned `headers` verbatim or storage rejects the write. That means an upload can never silently overwrite an existing object and can never store bytes that do not hash to what you declared. URLs are returned **once**, at issuance: re-reading the session gives you kinds, ids, hashes, and sizes, never a URL. Finalize is what makes an object real — it re-reads storage's own checksum and length (a multipart/COMPOSITE checksum is refused), writes the permanent row, and returns the receipts. Object ids are **never reusable in any state**: the row IS the tombstone. **Storage is org-pooled — against its OWN `sourceBytes` limit, separate from app `storageBytes` (kygit-handoff D4).** A vault's bytes are billed as `max(1 MB per-vault minimum, sum of each unique object's size with a 4 KiB per-object accounting floor)` — a small vault reads as roughly the minimum instead of scaling with object count, while the per-object floor keeps a large vault's accounting un-gameable. `TierConfig.sourceBytes` (prototype 1 GB / hobby 10 GB / team 50 GB) is a SEPARATE org-pooled quota from `storageBytes` (app/project storage): the two terms are compared against their own limits independently. An upload session reserves its bytes at issuance and releases them on abort or expiry; a maintenance lease adds bounded cycle headroom so a compaction can temporarily exceed the steady-state limit. Over the vault pool → `403 QUOTA_EXCEEDED` with `details.resource: "source_bytes"` and the pooled figures. `GET /tiers/v1/status` reports both: `pool_usage.gitvault_source_bytes` (the billed vault figure, compared against `pool_usage.source_bytes_limit`) is ALSO folded into `pool_usage.total_storage_bytes` for a single at-a-glance number, but the refusal a gitvault write actually hits is reproducible from `gitvault_source_bytes` vs `source_bytes_limit`, not the folded total. Grace (`past_due`/`frozen`/`dormant`) keeps the last-known tier's `sourceBytes`; a downgrade to prototype (see "Organization lifecycle" above) drops it to prototype's 1 GB — an over-quota vault keeps every byte readable, clonable, and mirrorable and refuses only NEW pushes, with a `renew_tier` next_action. **Prototype egress allowance (kygit-handoff D4, prototype only).** Object reads are counted at READ AUTHORIZATION by stored object size — never transport bytes, so the edge-served path counts identically — per organization per calendar month (UTC). Allowance = `max(3 GB, 3 × gitvault_source_bytes)`; over it, reads refuse `403 QUOTA_EXCEEDED` with `details.resource: "gitvault_egress_bytes"` and `renew_tier` + `configure_mirror` next_actions. Usage/limit are visible on `GET /tiers/v1/status` as `pool_usage.gitvault_egress_bytes_used` / `pool_usage.gitvault_egress_bytes_limit` (the limit is `null` on hobby/team — leased tiers are unmetered by this allowance in this change). Mirror sync reads count and fit inside the 3× headroom; `r402s-recover` reads your OWN bucket and is free by construction, at every tier. **The compaction headroom grant breaks the near-quota deadlock.** A compaction transiently needs roughly 2x a vault's `source_bytes` — the new checkpoint must land durably before the superseded history is pruned — but a vault sitting close to its pool limit could never fit that transient, which meant the ONE remedy for a growing WAL backlog (compact + prune) was structurally refused exactly when it was needed most. `POST …/compaction-grant` opens a bounded, TTL'd (~1 hour, hard-coded) grant that raises the org's effective storage limit by exactly `granted_bytes` for that window — never the reported usage, never a standing increase. The server measures and caps the grant at the vault's OWN current billed `source_bytes`; a client-declared amount is only ever a smaller request, never a larger one. `DELETE …/compaction-grant` closes it early (call this once the prune completes); otherwise it expires on its own and simply drops out of every quota SUM — the org's next write is refused exactly as it would be without a grant, so a crashed or abandoned compaction never becomes free storage. At most one active grant per project. **A vault OUTLIVES its project — permanently, and it keeps costing.** Deleting a project does **not** delete, expire, or unbill its vault. The vault's encrypted history still restores (`git fetch run402::/ refs/run402/deploys/latest`), its `source_bytes` still count against the organization's pooled storage, and **no client command removes it**: V0 has no purge verb, and `prune` requires an owner-signed intent plus two verifier receipts, which nothing issues for a deleted project. This is deliberate — the vault ledger is audit-grade — so it is disclosed rather than hidden: `DELETE /projects/v1/:project_id` names the surviving vault and its retained bytes, the vault record carries `project_deleted_at` plus a `gitvault_project_deleted` warning (never an empty `warnings[]`), and `pool_usage.gitvault_source_bytes` counts it. Delete a project only when you accept that its encrypted history and its storage charge both survive. **Reading the chain.** `GET …/heads` is bucket-derived and ascending from **your** pin — there is no hint object to trust. `after_generation` and `limit` are both required; `cursor` is opaque and bound to `(vault, after_generation, position)`, so a tampered or mismatched cursor is a typed retryable `400 INVALID_CURSOR`, never a silent restart. A gap or a malformed key fails closed as `409 CHAIN_BROKEN`. On the final page `total` is exact; while `has_more` is true it is `null` — never an estimate. **Policy.** Allocating a vault does **not** set the project's `gitvault_policy` — a freshly allocated vault starts with policy `null`, and its deploys proceed ungated (byte-identical to a vaultless project) until an owner opts in. `PATCH …/policy` is the **only** writer of `gitvault_policy` / `gitvault_policy_version`, in either direction: `required` gates deploys on a vaulted capture; `grandfathered` (deploys activate without a vaulted capture) requires owner + step-up, is audited, and leaves a persistent warning on the vault record and in `run402 doctor` until you set it back. The call is idempotent — setting the policy it already has returns `changed: false` and writes no audit row. `GET /tiers/v1/status` and the operator overview surface a `gitvault` summary (with `policy: null`) for any project with an allocated vault, whether or not its policy has ever been set, so a freshly allocated vault stays visible instead of disappearing from status while it still accrues `source_bytes` charges. **Push-gated deploy (the five outcomes).** On a project whose `gitvault_policy` is `required`, `/apply/v1` is gated on a vaulted capture. `POST /apply/v1/plans` accepts a `gitvault: { capture_id, snapshot_oid_hmac }` declaration — a keyed commitment, so the server never sees a commit id — journals it on the operation, and returns a `gitvault` rider plus a `gitvault_push` next_action carrying the exact `capture_binding` values to sign into the head. Push that head (`POST …/admissions`), exchange the returned `capture_receipt` for an activation token (`POST …/activation-tokens`), then commit with `gitvault: { activation_token_id }`; the token is consumed exactly once — inside the activation transaction, or, when the commit is a noop (the plan's digest already serving), at the noop commit's own settle before the operation goes `ready` (protocol rev 45) — so a presented token is never left unresolved. The audited escape hatch is `gitvault: { allow_unvaulted: true, override_reason }`, which requires `gitvault.override_unvaulted` (owner + step-up, or a project grant carrying that power explicitly — deploy authority alone is refused `403 OVERRIDE_NOT_AUTHORIZED`) and stamps a persistent advisory you clear at `POST …/override-completions`. Every commit response and operation snapshot on such a project carries a `gitvault` rider whose `outcome` is one of the protocol's **closed five** (`null` while the operation is still in flight): | `outcome` | What happened | |---|---| | `DEPLOYED_AND_VAULTED` | activated with a consumed activation token — the release is live and the source is captured (a byte-identical noop consumes its token too and reports this outcome) | | `DEPLOY_BLOCKED_PUSH_FAILED` | refused at the gate: no consumable token and no authorized override; nothing activated | | `DEPLOY_FAILED_VAULTED` | the push succeeded (the source IS captured) but the deploy failed afterwards | | `DEPLOY_FAILED_UNVAULTED` | failed under an override, so nothing was captured either | | `DEPLOYED_UNVAULTED_OVERRIDE` | activated through the audited override; the advisory persists until the capture is reported | A client that speaks no gitvault at all — no plan-time declaration AND no commit block — is refused `409 GITVAULT_CLIENT_UPGRADE_REQUIRED` at commit, with exactly two exits in `next_actions`: `upgrade_client` (`npm i -g run402@latest`) and `grandfather_policy` (`run402 repos policy grandfathered --reason `, `requires_approval: true`). The other gate refusals are `409 DEPLOY_BLOCKED_PUSH_FAILED` (no consumable token), `409 AUTHORIZATION_EPOCH_STALE` (a fresh epoch was installed — re-push for a new receipt and token), and `409 REPAIR_FENCE_PENDING`; each carries `details.outcome` from the same closed enum. **A vaultless project's apply responses are byte-identical to pre-gitvault ones** — the rider is absent, not null. **Multi-writer vaults (gitvault-multi-writer, protocol rev 47).** A vault's writer set is now CHAIN STATE — every ordinary push is signed by one of the vault's currently chain-admitted writers (`GET /gitvault/v1/vaults/:vault_id`'s `writer_set` above is the live projection), and a NEW transition, `add_writer_key`, rides an ordinary head to admit another one. Two authorization doors, both fence-time re-validated under lock (never trusted from an earlier, possibly-stale prepare-time check): `authorization.kind: "writer"` — the head's OWN signer must already be an active, non-gateway-blocked writer, and the added principal must be an active org member at role developer+ holding a possession-verified signing key (published via `POST /agent/v1/whoami/encryption-key`'s `signing_pubkey` above) not already burned as a writer on this vault; or `authorization.kind: "handoff"` — the head is signed BY THE ADDED KEY ITSELF, authorized by a `writer_admission_grant` an existing writer minted (at `POST /gitvault/v1/vaults/:vault_id/handoffs`, above) plus the claimant's own `writer_acceptance` (submitted at claim time, `POST /gitvault/v1/handoffs/:handoff_id/claim`, above) — both signatures independently verified, never one checked twice. An authenticated active member whose key is NOT (yet) a vault writer is refused `403 GITVAULT_WRITER_NOT_ADMITTED` on any push. `MAX_VAULT_WRITERS` (64, hard-coded) caps the set — `VALIDATION_FAILED` beyond it. Removing a writer rides the EXISTING epoch-rotation machinery as a new dimension: `rotate_epoch{writer_set_update}` (a NEW sibling of the recipient-rotation `reason`s `member_removed`/`recipient_key_revoked`/`epoch_secret_exposed` — the writer-removal-specific reason is `writer_key_revoked`) — the gateway computes the exact gateway-blocked-and-due-for-removal set and requires `removed[]` to match it exactly (`409 RECIPIENT_SET_MISMATCH` names the discrepancy), the rotation's own signer must survive the removal, and the resulting set must stay non-empty UNLESS the rotation explicitly declares the forced sole-writer-removal case (owner + step-up) — refused `409 EPOCH_ROTATION_WOULD_LEAVE_VAULT_UNCOVERED` with `details.resource: "writers"` otherwise. `409 EPOCH_ROTATION_REQUIRED` widens to `details.writer_removal_outstanding: true` and gates EVERY subsequent push — not just `add_writer_key` heads — until the overdue removal actually rotates. A vault whose writer set is force-emptied enters `read_only_terminal: true` (surfaced on the vault record, above): it keeps SERVING reads, but accepts no further pushes until a new writer is admitted through a recovery path — a protocol-chain-state condition, wholly separate from and unaffected by billing/lifecycle state (`/gitvault/v1/*` write routes are NEVER lifecycle-gated for grace/billing reasons; `read_only_terminal` is the one case where a vault legitimately stops accepting writes, and it is driven entirely by the writer chain, never by an organization's payment status). A membership revoke/demotion-below-developer or a signing-key revoke gateway-blocks that principal's writer keys on every vault of the org immediately (`gateway_blocked_at`, distinct from the on-chain `chain_status`, which only flips to `revoked` once the next rotation formally removes it) — the org's OTHER members keep pushing uninterrupted; only a subsequent push is gated, by the `writer_removal_outstanding` rule above. The rotation that clears it includes every SURVIVING writer on its current directory key alongside confirmed (pinned) recipients — the writer set is client-signed chain state, so no pin ceremony is needed for agent-built vaults — and the survivors complete it without any owner ceremony: `run402 org member rm` rotates inline on every vault of the org where the caller's key is a writer, and any surviving writer's plain `git push` rotates first (reason member_removed, fenced on the counters the envelope-recipients read now carries) and then lands; `run402 repos access revoke-key ` remains the owner's explicit path. Handoffs (kygit-handoff, above) now ALSO confer WRITE authority on claim — the blast-radius warning names it explicitly ("...becomes an owner of this org and a writer of this vault..."); minting one requires the minter to currently be an active writer (`403 HANDOFF_MINT_REQUIRES_WRITER`), and claiming one with a writer key that doesn't match an earlier successful claim by the same principal is `409 HANDOFF_CLAIM_WRITER_KEY_MISMATCH`. A bring-your-own-bucket vault (`storage_profile: "byo"`) is the one exception to all of the above: it admits exactly one writer in v1 (gitvault-byo-primary-bucket D11) — key envelopes are payload for a BYO vault, so they live only in the customer's bucket, and writer authority without bucket access is unusable. Both claim mints, every `add_writer_key` admission on the vault (either door), and a developer+ `org member add` on its organization are refused `409 GITVAULT_BYO_SINGLE_WRITER`, and the vault record reports `pending_writers: []` with `pending_writers_skipped: "byo_single_writer"`. `add_writer_key`/`writer_set_update` transitions feed `gitvault_writer_pending`/`_admitted`/`_removed` (the "Project events" section above) — coordination-class, feed-only notices; the vault record's `writer_set`/`pending_writers`/`read_only_terminal` fields remain the sole source of truth. **Errors.** gitvault refusals ride the canonical envelope with the protocol's closed registry code, each carrying the registry's own recovery prose in `next_actions[].why`: `CHAIN_BROKEN`, `CHAIN_UNUSABLE`, `HEAD_CAS_CONFLICT`, `GENERATION_REGRESSION`, `INVALID_CURSOR`, `MAINTENANCE_LEASE_HELD`, `MAINTENANCE_CYCLE_RESTART_REQUIRED`, `CLIENT_OPEN_ID_CONFLICT`, `CYCLE_REPLACEMENT_PENDING`, `STAGE_CANDIDATE_RESERVED`, `GC_EPOCH_STALE`, `ALLOCATION_SUPERSEDED`, `VAULT_CREATION_CONFLICT`, `REPAIR_FENCE_PENDING`, `REPAIR_NOT_AUTHORIZED`, `OVERRIDE_NOT_AUTHORIZED`, `AUTHORIZATION_EPOCH_STALE`, `GITVAULT_CLIENT_UPGRADE_REQUIRED`, `GITVAULT_ACCESS_DENIED`, `QUOTA_EXCEEDED`, `RATE_LIMITED`, `CUT_STORAGE_INTEGRITY_FAILURE`, `VAULT_UNRECOVERABLE`, `KEYSTORE_MISSING`, `GITVAULT_WRITER_NOT_ADMITTED`, `HANDOFF_MINT_REQUIRES_WRITER`, `HANDOFF_CLAIM_WRITER_KEY_MISMATCH`, … (the last three, gitvault-multi-writer rev 47, are documented in the "Multi-writer vaults" paragraph below). `409 GITVAULT_BYO_SINGLE_WRITER` is the platform's own BYO refusal (gitvault-byo-primary-bucket D11): a bring-your-own-bucket vault admits exactly one writer in v1 — both claim mints, every `add_writer_key` admission on it, and a developer+ `org member add` on its organization are refused under that ONE code. Four conditions the closed registry deliberately leaves to the platform ride the ordinary gateway codes instead: `400 VALIDATION_FAILED` (a malformed request), `404 RESOURCE_NOT_FOUND` (an unknown sub-resource of a vault you ARE authorized on), and `503 STORAGE_UNAVAILABLE` / `503 SERVICE_UNAVAILABLE` (the vault bucket or the control-plane service signing key is not provisioned — the subsystem stays inert rather than half-live). Read-side routes are never lifecycle-gated: a vault stays readable while its organization is in billing grace. **BYO primary bucket (`storage_profile: "byo"`, gitvault-byo-primary-bucket).** `POST /gitvault/v1/vaults` may declare `storage_profile: "byo"` + `byo_destination` at ALLOCATION ONLY — a customer-owned S3-compatible bucket/prefix, the ADDRESS ONLY, never credential material of any kind; the client resolves the credential (a profile name or ambient) locally at use time, and no request to run402 ever carries it. Absent or `"managed"` is the default and byte-identical to every vault allocated before this field existed; no route flips `storage_profile` on an existing vault in v1 — promote-mirror-to-primary for an existing vault is a tracked follow-up. On a BYO vault, payload ciphertext (WAL packs, checkpoint packs/manifests, ref state, retention roots, key envelopes — six kinds; the writer-signed checkpoint CLAIM SET is plaintext receipts the gateway must read to admit a checkpoint, so it stays gateway-held like every chain record and reaches your bucket through the chain copy) is written directly to the customer's own bucket by the client — the rung-3 claim, scoped to this profile only: your source ciphertext never touches our infrastructure — not even encrypted — and finalize submits a per-object attestation `{key, sha256, size_bytes, create_only_result}` instead of the gateway HEADing the object itself; the resulting receipts on those six payload kinds carry `storage_verification: "client_attested"`, versus `"gateway"` on every managed-vault receipt (byte-identical to today's implicit behavior). run402 still holds and authoritatively serves the signed chain — heads, admission records, rotation/prune/maintenance records — for a BYO vault exactly as for a managed one, so admission, rotation linearization, and deploy-lane activation gating are byte-identical regardless of profile; the client additionally dual-writes that chain into the customer bucket as a non-authoritative copy on every push (the shipped mirror engine's admission-order discipline, reused), so the bucket alone is a complete `r402s-recover` source. **Live reads:** with the gateway up, a BYO vault's chain (heads, admissions, receipts) is served by run402 exactly as for a managed vault, and every payload read is answered with the object's byo_key — POST …/object-reads entries and GET …/state carriers for the six payload kinds carry byo_key instead of a presign, the delta/restore_plan arms are omitted, and POST …/object-reads/bytes refuses 409 GITVAULT_BYO_PAYLOAD_NOT_HELD — so the client fetches payload from the customer bucket with the credential named in its local byo/.json (a second machine needs that file: destination + credential NAME, no secret material; without it, reads refuse GITVAULT_BYO_NOT_CONFIGURED by name). **Degraded reads:** on a network-class gateway failure (never a 4xx — an authz refusal never silently reroutes), chain and payload reads fall back to the vault's own BYO destination (or, for a managed vault, a configured mirror — same code path), so `git fetch`/`clone` keep working while run402 is down; the result is marked `degraded: true` with source provenance and proves validity, never freshness — local trust pins advance only as far as the copy chain-verifies. Writes still require the gateway in EVERY storage model (admission is irreducibly live-server), so "push needs us up" is unchanged for BYO vaults. **Billing:** BYO payload bytes never enter the org-pooled `source_bytes` accounting (receipts still record sizes for observability); the gateway-held chain bytes bill as ordinary storage, and abuse caps (object count/rate) stay platform-enforced regardless of profile. **Copy-count honesty:** run402 holds no payload copy of a BYO vault — only the small signed chain; your primary bucket is the sole copy of your source until you add a second customer-held location — so a single-bucket BYO vault has FEWER copies than a managed vault, not more. The standing `vault_unmirrored` finding (gitvault-mirror-default) continues to apply to BYO vaults exactly as to managed ones; its BYO remedy: add a second customer-held location: 'run402 repos mirror ' works unchanged against a different destination — for a BYO vault this is your only additional copy, since run402 holds no payload copy of its own. **Client status:** the gateway accepts and enforces `storage_profile: "byo"` today; the CLI surface (`run402 repos create --byo ` plus `--profile`/`--ambient`/`--region`/`--endpoint`, and `repos view`/`doctor` rendering of `storage_profile` + `byo_destination`) ships in the next `run402` npm release — see `/updates.txt` for the exact version once published. **BYO error codes.** `GITVAULT_BYO_ATTESTATION_MISMATCH` (`422`, gateway-raised — a platform-owned code alongside `VALIDATION_FAILED`/`RESOURCE_NOT_FOUND` above, NOT the protocol's closed registry): a BYO finalize's per-object attestation disagrees with the declared manifest — a missing or extra `key`, a `sha256`/`size_bytes` that does not equal the DECLARED manifest entry, or a `create_only_result` other than `"created"` (object ids are permanent tombstones, never reused, so an `"exists"` attestation is itself a mismatch) — refused before any receipt is minted. Three more codes are CLIENT-raised, against the customer's OWN bucket, which run402 has no credential to observe and therefore can neither detect nor report — they never appear in a run402 HTTP response: `GITVAULT_BYO_BUCKET_PROBE_FAILED` (the allocation-time create-only `if-none-match` + versioning-off probe found the destination will not honor the required write policy — the vault never half-exists), `GITVAULT_BYO_BUCKET_WRITE_REFUSED` (a push's payload write to the customer bucket failed — unlike a mirror, this is the PRIMARY, so the write failure fails the push), and `GITVAULT_BYO_OBJECT_MISSING` (a reader or `fsck` found a chain-referenced object absent from the customer bucket, adjudicated against run402's OWN signed chain — the honesty the client-attested model keeps: the platform can prove what SHOULD exist even though it cannot independently verify presence). **Member envelopes (gitvault-human-envelopes, widened to agents by gitvault-agent-envelopes).** Membership entails custody: every ACTIVE `human` OR `agent` org member is an envelope recipient on every vault the org owns (GitHub semantics — a member with read access can read the code; `ci` principals, grants and delegates never are). `POST /agent/v1/whoami/encryption-key` publishes the caller's own X25519 public key + `ek_` fingerprint (self-only; a human's key is PRF-derived in the browser, an agent's is the CLI keystore's own key under `custody_scheme: keystore_v1` — pending until its ECDH possession proof on `…/activate`; the private half never reaches the gateway). Enrollment is automatic: the SDK publishes + proves on a keystore's first gitvault operation and NEVER rotates — a differing local key fails `GITVAULT_KEY_ROTATION_REQUIRED` (remedies: keystore backup; an org owner revokes the stale key on `DELETE /orgs/v1/:org_id/members/:principal_id/encryption-key`; a sole-member principal may `replace_current`). **Cold open (D4):** a keystore with an identity but no repo file for a vault it is a covering recipient of restores `K_repo` from its OWN envelope — genesis signature-verified, creator fingerprints matched against the control plane's SIGNED allocation (`trust: platform_attested`, `continuity: first_seen|pinned`, `independently_verified: false` — consistency the platform vouches for, never end-to-end authentication; a creator-held recovery receipt is the stronger `receipt` tier), genesis hash pinned locally — so `git clone run402::/` works for a second agent with zero human steps. Not yet wrapped → `GITVAULT_ENVELOPE_PENDING` naming the key-holders and next actions, never a terminal error. **Fulfilment (D5):** a key-holding client wraps every pending desired recipient on its first ordinary gitvault operation in a process — `repos view/list/access` included — best-effort, reported as `reconcile_recipients`; `fsck`, `--no-write` and `recover` NEVER wrap (investigating a suspicious recipient must not complete the disclosure); `--no-reconcile` defers and says so. **Admission re-authorizes (consult P0):** a non-rotation `key_envelope` is admitted only for the vault creator's key or the CURRENT ACTIVE key of an ACTIVE desired recipient whose principal is an active custody-eligible member RIGHT NOW — checked at upload-session create and again inside finalize — else 403 `GITVAULT_ENVELOPE_RECIPIENT_NOT_ELIGIBLE`; the `gitvault_envelope_recipient_added` event is keyed on the resolved `(repo, principal_id, key_id, recipient-set version)` and ALSO lands as an org-level feed row so an all-agent org has a channel. Recovery posture: `source_backup` requires a SECOND distinct custodian with an admitted envelope; a registered-but-uncovered key is `custody_key_registered`, never a backup. `GET /orgs/v1/:org_id/encryption-keys` reads the org's directory (any active member). `GET /gitvault/v1/vaults/:vault_id/envelope-recipients` (above) reads which fingerprints currently have a working envelope on a vault, plus `desired[]` — the org's DESIRED-RECIPIENT STATE (repo-surface-consolidation task 2.1, design D5: "the server can never wrap a key — it owns the desired state, versioned and audited; fulfillment belongs to authorized key-holding clients"). Membership add/remove and key enroll/rotate write `internal.gitvault_desired_recipients` in the SAME transaction as the mutation they accompany, audited, versioned off a monotonic sequence (`desired_state_version`); a revoked member's row goes to `status: "pending_removal"` — named DISTINCTLY from a still-active member missing coverage, because it needs the opposite remedy. The operator overview's per-project `gitvault` block gains a `coverage` summary — `covered` / `pending` (names the missing members, and separately the `pending_removal` set) / `unrecoverable` (no currently-live principal holds a working envelope; deployed source still restores from plaintext CAS, git history does not) — computed from the live membership/key join (unchanged, so a pre-existing org's advisory never regresses) plus the desired-recipient substrate for the `pending_removal` half. **The directory, coverage, desired-recipient-state reads, and epoch rotation are now live end to end — both gateway and client (protocol rev 42, D193-D203, three rounds of adversarial review, shipped in client 4.45).** `run402 repos access repair` is the client producer: it samples fresh key material, computes the H-partition, wraps and uploads `key_envelope`s for every confirmed recipient, and submits the signed `rotate_epoch_payload`. `run402 repos access revoke-key ` declares a recipient's key revoked and drives the same rotation off it; `run402 repos access declare-exposure` runs the identical ceremony for a key believed compromised. The six `gitvault.rotate` routes above are enforced end-to-end server-side: an urgent rotation (`member_removed` / `recipient_key_revoked` / `epoch_secret_exposed`) is REQUIRED before any further ordinary push once the org's revocation watermark is undischarged (`409 EPOCH_ROTATION_REQUIRED`), and every pre-rev-42 vault carries `migration_rotation_required: true` unconditionally. Revoking a `pending_removal` principal's future access is enforceable the moment `run402 repos access repair` runs. Two honest limits stay true regardless: removal from the org denies the platform's reads immediately, but the cryptographic guarantee is forward-only — ciphertext a recipient already fetched decrypts with the key they already held, and convergence stays client-driven, so `run402 repos access` still separates *covered* from *pending*. The `gitvault_envelope_recipient_added` mandatory security-class event type fires today on the add side. **Rev 43 (D204-D208) additionally closes five gaps found by adversarial review of the rev-42 mechanism:** a pre-existing (pre-rev-42) vault with no predecessor pin manifest may now take its FIRST rotation as a bootstrap that reads its own co-riding manifest for the H-partition check (D204); a fleet re-arm sweep re-evaluates every vault's `migration_rotation_required` flag against real committed evidence rather than trusting an unproven manual clear (D204a, operator-driven, not on any automatic schedule yet); `recipient_pin_manifest` is now version-AND-content-addressed, closing a possible ledger-identity collision between two distinct attempts at the same version (D205); `/confirm` and `/repin` are idempotent — a repeated call for the identical tuple returns the SAME receipt rather than minting a new one every time (D206); and a new database-function chokepoint plus a mechanical CI gate ensure nothing outside one canonical file can write the desired-recipient table or the migration flag directly (D207/D208). Two new error codes: `DESIRED_RECIPIENTS_NOT_RECONCILED` (409, non-retryable — the organization has not completed D208's exact desired-recipient reconciliation; an operator-scoped prerequisite) and `PIN_MANIFEST_STORAGE_INTEGRITY_FAILURE` (409, non-retryable — the addressed recipient_pin_manifest object is missing or does not hash to the expected value). **Client 4.45 (above) predates rev 43** and does not declare `migration_bootstrap` on its rotation payloads — its rotation on a vault that needs the bootstrap branch (D204) is refused `RECIPIENT_SET_MISMATCH` until the client upgrades; a known, accepted gap, not a regression to chase. **Rev 44 (D209/D210, the epoch-rotation ADMISSION CANARY)** closes a reader-path gap: a rotated vault must be provably readable, not just provably writable, so every `rotate_epoch` admission now REQUIRES `rotate_epoch_payload.self_open_attestation` — the rotating client's claim that it round-tripped its OWN new-epoch envelope through the exported reader entry point (`openEpochRotationForRecipient`), carrying fsck's own `chain_verified_to_generation` / `decryptable_to_generation` split verbatim — refused `409 EPOCH_ROTATION_SELF_OPEN_UNPROVEN` (retryable) when absent or inconsistent with the gateway's own facts (the writer-in-envelopes biconditional, the fingerprint/generation equalities). The gateway verifies that structural consistency ONLY; the decryption claim itself is honest-client evidence. A rotation whose writer IS an included recipient self-mints the writer's `recipient_open_receipt` at commit; every OTHER recipient proves its own openability post-hoc via the new proof-of-open route (above), and the operator overview's per-project `gitvault.coverage` block gains an `openability` dimension — which envelope-covered members have proven they can OPEN their envelope under the current epoch, distinct from the envelope merely existing. **No published client emits the attestation yet (client 4.48 predates rev 44), so every rotation is refused `EPOCH_ROTATION_SELF_OPEN_UNPROVEN` until a rev-44-aware client ships — deliberate, and consistent with the standing no-rotations line while the round-5 findings are repaired; the feed of truth is the error's own next_action.** **Source-access wrapper custody (gitvault-recovery-custody, consult-round-1 hardened).** A member's vault encryption key is a randomly generated X25519 keypair whose private half exists only as encrypted WRAPPERS in a platform directory: one `webauthn_prf` wrapper per enrolled PRF-capable passkey (KEK derived from that credential's WebAuthn PRF output) plus at most one `recovery_code` wrapper (KEK derived from a generated-once, shown-once **source recovery code** — `SRC1-`-prefixed, check-character-carrying, decrypt-only; it never authenticates, never satisfies step-up, and is visibly disjoint from control-plane recovery codes). Every wrapper is a self-describing `swrap2` object (per-wrapper KDF salt; a canonical context binding principal, key id, wrapper id, kind, credential, FULL public-key hash, and RP id as both HKDF info and AAD) and is born **pending**: it counts for nothing until the client reads the STORED bytes back, unwraps them, full-public-key-compares, and answers an ECDH possession challenge only a holder of the member key can answer (`POST …/wrappers/:wrapper_id/activate`) — so a crash mid-enrollment can never strand an active key without a wrapper, and a stolen session can create garbage but never occupy custody. A `webauthn_prf` wrapper's credential id is SERVER-derived from a verified `gitvault.wrapper.add` step-up assertion, never client-supplied — and no server-bound assertion body ever carries PRF output (the projection sends verification fields only; a body with extension results is rejected `PRF_EGRESS_REJECTED`). Any single ACTIVE wrapper recovers the identical key: adding a device is adding a wrapper, never a rotation — the published public key and `ek_` fingerprint are stable across wrapper add/remove, so no vault is ever re-wrapped for it. The platform stores ciphertext it cannot open (host-blindness unchanged, under the stated assumption of authentic client code); blobs are returned only to their owning principal. Custody is EXPLICIT (`custody_scheme`: `legacy_direct_prf_v1` | `wrapped_legacy_scalar_v1` | `wrapped_random_v1`): readers select the key path from the scheme, never wrapper count, and wrapped custody never silently falls back to legacy derivation. Members enrolled under the prior direct-PRF derivation migrate by wrapping their CURRENT key (one assertion, fingerprint unchanged; activation flips the scheme) — with the honest disclosure that migration ADDS recovery methods and does not revoke the original credential's derivation (only key rotation detaches it). Enrollment always ends in a NAMED outcome — enrolled via PRF passkey / enrolled via recovery code / steered (a password-manager extension that intercepts `navigator.credentials` and silently drops the PRF extension is detected and named, with the phone/cross-device ceremony and the recovery code offered as the working alternatives) / declined. `GET /agent/v1/source-access/recovery-bundle` exports the member recovery bundle for the member's OWN storage — a server-side wrapper row is NOT offline backup; server-free recovery needs the bundle + the vault mirror + its recovery-receipt pin + the code. The org's **recovery posture** — evidence levels `control_plane_configured` (≥1 active human owner with a working login) and `source_wrapper_configured`/`source_backup_configured` (verified custody), plus `custody_legacy_present` — is computed on read (`GET /agent/v1/operator/status` `recovery_posture[]`, the org directory read) and advised on transition via the generation-keyed `org_recovery_posture_*` feed events above. r402s/v0 wire objects are untouched: wrappers are platform-directory substrate + client convention only. ## Unified apply — `/apply/v1` The canonical write primitive (v1.48+). One `ApplySpec` lands the entire app — migrations + RLS manifest + secrets + functions + site files + CDN assets + web routes + subdomains — atomically. The spec type is `ApplySpec` (which extends `ReleaseSpec` with an optional `assets` slice). Bytes flow through content-addressed storage (CAS) so unchanged files dedupe across writes. ### State machine ``` plan POST /apply/v1/plans upload PUT presigned S3 URLs (per missing content sha; bytes never go through the gateway body) commit POST /apply/v1/plans/:plan_id/commit → validate (spec well-formed; references resolve; CAS bytes present for every SHA) → snapshotting (migration-bearing plans create a durable pre-migration restore point before DDL) → stage (function versions, site deployment, secret set, subdomain reservation) → migrate-gate (only if migrations present; /rest/v1/* returns 503 + Retry-After for ~60s) → migrate (advisory-locked transaction; new id = run, same id+checksum = noop, mismatch = hard error) → schema-settle (canary SELECT until PostgREST reloads; up to 12×500ms) → activate (single transaction: flip release pointers, clear gate) → ready poll GET /apply/v1/operations/:operation_id (until status terminal: ready | failed | rolled_back) A ready snapshot carries the same `urls` (site, deployment, console) and `next_actions` (poll, watch_errors, hand_to_operator) as a synchronous ready commit — a polled deploy is never a weaker contract than the response that created it. events GET /apply/v1/operations/:operation_id/events (phase event stream, polling target) verify GET /apply/v1/operations/:operation_id/edge-coherence (probe public edge freshness) list GET /apply/v1/operations (pageable operations, newest first) resume POST /apply/v1/operations/:operation_id/resume (if schema_settling or activation_pending) ``` ### Routes | Path | Method | Auth | Cost | Description | |------|--------|------|------|-------------| | `/apply/v1/plans` | POST | SIWX + lifecycleGate | Free with tier | Plan a release. Returns the v2 plan envelope: `kind: "plan_response"`, nullable `plan_id`/`operation_id`, gateway-computed `manifest_digest`, `missing_content[]`, top-level resource diff buckets, `summary`, `warnings[]`, `expected_events[]`, optional `payment_required`. Plan body limit 5 MB; bytes always go direct-to-S3. Naturally idempotent on `(project_id, manifest_digest)` for ordinary apply. Body `mode: "reviewed_plan"` creates a short-lived, require-able reviewed plan with `plan_fingerprint` and `plan_expires_at` without upload/commit. Body `required_plan` verifies a reviewed plan before upload. Legacy `?dry_run=true` computes the envelope without creating plan/operation rows; it is not require-able and requires an inline spec. On a project with a `gitvault_policy` the body also accepts `gitvault: { capture_id, snapshot_oid_hmac }` and the response carries a `gitvault` rider — plus a `gitvault_push` next_action when the policy is `required`. See the gitvault section. | | `/apply/v1/plans/:plan_id/rehearse` | POST | SIWX/control-plane/delegate + lifecycleGate | Free with tier | Rehearse a persisted migration-bearing plan on a contained branch. Body `{ teardown?: "keep" \| "on_pass" \| "always" }` (default `keep`). Returns `202 { operation_id, status, poll_url, report }`. CI sessions are rejected in v1 (`REHEARSAL_CI_UNSUPPORTED`). Source project and source plan stay untouched. | | `/apply/v1/plans/:plan_id/commit` | POST | SIWX + lifecycleGate | Free with tier | Drive the state machine. Idempotency-Key honored. Optional body `required_plan` repeats reviewed-plan verification before release mutation. Returns `{ operation_id, release_id, status, urls?, edge?, error?, subdomain_bindings, restore_point?, snapshot_skipped_reason? }`; each binding is `{ host, claimed_at, kvs_synced_at }` and `kvs_synced_at: null` means the edge-store source write is not confirmed yet. `restore_point` appears when pre-migration snapshotting succeeded; `snapshot_skipped_reason` appears when an explicit skip or size downgrade allowed commit to continue without a restore point. On a `gitvault_policy: required` project the body's `gitvault` block is the push gate — `{ activation_token_id }`, or the audited `{ allow_unvaulted, override_reason }` — and the response carries a `gitvault` rider whose `outcome` is one of the closed five; a client that speaks no gitvault at all is refused `409 GITVAULT_CLIENT_UPGRADE_REQUIRED`. See the gitvault section. | | `/apply/v1/app-installs` | POST | SIWX/CI/delegate + `project.deploy` | Free with tier | Upsert declarative app-install convergence state for `run402 up`. Body `{ project_id, app_key, status, manifest_digest?, graph_digest?, source?, manifest?, resources?, bindings?, last_operation_id?, error? }`. Stores state only; do not put secret values in `manifest`, `resources`, or `bindings`. Unique per `(project_id, app_key)`. | | `/apply/v1/app-installs` | GET | SIWX/CI/delegate + project read/deploy authority | Free | Read app-install convergence state. Query `{ project_id, app_key }`. Returns 404 `APP_INSTALL_STATE_NOT_FOUND` when the app has never been recorded for that project. | | `/apply/v1/operations` | GET | apikey | Free | Operations for the project, newest first. Query: `limit` (default 50, max 100), `before` (operation id cursor), `status` (single or comma-separated), `since` (ISO timestamp), `project_id` (must match authenticated project), `include_total=true` (opt-in exact count). Response includes `operations`, `has_more`, `next_cursor`, and `total` only when requested. | | `/apply/v1/operations/:operation_id` | GET | apikey | Free | Snapshot the operation: `status`, `payment_required`, structured `error`, `activate_attempts`, `target_release_id`, `edge`, and `subdomain_bindings: [{ host, claimed_at, kvs_synced_at }]` for managed subdomains serving the project. | | `/apply/v1/operations/:operation_id/events` | GET | apikey | Free | Durable server-side phase event stream. Query: `limit` (default 100, max 500), `cursor` (event id cursor). Polling target until terminal. | | `/apply/v1/operations/:operation_id/edge-coherence` | GET | apikey | Free | Probe bound public hosts for mutable paths changed by an apply or promote operation. Promote compares its previous-live release with the promoted target. Returns `{coherent, paths[], pending_count, pointer_updates, next_actions}`. Cross-project operation access returns 403. | | `/apply/v1/operations/:operation_id/resume` | POST | SIWX | Free | Re-run the failed phase forward when in `activation_pending` or `schema_settling`. **NOT lifecycle-gated** — completes already-authorized work. Migrations are NEVER replayed. | | `/_run402/release.json` | GET | none | Free | Tenant-host release identity endpoint served before static resolution. Returns `{project_id, release_id, release_generation, activated_at}` with `Cache-Control: no-store`. | | `/_run402/config.js` | GET | none | Free | Tenant-host runtime config as a script: `window.RUN402 = {project_id, api_base, anon_key};` for the project the host resolves to at request time (branch hosts and transferred projects included). `Cache-Control: public, max-age=60`. Never carries `service_key`. Load it with `` instead of pasting a key into HTML. | | `/_run402/config.json` | GET | none | Free | The same object as JSON. | | `/apply/v1/releases/:release_id/promote` | POST | SIWX or CI session (`deploy`) + lifecycleGate | Free | Pointer-swap recovery: atomically point `live_release_id` and project-owned subdomains at a prior release's deployment without re-running apply. Body `{ project_id, allow_warning_codes? }`. Refuses non-promotable/no-op targets and returns `PROMOTE_DEPLOYMENT_UNSERVABLE` when the target snapshot or deployment row is gone. Returns `{ status, release_id, operation_id, previous_release_id, diff, warnings, edge }`; durable pointer updates are attempted immediately and retried by the worker. Logs `operation_kind: "promote"`. | | `/apply/v1/releases/:release_id` | GET | apikey | Free | Get the activation-time materialized state of a specific release. Response: `kind: "release_inventory"`, `state_kind: "effective"` (active/superseded) or `"desired_manifest"` (failed/staged). Capability `agent-deploy-observability`. | | `/apply/v1/releases/active` | GET | apikey | Free | Get the active release's CURRENT LIVE state — reads live tables. A `setSecret` call between activation and now appears here. Response's `state_kind: "current_live"`. Distinct from `/releases/:release_id` which is the activation-time snapshot. 404 `NO_ACTIVE_RELEASE` if no active release. | | `/apply/v1/releases/diff` | GET | apikey | Free | Diff two releases. Query params: `from=&to=`. Cross-project → 404 `RESOURCE_NOT_FOUND`. `from === to` → 400 `DIFF_SAME_RELEASE`. `to=empty` → 400 `INVALID_DIFF_TARGET`. Migrations are MONOTONIC (`applied_between_releases: string[]`), distinct from the plan response's `{new, noop}` shape. | | `/apply/v1/resolve` | GET | apikey | Free | Diagnose stable host/path resolution for a project-owned host. Query params: `host=`, optional `path` (default `/`) and `method` (default `GET`). Returns binding status, active release id/generation, route/static manifest SHAs, static manifest metadata, normalized path, match kind, static SHA, cache class/policy, authorization result, fallback state, legacy immutable-risk diagnostics, and `edge_propagation` when the resolved host is served through an eventually consistent edge store. | ### Plan response, reviewed plan, and local checks `POST /apply/v1/plans` returns the agent-deploy-observability plan envelope: ```json { "kind": "plan_response", "schema_version": "agent-deploy-observability.v1", "plan_id": "plan_...", "operation_id": "op_...", "base_release_id": "rel_...", "manifest_digest": "", "is_noop": false, "summary": "Adds one site path", "warnings": [], "expected_events": ["stage.start", "activate.start", "ready"], "missing_content": [{ "sha256": "", "size": 123, "present": false }], "migrations": { "new": [], "noop": [] }, "site": { "added": [], "removed": [], "changed": [] }, "functions": { "added": [], "removed": [], "changed": [] }, "secrets": { "added": [], "removed": [] }, "subdomains": { "added": [], "removed": [] }, "routes": { "added": [], "removed": [], "changed": [] }, "static_assets": { "unchanged": 0, "changed": 0, "added": 1, "removed": 0, "newly_uploaded_cas_bytes": 123, "reused_cas_bytes": 0, "deployment_copy_bytes_eliminated": 0, "legacy_immutable_warnings": [], "previous_immutable_failures": [], "cas_authorization_failures": [] }, "rehearsal": { "available": true, "rehearse_url": "/apply/v1/plans/plan_.../rehearse" } } ``` `GET /apply/v1/operations/:operation_id/events` returns ordered phase rows instead of only the current operation status. Each event has `{id,type,phase,status,message,details,created_at,updated_at}`; `details.duration_ms` is present on completed/failed/deferred phases when the phase start was observed. Typical top-level phases are `commit`, `stage`, `gating`, `migrate`, `expose`, `schema_settle`, `role_gate`, `activate`, and `ready`. Activation also emits `type:"commit.phase.detail"` sub-phases such as `activate.asset_copy_wait`, `activate.functions`, `activate.snapshot`, `activate.asset_variants`, `activate.transaction`, and `activate.side_effects`. Status is one of `started`, `done`, `failed`, `skipped`, or `deferred`. Old operations created before durable event recording return one synthetic event with `details.synthetic:true`. Commit responses and `GET /apply/v1/operations/:operation_id` include an `edge` block. `edge.state: "converging"` means the release is active in the control plane but public mutable edge surfaces may still be propagating; `not_applicable` means the operation did not change a public mutable surface; `unknown` means the gateway could not compute the diagnostic block. The block includes `pointer_updates` for edge-pointer propagation and `verify_url` when a verification probe is meaningful. Two targets are propagated per operation: `kvs` (the CloudFront KeyValueStore entry that routes managed-subdomain assets) and `cloudflare_kv` (the custom-domain snapshot). A third target, `cloudfront_invalidation`, is still part of the `EdgePointerTarget` enum but is no longer enqueued — a distribution-wide CloudFront purge buys nothing on a redeploy, because asset cache keys already carry the deployment id and tenant HTML is shared-cached for at most 60 seconds. The server never reports `edge.state: "coherent"` unless a verifier path has actually observed coherence. **`does_not_prove` — what a green receipt did NOT establish.** A `ready` commit, a successful promote, AND a re-read of the operation (`GET /apply/v1/operations/:operation_id`) all carry `does_not_prove[]`. The re-read matters: an agent whose commit response was dropped recovers by GETting the operation, and must not be able to infer MORE from the recovery than the original receipt claimed. All three paths compute it from one function, so they cannot diverge. It carries: stable tokens naming the claims activation does not support. A human reads "deployed" and infers the gap between accepted, live, and globally observable; that inference is exactly what an agent should not have to make. The tokens: | token | meaning | |---|---| | `edge_locations_converged` | The pointer flipped, but no verifier has observed an edge serving the new release. Emitted whenever `edge.state` is anything other than `coherent` or `not_applicable` — **including `unknown`**, because an edge we failed to observe is not an edge we verified. `edge.verify_url` is the probe that settles it. | | `deployed_functions_execute_successfully` | Functions in this release were deployed, never invoked. The commit path's only runtime check is the PostgREST schema-settle canary, which proves the schema reloads — not that any handler runs. Emitted on every release. | | `source_capture_activated` | A source-capture policy is `required` and activation has not completed, so the release is live but its provenance chain is not closed. | Every entry is computed from real state, so an **empty array is a strong claim**, not a missing field. Ambiguity always resolves toward "not proven" — claiming proof we do not have is the failure this field exists to prevent. Use `GET /apply/v1/operations/:operation_id/edge-coherence` to verify public-edge freshness after a static deploy or promote. The gateway computes a bounded probe set from the base→target static manifest diff (for promote, previous-live→target), excludes fingerprinted immutable assets, always includes changed HTML, caps paths with `paths_truncated` + counts, then probes the project's bound subdomain/custom-domain hosts. Per path it compares identity headers first (`x-run402-release-id`, `x-run402-release-generation`, and S3 metadata equivalents), then body SHA-256 for responses up to 1 MiB, then weak size/content-type. Response fields include `coherent`, `paths[]` with `state: coherent | stale_prior_release | unknown | error`, `observed_confidence: identity | body_hash | weak | error`, `pending_count`, `pointer_updates`, `vantage`, `probe_may_have_warmed_cache`, and `next_actions`. If no mutable paths changed, the response short-circuits with `probe_basis: "no_mutable_paths"`. After `run402 deploy promote ...`, use `run402 deploy verify --operation --wait` (or the returned `edge.verify_url`) before treating the promoted release as globally observable. Tenant hosts expose `GET /_run402/release.json` before static resolution. It returns `{project_id, release_id, release_generation, activated_at}` with `Cache-Control: no-store`. Static paths under `/_run402/` are reserved and rejected by apply validation. Stable static responses also stamp `x-run402-release-id` and `x-run402-release-generation`; Worker custom-domain responses stamp the same headers when their KV snapshot includes a generation. Reviewed plans are the gateway-authoritative approval loop used by `run402 up --plan` and `run402 deploy apply --plan`. Create one by sending the normalized `ReleaseSpec` inline with `mode: "reviewed_plan"`: ```json { "mode": "reviewed_plan", "spec": { "project": "prj_...", "site": { "patch": { "put": { "index.html": { "sha256": "", "size": 123 } } } } } } ``` The response includes the same diff/warnings envelope plus: ```json { "plan_id": "plan_...", "operation_id": null, "plan_fingerprint": "run402-reviewed-plan-v1:", "plan_expires_at": "2026-06-30T13:00:00.000Z", "planner_semantics_version": "run402.release_planner.v1", "base_identity": "release:rel_...", "next_actions": [ { "type": "retry", "command": "run402 deploy apply --require-plan plan_... --plan-fingerprint run402-reviewed-plan-v1:", "argv": ["run402", "deploy", "apply", "--require-plan", "plan_...", "--plan-fingerprint", "run402-reviewed-plan-v1:"], "why": "Apply exactly this reviewed plan before it expires." } ] } ``` Direct HTTP exact apply uses `required_plan` twice. First, send the normalized spec plus the reviewed plan identity to `POST /apply/v1/plans`; this verifies the reviewed plan before any content upload and returns the missing-content list for that exact plan: ```json { "spec": { "project": "prj_...", "site": { "patch": { "put": {} } } }, "required_plan": { "plan_id": "plan_...", "plan_fingerprint": "run402-reviewed-plan-v1:" } } ``` After uploading missing CAS content, repeat the same precondition at commit: ```http POST /apply/v1/plans/:plan_id/commit Content-Type: application/json { "required_plan": { "plan_id": "plan_...", "plan_fingerprint": "run402-reviewed-plan-v1:" } } ``` Reviewed-plan verification fails before upload or release mutation when the plan is expired, belongs to another project, was already committed, the normalized spec digest changed, the reviewed fingerprint differs, planner semantics changed, or the concrete base release drifted. Stable codes include `REVIEWED_PLAN_NOT_FOUND`, `REQUIRED_PLAN_NOT_REVIEWED`, `REVIEWED_PLAN_WRONG_PROJECT`, `REVIEWED_PLAN_ALREADY_COMMITTED`, `REVIEWED_PLAN_EXPIRED`, `REVIEWED_PLAN_SPEC_MISMATCH`, `REVIEWED_PLAN_FINGERPRINT_MISSING`, `REVIEWED_PLAN_FINGERPRINT_MISMATCH`, `PLAN_SEMANTICS_VERSION_UNSUPPORTED`, and `REVIEWED_PLAN_BASE_DRIFT`. Error envelopes include `fix.next_actions[]`; CLI/SDK callers should prefer the same-surface `--plan` action returned by their surface. `plan_fingerprint` is a versioned digest over the reviewed intent: normalized ReleaseSpec digest, manifest digest, concrete base identity, planner semantics version, confirmation-required warning set, destructive asset-sync facts, and materialized diff identity. It intentionally excludes `plan_id`, expiration, localized text, display summaries, and other non-binding samples. CLI/SDK local checks never call this route. `run402 up --manifest run402.deploy.ts --check` and `--print-spec` import explicit executable configs locally, normalize helpers, validate local file references, and stop before gateway calls, uploads, project creation, tier changes, or workspace-link writes. Executable TypeScript/JavaScript config files are trusted local code; v1 auto-discovers only JSON data manifests (`run402.deploy.json`, then `app.json`). If only `run402.deploy.ts` exists, the SDK/CLI returns `EXECUTABLE_CONFIG_REQUIRES_EXPLICIT_MANIFEST` with a recovery action to rerun with `--manifest run402.deploy.ts --check`. With `POST /apply/v1/plans?dry_run=true`, the gateway validates, resolves the base, checks missing content, computes the same diff/warnings/events envelope, and returns `plan_id: null` and `operation_id: null`. No `internal.apply_plans`, `internal.apply_operations`, or idempotency-key rows are written. Dry-run requires the full spec inline; `manifest_ref` returns HTTP 400 `DRY_RUN_REQUIRES_INLINE_SPEC`. Static site releases materialize a canonical `run402.static_manifest.v1` alongside the route manifest. The `static_assets` diff summarizes how many static paths are unchanged/changed/added/removed, how many CAS bytes are newly required vs reused, legacy immutable-cache risks, previous immutable same-path hard failures, and any CAS authorization failures. Activation verifies the static manifest and every referenced CAS object before flipping the active release pointer. ### Rehearsal: automatic in `up`, a primitive over HTTP `run402 up` (and the SDK `up` action / MCP `deploy`) rehearses for you: a migration-bearing plan against a project with a live release is rehearsed on a contained branch and committed only on a passing report; a project with no live release has nothing to protect, so `up` commits directly and reports `rehearsal: { status: "skipped", reason: "no_live_release" }`. `--no-rehearse` skips it anywhere. There is no `--rehearse` flag — the gateway decides, in the plan response: ```json "rehearsal": { "available": true, "rehearse_url": "/apply/v1/plans/plan_.../rehearse", "reason": null } "rehearsal": { "available": false, "rehearse_url": null, "reason": "no_migrations" } "rehearsal": { "available": false, "rehearse_url": null, "reason": "no_live_release", "next_actions": [{ "type": "commit_plan", "command": "POST /apply/v1/plans/plan_.../commit", "why": "This is the project's first deploy: there is no live release to branch from, so commit directly." }] } ``` Over HTTP, `POST /apply/v1/plans` advertises the envelope above and `POST /apply/v1/plans/:plan_id/rehearse` is the primitive. A rehearse call against a project with no live release still works — it runs on an **empty branch** (see Project branches) — the envelope simply does not advertise it. The plan's bytes must already be uploaded: a rehearse against a plan whose `missing_content` is still unmet (a plan-only plan, or a rehearse issued before the upload step) is refused `409 REHEARSAL_CONTENT_MISSING` with `details.missing_content` and an `upload_content` next action, before any snapshot or branch is spent. `POST /apply/v1/plans/:plan_id/rehearse` `POST /apply/v1/plans/:plan_id/rehearse` first upgrades the plan to `reviewed_plan` mode and recomputes its fingerprint. If an already-reviewed plan no longer matches current gateway facts, it returns `409 REHEARSAL_PLAN_STALE` with `current_plan_fingerprint` and re-plan `next_actions` before spending a snapshot or branch. Otherwise it creates a contained branch, applies the candidate, runs built-in checks (static 200s, function invocability, migration registry) plus optional `checks` declared in the spec, and returns: ```json { "operation_id": "op_...", "status": "ready", "poll_url": "/apply/v1/operations/op_...", "report": { "kind": "rehearsal_report", "status": "passed", "source_project_id": "prj_...", "plan_id": "plan_...", "plan_fingerprint": "run402-reviewed-plan-v1:0123456789abcdef...", "base_release_id": "rel_...", "data_as_of": "2026-07-04T12:00:00.000Z", "branch_project_id": "prj_branch_...", "branch_url": "https://parent--br-abcd.run402.com", "snapshot_id": "snap_...", "migrations": [{ "id": "001_init", "status": "passed" }], "checks": [{ "name": "static:/", "type": "static", "status": "passed", "duration_ms": 42 }], "fidelity": { "secrets": "absent", "parent_secret_keys": ["DATABASE_URL"], "email_mode": "sandbox", "scheduled_functions": "paused" }, "teardown": { "policy": "on_pass", "action": "deleted" }, "next_actions": [{ "type": "commit_plan", "method": "POST", "path": "/apply/v1/plans/plan_.../commit", "body": { "required_plan": { "plan_id": "plan_...", "plan_fingerprint": "run402-reviewed-plan-v1:0123456789abcdef..." } } }] } } ``` Always execute the report's bound `commit_plan` call. Unbound commits of rehearsed plans are rejected, and the bound commit re-derives current facts immediately before mutation. Declarable drift returns `409 PLAN_APPROVAL_MISMATCH` with `mismatch_reason: "REVIEWED_FACTS_CHANGED"`. `report.status: "failed"` is the normal way to inspect a broken candidate; use `teardown:"keep"` to preserve the branch. CI sessions cannot rehearse in v1; run the rehearsal with wallet, control-plane session, or a delegate that covers the project. Reviewed rows expire after one hour. Re-plan the identical spec: if the new `plan_fingerprint` equals the report fingerprint, facts are unchanged and the new bound plan may commit without another rehearsal. If it differs, re-rehearse. ### Project snapshots and restore Snapshots are internal restore points, not portable archives. They capture project data, migration registry rows, auth users/passkey public credentials (sessions/tokens/secrets excluded), and the live release id. Snapshot artifacts are CAS-backed and rejected by archive download paths. | Path | Method | Auth | Description | |------|--------|------|-------------| | `/projects/v1/:project_id/snapshots` | POST | wallet/control-plane + `project.snapshots.manage` | Create a manual snapshot. Manual cap: 20 per project; scheduled/pre-migration/pre-restore retention is automatic. | | `/projects/v1/:project_id/snapshots` | GET | same | List snapshots. Query: `limit` (default 20, max 100), `after` cursor, optional `kind=manual|pre_migration|pre_restore|scheduled`. | | `/projects/v1/:project_id/snapshots/:snapshot_id` | GET | same | Read one snapshot. | | `/projects/v1/:project_id/snapshots/:snapshot_id` | DELETE | same | Delete a snapshot and release CAS refs; in-use restore snapshots return `SNAPSHOT_IN_USE`. | | `/projects/v1/:project_id/snapshots/:snapshot_id/restore` | POST | same | Plan or confirm restore. Omit `confirm` to receive `restore_plan`; send `confirm` to materialize and flip. Optional `include:["auth"]` replaces auth identities; sessions/tokens are never restored. | Restore is deliberately two-step: ```json { "restore_plan": { "snapshot_id": "snap_...", "snapshot_at": "2026-07-04T12:00:00.000Z", "data_loss_statement": "All writes after 2026-07-04T12:00:00.000Z will be lost if this restore is confirmed.", "auth": { "mode": "not_restored", "users": 12, "passkeys": 8, "message": "..." }, "release": { "snapshot_live_release_id": "rel_old", "current_live_release_id": "rel_new" }, "target": { "current_schema_slot": "p0042", "behavior": "offline_materialize_then_atomic_flip" }, "confirm": { "token": "hmac...", "expires_at": "2026-07-04T12:15:00.000Z" }, "next_actions": [{ "type": "confirm_restore" }] } } ``` A confirmed restore creates a `pre_restore` undo snapshot, materializes into a spare slot, atomically swaps `schema_slot`, replaces applied migration registry rows with the snapshot set, and expires every open uncommitted apply plan for the project in the same flip transaction. The response includes `invalidated_plan_count` and a narrative telling callers to re-plan; the restore operation records a `restore.plans_invalidated` event even when the count is zero. A pre-restore plan can never commit post-restore. Auth identities may optionally be replaced, caches are refreshed, and `next_actions` cover undo and release promotion when relevant. Stale confirm tokens return 409 with fresh planning guidance. ### Project branches Branches are expiring contained project copies for rehearsal and collaboration. They derive a noindex host like `--br-.run402.com`, sandbox email by default, keep scheduled functions off unless requested, and set `RUN402_BRANCH=1` plus `x-run402-branch` for function runtimes. A parent with no live release still branches: the result is an **empty branch** — schema materialized from the parent's current (possibly empty) slot, identities copied, no cloned release, no cloned functions, `branch_url: null` and `release: { parent_release_id: null, branch_release_id: null }` — whose reserved `--br-` host comes up on the branch's first activation. Branch creation never fails for lack of a release. | Path | Method | Auth | Description | |------|--------|------|-------------| | `/projects/v1/:project_id/branches` | POST | wallet/control-plane + `project.branches.manage` | Create a branch from live data or `from_snapshot_id`. Body: `{ name?, from_snapshot_id?, ttl_days?, email_mode?: "sandbox"|"off", enable_cron?: boolean }`. Default TTL is 7 days; active cap is 3. | | `/projects/v1/:project_id/branches` | GET | same | List active, unexpired branches. | | `/projects/v1/:project_id/branches/:branch_project_id/renew` | POST | same | Extend expiry. Body `{ ttl_days? }`. | | `/projects/v1/:project_id/branches/:branch_project_id` | DELETE | same | Delete a branch through the standard project cascade. | Transfers, publishing, portable archive export, and handoff-style owner changes against a branch return stable branch rejection codes with parent-project next actions. ### Static public paths By default, apply-v1 uses implicit public paths for backwards compatibility: a release static asset such as `events.html` is directly reachable as `/events.html`, `index.html` is reachable as `/`, and `docs/index.html` is reachable as `/docs/`. Use `site.public_paths` when the public URL table should be separate from private release asset filenames: ```json { "site": { "replace": { "events.html": { "sha256": "", "size": 4096, "content_type": "text/html" } }, "public_paths": { "mode": "explicit", "replace": { "/events": { "asset": "events.html", "cache_class": "html" } } } } } ``` In explicit mode, only paths in `public_paths.replace` are direct static URLs. The asset filename above is not directly reachable as `/events.html`. Explicit mode is sticky: later `site.patch` changes that omit `site.public_paths` inherit the existing explicit public table, so newly added assets do not become public by filename. To change public paths in v1, send a complete `public_paths.replace` table. To intentionally restore filename-derived reachability, send `site.public_paths: { "mode": "implicit" }`; plan warnings flag this because public reachability may widen. Public path keys must be absolute paths such as `/events` with no query string, fragment, raw or encoded slash/backslash separator tricks, dot segments, control characters, duplicate canonical paths, or internal Run402 namespaces such as `/_cas/*` and `/_run402/*`. Entries currently accept only `{ "asset", "cache_class" }`; arbitrary response headers are rejected in v1. Each `asset` must be a relative release static asset path in the final materialized release. Release inventory includes `static_public_paths[]` with `public_path`, `asset_path`, `reachability_authority` (`implicit_file_path`, `explicit_public_path`, or `route_static_alias`), `direct`, `cache_class`, and route-only `methods` when applicable. Authenticated `GET /apply/v1/resolve` diagnostics include the matched `asset_path`, `reachability_authority`, and `direct` flag. ### Secrets are out-of-band, not in the spec `secrets.set` and `secrets.replace_all` are not accepted by `/apply/v1/plans` (HTTP 400 `INVALID_SPEC`). Set values via `POST /projects/v1/admin/{id}/secrets` with body `{ "key": "KEY1", "value": "..." }` BEFORE the deploy, then declare them on the deploy with `secrets.require: ["KEY1","KEY2"]`. The plan response includes a `MISSING_REQUIRED_SECRET` warning for keys that don't exist yet (HTTP 201 — non-blocking). The commit hard-errors with HTTP 422 if a required key is deleted between plan and commit. `secrets.delete[]` removes keys atomically with the activate phase. **Hard limit: 4 KiB per secret value (UTF-8 byte length)**; larger values reject with HTTP 413 `SECRET_VALUE_TOO_LARGE`. **Secret keys must match `^[A-Z_][A-Z0-9_]{0,127}$`.** Caveat: secret values are still passed to AWS Lambda as environment variables, so they are visible in CloudWatch Logs Insights queries against the function's log group. Treat the Lambda environment as the residual exposure surface; the platform does not encrypt them inside Lambda's environment block. ### Plan-time warning envelope (`WarningEntry`) Every `/apply/v1/plans` response carries a `warnings: WarningEntry[]` field (always present; empty array when no warnings). The shape: ```json { "code": "MISSING_REQUIRED_SECRET", "severity": "high", "requires_confirmation": true, "message": "Required secret keys are not yet set: OPENAI_API_KEY", "affected": ["OPENAI_API_KEY"], "details": { "missing_keys": ["OPENAI_API_KEY"] } } ``` Agents that want a "set then deploy" UX should detect `code: "MISSING_REQUIRED_SECRET"` in `warnings[]` and prompt the user (or auto-call setSecret) before committing. The plan stays valid for 24h; the warning is recomputed on every plan-cache hit so a key set after the initial plan is reflected on the next read. ### Release reads (capability `agent-deploy-observability`) Three GET endpoints expose materialized release state for agents that need to answer "what is currently deployed?" or "what changed between two releases?": - `GET /apply/v1/releases/` returns the **activation-time snapshot** — the materialized state captured when the release was activated. Persists across `setSecret` / function-config / subdomain mutations. `state_kind: "effective"` for active/superseded releases (snapshot read); `state_kind: "desired_manifest"` for failed/staged releases (manifest replay). - `GET /apply/v1/releases/active` returns the **CURRENT LIVE state** of the project — reads live tables. A `setSecret` call between activation and now WILL appear in this response. `state_kind: "current_live"`. Returns 404 `NO_ACTIVE_RELEASE` if the project has no active release. Use this when the agent needs to know what the deployed app sees RIGHT NOW. - `GET /apply/v1/releases/diff?from=&to=` returns the same diff envelope shape as the plan response, with `kind: "release_diff"`. Migrations are monotonic (`applied_between_releases: string[]`), distinct from the plan response's `{new, noop}` shape. All three endpoints are always available and always return JSON envelopes for errors — never HTML 404 (that was the bug #106/#107 closed). ### Stable host/path diagnostics `GET /apply/v1/resolve?host=&path=/assets/app.js&method=GET` is an authenticated read for agent diagnostics. The `apikey` must belong to the project bound to the host. Run402 subdomains and active custom domains resolve to the current live release; deployment-specific preview hosts stay on the deployment-id path and are not rewritten through this stable-host resolver. Successful responses include: ```json { "hostname": "example.com", "host_binding_id": "custom:example.com", "binding_status": "active", "project_id": "prj_1741340000_0042", "channel": "production", "release_id": "rel_...", "release_generation": 12, "route_manifest_sha256": "", "static_manifest_sha256": "", "static_manifest_metadata": { "file_count": 4, "total_bytes": 12345, "cache_classes": { "html": 1, "immutable_versioned": 2, "revalidating_asset": 1 }, "cache_class_sources": { "inferred": 4 }, "spa_fallback": "/index.html" }, "normalized_path": "/assets/app.abc123.js", "match": "static_exact", "static_sha256": "", "asset_path": "assets/app.abc123.js", "reachability_authority": "implicit_file_path", "direct": true, "content_type": "application/javascript", "cache_class": "immutable_versioned", "cache_policy": "public, max-age=31536000, immutable", "authorized": true, "authorization_result": "authorized", "cas_object": { "sha256": "", "exists": true, "expected_size": 1234, "actual_size": 1234 }, "fallback_state": "not_used", "legacy_immutable_risk": [], "emergency_fallback": { "enabled": false }, "edge_propagation": { "binding": "example.com", "claimed_at": "2026-07-04T12:00:00.000Z", "kvs_synced_at": "2026-07-04T12:00:02.000Z", "kvs_source": "present", "status": "propagating", "expected_visible_by": "2026-07-04T12:01:00.000Z", "hint": "A public URL 404 can be expected while the edge binding propagates." }, "result": 200 } ``` For matched static objects, `authorization_result` is `authorized`, `missing_cas_object`, `unauthorized_cas_object`, `size_mismatch`, or `unfinalized_or_deleting_cas_object`; static manifest compatibility failures use `match`, `authorization_result`, and `fallback_state` value `unsupported_manifest_version`; cached stable-host misses can report `fallback_state: "negative_cache_hit"`. Bad CAS states report `result: 503` without leaking internal CAS URLs. HTML static hits also include `response_variant` diagnostics with `varies_by: ["hostname"]` and a `variant_inputs_hash`, because transformed HTML cache identity can differ by canonical hostname even when the raw static SHA is shared. `edge_propagation.status` is `settled`, `propagating`, or `sync_pending`. `propagating` means the edge-store source has the binding but edge POPs may still lag; `sync_pending` means the source write is not confirmed and the reconciler will rewrite it; `expected_visible_by` is a claim-time + ~60s heuristic while fresh bindings settle. A 404 at a public static URL during `propagating` or `sync_pending` is expected and should be retried rather than treated as app failure. Misses are diagnostic, not redirects: absent paths return `match: "none"`/`result: 404`; missing manifests or missing fallback targets return `result: 503`; invalid canonical paths return `match: "path_error"` with `error_code`. The response never exposes internal CAS URLs. ### Content negotiation (`/content/v1/*`) Bytes always travel through CAS. The plan response lists missing SHAs; you negotiate uploads via: | Path | Method | Auth | Description | |------|--------|------|-------------| | `/content/v1/plans` | POST | apikey | Tell the gateway which SHAs you have, get presigned PUT URLs (single or multipart) for the missing ones. | | `/content/v1/plans/:plan_id/commit` | POST | apikey | Finalize: complete multipart uploads, promote staged objects to CAS. | PUT each `parts[].url` directly; for multipart, capture each `ETag` and pass them in the `commit` body as `parts: [{ part_number, etag }]`. ### SSR ISR cache (`/cache/v1/*`) — capability `ssr-isr-cache` (v1.52) Paired with `@run402/astro` v1.0+ (the Astro SSR preset). The gateway holds an origin-side ISR cache backed by CAS — every cacheable SSR response is stored as `_cas//` keyed by canonical `{host}:{release_id}:{locale}:{method}:{pathname}{?normalizedSearch}`. Cache writes are generation-guarded; concurrent MISS renders for the same key on one task share a single Lambda invocation (in-process single-flight dedup). | Path | Method | Auth | Description | |------|--------|------|-------------| | `/cache/v1/invalidate` | POST | Bearer service_key | Invalidate cache rows. Body discriminates on `kind`: `"exact"` (`{ host, path }`), `"prefix"` (`{ host, prefix }`), `"all"` (`{ host }`), or `"many"` (`{ urls: [...] }`). Host MUST be owned by caller's project; cross-project returns 403 `R402_CACHE_INVALIDATION_HOST_FORBIDDEN`. DELETE + per-(project, host) generation increment happen atomically. | | `/cache/v1/inspect` | GET | Bearer service_key | Inspect cache row state for a canonical key. Query params: `host`, `path`, `locale?` (default project default), `release_id?` (default project active). Returns `{ status: "HIT" \| "MISS", host?, path?, locale?, releaseId?, cachedAt?, expiresAt?, writtenUnderGeneration?, contentSha256?, headers? }`. Status is NEVER `BYPASS` — inspect does not issue a request. | **Cacheability rules** (response is stored ONLY when ALL apply): - Method is GET or HEAD - Status is in default cacheable set: `200, 301, 302, 410` - Response has `Cache-Control: public, s-maxage=N` with `N > 0` - No `Set-Cookie` header - No `Cache-Control: private` or `no-store` - No `Vary` (except `Accept-Language`, which is in the cache key as `locale`) - Body ≤ 1 MiB - No `auth.*` helper called during render (taint check via SSR Lambda metadata envelope; any `auth.user()` / `auth.requireUser()` / payment primitives flip `cacheBypassTainted` and disqualify the response from ISR storage) **Read-time bypass** (lookup skipped entirely): method ≠ GET/HEAD, ANY `Cookie` header, ANY `Authorization` header. Bypass reason emitted as `x-run402-cache-reason: { method | cookie | auth }`. **Debug headers** on every SSR response (gateway-generated, overrides user-set values of the same name): `x-run402-request-id`, `x-run402-release-id`, `x-run402-function`, `x-run402-cache` (`HIT` / `MISS` / `BYPASS`), `x-run402-cache-reason` (on `BYPASS` only — one of `no_s_maxage | private | auth | cookie | set_cookie | unsupported_vary | method | too_large | non_cacheable_status`), `x-run402-cache-age` (on `HIT` only, in seconds), `x-run402-locale`. On uncaught function error: adds `x-run402-error-code: R402_SSR_RUNTIME_ERROR`. **Cleanup** runs on the hourly scheduler tick: expired rows past 1h grace, stale-release rows (`release_id != project.live_release_id`) past 24h grace. Underlying CAS objects reaped on the normal hourly CAS GC tick once the last `ssr_cache` ref drops. ### `spec.i18n` — routed locale context ```json "i18n": { "defaultLocale": "en", "locales": ["en", "es", "fr", "pt-BR"], "detect": ["cookie:r402_locale", "accept-language"] } ``` Top-level slice on `ReleaseSpec`, NOT under a `spec` wrapper — `i18n` sits alongside `database`, `functions`, `site`, `assets`, etc. inside the JSON manifest you POST to `/apply/v1/plans`. The "spec" prefix in prose is the TypeScript type name (`ReleaseSpec.i18n`), not a JSON path. **Cookie naming convention.** The platform-canonical cookie name is **`r402_locale`** — use it unless you have a reason not to. Server-side negotiation only sees cookies + headers (never `localStorage`), so apps with a language switcher MUST write the cookie alongside whatever client-side persistence they do: ```js document.cookie = `r402_locale=${lang}; path=/; max-age=31536000; samesite=lax`; ``` If your app already ships with a different name (e.g., `wl_locale` from a Next.js/wrangler import) you can keep it — just pass `"cookie:wl_locale"` in `detect[]`. The matching is exact, so a typo (`cookie:r402-locale` vs `cookie:r402_locale`) is a silent miss. **Canonical casing — validated at deploy time (landed).** Locales must be in their RFC 5646 §2.1.1 canonical form: primary language subtag lowercase, 4-alpha script subtag Titlecase, 2-alpha region subtag UPPERCASE, 3-digit region subtag preserved, variant + extension + private-use subtags lowercase. So `pt-BR` not `pt-br`, `zh-Hant` not `zh-hant`, `es-419` not `ES-419`. Both `locales[]` entries AND `defaultLocale` are validated. The platform rejects non-canonical entries at `/apply/v1/plans` with `R402_LOCALE_NOT_CANONICAL` carrying both `input` and `canonical` (the suggested fix) in the envelope's `fix:` field, plus a human-readable "Did you mean ..." message. The reason for reject-rather-than-canonicalize: your translation table (e.g. `section_translations.language`) needs to match byte-for-byte, and canonicalizing-on-input would create a silent split between the spec and your DB column. Better to reject at deploy than to surface as a runtime 404. Pick canonical, use it consistently. Underscore separators (`pt_BR`, common when copied from Java/Linux locale strings) are also rejected — RFC 5646 §2.1 specifies `-` as the subtag separator. **Negotiation runtime.** Walks `detect[]` in order; first hit wins. Accept-Language gets RFC 4647 §3.4 longest-prefix lookup (`zh-Hant-TW` → `zh-Hant` → `zh`) with a stable q-tie sort. Cookie matching is case-insensitive on the cookie value, case-sensitive on the cookie name. The negotiated value is byte-identical to a `locales[]` entry by construction. Surfaces in user code as: - `Astro.locals.run402.locale` (in `@run402/astro` pages / middleware) - `getRun402Context(request).locale` (in plain Node22 functions via `@run402/functions`) - `x-run402-locale` + `x-run402-default-locale` request headers (raw access) - `event.context.locale` + `.defaultLocale` (raw routed-envelope consumers) When the active release has no `i18n` slice, all of the above are `null` (not "default-locale"). Apps that need to render *something* should `?? 'en'` (or whatever fallback they prefer) at the read site. **Visitor geo — `x-run402-country` request header.** Routed function invocations carry the visitor's country as an edge-authoritative request header, identical on managed subdomains (`.run402.com`) and verified custom domains: `req.headers.get('x-run402-country')` → an ISO 3166-1 alpha-2 code (plus the Cloudflare specials `T1` for Tor and `XX` for unknown), passed through verbatim from the edge. No i18n slice or config is required — the header is always on when the edge knows the country. When the edge supplies no geo (rare; e.g. a zone without IP Geolocation), the header is **absent** — never empty, never a guessed value — so read it as `req.headers.get('x-run402-country') ?? 'unknown'` if you need a fallback. Client-supplied geo headers (`x-run402-country`, `cf-ipcountry`, `cloudfront-viewer-country`) are scrubbed at both edges and can never reach your function as spoofed values. Compat note: on custom domains only, the legacy `cf-ipcountry` header is also present with the same value — prefer `x-run402-country`, which works on every hostname. ### `ApplySpec` shape `ApplySpec` extends `ReleaseSpec` with an optional `assets` slice for CDN-served key/value content. Release-scoped slices are `database`, `secrets`, `functions`, `site`, `subdomains`, `routes`, `checks`. A spec with only release-scoped slices and no `assets` is a valid `ApplySpec` — release-only intent is structurally inferred, not a separate method. ```jsonc { "project_id": "prj_1741340000_0042", "base": { "release": "current" }, // or { "release": "empty" } for a clean slate "database": { "migrations": [ { "id": "001_init", "checksum": "", "sql": "CREATE TABLE IF NOT EXISTS items (id serial PRIMARY KEY, title text NOT NULL);" } ], "expose": { "version": "1", "tables": [ { "name": "items", "expose": true, "policy": "user_owns_rows", "owner_column": "user_id", "force_owner_on_insert": true } ] } }, "secrets": { "require": ["OPENAI_API_KEY"], "delete": ["OLD_KEY"] }, "functions": { "replace": { "summarize": { "runtime": "node22", "source": { "sha256": "", "size": 1234, "content_type": "application/javascript" }, "config": { "timeout_seconds": 10, "memory_mb": 256 }, "schedule": null } }, "patch": { "delete": ["legacy-func"] } }, "routes": { "replace": [ { "pattern": "/api/*", "methods": ["GET", "POST"], "target": { "type": "function", "name": "api" } } ] }, "site": { "replace": { "index.html": { "sha256": "", "size": 512, "content_type": "text/html" } }, "public_paths": { "mode": "explicit", "replace": { "/": { "asset": "index.html", "cache_class": "html" } } } }, "subdomains": { "set": ["my-app"] } } ``` **Replace vs patch per resource.** `site.replace` = "this is the whole site" (paths absent are removed). `site.patch.put` / `patch.delete` = surgical updates. Same replace/patch shape applies to `functions`; `routes.replace` replaces the route table; `routes: null` or omitted carries routes forward; `routes.replace: []` clears the route table. `secrets` is intentionally value-free (`require[]` / `delete[]` only), and `subdomains` has its own set/add/remove/delete shape. Top-level absence = leave untouched. Wire migrations are append-only (`id`, `checksum`, SQL source): a new `id` runs once, same `id`+`checksum` is a noop, and same `id`+different checksum is `MIGRATION_CHECKSUM_MISMATCH`. SDK/CLI/MCP authoring may declare `name` instead of `id` for generated/idempotent SQL; the client compiles it to `_` before this API sees the spec. ### Web Routes `ApplySpec.routes` maps same-origin browser paths on deployed sites to serverless functions or exact static URL aliases. Route targets are public browser ingress and do not require a Run402 API key at the public edge; direct `/functions/v1/:name` remains API-key protected. Author routes as a replace-mode array, not as a path-keyed map: ```json { "functions": { "replace": { "admin": { "runtime": "node22", "source": { "sha256": "", "size": 2048 } }, "api": { "runtime": "node22", "source": { "sha256": "", "size": 2048 } } } }, "site": { "replace": { "events.html": { "sha256": "", "size": 4096, "content_type": "text/html" } } }, "routes": { "replace": [ { "pattern": "/admin", "methods": ["GET"], "target": { "type": "function", "name": "admin" } }, { "pattern": "/admin/*", "target": { "type": "function", "name": "admin" } }, { "pattern": "/api/*", "methods": ["GET", "POST", "OPTIONS"], "target": { "type": "function", "name": "api" } }, { "pattern": "/events", "methods": ["GET"], "target": { "type": "static", "file": "events.html" } } ] } } ``` `routes: null` or omitted carries the base release routes forward. `routes.replace: []` clears the route table. Each target function must exist in the same materialized release. Each static target file must exist in the final materialized static site after applying `site.replace` or `site.patch`, including when a carried-forward route table is preserved across a site deletion. Supported route patterns: - Exact absolute paths: `/admin`, `/login`, `/og.png`. - Final prefix wildcards only for function targets: `/api/*`, `/admin/*`. - No query strings, no regex, no mid-pattern wildcards, max 100 routes, max 256 bytes per pattern. Function target shape: ```json { "type": "function", "name": "api" } ``` Function route `methods` may be omitted for all supported methods, or set to any non-empty subset of `GET`, `HEAD`, `POST`, `PUT`, `PATCH`, `DELETE`, and `OPTIONS`. `GET` routes also match `HEAD`. Function routes may declare fixed x402 pricing: ```json { "pattern": "/api/credits", "methods": ["POST"], "target": { "type": "function", "name": "credits" }, "pricing": { "mode": "always", "amount_usd_micros": 250000, "pay_to": "org_default_payout" } } ``` `amount_usd_micros` is an integer USD micro-unit price, so `250000` is $0.25 per successful routed action. `mode:"always"` means every matching request requires payment before the handler runs, regardless of the caller's Run402 tier balance or the project's platform billing state. `pay_to` must be `org_default_payout`; the gateway resolves it to the owning organization's payout wallet at activation and again at serve time. `networks` may be omitted for production Base mainnet defaults, or set explicitly for supported rails such as Base Sepolia in test deployments. **Verified merchant claims: buyer-ready, hosted authoring standards-gated.** The public buyer surfaces add one policy input—SDK `pay.fetch(url, init, { requireReceipt:true })`, CLI `run402 pay --require-receipt`, and MCP `pay_url { ..., require_receipt:true }`. Before payment, the buyer considers every eligible exact-scheme requirement and selects one whose standard signed offer verifies against the exact URL (including query), network, asset, atomic amount, destination, validity, and direct `payTo` signer. If none qualifies it returns `MERCHANT_RECEIPT_REQUIRED` before creating a payment authorization, so `funds_moved:false`. After settlement it independently checks the standard receipt against the accepted offer, payer, transaction, resource, network, and signer. A required receipt that is missing, invalid, or unavailable raises `MERCHANT_RECEIPT_UNAVAILABLE` while preserving the upstream response, settlement facts, actual movement/replay state, raw non-secret evidence, and one recovery action that never recommends paying twice. Run402 Cloud recognizes the public ReleaseSpec literal `pricing.receipt:"on_fulfillment"` but currently rejects it at the exact `routes.replace..pricing.receipt` field before any mutation. The current `@x402/extensions` offer/receipt carrier has direct signatures but no interoperable self-contained authorization from an organization payout wallet to a managed delegated signer. Until that carrier is standardized and released, hosted tenant and tier challenges advertise no Run402-authored merchant evidence. Ordinary priced routes, independent direct-merchant verification, and rail-wide tier idempotency remain available. This gate prevents a private Run402 convention from looking like portable wallet authority. The dormant managed implementation is fail-closed: offer and settlement evidence share an 8 KiB encoded cap; exact offers last five minutes with 30 seconds of verifier skew; source/project/organization/global admission, bounded exact-offer caching, KMS cost budgets, circuits, metrics, and per-organization/global kill switches run before a signing call. Managed KMS keys are staged through the resumable apply-operation state machine, recovered by provider tags after crashes, and scheduled for deletion by immutable key ARN only after the last authorization expires and the payout epoch is retired. Custom-domain scope changes will require reauthorization before new offers; an old receipt remains verifiable from its carried signer authorization rather than the merchant's currently advertised signer. Priced routes are deliberately fixed-price and route-level. They do not read tenant account balances, waive payment for Run402 subscribers, accept service-key/admin/allowance bypass, or monetize direct `/functions/v1/:name` invocation. If an app needs conditional credit logic, expose a fixed-price route such as `POST /api/credits` that buys credits once, then keep the rest of the app behind ordinary unpriced routes and app-local authorization. Activation rejects a priced route unless the target is a function route and the owning organization has a resolvable payout wallet. Recovery codes include `PAYOUT_WALLET_REQUIRED` (no usable payout wallet), `PAYOUT_WALLET_AMBIGUOUS` (multiple active org wallets and no default), and `RECIPIENT_PAYOUT_WALLET_REQUIRED` when a project transfer recipient cannot receive future priced-route revenue. If a payout wallet later becomes unresolved after activation, matching requests fail closed with `503 PAYOUT_WALLET_UNRESOLVED` instead of silently serving for free or settling to the wrong wallet. **Durable caller-key payment identity.** Send a stable `Idempotency-Key` on every paid attempt. The raw key is case-sensitive, 1–255 visible ASCII bytes, and is never persisted; Run402 stores a SHA-256 scope over `(verified payer, project_id, route_pattern, method, caller key)` plus a non-secret request-contract digest. The same payer + compatible request + same key always resolves to one durable `payment_id` across processes. Changing wallets changes the verified payer and therefore creates a different payment identity; wallet rotation is not a recovery mechanism. Never remove or change a key merely to escape an uncertain outcome. The payment row is authoritative; signed authorizations are not retained. Proof-derived deduplication remains as a lower-level fence for callers without a key. Same-key replay is the status operation—there is no separate public payment-status route: | Retained state | Same-payer/same-request/same-key result | |---|---| | no row | Create one identity, then attempt settlement once. | | `created` / `settling` | `409 PAYMENT_INTENT_PENDING`; wait for `Retry-After`, then repeat the identical request and key. | | `ambiguous` | Reconcile authoritative facilitator/chain evidence; return trusted pending until terminal. Never mint a new key. | | `settled` | Reuse the stable payment outcome; `X-Run402-Payment-Deduplicated:true`, `Funds-Moved:false` for this attempt, and serialized first/replay delivery. | | resume-safe `settle_failed` | A fresh proof may resume only after no-movement and authorization-finality are both proven. Keep the same key. | | unsafe/unknown `settle_failed` | Fail closed and reconcile; no second facilitator call. | | compact tombstone / expired fence | Return the retained outcome; the old key never becomes chargeable again. | `IDEMPOTENCY_KEY_REUSED` means the key is bound to another request contract: repeat the original request or use a new key only for an intentionally new purchase. `PAYMENT_INTENT_DESTINATION_CHANGED` and `PAYMENT_INTENT_FENCE_EXPIRED` are recovery-only retained outcomes; reconcile them and do not resubmit settlement. `PAYMENT_CALLER_IDENTITY_NOT_ACTIVE` is the rollout fail-closed response: preserve the key and retry after caller identity is enabled—never downgrade to keyless/proof-only behavior. Minimum compatible package versions are `@run402/functions` 3.11.0 for routed function payment context and `@run402/sdk`, `run402`, or `run402-mcp` 4.11.0 for caller-keyed buyer recovery. Older buyer clients do not implement the exact trusted-pending journal contract and must not be used to recover an uncertain keyed attempt. **The `run402` CLI additionally requires 4.11.2 to make ANY paid request.** Releases before it shipped without the `@x402/extensions` peer, so a `npx -y run402` install had no paid stack at all — and reported that as `PAYMENT_WALLET_UNFUNDED` pointing at `run402 init`, which is misleading when the wallet is funded. If you see that code on a wallet you believe is funded, check your CLI version first; 4.11.2+ reports the real cause as `PAYMENT_DEPENDENCY_MISSING` with the install command. `run402-mcp` and `@run402/sdk` consumers that install the documented peers were never affected. Run402 challenges advertise `maxTimeoutSeconds: 300`. The gateway accepts five seconds of clock skew and rejects overlong hand-rolled authorizations before settlement with `400 PAYMENT_AUTHORIZATION_LIFETIME_EXCEEDED`. This is an intentional compatibility break for clients that ignored the advertised bound. Project ownership transfers and organization payout-wallet changes establish a generation drain before changing the destination. While pre-cutoff payment settlement or delivery is unresolved, new caller-keyed and keyless/proof-keyed attempts fail with `409 PAYMENT_DESTINATION_DRAINING`, `Retry-After`, cutoff/blocker metadata, and an identical-request `next_actions[]` retry. Existing pre-cutoff rows remain discoverable and reconcilable, but cannot settle afresh, acquire a delivery lease, or invoke the next owner's function. Transfer preview exposes `payment_drain`, including `outgoing_generation_settled_intent_count`. Static alias target shape: ```json { "type": "static", "file": "events.html" } ``` Static aliases are exact-only. Prefix patterns such as `/docs/*` are rejected for static targets. Static alias `methods` are optional: when omitted they default to `GET` + `HEAD`, and when provided they must be `["GET"]` or `["GET", "HEAD"]`; every form materializes to effective `GET` + `HEAD`. `target.file` is a materialized static-site file path, relative to the site root: no leading slash, query, fragment, backslash, trailing slash/directory shorthand, empty segment, `.` segment, or `..` segment. A static alias may target the apex `/` (or a trailing-slash directory path) to override what that URL would otherwise serve. The alias wins over the implicit `/` -> `index.html` mapping while the site's SPA fallback stays derived from the shipped root `index.html` — so `{ "pattern": "/", "target": { "type": "static", "file": "home.html" } }` serves a real static home page while unmatched navigation paths keep returning the SPA shell. The plan reports `STATIC_ALIAS_SHADOWS_STATIC_PATH` (warn) and `STATIC_ALIAS_DUPLICATE_CANONICAL_URL` (info) for this shape; both are expected and non-blocking. An alias that conflicts with an explicit `public_paths` declaration or another file's own public path is still rejected. The same normalized path may appear more than once only when effective methods are disjoint. For example, `/login` can serve a static alias for `GET`/`HEAD` and a function for `POST`; overlapping effective methods are rejected. Matching is deterministic and identical on managed subdomains, deployment hosts, and verified custom domains. Exact routes beat prefix routes; among prefixes, the longest prefix wins. Exact `/admin` matches both `/admin` and `/admin/`. `/admin/*` matches `/admin/settings` but not `/admin`, `/admin/`, `/admin.css`, or `/administrator`. Query strings do not affect selection and are forwarded unchanged for functions. `GET` routes also match `HEAD`. If a method path-matches a route but no allowed method matches, Run402 returns `405 Method Not Allowed` with the union of allowed methods for that path; matched route failures fail closed and never fall through to static HTML. Static aliases serve the target file bytes at the public alias URL. They do not redirect to or expose the underlying file path, rewrite query strings, run header rules, or invoke framework routing. In the static manifest they materialize as route-only entries (`direct: false`, `reachability_authority: "route_static_alias"`) backed by release static assets. Direct static serving and static aliases share content type, cache-control, HTML mutation, conditional request behavior, and `HEAD` response behavior. A missing static alias target returns a platform static-route error and does not fall back to SPA HTML. Plan/read surfaces include `routes: { manifest_sha256, entries }` on release inventory and `routes: { added, removed, changed }` in plan/release diffs. Static aliases render as route entries with `target: { "type": "static", "file": "" }`; warning and error `affected[]` strings use phrasing such as `/events -> static file events.html`. Route warnings include `PUBLIC_ROUTED_FUNCTION`, `ROUTE_TARGET_CARRIED_FORWARD`, `ROUTE_SHADOWS_STATIC_PATH`, `WILDCARD_ROUTE_SHADOWS_STATIC_PATHS`, `ROUTE_TABLE_NEAR_LIMIT`, `STATIC_ALIAS_SHADOWS_STATIC_PATH`, `STATIC_ALIAS_RELATIVE_ASSET_RISK`, `STATIC_ALIAS_DUPLICATE_CANONICAL_URL`, `STATIC_ALIAS_REDUNDANT_PUBLIC_PATH`, `STATIC_ALIAS_EXTENSIONLESS_NON_HTML`, `STATIC_ALIAS_TABLE_NEAR_LIMIT`, and `PUBLIC_PATH_MODE_WIDENS_TO_IMPLICIT`. Static aliases currently count toward the same temporary combined 100-entry route cap as function routes; `STATIC_ALIAS_TABLE_NEAR_LIMIT` includes `details.limit_scope: "combined_routes_temporary"` until a separate static-alias cap lands. Non-goals: static aliases are not rewrites, redirects, Web Output, framework adapters, edge runtime, streaming, ISR, image optimization, query transforms, header rules, or static-file monetization. Route-level x402 is supported only on function targets through the fixed-price `pricing` object above. Function targets keep the standard Node 22 Fetch Request -> Response contract: `export default async (req: Request) => Response`. For routed function requests, `req.method` is the original browser method and `req.url` is the full public URL, including scheme, host, path, and query string (`https://.run402.com/...`, deployment host, or the verified custom domain). ```js export default async function handler(req) { const url = new URL(req.url); if (url.pathname === "/admin/oauth/google") { const redirectUri = new URL("/admin/oauth/google/callback", url.origin); const authUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth"); authUrl.searchParams.set("client_id", process.env.GOOGLE_CLIENT_ID ?? "replace-me"); authUrl.searchParams.set("redirect_uri", redirectUri.toString()); authUrl.searchParams.set("response_type", "code"); authUrl.searchParams.set("scope", "openid email profile"); authUrl.searchParams.set("state", crypto.randomUUID()); return Response.redirect(authUrl, 302); } if (url.pathname === "/admin/oauth/google/callback") { const headers = new Headers({ location: "/admin" }); headers.append("Set-Cookie", "sid=example; HttpOnly; Secure; SameSite=Lax; Path=/"); headers.append("Set-Cookie", "flash=welcome; Secure; SameSite=Lax; Path=/; Max-Age=30"); return new Response(null, { status: 303, headers }); } return new Response("

Admin

", { headers: { "content-type": "text/html; charset=utf-8" }, }); } ``` The OAuth `redirect_uri` above is derived from `req.url`, so the same code works on `*.run402.com`, deployment hosts, and verified custom domains. Routed request fields available to user code: | Field | Contract | |---|---| | `req.method` | Original browser method. `GET` routes also match `HEAD`; a `HEAD` request still reaches the handler as `HEAD`. | | `req.url` | Full public URL with scheme, host, path, and query. Use `new URL(req.url)` for routing and OAuth callback URLs. | | `req.headers` | Browser headers forwarded through a duplicate-safe internal list and exposed through Fetch `Headers`. Cookie data is available through the `cookie` header. | | `await req.text/json/arrayBuffer()` | Buffered request body, max 6 MiB. Streaming request bodies are not supported. | | Payment metadata | On priced routes only, gateway-confirmed `x-run402-payment-*` headers are available after settlement. Use `getRoutedPaymentContext(req)` instead of parsing headers directly. | | Route metadata | Internal only. Run402 transports `run402.routed_http.v1` context between edge/gateway and Lambda; user handlers should use Fetch `Request`, not the raw envelope except through `@run402/functions` helpers. | Priced route caller flow: 1. First unauthenticated/underpaid request receives `402 Payment Required` with `PAYMENT-REQUIRED` challenge metadata and a tenant `payTo` address resolved from the owning org. 2. Choose a stable business `Idempotency-Key`, then retry the same browser route with that key and an x402 payment proof, normally in `X-PAYMENT`. 3. The gateway verifies the payer, claims or loads the durable caller identity, validates the 300-second authorization bound, and conditionally settles once. 4. On `PAYMENT_INTENT_PENDING`, wait for `Retry-After` and repeat the identical request with the same payer and key. The gateway-owned `X-Run402-Payment-Intent-State: pending` header plus exact managed/deployment Run402 host is the trusted pending signal; custom domains remain ambiguous to generic clients. 5. After settlement, the handler receives payment context and can persist idempotent app-side credit using `payment.paymentId`. Payment settlement is at-most-once per durable identity, but tenant function execution is honestly **at-least-once**. A crash after the handler commits but before the gateway returns can cause a serialized replay delivery. If the function fails after settlement, the browser receives a normal sanitized routed error plus `X-Run402-Payment-Id` when available. Handlers must deduplicate durable side effects by `paymentId`; `delivery:"first"|"replay"` is diagnostic, not an exactly-once promise. If a payment proof is present but the x402 facilitator rejects it, Run402 returns `402` with the normal `PAYMENT-REQUIRED` challenge header and a JSON envelope: `{ code:"TENANT_X402_PAYMENT_INVALID", message, request_id, details }`. `details.x402_error` carries the facilitator reason when available; for example `self_send_not_allowed` means the payer wallet is also the tenant payout wallet, so the caller must use a distinct buyer wallet. When Run402 can identify a payer balance/allowance shortfall for the priced-route quote, the envelope code is `payment_insufficient_funds` and includes `amount_usd_micros`, `network`, `asset`, and `next_actions:[{type:"fund_wallet",network,asset,minimum_amount_usd_micros}]`; agents should fund that wallet on the named network/asset and retry while the proof is still valid, or fetch a fresh challenge if it expired. Project-scoped tenant payment history is available to org developers and read-scoped project grants/delegates: ``` GET /projects/v1/:project_id/tenant-payments?status=settled&limit=50&after= ``` The list returns `{ project_id, payments, has_more, next_cursor }`. `status` is optional and must be `settling`, `settled`, `settle_failed`, or `ambiguous`; `limit` defaults to 50 and caps at 200. Payment records include `org_id`, route, amount, payer, settlement reference/tx, request id, reconciliation state, and timestamps. Raw x402 authorization headers, authorization hashes, and internal metadata are intentionally never returned. Routed response behavior from user code: | Field | Contract | |---|---| | `Response.status` | HTTP 200-599 except `101 Switching Protocols`. WebSockets are not supported. | | `Response.headers` | Forwarded as duplicate-safe headers. Append multiple `Set-Cookie` values with `headers.append("Set-Cookie", value)`; Run402 preserves them as separate browser headers. | | `Location` | Redirect targets are forwarded unchanged. Use absolute URLs when leaving the current origin. | | `Response.body` | Buffered response body, max 6 MiB. `HEAD` responses send headers without body bytes. SSE and streaming responses are not supported. | | Cache | Dynamic route responses are never stored in Run402's shared CDN cache. If no `Cache-Control` is set, Run402 adds `Cache-Control: private, no-store` and `x-run402-cache: dynamic-bypass`. | | CORS | Run402 does not add default wildcard CORS. Implement `OPTIONS` and CORS headers in the function when cross-origin access is intended. | Internally, Run402 converts the Fetch response to `run402.routed_http.v1`; agents should treat this as implementation detail unless they are debugging gateway internals: ```json { "status": 302, "headers": [["location", "/dashboard"]], "cookies": ["sid=abc; HttpOnly; Secure; SameSite=Lax; Path=/"], "body": null } ``` Custom-domain readiness checklist: - Verify the route first on the managed subdomain or deployment host, then verify the same path on the custom domain. - Confirm the deploy operation is `ready` and release/deployment inventory shows `routes.entries` plus a non-null `routes.manifest_sha256`. - Confirm the custom domain is verified and serving the intended deployment. Custom domains use the Cloudflare Worker route manifest but must behave the same as managed domains. - If the managed domain works but the custom domain returns a routed failure, check for `ROUTED_INVOKE_WORKER_SECRET_MISSING` or `ROUTE_MANIFEST_LOAD_FAILED`; those indicate Worker/gateway configuration or manifest propagation, not user handler code. Troubleshooting route failures: | Code | Meaning | Recovery | |---|---|---| | `ROUTE_MANIFEST_LOAD_FAILED` | The host selected a route, but the active route manifest could not be loaded. | Wait for the operation to reach `ready`, confirm `routes.manifest_sha256` is present, then redeploy or contact Run402 support if the manifest stays unavailable. | | `ROUTED_INVOKE_WORKER_SECRET_MISSING` | Custom-domain Worker is missing the shared gateway invoke secret. | Managed domains may still work. Re-sync/deploy Worker config or contact Run402 support. | | `ROUTED_INVOKE_AUTH_FAILED` | Gateway rejected the internal routed invoke signature. | Retry after propagation; persistent failures indicate shared secret or clock/config drift. | | `ROUTED_ROUTE_STALE` | The selected route no longer matches the active release or host after revalidation. | Wait for route propagation, then retry or redeploy the same route table. | | `ROUTE_METHOD_NOT_ALLOWED` | The path matched a route, but the HTTP method was not allowed. | Add the method to `methods`, add an `OPTIONS` route for CORS preflight, or remove the method restriction. | | `STATIC_ROUTE_TARGET_NOT_FOUND` | A static alias matched, but its materialized target file could not be served. | Confirm the route target `file` exists in the final release's static site and redeploy. | | `PAYOUT_WALLET_REQUIRED` | A priced route was deployed or transferred without a resolvable organization payout wallet. | Link an org wallet or set `PATCH /orgs/v1/:org_id/payout-wallet`, then redeploy or retry the transfer. | | `PAYOUT_WALLET_AMBIGUOUS` | The owning org has multiple active wallets and no default payout wallet. | Select the default with `PATCH /orgs/v1/:org_id/payout-wallet`. | | `PAYOUT_WALLET_UNRESOLVED` | A previously activated priced route could not resolve its payout wallet at serve time. | Restore/link/select the org payout wallet before retrying paid requests. | | `PAYMENT_PROOF_MISMATCH` | The supplied x402 proof does not exactly satisfy the priced route challenge. | Re-fetch the 402 challenge for the current route/method/amount/network and retry with a fresh proof. | | `payment_insufficient_funds` | The buyer wallet does not have enough USDC for the tenant priced-route quote on the reported network. | Follow the `fund_wallet` next action, then retry before the proof expires or fetch a fresh challenge. | | `ROUTED_RESPONSE_TOO_LARGE` | Handler response body exceeded the 6 MiB routed response limit. | Return a smaller body, move large assets to static deploy/storage, or redirect to a downloadable object. | For browser sessions, functions own app auth, CSRF checks, OAuth callbacks, cookie flags (`HttpOnly`, `Secure`, `SameSite`), Origin checks, and Fetch Metadata checks. ### Plan response ```json { "plan_id": "plan_...", "operation_id": "op_...", "base_release_id": "rel_...", "manifest_digest": "", "missing_content": [{ "sha256": "", "size": 512, "present": false }], "diff": { "is_noop": false, "resources": { "site": { "paths_added": ["index.html"], "paths_changed": [], "paths_removed": [] }, "database": { "migrations": [{ "id": "001_init", "status": "new" }] }, "functions": { "added": ["summarize"], "changed": [], "removed": [] }, "secrets": { "added": ["OPENAI_API_KEY"], "changed": [], "removed": [] }, "subdomains": { "added": ["my-app"], "removed": [] }, "routes": { "added": [], "removed": [], "changed": [] } } }, "payment_required": null } ``` For each `missing_content[i]` where `present: false`, hand the SHA + size to `POST /content/v1/plans` to get presigned PUT URLs. ### Commit response ```json { "operation_id": "op_...", "release_id": "rel_...", "status": "ready", "urls": { "site": "https://.run402.com", "console": "https://console.run402.com/orgs//projects/" } } ``` If `status` is non-terminal (`schema_settling`, `activation_pending`), poll `GET /apply/v1/operations/:operation_id`. The auto-resume worker retries automatically; agents can also call `POST /apply/v1/operations/:operation_id/resume` to re-drive the failed phase forward immediately. ### After deploy: hand your human two links Every commit or promote that reaches `status: "ready"` with a public site mints `urls.console` alongside `urls.site` — the same project, one link to visit and one link to manage from. Show your human both. When there is a site and the project has not already answered, the response's `next_actions[]` also carries one `hand_to_operator` entry — this is the offer, not a poll: ```json { "type": "hand_to_operator", "method": "POST", "path": "/feedback/v1", "body": { "project_id": "prj_...", "message": "promote: yes", "handle": null }, "credited_as": "SnowyJim32", "why": "Show your human the site and console links. Tell them Run402 would like to promote what they built on @run402com, for free, credited to SnowyJim32 and to them. Ask yes or no. On yes, optionally ask for their X handle and send the body above." } ``` The ask is an offer, never a claim — "would like to promote", never "liked" (nobody has reviewed the build). Relay it to your human verbatim in spirit: show `urls.site` and `urls.console`, name the promotion, ask yes or no. On yes, `POST` the entry's `body` as-is, filling in `handle` if your human gave one (max 64 chars, `@handle` or plain text — delivered to Telegram only, never stored). On no, do nothing; the offer is re-asked on the project's next activation, since a "no" is not recorded. `credited_as` is the deploying principal's `display_name`, otherwise `null`. Room presence is never consulted. Set the name once — `run402 init --name `, `run402 org whoami --set-name `, or `PATCH /agent/v1/me { display_name }` — and every deploy credits it; `run402 up -y` sets a detected default (`claude-code`, `codex`, `cursor`, or `agent`) for a principal that has none (wallet principals are created unnamed; the gateway never seeds a name from the wallet subject) and joins the project room under the same name. The entry disappears once your human has said yes: a project whose feed already holds a `feedback_sent` fact with `payload.promotion_consent: true` gets no further `hand_to_operator` entries. Building the entry is a best-effort response rider — its failure never fails a commit or promote that already succeeded, so treat its absence on an otherwise-`ready` response as "already answered or not offered this time," not as an error. ### Error fields specific to deploys `code` is one of `INVALID_SPEC`, `MIGRATION_FAILED`, `MIGRATION_CHECKSUM_MISMATCH`, `MIGRATE_GATE_ACTIVE`, `PLAN_NOT_FOUND`, `OPERATION_NOT_FOUND`, `NOT_RESUMABLE`. Two additional fields: `phase` (`validate | plan | stage | migrate | schema_settling | activate | commit | resume`) and `operation_id` for correlation. `MIGRATION_CHECKSUM_MISMATCH` is a hard error, never a success diff bucket. The envelope includes every conflicting migration in `details.conflicts[]`: ```json { "code": "MIGRATION_CHECKSUM_MISMATCH", "details": { "conflicts": [ { "id": "seed", "spec_checksum": "<64hex>", "registry_checksum": "<64hex>", "applied_at": "2026-01-01T00:00:00.000Z" } ] }, "next_actions": [ { "type": "fix_manifest", "why": "Generated SQL should use name instead of id so identity tracks content." }, { "type": "fix_manifest", "why": "A revised versioned migration needs a new id because applied migrations are immutable." }, { "type": "contact_operator", "why": "Legacy out-of-band adoption requires POST /apply/v1/admin/migrations/adopt." } ] } ``` Primary recovery: if the SQL is generated or seed-like, replace the manifest entry's `id` with `name` in the SDK/CLI/MCP authoring manifest. The client derives the wire id from SQL content, so changed content applies once under a new id and unchanged re-deploys noop. SQL declared with `name` MUST be idempotent (`CREATE OR REPLACE`, upserts, `IF NOT EXISTS`) because changed content re-runs against a database where earlier versions may already exist. If the migration is truly versioned and hand-edited, keep `id` semantics and ship the revision under a new id. Admin checksum adoption is only for legacy/out-of-band cases. `mutation_state: "rolled_back"` means the active release is unchanged (commonly with `MIGRATION_CHECKSUM_MISMATCH`). `mutation_state: "unknown"` means commit returned 5xx — use `Idempotency-Key` to retry safely or check `GET /apply/v1/operations/:operation_id` first. ## OIDC federation for CI/CD — `/ci/v1/*` (v1.36) GitHub Actions push-to-deploy without storing wallet keys in CI. A wallet-signed delegation links a GitHub OIDC subject pattern to a project; the workflow exchanges its GitHub OIDC JWT for a 15-min run402 session JWT and uses it on the CI-callable deploy routes (`/content/v1/plans*`, `/apply/v1/plans*`, `/apply/v1/operations/:operation_id` + `/events` + `/edge-coherence` + `/resume`). Same trust model as Vercel, Supabase, Netlify, Cloudflare etc. — connecting the workflow grants the workflow runtime authority over the project. **Dual-authority disclosure.** Code deployed via CI runs with the project's runtime authority — `RUN402_SERVICE_KEY` in env, `adminDb()` (BYPASSRLS), and configured secrets via `process.env`. Database authority too — `spec.database` ships migrations, RLS/expose changes, and schema-altering SQL. The wallet-signed delegation's `Statement` field discloses both surfaces verbatim — the wallet UI shows the consent text at sign time. Revoking a binding stops future CI gateway requests but does NOT undo already-deployed code, stop in-flight deploy operations, rotate exfiltrated keys, or remove deployed functions. Compromise recovery: revoke + SIWE-deploy a known-good release + rotate any service-role key the deployed code may have read. ### Routes | Path | Method | Auth | Description | |------|--------|------|-------------| | `/ci/v1/bindings` | POST | SIWX (project owner) | Create a binding. Body: `{ project_id, provider: "github-actions", subject_match, allowed_actions: ["deploy"], allowed_events?, route_scopes?, github_repository_id?, expires_at?, nonce, signed_delegation }`. `route_scopes` is optional and defaults to `[]` (no route authority); when set, use exact/final-wildcard route patterns such as `["/admin","/admin/*"]`. The `signed_delegation` is a SIWE/SIWX-shaped wallet-signed message; gateway parses + verifies it byte-for-byte against canonical `Statement` and `Resources` builders. Returns 201 with the binding row, 409 on duplicate-active `(project_id, issuer, subject_match)`, 400 on `nonce_replay` (same wallet+nonce already used). | | `/ci/v1/bindings` | GET | SIWX | List bindings for a project. Query: `?project=` (required). Includes revoked. | | `/ci/v1/bindings/:binding_id` | GET | SIWX | Binding detail with full parsed `created_sig`. | | `/ci/v1/bindings/:binding_id/revoke` | POST | SIWX | Idempotent kill switch. Sets `revoked_at = NOW()`. Existing CI sessions are rejected on their next gateway request (per-request DB recheck). | | `/ci/v1/bindings/:binding_id/asset-scopes` | POST | SIWX (binding owner) | Replace `asset_key_scopes` on the binding — gates the `spec.assets` slice for CI sessions. Body: `{ asset_key_scopes: string[] }`. Each scope is either an exact key (`astro/hero.jpg`) or a wildcard prefix (`astro/*`); bare `*` / `**` rejected. Capped at 64 entries, 256 chars each. Empty array means "no asset authority" (CI sessions hitting `assets.put` / `assets.sync` get 403 `CI_ASSET_SCOPE_DENIED`). 404 on unknown binding, 409 on revoked binding (cannot mutate), 403 on wallet/owner mismatch. Idempotent — same scope list is a no-op. | | `/ci/v1/token-exchange` | POST | **none — the OIDC JWT is the auth** | RFC 8693-shaped JSON token exchange. Body: `{ grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", subject_token: , subject_token_type: "urn:ietf:params:oauth:token-type:jwt", project_id }`. JSON only (NOT form-encoded). Body cap 12 KiB; `subject_token` cap 8192 bytes. Returns `{ access_token, token_type: "Bearer", expires_in, scope: "deploy" }`. `expires_in` may be < 900 if the binding's `expires_at` is closer than 15 minutes. On failure 403 with an `error` code: `access_denied` (no binding matches this token), `binding_revoked` (a subject-matching binding exists but was revoked — e.g. the project was transferred/handed off; re-create it with `run402 ci link github`), `ambiguous_binding`, `event_not_allowed`, `repository_id_mismatch`. | ### Token exchange — verification order 1. Validate RFC 8693 request shape and `subject_token.length <= 8192`. 2. Decode the JWT payload (NOT header) to read `iss`. Look up in `internal.trusted_oidc_issuers`. Unknown issuers → 401 `invalid_token` **without any external JWKS fetch**. 3. `jose.jwtVerify` with `algorithms: ["RS256"]` pinned and `clockTolerance: 30s`. Post-check `payload.aud === "https://api.run402.com"` exactly (rejects array-containing). 4. Validate required GitHub claims as non-empty strings: `sub`, `run_id`, `sha`, `actor`, `workflow_ref`, `repository`, `repository_id`, `event_name`. 5. Fetch candidates via the partial index `(project_id, issuer) WHERE revoked_at IS NULL`. Filter in app code by subject-pattern: exact match wins over wildcard, longer prefix wins among wildcards. Equal specificity → 403 `ambiguous_binding`. 6. Enforce `event_name ∈ binding.allowed_events`. Mismatch → 403 `event_not_allowed`. Default `allowed_events` is `['push', 'workflow_dispatch']` — `pull_request` and `pull_request_target` denied unless explicitly opted in. **This catches `pull_request_target` (the dangerous fork-PR variant) which substring-matching `:pull_request` in `sub` would miss.** 7. Enforce `payload.repository_id === binding.github_repository_id` if non-NULL. Mismatch → 403 `repository_id_mismatch`. NULL = soft-bound, accepted on `subject_match` alone (audit-logged distinctly as `ci_token_exchanged_softly_bound`). 8. Atomic conditional UPDATE on the binding (`WHERE id = $1 AND revoked_at IS NULL AND expires_at not stale`). 0 rows → 403 `access_denied` (revoked between SELECT and UPDATE). ### Subject pattern syntax `subject_match` accepts an exact OIDC subject string OR a string ending in a single `*` wildcard (matched as string-prefix, NOT regex). Reject: empty, control chars, multiple `*`, `*` not at the end, bare `*`, length > 256. Characters like `.`, `+`, `[`, `]`, `?` are accepted as literals — they're legal in git refs/tags/environment names. Examples: - Exact: `repo:user/repo:ref:refs/heads/main` — only the `main` branch. - Branch wildcard: `repo:user/repo:ref:refs/heads/*` — any branch. - Tag pattern: `repo:user/repo:ref:refs/tags/v*` — any `v*` tag. - Environment-gated (recommended for prod): `repo:user/repo:environment:production` — only workflows declaring `environment: production` (which can require manual approval in GitHub). **Caveat:** any matching workflow in the repo with `permissions: id-token: write` can attempt token-exchange — not necessarily only the intended deploy workflow. Use environment-gated subject patterns and branch protection to mitigate. `workflow_ref` constraint columns are deferred to a follow-up. ### CI deploy spec restriction (allowlist, not blocklist) When a CI session calls `POST /apply/v1/plans`, the gateway enforces a strict allowlist on the request body: - **Allowed top-level `spec` fields:** `project`, `database`, `functions`, `site`, `assets`, `base`, `routes`. Anything else (including `secrets`, `subdomains`, `checks`, or future `ApplySpec` additions) → 403 `forbidden_spec_field`. - **Forbidden by property presence** (rejected even when empty): `spec.secrets`, `spec.subdomains`, `spec.checks`. So `spec.secrets: {}` rejects. - **`spec.routes` requires delegated route scopes.** Bindings without `route_scopes` reject non-null `spec.routes` with 403 `forbidden_spec_field`. With scopes, the plan step compares the replace-mode route table to the current base table and rejects 403 `CI_ROUTE_SCOPE_DENIED` if any added, removed, or changed route is outside those scopes. Unchanged out-of-scope routes may be re-applied; `spec.routes: null` is allowed as preserve semantics. - **`spec.assets` requires delegated asset key scopes.** Mutations to `assets.put` / `assets.sync` / `assets.delete` reject 403 `CI_ASSET_SCOPE_DENIED` for any key falling outside `asset_key_scopes` on the binding. Scopes are either exact keys or `prefix/*` wildcards. `assets.sync.prune` requires a wildcard scope that fully contains the sync `prefix`. Defaults to NULL (closed) on bindings created via `POST /ci/v1/bindings`; mutate after the fact via `POST /ci/v1/bindings/:binding_id/asset-scopes`. - **`spec.base` restricted** to absent OR exactly `{ release: "current" }`. `{ release: "empty" }` and `{ release_id: ... }` reject (could nuke or pull state from a forbidden release). - **`manifest_ref` rejected** when non-null. CI is limited to inline specs under the 5 MB body cap. Public-repo CLI must do client-side preflight rejection on these fields when in CI mode. SIWE-authed requests bypass the restriction (the wallet has full authority). ### CI plan / operation tagging Plans created via a CI session are tagged with `created_via_ci_binding_id`. CI commit (`POST /apply/v1/plans/:plan_id/commit`) and resume (`POST /apply/v1/operations/:operation_id/resume`) reject HTTP 403 `forbidden_plan` if the plan was not created by the same binding — **same-binding-only**. This blocks the bypass where a CI session would commit a wallet-created plan that contains forbidden fields, AND prevents one CI binding from completing another binding's work. Wallet-authenticated commit/resume bypasses the guard (operator override). ### Error codes specific to CI federation `invalid_token`, `access_denied`, `insufficient_scope`, `event_not_allowed`, `repository_id_mismatch`, `ambiguous_binding`, `forbidden_spec_field`, `CI_ROUTE_SCOPE_DENIED`, `CI_ASSET_SCOPE_DENIED`, `forbidden_plan`, `nonce_replay`, `delegation_statement_mismatch`, `delegation_resource_uri_mismatch`, `signer_mismatch`, `delegation_oversized`, `delegation_parse_failed`, `delegation_signature_invalid`. ### Composition on the seven CI-callable routes The seven routes accept either their existing auth OR a CI session. Mandatory route order on the CI path: **auth helper → ciProjectResolver → lifecycleGate → restrictDeploySpecForCi (plan only) → ciPlanGuard (commit/resume only — runs BEFORE idempotency) → idempotencyMiddleware (commit only) → handler.** | Route | Existing auth | Also accepts CI session | |---|---|---| | `POST /content/v1/plans` | apikey | ✓ | | `POST /content/v1/plans/:plan_id/commit` | apikey | ✓ | | `POST /apply/v1/plans` | SIWX | ✓ (with deploy-spec restriction) | | `POST /apply/v1/plans/:plan_id/commit` | SIWX | ✓ (with plan guard) | | `GET /apply/v1/operations/:operation_id` | apikey | ✓ | | `GET /apply/v1/operations/:operation_id/events` | apikey | ✓ | | `GET /apply/v1/operations/:operation_id/edge-coherence` | apikey | ✓ | | `POST /apply/v1/operations/:operation_id/resume` | SIWX | ✓ (with plan guard) | Two additional READ-only routes accept a CI session for its own bound project — the error-read surface (`GET /projects/v1/:project_id/errors` + `/:fingerprint_id`). These are a deliberate, scoped widening (the CI deploy authority already includes shipping code, so reading the resulting error fingerprints is strictly less power) that make the promote gate keyless. They add NO new `allowed_actions` scope, NO plan guard, and NO deploy-spec restriction — the binding's existing `deploy` scope is required, own-project-only is enforced by the route (`req.project` is the bound project from the recheck SQL), and a revoked binding fails the same per-request recheck (401). Every OTHER route rejects CI session bearers with its normal auth response. ## Managed jobs Managed jobs are run402-owned async compute shapes for workloads that exceed request-time functions. Job types are run402-configured allowlist entries; no public kysigned FFLONK job type is currently registered. Callers submit input and a hard cost cap for a supported `job_type`; run402 owns image selection, instance type, AZ, retries, billing, logs, and artifact storage. Auth: `Authorization: Bearer `. Do not send public image names or resource knobs; unsupported fields are rejected. | Path | Method | Auth | Description | |------|--------|------|-------------| | `/jobs/v1/runs` | POST | service_key + lifecycleGate | Submit a job. Requires `Idempotency-Key`. Accepts an optional `callback_url` (see below). Returns `202` for a new job, `200` for an idempotency hit, `409` when the same key is reused with a different body, `429` for project concurrency/queue quota, and `503 Retry-After` when the platform queue is full. | | `/jobs/v1/runs` | DELETE | service_key + lifecycleGate | Purge all project-scoped managed-job run records. Queued/running jobs are included in the purge; any known EC2 runner instances are terminated before records are deleted. Returns `{deleted_jobs, cancelled_active_jobs, terminated_instances}`. | | `/jobs/v1/runs/:job_id` | GET | service_key | Poll project-scoped job status/result. | | `/jobs/v1/runs/:job_id` | DELETE | service_key + lifecycleGate | Cancel queued/running jobs. Terminal jobs are returned unchanged. Running cancellation terminates the runner instance when known. | | `/jobs/v1/runs/:job_id/logs` | GET | service_key | CloudWatch-backed runner logs. Query: `tail` (default 100, max 1000), `since` (inclusive ISO timestamp; legacy epoch ms also accepted). Returns chronological `{ logs: [...] }`. | | `/jobs/v1/runs/:job_id/artifacts/:filename` | GET | service_key | Download a completed job's recorded artifact. Streams the raw bytes with the artifact's content-type — same Bearer auth as the rest of `/jobs/v1`. `404` if the job isn't completed or the filename wasn't recorded. | Submit body: ```json { "job_type": "example.managed_job.v1", "input": { "input_json": { "envelopeId": "env_123" } }, "max_cost_usd_micros": 50000, "callback_url": "https://hooks.example.com/run402/jobs" } ``` `callback_url` (optional) is an absolute `https://` URL (max 2048 chars; `http://` and malformed URLs are rejected with `400 invalid_job_request`, code `INVALID_CALLBACK_URL`). When set, run402 pushes one durable webhook to it as soon as the job reaches a terminal state (`completed` / `failed` / `cancelled`), so you do not need to poll. Delivery is at-least-once (bounded retries with exponential backoff, then a dead-letter state) and **unsigned**. The body is the canonical webhook envelope: ```json { "id": "job_abc123:terminal", "type": "job_completed", "created_at": "2026-05-17T10:09:38.000Z", "schema_version": "1", "idempotency_key": "job_abc123:terminal", "payload": { "job_id": "job_abc123", "status": "completed", "artifacts": { "result.json": { "url": "https://api.run402.com/jobs/v1/runs/job_abc123/artifacts/result.json", "content_type": "application/json", "sha256": "9b21fa…", "size_bytes": 1234 } } } } ``` `type` is one of `job_completed` / `job_failed` / `job_cancelled`; `payload.artifacts` is present on success and `payload.error` (`{ code, message }`) on failure. The `Run402-Webhook-Id` header equals the `idempotency_key` (`:terminal`). Because delivery is at-least-once, **dedupe on that header and re-fetch authoritative state via `GET /jobs/v1/runs/:job_id` before acting** — the callback is a trigger, not the source of truth. `callback_url` is submit-only and is never returned by the GET endpoint. Status response shape is intentionally compact: ```json { "job_id": "job_abc123", "job_type": "example.managed_job.v1", "status": "running", "created_at": "2026-05-17T10:00:00.000Z", "started_at": "2026-05-17T10:01:12.000Z" } ``` Terminal success includes artifact references and derivation metadata: ```json { "job_id": "job_abc123", "job_type": "example.managed_job.v1", "status": "completed", "created_at": "2026-05-17T10:00:00.000Z", "started_at": "2026-05-17T10:01:12.000Z", "completed_at": "2026-05-17T10:09:38.000Z", "artifacts": { "result.json": { "url": "https://api.run402.com/jobs/v1/runs/job_abc123/artifacts/result.json", "content_type": "application/json", "sha256": "9b21fa…", "size_bytes": 1234 }, "worker.log": { "url": "https://api.run402.com/jobs/v1/runs/job_abc123/artifacts/worker.log", "content_type": "text/plain", "sha256": "c1d4e9…", "size_bytes": 88210 } }, "metadata": { "wall_seconds": 506, "cost_usd_micros": 40600, "raw_cost_usd_micros": 40600, "absorbed_overage_usd_micros": 0, "image_digest": "sha256:abc123...", "spot_rate_usd_hr_micros": 288800, "instance_type": "r5.4xlarge", "az": "us-east-1d", "peak_rss_gb": 61.1, "interrupt_count": 0, "attempt_count": 1, "billing_status": "charged" } } ``` Each artifact entry is `{ url, content_type, sha256?, size_bytes? }`. Download the bytes with `GET ` (equivalently `GET /jobs/v1/runs/:job_id/artifacts/:filename`) using the same `Authorization: Bearer ` as the rest of `/jobs/v1` — it streams the raw artifact, not JSON, with `Cache-Control: private, no-store`. `sha256`/`size_bytes` let you verify integrity before use and are omitted for jobs created before per-artifact capture (the `url` still serves). Statuses: `queued`, `running`, `completed`, `failed`, `cancelled`. Spot interruptions are retried internally; callers observe a later `running` attempt unless retry/fallback would exceed `max_cost_usd_micros`, in which case the job fails with `MAX_COST_EXCEEDED`. Other stable failure codes include `SPOT_INTERRUPTED`, `JOB_TIMEOUT`, `RUNTIME_ERROR`, `RUNNER_LAUNCH_FAILED`, `INPUT_UNAVAILABLE`, and `IMAGE_UNAVAILABLE`. Billing: successful jobs debit the project organization once with ledger kind `managed_job`, idempotency key `managed_job:`, reference type/id `managed_job`/``, and per-attempt derivation metadata. The charge is capped at `max_cost_usd_micros`; any estimation overage above the cap is absorbed by run402 and reported as `absorbed_overage_usd_micros`. ## File storage (content-addressed) **Writes go through `apply`, not a separate upload endpoint.** The `ApplySpec.assets` slice (or the SDK's `r.project(id).assets.*` namespace) is the canonical write path; bytes flow through CAS the same way site files and function source do. Reads, signed URLs, listings, and CDN diagnose stay on `/storage/v1/*` below. ### Writing assets (the apply path) Put the keys in your `ApplySpec`: ```jsonc { "project_id": "prj_...", "assets": { "put": { "logo.svg": { "sha256": "", "size": 1024, "content_type": "image/svg+xml" }, "docs/spec.pdf": { "sha256": "", "size": 92341, "content_type": "application/pdf", "visibility": "private" } }, "delete": ["old-asset.png"], "sync": { "prefix": "static/", "prune": true, "delete_set_digest": "", "base_revision": "" } } } ``` Submit `POST /apply/v1/plans` to get presigned PUT URLs for any missing SHAs (via the same `missing_content[]` and `/content/v1/plans` flow the deploy slices use), upload the bytes, then `POST /apply/v1/plans/:plan_id/commit`. Asset promotion happens in the same activation transaction that flips the release pointer — assets and the site/HTML that references them flip live at the same instant. SDK equivalents (preferred — typed, handles plan/commit/retry for you): - `r.project(id).apply({ assets: {...} })` — explicit slice, mixes freely with `database`/`site`/`functions`/etc. - `r.project(id).assets.put(key, source, opts?)` — single asset; sugar over a 1-item apply. - `r.project(id).assets.putMany(items, opts?)` — isomorphic in-memory batch. - `r.project(id).assets.uploadDir(dir, opts?)` — Node directory walk, additive. - `r.project(id).assets.syncDir(dir, { prune: true, confirm })` — Node directory walk with explicit prune (drift-protected via `base_revision`; HTTP 409 `ASSET_SYNC_DRIFT` if inventory changed between plan and commit). - `r.project(id).assets.prepareDir(dir, opts?)` — returns `{ manifest, applySlice }` with deterministic CDN URLs + SRI hashes pre-commit; lets you render HTML against final URLs and commit HTML + assets atomically. `visibility: "public"` (default) and `immutable: true` (default) together yield a content-addressed `cdn_url` with `Cache-Control: public, max-age=31536000, immutable` and an `sri` value for `