Gunnar

nordisk/gunnar-ui

docs/coverage.md

# What the console does with each of gunnar's RPCs

gunnar's control plane is 12 services and 80 RPCs. This table says what the console
does with every one of them, including the ones it deliberately ignores. A row per
RPC is enforced by `crates/gunnar-ui-server/tests/contract.rs`, so an RPC cannot be
added to the vendored protos and quietly go unconsidered.

Three statuses appear:

- **used** — the console calls it.
- **refused by gunnar** — the RPC exists in the protocol and the real backend
  answers `Unimplemented`. The console renders these as disabled panels rather than
  hiding them, because an operator who cannot find a feature in the console will go
  looking for it in gunnar, and the honest answer is that the protocol has it and
  the server does not.
- **not used** / **not surfaced** — a deliberate decision, with the reason. gunnar
  serves it; the console chooses not to call it, or has nowhere to put it yet.

## A row's STATUS is checked, not just its existence

`every_rpc_has_a_row_in_the_coverage_matrix` has always enforced a row per RPC. It
could not tell that a row's *status* had gone stale, and that is exactly what
happened: **seven rows lied for weeks.** All six `Policy.*` rows and `Refs.Diff`
said `refused by gunnar` about RPCs a real gunnar had been serving the whole time.
The console rendered the six as disabled panels, so branch protection existed,
worked, and was reachable from nothing — while a merge already required a verified
signed approval naming the exact commit and a **direct push to the same branch was
refused by nothing at all**.

The rows were not wrong when they were written. `gunnar-server`'s `control_plane.rs`
held a `PROTECTION_GAP` constant saying precisely this, and this file was quoting it
faithfully. Then `gunnar_core::PolicyStore` gave the policy document a home at
`refs/gunnar/policy`, every `Policy.*` verb started being served, and the constant
became dead code that `cargo check` reported as `never used` and nobody read.

So the status is now held to gunnar's own answer, by construction:

- gunnar declares its refusal set once, as
  [`gunnar_grpc::UNIMPLEMENTED_METHODS`](../../gunnar/crates/gunnar-grpc/src/auth.rs);
- `gunnar-cli/tests/policy_mirror.rs::the_only_refusal_left_on_these_two_services_is_the_one_that_is_written_down`
  drives **every** `Policy.*` and `Mirror.*` verb, and `Refs.Diff`, against a real
  server and asserts the set that actually answers `Unimplemented` is exactly that
  list — in both directions, so a refusal that quietly stops refusing is named;
- `every_refused_row_is_a_refusal_gunnar_still_makes` in `contract.rs` reads that
  list out of gunnar's source and asserts the `refused by gunnar` rows here are
  exactly it.

A row that claims a refusal gunnar no longer makes now fails **by name**.

## Repos

| RPC | Status | Where |
|---|---|---|
| `CreateNamespace` | used | `POST /api/namespaces` |
| `ListNamespaces` | used | `GET /api/namespaces` |
| `DeleteNamespace` | used | `DELETE /api/namespaces/{name}` — `?cascade=true` maps to `delete_repos`, and without it gunnar's refusal is the one we want |
| `Create` | used | `POST /api/repos` |
| `Delete` | used | `DELETE /api/repos/{ns}/{name}` |
| `List` | used | `GET /api/repos`. Ignores `page_size`, so the BFF slices and reports "N of M" |
| `Get` | used | `GET /api/repos/{ns}/{name}` |
| `SetDefaultBranch` | used | `PATCH /api/repos/{ns}/{name}` |
| `SetVisibility` | used | `PATCH /api/repos/{ns}/{name}` — the ACCOUNT admin's, re-asked before signing (`api::visibility`) |
| `SetEncrypted` | **not surfaced yet** | The account admin's "encrypted" flag (gunnar refuses plaintext pushes to such a repository). Set with `gunnar control repo set-encrypted` for now; a switch beside visibility on the Settings tab, re-asked before signing like visibility, is the next console step. The console already OPENS encrypted files and diffs in the tab (`browse::SealedFile`, `diff::SealedDiff`) — the reader's security key opens their encryption key in the tab (`Access.GetEncryptionKey`, CRYPT-YUBI), which unwraps the key ring |
| `RecipientKeys` | **not surfaced, deliberately** | A member's SSH public keys, for wrapping an encrypted repository's key ring to them. Wrapping is a member's act in their own clone (`gunnar-cli crypt add-member --from-server`); the console holds no key and wraps nothing |
| `SetNamespaceSettings` | used | `GET`/`PATCH /api/account/namespaces/{ns}/settings` — the account admin's two settings (namespace public, events public; private by default, owner 2026-10-03), re-asked before signing (`api::account_admin`) |
| `Undelete` | used | `POST /api/repos/{ns}/{name}/undelete`, as an Undo button beside every row of the deleted list on Storage. The console's delete is a SOFT delete (`purge: false`, server retention), so every one of those clicks was already undoable — from a control plane on a box whose PID 1 has no shell, which made a reversible action read as an irreversible one to everybody who only has the console |
| `Purge` | **not surfaced, deliberately** | Purge is irreversible. It is an operator's decision at the control plane, not a button beside Delete |
| `ListDeleted` | used | `GET /api/repos/deleted`, and the "Deleted, not yet purged" panel on Storage is drawn from it. `Admin.StorageInfo.total_tombstoned_bytes` is still the Held figure — it is the number the bill reconciles against — but the ROWS come from here, because this is the listing that owns the question, the one that says who deleted each repository, and the one an undo can hang off (one implementation) |
| `ListOrphans` | used | `GET /api/orphans`, and the "Orphan stores" panel on Storage — admin only, since the rows carry paths on the appliance's disk. A store on disk with no catalog row and no tombstone appears in no other listing anywhere, while `Create` refuses its name because the directory is there: an orphan is invisible bytes and invisible bytes are a billing surprise. The panel SHOWS and does not touch — recovering one is `--adopt` or `reclaim` at the control plane, and both take an irreversible decision about objects somebody wrote |
| `ReclaimOrphan` | **not surfaced, deliberately** | Destroys objects. Same reasoning as `Purge` |
| `Gc` | used | `POST /api/repos/{ns}/{name}/gc`, behind the Maintenance panel on the repository page. It ENQUEUES and returns before a gc has run: the server maintains one repository at a time globally and queues the rest, because a gc unlinks the whole exploded read cache — hundreds of thousands of files on a large repository, pure I/O — and ten owners pressing at once is a self-inflicted outage. Pressing twice is one gc; the server hands back the job already queued or running rather than enqueuing a second. Gated at `Write`, not Admin: nothing on the path destroys data that is not re-derivable, and `--prune` is inert. The button says "reclaims storage" and NOT "drops a cache" on purpose — `admin::gc` runs `Engine::maintain` = seal → `store.gc()` → `drop_explode_cache`, and the compaction leg really can reduce `pack_bytes`, which is the billed meter |
| `GcStatus` | used | `GET /api/repos/{ns}/{name}/gc`, polled every 2 s by the same panel while a job is queued or running, and read once on mount so a gc started from the CLI or another operator's console is visible without pressing anything. `null` means the server holds no job for this repository — never asked, or finished long enough ago to be dropped — and the panel renders that as an idle button rather than a permanent zero. Gated at `Read`: seeing that maintenance ran is not asking for it. `bytes_reclaimed` is withheld unless the job is DONE, because the zero-initialised field on a running job would render as "freed nothing" |
| `ArchiveOut` | **not surfaced** | Streams a repository archive cut down the channel. A download button is a real feature and not a small one (it must stream, not buffer); nothing in the BFF does it yet |

