--- title: "Operator" description: "The dashboard's own surface: who you are, what a workspace holds, where to point a client, and the receipts a workspace has accumulated." --- Everything in this router is scoped by control's own membership record (ADR-0033). The browser supplies a session token and a workspace id, and neither is trusted until a membership row has matched them. Role actions are enforced by control and never delegated to the identity provider (ADR-0037). ## Authentication and roles `Authorization: Bearer `, verified as [/api-reference/overview](/api-reference/overview) describes. A missing or malformed header is `401 session required`; an unconfigured identity provider is `503 dashboard identity is not configured`. A caller who is not a member of the workspace gets `404 workspace not found`, never `403` — a distinguishable "exists but forbidden" would be an oracle for workspace ids across tenants. A member whose role does not grant the action gets `403 action not permitted`. A frozen workspace answers `409 workspace is frozen` on any action that is not a read. | Action | viewer | developer | admin | owner | | --- | --- | --- | --- | --- | | `workspace.read` | yes | yes | yes | yes | | `audit.read` | yes | yes | yes | yes | | `usage.read` | yes | yes | yes | yes | | `key.manage` | no | yes | yes | yes | | `policy.draft` | no | yes | yes | yes | | `connection.manage` | no | no | yes | yes | | `policy.publish` | no | no | yes | yes | | `routing.publish` | no | no | yes | yes | | `pricing.write` | no | no | yes | yes | | `approval.decide` | no | no | yes | yes | | `membership.manage` | no | no | no | yes | | `approval.policy.manage` | no | no | no | yes | The three read actions are the only ones a frozen workspace still grants. ## Get the current operator `GET /api/v1/me` The signed-in operator and the workspaces they may act in. This is the one endpoint that writes as a side effect of a read: it provisions a workspace on first sign-in so the dashboard has somewhere to land, and publishes that workspace's first snapshot after the commit. Idempotent — a second call returns the same workspace. `{id, email}`. `{id, org_id, slug, env, frozen, role}` for every membership. The workspace to land on. ```bash Request curl https://control.tryobol.dev/api/v1/me \ -H "Authorization: Bearer $OBOL_SESSION" ``` ```json Response { "user": { "id": "usr_7c1", "email": "ops@acme.test" }, "workspaces": [ { "id": "ws_acme_prod", "org_id": "org_acme", "slug": "acme-prod", "env": "prod", "frozen": false, "role": "admin" } ], "default_workspace_id": "ws_acme_prod" } ``` ## Workspace overview `GET /api/v1/workspaces/{workspace_id}/overview` Counts the first dashboard screen is allowed to state. Every number is one Obol has measured; an empty workspace reports zeroes rather than a projection. `{id, org_id, slug, env, frozen, snapshot_version}`. `{active, total}`. `active` counts keys whose effective status is active right now, which accounts for expiry and revocation. `{total, connections_active}`. Model-provider connectors are excluded. `{published, revision_id, published_at, bundle_hash}`. `{total, last_at}`. ## Client endpoints `GET /api/v1/workspaces/{workspace_id}/mcp` Addresses an operator pastes into an IDE. Addresses only — the key plaintext exists in the browser for exactly one render and is never stored, echoed, or re-served (ADR-0004), so control cannot fill it in and does not pretend to. The gateway base URL. `{gateway_url}/mcp`. `{gateway_url}/v1`. `ob_live` in a prod workspace, `ob_test` otherwise. Also returned: `workspace_id` and `env`. ## List receipts `GET /api/v1/workspaces/{workspace_id}/receipts` The latest revision of each invocation's receipt, newest first. Superseded revisions are filtered out before the limit is applied, so a page is never short because a receipt was revised. 1–200. Opaque cursor, at most 2048 characters. A `v1.`/`v2.` value pages on `(occurred_at, receipt_id)`; anything else is read as a time cursor. Cannot be combined with `before`. Opaque receipt cursor, at most 1024 characters. A legacy RFC3339 timestamp is still accepted as a bound. Newly issued cursors always carry the full timestamp and receipt id pair so ties inside a second can be traversed (ADR-0042). One of `awaiting_approval`, `rate_limited`, `not_ready`, `denied`, `in_flight`, `completed`, `inconclusive`. An unknown value is `400 unknown invocation state` — a filter that quietly does nothing would show a complete-looking list that is not the list you asked for. One of `gateway_observed`, `vendor_signed`, `connector_attested`, `broker_attested`, `untrusted`. Unknown values are `400 unknown evidence trust class`. One of `not_required`, `pending`, `verified`, `contradicted`, `inconclusive`. Unknown values are `400 unknown verification state`. One of `allow`, `deny`, `approval_required`. Unknown values are `422 unknown authorization decision`. Exact tool name, 1–256 characters. Exact virtual key id, 1–64 characters. Exact connection id, 1–64 characters. Start of a UTC window, at most 64 characters. Must be sent with `to`, or the request is `422 from and to must be provided together`. A malformed window is `422 invalid UTC window`. End of the UTC window, exclusive. ### Response List-shaped receipt projections: `receipt_id`, `receipt_revision`, `invocation_id`, `tool`, `key_id`, `connection_id`, `state`, `decision`, `dispatch_state`, `evidence_trust`, `verification_state`, `verification_method`, `effect_claim`, `verification_evidence_trust`, `vendor_http_status`, `upstream_id`, `latency_ms`, `usd_micros`, `attempt_count`, `occurred_at`, `updated_at`. Cursor for the next page, or `null`. The same value, under the older field name. Both are emitted so existing clients keep working. `evidence_trust` names the class of proof, and the class is tier-dependent. A native route can be `gateway_observed`; a federated catalog route is `broker_attested` or `untrusted` and can never be `verified`. Read a receipt as "what this call achieved, and how much its evidence is worth" — see [/receipts/evidence](/receipts/evidence). ```bash Request curl "https://control.tryobol.dev/api/v1/workspaces/ws_acme_prod/receipts?limit=25&state=completed&evidence_trust=gateway_observed" \ -H "Authorization: Bearer $OBOL_SESSION" ``` ```json Response { "receipts": [ { "receipt_id": "rcp_01J8Z0", "receipt_revision": 2, "invocation_id": "inv_01J8Z0", "tool": "github.create_issue", "key_id": "vk_2f8", "connection_id": "conn_9f2c1d", "state": "completed", "decision": "allow", "dispatch_state": "accepted", "evidence_trust": "gateway_observed", "verification_state": "verified", "verification_method": "response_binding", "effect_claim": "persisted", "verification_evidence_trust": "gateway_observed", "vendor_http_status": 201, "upstream_id": "2118", "latency_ms": 412, "usd_micros": 0, "attempt_count": 1, "occurred_at": "2026-09-04T10:15:02Z", "updated_at": "2026-09-04T10:15:03Z" } ], "next_cursor": null, "next_before": null } ``` The remaining closed vocabularies, from `packages/proto/receipt.schema.json`: `dispatch_state` is `not_attempted`, `accepted`, `rejected`, or `unknown`; `verification_method` is `staged_plan`, `none`, or `response_binding`; and `effect_claim` is `accepted`, `persisted`, or `delivered`, or `null` when the envelope carries no verification block. A `202` is `accepted`, never `persisted`. ## Get a receipt `GET /api/v1/workspaces/{workspace_id}/receipts/{receipt_id}` Any receipt id belonging to the invocation. The endpoint resolves the id to its `invocation_id` and then returns the newest revision, so an older id still reaches the current receipt. Minimum `1`. Returns that immutable historical revision instead of the newest one. `404 receipt not found` when the revision does not exist. Returns the list projection plus `idempotency_key`, `request_id`, `policy_id`, `policy_revision_id`, and `envelope`. The envelope ships whole — the detail view has to be able to say what the gateway recorded, including per-candidate routing scores and weights, rather than what control found convenient to model. `404 receipt not found` when the id is not in this workspace. ## List receipt revisions `GET /api/v1/workspaces/{workspace_id}/receipts/{receipt_id}/revisions` Immutable historical revisions of one invocation's receipt, newest revision first. 1–200. Minimum `1`. Returns revisions strictly below this number. List-shaped projections of each historical envelope. Pass as `before_revision` for the next page, or `null` on the last page. ## Adjacent surfaces Two routers sit outside `/api/v1` and are not part of the operator API. ### Health `GET /healthz` Unauthenticated liveness. Returns `{"ok": true}`. Deliberately exempt from the `Cache-Control: no-store` middleware that covers `/api/v1`. ### Internal service reads `GET /internal/v1/workspaces/{workspace_id}/snapshot` and `GET /internal/v1/keys/{key_hash}` are the gateway's own reads. They are authenticated by a service JWT in the `Authorization` header, not a dashboard session, and they are not a public API — treat them as the gateway/control wire contract. - The snapshot read recompiles the committed workspace and policy pair. When it cannot — a busy compile seam, a bundle that no longer validates, a stored route qualifier that will not deserialize — it answers `503 snapshot_unavailable` and the gateway keeps serving its pinned snapshot. Shedding is the point: answering `200` with a body the gateway cannot load would take the tenant dark. - The key read takes a lowercase 64-character SHA-256 hex digest. Anything else is `400 key hash must be sha256 hex`; an unknown digest or a missing workspace is `404`. See [/gateway/overview](/gateway/overview) for how the data plane consumes both.