Saphan StudioDocs
Integrations

Reading the gateway's answers

Per operation: what it answers, how fresh it is, and the field you must read before the numbers.

What GET /v1/gates answers, and what it is not

GET /v1/gates is served by the engine door's first read verb (gates), which calls the owner-queue projection. Two consequences an operator should know before reading the body:

  • The read reads the record. It does not scan the disk, and it does not write. Point the process at the workspace whose queue you want; it does not aggregate across workspaces. This paragraph used to say the opposite — "the projection re-scans <workspace>/worktrees/* on every invocation … so the answer is always fresh — and the 'read' does write registry rows" — and that is the behaviour the owner ruled out on 2026-08-28. What you get instead is stated in full below, because a read whose freshness rule you do not know is worse than a slow one.
  • The body's field names are the record's, not new ones: pending, stream, gate, state, action, command, whose_move, as_of_ts. as_of_ts is the time of the read, never the time any gate was raised — nothing in the record carries an emission time, so any "age" you see elsewhere is computed. Each row's command keeps <decision> and <you> unsubstituted: the engine composes commands and only humans execute them.

Conformance of this body with the signed GET /v1/gates schema is UNVERIFIED on any host without the provisioned contract — see Verifying conformance below. The shape is the projection's, which is the only vocabulary available when the document cannot be read.

A second, differently-named refusal exists for this operation: if the process cannot reach its record (no --workspace, or a store that will not open) it answers 503 with the Problem type urn:saphan:gateway:record-unavailable and Retry-After: 30. That is a configuration problem an operator fixes in one command, as against not-implemented, which waiting and configuring never fix. Do not treat the two as one status.

How fresh a read is, and what stops being visible immediately

A read is fresh to the last write the engine performed. Nothing more, and it is worth being precise about, because the previous rule was "always".

A leg that has just written - state: stop-2-ready into its _EXEC_STATUS.md is not in /v1/gates or on /console yet. The file is on disk; the record has not been told. Refreshing the page does not ingest anything — it never re-reads a channel file, so no amount of reloading will make that stream appear.

Nothing here is a workaround to reach for: an operator who wants the record refreshed should run saphan bus reconcile (or saphan fleet show, or saphan fleet next --stream <slug>), not open a page.

The practical answer to "I hand-edited a channel file; will the console show it?" is no — run saphan bus reconcile against that workspace and it will. ⚠ RETIRED 2026-08-28 (r2 review, F2): both of these lines used to say "run any saphan fleet verb", which sent the operator to a coin flip — 5 of the 7 subverbs ingest nothing.

This is the owner's accepted trade, in the owner's own words (2026-08-28): "the write must go through the binary for now. But not the read. Otherwise this will not work." The read path pays no scan and takes no write lock, which is why it can be served by a process that holds a read-only handle to the record at all.

GET /v1/streams answers ONE PAGE, and you must read nextCursor to know if there is more

The register is bounded. A request that names no limit receives the contract's default page of 50 rows, never the whole fleet — the ceiling is 200, a limit outside [1,200] (or one that is not a number at all) is treated as absent and falls back to 50, and this operation answers no 400, because contract 3.0.1 declares only 200, 401 and 503 on it.