## Refs

| RPC | Status | Where |
|---|---|---|
| `List` | used | `GET …/refs`. `prefix` is gunnar's only working filter here and the only way a large reference set stays navigable |
| `Get` | used | `GET …/head`, for `HEAD` and its symbolic target |
| `Log` | used | `GET …/log`, and revision resolution everywhere in phase two — one call turns a branch name into a commit and its tree, which beats reading and parsing the commit object. The one RPC that honours `page_size`; still no cursor, so history stops at the first page |
| `ReadObject` | used | `GET …/object?oid=`, and the whole of phase two. Every directory listing, file view and diff is built from these bytes — they arrive undecorated, with no `"tree <n>\0"` header and no zlib frame. Three outcomes, not two: gunnar refuses over 64 MiB outright, caps one response at 4 MiB, and truncates to `max_bytes` below that |
| `ReadLfsObject` | used | `GET …/raw?path=`, when the blob at that path is a Git LFS POINTER. The tree holds 131 bytes of pointer text where the file should be — gunnar's own `docs/gunnar-fast-setup.pdf` is one — so the raw route reads the pointer, asks for the object by the sha256 it names, and serves the file. Whole object or a refusal: gunnar will not truncate one, because a prefix of a PDF is not a smaller PDF |
| `Blame` | used | `GET …/blame?rev=&path=`, behind the blame page. The same walk `gunnar blame` runs, but against the RUNNING server's open store — so an operator can blame a sealed appliance with no shell. The page colours each line by the commit that wrote it and names the signer |
| `Search` | used | `GET …/search?rev=&q=`, behind the search page. A literal-string scan over the LATEST revision of a repository (never history), the same walk `gunnar search` runs. The page returns the file and line of each hit |
| `Diff` | used | `GET …/diff?from=&to=&path=` (`git::diff::compare`, thin layer 2026-10-04): which paths changed, how, both ids and the line counts are gunnar's answer, asked as the reader. The console only draws the line-level hunks for the first files, from the two blobs (imara-diff) |

## Access

