Client-contract gateway
What the gateway serves, how you sign in, what answers today, and how it refuses.
saphan-gateway is a separate process. It serves the client contract beneath /v1,
operational metrics at /metrics, and a human-viewable console at /console, where every
web screen the inventory declares has its own address. It keeps
no database and no cache of decisions; its only long-lived objects are HTTP lifecycle
objects, the immutable route registry, the embedded UI assets the screen renders from, and —
since 2026-08-31 — an in-memory table of console sessions.
Before you paste any command in this document: every
curlagainst/v1/*needs a session cookie, and it must be sent as an explicit header — the cookie isPath=/console-scoped, so a cookie jar will not send it to/v1. Read § Signing in below first: one login, one header extraction ($SAPHAN_AUTH), and every example here works. Four addresses stay open and need no cookie —GET /,GET·POST /console/login,GET /metricsandGET /v1/capabilities— of which only the last two return anything (§5).
Surface census — product contract 3.1.0 · 41 registered · 9 implemented · 32 refusing.
(The product version and the OpenAPI syntax version both read 3.1.0 at this release. They
are two independent numbers that happen to agree, and only the first one moves with this surface.)
-
GET /v1/gates— the fleet's owed-gate queue: only the streams that owe a human an act, in merge-proximity order. -
GET /v1/streams— the resolved fleet register: every stream that exists, gated or not, terminal or not. A fleet with nothing owed answers the first with an empty queue and the second with its whole register; they are not one question with a flag. -
GET /v1/ledger— the cost ledger, windowed, grouped and paged. The first operation whose source is not the fleet register: it readscost_ledger, not the reconciled channel files. -
GET /v1/overview— the console's startup screen: one number for the owner, three counts for the fleet, one spend series. The first operation that composes — it is the only one of the five whose answer comes from three tables at once (the fleet register,capture_indexandcost_ledger), and it takes no parameters at all: one object, fixed shape, no window, no grouping, no page. -
GET /v1/sparks— the sparks list: one page ofcapture_index, newest first, windowed and paged. It closes the loopGET /v1/overviewopened — the console counted sparks that nobody could list. It is also the first operation whose contract asks for more than the record holds, and the first that says so in the bytes: four of its declared filters (state= worked,q,tag,since) cannot be applied, and every reply carries a per-parameterfiltersobject plusfilters_unservednaming them. Read §GET /v1/sparksbelow before writing a client against it — one of its required fields is always empty and the reason is a fact about the record. -
GET /v1/policies— the policy shelves: how many rules of each named family stand in the signed machine registry, paged. It is the first operation whose source is not a table this process opened at all — every count is taken over root-pin-verifiedidentity_recordrows throughregistry.LoadMachines, and the record handle is only the precondition that a resolved record exists. It is also the second operation that says what it could not answer, and the first to say it per shelf: theEgressshelf is published inshelvesUnservedwith its reason instead of as an empty shelf, becausecount: 0reads as there are no egress policies where the truth is nothing here can tell you. Read §GET /v1/policiesbelow before showing a shelf count —shelvesCompletebelongs next to it on the screen. -
GET /v1/fleet/seats— the fleet's seats and what the record knows about each one's usage: who your seats are, how much of each one's session and week is spent, and when each window renews. It is the first operation whose answer JOINS two sources of different ages — the signed machine registry (which seats exist right now, machines in service only) againstseat_usage_reading(what was last measured about each). It never probes: it spawns no vendor, makes no network request and writes no row, so it can be polled without spending anybody's quota, and the price of that is that every number carries its ownage_seconds. It is the third operation that publishes what it could not answer, and the first to do it per field: four facts the terminal prints are absent from the durable table and are named infields_unservedwith their repairs. Read §GET /v1/fleet/seatsbelow before rendering a seat —used_pctis always SPENT while one of your two backends reports REMAINING, andknowledgedistinguishes three answers a screen must not collapse. -
GET /v1/gates/{gateId}— one owed gate, and the first single-item read on this surface. The{gateId}is thekeyfieldGET /v1/gatesalready publishes on every row (<stream>·<gate>, so it is percent-encoded in a URL:alpha%C2%B7stop2), and no other spelling is accepted — an id taken from the list always resolves, an id composed by hand may not. Byte for byte, including whitespace: the key is not trimmed, so%20alpha%C2%B7stop2is a key no row publishes and answers404. One row, one name. ⚠ It resolves against the owed-gate queue, which is the only projection in this engine that carries a pending gate, so404means no gate with this key is owed right now and not no such gate ever existed: a gate the owner has already decided has left the queue and answers exactly as an unknown key does. Every 200 carries that sentence in its ownnotefield. -
GET /v1/sparks/{sparkId}— one spark, and the operation the console's spark-reader screen had no data for. The{sparkId}is the decimalidthatGET /sparkspublishes on every row, in that exact spelling:01,+1and1are not the string the list prints for row1, so they answer404— and the refusal prints the spelling that works, so a client one character off has the repair in its hand. It is not the ULID the contract's write side mints:POST /sparkshas the client mint a ULID,POST /v1/sparksis not served by this gateway, andcapture_indexhas no column that would hold one — so a ULID handed to this operation is reported absent with that reason spelled out, rather than looked up. It inherits every limit of the list beside it, because it is folded by the same function:textis empty on every row andstatereadscapturedon every row. Both notes travel on every reply (text_note,state_note,id_note) rather than being left in this document, because a client of this operation may never have called the list.
The other 32 refuse with 503 and the Problem type
urn:saphan:gateway:not-implemented. That is deliberate and observable, not a hidden
placeholder:
actcannot yet preservegateRev, authority, hardware signature time, device identity, or biometric evidence through the current engine-door request;capturecannot yet preserve the signedSparkEnvelope, and that envelope contains no authenticated actor for the current capture door;- the admitted
enrollverb appends anidentity_record, whilePOST /v1/enrollmentmust mint a short-lived QR payload.
Calling any of those similarly named functions would report an operation as implemented
while dropping signed contract data. /metrics reports the implemented/not-implemented
counts directly.
Signing in (the human act) It does not any more. /console/* and /v1/* require a console session, and this
chapter is the runbook for getting one — as a human at a browser, and as a shell holding curl.
The credential is the workspace's master-admin credential, the same one
docs/Console (admin) describes and the same one the engine's own /console uses. There is no
second credential, no second session mechanism and no gateway-specific account.
Set the credential, once per workspace
In a shell on the host, in the workspace this gateway will serve:
The verb asks for the password on your terminal, twice, with echo off. It is never typed on a
command line. The minimum length is 12 characters. saphan console admin status names the file
and its mode without printing anything from inside it.
Restart the gateway if it was already running
The gateway reads the credential once, when its router is built. A credential set under a running process is not seen by that process — it goes on refusing until it is restarted. If you set the credential first and start second, there is nothing to do here.
Log in, in a browser
Open the listen address. Any /console address you ask for redirects to the form and remembers
where you were going:
You land on /console/login. Type the user and password from step 1. On success you are put back
on the address you asked for. The page states, in one sentence, which cookie mode the connection
is in — on plain HTTP to loopback the cookie is deliberately not Secure, because a Secure
cookie is discarded by the browser on such a connection and login could not work at all. That
exception is loopback-only and the page says so; off this machine, serve the gateway over HTTPS.
To sign out, return to /console/login — with a live session it shows who you are and a Sign
out button. Sign-out is a POST carrying a per-session token, so no link on any other site can
sign you out.
Log in, from a shell
curl needs a cookie jar to log in. One login, then every read in this document reuses the
session it captured:
⚠ The line above puts the password on a command line, where the host's process table and your shell history can both see it. For anything but a throwaway loopback session, read it from a variable your shell did not record, or use the browser.
The jar alone will not authenticate /v1, and that is deliberate. The session cookie is
issued with Path=/console, so a standards-conforming cookie jar — curl's included — sends it on
/console/* requests and withholds it from /v1/*. That narrow scope is the cookie's browser
defence: on a hostname where a reverse proxy co-hosts anything else, a neighbour under another path
prefix never receives this session. Machine clients are not bound by that scoping, because they set
the header themselves. So read the session out of the jar once and carry it as an explicit
header:
Then every guarded /v1 read in this document carries -H "$SAPHAN_AUTH":
If $SAPHAN_AUTH comes back with an empty value, the login above did not succeed — re-run it
without -o /dev/null and read what it answered. A /v1 read with an empty cookie value gets the
same 401 as no cookie at all, so an empty extraction looks exactly like a rejected session.
⚠ Browser reads of /console/* need none of this: the browser holds the cookie and its path
scoping already matches. This step exists only because a shell client reads /v1.
What is open without a session, and it is exactly four addresses
Four addresses answer with no cookie. Two of them are reads — a scrape and a contract lookup — and two of them are the way in. An operator auditing the unauthenticated surface must count all four; an operator asking what data leaves this process unauthenticated looks only at the first two.
The two that RETURN something without a session:
| address | why it is open |
|---|---|
GET /metrics | operational scrape surface. Route and admission counts only, no record fact, and a scraper holds no cookie. |
GET /v1/capabilities | the signed contract declares security: [] on this operation and on no other — a client must learn where to log in before it can log in. |
The two that are the door itself:
| address | why it is open |
|---|---|
GET / | a redirect to /console, and /console then refuses. It carries no record fact; it is a signpost. |
GET·POST /console/login | the login form. A guard that guarded its own login would be a door with no handle. |
/metrics is open deliberately, so that a Prometheus scraper holding no cookie can read it.
Whether it should stay open is an owner's question and it is parked, not settled here.
When it refuses
| what you see | what it means | what to do |
|---|---|---|
/console/... bounces to /console/login | no session, or a cookie that is absent, expired, forged or signed out | log in (step 3) |
/v1/... answers 401 with "type":"urn:saphan:gateway:no-session" | the same five states, in the shape a machine client can decode | log in (step 4) and repeat with -H "$SAPHAN_AUTH" — a jar's -b will not carry a /console-scoped cookie to /v1 |
every address answers 401/503 naming console admin set | no credential is set for this workspace, or the file is unreadable | saphan console admin status, then step 1, then restart the process |
login answers 429 with Retry-After | five failed attempts from your address inside 15 minutes | wait the stated time; the correct password is locked out too, on purpose |
| login succeeds in the browser but the next page bounces back | the cookie was discarded — you are on plain HTTP and not on loopback | serve the gateway over HTTPS, or reach it from the host itself |
sign-out answers 403 | the form's per-session token was missing or belonged to another session | reload /console/login and use its own button |
⚠ A restart signs everybody out. The session table is in memory, and the key that signs the cookies is minted per process. That is not an oversight — a durable session table needs a store migration — but it is a fact to plan around: a gateway restarted mid-review costs every open browser one login.
⛔ What signing in did NOT change. The listener is still loopback-only; Resolve refuses a
non-loopback listen address and this release does not amend that.
Authentication is not a licence to bind a public interface — that is mTLS's wagon and an
owner-staged decision. And the row scope
is still unrestricted: this surface authenticates exactly one principal, the workspace's
master admin, so its rows were always the whole fleet. The day a second principal can log in here,
that stops being true.
Start, check, and stop
These are the human acts required to operate the process. The examples use a loopback
listener because the process speaks plaintext; terminate TLS in a reverse proxy on the same
machine and forward /v1 to it.
The verbs are the fleet's, and
serveis gone.saphan-gatewayis driven exactly likesaphan serverd:start(foreground by default,--detachto background it),stop,status.serveexisted for one release and now refuses with a message naming its replacement — if a script or a unit file still saysserve, that refusal is where you will find out.
-
Add the optional listen declaration to
/etc/saphan/config. Omit it to use127.0.0.1:7656: -
Start the process in the foreground, naming the workspace absolutely:
Keep it under the service supervisor used on that host.
SIGTERMandSIGINTboth begin a five-second graceful shutdown.To background it without a supervisor, add
--detach. It re-execs itself in a new session with its output redirected to the daemon log, and returns only once the gateway is confirmed to be answering — a failed start reports a non-zero exit with the log's tail, never a success the process did not achieve.One instance per user per machine. The guard is an exclusive-create pidfile under the same daemon path ladder
saphan serverduses, at<RuntimeDir>/saphan-gateway.pid; the log is<LogDir>/saphan-gateway.log. On macOS that is~/Library/Application Support/saphan/runand~/Library/Logs/saphan; on Linux it is$XDG_RUNTIME_DIR/saphanand$XDG_STATE_HOME/saphan/log. A secondstartrefuses and names the live pid rather than binding a second listener.SAPHAN_RUNTIME_DIRandSAPHAN_LOG_DIRoverride either slot.It also publishes the address it actually bound, at
<RuntimeDir>/saphan-gateway.addr— a small JSON object holding the pid, the boundhost:port, the ladder rung that chose it, the workspace and the start time. It is written after the listener is up and removed on a clean exit.statusREADS it (step 3), and a start that cannot write it refuses rather than serving an instance nobody can ask about. -
Ask whether it is running — and note that this answers health, not liveness:
It reads the pidfile and probes the listener. A live pid whose listener does not answer prints
UNHEALTHYand exits non-zero — that combination is a real failure mode (the process holds its pidfile while every client sees a refused connection) and it is exactly what the metering daemon's own founding incident was.The address it probes comes from the record the running instance wrote, never from the listen ladder in your shell. This matters in one very ordinary situation, and until 2026-08-27 it was reported wrong:
Shell B does not have that variable, so re-resolving
--listen→SAPHAN_GATEWAY_LISTEN→[gateway] listen→ default gave it127.0.0.1:7656, it probed that, found nothing, and printedUNHEALTHYabout a gateway that was serving perfectly. It now reportsrunningand adds a `` line naming the address your own environment would have resolved, so a divergence is visible instead of fatal.Three outcomes you will see, and what each means for your hands:
Report Exit What to do running (pid N)0 Nothing. The listenline is the real address; the `` line, if present, is only telling you your shell would have guessed differently.UNHEALTHY — … the address it recorded at start … does not answer1 The process is alive and serving nothing. stop, thenstartagain, and read<LogDir>/saphan-gateway.log.UNKNOWN — … no instance record names the address it bound1 The pid is alive and no address record exists (an instance from before this release, or something deleted the file, or you are asking from a shell whose SAPHAN_RUNTIME_DIR/$HOMEresolves a different runtime directory from the one the instance published into — the report names the record path it looked at, so compare it with the address andlogs at …directory the starting shell printed on itsstarted (pid N, detached)line).statuswill not guess an address for you.stopandstartagain to republish it.Add
--jsonwhen a script reads this instead of a person:One JSON object, same verdict, same exit code. Branch on
healthyfor the yes/no and onstate(running·unhealthy·not-running·stale-pidfile·no-address-record·record-pid-mismatch) for the cause.listenis the instance's real address;resolved_listenis what your shell's ladder produced, andladder_divergesis the comparison already made for you — do not re-implement it, that comparison is where the original defect lived. -
Check the operational census locally:
This release reports
implemented 9,not_implemented 32, and twelve admitted-door candidates. A missing family or a different total is a deployment/build mismatch. -
Read the nine operations that answer, against the workspace you started it for:
Expect
200and a JSON object whosependingarray holds one entry per gate the fleet owes a human. An emptypendingmeans the record was read and nothing is owed — it is not an error. A503withtypeurn:saphan:gateway:record-unavailablemeans this process cannot reach its record; thedetailnames the repair.Expect
200and one object. ReadwaitsForMefirst and read it with its two companions, because this is the number a badge shows and a badge has no room to explain itself:waits_for_me_partsis the same number's two summands —gates(one owed decision per stream, the same rows/v1/gatespublishes) andsparks(captured thoughts). If the badge and the gate screen disagree, this field says which half moved.waits_for_me_completeis a positive claim that both halves are counted in full. It istrueon this release. If you ever readfalse, the number is a floor and not a count, and the next field says which half is short.waits_for_me_notestates what the number counts and — the part that matters — what it deliberately does not: an owner-block element of kindquestionis a move only you can make, and it is not in this number, because the contract's sentence for this field says gates and sparks and has no room for it. Read/console/nextfor those.
contract is captured | worked, and nothing in this release can write worked —
capture_index has no state column and POST /v1/sparks/{sparkId}/work is not implemented. So a thought you
jotted down in June and have long since acted on is still in this number, because the
record was never told. It is not a bug in the count; it is the record having no word for what
you did. The number narrows on its own the day sparks can be marked worked.
streams carries the three counts the contract requires — running, awaitingHuman,
merged — plus other, total and other_note, which are not in the contract and are
there so you can check the arithmetic: the three published counts do not sum to the fleet,
because a stream that was closed without merging, or is planned and never dispatched, or is
broken, is in none of them. running + awaitingHuman + merged + other == total, always.
spend.series is one point per calendar UTC day over the last 30 days, ascending, and it
is not zero-filled: a day with no ledger row has no point, because "nothing happened" and
"things happened and cost nothing" are different facts and only one of them was measured. Use
spend.from / spend.to to draw a continuous axis. spend.meter names which meter the
amounts are in (cost_real_usd) — on a fleet whose seats are all subscription-covered every
amount is a truthful 0, and spend.window_rows is how you tell that from an empty window.
Expect 200, a sparks array newest-first, a nextCursor (a string, or null at the end
of the list) and a total_count. Read filters_complete before you read sparks — see
the section below for why.
Expect 200, a shelves array, a shelvesUnserved array carrying egress with its reason,
and shelvesComplete: false. Read shelvesComplete beside any shelf count you show a
human — see § GET /v1/policies below for why one shelf is named instead of counted, and for
the two different meanings of a 0.
Expect 200, a seats array with one entry per seat on a machine in service, a
fields_unserved array with four entries and fields_complete: false. Read every seat's
knowledge before you read its numbers — see § GET /v1/fleet/seats below, which is the only
place the three answers and the three renewal-instant absences are written down.
Why 503 and not 501
501 Not Implemented is what this condition is, and it is the wrong thing to send. Contract
3.0.1 — the document of the day, and this is the historical measurement that decided the code —
declares no 501 response on any of its 39 operations, so a client generated from that
document has no type for the body and cannot decode the answer at all. 503 is declared on
every operation, so the refusal arrives in a shape the client already understands.
The cost of that choice is one overstatement: 503 says "retry later" about a condition that
waiting does not fix, so the gateway sends Retry-After: 3600 rather than a short delay. The
type field is the discriminator — urn:saphan:gateway:not-implemented never appears on a
real engine outage, so a client can tell "wait for the engine" from "this release does not do
that" without guessing. The end state is a 501 response added to the signed contract; that
is an owner's act on clients/openapi.yaml, and it is owed.
When an id is not on the record (404, and how to tell it from the two 503s)
The two single-item operations — GET /v1/gates/{gateId} and GET /v1/sparks/{sparkId} — are
the first on this surface that can be given something wrong by the caller. Every operation
before them took no path parameter, so every failure they had was about the process. An id
changes that, and it needs its own answer: 404 with the Problem type
urn:saphan:gateway:not-on-the-record, and no Retry-After header, because no amount of
waiting turns a stale id into a live one.
Read the type, not the status code. There are now three refusals on this surface and each is
fixed by a different person:
type | Status | What it means | Who fixes it |
|---|---|---|---|
urn:saphan:gateway:not-implemented | 503 | This release does not serve that operation | nobody today — a wagon ships it |
urn:saphan:gateway:record-unavailable | 503 | This process cannot reach its record | the operator, in one command |
urn:saphan:gateway:not-on-the-record | 404 | The record answered; that id is not in it | the caller, by sending a different id |
The detail is written for the person holding the 404, and it carries the two things that
decide what to do next:
- The denominator.
GET /v1/gates/{gateId}'s absence says how many gates were owed —0 gate(s) are owed and none carries that keyis a different problem from47 gate(s) are owed and none carries that key, and only the second one is your id. - Whether the record could ever have held that id.
GET /v1/sparks/{sparkId}keeps four absences apart, because each one is a different repair: a decimal id the record does not have · a ULID, which means your client is minting the wrong kind of id (POST /sparkshas the client mint one and this gateway does not serve that write, so no ULID has ever reached this record) · a spelling of a real number the list never prints (01,+1,1), whose sentence names the form that resolves · and a number that is out of range for a row id, which is a well-formed decimal integer and is not reported as a malformed one.
Two things a 404 here does NOT tell you, and both are deliberate.
- It is not proof the thing does not exist. A row your row grant excludes and a row that
was never written answer identically, on both operations — and on
GET /v1/sparks/{sparkId}a capture of another kind stored under that id answers identically too, so the 404 does not tell you that something is there. That is the fail-closed shape: the difference between no such thing, not yours and that is a transcript is exactly what an enumeration attack measures, so this gateway does not publish it. Do not read a 404 as an absence proof. - On
GET /v1/gates/{gateId}it is not even about existence. The id is resolved against the owed-gate queue, the only projection in this engine that carries a pending gate, so a gate the owner has already decided has left that queue and answers exactly as an unknown key does. Every200from that operation carries the same sentence in itsnotefield, so a client never has to have read this page.
-
Check one refusing contract route and inspect its named refusal body:
The expected status in this release is
503with aRetry-After: 3600header, and JSON fieldstype(urn:saphan:gateway:not-implemented),title, and adetailnamingGET /v1/capabilities. A 404 means the contract router is not installed. A503whosetypeis not that URN is a real engine problem, not this release's missing adapter. -
Stop it:
It sends
SIGTERMto the pid its own pidfile names — so it cannot stop the wrong process, and it needs no flags. A supervisor's own stop, orSIGTERM/SIGINTto a foreground process, does the same thing; all three begin the same five-second graceful shutdown, and the pidfile is removed on the way out. Do not delete any workspace data: the gateway created none.If
stopsaysnot running (no pidfile)ornot running (stale pidfile, pid N gone), the process is already down — the second message means it did not shut down cleanly and left its pidfile behind, which the nextstarttakes over automatically.
Startup refusals
| What it says | What to do |
|---|---|
--workspace is required | Pass the absolute workspace served by this instance. |
--workspace must be an absolute path | Replace a relative spelling with the full path. |
names a port but no address | Write 127.0.0.1:7656, not :7656. |
is not loopback | Bind the gateway to loopback and terminate public TLS in a local proxy. |
[gateway] unknown key | Correct the key; the only key is listen. |
`serve` is gone | Use start (add --detach to background it), stop or status. The refusal prints all three. |
a live saphan-gateway instance is already running (pid N) | One instance per user per machine. saphan-gateway stop, then start again. |
the daemon path ladder resolved no runtime or log directory | Set $HOME, or set SAPHAN_RUNTIME_DIR and SAPHAN_LOG_DIR explicitly. |
overview.css block … hashes to … (at start) | The extracted mockup CSS was edited by hand. Re-extract it from the mockup, or revert; the process will not serve CSS it cannot attribute. |
the screen inventory declares no web screen … (at start) | A consoleassets/<id>.screen.tmpl was added for an id screens.json does not carry on the web surface. Declare the screen in the inventory, or rename the template; a template is not a licence to render a frame the mockup does not have. |
| instance record … (at start) | The gateway could not publish <RuntimeDir>/saphan-gateway.addr, so status would have nothing to read; it refuses rather than serve unaskable. Check the runtime directory's permissions, or set SAPHAN_RUNTIME_DIR. |
There is no repair command that enables the three candidate verbs. They need reviewed, lossless engine-door contracts first; changing a refusal to success by discarding signed fields is not remediation.