Two fields, and reading only one of them is how an operator concludes the fleet is smaller than it is:

  • total_count is the fleet's size, computed before any page bound. streams.length is this page's size. On any fleet larger than 50 they differ, and only the first one answers "how many streams do I have".

  • nextCursor is null ONLY at the end of the list. A non-null value is an opaque token: store it, hand it straight back as ?cursor=<token>, never parse it and never compose one. It is not /sync's since — that cursor is of a delta, this one is of a page.

  • Paging is not stable, and the walk can silently come up short. The register is ordered by updated_ts descending and the cursor is that order's coordinate. ⚠ AMENDED IN PLACE 2026-08-28. This sentence used to continue "Every read reconciles, so the record genuinely moves while you page it". That clause is now false: GET /v1/streams replays the record and writes nothing, so your own reads no longer move anything under you. The record still moves — an engine write moves it (a gate, a merge, a saphan return, a saphan bus reconcile), and such a write can land between two of your pages. The window is narrower; it is not closed, and the shortfall below is unchanged whenever a write does land: a stream touched between two of your requests takes a newer timestamp and jumps to the front of the order — ahead of a cursor that is still perfectly valid — and is then never served to you. Nothing about the reply says so. nextCursor: null still means end of list, and the response is still correct: it is the right page of the order as it now stands. Measured on a six-stream register at ?limit=3, with one unseen stream touched between the two requests: 5 of 6 streams delivered, and 3 of 3 assertionsnextCursor: null, total_count: 6, no repeated row — hold exactly as the contract promises. The row that moved is simply absent.

    Your only client-side detector is to compare the rows you collected against total_count, and a divergence is not necessarily a defect: a fleet that gained or lost a stream while you walked it diverges the same way. Treat a shortfall as "re-read from the top", never as an error to report. If you need a consistent snapshot, take the whole register in one request (?limit=200, up to the ceiling) rather than walking it.

Paging the whole register by hand, from the loopback listener:

# 1. first page — 50 rows, and the token for the next one
curl -H "$SAPHAN_AUTH" --fail --show-error -s http://127.0.0.1:7656/v1/streams \
  | python3 -c 'import json,sys; d=json.load(sys.stdin); print(len(d["streams"]), "of", d["total_count"], "next:", d["nextCursor"])'

# 2. next page — repeat with the token, until nextCursor is null
curl -H "$SAPHAN_AUTH" --fail --show-error -sG http://127.0.0.1:7656/v1/streams \
  --data-urlencode "cursor=<the nextCursor from step 1>"

Not paging is a silent wrong answer, not an error. A caller that reads the first page and stops gets a well-formed 200 describing 50 of your streams; nothing in that response is malformed, and nothing warns you. total_count is the only field that contradicts it.

GET /v1/ledger answers about MONEY, and three things about it will surprise you

GET /v1/ledger returns one window of cost_ledger, rolled up into buckets, one page at a time. Four query parameters, all optional, all with contract defaults: window (mine·today·7d·30d·all, default mine), groupBy (day·envelope·stream, default day), limit (1–200, default 50) and cursor.

# today, by day
curl -H "$SAPHAN_AUTH" --fail --show-error -s 'http://127.0.0.1:7656/v1/ledger?window=today'

# the last 30 days, by stream, 10 buckets at a time
curl -H "$SAPHAN_AUTH" --fail --show-error -s 'http://127.0.0.1:7656/v1/ledger?window=30d&groupBy=stream&limit=10'

1. total and every amount are the REAL meter — cost_real_usd — and nothing else is added into them. cost_ledger carries two meters. The real one is money somebody is billed for; the virtual one is what a subscription seat would have cost if it were metered, and it is not money. The reply names which one you are reading in its meter field.

So a fleet running entirely on subscription seats answers with every amount at 0.00, and that is correct. The engine's own writer forces cost_real_usd to zero for subscription-covered, local-energy and deterministic rows. Read window_rows beside the total: total: 0.00 with window_rows: 0 means nothing happened; total: 0.00 with window_rows: 412 means 412 acts, all covered by an allowance. Those are different sentences and the total renders them identically. The virtual meter is not published by this operation at all — contract 3.0.1's Ledger schema has no field for a second amount. That is a contract gap, not a missing feature.

2. groupBy=envelope is answered with day, and the reply tells you so. The contract declares three groupings; the record supports two. There is no spend envelope anywhere in the record — no cap column, no quote column, nothing the contract's EnvelopeAlarm (envelope, pct, cap, spent, derivedFrom) could be built from. So the operation serves the default grouping and reports "groupBy": "day" in the reply. Read that field rather than assuming you got what you asked for:

curl -H "$SAPHAN_AUTH" --fail --show-error -s 'http://127.0.0.1:7656/v1/ledger?groupBy=envelope' \
  | python3 -c 'import json,sys; print("served groupBy:", json.load(sys.stdin)["groupBy"])'