| RPC | Status | Where |
|---|---|---|
| `AddPrincipal` | used | `POST /api/principals`, and `gunnar-ui-server enroll`. The `id` field is sent empty everywhere, which gunnar reads as "the same as `name`" — the account slug every caller got before the field existed |
| `ListPrincipals` | used | `GET /api/principals`. The `name` probe (vendored 2026-09-13) is sent empty everywhere, including in the two by-name lookups: a gunnar older than the field ignores it and answers with the whole table, and a lookup that silently degraded into "the first page holds nobody by that name" would provision a second principal for someone who already has one |
| `RenamePrincipal` | not surfaced | Served, and vendored 2026-09-13 with the id/name split. Changes a principal's DISPLAY NAME and nothing else — the id, and with it the account namespace, every key, grant, membership and gate, stays put. The console has no rename control yet; the principals table renders the name gunnar sends and pins the id, so one would drop in without anything else moving |
| `RemovePrincipal` | used | `DELETE /api/principals/{id}` |
| `AddKey` | used | `POST /api/keys`, and `gunnar-ui-server enroll`. The `attestation` field is sent absent: it must be signed with a key the TARGET principal holds, which lives in their browser or their agent and never in this console. gunnar requires it exactly when the target already holds a signing key and the caller is not the target — first enrolment is unaffected — and refuses with `FAILED_PRECONDITION` otherwise, in words the page shows |
| `RevokeKey` | used | `DELETE /api/keys/{fingerprint}` |
| `ListKeys` | used | `GET /api/keys`. `last_used` is always absent: gunnar never writes it, so the console renders that difference rather than an empty cell |
| `Grant` | used | `POST /api/grants`, and `gunnar-ui-server enroll` |
| `Revoke` | used | `POST /api/grants/revoke` — a POST because the grant is named by three fields, two of which are meaningfully empty |
| `ListGrants` | used | `GET /api/grants` |
| `AddGroupMember` | not surfaced | Served — adds a member to a group, the change carried as the acting admin's `sshsig` and appended to the signed membership log. No group-membership page in the console yet |
| `RemoveGroupMember` | not surfaced | Served — the signed removal, as above. No console surface yet |
| `ListGroupMembers` | not surfaced | Served — a group's direct members. No console surface yet |
| `ListGroupMembershipEvents` | not surfaced | Served — the signed membership log, the evidence behind the live table. No console surface yet |
| `WhoAmI` | used | Every session's console role (`auth::middleware::gunnar_role`, and at login `auth::routes::login_finish`): the person's highest server-wide level, asked AS them with their session key — `Admin` → admin, `Write` → operator, less → viewer |
| `AddCredential` | used | Thin layer L (2026-10-04): a passkey this console registered before L is MOVED to gunnar at its first login (`auth::routes::move_to_gunnar`), the stored ES256 key in SEC1 form on the principal it was pinned to, with the console's key — the authority that registered it |
| `ListCredentials` | used | `GET /auth/passkey/credentials` AS the person (an admin every passkey, anybody else their own); `login/start`'s `allowCredentials` (console key); a paid signup's "already registered?" (console key) |
| `RevokeCredential` | used | `POST /auth/passkey/credentials/revoke` AS the person; gunnar ends the session keys that passkey enrolled and refuses the last admin passkey |
| `RenameCredential` | used | `POST /auth/passkey/credentials/rename` AS the person |
| `GetEncryptionKey` | used | CRYPT-YUBI: `GET /api/account/encryption-key` AS the person — the public half and the unlock methods (ciphertext wraps) the tab opens with a security key |
| `CreateEncryptionKey` | used | `POST /api/account/encryption-key` AS the person: the first security key enrolled in the tab makes the key; gunnar refuses a second |
| `AddUnlockMethod` | used | `POST /api/account/encryption-key/methods` AS the person: one more security key, or a printed recovery code, wrapped in the tab |
| `RemoveUnlockMethod` | used | `DELETE /api/account/encryption-key/methods/{id}` AS the person; gunnar refuses the last one |
| `EnrolmentLink` | used | a paid signup's first passkey (`register/start?signup=`), with the console's own key: gunnar's first-login link onto the account monetize names (first only), redeemed at once through the identity core; the break-glass bearer's invite (`POST /auth/passkey/invites`, it holds no core session). The ceremonies, sessions and invites themselves are the identity core's `nornir.identity.v1.IdentityService` (IDENTITY-HOST): login, step-up, logout, an invite's redemption, a person's invites |
| `MachineChallenge` | used | `GET /auth/machine/enrol/start`, with the console's key (the relay): a machine's creation challenge, consumed by `RequestMachineEnrolment` |
| `RequestMachineEnrolment` | used | `POST /auth/machine/enrol` (after `GET /auth/machine/enrol/start`, gunnar's challenge), anonymously for the asking machine, with the console's key (the relay): an ops kit's creation ceremony, the machine name (`kit-*`) and role it asks; gunnar checks the ceremony and holds the request PENDING with a code — the console decides nothing (2026-10-05) |
| `MachineEnrolmentStatus` | used | `GET /auth/machine/enrol/{id}`, anonymously, with the console's key: the asking machine's poll |
| `ListMachineEnrolments` | used | `GET /auth/machine/requests` AS the person: Administration → Identities' "Machines asking to enrol" (gunnar answers a server admin, refuses anybody else) |
| `ApproveMachineEnrolment` | used | `POST /auth/machine/requests/{id}/approve` AS the person with their fresh user-verified assertion (`GET /auth/machine/step-up`, the person's own passkeys, UV required) — gunnar verifies the step-up, that the approver is a person and an admin, the code and the expiry, and makes the MACHINE principal. The break-glass bearer approves nothing (it is the owner's first invite): refused here, nothing relayed |
| `RefuseMachineEnrolment` | used | `POST /auth/machine/requests/{id}/refuse` AS the person |
| `Check` | used | `GET /api/repos/{ns}/{n}/visibility`, `GET /api/account/namespaces/{ns}/settings` and the mirror panel's `may_tend` (`api::may`): "may I?", asked AS the person — the same decision the verb itself makes (grants, groups, ownership, the gate), so the page draws a control on gunnar's word and the verb, signed as the same person, is decided by gunnar again |

## Policy

**All six of these rows said `refused by gunnar` until 2026-08-31, and all six
were wrong.** gunnar stores the policy document at `refs/gunnar/policy`
(`gunnar_core::PolicyStore`), opens it beside the catalog and the identity table
because it is read on the push path, and serves every verb below off it. The
rows were quoting a `PROTECTION_GAP` constant in `gunnar-server` that had gone
dead — `cargo check` reported it as `never used` — and the console rendered six
disabled panels on their word.

What that cost: a merge has required a verified signed approval naming the exact
commit since `7f605beb`, and a **direct push to the same branch was refused by
nothing at all**, because the surface that would have stopped it was reachable
from neither the console nor `gunnar control`. Branch protection was not
missing. It was unreachable.

Read is `Read` on the repository; the three writes are `Admin` on gunnar's side
("turning the pre-receive gate off for a ref is an ACL act, not a push"), and
`Operator` on the console's ladder — a viewer may see a rule and may not set
one. `uitest`'s `a-protected-branch-refuses-a-direct-force-push` is the row that
makes any of this mean something: it writes a rule through the console, then
drives stock git and requires the push to be REJECTED.

| RPC | Status | Where |
|---|---|---|
| `GetProtection` | used | `GET …/protection`, and the Branch protection panel on the repository page. Each rule renders as what it FORBIDS — `force-push refused`, `delete refused`, `signature required` — never as the proto's `allow_force_push: false` double negative |
| `SetProtection` | used | `POST …/protection`, upserted on `ref_pattern`. A pattern that is not a full ref name is refused before the RPC with the fix in the message: a rule on `main` matches no ref, stores cleanly, lists back, and guards nothing |
| `DeleteProtection` | used | `DELETE …/protection?ref_pattern=…`. The pattern is a query parameter and not a path segment, because `refs/heads/main` has two slashes and never survives axum's `{pattern}` matcher — proved both ways in `api/policy.rs` |
| `GetRefNamePolicy` | used | `GET …/ref-names`, shown with deny-wins stated where it applies |
| `SetRefNamePolicy` | used | `PUT …/ref-names`. `PUT` and not `PATCH`: the document is replaced whole, so a request that omits a list clears it |
| `ListGates` | used | `GET …/gates`. `enabled` means "this repository has configuration for that gate", not "the code is compiled in" — a gate with nothing configured rejects nothing, and the panel says so |

## Events

| RPC | Status | Where |
|---|---|---|
| `Watch` | used | One subscription for the whole process, fanned out at `GET /api/events` as server-sent events. The BFF **resumes**: it keeps the last sequence it forwarded and sends it back as `from_seq`, so gunnar replays what it still holds and reports what it does not as an `EventGap` with a MEASURED `dropped`. It no longer synthesises a gap of its own — that one was arithmetic on two sequence numbers seen either side of a reconnect, and after a restart it produced nothing at all, because the new numbers were lower. The first message of every stream is a `StreamOpened` carrying the run's epoch; when the epoch changes the console shows a `restart` line (no count — nothing buffered those events, so there is no number to report) and re-reads the lists, rather than pretending continuity |

## Admin

| RPC | Status | Where |
|---|---|---|
| `Health` | used | `GET /api/health`, and the badge in the top bar. Its `twins` rows are `GET /api/twin` (operator), the Twin panel on Storage. `grpc_authentication_enabled` is surfaced as a warning: gunnar reports it on the wire so an operator can see the hole |
| `Stats` | used | `GET /api/stats` |
| `StorageInfo` | used | Two routes and two audiences. `GET /api/storage` is the per-repository picture the Storage page draws. `GET /api/machine` reads the **`reality`** field (field 10, additive, 2026-09-13) and nothing else in the message — an **admin** read. Since 2026-10-04 there is no Machine page (`/machine` leads to the Overview): its `glance` lines (fields 18-20, gunnar's own verdicts on `/data`'s shape, the off-stripe copies, the free space and the disk speed) are the Overview's **Machine card**, carried as they came; the layout config the installer wrote (carried UNPARSED, key by key, folded), `/data`'s own shape (`data_shape`, `data_devices`), where `/serialdat` is (`backing_device`/`backing_class`, labelled as `/serialdat`'s) and gunnar's placement table with its off-stripe verdicts are Administration → Storage's **Layout** panel. A gunnar older than field 10 answers `Ok` with `reality` absent, and the panel names that server rather than drawing an empty machine as a finding |
| `Topology` | used | Additive 2026-10-04. The estate's boxes as gunnar knows them — this box (primary or twin), its twins and standbys with their live state, the consoles that call it, the monetize it registers with; one row each with the sentence that says how gunnar knows. `GET /api/machine/topology` carries the rows verbatim and, in `svg`, draws them with `nordisk-estate-svg`, the same renderer monetize's `EstateAdmin.Draw` uses, as the picture on the Overview's **Machine card**. With the installer's estate record (`recorded`, owner 2026-10-04: the installer has the full picture and hands it to gunnar at every install and re-admission) the rows are exactly the laid boxes, each with what was declared (laid as, public/utility address, hostname, size, disks) carried verbatim and drawn on its chip. Admin read (under `/api/machine`); gunnar requires server-wide `Read`. The console decides no box and no state |
| `Capacity` | used | The box line on `/usage/{namespace}` — `This box: N free of M · cannot grow while running` — and the `remaining` figure beside a meter, which is the smaller of what the cap leaves and what the box can serve. Asked with `requested_bytes: 0` and no namespace: the console wants the measurement, not a verdict. Takes a **server-wide** `Read`; a console whose key holds less gets no box line and no box term in `remaining`, and the page says so rather than showing a figure it could not read. Since 2026-09-15 also `GET /api/dataset` — the **Data set** panel on the Storage page: total, used and free of the store root, the member layout (`4 × 2.0G raid0, xfs`, from gunnar's `data_set` words) and the growth answer (sealed / grows at restart / grows while running / grown out of band). An **operator** read, not cached, gunnar's refusal passed through as itself. Since 2026-09-28 also `GET /api/disks` (**admin**, `ADMIN_READS`) asked with `disks: true` — field 3, additive on gunnar branch `ui-404-admin-disks` — for `CapacityResponse.disks` (field 27): every storage mount, every block device and the data set's md members, measured on the box by edda's `disk-snapshot`. The Disks panel on `/admin`. A gunnar older than the field answers the data set alone and the panel says so by name |
| `GrowStart` | **not surfaced** | Additive 2026-09-14 (runtime data-set growth). Drains the box and unmounts `/data`: an admin act that belongs in `gunnar control grow start` with a signed go-ahead, not a button |
| `GrowStatus` | **not surfaced** | The growth's phase. The data-set panel shows the growth ANSWER from `Capacity`; the phase of a grow in progress is `gunnar control grow status` |
| `IssuerInfo` | **not surfaced, and it answers a question only this console is placed to ask** | Additive 2026-09-17 (gunnar `5a8552c7`), vendored here 2026-09-20 — the drift guard had been red in the meantime, so nothing was watching the seam while it was. It reads the issuer certificate and NEVER a key: it reaches the PKI document through `ServerStores::pki_readonly()`, a secret store that refuses every call, so the verb is structurally incapable of minting or rotating. Server-wide `Read`, like `Health` and `StorageInfo`. Why it exists at all: `gunnar issuer show` answers from the DISK and the appliance has no shell, while the only other RPC that ever returned the certificate is `Bootstrap.Enroll`, whose window closes for good on the first enrol. So on a RESTORED box — the one where *"is this still the CA my clients pinned?"* is the question that matters — there was no way to find out. Not wired yet, and that is a gap rather than a decision: the console is the only surface an operator has on a shell-less box, and a restore is exactly when somebody needs this read. What it would cost is one admin GET beside `/api/machine` |
| `GrowResume` | **not surfaced** | As `GrowStart` |
| `GetSetting` | used (`public-site` and `mcp` builds only) | Additive 2026-10-26 (owner decision, item 5). gunnar's GENERIC server setting: bytes under a name the console chooses. A `public-site` console reads `console.public-site`, an `mcp` console `console.mcp`, once at start (`console_setting::adopt`): the stored position of the administrator's switch wins over the environment's initial one. A default build carries no switch and never calls it. Server-wide `Admin` |
| `SetSetting` | used (`public-site` and `mcp` builds only) | Additive 2026-10-26. `PUT /api/public-site` and `PUT /api/mcp` (an administrator only; anyone else 403) store the switch's position here BEFORE the console applies it, so a position the next restart would lose is never shown. gunnar audits each write (its principal, the name, old -> new) and the console audits the person (`console.switch`). Server-wide `Admin` |
| `AuditTrail` | **not surfaced** | Additive 2026-09-21 (lane T11), vendored here 2026-09-28. The audit trail's SHAPE — entry count, tip hash, whether the chain verifies, the hash at a given `at_seq` — never its bodies. Server-wide `Read`. The prefix proof a restored box needs (`hash_at_seq` equal to the primary's last recorded hash) is a restore drill's question, asked by `gunnar control` today; a console panel for it would be one admin GET beside `/api/machine`, and is not built |
| `SetTunnelAddress` | **not surfaced, deliberately** | Additive 2026-09-21 (T17). Server-wide **Admin**: it sets what the WireGuard hub routes to this box, once, and refuses to re-point. A one-time act for `gunnar control`, not a browser button |
| `IssueTwinCertificate` | **not surfaced, deliberately** | Additive 2026-09-21 (lane T9). Server-wide **Admin**: it mints a credential into the replication stream that carries every sealed secret. Part of `gunnar control twin certify`'s three-step pairing; never from a browser |
| ~~`Machine`~~ | **never existed** | The console drew this page against an `Admin.Machine` RPC for a while. gunnar never served it: it answered the same need additively, on `Admin.StorageInfo`, as the `reality` field — see that row. The invented messages (`MachineRequest`, `Disk`, `Partition`, `DataSet`, `SerialdatArea`, `RejectedDisk`, `InstallRecord`, `Mount`, `AreaUsage`, `Placement`) are gone from the vendored proto and from this console, and the structure they described is precisely the parse gunnar declined to make. This row is kept as a headstone, not as coverage; `every_rpc_has_a_row_in_the_coverage_matrix` does not require it and `matrix_rows` does not parse it, because the vendored proto declares no such RPC |
| `VerifyChain` | used | `GET /api/repos/{ns}/{name}/chain` and `GET /api/chain/artifact/{sha256}` — the chain view (`/repo/{ns}/{name}/chain`): line → commit → signature → push attestation → transparency log (→ build record, from an artifact), each link with gunnar's own verdict, and a hole rendered in words |

## Mirror

**Nothing on this service is refused any more, and the console drives all five of
the mirroring verbs.** The row that said `Sync` was `Unimplemented` was true for
months and stopped being true on 2026-09-08 (gunnar `23c5b8a`): the argument was
that fetching runs over `gunnar-client`, which needs `gix`, a `.git` directory and
a worktree, none of which exist on an appliance running as PID 1 over znippy — so
a server-side crate could not link it. `gunnar_wire::fetch_pack` is a fetch client with
none of those, and `Mirror.Sync` is now an **enqueue** on the same one-job-at-a-time
runner `Repos.Gc` and `Accounts.Purge` use.

**gunnar's own `UNIMPLEMENTED_METHODS` still names `Mirror.Sync`.** That constant
has not caught up with its server: it describes the refusal `MemoryControlPlane`
(the in-memory double in `gunnar-grpc/src/seam.rs`) still makes, and the console
does not talk to that plane. `contract.rs` holds this table to what a REAL
`gunnar-server` refuses, with gunnar's own source as the evidence — see
`REFUSALS_GUNNARS_OWN_SOURCE_CONTRADICTS` there for why that exception is narrow
and self-healing rather than a skip.

### What the console does with each verb

The whole flow is three screens: **Connect a forge** (paste a token, or GitHub's
OAuth), **`/mirrors/import`** (the account's repositories, picked), and
**`/mirrors`** plus the panel on each repository page (the jobs, live). Per chosen
repository the import makes exactly three calls in this order — `Add(create_repo:
true)`, then `SetCredential(TOKEN)` when the remote is `https://`, then `Sync` —
and a failure on one repository does not stop the others.

`Sync` answers with a **job**, not a result. `refs_changed` and `updates` on the
response are 0 and empty deliberately, and the console never reads them: it reads
`job.state` and polls `Status` while the job is `QUEUED` or `RUNNING`, exactly as
the Maintenance panel polls `GcStatus`. `has_progress` is read BEFORE the object
pair — `(0, 0)` with `has_progress: false` is "no denominator yet" and is drawn as
such, never as 0%.

| RPC | Status | Where |
|---|---|---|
| `Add` | used | `POST /api/mirrors/import`, once per chosen repository, with `create_repo: true`. **No hash kind is passed and none may be**: gunnar takes it from the remote's advertised object-format, and a console that guessed sha1 would make a sha256 upstream unmirrorable with an error about objects rather than about the guess. Idempotent on the real plane — `mirror_add` does a `set`, so re-importing a repository already mirrored replaces the registration instead of refusing, which is what makes the picker safe to press twice |
| `Sync` | used | `POST /api/mirrors/{ns}/{name}/sync` (the "Sync now" button) and the last leg of every import. It **ENQUEUES**: the response's `job` is the one it created or the one already queued or running for that repository, so pressing twice is one job and the console needs no client-side guard beyond not firing two requests at once. The dedup is the server's, which is the only place it can be — the CLI holds the same credential |
| `Status` | used | `GET /api/mirrors` (every registration, `Status(all)`) and `GET /api/mirrors/{ns}/{name}` (one), polled every 2 s by the panel while the job is `QUEUED` or `RUNNING` and read once on mount so a sync started from the CLI or another operator's console is visible without pressing anything. `bytes` is rendered as the live signal because it is the figure that moves continuously; the object pair is rendered as a milestone. `updates` (ref → old..new) appears only when `DONE`, and `error` verbatim when `FAILED`. Readable by any signed-in role — seeing that a mirror ran is not asking for one |
| `Remove` | used | `DELETE /api/mirrors/{ns}/{name}` — "Remove mirror", with the same typed-name confirmation the account purge uses, checked in the BFF rather than only in the page. It deletes the REGISTRATION and not the repository, and the panel says so |
| `SetCredential` | used | `POST /api/mirrors/{ns}/{name}/credential`. Two callers, one verb: the import hands the connected forge token over ONCE for an `https://` remote (`kind: TOKEN`, `username` per forge — `x-access-token` for GitHub, `oauth2` for GitLab, the account name for Codeberg/Forgejo), and "Forget credential" sends `kind: UNSPECIFIED` with an empty secret. **The secret only ever travels INTO gunnar**: it is never returned by any RPC, and `MirrorConfig.credential` gives the console a kind and nothing else, which is all the panel renders. `ssh://` needs `SSH_KEY` and `https://` needs `TOKEN`; gunnar refuses a mismatch by name and the console shows that refusal verbatim rather than guessing |
| `SetReplication` | **not surfaced** | Configures a standby. NOT refused — the replication half of this service has always been implemented — but the console has no page for the replication topology |
| `RemoveReplication` | **not surfaced** | As above |
| `ReplicationStatus` | **not surfaced, and the one worth wiring next** | `Admin.Stats` already carries `standbys[]` with `last_success_unix_ms`, where **0 means NEVER**. A standby that has never taken a byte is a durability promise that does not exist, and right now nothing a person looks at would say so |
| `ResyncReplication` | **not surfaced** | Backfills a standby that was configured after the repositories were written. An operator-initiated, long-running job with no progress on the wire; `gunnar replication resync` is where it belongs until there is somewhere to watch it |
| `SetRole` | **not surfaced** | Served — sets a node's replication role. No console page for the replication topology yet |
| `DemoteReplication` | **not surfaced** | Served — steps a primary down. No console surface yet |
| `AbdicateReplication` | **not surfaced** | Served — a primary hands the role off. No console surface yet |
| `FlushReplication` | **not surfaced** | Additive 2026-09-14: waits for every standby to drain and answers the watermark. A step inside an operator's grow or reimage sequence, driven from the CLI |
| `LogHead` | used | `GET /api/restore` asks the configured twin (`GUNNAR_UI_TWIN`, signed with the console key) for its head: reachable, first and head seq; `POST /api/restore` asks again and pumps from it (`api::twin::replay`) |
| `LogFetch` | used | `POST /api/restore`: the twin's log fetched in ranges and streamed into the fresh box's `Bootstrap.Restore`; a broken fetch aborts the restore call. Never a browser's stream |
| `SnapshotFetch` | used | `POST /api/restore`, first, when the twin's log starts after 1 — the snapshot it installed goes ahead of its log, as `gunnar control twin replay` does |
| `LogRetain` | **not surfaced** | Additive 2026-09-15: lowers the primary's log retention floor for its twin (`gunnar control twin retain`). An operator verb on the CLI; the console does not offer it |

### The forge half, which is not gunnar's

Listing an account's repositories is not a control-plane RPC and never will be:
git has no verb for "enumerate an account", so the list comes from each forge's
own HTTPS REST API through `gunnar-forge`. Those routes are the BFF's own and
appear in the route table at the end of this file, not here.

## PullRequests

| RPC | Status | Where |
|---|---|---|
| `Create` | used | `POST …/pulls`. Refs must be full `refs/heads/…` names and resolve when the request runs |
| `List` | used | `GET …/pulls?state=` |
| `Get` | used | `GET …/pulls/{number}` |
| `Close` | used | `POST …/pulls/{number}/close` |
| `Merge` | used | `POST …/pulls/{number}/merge`. FAST-FORWARD ONLY — gunnar refuses a non-ff by name; the console shows the refusal verbatim. The ref moves server-side through the same CAS a push uses, and the merge publishes the same `RefUpdated` event. **It also refuses without a verified approval naming the tip** — see `Forge` below |

## Forge

The review half of a pull request: comments and approvals, as `sshsig`-signed
append-only events. `PullRequests` owns the record; this owns what people said
about it, because the value of "somebody approved commit `abc`" is that a third
party can check it without trusting the server.

**This service was not vendored here until 2026-08-31, and the console was
therefore unable to merge anything.** `PullRequests.Merge` refuses unless a
verified approval names the commit the source branch is at right now; the console
offered a Merge button and no way to approve, so every merge from the web answered
"Nobody has approved this pull request."

| RPC | Status | Where |
|---|---|---|
| `Append` | used | `POST …/pulls/{number}/approve`, with the commit in the body. The console signs the event with its own identity key — gunnar has no `Sign` RPC and must not, since a server that could sign an approval could manufacture one |
| `GetReview` | used | `GET …/pulls/{number}/review`. Folded fresh every time, with every approval re-checked against gunnar's key table, so an approval by a key that has since been revoked stops counting the moment the key does |
| `ListReviews` | not used | One page shows one pull request. The list view would need a review per row, and gunnar answers with the whole repository's reviews in one message — a cost the list does not pay for a badge |
| `GetIssue` | used | `GET …/issues/{number}`. An issue folded from its signed events — the opening, the comments, the close — with the same `Append` verifying each. The mutating verbs (`POST …/issues`, `…/issues/{number}/comment`, `…/issues/{number}/close`) all go through `Append`, signed by the console's own key |
| `ListIssues` | used | `GET …/issues`. Every issue in the repository, folded, one row each. Unlike `ListReviews` this IS used: an issue owns its own open/closed state — there is no record behind it — so the list has a state to show and gunnar's whole-repo answer is exactly what a list wants |

**Whose approval it is.** gunnar sees exactly one principal here: the key this
process signs with. The console has no access to a browser user's ssh key and must
not invent one, so an approval made from the console is *the console's*, and the
console writes a second event — a comment — naming which of its own sessions asked
for it. The signature is evidence; that comment is the console's word, and the page
says as much rather than rendering a name it cannot prove. A reviewer who wants
their **own** key on the approval uses the CLI.

## Bootstrap

| RPC | Status | Where |
|---|---|---|
| `Status` | used | `GET /api/bootstrap`. Also `GET`/`POST /api/restore`: whether this box is fresh enough to restore from the twin. The only ungated RPC, so it is the one thing that answers before the console's own identity is enrolled |
| `Enroll` | not used | It works exactly once per server and hands back a private key in the response. That belongs in a terminal with a human watching, not in a browser tab — `gunnar control bootstrap` is the path |
| `GrowStatus` | not used | Additive 2026-09-14: the growth's phase WITHOUT a credential, for while the identity table is off the stripe. The console's data-set panel reads the growth answer from `Admin.Capacity` instead |
| `BecomeTwin` | not used | Additive 2026-09-15. Turns a FRESH box into a twin and answers a public key to enrol on the primary — a once-per-box act for a terminal, like `Enroll` |
| `AssignTunnelAddress` | not used | Additive 2026-09-21 (T17). Unauthenticated while the box is fresh: hands the box its WireGuard tunnel address at bootstrap. A once-per-box act for the installer and `gunnar control`, like `Enroll` |
| `TwinCsr` | not used | Additive 2026-09-21 (lane T9). A twin's replication certificate request, minted on the box. Step one of `gunnar control twin certify`; the console does not pair twins |
| `InstallTwinCertificate` | not used | Additive 2026-09-21 (lane T9). Installs the primary-signed replication certificate on a twin. Step three of `gunnar control twin certify`; not a console act |
| `Restore` | used | `POST /api/restore` (admin, typed confirmation from `GET /api/restore`), offered only when `Status` reads fresh (window open, no principals, no keys) and the twin answers `Mirror.LogHead`. Answers head_seq/applied/lost/restart_required/detail verbatim |

## Billing

**Tickets: gunnar vouching that a caller may act for a namespace.** Metered
only — the open build sells nothing and does not contain the service.

This is the one service on the control plane whose *answer* is meant to leave
the box. A customer carries a ticket to monetize, which checks the appliance's
signature rather than taking this console's word for anything.

It exists because of a gap that is nobody's bug: monetize authenticates one
shared bearer, so `Purchase.Start` cannot tell this console's operator from one
of its tenants, and a lapsed subscription therefore needed an operator to renew
it by hand every time. gunnar is the party that knows who somebody is — it holds
the identity table, and a principal's id *is* the namespace it owns — so gunnar
says so, in something monetize can verify offline.

**Offline, deliberately.** Having monetize call back to the appliance would need
no new key at all, and was rejected: a renewal is what a customer does when the
box is full, suspended or down, and a design that needs the box reachable before
it can be paid for cannot serve the case it was built for.

| RPC | Status | Where |
|---|---|---|
| `MintActorTicket` | used | `GET /api/account/subscription`, `POST /api/account/subscription/renew` — one ticket per call, minted fresh. **Asked for over the TENANT's own key**, the one `tenant.rs` enrolled on their own gunnar principal, never over this console's server-wide admin channel: gunnar authorises it as `Access::namespace(name)` at `Admin`, so the appliance is deciding from the key that signed the call and a routing bug in this crate is refused there rather than trusted here. `api::subscription` has a starred test on the credential's fingerprint, because substituting `state.gunnar` compiles and leaves every other test green |
| `ApplianceIdentity` | **not surfaced** | The public half of the key this box signs tickets with, so an operator can configure their monetize to trust it — the mirror of gunnar's own `--monetize-pubkey`. A public key is not a secret, but it is operator configuration and a tenant has no use for it; it is read at the control plane when a deployment is set up, not from a console page |

## Entitlement

**The tenant's own plan**, as the appliance holds it — the last fact monetize
pushed. Metered only.

Read from gunnar rather than from monetize deliberately. gunnar's
`Entitlement.Get` is namespace-scoped, so the tenant's own key answers it and
the narrowing happens on gunnar's side of the wire, where a mistake in the BFF
cannot reach it. Asking monetize would have meant holding the answer on this
console's word instead.

**`Set` is generated and that is deliberate, not overlooked.** A file is the
unit of vendoring, so vendoring `Get` brings `Set` with it — and the rule this
repository holds is that a client which is not generated is a call that cannot
be typed. What typing this one would buy is nothing: gunnar verifies every
`Entitlement.Set` against MONETIZE's Ed25519 key before it writes anything, and
this console holds no such key and cannot forge one. A fact it invented is
refused at the door.

That asymmetry is exactly why monetize's `ledger.proto` is **not** vendored for
the purchase-history pane: `Ledger.Sales` has no signature to fail on, so
generating it really would hand this binary every tenant's money. History comes
from a tenant-scoped `Purchase.History` instead.

| RPC | Status | Where |
|---|---|---|
| `Get` | used | `GET /api/account/plan` — the caller's OWN namespace and no other, taken from the session's pinned principal. Signed with the tenant's own credential, so gunnar decides. Not gated on whether this monetize can sell: a customer whose renewal path is unavailable still has the right to see that their plan has lapsed, and that is precisely when they most need to |
| `Set` | **not surfaced, deliberately** | Writing a fact is monetize's, over its own signed push. There is no console route and there is no console key that could make one verify |
| `List` | **not surfaced** | Every tenant's plan on the box. A customer must not learn that other customers exist, and an operator reading the fleet has monetize's own console |

## Usage

Per-namespace meters. **Open source and always on** — not behind a feature, a
licence or an edition, because the counting happens inside gunnar whether or not
anything reads it, and a person running gunnar on their own disk wants the same
numbers as anybody else.

The two RPCs differ in the grant they take, and the console deliberately uses
only the narrow one.

| RPC | Status | Where |
|---|---|---|
| `Read` | used | `GET /api/namespaces/{name}/usage` — one namespace, and it takes `Read` **on that namespace**. gunnar's refusal reaches the browser as a 403 in gunnar's own words, carrying no meters. Since 2026-09-05 the answer also carries the namespace's **allowance**: per meter, the cap the box holds for it, absent when there is none — the community appliance holds none for anyone. The page draws it as used / allowed / remaining, with `no cap` where there is none; nothing about it is a price, a plan or a date |
| `Activity` | used | `GET /api/namespaces/{name}/activity` — the FLOW series, one row per UTC day that moved something. Takes `Read` **on the namespace**, the same grant `Read` takes: what a tenant pushed and fetched is that tenant's own business. Every meter `Read` carried before 2026-09-10 was a STOCK meter and read the same for a namespace pushed to this morning and one last touched in March; this is bytes in and out, pushes, fetches, refusals with their reason, and when the namespace was last seen. The Usage page draws the last 30 days as bars |
| `AdmitPlan` | used | `GET /api/namespaces/{name}/admit` — the ADMISSION plan (2026-09-28): what the namespace moved over gunnar's rolling window, its budget and what is left (`null`, never 0, when the box has none), and per kind of work (clone, fetch, push, request) the class the gate would give the next transfer — `Full`, or `Reduced` (the anonymous share) once over budget — with gunnar's own sentence, verbatim. Takes `Read` **on the namespace**, the same grant as `Activity`; a box built without gunnar's `admit-share` answers `pass-through`, every kind `Full` |
| `ReadAll` | not used | It spans every namespace and takes a **server-wide** `Read`. Answering a page about one namespace from it would work on this console's server-wide credential and fail on any narrower one, after handing this process every namespace's figures on the way. `api::usage`'s tests stand a gunnar up that refuses it, so a fallback to it fails the suite rather than passing in production |

## Clans

**Clans** (owner, 2026-10-03/05): ordinary accounts group in a clan with a
namespace of its own; its admin administers what is made in it, and every member
gives the clan part of its OWN room — the clan's room is the sum. You → Clans
(`/account/clans`) calls every verb AS THE PERSON (`api::clans`); gunnar decides
who the clan's admin is, whether an account may join (an enterprise account's
employee may not), whether a contribution fits and how low it may go. The page
draws gunnar's numbers and gunnar's refusals, and decides nothing.

| RPC | Status | Where |
|---|---|---|
| `Create` | used | `POST /api/account/clans` — a free name becomes the clan and its private namespace; the person is its admin and gives `contribution_gib` of their own room |
| `Get` | used | `GET /api/account/clans/{clan}` — to a member or an invited account; to anybody else gunnar answers as for a clan that does not exist |
| `List` | used | `GET /api/account/clans` — the person's clans and invitations, each with gunnar's room, what the clan stores, the person's floor and what their account could still give |
| `Invite` | used | `POST /api/account/clans/{clan}/invite` — the clan admin's |
| `Accept` | used | `POST /api/account/clans/{clan}/accept` — the invited account joins, giving room of its own |
| `Decline` | used | `POST /api/account/clans/{clan}/decline` — declines one's own invitation, or (the admin) withdraws another's |
| `SetContribution` | used | `POST /api/account/clans/{clan}/contribution` — up out of the person's own room, down no lower than what the clan stores |
| `Leave` | used | `POST /api/account/clans/{clan}/leave` — refused while the clan uses the room, and to its last admin |
| `RemoveMember` | used | `POST /api/account/clans/{clan}/remove` — the clan admin's |
| `SetAdmin` | used | `POST /api/account/clans/{clan}/admin` — the clan admin's; the last admin stays one |
| `Dissolve` | used | `POST /api/account/clans/{clan}/dissolve` — the clan admin's, of an EMPTY clan |
| `OfferRepo` | used | `POST /api/account/clans/{clan}/offer` — round 2: the clan admin offers one of the clan's repositories to an account, to move it out (the way to empty a clan before it dissolves) |
| `AnswerRepoOffer` | used | `POST /api/account/clans/{clan}/offers/{repo}` — the offered account's admin takes it (gunnar moves it, everything it holds, if the account's room holds it) or declines; the clan admin withdraws |

## Accounts

**The per-USER verbs**, vendored 2026-09-07. `Repos` reclaims one repository at a
time and its console delete is deliberately SOFT — a tombstone, so one click is
undoable. These are the opposite kind of verb: the unit is a *user*, and a purge
here reclaims tombstones rather than making them.

They are surfaced, where `Repos.Purge` and `Repos.ReclaimOrphan` deliberately are
not, and the difference is reachability. Reclaiming one repository is one CLI call
away, so leaving it at the control plane costs an operator a shell they have. There
was no per-user verb **at all** before this: emptying an account meant a loop over
`repo delete --purge` that nothing serialised, nothing locked the user out during,
and that could not see the two classes of bytes outside the catalog — soft-delete
tombstones and orphan stores. On a box whose PID 1 is gunnar and which has no
shell, "at the control plane" means nowhere a human can reach.

What keeps them safe is not hiding them: `/api/accounts` is an ADMIN surface in
`role_floor`, and the purge refuses unless the account's own name is typed back —
checked in the BFF, because a guard that lived only in the page would protect the
button rather than the verb.

| RPC | Status | Where |
|---|---|---|
| `Gate` | used | `POST /api/accounts/{account}/gate` — locks a user out of **everything**, at every level, on both doors. Not read-only, and there is no option for it: in gunnar a read creates things (a fetch explodes the read cache, a first read of an unindexed pack writes an index), so a permissive read would put bytes back behind a purge. Its own verb and not a flag on `Purge`, because both callers need it alone — an operator freezing an account, monetize locking a non-payer out |
| `Ungate` | used | `DELETE /api/accounts/{account}/gate` — unconditional, so a lease left behind by a purge that died can always be cleared by hand. Ungating an account that is not locked is a success, not a 404 |
| `ListAccountGates` | used | `GET /api/accounts/gates` — who is locked out. Expired purge leases are not in it: a lease that ran out is not a lockout in force. Spelt `ListAccountGates` and not `ListGates` because `Policy.ListGates` has meant a repository's push gates for longer |
| `Purge` | used | `POST /api/accounts/{account}/purge` — every repository, every soft-deleted one still holding bytes, every orphan store, the namespace rows, and optionally the user itself. **No window and no undo.** gunnar takes the lockout before it answers, so the response's `gate` is a fact while its `job` is still a promise; the page shows them in that order |
| `PurgeStatus` | used | `GET /api/accounts/{account}/purge` — the poller behind the same path, and it carries the LOCKOUT beside the job. `done` next to a gate still in force is a different situation from `done` next to an open account, and only one of them needs an operator |
| `EnrollEmployee` | used | `POST /api/account/employees` — the ACCOUNT admin's (owner, 2026-10-03: an enterprise account is one that enrolled employees): gunnar creates the NEW principal bound to the account, the console mints an invite pinned to it (`api::account_admin`) |
| `ListEmployees` | used | `GET /api/account/employees?account=` — the account admin's |
| `ClaimOperator` | used | Additive 2026-10-04 (fix/first-login). The break-glass token's passkey registration calls it (`auth::routes::bind_the_operator`): gunnar answers the estate's operator account — made at install, or made now, or adopted over an ownerless namespace — and the passkey is pinned to it. A gunnar without the verb gets the old binding by name |

## Sso

**Company single sign-on** (owner, 2026-10-05): an account's own identity provider
— OIDC or SAML 2.0 — so a company's people log in with the company's identity
system, and SCIM 2.0 provisioning (the "batch import") beside it. gunnar decides
every identity; the console relays what the browser and the IdP said (thin layer
L), and the account admin configures as the person.

| RPC | Status | Where |
|---|---|---|
| `SetProvider` | used | `PUT /api/account/sso` (`api::sso::write`, as the person): the account admin's. The client secret passes through once and never comes back; the console's own origin is sent as `console_origin` (the redirect URI and the SAML ACS hang off it) |
| `GetProvider` | used | `GET /api/account/sso?account=` — the provider (no secret; SAML certificates as fingerprints), with the addresses to register at the IdP |
| `RemoveProvider` | used | `DELETE /api/account/sso?account=` |
| `CreateScimToken` | used | `POST /api/account/sso/scim-tokens` — the bearer's secret shown once on Administration → Identity provider |
| `ListScimTokens` | used | inside `GET /api/account/sso` |
| `RevokeScimToken` | used | `DELETE /api/account/sso/scim-tokens/{id}?account=` |
| `ListDirectory` | used | inside `GET /api/account/sso` — the people SSO, SCIM and the import made |
| `SetUserActive` | used | `POST /api/account/sso/users/{principal}/active` — deactivate (gated, sessions revoked, data kept) or reactivate |
| `VerifyDomain` | used | `POST /api/account/sso/domains/{domain}/verify` — read the domain's DNS proof (A34-5); only a proven domain routes a sign-in |
| `RebindUsers` | used | `POST /api/account/sso/rebind` — open a rebind after an IdP migration (A34-11): the next sign-in binds the same employee afresh |
| `ImportUsers` | used | `POST /api/account/sso/import` — the CSV import, with a dry run, queued at gunnar as a job (A34-17) |
| `ImportStatus` | used | `GET /api/account/sso/import?account=&job_id=` — the import job as it stands; the people a finished import deactivated are signed out here (A34-9) |
| `Discover` | used | `GET /auth/sso/discover?hint=` (public, rate limited per address) — the login page's "Sign in with your company" |
| `Begin` | used | `GET /auth/sso/start?account=` and `GET /login/sso/<account>` (public): gunnar's redirect; the state bound to the browser by the `gunnar_sso` cookie |
| `Finish` | used | `GET /auth/sso/oidc/callback`, `POST /auth/sso/saml/acs` (public): relayed with a session key minted here; gunnar verifies, names the employee, enrols the key |
| `SecondFactor` | used | `POST /auth/sso/second/start` + `…/finish` (public, the pending cookie): the passkey after the IdP when the account requires one |
| `Scim` | used | `ANY /scim/v2/…` (public; the IdP's bearer decides the account, at gunnar) |

## What phase two adds on top

Three routes, all of them built out of `Refs.Log` and `Refs.ReadObject` and none
of them a new gunnar RPC.

| Route | What it does |
|---|---|
| `GET …/browse?rev=&path=` | One entry point for both directories and files. A link to a path carries no hint about which it is, so making the browser guess would cost a wasted round trip and a refusal in the log every time it guessed wrong |
| `GET …/diff?from=&to=&path=` | Walks both trees and compares them here |
| `GET …/object?oid=` | The raw object, for anything the pages above do not cover |

## The forge half, which is not gunnar's at all

Mirroring starts with a question git cannot answer: **what repositories does this
account have?** The git protocol starts from a URL you already know and has no
concept of an account, so `git ls-remote` against `github.com` is not a shorter
version of this — it is a different question with no answer. Every forge exposes
the list over its own HTTPS REST API and nowhere else, and `gunnar-forge` is the
crate that asks.

These routes therefore reach no gunnar RPC. They are in this file anyway because
the import flow's next step is `Mirror.Add`, and a reader working out where a
mirror came from should not have to guess where the list did.

| Route | What it does |
|---|---|
| `POST /api/forges/connect` | Paste a personal access token. Works on every forge including a self-hosted one, needs no app registration, and is the honest baseline. The token is verified by listing with it and then held **server-side in the BFF, keyed to the session** — never in the browser, never in a cookie, never in a response body, never in a log line |
| `GET /api/forges` | Which forges this session has connected: the kind, the base URL, the account, and how many repositories were listed. **Never the token**, and there is no route that returns one |
| `DELETE /api/forges/{forge}` | Forget the connection. The token is dropped from the BFF's memory |
| `GET /api/forges/{forge}/repos` | The account's repositories, from the held token, normalised to one shape by `gunnar-forge` (`full_name`, both clone URLs, `default_branch`, `private`, `archived`, `size_kib`, `description`). A forge 401 comes back as the forge's own refusal and queues nothing |
| `GET /api/forge/{forge}/authorize` | Start an OAuth authorization-code flow. Registered **only** for a forge whose client id and secret this console was given, so a forge with no app configured has no button rather than a disabled one |
| `GET /api/forge/{forge}/callback` | The redirect back. The `state` is minted here, held server-side against the session and compared on return; a bad or missing one is refused before the code is exchanged. The BFF holds the client secret and does the exchange, so the access token never reaches the browser |

Three properties are worth knowing about, because they are what keep this
affordable:

- **Objects are cached by id and never invalidated.** A git object is immutable
  under its id, so the second person to open a file pays nothing. The cache is
  bounded by bytes, and a *truncated* object is never stored — it would poison
  every later full read of the same id.
- **Identical subtrees are not descended into.** Two trees with the same id have
  the same contents all the way down, which is what makes comparing two
  revisions of a large repository cost a handful of objects rather than all of
  them.
- **Every request has a fetch budget.** Each object is one call spending one of
  gunnar's single-use credentials, from a pool of 8192 per five minutes that the
  whole server shares. Running out is reported, never hidden, and the remaining
  budget is on screen.

## Enterprise

The owner's enterprise list (2026-10-05). gunnar decides every row: who an
account's admin is, whether a step-up is fresh, what a name is, what the
operator may see. Signed AS THE PERSON (`api::enterprise`).

| RPC | Status | Where |
|---|---|---|
| `AuditLog` | used | `GET /api/account/audit` — Administration → Audit log, the account admin's; the operator is refused by gunnar |
| `ExportAuditLog` | used | `GET /api/account/audit/export?format=csv\|json` — the Export buttons; gunnar's bytes and file name, served as an attachment |
| `Aggregates` | used | `GET /api/accounts/aggregates` — Administration → Accounts, the operator's totals |
| `RequestOperatorAccess` | used | `POST /api/accounts/looks` — Administration → Accounts, "Ask to look inside an account"; the answer's code is told to the account admin |
| `ListOperatorAccess` | used | `GET /api/accounts/looks` (the operator's own) and `GET /api/account/operator-access` (the account's, its admin's) |
| `ApproveOperatorAccess` | used | `POST /api/account/operator-access/{id}/approve` — the touch (`GET /api/account/step-up`: gunnar's challenge, the person's passkeys) relayed byte for byte; gunnar verifies it |
| `RefuseOperatorAccess` | used | `POST /api/account/operator-access/{id}/refuse` |
| `RevokeOperatorAccess` | used | `POST /api/account/operator-access/{id}/end` (the account admin) and `POST /api/accounts/looks/{id}/end` (the operator) |
| `CheckName` | not surfaced | A dry run of the name filter. Every door that makes a name asks gunnar already and shows its sentence; a pre-check would be a second answer |
| `GetNameLists` | used | `GET /api/accounts/name-lists` |
| `SetNameLists` | used | `PUT /api/accounts/name-lists` — no form yet: the lists are gunnar's configuration, set as a whole |
| `ListNameReviews` | used | `GET /api/account/names` — the Names panel on the Audit log page |
| `DecideNameReview` | used | `POST /api/account/names/{id}` — Allow / Refuse on that panel |

## Not reachable over the control plane at all

These have no RPC, so the console cannot offer them however much it would like to.
Named here so the absence is a decision rather than an oversight.

| Thing | Where it lives instead |
|---|---|
| Access tokens (`gunnar_pat_…`) | `gunnar token add\|list\|revoke` on the server. Smart-HTTP only |
| Issuer and certificates | `gunnar issuer …` on the server |
| Garbage collection, reflog | `gunnar gc`, `gunnar reflog` |
| Admission limits and their counters | In-process only; `--max-concurrent-transfers` at start |
| Prometheus counters | `--metrics-bind`, unauthenticated and same-origin-hostile, so the console does not proxy it |