RECEIPT_PROCESS_ROLE environment variable — the same build, started five different ways.
Four of those five roles are workers, and they get their work from Resonate: a separate durable-promise broker that Receipt runs alongside the runtime. A process registers a named function with Resonate and then waits; when the runtime wants that function run, it asks Resonate to invoke it, and Resonate keeps the call alive across a restart on either side. The queue itself is still the receipt chain — Resonate only delivers the work. Jobs and durable execution follows one job through it end to end.
The five roles
api is the only role that binds an HTTP port, and the only role for which the runtime enables heartbeats. Every other role is a Resonate worker that listens on no port at all.
When
RECEIPT_PROCESS_ROLE is absent the runtime defaults to api. When it is set to a value that is not one of the five names, it resolves to all, which falls back to the api task group, binds no port and registers no functions.driver, worker-chat, worker-control and worker-codex. Each of them holds one long-lived poll connection open to Resonate — one stream per process, not one per registered function — so an outbound connection that carries no bytes for hours is expected rather than a leak.
Each role polls its own Resonate task group: receipt-api, receipt-driver, receipt-chat, receipt-control and receipt-codex, overridable with RESONATE_GROUP_API, RESONATE_GROUP_DRIVER, RESONATE_GROUP_CHAT, RESONATE_GROUP_CONTROL and RESONATE_GROUP_CODEX. A single RECEIPT_RESONATE_EXECUTE_CONCURRENCY overrides the per-role concurrency defaults. The limit wraps receipt.job.execute, so it only constrains the three worker roles.
The service gateway
A single gateway sits in front of every service and routes by route prefix, so a browser, a CLI or an MCP client only ever needs one origin. Its table comes from a declarative service graph, not from per-deployment configuration:
Longest prefix wins, and
/ is always evaluated last. Prefix stripping rewrites the upstream path, so /runtime/healthz reaches the runtime as /healthz. The connect service deliberately does not strip, so /connect/... arrives at the runtime with its path intact and lands on the runtime’s own /connect/... routes.
A private service answers a bare 404 Not found — plain text, Cache-Control: no-store, no body detail — unless the request is addressed to a loopback host (localhost, 127.0.0.1, ::1), or RECEIPT_SERVICE_GATEWAY_EXPOSURE=private, or RECEIPT_SERVICE_GATEWAY_ALLOW_PRIVATE=1. WebSocket upgrades are proxied for every service in the table except the web app, which is what makes /runtime/factory/live work.
One path escapes the table. The gateway carves out /receipt-debug/* and proxies it to the runtime on its historical public path, so incident tooling can reach the operator diagnostics through the same origin as everything else. Those routes are token-gated in the runtime itself; with neither a debug token nor a debug JWT secret configured they answer 503 with receipt_debug_disabled.
Two consequences worth knowing
Matching is per-prefix, not per-path-segment-with-fallback:- An unmatched extension of a prefix falls through to the web app.
/runtime-extra/healthdoes not match/runtime; it matches/and is served by the web application. The same applies to/integrations-connectivity, which does not match/integrations. - The runtime’s own dashboard sits at a doubled path. The runtime registers an HTML dashboard at
GET /runtime. Because/runtimeis the gateway prefix that gets stripped, that page is at/runtime/runtimethrough the gateway. A bareGET <gateway>/runtimemaps toGET <runtime>/, which has no route, and returns the runtime’s404 Not found.
The call graph
The web application calls the runtime over HTTP:POST /agents/factory/jobs to start work, then POST /jobs/:id/abort, GET /jobs?status=..., the status and event routes under /jobs/:id, and GET /receipt/stream. From there the api role begins a driver RPC, the driver begins a worker RPC, and the worker executes and settles. Workers reach the sandbox controller for computer execution. Every role talks to Postgres. The lanes, leases and statuses that govern that handoff are covered in jobs and durable execution.
Storage
Storage is Postgres and nothing else. There is no SQLite receipt store and no local job backend in the server.- The connection string comes from
ZERO_UPSTREAM_DB. It is the only variable read for it; when it is unset the runtime throwsReceipt Postgres storage requires ZERO_UPSTREAM_DB. RECEIPT_DATA_DIR(orDATA_DIR, which it overrides) is not a directory of receipts. It is a tenant key: the resolved path is hashed into the Postgres schema namereceipt_data_<sha256[0:24]>, unlessRECEIPT_POSTGRES_SCHEMAis set explicitly, in which case that schema is used and the derivation is skipped. The directory itself still holds worker packets, artifacts and logs.- Unqualified statements always run under an explicit
search_path, including when the schema ispublic: service pools pass it as a connection option, the shared receipt pool sets it on each checkout and inside every transaction, and raw Receipt queries name the schema. A role default cannot silently redirect a query. Pool size defaults to 2, overridable withRECEIPT_POSTGRES_POOL_MAX.