# served groupBy: day

This operation answers no 400 — contract 3.0.1 declares only 200, 401 and 503 on it — so every unrecognised window, groupBy, limit or cursor falls back to the contract's own default. That is why the reply echoes window and groupBy: they are the only place a mistyped parameter becomes visible.

3. The cursor is OPAQUE and this process is the only one that can mint one. Store it, hand it back, never build one. A token this process did not issue — hand-composed, edited, or minted by a previous process before a restart — does not verify, and the read answers the first page rather than an error. So a walk interrupted by a gateway restart costs one repeated page and then continues; it does not fail and it does not loop.

# 1. first page
curl -H "$SAPHAN_AUTH" --fail --show-error -s 'http://127.0.0.1:7656/v1/ledger?limit=10' \
  | python3 -c 'import json,sys; d=json.load(sys.stdin); print(len(d["rows"]), "of", d["total_groups"], "next:", d["nextCursor"])'

# 2. repeat with the token until nextCursor is null
curl -H "$SAPHAN_AUTH" --fail --show-error -sG http://127.0.0.1:7656/v1/ledger \
  --data-urlencode "cursor=<the nextCursor from step 1>" --data-urlencode "limit=10"

A walk pins its window. The bounds are resolved once, on the first page, and carried inside the cursor — so from and to are identical on every page of one walk even though window=today names a moving interval. Rows written while you are paging appear in the next walk, not this one. Buckets are ordered by key ascending (chronological for day), and the coordinate is that key, so a bucket whose amount grows mid-walk does not move under you.

When /v1/streams answers 503 and /v1/gates answers 200 on the same workspace

Do not go and look at the database. It opened; that is what makes this confusing.

Symptom. On one process, pointed at one workspace, in the same minute:

curl -H "$SAPHAN_AUTH" -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:7656/v1/streams   # 503
curl -H "$SAPHAN_AUTH" -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:7656/v1/gates     # 200

and the body of the 503 carries urn:saphan:gateway:record-unavailable — the configuration discriminator — while naming a trust root pin and a path under a home directory.

Cause. GET /v1/streams builds a fleet view, and a fleet view reads the signed machine registry. That registry verifies every identity row against the owner's trust root pin, loaded from ~/.saphan/trust/root.key.pub — resolved against the $HOME of the gateway process, not of the person typing curl, and not of the workspace. GET /v1/gates builds the owed-gate queue, which never reads the machine registry, so it is untouched.

Two consequences that explain the shape of the symptom:

  • The pin is only loaded once the registry has at least one machine row. Zero admitted machines short-circuits to a lawful empty answer, so a fresh workspace never sees this — it appears the day the first machine is admitted, which is rarely the day anyone changed the gateway.
  • Because $HOME is the process's, this bites hardest when the gateway runs as a service account: systemd units with no User=/Environment=HOME=, containers, and sudo without -H all hand the process a $HOME that is not the owner's.

