Authorization: scopes, tenancy and denials
Two data classes, the row-level grant a human writes, and one denial shape.
Two data classes, closed
The scope vocabulary is two data classes over the closed contract projection, and the AS never invents scopes of its own — a test asserts the advertised set stays exactly this:
| Scope / data class | Covers |
|---|---|
stream-state | The resolved fleet: stream rows, gates, evidence, phase spans, captures. Never a dollar figure. |
cost-data | Token and dollar figures: ledger totals, the cost column in board artifacts, per-stream rollups. |
Each tool declares the classes its response can surface (the table in section 4);
authorization is requested is a subset of granted. A token with stream-state only cannot call
cost_show — proven at the binary boundary by a conformance cell.
Three scope shapes exist: guest (stream-state only — the least-privilege token),
tenant (chosen classes, plus a row-level slice: the projection is narrowed to the
tenant's named streams before rendering), and the local stdio operator (unrestricted —
via the one named constructor, never a silent default; an absent scope on any other path
denies).
The row-level grant is a human act: --stream
Classes come from the token. Rows do not. Which streams a client's tokens may read is a workspace grant a human writes into the signed binding row, so a token can never widen its own row slice by carrying a bigger claim. The grant is stated when the binding is enrolled:
--stream is required, and it has two legal forms:
| You want | You write |
|---|---|
| this client reads exactly these streams | --stream alpha --stream gamma |
| this client reads the whole fleet | --stream '*' |
There is no default and no third form. Omitting --stream refuses the enrollment by name
rather than creating a binding — because the default that used to exist (an absent grant
read as "everything") is exactly how a token issued for one tenant read every other
tenant's streams. Quote the *: an unquoted asterisk is expanded by your shell.
Re-granting is a new row, never an edit. The bind-file is append-only and latest-wins
per client_id, so widening or narrowing a client's slice means enrolling it again with
the new --stream set; the previous row stays in the audit history. The engine re-reads
the store on every request, so the change takes effect on the very next call — no restart.
When it refuses. A client whose binding names no streams is not an error state — it is
a client granted nothing, and its tokens read zero rows. The symptom is an empty fleet
projection rather than a denial: fleet_show answers 200 with no rows, and stream_detail
on any slug returns the same access-denied shape an unknown slug does (a stream outside
your slice is indistinguishable from one that does not exist — that is deliberate). If a
client that should see streams sees none, read its row in bridge/registry/BINDINGS.md
(the projection saphan identity export regenerates); the streams column is the answer.
Rows granted to the workspace's own surfaces are not affected by any of this: the local stdio operator and the master-admin console session are unrestricted by construction, each through one named constructor, and neither reads a binding.
What a row grant does to your responses — and the boundary that checks them
This subsection is for someone integrating a client against this server for the first time. Everything here is observable from outside: no server source is needed to predict it.
Your rows are narrowed before the response is composed. Under a token whose binding names a set of streams, every tool answers from that set only, and the narrowing is silent by design — nothing in the payload says "some rows were removed". Concretely:
| Tool shape | What you see under a row grant |
|---|---|
List / projection (fleet_show, capture_list, …) | Fewer rows, 200 OK. total_count counts your slice, not the fleet. A grant naming nothing yields zero rows, not an error. |
Single-row lookup (stream_detail, cost_show, capture_show) | A stream outside your grant returns the same access-denied shape as a stream that does not exist. You cannot tell the two apart, and that is intentional. |
Aggregate (spend_window, executor_census, fleet_usage_projection) | Numbers computed over your rows only — including the row count the tool checks its own buckets against, so a narrowed answer still reconciles instead of refusing. Rows the record attributes to no stream at all are not yours: they ride an (unattributed) bucket for an unrestricted reader and are absent for you. |
The boundary that checks the answer. After a tool composes its response and before the
bytes leave, a row-restricted response is decoded again and inspected for stream slugs
outside your grant. If it finds one, the whole response is discarded and you get the
standard access-denied shape with a ref; the operator can recover the cause from that
ref. So an integrator's practical rule is: an access-denied on a call that returned
data yesterday is worth reporting with its ref — it may be this check firing, and it is
never something you can fix from the client side.
How the check looks: five key names, at any depth. It is a match on the JSON key name, not on the value — anywhere in the decoded document, however deeply nested:
A tool may declare additional keys for its own payload where a key genuinely holds a slug
(spend_window does, for the bucket key when you group by stream). That is the whole net.
Its three limits, stated so you do not read it as a guarantee it does not make:
- A key outside those names is not watched.
{"origin":{"name":"…"}}carries a slug inname, andnameis not watched — it also holds machine, model and seat names, so watching it would refuse correct answers. - A slug carried in a value under an unwatched key is not caught. A slug inside a
rendered artifact (the md/csv/html blob
board_exportreturns), inside prose, or inside any non-JSON payload is invisible to this check. It reads keys, not text. - It refuses, it never redacts. There is no partial answer: a response naming an ungranted slug is dropped whole. The corollary matters for anyone designing a client — if a legitimate response happens to carry one of those five key names holding a value that is not in your grant, you get a hard refusal rather than a trimmed result.
Treat this boundary as a backstop, not as the thing that keeps your rows separate. The narrowing in the table above is what does that; the check exists so a mistake in it fails loudly instead of shipping another tenant's slug.
One denial shape, and a cause that never rides the wire
Every denial — scope failure, unknown stream slug, unmapped token — funnels through one function and produces one byte-identical shape:
The real cause is recoverable only from the access log and the ledgered refusal row, both
keyed by ref. A denial's wall-clock envelope is padded to a fixed floor (15 ms), so
timing does not correlate with how far a request got before it was refused. And every
denial is ledgered as data: a zero-cost refusal row carrying tool, cause, ref, tenant — and
the token's actor claim, recorded explicitly as "attribution, not proof".
Design consequence, stated plainly: an unauthorized caller cannot distinguish "this stream does not exist" from "you may not see this stream" from "your token lacks the class". That is the point — existence is data — and the operator, holding the ledger, loses nothing.