Fix, in order:

  1. Find the $HOME the gateway process actually has — not your shell's:
    tr '\0' '\n' < /proc/$(pgrep -f saphan-gateway | head -1)/environ | grep '^HOME='
  2. Check the pin there. doctor is the health check for exactly this material:
    sudo -u <the gateway's user> -H saphan trust doctor
  3. If the owner already has a root key (the normal case — it was minted once, on the owner's machine), copy only the public half into that home. Never the private key, and never mint a second root: a fresh root would not verify a single existing identity row.
    install -o <gateway user> -g <gateway group> -m 0700 -d <that HOME>/.saphan/trust
    install -o <gateway user> -g <gateway group> -m 0644 \
      <owner's>/.saphan/trust/root.key.pub <that HOME>/.saphan/trust/root.key.pub
    If the fleet has no root key at all yet, that is a different act performed once by the owner on the owner's machine — saphan trust init — and not something to do here.
  4. For a systemd unit, pin the home explicitly rather than relying on the default, so the next restart cannot undo step 3:
    [Service]
    User=saphan
    Environment=HOME=/var/lib/saphan
  5. Re-check. /v1/streams should answer 200, and /v1/gates should still answer 200:
    curl -H "$SAPHAN_AUTH" -s -o /dev/null -w 'streams %{http_code}\n' http://127.0.0.1:7656/v1/streams
    curl -H "$SAPHAN_AUTH" -s -o /dev/null -w 'gates   %{http_code}\n' http://127.0.0.1:7656/v1/gates

Note — the class on the wire is wrong, deliberately, for now. This is a provisioning defect: a file this host was never given. The gateway reports it as urn:saphan:gateway:record-unavailable, which says "the record is unavailable" and sends Retry-After: 30 about a condition that waiting never fixes. Reclassifying it needs a discriminator the door does not yet carry, and inventing one here would let a genuinely missing trust root be rendered as "this workspace has admitted no machines" — the exact substitution the fail-closed loader exists to prevent. So the misclassification is carried on purpose and deferred to its own wagon, and this runbook is what stands in for it until then. The refusal text is the part you can trust: it names the missing path and says the machine registry is what asked for it.

GET /v1/fleet/seats — the fleet screen, and the four sentences you must read before its numbers

This operation answers what no other operation on this surface could: who your seats are, how much of each one's session and week is spent, and when each window renews. It is also the most misreadable reply the gateway serves, because every one of its honest absences looks like a blank. Four things, and the last two are the ones that cost real money if you skim them.

1. It never probes. Every number is as old as its own age_seconds says.

saphan fleet usage reads seats by spawning the vendor binary once per seat. This route does nothing of the kind — it reads back what those probes already recorded, so it costs one database query no matter how many seats you have and it can be polled by a dashboard without spending anybody's quota. The price of that is age: a seat's reading.as_of_ts is when the reading was measured, and reading.age_seconds is how long ago that was. A percentage with no age reads as current no matter how old it is, which is why both ride on every row.

To refresh them, run the probe — at a terminal, deliberately, because it costs vendor calls:

saphan fleet usage --workspace /srv/saphan/engine

1a. But an old reading is not served as if it were current — it is SETTLED first, and reading.source tells you whether anything overruled it. Age alone is a number a client can skim past; this is the same judgement made for you, on the reply's own clock, before the state is written. Two rules fire, and neither of their outcomes is free:

reading.sourcewhat happenedwhat else the row carries
storednothing overruled the row — this is the ONLY shape in which a free state means the seat can still be spentthe stored state and percentages, unchanged
stalethe reading aged past the 60-minute staleness ceiling (or past its own named reset instant) with no re-probe since, so it is now state: "unknown"stored_state, settled_reason
run-facta lane-exhausted run started on this seat strictly after the reading was taken, so it is now state: "exhausted"stored_state, settled_reason, run_fact_run_id, run_fact_ts_started

remark: measured on this fleet — a seat once read 0% session and 100% week at the same instant; the binding line is whichever window is SPENT, not the state word. A stale free rendered as spendable is the single failure that costs a whole leg's wall-clock and returns lane-exhausted. remark: stored_state appears ONLY on a row settlement changed, so its presence is itself the signal; a row without it was not overruled.

The settlement is additive, never destructive: stored_state is what the table still holds, so you can see both that the row says free and that this engine refuses to act on it. But the percentages go with the verdict — a settled row publishes used_pct: null for both windows, because an old number cannot survive its own state, and a state: "unknown" row still showing used_pct: 24 reads to every client as a seat with 76% of its session left.

2. A seat is in one of THREE states, and collapsing any two of them gives you a wrong screen. Branch on knowledge first:

knowledgewhat it meanswhat else the row carries
measureda reading existsreading, with its own age
never-measuredthe seat can be read and nobody has ever probed itnever_measured, naming the probe to run
unmeasurableno reading can exist for this seat as it standsunmeasurable, naming which of six refusals applies

remark: never-measured is not a broken seat and unmeasurable is not a broken vendor — the first says the instrument was never pointed here, the second says our instrument does not reach this seat. remark: measured 2026-08-31 on this fleet — 21 seats probed, of which 5 answered unmeasurable for two different reasons, so a screen that renders one sentence for all of them is already wrong here. remark: an ssh machine whose worktrees_root is set but relative is unmeasurable, not never-measured — the root must be ABSOLUTE for a far workdir to be derivable, and a relative one resolves against whatever directory the caller happens to be in. Following a never-measured instruction to probe such a seat gets you a refusal, not a reading.

An unmeasurable row is an honest statement about the instrument, never a slight on a vendor. A fleet routinely runs seats of several backends side by side on purpose, and one whose usage reader has not been written reads usage probe unsupported for backend "qwen-code" — no vendor-native usage reader is registered because nobody has written that reader; the vendor withholds nothing. A seat that reads identity "…"'s seat carries no bound profile home (seated before cast-surface) — re-seat to bind a profile was created before profile homes were bound, and the sentence names the repair.

3. used_pct is ALWAYS SPENT, NEVER REMAINING — and your two backends report opposite polarities. Claude Code prints usage spent (session=24%); Codex prints headroom remaining (headroom=81.0%). The record stores only "used" semantics and records what it translated from, so a Codex seat measured at 81% headroom appears here as used_pct: 19 with reported: "free". A client that renders used_pct as headroom has inverted a seat's capacity, which is how a dispatch lands on the fullest lane in the fleet. reported is the provenance: used was stored as read, free was translated, unknown is a row written before that column existed.

used_pct: null and used_pct: 0 are opposite verdicts: 0 is the freest possible seat and null is a window nobody may plan against. They are never the same bytes here.

4. Four facts are NOT in this reply, they are NAMED in fields_unserved, and the renewal instant is the one that used to bite you. The durable table (seat_usage_reading) has columns for the state, the two percentages, the per-model map, each window's own reset clause since migration 59, and the one first-printed clause it always had. It has no column for the vendor's own text. Everything below follows from that:

fieldwhy it is absentwhat to do
a window's resets_at on a reading written before migration 59that older row carries one clause — whichever the vendor printed first, the session's — and binding it to a window needs the vendor's verbatim text, which is not stored. A reading written SINCE answers both windows from their own columnsre-probe: the table is append-only and this reply reads the newest row per seat, so one probe replaces the answer. No migration is owed
reading.unknown_reason, on a STORED unknown onlythere is no reason column, so an unknown the table already held — a headroom-stale rollout, an unparseable probe — is the same seven bytes either way. An unknown this reply produced itself by settling (§1a) does carry its sentence, in settled_reasonre-probe; the terminal prints the reason. Check reading.source first: only stored is the unexplainable kind
the Codex rate window (primary/secondary)folded into the session/week columns at write time; the name is not storedre-probe
the vendor's own textnever storedre-probe

headroom-stale is NOT headroom-exhausted, and this reply can only tell you one of them. Exhaustion has its own state and is distinguishable here (state: "exhausted"). Staleness — a Codex rollout reading older than its ceiling, on a seat that is otherwise perfectly dispatchable — arrives as state: "unknown", and whether it can say why depends on where the staleness was decided: one this reply settled itself carries source: "stale" and a settled_reason naming the age and the ceiling; one the table already stored that way carries source: "stored" and no reason, because there is nowhere in the row to put one. Treating those seats as dead idles working capacity; treating them as live dispatches onto a lane you have not measured. When a seat reads unknown, re-probe it before you decide anything about it.

Nothing here ever synthesises a date. When a window has no resets_at, it carries reset_unknown_reason, and there are three different ones, which is the whole reason the field exists:

  • the vendor printed none because nothing is spent in this window — a 0%-used session genuinely has nothing to renew. Four of this fleet's eight readable claude seats were in this state on 2026-08-31.
  • the durable reading carries one clause and does not store the vendor's text — the instant was never written down. Re-probe.
  • this reading carries no number for this window at all — there is nothing for an instant to be about.

And one thing that is deliberately NOT here: nine seats. seats is taken from the signed machine registry, machines in service only — never from the identity table. On this fleet the identity table holds 29 subjects while machine list resolves 20 live seats; the extra nine are seats of retired machines and identities never placed on one. They would render as real seats whose probe has not run yet, and no run can ever be dispatched to any of them. seats_on_retired_machines and machines_retired are published so that "we dropped the phantoms" is auditable from the reply instead of asserted here.

GET /v1/sparks answers about YOUR OWN THOUGHTS, and it cannot do four of the things you may ask it

This is the first operation in this gateway whose signed contract asks for more than the record holds, and it is the first that tells you so in the reply rather than in a release note. Nothing below is a bug report: it is what the operation IS on this release, and every sentence of it is readable from the answer itself.

1. Four filters do not narrow anything, and the reply names them. capture_index — the one table that holds a spark — has eleven columns: id · ts · kind · actor · stream · ref · sha256 · sig · blob_ref · ack · ack_ts. There is no state column, no tags column and no text column. So:

you sendwhat happenswhy
window=…appliedcapture_index.ts exists. mine is served as the whole record — see below.
state=capturedapplied, derivedit matches every row, because no row can be anything else.
state=workedNOT appliednothing in this release can write worked.
q=…NOT appliedq searches CONTENT, and content is not in this index.
tag=…NOT appliedthere is no tags column.
since=…NOT appliedsince is a DELTA cursor for a client cache, not a page cursor. Use cursor.
limit · cursorappliedthe page is bounded at 50 by default and 200 at most.

remark: this table is the disposition of a FILTER, never a statement about your data — a row missing from a q= search was not excluded, it was never searched for. remark: measured 2026-08-29 against store/migrate.go migration 13; the day capture_index grows a state or tags column, the two rows that say NOT applied are the ones that go stale first.

2. An unapplied filter returns MORE than you asked for, never less — and that is a decision. Ask for ?state=worked and you get the sparks you would have got with no filter at all, plus filters.state.served: false. The other honest answer was an empty list, and it was rejected: to a client that reads only sparks, an empty list says you have no worked sparks, which is a confident false negative. A wider page says here are your sparks, which is true and merely wider than you asked for. So: check filters_complete (a boolean) or filters_unserved (an array of parameter names) before you render anything as a search result.

3. text is empty on every row, and it is the record's doing, not the door's. The Spark schema requires text, so the key is always present — and its value is always "". A capture stores its sha256 in the index and its bytes in the evidence store (CouchDB), under blob_ref, and this gateway process opens no evidence store: the engine door reads no config by design. Each row carries text_available: false and a text_absence_reason naming which of the two cases it is — never durably stored (ack: false) or stored where this door cannot read it (ack: true). The reply's text_rows is how many rows on this page did carry text; it is 0 on every page in this release, and it is counted rather than asserted.

sha256 is not a checksum of text. It is custody over the ORIGINAL bytes, while the stored form is redacted. The two legitimately differ, and a client that presents one as a proof of the other will eventually accuse the record of corruption.

4. id is the record's id, not the ULID the write side describes. POST /sparks says the client mints a sortable ULID — and POST /sparks is one of the operations this gateway refuses. The ids you read here are capture_index row ids in decimal. They are stable, and they are what GET /sparks/{sparkId} will have to resolve when that operation lands.

5. window=mine is served as all, exactly as /v1/ledger serves it. mine asks whose traffic, which needs an authenticated subject; this process has none, so the honest render is the local operator's own view of their own workspace. The reply's window field says which token was actually served, and from/to are the bounds it resolved to — read those, never the token you sent.

6. Paging is stable, and unlike /v1/streams it cannot skip a row. The cursor's coordinate is the row id, which never changes, and the table is append-only; a spark captured mid-walk takes the highest id and is simply on your next walk rather than lost from this one. The token is opaque and MAC'd: do not parse it and do not compose one. A token this process did not mint — including one from before a restart, and one presented with different filters — does not verify and you are served the FIRST page of the window you asked for. That costs one repeated page after a restart; it is not a loop.

GET /v1/policies counts the registry, and one shelf it cannot count says so

curl -H "$SAPHAN_AUTH" --fail --show-error http://127.0.0.1:7656/v1/policies

Expect 200, a shelves array, a shelvesUnserved array, a shelvesComplete boolean and a nextCursor (a string, or null at the end). Read shelvesComplete beside any count you show a human — that is the contract's own instruction and § 2 below is why.

1. A policy here is a named set of rules that binds machines and seats. The machine sets a ceiling and a seat may narrow it and may never widen it. So a shelf's count is how many rules of that family stand in the record, and the record it is counted over is the signed machine registryidentity_record rows verified against the owner root pin, not the fleet register and not any table the read's own handle holds.

shelfbindsone rule is
confinementmachinesone machine in service whose row carries a believed filesystem-confinement measurement.
seatsseatsone seat seated on a machine in service — identity, slot, role, backend, lane, billing class.
sessionsmachines · seatsone (machine, slot) that carries an explicit concurrent-session ceiling.
toolchainmachinesone (machine, backend) agent-binary registration.
egressmachines · seatsNOT COUNTED — see § 2.

remark: measured 2026-08-29 against registry.Machine / registry.MachineIdentity; a shelf's count is a count of RULES IN THE RECORD, never of hosts, seats or capacity. remark: a retired machine is counted nowhere, and its seats with it — its rules bind nothing; machines_retired is published so that absence is auditable rather than assumed. remark: sessions counts only ceilings somebody wrote. A slot with no configured value reads as the build's default of one live run, and a default nobody wrote is not a rule. remark: confinement counts the believed measurement (Confinement.Effective). A row this build cannot reason about projects as unprobed; when any exist, the shelf's note says how many and tells you to re-probe.

2. Egress is in shelvesUnserved, and an empty shelf would have been the wrong answer. An egress policy in this engine is an authored document, not a registry row: it is compiled from a document, and a run that asks for one (saphan run --egress-policy) is judged by the workspace's single canonical document. ⚠ The design's machine-and-seat intersection — the seat narrowing its machine and never widening it — ships and is called by nothing, so no machine and no seat is assigned a policy today; this paragraph described that intersection as how a run IS judged And the shipped default remains that nothing is assigned at all: a run that does not ask is judged by no policy. None of that is in the machine registry, and this door opens no document and reads no config. So the shelf is published by name, with its reason, and shelvesComplete is false. ⛔ The machine registry's own egress field is not this shelf: it is a measured capability — which rung of the cut a host CAN perform — and counting it would answer how many hosts can cut egress under the name how many egress rules exist. A host that cuts egress perfectly and has been handed no allow-list has a full capability and zero rules.

3. shelvesComplete is computed, never declared, and it is true if and only if shelvesUnserved is empty. It is a fact about the whole catalogue, not about the page you are holding — so is shelvesUnserved — because a client walking to page 2 must not be told every shelf is served just because the unserved one was on page 1.

4. Two different zeros, and registry_present is how you tell them apart. A count of 0 can mean no machine has ever been admitted to this workspace or machines are admitted and none carries a rule of that family. registry_present: false says it is the first, and registry_note says it in words. An absent registry does not make a shelf unserved: unserved means this installation cannot answer, while an absent registry is an answer.

5. rules_total is not how many rules the fleet has. It is the sum of the served shelves; every unserved shelf contributes an unknown number. machines_in_service, machines_retired and seats_in_service are the denominators every count was taken over, published so the arithmetic can be checked without reading the source.

6. This reply is COUNTS ONLY. No machine id, no host, no ssh user or port, no seat name, no config dir, no binary path, no registry path. There is no row grant that could narrow it — the machine registry has no stream column for one to select on. If a future release puts an identifier on this operation, treat that as widening a surface, not as an enrichment.