gunnar_grpc/seam.rs
1//! **The storage seam: temporary, and marked so.**
2//!
3//! # Why this trait exists at all
4//!
5//! `gunnar-store` (the real `ObjectStore` / `RefStore` of the storage seam) and `gunnar-core`
6//! (the repo model, identity table and ACL) are being written in
7//! parallel with this crate. Blocking on them would have meant writing no
8//! services at all, so the service implementations here are written against
9//! [`ControlPlane`], a small local trait describing exactly what the seven
10//! gRPC services need and nothing more.
11//!
12//! # SEAM: what happens when `gunnar-core` / `gunnar-store` land
13//!
14//! This trait is **not** the intended long-term shape and must not calcify:
15//!
16//! - The repo/namespace/principal/key/grant model belongs to `gunnar-core`.
17//! When it exists, [`ControlPlane`] becomes a thin *adapter* over it (one
18//! impl in this crate) and stops being an independent definition of the
19//! model. It must not become a second source of truth for what a `Repo` is
20//! (one implementation, never a twin).
21//! - The ref/object methods ([`ControlPlane::list_refs`],
22//! [`ControlPlane::log`], [`ControlPlane::read_object`],
23//! [`ControlPlane::diff`]) are exactly the `RefStore`/`ObjectStore` surface
24//! of the storage seam seen from the control plane. They will delegate, not reimplement.
25//! - [`MemoryControlPlane`] is a **test double**, not a backend. It is not the
26//! `mem` backend of the storage seam and must not be mistaken for one.
27//!
28//! # Why it is synchronous
29//!
30//! `ObjectStore` and `RefStore` in the storage seam are synchronous traits, and this one is
31//! shaped to match so the adapter is a straight delegation. That also keeps it
32//! object-safe without pulling in `async-trait`. A backend whose work is long
33//! enough to matter belongs behind `spawn_blocking` at the call site, which is
34//! a decision the real store can make once it knows its own costs.
35
36use std::collections::BTreeMap;
37use std::sync::Mutex;
38
39use crate::auth::Access;
40
41use gunnar_proto::{
42 AddWebhookRequest, BranchProtection, Commit, FileChange, Grant, GrantLevel, HashKind,
43 IssueState, MirrorConfig, MirrorCredentialKind, MirrorState, Namespace, Principal,
44 PrincipalKind, PublicKey, PullRequest, ReadLfsObjectResponse, ReadObjectResponse,
45 RefNamePolicy, RefTarget, ReplicationStatusResponse, ReplicationTarget, Repo, RepoPath,
46 RepoStorage, ResyncReplicationResponse, ReviewState, SignedForgeEvent, SignedKeyAttestation,
47 SignedMembershipEvent, StorageInfoResponse, Visibility, WebhookListResponse,
48};
49
50/// **The two arguments a credential write must agree about**, checked once for
51/// every implementation of the seam.
52///
53/// A `kind` with no `secret`, or a `secret` with no `kind`, are the same
54/// request read two ways — and one of those readings SILENTLY FORGETS a stored
55/// credential. That is the failure this refuses: an operator who meant to set a
56/// token and left the kind unset would otherwise clear the key they already
57/// had, and find out at the next sync.
58///
59/// Free function rather than a default body, because `CoreControlPlane` and the
60/// memory double must apply the same rule and a default body is the one thing
61/// an implementation can silently not call.
62pub fn credential_arguments(kind: MirrorCredentialKind, secret: &str) -> ControlResult<()> {
63 match (kind, secret.is_empty()) {
64 (MirrorCredentialKind::Unspecified, true) => Ok(()),
65 (MirrorCredentialKind::Unspecified, false) => Err(ControlError::InvalidArgument(
66 "a credential value was given with no kind. `kind = UNSPECIFIED` is how a stored \
67 credential is FORGOTTEN, and it must carry no value — otherwise \"set this token\" \
68 and \"forget my key\" are the same request"
69 .to_owned(),
70 )),
71 (_, true) => Err(ControlError::InvalidArgument(format!(
72 "a credential of kind {kind:?} was asked for with an empty value. To forget the \
73 stored credential, send `kind = UNSPECIFIED`"
74 ))),
75 (_, false) => Ok(()),
76 }
77}
78
79/// Everything that can go wrong behind the seam.
80///
81/// Deliberately small: it maps one-to-one onto the gRPC status codes the
82/// services return, in [`crate::status`]: one converter, not a `match` copied
83/// into seven service impls (one implementation).
84#[derive(Debug, thiserror::Error)]
85pub enum ControlError {
86 #[error("{0} not found")]
87 NotFound(String),
88 #[error("{0} already exists")]
89 AlreadyExists(String),
90 #[error("invalid argument: {0}")]
91 InvalidArgument(String),
92 #[error("failed precondition: {0}")]
93 FailedPrecondition(String),
94 /// The request is well formed and would be answered, but answering it would
95 /// cost the server more than it is willing to spend.
96 ///
97 /// Distinct from [`ControlError::InvalidArgument`] on purpose: the caller
98 /// did nothing wrong, and a retry with a narrower request is the right
99 /// response, which is what `RESOURCE_EXHAUSTED` tells a generated client and
100 /// `INVALID_ARGUMENT` does not.
101 #[error("resource exhausted: {0}")]
102 ResourceExhausted(String),
103 /// The seam is not wired to a real store yet. This is the honest answer for
104 /// every ref/object operation until `gunnar-store` lands, and it is the
105 /// value that must NOT quietly turn into an empty successful response. An
106 /// empty ref list is indistinguishable from a working server with no refs.
107 #[error("not implemented: {0}; the storage seam is not wired to gunnar-store yet")]
108 Unimplemented(&'static str),
109 #[error("internal error: {0}")]
110 Internal(String),
111 /// **The state could not be read.** A storage fault, not an answer.
112 ///
113 /// Distinct from [`ControlError::Internal`] because a caller must be able
114 /// to tell "I could not find out" from "I found out and here it is". It
115 /// exists for `Bootstrap.Status` above all: an unreadable identity table
116 /// reported as `window_open: false` would be the most dangerous lie this
117 /// server can tell, and one reported as `window_open: true` would open the
118 /// door on a disk fault.
119 #[error("unavailable: {0}")]
120 Unavailable(String),
121 /// No verified caller. Distinct from [`ControlError::PermissionDenied`] on
122 /// purpose: "I do not know who you are" and "I know who you are and the
123 /// answer is no" are different facts, and collapsing them makes an
124 /// operator's first question unanswerable.
125 #[error("unauthenticated: {0}")]
126 Unauthenticated(String),
127 #[error("permission denied: {0}")]
128 PermissionDenied(String),
129}
130
131pub type ControlResult<T> = Result<T, ControlError>;
132
133/// **A `Mirror.LogFetch` range, ready to stream** ([`ControlPlane::log_fetch_stream`]): run on a
134/// blocking thread, it hands the range to the sink as `LogChunk` pieces and returns once the
135/// range is out (twin-streaming, 2026-10-06).
136pub type LogFetchProducer = Box<
137 dyn FnOnce(&mut dyn FnMut(gunnar_proto::LogChunk) -> ControlResult<()>) -> ControlResult<()>
138 + Send,
139>;
140
141/// The most blob bytes one `Mirror.LogFetch` piece carries: under every client's default 4 MiB
142/// message ceiling with room for the meta.
143pub const LOG_PIECE_BYTES: usize = 1 << 20;
144
145/// One whole entry as `LogChunk` pieces into `sink`: the blob in [`LOG_PIECE_BYTES`] slabs,
146/// `meta` on the first piece, `last` on the last; an empty blob is one piece.
147pub fn log_entry_pieces(
148 e: gunnar_proto::LogEntry,
149 sink: &mut dyn FnMut(gunnar_proto::LogChunk) -> ControlResult<()>,
150) -> ControlResult<()> {
151 log_entry_pieces_from(e, 0, sink)
152}
153
154/// [`log_entry_pieces`] from byte `offset` of the blob on (restore-large-entry, 2026-10-07):
155/// the pieces below it are not sent, every piece says its `offset`, and an offset at the blob's
156/// end is one empty last piece. An offset PAST the end is not this entry as the asker knew it
157/// (an entry erased since): it is sent whole from 0, and the asker sees a length that differs.
158/// No `entry_crc`: a plane holding entries in memory has no stored crc to vouch with.
159pub fn log_entry_pieces_from(
160 e: gunnar_proto::LogEntry,
161 offset: u64,
162 sink: &mut dyn FnMut(gunnar_proto::LogChunk) -> ControlResult<()>,
163) -> ControlResult<()> {
164 let blob_len = e.blob.len() as u64;
165 let start = if offset <= blob_len {
166 offset as usize
167 } else {
168 0
169 };
170 let rest = &e.blob[start..];
171 let n = rest.len().div_ceil(LOG_PIECE_BYTES).max(1);
172 let mut meta = Some(e.meta);
173 for i in 0..n {
174 let piece = &rest
175 [(i * LOG_PIECE_BYTES).min(rest.len())..((i + 1) * LOG_PIECE_BYTES).min(rest.len())];
176 sink(gunnar_proto::LogChunk {
177 seq: e.seq,
178 kind: e.kind,
179 meta: meta.take().unwrap_or_default(),
180 blob_len,
181 blob: piece.to_vec(),
182 last: i + 1 == n,
183 restore_head: 0,
184 restore_bytes: 0,
185 log_id: String::new(),
186 offset: Some((start + (i * LOG_PIECE_BYTES).min(rest.len())) as u64),
187 entry_crc: None,
188 snapshot_item: None,
189 snapshot_id: String::new(),
190 })?;
191 }
192 Ok(())
193}
194
195/// **One `Bootstrap.Restore` stream, taken a chunk at a time** (restore-large-entry,
196/// 2026-10-07) — what the handler feeds every chunk of the stream into, so where an entry's
197/// bytes live while it arrives is the plane's business and not the wire's.
198///
199/// The server's intake stages an entry larger than one piece on the box's disk as it arrives
200/// and keeps what it holds when the stream ends inside it, so the next stream resumes the entry
201/// at the byte it reached (`Bootstrap.Status.restore_partial_*`, `LogFetchRequest.from_offset`);
202/// the default one ([`ControlPlane::restore_intake`]) gathers each entry in memory, as every
203/// restore did before.
204pub trait RestoreIntake: Send {
205 /// One chunk: a piece of an entry (`seq` > 0) or of a snapshot (`seq` 0). An entry is
206 /// applied when its last piece arrives whole.
207 fn take(&mut self, chunk: &gunnar_proto::LogChunk) -> ControlResult<()>;
208 /// The stream ended cleanly. `Err` when it ended inside an entry — naming what this box
209 /// holds of it and where the next stream resumes.
210 fn end_of_stream(&mut self) -> ControlResult<()>;
211 /// `(applied, lost)` by this stream: entries applied, and replayed CAS claims that lost.
212 fn counts(&self) -> (u64, u64);
213}
214
215/// The intake every plane had before: each entry gathered whole in memory, then
216/// [`ControlPlane::restore_apply`]. An entry the stream ends inside is lost with the stream.
217struct GatherIntake<'a, P: ?Sized> {
218 plane: &'a P,
219 next: u64,
220 open: Option<gunnar_proto::LogEntry>,
221 want_len: u64,
222 applied: u64,
223 lost: u64,
224}
225
226impl<P: ControlPlane + ?Sized> RestoreIntake for GatherIntake<'_, P> {
227 fn take(&mut self, chunk: &gunnar_proto::LogChunk) -> ControlResult<()> {
228 // A snapshot piece (seq 0): staged by the plane, its terminator installs it.
229 if chunk.seq == 0 {
230 if let Some(e) = &self.open {
231 return Err(ControlError::InvalidArgument(format!(
232 "a snapshot piece arrived with seq {} unfinished",
233 e.seq
234 )));
235 }
236 if let Some(at) = self.plane.restore_snapshot_chunk(chunk)? {
237 self.next = at + 1;
238 }
239 return Ok(());
240 }
241 let entry = match self.open.as_mut() {
242 Some(e) if e.seq == chunk.seq => e,
243 Some(e) => {
244 return Err(ControlError::InvalidArgument(format!(
245 "seq {} arrived with seq {} unfinished",
246 chunk.seq, e.seq
247 )))
248 }
249 None => {
250 self.want_len = chunk.blob_len;
251 self.open.insert(gunnar_proto::LogEntry {
252 seq: chunk.seq,
253 kind: chunk.kind,
254 meta: chunk.meta.clone(),
255 blob: Vec::with_capacity(gunnar_proto::reserve_for(chunk.blob_len)),
256 })
257 }
258 };
259 entry.blob.extend_from_slice(&chunk.blob);
260 if !chunk.last {
261 return Ok(());
262 }
263 let done = self.open.take().expect("open entry");
264 if done.blob.len() as u64 != self.want_len {
265 return Err(ControlError::InvalidArgument(format!(
266 "seq {}: {} of {} blob bytes arrived",
267 done.seq,
268 done.blob.len(),
269 self.want_len
270 )));
271 }
272 if done.seq < self.next {
273 return Ok(());
274 }
275 if done.seq > self.next {
276 return Err(ControlError::FailedPrecondition(format!(
277 "seq {} arrived where {} was due; resend from {}",
278 done.seq, self.next, self.next
279 )));
280 }
281 if self.plane.restore_apply(done)? {
282 self.lost += 1;
283 }
284 self.applied += 1;
285 self.next += 1;
286 Ok(())
287 }
288
289 fn end_of_stream(&mut self) -> ControlResult<()> {
290 if self.open.is_some() {
291 return Err(ControlError::InvalidArgument(
292 "the stream ended inside an entry; resend from the box's head + 1".into(),
293 ));
294 }
295 Ok(())
296 }
297
298 fn counts(&self) -> (u64, u64) {
299 (self.applied, self.lost)
300 }
301}
302
303// ───────────────────────────────────────────────────────────────────────────
304// The admission gate, reached through a seam (the gRPC half of the shared gate)
305// ───────────────────────────────────────────────────────────────────────────
306//
307// The git front doors — SSH and smart HTTP — put every transfer through ONE
308// gate object, `gunnar_ssh::Admission`: max-min-fair per-principal share, a
309// server-wide concurrency ceiling, and a memory ledger. `Repos.ArchiveOut`
310// streams a whole repository's stored bytes down the control-plane channel; it
311// is a transfer in everything but name and must take the SAME permit a
312// `git-upload-pack` does, or the third front door is the one with no gate.
313//
314// `gunnar_ssh::Admission` cannot be named here: `gunnar-grpc` is on the
315// client/control graph (`gunnar-cli --features control` pulls this crate WITHOUT
316// `gunnar-server`), and a `gunnar-ssh` dependency here would drag the whole SSH
317// server — russh and all — into every client. So the gate is reached through a
318// seam, exactly as the storage backend is reached through `ControlPlane`:
319// `gunnar-server` implements [`TransferGate`] over the real `Arc<Admission>` the
320// SSH and HTTP fronts already hold. This is a seam TO the one gate, not a second
321// gate — no admission logic lives here.
322
323/// A held admission permit for a control-plane transfer.
324///
325/// It is opaque on purpose: the real permit is a `gunnar_ssh::AdmissionGuard`,
326/// which this crate may not name. Dropping it releases the server-wide slot, the
327/// per-principal count and the memory reservation — so the caller holds it for
328/// the whole stream and lets it fall at the end, the same lifetime the SSH and
329/// HTTP paths give their guard.
330pub struct TransferPermit(pub Box<dyn TransferHold>);
331
332/// What a held [`TransferPermit`] can still be told.
333///
334/// One thing: that the transfer's real work has begun, which is the sample the
335/// governor's CoDel controller reads. The SSH and HTTP paths call the guard's
336/// own `entered_service` at the first line of the spawned transfer; this is that
337/// call, across the seam.
338pub trait TransferHold: Send {
339 /// File the sojourn sample: everything before this was the transfer waiting.
340 fn entered_service(&self);
341}
342
343/// Why the gate refused a control-plane transfer, carrying the same fields the
344/// SSH and HTTP refusals log.
345///
346/// The counters are read from the gate at the moment of refusal so the audit
347/// line names the state that refused — `in_flight`, `peak_in_flight`, the
348/// governed `limit`, `reserved_bytes` and the running `refused_total` — rather
349/// than a bare "busy". `message` is the gate's own `client_message`, so the
350/// sentence the operator reads is the one the log recorded.
351#[derive(Clone, Debug)]
352pub struct TransferRefused {
353 /// The gate's own client-facing sentence.
354 pub message: String,
355 /// Transfers in flight across all fronts when this was refused.
356 pub in_flight: u64,
357 /// The high-water mark of `in_flight`.
358 pub peak_in_flight: u64,
359 /// The governed concurrency limit in force at the refusal.
360 pub limit: usize,
361 /// Bytes the memory ledger had reserved to in-flight transfers.
362 pub reserved_bytes: u64,
363 /// How many transfers this gate has refused since start-up, this one
364 /// included.
365 pub refused_total: u64,
366}
367
368/// The seam to the ONE admission gate (`gunnar_ssh::Admission`).
369///
370/// `gunnar-server` implements it over the real `Arc<Admission>` the SSH and HTTP
371/// fronts hold; a plaintext test router that wires no gate leaves it `None` and
372/// the transfer-shaped RPCs run ungated, which is exactly the ungated state
373/// the red half of the test drives.
374pub trait TransferGate: Send + Sync {
375 /// Admit `principal` for one control-plane transfer of `namespace`'s data,
376 /// or refuse it.
377 ///
378 /// **Never blocks** — it is `gunnar_ssh::Admission::try_admit` across the
379 /// seam. On success the returned [`TransferPermit`] is held for the whole
380 /// stream; on refusal the [`TransferRefused`] carries the audit fields and
381 /// the client sentence.
382 ///
383 /// `namespace` is carried for the flow ledger on the other side, which
384 /// counts a refusal against the tenant that met it. It was not here before
385 /// the ledger was, and the call site always had it: the refusal was logged
386 /// with `repo = %path` in the same breath as it was counted against nobody.
387 fn admit_transfer(
388 &self,
389 principal: &str,
390 namespace: &str,
391 ) -> Result<TransferPermit, TransferRefused>;
392}
393
394// ───────────────────────────────────────────────────────────────────────────
395// Identity: the ACL model, asked through the seam, decided in gunnar-core
396// ───────────────────────────────────────────────────────────────────────────
397
398/// A key as the gate needs it: whose it is, and the OpenSSH line to verify a
399/// signature against.
400///
401/// The line is carried rather than a parsed key because `gunnar-ssh` and
402/// `gunnar-core` link semver-incompatible `ssh-key` versions and no key
403/// *type* can cross that line. Text can.
404/// **A web credential (a WebAuthn passkey) as the plane stores it** (Stage L,
405/// 2026-10-04): the wire row, and the public key the wire never carries —
406/// P-256, SEC1 uncompressed (`0x04 || X || Y`), which is what gunnar verifies a
407/// login's assertion with.
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct StoredCredential {
410 pub credential: gunnar_proto::WebCredential,
411 pub public_key: Vec<u8>,
412}
413
414/// **The operator's first invite, as the identity table holds it** (Stage L;
415/// IDENTITY-HOST 2026-10-27): the installer's break-glass token by its
416/// SHA-256 — never the token — for the operator's FIRST passkey. The identity
417/// core reads it as one of its own invites (a fed one, `sha256:<hex>`, onto
418/// the operator) and spends it there; the plane only keeps the record.
419#[derive(Debug, Clone, PartialEq, Eq)]
420pub struct FirstInvite {
421 pub principal_id: String,
422 /// Lowercase hex SHA-256 of the token.
423 pub secret_hash: String,
424 pub created_at_unix_ms: i64,
425 pub expires_at_unix_ms: i64,
426}
427
428/// **When a principal last signed in, and last changed something**
429/// (2026-10-21) — what `Access.ListPrincipals` answers beside each principal.
430/// `None`: never, as far as this box knows.
431#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
432pub struct PrincipalSeen {
433 /// Unix ms of the last console session issued to it.
434 pub last_sign_in_unix_ms: Option<i64>,
435 /// Unix ms of its last authenticated call that changed something.
436 pub last_operation_unix_ms: Option<i64>,
437}
438
439/// **An approved machine enrolment** (`Access.ApproveMachineEnrolment`,
440/// 2026-10-05), committed in ONE update: the MACHINE principal made
441/// when it is not there (a principal of that id that is not a machine is
442/// `FailedPrecondition`); its server-wide grant set; the credential stored
443/// (an id already registered is `AlreadyExists`); and the approver's step-up
444/// passkey's counter and last use, when a person approved.
445#[derive(Debug, Clone, PartialEq, Eq)]
446pub struct MachineApproval {
447 /// The machine principal: id = name (`kit-*`).
448 pub machine: String,
449 /// `Admin` or `Write`.
450 pub server_level: GrantLevel,
451 pub credential_id: Vec<u8>,
452 pub public_key: Vec<u8>,
453 pub label: String,
454 pub sign_count: u32,
455 pub now_unix_ms: i64,
456 /// The approver's step-up passkey and the counter it presented.
457 pub step_up: Option<(Vec<u8>, u32)>,
458}
459
460/// What an approval committed.
461#[derive(Debug, Clone, PartialEq, Eq)]
462pub struct MachineApproved {
463 pub principal: Principal,
464 pub credential: gunnar_proto::WebCredential,
465 /// The machine principal was made by this approval.
466 pub created: bool,
467}
468
469#[derive(Debug, Clone, PartialEq, Eq)]
470pub struct RegisteredKey {
471 /// The ACL principal the key belongs to.
472 pub principal_id: String,
473 /// `<algo> <base64> [comment]`, exactly as `authorized_keys` writes it.
474 pub openssh: String,
475 /// The principal is a `Machine` (the ACL model): a server's deploy key rather than a
476 /// person's. The one caller a browse replica's control plane still lets
477 /// write (`Repos.Delete`, the browse replica).
478 pub machine: bool,
479}
480
481/// Who a **client certificate** belongs to, once the handshake has already
482/// proven the peer holds its private key.
483///
484/// The difference from [`RegisteredKey`] is the whole reason this is a separate
485/// type: a `RegisteredKey` carries an `openssh` line **because the gate still
486/// has to verify a signature against it**. A certificate has no such step left.
487/// TLS proved possession during the handshake and the issuer proved provenance,
488/// so what remains is one table lookup: are these bytes registered, and to
489/// whom. Handing back an `openssh` field here would invite a second
490/// verification against material that is not the credential, which is how a
491/// check comes to pass against the wrong thing.
492///
493/// The certificate is **not** an identity model of its own (the mTLS and
494/// certificate-identity plans): the fingerprint lands in the same ACL key table
495/// as every SSH key, under `algo: "x509"`, and the same `AuthTable::may` decides
496/// what the principal can do. A certificate the CA signed and nobody registered
497/// reaches the gate and is refused there.
498#[derive(Debug, Clone, PartialEq, Eq)]
499pub struct CertificateIdentity {
500 /// The ACL principal the certificate was registered to.
501 pub principal_id: String,
502 /// The `SHA256:…` fingerprint of the DER, as
503 /// `gunnar_core::auth::fingerprint_of_certificate` computes it. Carried so
504 /// the audit record can name the credential without re-hashing it in a
505 /// crate that must not own that decision.
506 pub fingerprint: String,
507}
508
509/// **The two ACL questions the gRPC gate asks, and neither of them is answered
510/// in this crate.**
511///
512/// Split out of [`ControlPlane`] rather than folded into it so that the
513/// distinction is visible at the type level: a control plane that has an
514/// identity table implements this, and one that does not cannot be mistaken
515/// for one that does.
516///
517/// # Why there is no default implementation that allows
518///
519/// [`Identity::may`] could be *derived* from [`ControlPlane::list_grants`]:
520/// filter by principal, take the maximum level. That derivation would be a
521/// **second grant decision**, living beside `gunnar_core::AuthTable::may`, free
522/// to disagree with it. The one-sandbox rule records what two security checks that disagree
523/// cost the last time: the safe one gives false confidence about the permissive
524/// one. So this is a delegation, and the only implementation with an opinion is
525/// `AuthTable`'s.
526pub trait Identity: Send + Sync {
527 /// The registered key for a `SHA256:…` fingerprint, or `None` if there is
528 /// no such key, the key has expired, or its principal is gone.
529 ///
530 /// `Result<Option<_>>`, not `Option<_>`: the table lives in storage and a
531 /// read can fail, and a bare `Option` has to invent an answer for that. The
532 /// safe-looking invention (`None`) turns a storage fault into a silent
533 /// authentication failure (the ACL model, the same reason `may` returns a `Result`).
534 fn registered_key(&self, fingerprint: &str) -> ControlResult<Option<RegisteredKey>>;
535
536 /// **A key this box was FED to enrol and has not enrolled yet**: its
537 /// OpenSSH line, for a fingerprint [`Identity::registered_key`] does not
538 /// know (cov-E r3 2026-10-06, run 3). A primary's fed principals — the
539 /// front's certify key, the console's, monetize's — are enrolled by its
540 /// pairing loop after the installer's feed lands: a moment later on a
541 /// healthy box, MINUTES later on a starved one (measured: 4 m 47 s, the
542 /// certify key sixth in line). A caller holding one of those keys is not a
543 /// stranger, and the gate answers it `Unavailable` (ask again), never
544 /// `Unauthenticated`, once its signature verifies against the fed key
545 /// ([`crate::auth::AuthFailure::Enrolling`]).
546 ///
547 /// Defaulted to `None` — every unknown fingerprint unknown, the answer
548 /// before — because `None` IS the refusal: an adapter that never says a
549 /// key is awaited answers exactly as it always did, and nothing is let in
550 /// by this answer either way.
551 fn awaiting_enrolment(&self, _fingerprint: &str) -> Option<String> {
552 None
553 }
554
555 /// Who these **client-certificate** DER bytes were registered to, or `None`
556 /// if nobody registered them, the registration expired, or its principal is
557 /// gone.
558 ///
559 /// # Why the DER and not a fingerprint
560 ///
561 /// Hashing the credential is the one step that decides *what the credential
562 /// is*, and `gunnar_core::auth::fingerprint_of_certificate` already owns
563 /// that decision for both the SSH and the X.509 side of the ACL table. This
564 /// crate deliberately does not link `gunnar-core`
565 /// ([`crate::peer_certificate_der`] says why), so a fingerprint computed on
566 /// this side of the seam would be a *second* opinion about what bytes a
567 /// certificate is — and the two would agree right up until one of them
568 /// changed. The seam therefore carries the bytes and the adapter hashes.
569 ///
570 /// # Why this is not a second identity model
571 ///
572 /// It returns an ACL principal id, from the same table
573 /// [`Identity::registered_key`] reads, and every authorisation question is
574 /// still answered by [`Identity::may`]. The certificate is a *credential*,
575 /// like an SSH key is a credential. What the certificate's subject claims
576 /// is never consulted: two certificates can claim the same subject, and the
577 /// bytes are what was registered (the mTLS plan, and the tension with the
578 /// certificate-identity plan's cert-CN shape is resolved this way on purpose).
579 ///
580 /// **Required, not defaulted.** A default returning `Ok(None)` would be a
581 /// silent "no certificate identity here" for any adapter that forgot to
582 /// implement it, and an adapter that forgot is exactly the one whose
583 /// operators would then be told their registered certificate is unknown.
584 /// The two implementations in this workspace both answer.
585 fn registered_certificate(&self, der: &[u8]) -> ControlResult<Option<CertificateIdentity>>;
586
587 /// **May this principal do this, at this level?** `gunnar_core::AuthTable::may`
588 /// (the ACL model) seen through the seam.
589 fn may(&self, principal_id: &str, access: &Access, level: GrantLevel) -> ControlResult<bool>;
590
591 /// **[`ControlPlane::may`] at `level` for every repository of a listing**
592 /// (round 5.3.1): one answer per row, in order. Defaulted to one `may` per
593 /// row; the core plane answers the whole listing under one read of the
594 /// catalog and one of the identity table, so concurrent listings no longer
595 /// queue on each other's per-row store reads (test5 2026-10-08).
596 fn may_each_repo(
597 &self,
598 principal_id: &str,
599 paths: &[RepoPath],
600 level: GrantLevel,
601 ) -> ControlResult<Vec<bool>> {
602 paths
603 .iter()
604 .map(|p| self.may(principal_id, &Access::repo(p), level))
605 .collect()
606 }
607
608 /// **The methods a NARROWED principal may call** (f-principal-reach), or
609 /// `None` for a principal judged by its grants. A narrowed principal holds
610 /// no grant: [`crate::service::ServiceContext::require`] lets it through
611 /// exactly the methods listed and refuses every other by name. Defaulted
612 /// to `None` because it only ever RESTRICTS a principal nobody narrowed —
613 /// an adapter that does not know narrowing judges every caller as before.
614 fn narrowed_methods(&self, _principal_id: &str) -> ControlResult<Option<Vec<String>>> {
615 Ok(None)
616 }
617}
618
619// ───────────────────────────────────────────────────────────────────────────
620// First boot: the one unauthenticated door, seen through the seam
621// ───────────────────────────────────────────────────────────────────────────
622
623/// What a server can say about its own enrolment, without a credential.
624///
625/// Every field is a fact the server holds, never a derivation this crate makes:
626/// [`BootstrapState::window_open`] is `gunnar_core::AuthTable::bootstrap_window_open`,
627/// the one predicate that also gates the write. A second opinion computed here
628/// out of `principals == 0` would be a status that could disagree with the
629/// gate, and the permissive one would win silently (the one-sandbox rule).
630#[derive(Debug, Clone, PartialEq, Eq, Default)]
631pub struct BootstrapState {
632 /// `Bootstrap.Enroll` will be served without a credential.
633 pub window_open: bool,
634 /// The window has been used. Not the negation of `window_open`.
635 pub bootstrapped: bool,
636 pub bootstrapped_at_unix_ms: i64,
637 pub bootstrapped_principal: String,
638 pub bootstrapped_fingerprint: String,
639 pub principals: u64,
640 pub keys: u64,
641 /// **The git listener is serving**, i.e. the boot window is closed.
642 ///
643 /// `false` while the server's start-up disk test is still saturating the
644 /// disk a repository's first post-crash open has to read; git transfers are
645 /// refused with `EX_TEMPFAIL` and a sentence containing `starting` for that
646 /// whole window. It rides on `Bootstrap.Status` and not on `Admin.Health`
647 /// because this is the one call an unenrolled server answers, and a boot
648 /// script is exactly the caller with no credential.
649 ///
650 /// `Default` is `false`: an implementation that forgets it says "not yet",
651 /// and "wait" is the safe thing to say wrongly.
652 pub serving_git: bool,
653 /// One sentence about [`BootstrapState::serving_git`].
654 pub readiness: String,
655 /// **The build stamp** — `gunnar_common::BUILD_REF`, the same string an
656 /// SSH banner's comments field carries. Empty when the plane does not
657 /// know it (a test double); the server fills it.
658 pub build: String,
659 /// `primary` or `twin`: the `--role` this box was started with, or a box
660 /// that has since become a twin. What a door proves a twin medium by, since
661 /// a twin serves no git and so has no banner.
662 pub role: String,
663 /// **The twin a RECHARGED main awaits** (`Bootstrap.Status.recharge_from`): set while the
664 /// box is a re-admitted main that is fresh or restoring; empty otherwise.
665 pub recharge_from: String,
666 /// A restore is under way (`txlog-restoring`).
667 pub restoring: bool,
668 /// The box's transaction-log head.
669 pub log_head_seq: u64,
670 /// **The entry a restoring box holds part of**, staged on its disk (restore-large-entry,
671 /// 2026-10-07): `(seq, bytes held durably)`; `None` when nothing is staged.
672 pub restore_partial: Option<(u64, u64)>,
673 /// **This box stages a twin's snapshot across streams** (restore-after-snapshot, 2026-10-07).
674 pub restore_snapshot_resumable: bool,
675 /// What a restoring box holds of a twin's snapshot: `(id, items whole, bytes of the next,
676 /// whole)`; `None` when nothing is staged.
677 pub restore_snapshot: Option<(String, u64, u64, bool)>,
678}
679
680/// What [`FirstBoot::enroll_first_identity`] created.
681#[derive(Debug, Clone, PartialEq)]
682pub struct Enrolled {
683 pub principal: Principal,
684 pub key: PublicKey,
685 /// The namespace this principal is intrinsically admin of.
686 pub account: String,
687 pub bootstrapped_at_unix_ms: i64,
688 /// A client certificate minted for the principal by the server's issuer,
689 /// with its private key and the issuer certificate to pin. All three empty
690 /// when the server has no issuer — enrolment does not fail on their
691 /// account, because the window is already shut when minting runs.
692 pub client_cert_pem: String,
693 pub client_key_pem: String,
694 pub issuer_cert_pem: String,
695}
696
697/// **The trust-on-first-use door.**
698///
699/// A supertrait of [`ControlPlane`] for the same reason [`Identity`] is one: a
700/// control plane that cannot answer *has anybody been enrolled here* cannot be
701/// served safely, and a compile error is cheaper than a runtime hole. There is
702/// no default implementation, and in particular no default that answers
703/// `window_open: true`.
704pub trait FirstBoot: Send + Sync {
705 /// The enrolment state, as the server holds it.
706 ///
707 /// `Result`, so a table that cannot be read is an error and never a
708 /// cheerful `window_open: false` **or** `true`. Both inventions are wrong
709 /// and one of them opens the door.
710 fn bootstrap_state(&self) -> ControlResult<BootstrapState>;
711
712 /// Create the first identity and shut the window, atomically.
713 ///
714 /// [`ControlError::FailedPrecondition`] once it has succeeded once,
715 /// including after every identity it created has been deleted.
716 fn enroll_first_identity(
717 &self,
718 name: &str,
719 openssh_public_key: &str,
720 ) -> ControlResult<Enrolled>;
721}
722
723/// Counts for `Admin.Stats`. Event counters come from the bus, not from here.
724#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
725pub struct ControlCounts {
726 pub namespaces: u64,
727 pub repos: u64,
728 pub principals: u64,
729 pub keys: u64,
730 pub grants: u64,
731}
732
733/// One repository's index work in flight, for `Admin.Stats` (`index`) and the
734/// dashboard's `index` row. Information, never a gate.
735#[derive(Debug, Clone, Default, PartialEq, Eq)]
736pub struct IndexProgressRow {
737 pub namespace: String,
738 pub name: String,
739 pub pack_id: u64,
740 pub objects_done: u64,
741 pub objects_total: u64,
742 pub bytes_done: u64,
743 pub bytes_total: u64,
744 pub phase: String,
745 pub started_at_unix_ms: i64,
746 pub resumed: bool,
747 pub reinflations: u64,
748 pub pauses: u64,
749}
750
751impl IndexProgressRow {
752 pub fn percent(&self) -> u32 {
753 (self.objects_done * 100)
754 .checked_div(self.objects_total)
755 .map_or(0, |percent| percent.min(100) as u32)
756 }
757}
758
759/// What a merge moved: the fact the `RefUpdated` event needs, handed back so
760/// the SERVICE publishes it — the seam stays free of the event bus.
761#[derive(Debug, Clone, PartialEq, Eq)]
762pub struct MergedRef {
763 /// Full target ref name.
764 pub ref_name: String,
765 /// Where it was.
766 pub old_oid: String,
767 /// Where it is now: the source tip.
768 pub new_oid: String,
769}
770
771/// What a new repository is created with. Server defaults are applied by the
772/// implementation, not by the caller.
773#[derive(Debug, Clone)]
774pub struct NewRepo {
775 pub path: RepoPath,
776 pub hash_kind: HashKind,
777 /// Full ref name. Empty means the server default.
778 pub default_branch: String,
779 pub visibility: Visibility,
780 pub description: String,
781 /// Take over a store already on disk at this name; see
782 /// `CreateRepoRequest.adopt`.
783 pub adopt: bool,
784}
785
786/// What a `Repos.Delete` was asked to do.
787#[derive(Debug, Clone, PartialEq, Eq, Default)]
788pub struct DeleteOptions {
789 /// Reclaim the bytes NOW instead of tombstoning them.
790 pub purge: bool,
791 /// How long a tombstone keeps them. `None` = the server's default.
792 /// `Some(0)` is "at the next sweep" and is a decision, not an absence.
793 pub retention_days: Option<u32>,
794 /// Who asked. Recorded in the tombstone.
795 pub principal_id: String,
796}
797
798/// What a `Repos.Delete` did. **Not a unit**: "the bytes went" and "the bytes
799/// are being kept until a date" are different outcomes and a caller that could
800/// not tell them apart could not report either one honestly.
801#[derive(Debug, Clone, Copy, PartialEq, Eq)]
802pub enum DeleteOutcome {
803 /// The bytes were reclaimed now.
804 Purged {
805 /// What came back.
806 bytes_reclaimed: u64,
807 },
808 /// The bytes are held until the deadline.
809 Tombstoned {
810 /// When a sweep may take them.
811 purge_after_unix_ms: i64,
812 /// What is being held — and still charged for.
813 bytes_kept: u64,
814 },
815}
816
817/// **The two things `Admin.Stats` reports that are not counts of rows**:
818/// bytes held by tombstones, and what replication is doing.
819///
820/// The standby rows are `gunnar_proto::StandbyStatus` and not a local twin:
821/// `storage_info` already returns the proto type straight through, and a
822/// second definition of "what a standby looks like" is exactly what one
823/// implementation forbids. The default is all-zero and `configured: false`, which is what a
824/// control plane with no replication *is* — a fact, not an absence.
825#[derive(Debug, Clone, Default, PartialEq)]
826pub struct StatsExtras {
827 /// Repositories deleted and not yet purged.
828 pub tombstoned_repos: u64,
829 /// Bytes they are holding — still charged to the account.
830 pub tombstoned_bytes: u64,
831 /// Is any standby configured?
832 pub replication_configured: bool,
833 /// replication counters.
834 pub replication_enqueued: u64,
835 /// Attempts a standby accepted.
836 pub replication_synced: u64,
837 /// Attempts that failed. Never resets.
838 pub replication_failed: u64,
839 /// Times the event subscription fell behind.
840 pub replication_bus_gaps: u64,
841 /// Every ref on this server, and how many of them no standby carries.
842 /// Replication copies `refs/heads/*` and `refs/tags/*` only.
843 pub replication_refs_total: u64,
844 pub replication_refs_unmirrored: u64,
845 /// One row per standby.
846 pub standbys: Vec<gunnar_proto::StandbyStatus>,
847 /// The tenants row: namespaces by ENFORCED plan state. Zero on a build
848 /// that holds no facts.
849 pub tenants_paid: u64,
850 pub tenants_grace: u64,
851 pub tenants_suspended: u64,
852}
853
854/// One soft-deleted repository, as `Repos.ListDeleted` reports it.
855#[derive(Debug, Clone, PartialEq, Eq)]
856pub struct DeletedRepoRow {
857 /// Where it was.
858 pub path: RepoPath,
859 /// When the delete was asked for.
860 pub deleted_at_unix_ms: i64,
861 /// When a sweep may reclaim it; 0 = no deadline.
862 pub purge_after_unix_ms: i64,
863 /// Who asked.
864 pub principal_id: String,
865 /// Bytes still on disk, and still on the account.
866 pub bytes: u64,
867}
868
869/// **One store directory with no catalog row and no soft-delete tombstone**,
870/// as `Repos.ListOrphans` reports it.
871///
872/// The invisible class. Every reclaim path the server had keyed off a record —
873/// `Repos.Delete` off the catalog, `Purge`/`Undelete`/`ListDeleted` and the
874/// expiry sweep off a tombstone — so a directory with neither was reachable by
875/// nothing at all, while `Repos.Create` refused its name because it was there.
876#[derive(Debug, Clone, PartialEq, Eq)]
877pub struct OrphanRow {
878 /// The name the directory stands at.
879 pub path: RepoPath,
880 /// Where it is on the server's disk. There is no shell on the appliance,
881 /// so this is reported rather than looked up.
882 pub dir: String,
883 /// What it is holding.
884 pub bytes: u64,
885 /// `sha1`, `sha256`, or **empty for a store written before the
886 /// `OBJECT_FORMAT` marker existed** — which is what a create with `adopt`
887 /// takes the caller's hash at their word about.
888 pub hash_marker: String,
889 /// Newest mtime under the directory.
890 pub modified_unix_ms: i64,
891 /// Non-empty when a tombstone records a reclaim that FAILED here.
892 pub failed_reclaim: String,
893}
894
895/// What a finished [`ControlPlane::archive_out`] cut did — the last message of
896/// a `Repos.ArchiveOut` stream.
897///
898/// It is a `gunnar-grpc` type rather than the plugin's `ArchiveOutReceipt`
899/// because this crate must not link a storage engine (the dependency budget): the seam is where
900/// the engine's answer becomes the control plane's.
901#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
902pub struct ArchiveCut {
903 /// The watermark this cut started above.
904 pub from_generation: u64,
905 /// The watermark to pass as `from_generation` next time.
906 pub to_generation: u64,
907 /// Payload bytes written into the sink.
908 pub bytes_out: u64,
909 /// SHA-256 of every byte written.
910 pub file_sha256: [u8; 32],
911}
912
913/// Where a queued maintenance run has got to.
914///
915/// The seam's own vocabulary rather than the generated `gunnar.v1.GcState`,
916/// for the same reason every other type in this file is: an adapter implements
917/// this trait without linking the protos, and the mapping to the wire lives in
918/// exactly one place (`service/repos.rs`).
919#[derive(Debug, Clone, Copy, PartialEq, Eq)]
920pub enum GcState {
921 /// Waiting its turn behind other repositories' gcs.
922 Queued,
923 /// The one gc the server is running.
924 Running,
925 /// Finished. `bytes_reclaimed` is the answer.
926 Done,
927 /// Finished badly. The repository was NOT maintained.
928 Failed,
929}
930
931/// One queued or completed maintenance run, as a caller sees it.
932#[derive(Debug, Clone)]
933pub struct GcJob {
934 /// Stable for the life of the job, so a console can tell its own run from
935 /// somebody else's that started after it.
936 pub job_id: String,
937 pub path: RepoPath,
938 pub state: GcState,
939 /// How many jobs are ahead of this one. 0 unless [`GcState::Queued`].
940 pub queue_position: u32,
941 /// Bytes that left the filesystem. Only meaningful when [`GcState::Done`].
942 pub bytes_reclaimed: u64,
943 /// Only set when [`GcState::Failed`].
944 pub error: Option<String>,
945 pub enqueued_at_unix_ms: i64,
946 /// 0 until the job leaves [`GcState::Running`].
947 pub finished_at_unix_ms: i64,
948}
949
950// ───────────────────────────────────────────────────────────────────────────
951// Accounts: the gate, and the per-user hard purge
952// ───────────────────────────────────────────────────────────────────────────
953
954/// Why an account is locked out. Mirrors `gunnar.v1.GateReason`.
955#[derive(Debug, Clone, Copy, PartialEq, Eq)]
956pub enum GateReason {
957 /// Somebody decided. No deadline.
958 Administrative,
959 /// A purge is holding it, under a lease it re-stamps as it goes.
960 Purge,
961}
962
963/// **What `Accounts.ClaimOperator` answered** (2026-10-04): the estate's
964/// operator, as gunnar holds it now. See the proto for the rule; the plane is
965/// where it is kept.
966#[derive(Debug, Clone, PartialEq, Eq)]
967pub struct OperatorClaim {
968 /// The operator: a USER, id = name, the account it is named after.
969 pub principal: Principal,
970 /// This call created the principal (and claimed its namespace).
971 pub created: bool,
972 /// The namespace already existed with no principal of that id and was
973 /// adopted as the operator's account rather than refused.
974 pub adopted_namespace: bool,
975 /// `fed` — the installer's `operator_name` — or `asked`, the request's.
976 pub named_by: &'static str,
977}
978
979/// **The id of the operator's first invite** — the installer's break-glass
980/// token, recorded by its hash (`Accounts.ClaimOperator`'s
981/// `bootstrap_invite_sha256`, the feed's `operator_invite_sha256`). One per
982/// box: recording it again replaces it.
983pub const OPERATOR_INVITE_ID: &str = "operator-bootstrap";
984
985/// How long the operator's first invite stays redeemable from the moment it
986/// is recorded — the most any invite lives. Every feed (an install, an
987/// upgrade's re-admission) records it again while the operator holds no
988/// passkey, so a box that waits longer than this for its owner is renewed by
989/// its next feed.
990pub const OPERATOR_INVITE_TTL_MS: i64 = 30 * 24 * 60 * 60 * 1000;
991
992/// **What became of the operator's first invite.**
993#[derive(Debug, Clone, Copy, PartialEq, Eq)]
994pub enum BootstrapInvite {
995 /// Recorded now (none was pending, or a different hash was).
996 Recorded,
997 /// The same hash was pending; its expiry is renewed.
998 Renewed,
999 /// The operator already holds a passkey: the bootstrap is spent, nothing
1000 /// is recorded, and a leftover one is withdrawn.
1001 Spent,
1002}
1003
1004/// **The least a first invite may live**: a minute. A link that dies before
1005/// a person can open it is no way in.
1006pub const OPERATOR_INVITE_MIN_TTL_MS: i64 = 60 * 1000;
1007
1008/// **A first invite's lifetime, checked** (private ownership, 2026-10-07):
1009/// at least [`OPERATOR_INVITE_MIN_TTL_MS`], at most
1010/// [`OPERATOR_INVITE_TTL_MS`] — the most any invite lives. `INVALID_ARGUMENT`
1011/// otherwise, by name; nothing is recorded.
1012pub fn bootstrap_ttl(ttl_ms: i64) -> ControlResult<i64> {
1013 if (OPERATOR_INVITE_MIN_TTL_MS..=OPERATOR_INVITE_TTL_MS).contains(&ttl_ms) {
1014 Ok(ttl_ms)
1015 } else {
1016 Err(ControlError::InvalidArgument(format!(
1017 "the first invite's lifetime is {ttl_ms} ms: it lives at least a minute and at most {} days",
1018 OPERATOR_INVITE_TTL_MS / 86_400_000
1019 )))
1020 }
1021}
1022
1023/// A bootstrap hash as the wire carries it: 64 lowercase hex digits (what
1024/// `webauthn::sha256_hex` writes and an invite row keeps). `INVALID_ARGUMENT`
1025/// otherwise — a token sent in place of its hash is refused, never stored.
1026pub fn bootstrap_hash(raw: &str) -> ControlResult<String> {
1027 let hash = raw.trim();
1028 if hash.len() == 64
1029 && hash
1030 .bytes()
1031 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
1032 {
1033 Ok(hash.to_owned())
1034 } else {
1035 Err(ControlError::InvalidArgument(
1036 "the bootstrap invite is the lowercase hex SHA-256 of the break-glass token (64 \
1037 characters), never the token itself"
1038 .into(),
1039 ))
1040 }
1041}
1042
1043/// **Which name is the operator's**, the one rule both planes keep: the fed
1044/// name wins; the asked one only when nothing was fed; neither is
1045/// `FAILED_PRECONDITION`. Blank is absent.
1046pub fn operator_name<'a>(
1047 fed: Option<&'a str>,
1048 asked: &'a str,
1049) -> ControlResult<(&'a str, &'static str)> {
1050 match (fed.map(str::trim).filter(|n| !n.is_empty()), asked.trim()) {
1051 (Some(fed), _) => Ok((fed, "fed")),
1052 (None, asked) if !asked.is_empty() => Ok((asked, "asked")),
1053 _ => Err(ControlError::FailedPrecondition(
1054 "no operator is named for this box: the installer fed no `operator_name` and the \
1055 request named nobody"
1056 .into(),
1057 )),
1058 }
1059}
1060
1061/// **One account, locked**, as `Accounts` reports it.
1062#[derive(Debug, Clone, PartialEq, Eq)]
1063pub struct AccountGate {
1064 /// A principal id, which is also the namespace that principal owns.
1065 pub account: String,
1066 pub reason: GateReason,
1067 /// A principal id for a person, a job id for a purge. Only a matching
1068 /// holder may lift its own gate.
1069 pub holder: String,
1070 /// The principal that asked, for the audit trail.
1071 pub actor: String,
1072 /// Shown to the locked-out user in the refusal they get.
1073 pub note: String,
1074 pub at_unix_ms: i64,
1075 /// **0 = no deadline.** Non-zero is a purge's lease, which is what stops a
1076 /// dead purge from bricking an account.
1077 pub expires_unix_ms: i64,
1078}
1079
1080/// Where an account purge has got to. Mirrors `gunnar.v1.PurgeState`.
1081#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1082pub enum PurgeState {
1083 /// Waiting its turn behind other accounts' purges.
1084 Queued,
1085 /// The one purge the server is running.
1086 Running,
1087 /// Finished, and everything it set out to reclaim is gone.
1088 Done,
1089 /// Finished badly. The account is PARTIAL and `failures` says where.
1090 Failed,
1091}
1092
1093/// One queued or completed account purge, as a caller sees it.
1094#[derive(Debug, Clone)]
1095pub struct PurgeJob {
1096 /// Stable for the life of the job, and also the gate's `holder` while it
1097 /// runs.
1098 pub job_id: String,
1099 pub account: String,
1100 pub state: PurgeState,
1101 /// How many jobs are ahead of this one. 0 unless [`PurgeState::Queued`].
1102 pub queue_position: u32,
1103 /// Stores the walk found. **0 until the job starts** — counted when its
1104 /// turn comes, because an account can grow while a job waits.
1105 pub stores_found: u32,
1106 /// How many of those are gone.
1107 pub stores_purged: u32,
1108 pub bytes_reclaimed: u64,
1109 pub principal_removed: bool,
1110 /// **Whether the lockout this job took has been released.** The property
1111 /// that decides whether the account is usable again, on the wire rather
1112 /// than only in a log. False on a job that found an administrative gate
1113 /// already in place: it did not take that one and must not lift it.
1114 pub gate_released: bool,
1115 /// Only set when [`PurgeState::Failed`].
1116 pub error: Option<String>,
1117 /// One line per store that could not be reclaimed. The recovery list.
1118 pub failures: Vec<String>,
1119 pub enqueued_at_unix_ms: i64,
1120 /// 0 until the job leaves [`PurgeState::Queued`].
1121 pub started_at_unix_ms: i64,
1122 /// 0 until the job leaves [`PurgeState::Running`].
1123 pub finished_at_unix_ms: i64,
1124}
1125
1126/// **What an enrolment asks** (`Accounts.EnrollEmployee`; owner, 2026-10-04,
1127/// "Add users"): ONE named employee holding `room_bytes`, `seats` unnamed
1128/// seats each holding it, or a seat claim (`from_seat`). The service has
1129/// already applied the rules a person types against (count range, names,
1130/// the room's default and floor); the plane keeps the rules about what is
1131/// STORED: a new principal only, an open seat for a claim, and the room.
1132#[derive(Debug, Clone, Default, PartialEq, Eq)]
1133pub struct EmployeeAsk {
1134 /// The new principal's id and display name; both empty for seats.
1135 pub id: String,
1136 pub name: String,
1137 /// Bytes per employee or seat. Ignored for a claim: the seat decides.
1138 pub room_bytes: u64,
1139 /// Unnamed seats to grant; 0: one named employee (or a claim).
1140 pub seats: u32,
1141 /// Fill an open seat with the named employee.
1142 pub from_seat: bool,
1143 /// The account's room — its `pack_bytes` cap. `None`: nothing caps it on
1144 /// this server.
1145 pub cap: Option<u64>,
1146 /// What the account stores now (counted against `cap`).
1147 pub stored: u64,
1148 /// The order this belongs to, for its retry; empty: none.
1149 pub order: String,
1150 /// Ask, write nothing.
1151 pub dry_run: bool,
1152}
1153
1154/// **The plane's answer** to an [`EmployeeAsk`]. A short account is an
1155/// answer, not an error, at this layer: `shortfall_bytes > 0` with
1156/// `unchanged`, nothing written — the SERVICE turns it into the refusal, in
1157/// one sentence for every plane.
1158#[derive(Debug, Clone, Default, PartialEq)]
1159pub struct EmployeeAnswer {
1160 pub principal: Option<Principal>,
1161 pub room_bytes: u64,
1162 pub seats: u32,
1163 pub seats_open: u32,
1164 pub need_bytes: u64,
1165 pub unused_bytes: Option<u64>,
1166 pub shortfall_bytes: u64,
1167 pub unchanged: bool,
1168}
1169
1170/// What `Accounts.Purge` was asked for, carried whole rather than as three
1171/// positional booleans nobody can read at a call site.
1172#[derive(Debug, Clone, Default)]
1173pub struct PurgeAccountOptions {
1174 /// The principal that asked. Recorded on the gate.
1175 pub principal_id: String,
1176 /// Why. Required at the edge; shown to the locked-out user.
1177 pub reason: String,
1178 /// Also remove the principal, its keys, grants, tokens and memberships.
1179 pub remove_principal: bool,
1180 /// Refuse unless the account holds no live repositories.
1181 pub expect_empty: bool,
1182}
1183
1184/// The control plane's view of storage. See the module docs: this is a seam.
1185///
1186/// [`Identity`] is a supertrait, not an optional extra: a control plane that
1187/// cannot say who a key belongs to and whether they may act cannot be served
1188/// safely, and making that a compile error is cheaper than making it a runtime
1189/// hole.
1190pub trait ControlPlane: Identity + FirstBoot + Send + Sync + 'static {
1191 // ── namespaces (multi-tenancy) ─────────────────────────────────────────
1192 fn create_namespace(&self, name: &str, description: &str) -> ControlResult<Namespace>;
1193 fn list_namespaces(&self) -> ControlResult<Vec<Namespace>>;
1194 /// **One namespace, if it exists** — what the account settings and the
1195 /// outsider's view read (`Namespace.public`, `Namespace.events_public`).
1196 ///
1197 /// The default walks [`ControlPlane::list_namespaces`], which is right for
1198 /// the test double; the real adapter answers it with one catalog probe.
1199 fn namespace(&self, name: &str) -> ControlResult<Option<Namespace>> {
1200 Ok(self
1201 .list_namespaces()?
1202 .into_iter()
1203 .find(|namespace| namespace.name == name))
1204 }
1205 /// **`name` as an outsider may be shown it** — the namespace whose
1206 /// `public` / `events_public` its account admin opened, read for a caller
1207 /// who is NOT inside it. `None` when it does not exist, and also while the
1208 /// account or the caller is gated: the gate outranks the admin's setting
1209 /// exactly as it outranks `Visibility::Public` in `AuthTable::may`, so a
1210 /// public account that is being emptied is not listed and its events are
1211 /// not shown, and a locked-out principal is shown no public account at all.
1212 ///
1213 /// The default is [`ControlPlane::namespace`], which is right only for a
1214 /// double that cannot gate anybody (its `gate_account` is
1215 /// `Unimplemented`). An adapter that gates answers it with the gate read.
1216 fn namespace_for_outsider(
1217 &self,
1218 principal_id: &str,
1219 name: &str,
1220 ) -> ControlResult<Option<Namespace>> {
1221 let _ = principal_id;
1222 self.namespace(name)
1223 }
1224 /// **The account admin's two settings** (owner, 2026-10-03: namespaces and
1225 /// emitted events are not public by default, "such things has to be
1226 /// set"): is the namespace public, are its events public. `None` leaves a
1227 /// setting as it is. Who may (the account admin) is the service's question.
1228 fn set_namespace_settings(
1229 &self,
1230 name: &str,
1231 public: Option<bool>,
1232 events_public: Option<bool>,
1233 ) -> ControlResult<Namespace>;
1234 /// Returns how many repos went with it. Refuses a non-empty namespace
1235 /// unless `delete_repos`.
1236 fn delete_namespace(&self, name: &str, delete_repos: bool) -> ControlResult<u32>;
1237
1238 // ── repos ──────────────────────────────────────────────────────────────
1239 fn create_repo(&self, spec: NewRepo) -> ControlResult<Repo>;
1240
1241 /// **Has `namespace` room for a NEW repository, by its plan?** — the
1242 /// questions a push-to-create meets at the front door, asked for
1243 /// `Repos.Create` (owner 2026-10-06: the console's New repository is the
1244 /// account admin's, by the push's rules): the plan's verdict on a write,
1245 /// then whether the namespace is already at its cap. `machine`: the caller
1246 /// is a machine principal, which a plan does not bind unless it says so
1247 /// (as on the SSH door). The refusal is the plan's own line.
1248 ///
1249 /// Defaulted to `Ok(())`: a plane with no plan (the memory double, a
1250 /// community build) has room, which is what it answered before.
1251 fn create_room(&self, _namespace: &str, _machine: bool) -> ControlResult<()> {
1252 Ok(())
1253 }
1254 fn get_repo(&self, path: &RepoPath) -> ControlResult<Repo>;
1255 fn list_repos(&self, namespace: Option<&str>) -> ControlResult<Vec<Repo>>;
1256 /// **Delete a repository: a dated tombstone by default, a reclaim only
1257 /// when [`DeleteOptions::purge`] says so.**
1258 ///
1259 /// The signature carries the whole decision rather than a bare path
1260 /// because there is no safe default for the caller to omit: `purge` is the
1261 /// difference between "the name is free again in thirty days" and "181 GiB
1262 /// went in two seconds", and the principal is what the tombstone records
1263 /// so an undelete can say who deleted it.
1264 fn delete_repo(&self, path: &RepoPath, options: DeleteOptions) -> ControlResult<DeleteOutcome>;
1265
1266 /// **Put a soft-deleted repository back**, inside its window. Returns the
1267 /// restored catalog row, with the recorded hash kind and default branch.
1268 fn undelete_repo(&self, path: &RepoPath) -> ControlResult<Repo> {
1269 let _ = path;
1270 Err(ControlError::Unimplemented("Repos.Undelete"))
1271 }
1272
1273 /// **Reclaim a soft-deleted repository's bytes now.** Returns the bytes.
1274 fn purge_repo(&self, path: &RepoPath) -> ControlResult<u64> {
1275 let _ = path;
1276 Err(ControlError::Unimplemented("Repos.Purge"))
1277 }
1278
1279 /// **Ask for a LIVE repository to be maintained** (`Repos.Gc`): seal the
1280 /// live logs, compact the archive, unlink the exploded read cache.
1281 ///
1282 /// **Returns when the job is ACCEPTED, not when the gc has run.** A gc is
1283 /// hundreds of thousands of unlinks and the box has one disk, so the plane
1284 /// runs one at a time and queues the rest; an RPC that blocked for the
1285 /// duration is what makes a console button feel broken. The returned
1286 /// [`GcJob`] is the one now responsible for the repository — a fresh one,
1287 /// or the one that was already queued or running, because a button gets
1288 /// pressed twice and twice is one gc.
1289 ///
1290 /// Named apart from [`ControlPlane::purge_repo`] because they are opposite
1291 /// in consequence and identical only in shape. A purge ends a retention
1292 /// window and there is no undo; this one reclaims what the server can
1293 /// re-derive, so the worst it costs is a slower next read.
1294 fn gc_repo(&self, path: &RepoPath) -> ControlResult<GcJob> {
1295 let _ = path;
1296 Err(ControlError::Unimplemented("Repos.Gc"))
1297 }
1298
1299 /// **How a repository's gc is going** (`Repos.GcStatus`).
1300 ///
1301 /// `Ok(None)` is a real answer and not a stub: the plane is holding no job
1302 /// for this repository, because none was ever asked for or the last one
1303 /// finished long enough ago to have been dropped. A plane that cannot see
1304 /// the queue at all still answers `Unimplemented` — "nothing is running" and
1305 /// "I cannot tell" must not look alike.
1306 fn gc_status(&self, path: &RepoPath) -> ControlResult<Option<GcJob>> {
1307 let _ = path;
1308 Err(ControlError::Unimplemented("Repos.GcStatus"))
1309 }
1310
1311 /// What is deleted and not yet purged. Empty is a legitimate answer here —
1312 /// unlike a ref list, "nothing is deleted" is a fact a caller can act on —
1313 /// but the default is still `Unimplemented`, because a control plane that
1314 /// cannot see tombstones must not report that there are none.
1315 fn list_deleted(&self, namespace: Option<&str>) -> ControlResult<Vec<DeletedRepoRow>> {
1316 let _ = namespace;
1317 Err(ControlError::Unimplemented("Repos.ListDeleted"))
1318 }
1319 /// **How many days a soft delete that names no window keeps the bytes**
1320 /// (`ListDeletedResponse.default_retention_days`, 2026-10-28). 0 = this plane
1321 /// does not say; a client then speaks of "the retention window" without a number.
1322 fn default_delete_retention_days(&self) -> u32 {
1323 0
1324 }
1325 /// **Every store directory with no catalog row and no soft-delete
1326 /// tombstone** (`Repos.ListOrphans`).
1327 ///
1328 /// Empty is a legitimate answer — "nothing is stranded" is a fact an
1329 /// operator can act on — but the default is still `Unimplemented`, for the
1330 /// same reason [`ControlPlane::list_deleted`]'s is: a control plane that
1331 /// cannot walk the disk must not report that the disk is clean.
1332 fn list_orphans(&self, namespace: Option<&str>) -> ControlResult<Vec<OrphanRow>> {
1333 let _ = namespace;
1334 Err(ControlError::Unimplemented("Repos.ListOrphans"))
1335 }
1336
1337 /// **Reclaim an orphan's bytes** (`Repos.ReclaimOrphan`). Returns them.
1338 ///
1339 /// Refuses a catalogued or soft-deleted repository by name: each of those
1340 /// has its own verb, and a second door onto destroying a live repository
1341 /// is not a recovery tool.
1342 fn reclaim_orphan(&self, path: &RepoPath) -> ControlResult<u64> {
1343 let _ = path;
1344 Err(ControlError::Unimplemented("Repos.ReclaimOrphan"))
1345 }
1346
1347 fn set_default_branch(&self, path: &RepoPath, default_branch: &str) -> ControlResult<Repo>;
1348 fn set_visibility(&self, path: &RepoPath, visibility: Visibility) -> ControlResult<Repo>;
1349
1350 /// Turn a repository's "encrypted" flag on or off (`Repos.SetEncrypted`).
1351 /// The caller has already checked that the account admin asks.
1352 fn set_encrypted(&self, path: &RepoPath, encrypted: bool) -> ControlResult<Repo> {
1353 let _ = (path, encrypted);
1354 Err(ControlError::Unimplemented("Repos.SetEncrypted"))
1355 }
1356
1357 /// A principal's SSH public keys as stored OpenSSH lines
1358 /// (`Repos.RecipientKeys`). The caller has already checked access.
1359 fn recipient_keys(&self, principal_id: &str) -> ControlResult<Vec<gunnar_proto::RecipientKey>> {
1360 let _ = principal_id;
1361 Err(ControlError::Unimplemented("Repos.RecipientKeys"))
1362 }
1363
1364 /// **A person's encryption key and its unlock methods** (`Access.GetEncryptionKey`,
1365 /// CRYPT-YUBI). `exists: false` when they have none. The caller is the person.
1366 fn encryption_key(
1367 &self,
1368 principal_id: &str,
1369 ) -> ControlResult<gunnar_proto::GetEncryptionKeyResponse> {
1370 let _ = principal_id;
1371 Err(ControlError::Unimplemented("Access.GetEncryptionKey"))
1372 }
1373 /// The first time (`Access.CreateEncryptionKey`); `AlreadyExists` when one is held.
1374 fn create_encryption_key(
1375 &self,
1376 principal_id: &str,
1377 public_key: &str,
1378 first: gunnar_proto::UnlockMethod,
1379 ) -> ControlResult<gunnar_proto::GetEncryptionKeyResponse> {
1380 let _ = (principal_id, public_key, first);
1381 Err(ControlError::Unimplemented("Access.CreateEncryptionKey"))
1382 }
1383 /// One more unlock method (`Access.AddUnlockMethod`).
1384 fn add_unlock_method(
1385 &self,
1386 principal_id: &str,
1387 method: gunnar_proto::UnlockMethod,
1388 ) -> ControlResult<gunnar_proto::GetEncryptionKeyResponse> {
1389 let _ = (principal_id, method);
1390 Err(ControlError::Unimplemented("Access.AddUnlockMethod"))
1391 }
1392 /// Delete one method's wrap (`Access.RemoveUnlockMethod`); the last is `FailedPrecondition`.
1393 fn remove_unlock_method(
1394 &self,
1395 principal_id: &str,
1396 id: &str,
1397 ) -> ControlResult<gunnar_proto::GetEncryptionKeyResponse> {
1398 let _ = (principal_id, id);
1399 Err(ControlError::Unimplemented("Access.RemoveUnlockMethod"))
1400 }
1401
1402 // ── accounts: the gate, and the per-user hard purge ────────────────────
1403
1404 /// **Lock `account` out, completely** (`Accounts.Gate`).
1405 ///
1406 /// Returns the gate as stored, and whether the account was **already**
1407 /// gated when the request arrived. Idempotent; the newest writer owns the
1408 /// row, because an operator must be able to suspend an account a purge is
1409 /// already holding.
1410 ///
1411 /// The default is [`ControlError::Unimplemented`] and not a silent success:
1412 /// an adapter that cannot lock anybody out must not answer "locked", or
1413 /// [`ControlPlane::purge_account`] would walk an account that is still
1414 /// live and a caller would be told the user was shut out when they were
1415 /// not.
1416 fn gate_account(
1417 &self,
1418 account: &str,
1419 actor: &str,
1420 note: &str,
1421 ) -> ControlResult<(AccountGate, bool)> {
1422 let _ = (account, actor, note);
1423 Err(ControlError::Unimplemented("Accounts.Gate"))
1424 }
1425
1426 /// **Let `account` back in** (`Accounts.Ungate`). Returns what was lifted,
1427 /// or `None` if it was not gated.
1428 ///
1429 /// Unconditional — it does not consult the holder — because this is the
1430 /// operator's override, and an override that could be blocked by a lease
1431 /// belonging to a process that no longer exists is not an override.
1432 fn ungate_account(&self, account: &str) -> ControlResult<Option<AccountGate>> {
1433 let _ = account;
1434 Err(ControlError::Unimplemented("Accounts.Ungate"))
1435 }
1436
1437 /// **Every account locked out right now** (`Accounts.ListAccountGates`).
1438 ///
1439 /// Empty is a legitimate answer; the default is still `Unimplemented`, for
1440 /// the reason [`ControlPlane::list_deleted`]'s is. A plane that cannot read
1441 /// the gate table must not report that nobody is locked out.
1442 fn account_gates(&self) -> ControlResult<Vec<AccountGate>> {
1443 Err(ControlError::Unimplemented("Accounts.ListAccountGates"))
1444 }
1445
1446 /// **Empty an account: gate it NOW, enqueue the walk** (`Accounts.Purge`).
1447 ///
1448 /// Returns when the account is locked out and the job is accepted, **not**
1449 /// when the account is empty. The two halves are deliberately different in
1450 /// timing: the gate is synchronous so there is no window in which a purge
1451 /// has been accepted and the user can still push, and the walk is queued
1452 /// because it is minutes of unlink I/O and an RPC that held a tonic worker
1453 /// for it would make a console button look broken.
1454 ///
1455 /// The returned [`AccountGate`] is the one now in force — which may be an
1456 /// administrative gate that was already there rather than this job's lease.
1457 fn purge_account(
1458 &self,
1459 account: &str,
1460 options: PurgeAccountOptions,
1461 ) -> ControlResult<(PurgeJob, AccountGate)> {
1462 let _ = (account, options);
1463 Err(ControlError::Unimplemented("Accounts.Purge"))
1464 }
1465
1466 /// **How an account's purge is going** (`Accounts.PurgeStatus`), and the
1467 /// lockout as it stands.
1468 ///
1469 /// `Ok((None, None))` is a real answer: no job is being held for this
1470 /// account and it is not gated. A plane that cannot see the queue answers
1471 /// `Unimplemented` instead — "nothing is running" and "I cannot tell" must
1472 /// not look alike.
1473 fn purge_account_status(
1474 &self,
1475 account: &str,
1476 ) -> ControlResult<(Option<PurgeJob>, Option<AccountGate>)> {
1477 let _ = account;
1478 Err(ControlError::Unimplemented("Accounts.PurgeStatus"))
1479 }
1480
1481 /// **Write one repository's archive bytes above `from_generation` into
1482 /// `sink`** (`Repos.ArchiveOut`).
1483 ///
1484 /// The bytes are exactly what `gunnar repo archive-out --to FILE` writes,
1485 /// so what comes off the wire restores with `archive-in` and nothing new
1486 /// has to understand a second format.
1487 ///
1488 /// **Blocking, on purpose.** It reads files and writes a sink; the service
1489 /// runs it on the blocking pool rather than pretending it is async, which
1490 /// is the same shape `replicate_once` already uses for send-pack.
1491 ///
1492 /// The default is [`ControlError::Unimplemented`] and not an empty
1493 /// success: an archive stream that ends immediately looks exactly like a
1494 /// repository with no bytes, and a backup that silently archived nothing is
1495 /// the failure this whole surface exists to prevent.
1496 fn archive_out(
1497 &self,
1498 path: &RepoPath,
1499 from_generation: u64,
1500 sink: &mut dyn std::io::Write,
1501 ) -> ControlResult<ArchiveCut> {
1502 let _ = (path, from_generation, sink);
1503 Err(ControlError::Unimplemented("Repos.ArchiveOut"))
1504 }
1505
1506 // ── refs and objects (the storage seam: RefStore / ObjectStore) ────────
1507 fn list_refs(&self, path: &RepoPath, prefix: &str, peel: bool)
1508 -> ControlResult<Vec<RefTarget>>;
1509 fn get_ref(&self, path: &RepoPath, ref_name: &str, peel: bool) -> ControlResult<RefTarget>;
1510 fn log(
1511 &self,
1512 path: &RepoPath,
1513 start: &str,
1514 limit: u32,
1515 touching: &str,
1516 ) -> ControlResult<Vec<Commit>>;
1517 /// [`ControlPlane::log`] walked from every branch and tag tip (and `HEAD`)
1518 /// at once, newest committer time first, at most `limit` commits.
1519 fn log_all_refs(
1520 &self,
1521 path: &RepoPath,
1522 limit: u32,
1523 touching: &str,
1524 ) -> ControlResult<Vec<Commit>> {
1525 let _ = (path, limit, touching);
1526 Err(ControlError::Unimplemented("Refs.Log all_refs"))
1527 }
1528
1529 // ── pull requests ──────────────────────────────────────────────────────
1530 /// Open a pull request. `author` is the authenticated principal.
1531 fn create_pull(
1532 &self,
1533 path: &RepoPath,
1534 author: &str,
1535 title: &str,
1536 description: &str,
1537 source_ref: &str,
1538 target_ref: &str,
1539 ) -> ControlResult<PullRequest>;
1540 /// Every pull in one repository; `state` zero means all.
1541 fn list_pulls(&self, path: &RepoPath, state: i32) -> ControlResult<Vec<PullRequest>>;
1542 fn get_pull(&self, path: &RepoPath, number: u64) -> ControlResult<PullRequest>;
1543 fn close_pull(&self, path: &RepoPath, number: u64) -> ControlResult<PullRequest>;
1544 /// FAST-FORWARD ONLY. Moves the target ref through the store's CAS and
1545 /// returns what moved, so the service can publish the same `RefUpdated`
1546 /// a push would.
1547 ///
1548 /// # Why the caller's identity crosses this seam
1549 ///
1550 /// It did not, until GUN-004. `merge_pull(path, number)` moved a
1551 /// **protected** ref and the principal-dependent half of branch protection
1552 /// — `allowed_principals` — was impossible to evaluate on the far side,
1553 /// because the far side did not know who was asking. So `PullRequests.Merge`
1554 /// read the same policy document the push path reads, took
1555 /// `forbid_self_approval` and `required_approvals` out of it, and wrote the
1556 /// ref with `update_ref`: an explicitly configured per-ref protection,
1557 /// bypassed by a side door, while the push path refused correctly and the
1558 /// protection *looked* like it worked.
1559 ///
1560 /// `principal_id` is `Caller::principal_id`, the same string
1561 /// `ServiceContext::require` just checked a grant for.
1562 fn merge_pull(
1563 &self,
1564 path: &RepoPath,
1565 number: u64,
1566 principal_id: &str,
1567 ) -> ControlResult<(PullRequest, MergedRef)>;
1568 /// **A new repository's first commit, written by the server**
1569 /// (`CreateRepoRequest.readme`): `README.md` holding `readme` on the default
1570 /// branch. Answers the commit id (hex). A control plane that keeps no git
1571 /// store refuses by name.
1572 fn write_first_commit(&self, path: &RepoPath, readme: &str) -> ControlResult<String> {
1573 let _ = readme;
1574 Err(ControlError::FailedPrecondition(format!(
1575 "this control plane writes no commit into {path}: it keeps no git store"
1576 )))
1577 }
1578 fn read_object(
1579 &self,
1580 path: &RepoPath,
1581 oid: &str,
1582 max_bytes: u64,
1583 ) -> ControlResult<ReadObjectResponse>;
1584
1585 /// **One LFS object, by the sha256 its pointer names** (`Refs.ReadLfsObject`).
1586 ///
1587 /// A file kept in LFS is a POINTER in the tree, so a caller that reads the
1588 /// tree gets 131 bytes of text where the file should be. This is the way
1589 /// to the bytes for a caller that already holds Read on the repository and
1590 /// speaks gRPC rather than one of `git lfs`'s transfer adapters.
1591 ///
1592 /// Whole object or nothing: a prefix of a git blob is a readable prefix of
1593 /// a file, and a prefix of an LFS object is a broken PDF. Past the ceiling
1594 /// it is refused, and the caller that wants it anyway wants `git lfs`.
1595 ///
1596 /// `range` is `Some((offset, length))` for a ranged read (the request's
1597 /// `offset`/`length`, not both 0): the bytes from `offset`, at most
1598 /// `length` (0 = to the ceiling), the ceiling applied to the range, an
1599 /// offset past the end refused. `None` is the whole-or-refused read.
1600 ///
1601 /// Defaulted to `Unimplemented` so a control plane that has no LFS store —
1602 /// the seams in this crate's tests — needs no stub.
1603 fn read_lfs_object(
1604 &self,
1605 path: &RepoPath,
1606 oid: &str,
1607 max_bytes: u64,
1608 range: Option<(u64, u64)>,
1609 ) -> ControlResult<ReadLfsObjectResponse> {
1610 let _ = (path, oid, max_bytes, range);
1611 Err(ControlError::Unimplemented("Refs.ReadLfsObject"))
1612 }
1613 fn diff(
1614 &self,
1615 path: &RepoPath,
1616 from: &str,
1617 to: &str,
1618 path_prefixes: &[String],
1619 ) -> ControlResult<Vec<FileChange>>;
1620
1621 /// **Blame a file at a revision** (`Refs.Blame`).
1622 ///
1623 /// The walk itself lives in `gunnar-server`'s `blame` module — the ONE
1624 /// implementation the store-local CLI verb already uses (one implementation) — and it
1625 /// runs here against the store the running server already holds, so a
1626 /// no-shell appliance can be blamed over the control plane while the
1627 /// exclusive lock that verb takes makes it impossible to run down.
1628 ///
1629 /// The default is `Unimplemented`, not an empty blame: a control plane that
1630 /// cannot walk must refuse, because zero lines reads as "an empty file",
1631 /// which is a wrong answer a console reader would believe.
1632 fn blame(
1633 &self,
1634 path: &RepoPath,
1635 rev: &str,
1636 file: &str,
1637 ) -> ControlResult<gunnar_proto::BlameResponse> {
1638 let _ = (path, rev, file);
1639 Err(ControlError::Unimplemented("Refs.Blame"))
1640 }
1641
1642 /// **Search one revision's files for a literal string** (`Refs.Search`).
1643 ///
1644 /// The same store-shared, walk-once-live implementation as [`blame`]: the
1645 /// `search` module in `gunnar-server`, reached against the running server's
1646 /// open store. `max_file_bytes` and `max_matches` of zero mean the module's
1647 /// defaults. Empty is a legitimate answer (a string that is not there), so
1648 /// the refusal below is the walk that could not START, never a miss.
1649 ///
1650 /// [`blame`]: ControlPlane::blame
1651 fn search(
1652 &self,
1653 path: &RepoPath,
1654 rev: &str,
1655 pattern: &str,
1656 max_file_bytes: u64,
1657 max_matches: u32,
1658 ) -> ControlResult<gunnar_proto::SearchResponse> {
1659 let _ = (path, rev, pattern, max_file_bytes, max_matches);
1660 Err(ControlError::Unimplemented("Refs.Search"))
1661 }
1662
1663 // ── identity and access (the ACL model) ────────────────────────────────
1664 /// Create a principal with `id` as its identity (and account) and `name`
1665 /// as its display name. The service resolves an empty wire `id` to the
1666 /// name before calling, so this never sees an empty id.
1667 fn add_principal(&self, id: &str, name: &str, kind: PrincipalKind) -> ControlResult<Principal>;
1668 /// **Enroll an employee: a NEW user created by and bound to `account`**
1669 /// (`Accounts.EnrollEmployee`). `AlreadyExists` for an id or name that is
1670 /// taken — an existing principal never becomes an employee — and for an id
1671 /// that names an existing namespace (its owner-by-name would be the
1672 /// employee). `NotFound` when `account` is not a namespace. The employee
1673 /// gets no namespace of its own and `Read` on the account's.
1674 ///
1675 /// With room ("Add users"): see [`EmployeeAsk`]. `FailedPrecondition`
1676 /// for a claim with no open seat; a short account is answered with
1677 /// `shortfall_bytes` and nothing written.
1678 fn enroll_employee(&self, account: &str, ask: &EmployeeAsk) -> ControlResult<EmployeeAnswer>;
1679 /// The account's employees, by id.
1680 fn employees(&self, account: &str) -> ControlResult<Vec<Principal>>;
1681 /// **Has the account enrolled anyone?** What `Access.WhoAmI` asks on every call; a plane
1682 /// that can answer without listing (CoreControlPlane) says so.
1683 fn has_employees(&self, account: &str) -> ControlResult<bool> {
1684 Ok(!self.employees(account)?.is_empty())
1685 }
1686 /// The account's seats: every seat granted, and the open ones.
1687 fn seats(&self, account: &str) -> ControlResult<(u32, u32)> {
1688 let _ = account;
1689 Ok((0, 0))
1690 }
1691 /// **Clans** (2026-10-05, [`crate::clans::ClanPlane`]), when this plane
1692 /// keeps an identity table that holds them. `None`: every `Clans` verb is
1693 /// UNIMPLEMENTED and no namespace is a clan's.
1694 fn clans(&self) -> Option<&dyn crate::clans::ClanPlane> {
1695 None
1696 }
1697 /// **The estate's operator, created on first claim** (`Accounts.ClaimOperator`,
1698 /// 2026-10-04). `asked` is the caller's name for it, used only when nothing
1699 /// was fed ([`operator_name`]). Idempotent; an ownerless namespace of that
1700 /// name is adopted; a machine under the name is `FAILED_PRECONDITION`. The
1701 /// principal is created keyless and gunnar-admin. The default is the
1702 /// honest answer of a plane that keeps no operator.
1703 fn claim_operator(&self, asked: &str) -> ControlResult<OperatorClaim> {
1704 let _ = asked;
1705 Err(ControlError::Unimplemented("Accounts.ClaimOperator"))
1706 }
1707 /// **The operator's first invite** (Stage L, 2026-10-04): record
1708 /// `secret_sha256` (checked by [`bootstrap_hash`]) as the invite
1709 /// [`OPERATOR_INVITE_ID`] for `operator`'s first passkey — no server
1710 /// grant, expiring `ttl_ms` after `now` (checked by [`bootstrap_ttl`]:
1711 /// [`OPERATOR_INVITE_TTL_MS`] for the kit's own estates, shorter for a
1712 /// SOLD estate's first-login link) — unless the operator already holds a
1713 /// passkey ([`BootstrapInvite::Spent`]). The console's first-admin
1714 /// registration, or the buyer's first-login link, redeems it through
1715 /// `Access.WebRegister`.
1716 fn operator_bootstrap_invite(
1717 &self,
1718 operator: &str,
1719 secret_sha256: &str,
1720 now_unix_ms: i64,
1721 ttl_ms: i64,
1722 ) -> ControlResult<BootstrapInvite> {
1723 let _ = (operator, secret_sha256, now_unix_ms, ttl_ms);
1724 Err(ControlError::Unimplemented(
1725 "Accounts.ClaimOperator (bootstrap invite)",
1726 ))
1727 }
1728 fn list_principals(&self) -> ControlResult<Vec<Principal>>;
1729 /// **When each principal last signed in and last changed something**
1730 /// (2026-10-21), by principal id; a principal with no entry was never seen.
1731 /// The default knows nothing — a plane that keeps no stamps answers
1732 /// "never" for everybody, never an error.
1733 fn principal_activity(
1734 &self,
1735 ) -> ControlResult<std::collections::BTreeMap<String, PrincipalSeen>> {
1736 Ok(std::collections::BTreeMap::new())
1737 }
1738 /// One principal by its display name, or `None`.
1739 fn principal_by_name(&self, name: &str) -> ControlResult<Option<Principal>>;
1740 /// One principal by its ID, or `None` (`Access.WhoAmI`). The default walks
1741 /// [`ControlPlane::list_principals`]; a plane with an id index may answer
1742 /// it directly.
1743 fn principal(&self, id: &str) -> ControlResult<Option<Principal>> {
1744 Ok(self.list_principals()?.into_iter().find(|p| p.id == id))
1745 }
1746 /// **Does `principal_id` hold a SERVER-scope grant** (the seller's mark,
1747 /// `Namespace.owner_server_grant`)? The default: no grant is known here.
1748 fn holds_server_grant(&self, principal_id: &str) -> ControlResult<bool> {
1749 let _ = principal_id;
1750 Ok(false)
1751 }
1752 /// Change the display name and nothing else. `AlreadyExists` when another
1753 /// principal carries the name (as name or id); `NotFound` for an unknown id.
1754 fn rename_principal(&self, principal_id: &str, name: &str) -> ControlResult<Principal>;
1755 /// **Register a web credential on a principal** (`Access.AddCredential`,
1756 /// Stage L). `NotFound` for an unknown principal; `FailedPrecondition` for
1757 /// one that is not a USER (no human credential on a machine principal);
1758 /// `AlreadyExists` for a credential id already registered. The public key
1759 /// is validated by the service before it gets here. Removed with the
1760 /// principal. Defaults to `Unimplemented`: a plane that keeps no identity
1761 /// table keeps no passkeys either.
1762 fn add_credential(
1763 &self,
1764 principal_id: &str,
1765 credential_id: &[u8],
1766 public_key: &[u8],
1767 label: &str,
1768 sign_count: u32,
1769 ) -> ControlResult<gunnar_proto::WebCredential> {
1770 let _ = (principal_id, credential_id, public_key, label, sign_count);
1771 Err(ControlError::Unimplemented("Access.AddCredential"))
1772 }
1773 /// One principal's web credentials, or every one (`None`).
1774 fn credentials(
1775 &self,
1776 principal_id: Option<&str>,
1777 ) -> ControlResult<Vec<gunnar_proto::WebCredential>> {
1778 let _ = principal_id;
1779 Err(ControlError::Unimplemented("Access.ListCredentials"))
1780 }
1781 /// One web credential with its public key, or `None`.
1782 fn credential(&self, credential_id: &[u8]) -> ControlResult<Option<StoredCredential>> {
1783 let _ = credential_id;
1784 Err(ControlError::Unimplemented("Access.RevokeCredential"))
1785 }
1786 /// **A passkey was used** (the identity core's login and step-up,
1787 /// IDENTITY-HOST 2026-10-27): its counter and last use, in one update. The
1788 /// core checked the counter rule; one that went back is still refused here
1789 /// (`PermissionDenied`). `NotFound` for a credential that is gone.
1790 fn record_passkey_use(
1791 &self,
1792 credential_id: &[u8],
1793 sign_count: u32,
1794 now_unix_ms: i64,
1795 ) -> ControlResult<gunnar_proto::WebCredential> {
1796 let _ = (credential_id, sign_count, now_unix_ms);
1797 Err(ControlError::Unimplemented("IdentityService.LoginFinish"))
1798 }
1799 /// **Set a principal's server-wide level** (the identity core's first
1800 /// admin, IDENTITY-HOST): the scope-less grant.
1801 fn set_server_level(&self, principal_id: &str, level: GrantLevel) -> ControlResult<()> {
1802 self.grant(principal_id, "", "", level).map(|_| ())
1803 }
1804 /// Change a web credential's label; the updated row, or `None` if there
1805 /// is no such credential.
1806 fn rename_credential(
1807 &self,
1808 credential_id: &[u8],
1809 label: &str,
1810 ) -> ControlResult<Option<gunnar_proto::WebCredential>> {
1811 let _ = (credential_id, label);
1812 Err(ControlError::Unimplemented("Access.RenameCredential"))
1813 }
1814 /// **The operator's first invite, if one is pending** (IDENTITY-HOST): the
1815 /// record [`ControlPlane::operator_bootstrap_invite`] made, which the
1816 /// identity core reads as a fed invite of its own. The default: none.
1817 fn first_invite(&self) -> ControlResult<Option<FirstInvite>> {
1818 Ok(None)
1819 }
1820 /// **The operator's first invite is spent or withdrawn** (the identity core
1821 /// redeemed it, or its person holds a passkey): the record goes.
1822 fn drop_first_invite(&self) -> ControlResult<()> {
1823 Ok(())
1824 }
1825 /// **Commit an approved machine enrolment** (2026-10-05): see
1826 /// [`MachineApproval`]. The ONE way a web credential lands on a MACHINE
1827 /// principal — `add_credential` and the identity core still refuse one. A
1828 /// step-up counter that did not increase is `PermissionDenied`. The
1829 /// operator's first invite is never touched: it is the owner's.
1830 fn approve_machine_enrolment(
1831 &self,
1832 approval: MachineApproval,
1833 ) -> ControlResult<MachineApproved> {
1834 let _ = approval;
1835 Err(ControlError::Unimplemented(
1836 "Access.ApproveMachineEnrolment",
1837 ))
1838 }
1839 /// The account that enrolled `principal_id` as an employee, if any.
1840 fn employer_of(&self, principal_id: &str) -> ControlResult<Option<String>> {
1841 let _ = principal_id;
1842 Err(ControlError::Unimplemented("Accounts.EnrollEmployee"))
1843 }
1844 /// **Company SSO's storage** ([`crate::sso::plane::SsoPlane`]), when this
1845 /// plane has an identity table that holds it. `None`: every `Sso` verb
1846 /// answers UNIMPLEMENTED, and `Access.WebLogin` knows of no `require_sso`.
1847 fn sso(&self) -> Option<&dyn crate::sso::plane::SsoPlane> {
1848 None
1849 }
1850 /// **Start a directory import as a background job** (`Sso.ImportUsers`,
1851 /// A34-17): one per account at a time on the server's jera lane, the
1852 /// whole file in one write. Returns the job as it stands; an account with
1853 /// an import still queued or running is refused by name.
1854 fn sso_import(
1855 &self,
1856 order: crate::sso::scim::ImportOrder,
1857 ) -> ControlResult<crate::sso::scim::ImportJob> {
1858 let _ = order;
1859 Err(ControlError::Unimplemented("Sso.ImportUsers"))
1860 }
1861 /// **The account's latest import job** (`Sso.ImportStatus`), if the
1862 /// server still holds it.
1863 fn sso_import_status(
1864 &self,
1865 account: &str,
1866 ) -> ControlResult<Option<crate::sso::scim::ImportJob>> {
1867 let _ = account;
1868 Err(ControlError::Unimplemented("Sso.ImportStatus"))
1869 }
1870 /// Remove a web credential; the row it was, or `None` if there was none.
1871 fn revoke_credential(
1872 &self,
1873 credential_id: &[u8],
1874 ) -> ControlResult<Option<gunnar_proto::WebCredential>> {
1875 let _ = credential_id;
1876 Err(ControlError::Unimplemented("Access.RevokeCredential"))
1877 }
1878
1879 /// Returns (keys revoked, grants revoked).
1880 fn remove_principal(&self, principal_id: &str) -> ControlResult<(u32, u32)>;
1881 /// Register a key on `principal_id`.
1882 ///
1883 /// `caller` is the principal on the connection (empty under allow-all), and
1884 /// `attestation` the target's signed word that this key may act as them.
1885 /// The rule for when the attestation is required is
1886 /// `gunnar_core::key_attestation::Requirement::of`, one writer both planes
1887 /// ask; the plane derives the new key's fingerprint itself and checks the
1888 /// attestation against it before anything is written.
1889 #[allow(clippy::too_many_arguments)]
1890 fn add_key(
1891 &self,
1892 principal_id: &str,
1893 openssh_public_key: &str,
1894 comment: &str,
1895 expires_at_unix_ms: Option<i64>,
1896 caller: &str,
1897 attestation: Option<SignedKeyAttestation>,
1898 ) -> ControlResult<PublicKey>;
1899 fn revoke_key(&self, fingerprint: &str) -> ControlResult<()>;
1900 fn list_keys(&self, principal_id: Option<&str>) -> ControlResult<Vec<PublicKey>>;
1901 fn grant(
1902 &self,
1903 principal_id: &str,
1904 namespace: &str,
1905 repo: &str,
1906 level: GrantLevel,
1907 ) -> ControlResult<Grant>;
1908 fn revoke(&self, principal_id: &str, namespace: &str, repo: &str) -> ControlResult<()>;
1909 fn list_grants(
1910 &self,
1911 principal_id: Option<&str>,
1912 namespace: Option<&str>,
1913 repo: Option<&str>,
1914 ) -> ControlResult<Vec<Grant>>;
1915
1916 // ── groups (punkt 9) ───────────────────────────────────────────────────
1917 //
1918 // A group is a principal (`add_principal` with `PrincipalKind::Group`);
1919 // these manage its membership. A member inherits the group's grants
1920 // through `may`, so the RPC gates each of these on Admin exactly as it gates
1921 // `grant`. Default bodies return `Unimplemented` so a control plane that has
1922 // no membership store (the in-memory test double) compiles unchanged.
1923 //
1924 // Add and Remove take the acting admin's `signed` event and, like
1925 // `forge_append`, verify it against the ACL key table before applying and
1926 // append it to the signed membership log afterwards — the verification
1927 // lives behind this seam so there is one implementation of "is this
1928 // evidence" and not one per transport (one implementation). They return the log index
1929 // the event landed at, the index an inclusion proof is taken at.
1930 /// Verify the acting admin's signed event, make `member_id` a member of
1931 /// `group_id`, and append the event to the membership log.
1932 fn add_group_member(
1933 &self,
1934 group_id: &str,
1935 member_id: &str,
1936 signed: SignedMembershipEvent,
1937 ) -> ControlResult<u64> {
1938 let _ = (group_id, member_id, signed);
1939 Err(ControlError::Unimplemented("Access.AddGroupMember"))
1940 }
1941 /// Verify the signed event, remove `member_id` from `group_id`, and append
1942 /// it. Returns whether it was a member and the log index.
1943 fn remove_group_member(
1944 &self,
1945 group_id: &str,
1946 member_id: &str,
1947 signed: SignedMembershipEvent,
1948 ) -> ControlResult<(bool, u64)> {
1949 let _ = (group_id, member_id, signed);
1950 Err(ControlError::Unimplemented("Access.RemoveGroupMember"))
1951 }
1952 /// The direct members of a group.
1953 fn list_group_members(&self, group_id: &str) -> ControlResult<Vec<Principal>> {
1954 let _ = group_id;
1955 Err(ControlError::Unimplemented("Access.ListGroupMembers"))
1956 }
1957 /// The signed membership log, optionally filtered to one group, in append
1958 /// order — the evidence behind the live table.
1959 fn membership_events(
1960 &self,
1961 group_id: Option<&str>,
1962 ) -> ControlResult<Vec<SignedMembershipEvent>> {
1963 let _ = group_id;
1964 Err(ControlError::Unimplemented(
1965 "Access.ListGroupMembershipEvents",
1966 ))
1967 }
1968
1969 // ── policy (the pre-receive gate) ──────────────────────────────────────
1970 fn get_protection(
1971 &self,
1972 path: &RepoPath,
1973 ref_pattern: Option<&str>,
1974 ) -> ControlResult<Vec<BranchProtection>>;
1975 fn set_protection(
1976 &self,
1977 path: &RepoPath,
1978 protection: BranchProtection,
1979 ) -> ControlResult<BranchProtection>;
1980 fn delete_protection(&self, path: &RepoPath, ref_pattern: &str) -> ControlResult<()>;
1981 fn get_ref_name_policy(&self, path: &RepoPath) -> ControlResult<RefNamePolicy>;
1982 fn set_ref_name_policy(
1983 &self,
1984 path: &RepoPath,
1985 policy: RefNamePolicy,
1986 ) -> ControlResult<RefNamePolicy>;
1987
1988 // ── forge: the review half (roadmap phase 1) ───────────────────────────
1989 //
1990 // The server never signs. `forge_append` takes an event the caller signed,
1991 // verifies it against the ACL key table, and appends it; the verification
1992 // lives behind this seam rather than in the service so that there is one
1993 // implementation of "is this evidence" and not one per transport (one implementation).
1994 //
1995 // There is no `next_pull` here: the number belongs to the record, and
1996 // `create_pull` assigns it. A second numbering authority is exactly the
1997 // twin this split exists to avoid.
1998 /// Verify a signed review event against the registered keys and append it.
1999 ///
2000 /// Returns the index it landed at — the index an inclusion proof is taken
2001 /// at. `InvalidArgument` for an event that does not verify, because a
2002 /// signature that does not check out is a malformed request and not a
2003 /// server fault.
2004 fn forge_append(&self, event: SignedForgeEvent) -> ControlResult<u64>;
2005 /// **Append an event gunnar attests** (GHUNT-19): `event` arrives UNSIGNED
2006 /// from a person's identity-core session that stepped up — which holds no
2007 /// key of theirs — and the plane signs it with gunnar's own
2008 /// forge-attestation key, the actor named and
2009 /// `session_stepped_up_at_unix_ms` inside the signature. The caller (the
2010 /// service) has already checked that the session stepped up and that the
2011 /// actor is its person; the subject is checked here exactly as for a signed
2012 /// event. Returns the index it landed at and the attestation in words
2013 /// (`attested for session step-up at <time>`), for the audit row.
2014 fn forge_append_attested(
2015 &self,
2016 event: SignedForgeEvent,
2017 session_stepped_up_at_unix_ms: i64,
2018 ) -> ControlResult<(u64, String)> {
2019 let _ = (event, session_stepped_up_at_unix_ms);
2020 Err(ControlError::Unimplemented("Forge.Append (attested)"))
2021 }
2022 /// One pull request's review, folded, with every approval re-checked now.
2023 fn forge_review(
2024 &self,
2025 repo: &str,
2026 pull: u64,
2027 ) -> ControlResult<(ReviewState, Vec<SignedForgeEvent>)>;
2028 /// The review on every pull request in `repo` that has one.
2029 fn forge_reviews(&self, repo: &str) -> ControlResult<Vec<ReviewState>>;
2030
2031 // ── forge: the issue half ──────────────────────────────────────────────
2032 //
2033 // The SAME log and the SAME `forge_append`: an issue event is signed,
2034 // verified against the ACL key table and appended exactly as a review event
2035 // is. What differs is only the fold — an issue owns its open/closed state
2036 // where a review defers it to the pull request record — so these read the
2037 // one log through a different fold rather than opening a second store.
2038 /// One issue, folded from its events, or `None` if nothing opened that
2039 /// number. The whole thread is returned alongside — the opening, every
2040 /// comment, and the close if there is one.
2041 fn forge_issue(
2042 &self,
2043 repo: &str,
2044 number: u64,
2045 ) -> ControlResult<Option<(IssueState, Vec<SignedForgeEvent>)>>;
2046 /// Every issue in `repo`, folded, in the order they were opened.
2047 fn forge_issues(&self, repo: &str) -> ControlResult<Vec<IssueState>>;
2048
2049 // ── admin ──────────────────────────────────────────────────────────────
2050 fn counts(&self) -> ControlResult<ControlCounts>;
2051
2052 /// **Tombstones and replication, for `Admin.Stats`.**
2053 ///
2054 /// The default is the honest empty one and NOT `Unimplemented`: unlike a
2055 /// ref list, "no tombstones and no standbys" is a real state a caller can
2056 /// act on, and a `Stats` that failed because a test double has no
2057 /// replicator would make the whole call unusable.
2058 fn stats_extras(&self) -> ControlResult<StatsExtras> {
2059 Ok(StatsExtras::default())
2060 }
2061 fn storage_info(&self, path: Option<&RepoPath>) -> ControlResult<StorageInfoResponse>;
2062
2063 /// **"Which CA does this box mint with?"** (`Admin.IssuerInfo`).
2064 ///
2065 /// A read of the PKI control document: the live issuer's certificate and
2066 /// fingerprint, and the trusted-CA list. `gunnar-server` answers it
2067 /// through [`ServerStores::pki_readonly`], whose secret store refuses
2068 /// every call — so this seam cannot mint, cannot rotate, and cannot reach
2069 /// the private key even if someone later edits the body of the one
2070 /// implementation.
2071 ///
2072 /// The default is `Unimplemented` and NOT `has_issuer: false`, for the
2073 /// reason [`Self::capacity`] gives above: a control plane with no PKI
2074 /// behind it has nothing to read, and "this box has no issuer" is a
2075 /// SPECIFIC and actionable fact — it is what a never-served data root
2076 /// looks like. A caller told that when the truth is "I cannot say" would
2077 /// go and mint one over the top of a CA their whole fleet pins.
2078 fn issuer_info(&self) -> ControlResult<gunnar_proto::IssuerInfoResponse> {
2079 Err(ControlError::Unimplemented("Admin.IssuerInfo"))
2080 }
2081
2082 /// **"Is the record of what happened here still complete?"**
2083 /// (`Admin.AuditTrail`, lane T11, 2026-09-21).
2084 ///
2085 /// The shape of the control plane's tamper-evident audit chain — entry
2086 /// count, tip hash, whether it verifies, whether it is replicated into the
2087 /// transaction log, and optionally the hash at one sequence number. Never
2088 /// an entry body: this answers completeness, not contents.
2089 ///
2090 /// The default is `Unimplemented` and NOT `present: false`, for the reason
2091 /// [`Self::issuer_info`] gives: "this box keeps no trail" is a specific and
2092 /// alarming fact, and a caller told it when the truth is "I cannot say"
2093 /// would report a box as unaudited that is not.
2094 fn audit_trail(&self, _at_seq: u64) -> ControlResult<gunnar_proto::AuditTrailResponse> {
2095 Err(ControlError::Unimplemented("Admin.AuditTrail"))
2096 }
2097
2098 /// **A generic server setting** (`Admin.GetSetting`, owner decision 2026-10-26): the bytes
2099 /// stored under `name`, `None` where nothing is. The server knows no setting name: the
2100 /// client names them (gunnar-core `settings`).
2101 fn setting(&self, _name: &str) -> ControlResult<Option<Vec<u8>>> {
2102 Err(ControlError::Unimplemented("Admin.GetSetting"))
2103 }
2104
2105 /// **Store a generic server setting** (`Admin.SetSetting`): `value` under `name`; the value
2106 /// it replaced, `None` for a new name. Bounded (gunnar-core `settings`), and a refusal names
2107 /// the bound.
2108 fn set_setting(&self, _name: &str, _value: &[u8]) -> ControlResult<Option<Vec<u8>>> {
2109 Err(ControlError::Unimplemented("Admin.SetSetting"))
2110 }
2111
2112 /// **"If a tenant's cap rose to N bytes, could you serve it?"**
2113 /// (`Admin.Capacity`) — the question a seller must ask BEFORE the money
2114 /// moves, and OPEN SOURCE, ALWAYS ON: a self-hoster wants to know whether
2115 /// their disk can grow as much as a service provider does, so this is not
2116 /// behind `metered`.
2117 ///
2118 /// Not "how much free space is there". The answer carries a verdict AND,
2119 /// independently, whether the data set can ever hold more than it does —
2120 /// because on the appliance the second answer is NO and a caller that read
2121 /// a single small number would buy a disk that is billed and invisible.
2122 /// `gunnar-server`'s `capacity` module is the ONE implementation (one implementation);
2123 /// it measures with the same `statvfs` the push door refuses under.
2124 ///
2125 /// The default is `Unimplemented`, not a plausible answer: a control plane
2126 /// with no filesystem behind it has nothing to measure, and "0 bytes free"
2127 /// from it would read as a full disk rather than as an unanswered
2128 /// question — which is exactly the blind guard to avoid.
2129 fn capacity(
2130 &self,
2131 _request: &gunnar_proto::CapacityRequest,
2132 ) -> ControlResult<gunnar_proto::CapacityResponse> {
2133 Err(ControlError::Unimplemented("Admin.Capacity"))
2134 }
2135
2136 // ── The runtime data-set growth (`Admin.Grow*`, `Bootstrap.GrowStatus`) ──
2137 //
2138 // `DATA-SET-GROWTH-FLOW.md` §3, ruling 1. `gunnar-server`'s `grow` module
2139 // is the ONE implementation; a control plane without it answers an honest
2140 // `idle` with `engine_present: false` on status — never `Unimplemented`,
2141 // because the credential-free status is what an outside actor reads to
2142 // decide about the volumes, and "I cannot say" must not look like "ask
2143 // again later" — and refuses the two mutators by name.
2144
2145 /// Begin a runtime growth: drain, unmount, wait, grow, mount, reopen.
2146 fn grow_start(
2147 &self,
2148 _request: &gunnar_proto::GrowStartRequest,
2149 ) -> ControlResult<gunnar_proto::GrowStatusResponse> {
2150 Err(ControlError::Unimplemented(
2151 "Admin.GrowStart: this control plane has no runtime grow engine (gunnar-server built without `grow-runtime`)",
2152 ))
2153 }
2154
2155 /// The growth's phase, members and journal. Answered with and without a
2156 /// credential (`Admin.GrowStatus`, `Bootstrap.GrowStatus`).
2157 fn grow_status(&self) -> ControlResult<gunnar_proto::GrowStatusResponse> {
2158 Ok(gunnar_proto::GrowStatusResponse {
2159 phase: "idle".to_owned(),
2160 engine_present: false,
2161 serving: true,
2162 server_unix_ms: crate::bus::now_unix_ms(),
2163 detail: "this control plane has no runtime grow engine; the data set grows only at a restart, if at all".to_owned(),
2164 ..Default::default()
2165 })
2166 }
2167
2168 /// The outside actor says "look again" (or the operator gives up).
2169 fn grow_resume(&self, _give_up: bool) -> ControlResult<gunnar_proto::GrowStatusResponse> {
2170 Err(ControlError::Unimplemented(
2171 "Admin.GrowResume: this control plane has no runtime grow engine (gunnar-server built without `grow-runtime`)",
2172 ))
2173 }
2174
2175 /// **Per-namespace meters** (`Usage.Read` / `Usage.ReadAll`), the numbers a
2176 /// bill is made of — and OPEN SOURCE, ALWAYS ON: a self-hoster wants them
2177 /// as much as a service provider does, so this is not behind `metered`.
2178 ///
2179 /// `Some(ns)` answers one namespace and is `NotFound` for one the catalog
2180 /// does not hold; `None` answers every namespace, including ones with no
2181 /// repositories (`repos = 0`, everything else 0) — a tenant with nothing
2182 /// is a real row, not an absent one. What each meter counts, file by
2183 /// file, is written in `proto/gunnar/v1/usage.proto`; the ONE
2184 /// implementation is `gunnar-server`'s (one implementation), which measures the same
2185 /// directories `Admin.StorageInfo` walks, attributed per file.
2186 ///
2187 /// The default is `Unimplemented`, not an empty answer: a control plane
2188 /// with no store behind it has no bytes to count, and "0 bytes" from it
2189 /// would read as a free tenant rather than as an unanswered question.
2190 fn usage(&self, namespace: Option<&str>) -> ControlResult<Vec<gunnar_proto::NamespaceUsage>> {
2191 let _ = namespace;
2192 Err(ControlError::Unimplemented("Usage"))
2193 }
2194
2195 /// **One namespace's repositories, each with what it stores**
2196 /// (`Usage.Read`'s `repos`, 2026-10-04) — measured by the same walk that
2197 /// sums [`ControlPlane::usage`], so the rows' `stored_bytes` add up to the
2198 /// namespace's `pack_bytes`. Sorted by repository name. `NotFound` for a
2199 /// namespace the catalog does not hold; the default is `Unimplemented`, for
2200 /// [`ControlPlane::usage`]'s reason.
2201 fn repo_usage(&self, namespace: &str) -> ControlResult<Vec<gunnar_proto::RepoStored>> {
2202 let _ = namespace;
2203 Err(ControlError::Unimplemented("Usage"))
2204 }
2205
2206 /// **One page of every namespace's meters** (`UsageBatch.ReadMany`,
2207 /// 2026-10-06), in namespace order, at most `page_size` rows, resuming
2208 /// after `page_token` ([`usage_page_token`]); with a `changed_since` cursor
2209 /// it may answer only the namespaces whose row changed.
2210 ///
2211 /// The default is CORRECT and not cheap: it pages over
2212 /// [`ControlPlane::usage`]`(None)` and never honours `changed_since`
2213 /// (`changed_only = false`, no cursor) — every plane that can read usage can
2214 /// page it. `gunnar-server` overrides it with its usage ledger, which is the
2215 /// whole point of the call.
2216 fn usage_page(
2217 &self,
2218 page_token: &str,
2219 page_size: usize,
2220 changed_since: &str,
2221 ) -> ControlResult<gunnar_proto::ReadManyUsageResponse> {
2222 let _ = changed_since;
2223 let (_, after) = usage_page_token::decode(page_token);
2224 let mut rows = self.usage(None)?;
2225 rows.sort_by(|a, b| a.namespace.cmp(&b.namespace));
2226 Ok(usage_page_token::page(
2227 rows,
2228 &after,
2229 page_size,
2230 String::new(),
2231 false,
2232 ))
2233 }
2234
2235 /// **The FLOW series** (`Usage.Activity`): one row per UTC day this
2236 /// namespace moved anything, oldest first — and OPEN SOURCE, ALWAYS ON,
2237 /// for [`ControlPlane::usage`]'s reason.
2238 ///
2239 /// The window is inclusive at both ends and resolved to whole UTC days;
2240 /// `0` for `since_unix_ms` means the whole kept window, `0` for
2241 /// `until_unix_ms` means now. A day with no traffic is ABSENT rather than
2242 /// returned as a row of zeroes: the caller knows which days it asked for,
2243 /// and a day that was never written and a day on which nothing happened
2244 /// are the same fact.
2245 ///
2246 /// The second half of the answer is how many days this server keeps, which
2247 /// is what lets a caller tell an empty series that means *nothing
2248 /// happened* from one that means *you asked about a time this box no
2249 /// longer remembers*.
2250 ///
2251 /// The default is `Unimplemented`, not an empty series — for exactly the
2252 /// reason [`ControlPlane::usage`]'s is. A control plane with no flow ledger
2253 /// behind it counted nothing, and "no days" from it would read as a silent
2254 /// tenant rather than as an unanswered question. That distinction is the
2255 /// whole point of this call.
2256 fn activity(
2257 &self,
2258 namespace: &str,
2259 since_unix_ms: i64,
2260 until_unix_ms: i64,
2261 ) -> ControlResult<(Vec<gunnar_proto::DayActivity>, i64)> {
2262 let _ = (namespace, since_unix_ms, until_unix_ms);
2263 Err(ControlError::Unimplemented("Usage.Activity"))
2264 }
2265
2266 /// **The admission plan for a namespace** (`Usage.AdmitPlan`): its usage
2267 /// over the rolling window, the budget, and per kind of work the class
2268 /// the gate would give the next transfer, the learnt estimate and the
2269 /// plan's sentence. Open source and always on — a build without
2270 /// `admit-share` answers `pass-through`, which is the truth about it.
2271 ///
2272 /// The default is `Unimplemented`, for [`ControlPlane::usage`]'s reason: a
2273 /// control plane with no admission gate behind it has no plan to report.
2274 fn admit_plan(&self, namespace: &str) -> ControlResult<gunnar_proto::AdmitPlanResponse> {
2275 let _ = namespace;
2276 Err(ControlError::Unimplemented("Usage.AdmitPlan"))
2277 }
2278
2279 // ── the METERED appliance: `Entitlement` ────────────────────────────────
2280 //
2281 // Compiled only with the `metered` feature, and then the defaults are a
2282 // refusal BY NAME — the in-memory test double has no monetize key to
2283 // verify a fact against and no control document to keep it in. Off, these
2284 // methods do not exist: a `ControlPlane` with no `entitlement_set` is the
2285 // whole point (the metered build split), not a stub that returns
2286 // `Unimplemented` from a binary that still carries the code.
2287
2288 /// **The fact being ENFORCED for one namespace**, or `None` = Free.
2289 ///
2290 /// # The contract, and the one implementation that does not keep it
2291 ///
2292 /// "Enforced", not "stored". Two things read a namespace's plan on this
2293 /// box and they read different places:
2294 ///
2295 /// * `gunnar_ssh::PlanGate` — the push door, `Entitlement.Set`'s whole
2296 /// purpose — asks the CACHE (`EntitlementCache::allows`), which holds
2297 /// only facts that verified under the configured monetize key;
2298 /// * this method, on `gunnar-server`, read the STORE until 2026-09-21
2299 /// (`EntitlementSlot::get` → `EntitlementStore::get`), which holds
2300 /// whatever was written, unverified on the way out. **It now reads the
2301 /// cache** — the fix described below is made, and
2302 /// `gunnar-server/tests/entitlement.rs`
2303 /// `a_box_restored_under_the_wrong_key_reports_no_plan_it_does_not_enforce`
2304 /// pins it. The rest of this note is the reason, kept.
2305 ///
2306 /// They agreed on every path except one, and it is a path that runs on
2307 /// every restart. `EntitlementSlot::open` loads each stored fact and pushes
2308 /// it into the cache **through `monetize_embed::signing::verify_fact`**; a
2309 /// fact that no longer verifies — the monetize key was rotated, the control
2310 /// document was edited — is logged (`entitlement.boot.refused`) and left
2311 /// out, and that namespace is Free for everything the cache answers.
2312 ///
2313 /// The store still has it. So `Usage.Read` reports its caps as the
2314 /// namespace's `Allowance`, `Entitlement.Get` returns it, and
2315 /// `Entitlement.List` lists it, while NOTHING on the box enforces any of
2316 /// it. That is precisely the defect `Allowance.enforcement` was added to
2317 /// close, coming back through a different door and worse: there the tenant
2318 /// was shown a measured number labelled as a limit, here they are shown a
2319 /// `pack_bytes` cap labelled `ENFORCED` — by
2320 /// [`crate::service::meters::METERS`], correctly, since that table
2321 /// describes the CODE — with no fact in force behind it.
2322 ///
2323 /// **A rejected fact must not be reported as an allowance.** The fix is in
2324 /// the implementation and not here: `EntitlementSlot::get` must answer out
2325 /// of the same snapshot `PlanGate` reads, so that a fact refused at boot is
2326 /// `None` to both. Reporting a plan the box will not act on is a promise
2327 /// nobody keeps, and it is silent — the only trace today is one
2328 /// `tracing::error!` at startup.
2329 ///
2330 /// Refusing to serve `Usage.Read` at all in that state would be the wrong
2331 /// repair: the METERS are still true, and a tenant whose signature broke
2332 /// still deserves to see what they hold.
2333 #[cfg(feature = "metered")]
2334 fn entitlement_get(
2335 &self,
2336 namespace: &str,
2337 ) -> ControlResult<Option<gunnar_proto::EntitlementFact>> {
2338 let _ = namespace;
2339 Err(ControlError::Unimplemented("Entitlement.Get"))
2340 }
2341
2342 /// Every fact, one per namespace.
2343 #[cfg(feature = "metered")]
2344 fn entitlement_list(&self) -> ControlResult<Vec<gunnar_proto::EntitlementFact>> {
2345 Err(ControlError::Unimplemented("Entitlement.List"))
2346 }
2347
2348 /// The stored facts this box refused when it booted (`Entitlement.List`'s
2349 /// `refused_at_boot`). Default `None`: nothing refused, or nothing held.
2350 #[cfg(feature = "metered")]
2351 fn entitlement_refused_at_boot(&self) -> ControlResult<Option<gunnar_proto::RefusedAtBoot>> {
2352 Ok(None)
2353 }
2354
2355 /// **Verify, log, store, swap.** Returns the state now enforced and the
2356 /// sequence number (line index) of the attestation line the accepted
2357 /// fact was appended as. A fact that does not verify is
2358 /// [`ControlError::PermissionDenied`] naming why, and NOTHING moves — not
2359 /// the snapshot, not the document, not the log.
2360 #[cfg(feature = "metered")]
2361 fn entitlement_set(
2362 &self,
2363 fact: gunnar_proto::EntitlementFact,
2364 ) -> ControlResult<(gunnar_proto::EntitlementState, u64)> {
2365 let _ = fact;
2366 Err(ControlError::Unimplemented("Entitlement.Set"))
2367 }
2368
2369 // ── the METERED appliance: `Billing` ────────────────────────────────────
2370 //
2371 // Two methods and a deliberate shape: this side of the seam holds the
2372 // KEY, and the other side holds the ticket FORMAT.
2373 //
2374 // `crate::service::billing` builds the ticket, asks here for a signature
2375 // over bytes, and pastes the result in. It is never handed key material.
2376 // The alternative — a seam method that returns the private key — would put
2377 // a signing key into a crate whose job is to parse requests, and would add
2378 // a second place on this box that can produce the appliance's signature
2379 // over anything at all. The alternative in the other direction — a seam
2380 // method that mints the whole ticket — would move the format into
2381 // `gunnar-server` and leave the grant check and the thing it guards in two
2382 // different crates.
2383
2384 /// **The public half of the actor-ticket key, 64 lowercase hex characters**
2385 /// (`Billing.ApplianceIdentity`).
2386 ///
2387 /// The default is a refusal BY NAME rather than a fabricated key: a control
2388 /// plane that cannot reach a secret store has no key, and answering with
2389 /// something that looks like one would be advertising a key that verifies
2390 /// nothing this box will ever sign.
2391 #[cfg(feature = "metered")]
2392 fn actor_ticket_public_key_hex(&self) -> ControlResult<String> {
2393 Err(ControlError::Unimplemented("Billing.ApplianceIdentity"))
2394 }
2395
2396 /// **Sign `message` with the actor-ticket key** — raw Ed25519, 64 bytes, no
2397 /// envelope.
2398 ///
2399 /// `message` is `monetize_embed::ticket::ticket_message` of an unsigned
2400 /// ticket, and this method has no opinion about that: it takes bytes,
2401 /// because the half that holds the key is not the half that knows the
2402 /// format, and an opinion here would be a second place the format is
2403 /// written down.
2404 ///
2405 /// **Every call to this is a ticket that exists.** The caller must have
2406 /// passed its grant check before it gets here — `service::billing` does,
2407 /// and `tests/billing.rs` counts the calls to prove a refused request makes
2408 /// none.
2409 #[cfg(feature = "metered")]
2410 fn actor_ticket_sign(&self, message: &[u8]) -> ControlResult<[u8; 64]> {
2411 let _ = message;
2412 Err(ControlError::Unimplemented("Billing.MintActorTicket"))
2413 }
2414
2415 /// **The named sealed slot `name`** (2026-10-09): its owner's principal id
2416 /// and its value, or `None` when there is no such slot. The ACL is the
2417 /// service's (`service::sealed`); the plane stores and reads.
2418 #[cfg(feature = "metered")]
2419 fn sealed_get(&self, name: &str) -> ControlResult<Option<(String, Vec<u8>)>> {
2420 let _ = name;
2421 Err(ControlError::Unimplemented("Sealed.Get"))
2422 }
2423
2424 /// **Store `value` in the slot `name`, owned by `owner`** (a principal id):
2425 /// sealed under the KEK, one `Secret` frame. Answers whether it was created.
2426 #[cfg(feature = "metered")]
2427 fn sealed_put(&self, name: &str, owner: &str, value: &[u8]) -> ControlResult<bool> {
2428 let _ = (name, owner, value);
2429 Err(ControlError::Unimplemented("Sealed.Put"))
2430 }
2431
2432 /// **Walk the provenance chain** (`Admin.VerifyChain`, roadmap phase 2).
2433 ///
2434 /// The walk itself lives in `gunnar-server`'s `verify` module — the one
2435 /// implementation the CLI verb already uses (one implementation) — so the default here
2436 /// is `Unimplemented`, not an empty chain: a control plane that cannot
2437 /// walk the chain must refuse, because an empty chain with no BROKEN link
2438 /// reads as merely incomplete, and "the backend cannot verify anything"
2439 /// must never be dressed up as "nothing was disproven".
2440 fn verify_chain(
2441 &self,
2442 request: &gunnar_proto::VerifyChainRequest,
2443 ) -> ControlResult<gunnar_proto::VerifyChainResponse> {
2444 let _ = request;
2445 Err(ControlError::Unimplemented("Admin.VerifyChain"))
2446 }
2447
2448 /// Index work in flight. Default: none — an engine without a background
2449 /// indexer has nothing to report, and that is not an error.
2450 fn index_progress(&self) -> ControlResult<Vec<IndexProgressRow>> {
2451 Ok(Vec::new())
2452 }
2453
2454 // ── mirror (mirroring) ─────────────────────────────────────────────────
2455 fn mirror_add(
2456 &self,
2457 path: &RepoPath,
2458 remote_url: &str,
2459 prune: bool,
2460 create_repo: bool,
2461 ) -> ControlResult<MirrorConfig>;
2462 /// **Enqueue a sync, and return without syncing.**
2463 ///
2464 /// Returns the registration with [`MirrorConfig::job`] set to the job now
2465 /// responsible for the repository — a new one, or the one that was already
2466 /// queued or running for it. It does NOT return what moved: a fetch is
2467 /// minutes of somebody else's network and a unary RPC that waited for one
2468 /// would time out. Poll `mirror_status` until the job is DONE.
2469 ///
2470 /// It used to return `(MirrorConfig, Vec<RefUpdate>)` and to be
2471 /// `Unimplemented` in every implementation, so nothing ever read that pair.
2472 fn mirror_sync(&self, path: &RepoPath) -> ControlResult<MirrorConfig>;
2473 /// **Store, replace or forget the credential this mirror dials with.**
2474 ///
2475 /// `secret` is the value and is **write-only**: nothing returns it, and the
2476 /// `MirrorConfig` that comes back says only which kind is set. A `kind` of
2477 /// `Unspecified` forgets it, and is the only spelling of that; see
2478 /// `mirror.proto` for why this is a verb of its own rather than two more
2479 /// fields on `mirror_add`.
2480 fn mirror_set_credential(
2481 &self,
2482 path: &RepoPath,
2483 kind: MirrorCredentialKind,
2484 secret: &str,
2485 username: &str,
2486 ) -> ControlResult<MirrorConfig>;
2487 fn mirror_status(
2488 &self,
2489 namespace: Option<&str>,
2490 path: Option<&RepoPath>,
2491 ) -> ControlResult<Vec<MirrorConfig>>;
2492 fn mirror_remove(&self, path: &RepoPath) -> ControlResult<()>;
2493
2494 // ── replication: the OPERATOR half ─────────────────────────────────────
2495 //
2496 // Default bodies, and the default is a REFUSAL BY NAME. Two callers cannot
2497 // do this: a build without `gunnar-server`'s `replication` feature, and the
2498 // in-memory test double, which has no replicator to reconfigure. Both must
2499 // say so out loud — an empty `Ok` here would be a control plane that
2500 // accepts a standby and silently keeps none, which is the exact shape of
2501 // the defect replication's whole file is written against.
2502 //
2503 // They exist at all because the appliance has NO SHELL. `replication.json`
2504 // is the truth, and until these three verbs there was no way to write it on
2505 // an OS-less image that runs as PID 1.
2506
2507 /// Add or replace one standby by name, persist it, and start replicating.
2508 fn replication_set(
2509 &self,
2510 _target: ReplicationTarget,
2511 ) -> ControlResult<ReplicationStatusResponse> {
2512 Err(ControlError::Unimplemented(
2513 "this server was built without replication (`--features replication`), so it cannot be given a standby",
2514 ))
2515 }
2516 /// Drop one standby by name, persist that, and stop replicating to it.
2517 fn replication_remove(&self, _name: &str) -> ControlResult<ReplicationStatusResponse> {
2518 Err(ControlError::Unimplemented(
2519 "this server was built without replication (`--features replication`), so it has no standby to remove",
2520 ))
2521 }
2522 /// Drop EVERY standby at once — the failover demote. Persist the empty
2523 /// roster and stop replicating anywhere.
2524 fn replication_demote(&self) -> ControlResult<ReplicationStatusResponse> {
2525 Err(ControlError::Unimplemented(
2526 "this server was built without replication (`--features replication`), so it has no standby to demote",
2527 ))
2528 }
2529 /// **Set this box's role**: `STANDBY` freezes client writes (a read-only
2530 /// standby); `PRIMARY` is a writable primary — the takeover flip; `BROWSE`
2531 /// is a frozen standby that serves anonymous reads and can never be
2532 /// promoted (the browse replica) — an implementation refuses
2533 /// `PRIMARY` on a browse box with `FailedPrecondition`. Persist it and
2534 /// flip the shared write-gate the front doors read.
2535 fn replication_set_role(
2536 &self,
2537 _role: gunnar_proto::ReplicationRole,
2538 ) -> ControlResult<ReplicationStatusResponse> {
2539 Err(ControlError::Unimplemented(
2540 "this server was built without replication (`--features replication`), so it has no role to set",
2541 ))
2542 }
2543 /// **Abdicate**: freeze client writes, flush pending to the standbys, then
2544 /// demote — the master steps down cleanly, in that order.
2545 fn replication_abdicate(&self) -> ControlResult<ReplicationStatusResponse> {
2546 Err(ControlError::Unimplemented(
2547 "this server was built without replication (`--features replication`), so it cannot abdicate",
2548 ))
2549 }
2550 /// What is configured, and what each running standby has done.
2551 ///
2552 /// `include_refs` asks for the expensive half — the count of refs a mirror
2553 /// does not carry, which opens every repository's reference database. False
2554 /// (a plain `replication list`) answers the cheap operational signal and
2555 /// opens no store; true (`--refs`) takes the walk.
2556 fn replication_status(&self, _include_refs: bool) -> ControlResult<ReplicationStatusResponse> {
2557 Err(ControlError::Unimplemented(
2558 "replication: this server was built without the `replication` feature",
2559 ))
2560 }
2561 /// **Backfill**: queue every repository one standby covers, or one named
2562 /// repository, so a standby added AFTER the repositories were written gets
2563 /// them. Adding a standby enqueues nothing.
2564 fn replication_resync(
2565 &self,
2566 _name: &str,
2567 _repo: Option<&RepoPath>,
2568 _except: &[RepoPath],
2569 ) -> ControlResult<ResyncReplicationResponse> {
2570 Err(ControlError::Unimplemented(
2571 "replication: this server was built without the `replication` feature, so it has no standby to backfill",
2572 ))
2573 }
2574
2575 /// **The replication watermark** (`Mirror.FlushReplication`,
2576 /// `DATA-SET-GROWTH-FLOW.md` G2/T11): push everything pending to every
2577 /// standby, wait — bounded — and report the tuple that proves (or does
2578 /// not) that the twin is caught up. Changes no role and no roster.
2579 fn replication_flush(
2580 &self,
2581 _request: &gunnar_proto::FlushReplicationRequest,
2582 ) -> ControlResult<gunnar_proto::FlushReplicationResponse> {
2583 Err(ControlError::Unimplemented(
2584 "replication: this server was built without the `replication` feature, so it has no standby to flush to",
2585 ))
2586 }
2587
2588 // ── The transaction log: what the twin fetches ──────────────────────────
2589
2590 /// The log's head — `head_seq`, `first_seq`, bytes, and on a twin what it
2591 /// has applied. Default `Unimplemented` for a plane with no log.
2592 fn log_head(&self) -> ControlResult<gunnar_proto::LogHeadResponse> {
2593 Err(ControlError::Unimplemented(
2594 "transaction log: this control plane keeps none",
2595 ))
2596 }
2597
2598 /// Entries `from..=to`, whole, at most `max_bytes` of blob in total but
2599 /// never fewer than one entry. `from` below the oldest retained sequence
2600 /// is `FailedPrecondition` naming it.
2601 fn log_fetch(
2602 &self,
2603 _from: u64,
2604 _to: u64,
2605 _max_bytes: u64,
2606 ) -> ControlResult<Vec<gunnar_proto::LogEntry>> {
2607 Err(ControlError::Unimplemented(
2608 "transaction log: this control plane keeps none",
2609 ))
2610 }
2611
2612 /// **[`ControlPlane::log_fetch`], streamed** (twin-streaming, 2026-10-06): the same
2613 /// refusals, answered NOW — before the call's first piece, so `Mirror.LogFetch` keeps its
2614 /// status codes (`FailedPrecondition` for a range retention dropped, and the rest) — then a
2615 /// producer that hands the range to its sink as `LogChunk` pieces of at most
2616 /// [`LOG_PIECE_BYTES`], `meta` on an entry's first, `last` on its last. The service runs it
2617 /// on a blocking thread behind a few pieces of window, so the sink's backpressure paces it.
2618 ///
2619 /// The default gathers through `log_fetch` and cuts each entry into pieces, which is right
2620 /// for a plane that holds its entries in memory anyway. A plane that keeps a real log
2621 /// overrides it and reads each entry off the disk a piece at a time: a 512 MiB pack must not
2622 /// cost the primary 512 MiB (it cost twice that before: the entry, then its pieces).
2623 fn log_fetch_stream(
2624 &self,
2625 from: u64,
2626 to: u64,
2627 max_bytes: u64,
2628 ) -> ControlResult<LogFetchProducer> {
2629 let entries = self.log_fetch(from, to, max_bytes)?;
2630 Ok(Box::new(move |sink| {
2631 for e in entries {
2632 log_entry_pieces(e, sink)?;
2633 }
2634 Ok(())
2635 }))
2636 }
2637
2638 /// **[`ControlPlane::log_fetch_stream`] with the FIRST entry started at byte `from_offset`
2639 /// of its blob** (`LogFetchRequest.from_offset`, restore-large-entry 2026-10-07): a restore
2640 /// resuming an entry the box being filled holds part of. Every piece says its `offset`.
2641 /// 0 is [`ControlPlane::log_fetch_stream`] exactly. The default gathers through
2642 /// `log_fetch` and cuts the first entry from the offset; a plane with a real log overrides it,
2643 /// reads the blob from the offset only and stamps the stored crc on the last piece for the
2644 /// receiver to check.
2645 fn log_fetch_stream_at(
2646 &self,
2647 from: u64,
2648 from_offset: u64,
2649 to: u64,
2650 max_bytes: u64,
2651 ) -> ControlResult<LogFetchProducer> {
2652 if from_offset == 0 {
2653 return self.log_fetch_stream(from, to, max_bytes);
2654 }
2655 let entries = self.log_fetch(from, to, max_bytes)?;
2656 Ok(Box::new(move |sink| {
2657 for e in entries {
2658 let at = if e.seq == from { from_offset } else { 0 };
2659 log_entry_pieces_from(e, at, sink)?;
2660 }
2661 Ok(())
2662 }))
2663 }
2664
2665 /// **On a twin: its primary's heartbeat** (when the primary last answered),
2666 /// with the silence bound `silent_after_ms`. Default `None`: not a twin.
2667 fn primary_heartbeat(&self, _silent_after_ms: u64) -> Option<gunnar_proto::PrimaryHeartbeat> {
2668 None
2669 }
2670
2671 /// **What `Admin.Topology` needs from the plane** (2026-10-04): the
2672 /// pairing feed's peers, which principals run on the front, the plugin
2673 /// registration, this box's own address and data volume. Default: a box
2674 /// that knows only itself — which is the truthful answer for a plane with
2675 /// no pairing (a solo gunnar, a test).
2676 fn topology_facts(&self) -> crate::topology::Facts {
2677 crate::topology::Facts::default()
2678 }
2679
2680 /// **Is this box a twin right now?** Default `false`. Read by the browse refusal: a twin is
2681 /// frozen like a browse replica, yet it must take its OWN data-set growth exactly as main
2682 /// does (owner ruling 2026-09-22, T21), so the grow verbs pass on a twin and on no browse box.
2683 fn is_twin(&self) -> bool {
2684 false
2685 }
2686
2687 /// **Why this box takes no control-plane write right now** — a twin, a
2688 /// browse replica — or `None`. Read by the paths outside the gate that write
2689 /// anyway (the identity core, RA5-10), so they refuse as the gate's browse
2690 /// refusal does. Default: `None` (a plane with no replication).
2691 fn frozen(&self) -> Option<String> {
2692 None
2693 }
2694
2695 /// **Does this box's transaction log FOLLOW another's** (a twin, or a main
2696 /// being restored)? It originates no transaction until it opens as the
2697 /// primary. Default `false` (wiring tracer B 2026-10-08: `Entitlement.Set`
2698 /// answers such a box's "try again" before it asks whether a namespace
2699 /// exists — a namespace made after the twin's snapshot is not replayed yet).
2700 fn log_follows(&self) -> bool {
2701 false
2702 }
2703
2704 /// **States this box is in that an operator must act on and no console reaches**
2705 /// (`Admin.Health.alarms`). Default: none.
2706 fn health_alarms(&self) -> Vec<gunnar_proto::HealthAlarm> {
2707 Vec::new()
2708 }
2709
2710 /// **Why this box reports `maintenance`** (`Admin.Health.status`), one reason each; empty is
2711 /// `serving`. It still serves either way. Default: none.
2712 fn maintenance_reasons(&self) -> Vec<String> {
2713 Vec::new()
2714 }
2715
2716 /// Is `principal_id` a member of `group`? The twin's log gate asks it
2717 /// (`Mirror.LogHead` / `LogFetch` for a member of group `twin`). Default
2718 /// `false`: a plane with no groups has no twin members.
2719 fn is_group_member(&self, _principal_id: &str, _group: &str) -> ControlResult<bool> {
2720 Ok(false)
2721 }
2722
2723 /// `Bootstrap.BecomeTwin`: a fresh box becomes a twin of `primary`;
2724 /// answers the twin's public key line.
2725 fn become_twin(&self, _primary: &str) -> ControlResult<String> {
2726 Err(ControlError::Unimplemented(
2727 "twin: this control plane cannot become a twin",
2728 ))
2729 }
2730
2731 /// `Bootstrap.AssignTunnelAddress` (2026-09-21, T17): `address` empty =
2732 /// answer only; otherwise record it as this box's WireGuard tunnel address
2733 /// and raise `wg0`. Default: a plane with no tunnel has no address to take.
2734 fn assign_tunnel_address(
2735 &self,
2736 _address: &str,
2737 ) -> ControlResult<gunnar_proto::AssignTunnelAddressResponse> {
2738 Err(ControlError::Unimplemented(
2739 "tunnel: this control plane has no WireGuard tunnel (built without wg-client)",
2740 ))
2741 }
2742
2743 /// `Admin.SetTunnelAddress` (2026-09-21, T17): the SAME answer as
2744 /// [`ControlPlane::assign_tunnel_address`] for a box past its bootstrap
2745 /// window. The caller has already been held to server-wide Admin by the
2746 /// service; the plane does not re-check the window.
2747 fn set_tunnel_address(
2748 &self,
2749 _address: &str,
2750 ) -> ControlResult<gunnar_proto::AssignTunnelAddressResponse> {
2751 Err(ControlError::Unimplemented(
2752 "tunnel: this control plane has no WireGuard tunnel (built without wg-client)",
2753 ))
2754 }
2755
2756 /// `Bootstrap.TwinCsr`: this twin's replication certificate request,
2757 /// minting its TLS key on the box on first call (lane T9).
2758 fn twin_csr(&self) -> ControlResult<String> {
2759 Err(ControlError::Unimplemented("Bootstrap.TwinCsr"))
2760 }
2761
2762 /// `Bootstrap.InstallTwinCertificate`: take the primary-signed certificate
2763 /// and pin its issuer; answers whole days left.
2764 fn install_twin_certificate(
2765 &self,
2766 _cert_pem: &str,
2767 _ca_pem: &str,
2768 _replication_port: u16,
2769 _replication_host: &str,
2770 ) -> ControlResult<i64> {
2771 Err(ControlError::Unimplemented(
2772 "Bootstrap.InstallTwinCertificate",
2773 ))
2774 }
2775
2776 /// `Admin.IssueTwinCertificate`, on the primary: `(cert, issuer, port, host)`
2777 /// — `host` is the address the listener is bound on when that is not the
2778 /// joined one (its tunnel address), empty otherwise.
2779 fn issue_twin_certificate(
2780 &self,
2781 _principal: &str,
2782 _csr_pem: &str,
2783 ) -> ControlResult<(String, String, u16, String)> {
2784 Err(ControlError::Unimplemented("Admin.IssueTwinCertificate"))
2785 }
2786
2787 /// [`ControlPlane::log_head`], **signed over the caller's nonce** — what a
2788 /// twin's poll actually calls (2026-09-21, lane T4). The default ignores the
2789 /// nonce and answers unsigned, which is the honest answer for a plane with
2790 /// no key of its own; a twin that has pinned a key refuses it.
2791 fn log_head_signed(&self, _nonce: &str) -> ControlResult<gunnar_proto::LogHeadResponse> {
2792 self.log_head()
2793 }
2794
2795 /// **A twin's proof that a NEWER generation of this box's log exists**
2796 /// (`LogHeadRequest.generation_proof`, stinky row 18, the main's half).
2797 /// Called before the answer is made. A box that finds the proof genuine — its
2798 /// own log, its own host key, a generation above its own — fences itself and
2799 /// originates nothing from then on; anything else is ignored. The default
2800 /// keeps no log and has nothing to fence.
2801 fn fence_by_generation(&self, _proof: &gunnar_proto::GenerationProof) -> ControlResult<()> {
2802 Ok(())
2803 }
2804
2805 /// **Is this box's git listener still bound where a primary's was?**
2806 ///
2807 /// A separate question from `become_twin`'s answer because it stays true
2808 /// until the box restarts, and can be asked again afterwards. The bind is
2809 /// chosen once at start-up; a box conferred the twin role over the wire is
2810 /// past that point. Nothing is SERVED through that socket — the front door
2811 /// refuses every git service on a twin from the instant the role lands —
2812 /// but it is still bound on an address a twin's socket does not belong on.
2813 ///
2814 /// `false` for a plane that has no git listener to be wrong about.
2815 fn twin_git_listener_needs_restart(&self) -> bool {
2816 false
2817 }
2818
2819 /// `Bootstrap.Restore`, start: the next sequence this box takes. `owner` is
2820 /// who the stream's credential proved the caller to be
2821 /// ([`crate::auth::authenticate_restore`]): a box already restoring resumes
2822 /// only for the owner that started it, and refuses anybody else
2823 /// `PERMISSION_DENIED` (h-v1-6a, 2026-09-28).
2824 fn restore_begin(&self, _owner: &str) -> ControlResult<u64> {
2825 Err(ControlError::Unimplemented(
2826 "restore: this control plane keeps no log to restore into",
2827 ))
2828 }
2829
2830 /// **The keys this box takes a `Bootstrap.Restore` from** (h-v1-6a,
2831 /// 2026-09-28), as OpenSSH public key lines. A fresh box has no identity
2832 /// table to check a credential against, so these are the keys it was FED
2833 /// before anybody could enrol: what the installer's door handed it, and
2834 /// what the medium named. Default: none — a plane that was fed nothing
2835 /// refuses every restore by name, which is the fail-closed answer.
2836 fn restore_keys(&self) -> ControlResult<Vec<String>> {
2837 Ok(Vec::new())
2838 }
2839
2840 /// `Bootstrap.Restore`, the room check: has this box's data set space for a stream of
2841 /// `announced_bytes` of log? Asked ONCE, on the first chunk, before anything of the restore
2842 /// is written — the restore-onto-different-hardware case. `0` = the sender did not say, and
2843 /// no guess is made. A default `Ok(())`: a plane with no data set of its own cannot run out.
2844 fn restore_room(&self, _announced_bytes: u64) -> ControlResult<()> {
2845 Ok(())
2846 }
2847
2848 /// **`Bootstrap.Restore`, the stream's intake** (restore-large-entry, 2026-10-07): what every
2849 /// chunk of one stream is fed into. Starting one is [`ControlPlane::restore_begin`] — the
2850 /// owner check, the next sequence — and, on a plane that stages, holding the box's ONE intake
2851 /// until it is dropped. `Ok(None)`: another stream's intake still holds the box (a stream the
2852 /// sender abandoned whose apply has not returned yet); the caller waits a little and asks
2853 /// again rather than racing it over the same staged entry.
2854 ///
2855 /// The default gathers every entry whole in memory and applies it with
2856 /// [`ControlPlane::restore_apply`]: right for a plane with no disk of its own.
2857 fn restore_intake<'a>(
2858 &'a self,
2859 owner: &str,
2860 ) -> ControlResult<Option<Box<dyn RestoreIntake + 'a>>> {
2861 let next = self.restore_begin(owner)?;
2862 Ok(Some(Box::new(GatherIntake {
2863 plane: self,
2864 next,
2865 open: None,
2866 want_len: 0,
2867 applied: 0,
2868 lost: 0,
2869 })))
2870 }
2871
2872 /// `Bootstrap.Restore`, one whole entry: `true` when a replayed CAS lost.
2873 fn restore_apply(&self, _entry: gunnar_proto::LogEntry) -> ControlResult<bool> {
2874 Err(ControlError::Unimplemented(
2875 "restore: this control plane keeps no log to restore into",
2876 ))
2877 }
2878
2879 /// `Bootstrap.Restore`, end: the head reached. `announced_head` is the largest
2880 /// `LogChunk.restore_head` the stream carried (0 = none): a box whose head is below it
2881 /// refuses to finish and stays restoring.
2882 fn restore_finish(&self, _announced_head: u64, _log_id_of_source: &str) -> ControlResult<u64> {
2883 Err(ControlError::Unimplemented(
2884 "restore: this control plane keeps no log to restore into",
2885 ))
2886 }
2887
2888 /// `Mirror.SnapshotFetch`: a consistent snapshot streamed into `sink` as `LogChunk` pieces —
2889 /// items with `seq` 0, at most 1 MiB of blob a piece, `blob_len` the item's total on its
2890 /// last piece and `u64::MAX` before it, the `SnapshotAt` terminator last. Never gathered:
2891 /// the sink's backpressure paces the producer. Answers the sequence it was taken at.
2892 fn snapshot_stream(
2893 &self,
2894 _sink: &mut dyn FnMut(gunnar_proto::LogChunk) -> ControlResult<()>,
2895 ) -> ControlResult<u64> {
2896 Err(ControlError::Unimplemented(
2897 "snapshot: this control plane keeps no log",
2898 ))
2899 }
2900
2901 /// **[`ControlPlane::snapshot_stream`] from item `from_item`, byte `from_offset` on**
2902 /// (`SnapshotFetchRequest`, restore-after-snapshot 2026-10-07): a twin's kept base, resumed
2903 /// for a box being restored across capped calls; every piece names its item, offset and base
2904 /// (`LogChunk.snapshot_item`, `offset`, `snapshot_id`). `expect_id`: the base the asker holds
2905 /// part of — another one now is refused `[snapshot-changed]`. The default resumes nothing:
2906 /// asked from its start it is `snapshot_stream`, otherwise refused by name.
2907 fn snapshot_stream_from(
2908 &self,
2909 from_item: u64,
2910 from_offset: u64,
2911 expect_id: &str,
2912 sink: &mut dyn FnMut(gunnar_proto::LogChunk) -> ControlResult<()>,
2913 ) -> ControlResult<u64> {
2914 if from_item == 0 && from_offset == 0 && expect_id.trim().is_empty() {
2915 return self.snapshot_stream(sink);
2916 }
2917 Err(ControlError::Unimplemented(
2918 "snapshot: this control plane cannot resume a snapshot",
2919 ))
2920 }
2921
2922 /// `Mirror.LogRetain`: drop every whole segment below `from` (0 = head + 1); answers the
2923 /// head as `LogHead` does. Refused on a twin.
2924 fn log_retain(&self, _from: u64) -> ControlResult<gunnar_proto::LogHeadResponse> {
2925 Err(ControlError::Unimplemented(
2926 "retention: this control plane keeps no log",
2927 ))
2928 }
2929
2930 /// ★ **The log's segments, for a byte-bounded retention** (cov-13, 2026-10-06):
2931 /// `(first, last, bytes)`, oldest first, the open one last (`last = first - 1` while it
2932 /// holds nothing). Only whole segments are dropped, so this is what a byte budget is
2933 /// decided over. Default `Unimplemented`: a plane with no log.
2934 fn log_layout(&self) -> ControlResult<Vec<(u64, u64, u64)>> {
2935 Err(ControlError::Unimplemented(
2936 "retention: this control plane keeps no log",
2937 ))
2938 }
2939
2940 /// ★ **The data volume the log lives on**: `(total, used)` bytes, statvfs — what the
2941 /// retention's byte budget is a share of, and what a twin silent through main's growth
2942 /// is owed (cov-13). `None` when unknown, and while the set is released for a growth
2943 /// (statvfs would read the box's root, not the set).
2944 fn data_volume(&self) -> Option<(u64, u64)> {
2945 None
2946 }
2947
2948 /// ★ `twin-owed.json` (cov-13): what each twin is owed, as last written; empty = never.
2949 /// Default `Unimplemented`: the book then lives in memory only.
2950 fn twin_owed_read(&self) -> ControlResult<Vec<u8>> {
2951 Err(ControlError::Unimplemented(
2952 "twin-owed: this control plane keeps no data root",
2953 ))
2954 }
2955
2956 /// ★ Write `twin-owed.json` (replace, crash-safe).
2957 fn twin_owed_write(&self, _bytes: &[u8]) -> ControlResult<()> {
2958 Err(ControlError::Unimplemented(
2959 "twin-owed: this control plane keeps no data root",
2960 ))
2961 }
2962
2963 /// `Bootstrap.Restore`, one snapshot piece (`seq` 0), staged on disk: `Some(at)` once the
2964 /// terminator installed the snapshot, so the restore continues from `at + 1`.
2965 fn restore_snapshot_chunk(
2966 &self,
2967 _chunk: &gunnar_proto::LogChunk,
2968 ) -> ControlResult<Option<u64>> {
2969 Err(ControlError::Unimplemented(
2970 "restore: this control plane keeps no log to restore into",
2971 ))
2972 }
2973
2974 // ── Outbound webhooks: delivery to an external URL ─────────────────────
2975 //
2976 // A webhook is a subscriber to the SAME event bus the replicator and the SSE
2977 // fan-out consume (one implementation), which POSTs each matching event to a URL, signed
2978 // with an sshsig the receiver verifies against the server's published key.
2979 // Configuration lives in `<data-root>/webhooks.json` and is set over the
2980 // control plane because the appliance has no shell — exactly the reason the
2981 // replication verbs above exist. Default `Unimplemented`, so a server built
2982 // without the `webhooks` feature says so rather than silently accepting a
2983 // configuration it will never deliver on (measured, not asserted — the silent green).
2984
2985 /// Add or replace one endpoint by name, persist it, and start delivering.
2986 fn webhook_add(&self, _request: AddWebhookRequest) -> ControlResult<WebhookListResponse> {
2987 Err(ControlError::Unimplemented(
2988 "outbound webhooks: this server was built without the `webhooks` feature, so it cannot be given an outbound endpoint",
2989 ))
2990 }
2991
2992 /// What is configured, plus the signing public key a receiver verifies with.
2993 fn webhook_list(&self) -> ControlResult<WebhookListResponse> {
2994 Err(ControlError::Unimplemented(
2995 "outbound webhooks: this server was built without the `webhooks` feature, so it has no outbound endpoints",
2996 ))
2997 }
2998
2999 /// Drop one endpoint by name, persist that, and stop delivering to it.
3000 fn webhook_remove(&self, _name: &str) -> ControlResult<WebhookListResponse> {
3001 Err(ControlError::Unimplemented(
3002 "outbound webhooks: this server was built without the `webhooks` feature, so it has no outbound endpoint to remove",
3003 ))
3004 }
3005
3006 /// **The owner's enterprise list** (2026-10-05): the account audit log,
3007 /// the operator's aggregates and look, the name filter
3008 /// ([`crate::enterprise::EnterprisePlane`]), when this plane has the
3009 /// stores behind it. `None`: nothing is recorded, nothing is shielded,
3010 /// every name is clean, and every `Enterprise` verb answers UNIMPLEMENTED.
3011 fn enterprise(&self) -> Option<&dyn crate::enterprise::EnterprisePlane> {
3012 None
3013 }
3014}
3015
3016// ───────────────────────────────────────────────────────────────────────────
3017// The test double
3018// ───────────────────────────────────────────────────────────────────────────
3019
3020/// An in-memory [`ControlPlane`]. **A test double, not a backend.**
3021///
3022/// It implements the catalogue and identity halves so the services can be
3023/// exercised end to end, and answers [`ControlError::Unimplemented`] for
3024/// everything that genuinely needs `gunnar-store`: refs, objects, diffs and
3025/// mirror sync. Returning an empty success for those would be worse than
3026/// useless: an empty ref list looks exactly like a working server with no refs
3027/// (measured, not asserted — the silent green).
3028#[derive(Debug, Default)]
3029pub struct MemoryControlPlane {
3030 inner: Mutex<Inner>,
3031 /// **A slow synchronous call, on demand** (R5.1-G): milliseconds every
3032 /// `bootstrap_state` sleeps on the calling thread before it answers — the
3033 /// shape of a real control-plane call (a disk walk, an fsync) that holds a
3034 /// worker without yielding. Zero, the default, answers at once.
3035 stall_ms: std::sync::atomic::AtomicU64,
3036}
3037
3038/// One seat lot: `(room per seat, total, filled ids, order)`.
3039type SeatLot = (u64, u32, Vec<String>, String);
3040
3041#[derive(Debug, Default)]
3042struct Inner {
3043 namespaces: BTreeMap<String, Namespace>,
3044 repos: BTreeMap<(String, String), Repo>,
3045 principals: BTreeMap<String, Principal>,
3046 /// Employee id → the account that enrolled it (`enroll_employee`).
3047 employers: BTreeMap<String, String>,
3048 /// Employee id → the room it holds and the order it came with.
3049 rooms: BTreeMap<String, (u64, String)>,
3050 /// Account → its seat lots `(room per seat, total, filled ids, order)`.
3051 seats: BTreeMap<String, Vec<SeatLot>>,
3052 /// Order → `(account, principal or "", seats, room, need)`.
3053 room_orders: BTreeMap<String, (String, String, u32, u64, u64)>,
3054 /// namespace → what a test says gunnar measured for it (`usage`).
3055 /// Empty in every other use, and then `usage` answers `Unimplemented`
3056 /// exactly as before: the double has no store to count.
3057 usage: BTreeMap<String, gunnar_proto::NamespaceUsage>,
3058 /// Web credentials (Stage L): credential id (base64url) → the row and
3059 /// its public key.
3060 credentials: BTreeMap<String, StoredCredential>,
3061 /// Web credential (base64url) → the session keys its logins enrolled.
3062 credential_sessions: BTreeMap<String, Vec<String>>,
3063 /// The operator's first invite (the installer's token by its hash).
3064 first_invite: Option<FirstInvite>,
3065 keys: BTreeMap<String, PublicKey>,
3066 grants: Vec<Grant>,
3067 protections: BTreeMap<(String, String), BTreeMap<String, BranchProtection>>,
3068 ref_policies: BTreeMap<(String, String), RefNamePolicy>,
3069 mirrors: BTreeMap<(String, String), MirrorConfig>,
3070 /// fingerprint → the `authorized_keys` line, so a credential can be
3071 /// verified. The proto `PublicKey` deliberately does not carry key
3072 /// material; the gate needs it, so the double keeps it beside.
3073 openssh: BTreeMap<String, String>,
3074 /// Client-certificate DER → the identity it was registered under.
3075 ///
3076 /// Keyed by the **bytes**, not by a fingerprint the double computed: the
3077 /// real adapter hashes with `gunnar_core::auth::fingerprint_of_certificate`
3078 /// and a second hash spelled here would be a twin of that decision (one implementation).
3079 /// The double is handed the fingerprint by whoever registers, so the two
3080 /// can never disagree — there is only one of them.
3081 certificates: BTreeMap<Vec<u8>, CertificateIdentity>,
3082 /// `Scope::Server` grants (the ACL model). The proto `Grant` cannot express one, since
3083 /// it requires a namespace, which is a real gap recorded in
3084 /// `gunnar-cli`'s adapter; the double works around it rather than
3085 /// pretending a server-wide grant is a namespace grant on `""`.
3086 server_grants: BTreeMap<String, GrantLevel>,
3087 /// Narrowed principals → the methods each may call (f-principal-reach).
3088 narrowed: BTreeMap<String, Vec<String>>,
3089 /// See [`MemoryControlPlane::force_may`]. `None` in every real use.
3090 forced_may: Option<bool>,
3091 /// See [`MemoryControlPlane::set_twin`]. `false` unless a test says otherwise.
3092 twin: bool,
3093 /// `set_log_follows`: a main being restored.
3094 log_follows: bool,
3095 /// See [`MemoryControlPlane::await_enrolment`]: fingerprint → the fed key's
3096 /// OpenSSH line. Empty unless a test feeds one.
3097 awaiting: BTreeMap<String, String>,
3098 /// See [`MemoryControlPlane::forge_principal_id`]. `None` in every real use.
3099 forged_principal_id: Option<String>,
3100 /// The bootstrap record: `(at, principal, fingerprint)`. Beside the
3101 /// identity maps above and never removed with them, which is the whole
3102 /// property the double has to reproduce.
3103 bootstrap: Option<(i64, String, String)>,
3104 /// namespace → the fact this box holds for it (the METERED appliance).
3105 ///
3106 /// Seeded by a test; empty in every other use. `entitlement_get` answers
3107 /// out of it, which is the truthful thing for a double to do — "no fact"
3108 /// is Free, and that is a real answer rather than a missing one. Writing a
3109 /// fact is still [`ControlError::Unimplemented`]: the double has no
3110 /// monetize key to VERIFY one with, and a `set` that stored whatever it
3111 /// was given would be a double that is more permissive than the thing it
3112 /// stands in for.
3113 #[cfg(feature = "metered")]
3114 entitlements: BTreeMap<String, gunnar_proto::EntitlementFact>,
3115 /// How many times the actor-ticket key has been asked to sign.
3116 ///
3117 /// **An instrument, and the reason it exists is a specific defect.** A
3118 /// handler that signed a ticket and then checked the grant would satisfy a
3119 /// test that only looked at the returned `Status`, while having put a
3120 /// valid, spendable ticket for somebody else's tenant into whatever sits
3121 /// between the handler and the caller. Counting the signatures is how a
3122 /// refusal is held to "nothing was minted" rather than to "nothing was
3123 /// returned".
3124 #[cfg(feature = "metered")]
3125 actor_tickets_signed: u64,
3126 /// The double's actor-ticket key, minted on first use, exactly as the real
3127 /// appliance's is — a real ed25519 key and never a stand-in, because the
3128 /// signature it makes is checked by the OTHER repository's verifier in
3129 /// `tests/billing.rs`.
3130 #[cfg(feature = "metered")]
3131 actor_ticket_key: Option<ed25519_dalek::SigningKey>,
3132}
3133
3134impl Inner {
3135 /// The double's actor-ticket key, minted on first use.
3136 ///
3137 /// Minted from `OsRng` and not from a constant seed. A fixed key would make
3138 /// every run of `tests/billing.rs` produce byte-identical tickets, which
3139 /// reads as reassuring and is the opposite: the contract test would then
3140 /// be checking one recorded signature rather than the agreement between a
3141 /// signer and a verifier.
3142 #[cfg(feature = "metered")]
3143 fn actor_ticket_key(inner: &mut Inner) -> &ed25519_dalek::SigningKey {
3144 inner.actor_ticket_key.get_or_insert_with(|| {
3145 use rand::Rng as _;
3146 let mut seed = [0u8; 32];
3147 rand::rand_core::UnwrapErr(rand::rngs::SysRng).fill_bytes(&mut seed);
3148 ed25519_dalek::SigningKey::from_bytes(&seed)
3149 })
3150 }
3151
3152 /// Who, other than `own_id`, holds `name` — as their name or as their id.
3153 /// The double's spelling of `AuthTable::refuse_taken_name`.
3154 fn name_holder(inner: &Inner, name: &str, own_id: &str) -> Option<String> {
3155 inner
3156 .principals
3157 .values()
3158 .find(|p| p.id != own_id && (p.name == name || p.id == name))
3159 .map(|p| p.id.clone())
3160 }
3161}
3162
3163/// The default branch a repo gets when the request does not name one.
3164pub const DEFAULT_BRANCH: &str = "refs/heads/main";
3165
3166/// sha256 is the default hash kind for new repos.
3167pub const DEFAULT_HASH_KIND: HashKind = HashKind::Sha256;
3168
3169impl MemoryControlPlane {
3170 /// **Say this plane is a twin** (T21): what `CoreControlPlane::is_twin` reads off
3171 /// `twin.json`, for the gate's twin-only grow verbs.
3172 pub fn set_twin(&self, yes: bool) {
3173 self.inner.lock().expect("memory control plane").twin = yes;
3174 }
3175
3176 /// **Say this plane's log follows another's** (`ControlPlane::log_follows`):
3177 /// a main being restored, as `CoreControlPlane` reads it off its txlog.
3178 pub fn set_log_follows(&self, yes: bool) {
3179 self.inner.lock().expect("memory control plane").log_follows = yes;
3180 }
3181
3182 /// **Say this key was fed and is not enrolled yet** (`Identity::awaiting_enrolment`):
3183 /// what a primary's installer feed says of the front's keys until its pairing loop
3184 /// enrolled them.
3185 pub fn await_enrolment(&self, fingerprint: &str, openssh: &str) {
3186 self.inner
3187 .lock()
3188 .expect("memory control plane")
3189 .awaiting
3190 .insert(fingerprint.to_owned(), openssh.to_owned());
3191 }
3192
3193 pub fn new() -> Self {
3194 Self::default()
3195 }
3196
3197 /// Make every `bootstrap_state` (`Bootstrap.Status`) hold its thread for
3198 /// `by` before it answers: a synchronous call that blocks the worker it
3199 /// runs on, which is what the keepalive test needs every handler worker
3200 /// doing at once.
3201 pub fn stall_bootstrap_state(&self, by: std::time::Duration) {
3202 self.stall_ms.store(
3203 by.as_millis().min(u64::MAX as u128) as u64,
3204 std::sync::atomic::Ordering::SeqCst,
3205 );
3206 }
3207
3208 /// Convenience for tests: a namespace and a repo in one call.
3209 pub fn with_repo(namespace: &str, repo: &str) -> Self {
3210 let plane = Self::new();
3211 plane
3212 .create_namespace(namespace, "")
3213 .expect("fresh namespace");
3214 plane
3215 .create_repo(NewRepo {
3216 path: RepoPath::new(namespace, repo),
3217 hash_kind: DEFAULT_HASH_KIND,
3218 default_branch: String::new(),
3219 visibility: Visibility::Private,
3220 description: String::new(),
3221 adopt: false,
3222 })
3223 .expect("fresh repo");
3224 plane
3225 }
3226
3227 fn key(path: &RepoPath) -> (String, String) {
3228 (path.namespace.clone(), path.repo.clone())
3229 }
3230
3231 /// **Narrow a principal to a method list** (f-principal-reach).
3232 pub fn narrow(&self, principal_id: &str, methods: &[&str]) {
3233 self.inner.lock().unwrap().narrowed.insert(
3234 principal_id.to_owned(),
3235 methods.iter().map(|m| m.to_string()).collect(),
3236 );
3237 }
3238
3239 /// Give a principal a `Scope::Server` grant (the ACL model).
3240 ///
3241 /// Not on [`ControlPlane`]: the proto `Grant` cannot express a server-wide
3242 /// scope (it requires a namespace), which is a gap in `gunnar-proto`
3243 /// recorded in `gunnar-cli`'s adapter. Until that is fixed the double
3244 /// carries server grants beside the namespace/repo ones rather than
3245 /// smuggling one in as a namespace grant on `""`.
3246 pub fn grant_server(&self, principal_id: &str, level: GrantLevel) {
3247 self.inner
3248 .lock()
3249 .unwrap()
3250 .server_grants
3251 .insert(principal_id.to_owned(), level);
3252 }
3253
3254 /// Seed what gunnar measured for one namespace (`Usage.Read`'s row).
3255 pub fn set_usage(&self, usage: gunnar_proto::NamespaceUsage) {
3256 self.inner
3257 .lock()
3258 .unwrap()
3259 .usage
3260 .insert(usage.namespace.clone(), usage);
3261 }
3262
3263 /// Seed the fact this box holds for one namespace (the METERED appliance).
3264 ///
3265 /// The double's stand-in for a fact monetize signed and `Entitlement.Set`
3266 /// verified and stored. It takes the fact as given and checks no signature,
3267 /// which is why it is a test-only setter and not `entitlement_set`.
3268 #[cfg(feature = "metered")]
3269 pub fn set_entitlement_fact(&self, fact: gunnar_proto::EntitlementFact) {
3270 self.inner
3271 .lock()
3272 .unwrap()
3273 .entitlements
3274 .insert(fact.namespace.clone(), fact);
3275 }
3276
3277 /// **How many times the actor-ticket key has been asked to sign.**
3278 ///
3279 /// The instrument that turns "the request was refused" into "nothing was
3280 /// minted". See the field's own comment for the defect it exists to catch.
3281 #[cfg(feature = "metered")]
3282 pub fn actor_tickets_signed(&self) -> u64 {
3283 self.inner.lock().unwrap().actor_tickets_signed
3284 }
3285
3286 /// **A red-first instrument.** Force [`Identity::may`] to a constant answer.
3287 ///
3288 /// `Some(false)` must make every *allow* test in `tests/auth.rs` go red,
3289 /// and `Some(true)` must make every *permission-denied* test go red. Both
3290 /// are asserted there. Without them a handler that had been hard-wired to
3291 /// allow, or a gate that refused everything, would pass a naive suite just
3292 /// as well as a correct one. That is the exact failure this instrument
3293 /// exists to make impossible.
3294 ///
3295 /// `None` restores the real answer.
3296 pub fn force_may(&self, forced: Option<bool>) {
3297 self.inner.lock().unwrap().forced_may = forced;
3298 }
3299
3300 /// **A red-first instrument, for anonymous read.** Make
3301 /// [`Identity::registered_key`] name
3302 /// a principal of the caller's choosing, whatever the key table says.
3303 ///
3304 /// `AuthGate` refuses a credential that resolves to the anonymous principal
3305 /// id. Through `gunnar-core` that state is unreachable (the reserved id
3306 /// cannot be registered and cannot be given a key), so without this the
3307 /// guard could never be *seen* refusing, and a guard nobody has watched
3308 /// fail is worth nothing.
3309 ///
3310 /// Nor is it a contrivance. `AuthGate` takes [`ControlPlane`] as a
3311 /// **trait**, so any adapter, present or future, can return whatever
3312 /// principal id it likes. That is the situation this reproduces.
3313 ///
3314 /// `None` restores the real answer.
3315 pub fn forge_principal_id(&self, id: Option<&str>) {
3316 self.inner.lock().unwrap().forged_principal_id = id.map(str::to_owned);
3317 }
3318}
3319
3320/// `Admin` implies `Write` implies `Read` (the ACL model). One ordering, used by every
3321/// comparison below.
3322fn level_rank(level: GrantLevel) -> u8 {
3323 match level {
3324 GrantLevel::Unspecified => 0,
3325 GrantLevel::Read => 1,
3326 GrantLevel::Write => 2,
3327 GrantLevel::Admin => 3,
3328 }
3329}
3330
3331/// The **test double's** ACL.
3332///
3333/// It is a double of `gunnar_core::AuthTable::may`, not a second production
3334/// decision: the real server routes every `may` through `AuthTable` via the
3335/// adapter, and this exists so the seven services can be exercised without a
3336/// storage backend. Its rules are deliberately the same three: unknown
3337/// principal may nothing, a covering grant must be at least `level`, and a
3338/// namespace grant matches its namespace **exactly** (`team` does not reach
3339/// `team/sub`).
3340impl Identity for MemoryControlPlane {
3341 fn awaiting_enrolment(&self, fingerprint: &str) -> Option<String> {
3342 self.inner
3343 .lock()
3344 .unwrap()
3345 .awaiting
3346 .get(fingerprint)
3347 .cloned()
3348 }
3349
3350 fn narrowed_methods(&self, principal_id: &str) -> ControlResult<Option<Vec<String>>> {
3351 Ok(self
3352 .inner
3353 .lock()
3354 .unwrap()
3355 .narrowed
3356 .get(principal_id)
3357 .cloned())
3358 }
3359
3360 fn registered_key(&self, fingerprint: &str) -> ControlResult<Option<RegisteredKey>> {
3361 let inner = self.inner.lock().unwrap();
3362 let Some(key) = inner.keys.get(fingerprint) else {
3363 return Ok(None);
3364 };
3365 if let Some(expires) = key.expires_at_unix_ms {
3366 if now_ms() >= expires {
3367 return Ok(None);
3368 }
3369 }
3370 let Some(openssh) = inner.openssh.get(fingerprint) else {
3371 return Ok(None);
3372 };
3373 Ok(Some(RegisteredKey {
3374 principal_id: inner
3375 .forged_principal_id
3376 .clone()
3377 .unwrap_or_else(|| key.principal_id.clone()),
3378 openssh: openssh.clone(),
3379 machine: inner
3380 .principals
3381 .get(&key.principal_id)
3382 .is_some_and(|p| p.kind == PrincipalKind::Machine as i32),
3383 }))
3384 }
3385
3386 fn registered_certificate(&self, der: &[u8]) -> ControlResult<Option<CertificateIdentity>> {
3387 let inner = self.inner.lock().unwrap();
3388 Ok(inner.certificates.get(der).cloned())
3389 }
3390
3391 fn may(&self, principal_id: &str, access: &Access, level: GrantLevel) -> ControlResult<bool> {
3392 if level == GrantLevel::Unspecified {
3393 return Ok(false);
3394 }
3395 let inner = self.inner.lock().unwrap();
3396 if let Some(forced) = inner.forced_may {
3397 return Ok(forced);
3398 }
3399 if !inner.principals.contains_key(principal_id) {
3400 return Ok(false);
3401 }
3402 // A principal is intrinsically admin of the account named after it.
3403 // `gunnar_core::AuthTable::owns_account` is the real decision; this is
3404 // the double keeping the same shape, and `tests/bootstrap.rs` asserts
3405 // both directions of it so the double cannot quietly allow more.
3406 // A MEMBER owns no account by its name (2026-10-07), as in the table.
3407 let member = inner.employers.contains_key(principal_id);
3408 let owns_account =
3409 |namespace: &str| !member && !namespace.is_empty() && namespace == principal_id;
3410 match access {
3411 Access::Namespace(name) if owns_account(name) => return Ok(true),
3412 Access::Repo(path) if owns_account(&path.namespace) => return Ok(true),
3413 _ => {}
3414 }
3415 // A PUBLIC repository is readable by every registered principal —
3416 // `AuthTable::may`'s rule for it, kept here so a double cannot quietly
3417 // hide what the real table shows (the outsider's view of an account,
3418 // `tests/account_model.rs`).
3419 if let Access::Repo(path) = access {
3420 if level == GrantLevel::Read
3421 && inner
3422 .repos
3423 .get(&(path.namespace.clone(), path.repo.clone()))
3424 .is_some_and(|repo| repo.visibility == Visibility::Public as i32)
3425 {
3426 return Ok(true);
3427 }
3428 }
3429 let want = level_rank(level);
3430 // **A server grant stops at a customer's account** (2026-10-05):
3431 // `gunnar_core::AuthTable`'s shield, kept here so the double cannot
3432 // quietly show an operator what the real table refuses it.
3433 // The box's own first identity (bootstrap) is the estate, as a
3434 // machine is: `gunnar_core::AuthTable::is_box_identity`.
3435 let box_identity = inner.bootstrap.as_ref().map(|(_, id, _)| id.as_str());
3436 let machine = box_identity == Some(principal_id)
3437 || inner
3438 .principals
3439 .get(principal_id)
3440 .is_some_and(|p| p.kind == PrincipalKind::Machine as i32);
3441 let shielded = |namespace: &str| {
3442 let account = namespace.split('/').next().unwrap_or("");
3443 !machine
3444 && !account.is_empty()
3445 && box_identity != Some(account)
3446 && account != principal_id
3447 && inner
3448 .principals
3449 .get(account)
3450 .is_some_and(|p| p.kind == PrincipalKind::User as i32)
3451 && !inner.employers.contains_key(account)
3452 && inner.employers.get(principal_id).map(String::as_str) != Some(account)
3453 };
3454 let inside_closed_account = match access {
3455 Access::Server => false,
3456 Access::Namespace(name) => shielded(name),
3457 Access::Repo(path) => shielded(&path.namespace),
3458 };
3459 let server = inner
3460 .server_grants
3461 .get(principal_id)
3462 .copied()
3463 .map(level_rank)
3464 .unwrap_or(0);
3465 if server >= want && !inside_closed_account {
3466 return Ok(true);
3467 }
3468 let scoped = inner
3469 .grants
3470 .iter()
3471 .filter(|grant| grant.principal_id == principal_id)
3472 .filter(|grant| match access {
3473 // A server-scoped question is never answered by a namespace or
3474 // repo grant. Widening it there would be silent escalation.
3475 Access::Server => false,
3476 Access::Namespace(name) => grant.repo.is_empty() && &grant.namespace == name,
3477 Access::Repo(path) => {
3478 grant.namespace == path.namespace
3479 && (grant.repo.is_empty() || grant.repo == path.repo)
3480 }
3481 })
3482 .filter_map(|grant| GrantLevel::try_from(grant.level).ok())
3483 .map(level_rank)
3484 .max()
3485 .unwrap_or(0);
3486 Ok(scoped >= want)
3487 }
3488}
3489
3490/// The double's first-boot door.
3491///
3492/// It mirrors `gunnar_core::AuthTable::install_first_identity` in the one thing
3493/// that matters: the record is checked and written **under the same lock** as
3494/// the identity it creates, so two concurrent enrolments cannot both win. In
3495/// the real server that lock is a compare-and-swap on `refs/gunnar/auth`; here
3496/// it is the `Mutex` this whole double is behind. `tests/bootstrap.rs` races
3497/// both.
3498impl FirstBoot for MemoryControlPlane {
3499 fn bootstrap_state(&self) -> ControlResult<BootstrapState> {
3500 let stall = self.stall_ms.load(std::sync::atomic::Ordering::SeqCst);
3501 if stall > 0 {
3502 // Blocking on purpose: `std::thread::sleep`, not a timer, so the
3503 // worker is held exactly as a synchronous store call holds it.
3504 std::thread::sleep(std::time::Duration::from_millis(stall));
3505 }
3506 let inner = self.inner.lock().unwrap();
3507 let (at, principal, fingerprint) =
3508 inner
3509 .bootstrap
3510 .clone()
3511 .unwrap_or((0, String::new(), String::new()));
3512 Ok(BootstrapState {
3513 window_open: inner.bootstrap.is_none()
3514 && inner.principals.is_empty()
3515 && inner.keys.is_empty(),
3516 bootstrapped: inner.bootstrap.is_some(),
3517 bootstrapped_at_unix_ms: at,
3518 bootstrapped_principal: principal,
3519 bootstrapped_fingerprint: fingerprint,
3520 principals: inner.principals.len() as u64,
3521 keys: inner.keys.len() as u64,
3522 // An in-memory control plane has no disk to be racing, so it is
3523 // always serving. The boot window is `gunnar-server`'s fact.
3524 serving_git: true,
3525 readiness: "serving git".to_owned(),
3526 build: String::new(),
3527 role: "primary".to_owned(),
3528 ..Default::default()
3529 })
3530 }
3531
3532 fn enroll_first_identity(
3533 &self,
3534 name: &str,
3535 openssh_public_key: &str,
3536 ) -> ControlResult<Enrolled> {
3537 let fingerprint = fingerprint_of_openssh(openssh_public_key).ok_or_else(|| {
3538 ControlError::InvalidArgument(format!(
3539 "{openssh_public_key:?} is not an authorized_keys line"
3540 ))
3541 })?;
3542 let mut inner = self.inner.lock().unwrap();
3543 if let Some((at, principal, _)) = &inner.bootstrap {
3544 return Err(ControlError::FailedPrecondition(format!(
3545 "this server was bootstrapped at {at} by \"{principal}\"; the window is closed \
3546 permanently and deleting identities does not reopen it"
3547 )));
3548 }
3549 if !inner.principals.is_empty() || !inner.keys.is_empty() {
3550 return Err(ControlError::FailedPrecondition(format!(
3551 "this server already holds {} principals and {} keys, so it is not a fresh server",
3552 inner.principals.len(),
3553 inner.keys.len()
3554 )));
3555 }
3556 if name.is_empty() {
3557 return Err(ControlError::InvalidArgument("`name` is required".into()));
3558 }
3559 let at = now_ms();
3560 let principal = Principal {
3561 id: name.to_owned(),
3562 name: name.to_owned(),
3563 kind: PrincipalKind::User as i32,
3564 created_at_unix_ms: at,
3565 };
3566 let key = PublicKey {
3567 fingerprint: fingerprint.clone(),
3568 algo: String::new(),
3569 principal_id: name.to_owned(),
3570 comment: String::new(),
3571 added_at_unix_ms: at,
3572 last_used_unix_ms: None,
3573 expires_at_unix_ms: None,
3574 };
3575 inner.principals.insert(name.to_owned(), principal.clone());
3576 inner.keys.insert(fingerprint.clone(), key.clone());
3577 inner
3578 .openssh
3579 .insert(fingerprint.clone(), openssh_public_key.trim().to_owned());
3580 // gunnar-admin, the ordinary `Scope::Server` Admin grant.
3581 inner
3582 .server_grants
3583 .insert(name.to_owned(), GrantLevel::Admin);
3584 inner.namespaces.insert(
3585 name.to_owned(),
3586 Namespace {
3587 name: name.to_owned(),
3588 description: String::new(),
3589 created_at_unix_ms: at,
3590 owner_kind: 0,
3591 ..Default::default()
3592 },
3593 );
3594 inner.bootstrap = Some((at, name.to_owned(), fingerprint));
3595 Ok(Enrolled {
3596 principal,
3597 key,
3598 account: name.to_owned(),
3599 bootstrapped_at_unix_ms: at,
3600 // The in-memory control plane has no PKI; the degraded (empty)
3601 // shape is exactly what it should exercise.
3602 client_cert_pem: String::new(),
3603 client_key_pem: String::new(),
3604 issuer_cert_pem: String::new(),
3605 })
3606 }
3607}
3608
3609fn now_ms() -> i64 {
3610 crate::bus::now_unix_ms()
3611}
3612
3613/// The `SHA256:<base64>` fingerprint OpenSSH prints, computed by `ssh-key`
3614/// itself.
3615///
3616/// This used to be a deliberate `PLACEHOLDER-…` string, because computing
3617/// "something like" a fingerprint and letting an integration believe it would
3618/// have been a lie. Since gRPC authentication landed this crate links
3619/// `ssh-key`, so the double can produce the **real** value. It must, because a
3620/// credential carries the fingerprint the client's key actually has and the
3621/// double has to be able to find it.
3622///
3623/// It is not a second definition of gunnar's fingerprint: `ssh-key`'s own
3624/// `PublicKey::fingerprint` and `gunnar_core::fingerprint_of_blob` are both
3625/// sha256 over the same wire blob, and `gunnar-core` only spells it out by hand
3626/// because russh pins an incompatible `ssh-key`. A mismatch between them
3627/// would be caught the first time a real key was used against a real table.
3628fn fingerprint_of_openssh(line: &str) -> Option<String> {
3629 let key = ssh_key::PublicKey::from_openssh(line).ok()?;
3630 Some(key.fingerprint(ssh_key::HashAlg::Sha256).to_string())
3631}
3632
3633/// **The double's spelling of the key-attestation rule**, structurally.
3634///
3635/// The rule and its arithmetic are `gunnar_core::key_attestation` — one
3636/// writer, which the shipped plane asks. This crate does not depend on
3637/// `gunnar-core` (see the note on `fingerprint_of_openssh`'s neighbour), so the
3638/// double reproduces the three arms — nobody to ask, the caller is the target,
3639/// otherwise required — and checks the attestation's SHAPE: present, about
3640/// this principal and this fingerprint, signed by a key the target holds. It
3641/// does **not** verify the signature; the real record is verified on the real
3642/// plane, and `gunnar-cli/tests/add_key_attested.rs` drives that red. The same
3643/// stance the double takes on membership events, which it does not implement
3644/// at all.
3645fn require_attestation(
3646 target: &str,
3647 caller: &str,
3648 holders: &[String],
3649 adding: &str,
3650 attestation: Option<&SignedKeyAttestation>,
3651) -> ControlResult<()> {
3652 if holders.is_empty() || (!caller.is_empty() && caller == target) {
3653 return Ok(());
3654 }
3655 let Some(signed) = attestation else {
3656 return Err(ControlError::FailedPrecondition(format!(
3657 "principal {target} already holds {} key(s), so adding another needs {target}'s own \
3658 attestation over the new key's fingerprint ({adding})",
3659 holders.len()
3660 )));
3661 };
3662 let Some(subject) = signed.attestation.as_ref() else {
3663 return Err(ControlError::InvalidArgument(
3664 "`attestation.attestation` is required".into(),
3665 ));
3666 };
3667 if subject.principal_id != target || subject.fingerprint != adding {
3668 return Err(ControlError::InvalidArgument(format!(
3669 "the attestation is for key {} on {:?} but the request adds key {adding} to {target:?}",
3670 subject.fingerprint, subject.principal_id
3671 )));
3672 }
3673 if !holders.iter().any(|held| held == &signed.key_fingerprint) {
3674 return Err(ControlError::InvalidArgument(format!(
3675 "the attestation is signed by {}, which is not a key {target:?} holds",
3676 signed.key_fingerprint
3677 )));
3678 }
3679 Ok(())
3680}
3681
3682impl ControlPlane for MemoryControlPlane {
3683 fn usage(&self, namespace: Option<&str>) -> ControlResult<Vec<gunnar_proto::NamespaceUsage>> {
3684 let inner = self.inner.lock().unwrap();
3685 if inner.usage.is_empty() {
3686 return Err(ControlError::Unimplemented("Usage"));
3687 }
3688 Ok(inner
3689 .usage
3690 .values()
3691 .filter(|row| namespace.is_none_or(|ns| row.namespace == ns))
3692 .cloned()
3693 .collect())
3694 }
3695
3696 fn is_twin(&self) -> bool {
3697 self.inner.lock().expect("memory control plane").twin
3698 }
3699
3700 fn log_follows(&self) -> bool {
3701 let inner = self.inner.lock().expect("memory control plane");
3702 inner.twin || inner.log_follows
3703 }
3704
3705 /// The fact this box holds, or `None` = Free.
3706 ///
3707 /// The double answers this one rather than refusing, because it now has
3708 /// somewhere to keep a fact ([`MemoryControlPlane::set_entitlement_fact`])
3709 /// and "no fact" is a real answer and not a missing one. `entitlement_set`
3710 /// stays `Unimplemented`: storing is not the half the double is missing,
3711 /// VERIFYING is, and a double that stored an unverified fact would be more
3712 /// permissive than the thing it stands in for.
3713 #[cfg(feature = "metered")]
3714 fn entitlement_get(
3715 &self,
3716 namespace: &str,
3717 ) -> ControlResult<Option<gunnar_proto::EntitlementFact>> {
3718 Ok(self
3719 .inner
3720 .lock()
3721 .unwrap()
3722 .entitlements
3723 .get(namespace)
3724 .cloned())
3725 }
3726
3727 #[cfg(feature = "metered")]
3728 fn entitlement_list(&self) -> ControlResult<Vec<gunnar_proto::EntitlementFact>> {
3729 Ok(self
3730 .inner
3731 .lock()
3732 .unwrap()
3733 .entitlements
3734 .values()
3735 .cloned()
3736 .collect())
3737 }
3738
3739 /// The double's actor-ticket key, minted on first use and stable after —
3740 /// which is what the real store's `get_or_create_with` gives the appliance,
3741 /// reproduced here under this double's one `Mutex`.
3742 #[cfg(feature = "metered")]
3743 fn actor_ticket_public_key_hex(&self) -> ControlResult<String> {
3744 let mut inner = self.inner.lock().unwrap();
3745 let key = Inner::actor_ticket_key(&mut inner);
3746 Ok(key
3747 .verifying_key()
3748 .to_bytes()
3749 .iter()
3750 .map(|byte| format!("{byte:02x}"))
3751 .collect())
3752 }
3753
3754 #[cfg(feature = "metered")]
3755 fn actor_ticket_sign(&self, message: &[u8]) -> ControlResult<[u8; 64]> {
3756 use ed25519_dalek::Signer as _;
3757 let mut inner = self.inner.lock().unwrap();
3758 let signature = Inner::actor_ticket_key(&mut inner).sign(message).to_bytes();
3759 // Counted AFTER the signature exists, so the number means "a ticket
3760 // this box signed" and not "a call that was attempted".
3761 inner.actor_tickets_signed += 1;
3762 Ok(signature)
3763 }
3764
3765 fn create_namespace(&self, name: &str, description: &str) -> ControlResult<Namespace> {
3766 if name.is_empty() {
3767 return Err(ControlError::InvalidArgument("namespace is empty".into()));
3768 }
3769 let mut inner = self.inner.lock().unwrap();
3770 if inner.namespaces.contains_key(name) {
3771 return Err(ControlError::AlreadyExists(format!("namespace {name}")));
3772 }
3773 let namespace = Namespace {
3774 name: name.to_string(),
3775 description: description.to_string(),
3776 created_at_unix_ms: now_ms(),
3777 owner_kind: 0,
3778 ..Default::default()
3779 };
3780 inner.namespaces.insert(name.to_string(), namespace.clone());
3781 Ok(namespace)
3782 }
3783
3784 fn list_namespaces(&self) -> ControlResult<Vec<Namespace>> {
3785 let inner = self.inner.lock().unwrap();
3786 Ok(inner
3787 .namespaces
3788 .values()
3789 .cloned()
3790 .map(|mut ns| {
3791 ns.employees = inner
3792 .employers
3793 .values()
3794 .filter(|account| **account == ns.name)
3795 .count() as u32;
3796 ns
3797 })
3798 .collect())
3799 }
3800
3801 fn set_namespace_settings(
3802 &self,
3803 name: &str,
3804 public: Option<bool>,
3805 events_public: Option<bool>,
3806 ) -> ControlResult<Namespace> {
3807 {
3808 let mut inner = self.inner.lock().unwrap();
3809 let ns = inner
3810 .namespaces
3811 .get_mut(name)
3812 .ok_or_else(|| ControlError::NotFound(format!("namespace {name}")))?;
3813 if let Some(public) = public {
3814 ns.public = public;
3815 }
3816 if let Some(events_public) = events_public {
3817 ns.events_public = events_public;
3818 }
3819 }
3820 Ok(self
3821 .namespace(name)?
3822 .expect("the namespace was just written"))
3823 }
3824
3825 fn delete_namespace(&self, name: &str, delete_repos: bool) -> ControlResult<u32> {
3826 let mut inner = self.inner.lock().unwrap();
3827 if !inner.namespaces.contains_key(name) {
3828 return Err(ControlError::NotFound(format!("namespace {name}")));
3829 }
3830 let held: Vec<(String, String)> = inner
3831 .repos
3832 .keys()
3833 .filter(|(ns, _)| ns == name)
3834 .cloned()
3835 .collect();
3836 if !held.is_empty() && !delete_repos {
3837 return Err(ControlError::FailedPrecondition(format!(
3838 "namespace {name} holds {} repos; deleting repos is never implicit",
3839 held.len()
3840 )));
3841 }
3842 for key in &held {
3843 inner.repos.remove(key);
3844 inner.mirrors.remove(key);
3845 inner.protections.remove(key);
3846 inner.ref_policies.remove(key);
3847 }
3848 inner.namespaces.remove(name);
3849 inner.grants.retain(|g| g.namespace != name);
3850 Ok(held.len() as u32)
3851 }
3852
3853 fn create_repo(&self, spec: NewRepo) -> ControlResult<Repo> {
3854 spec.path
3855 .validate()
3856 .map_err(|e| ControlError::InvalidArgument(e.to_string()))?;
3857 let mut inner = self.inner.lock().unwrap();
3858 if !inner.namespaces.contains_key(&spec.path.namespace) {
3859 return Err(ControlError::NotFound(format!(
3860 "namespace {}",
3861 spec.path.namespace
3862 )));
3863 }
3864 let key = MemoryControlPlane::key(&spec.path);
3865 if inner.repos.contains_key(&key) {
3866 return Err(ControlError::AlreadyExists(format!(
3867 "repository {}",
3868 spec.path
3869 )));
3870 }
3871 let hash_kind = if spec.hash_kind == HashKind::Unspecified {
3872 DEFAULT_HASH_KIND
3873 } else {
3874 spec.hash_kind
3875 };
3876 let default_branch = if spec.default_branch.is_empty() {
3877 DEFAULT_BRANCH.to_string()
3878 } else {
3879 spec.default_branch.clone()
3880 };
3881 let visibility = if spec.visibility == Visibility::Unspecified {
3882 Visibility::Private
3883 } else {
3884 spec.visibility
3885 };
3886 let repo = Repo {
3887 path: Some(spec.path.clone()),
3888 hash_kind: hash_kind as i32,
3889 default_branch,
3890 visibility: visibility as i32,
3891 created_at_unix_ms: now_ms(),
3892 description: spec.description,
3893 encrypted: false,
3894 };
3895 inner.repos.insert(key, repo.clone());
3896 Ok(repo)
3897 }
3898
3899 fn get_repo(&self, path: &RepoPath) -> ControlResult<Repo> {
3900 self.inner
3901 .lock()
3902 .unwrap()
3903 .repos
3904 .get(&MemoryControlPlane::key(path))
3905 .cloned()
3906 .ok_or_else(|| ControlError::NotFound(format!("repository {path}")))
3907 }
3908
3909 fn list_repos(&self, namespace: Option<&str>) -> ControlResult<Vec<Repo>> {
3910 Ok(self
3911 .inner
3912 .lock()
3913 .unwrap()
3914 .repos
3915 .iter()
3916 .filter(|((ns, _), _)| namespace.is_none_or(|want| want == ns))
3917 .map(|(_, repo)| repo.clone())
3918 .collect())
3919 }
3920
3921 /// The test double holds no bytes, so both arms of the real verb collapse
3922 /// to removing the row — but the OUTCOME still says which was asked for,
3923 /// because that is the fact the service reports and a double that always
3924 /// said `Purged` would let a soft-delete regression pass.
3925 fn delete_repo(&self, path: &RepoPath, options: DeleteOptions) -> ControlResult<DeleteOutcome> {
3926 let key = MemoryControlPlane::key(path);
3927 let mut inner = self.inner.lock().unwrap();
3928 if inner.repos.remove(&key).is_none() {
3929 return Err(ControlError::NotFound(format!("repository {path}")));
3930 }
3931 inner.mirrors.remove(&key);
3932 inner.protections.remove(&key);
3933 inner.ref_policies.remove(&key);
3934 inner
3935 .grants
3936 .retain(|g| !(g.namespace == path.namespace && g.repo == path.repo));
3937 Ok(if options.purge {
3938 DeleteOutcome::Purged { bytes_reclaimed: 0 }
3939 } else {
3940 DeleteOutcome::Tombstoned {
3941 purge_after_unix_ms: now_ms()
3942 + i64::from(options.retention_days.unwrap_or(30)) * 86_400_000,
3943 bytes_kept: 0,
3944 }
3945 })
3946 }
3947
3948 fn set_default_branch(&self, path: &RepoPath, default_branch: &str) -> ControlResult<Repo> {
3949 if default_branch.is_empty() {
3950 return Err(ControlError::InvalidArgument(
3951 "default_branch is empty".into(),
3952 ));
3953 }
3954 let mut inner = self.inner.lock().unwrap();
3955 let repo = inner
3956 .repos
3957 .get_mut(&MemoryControlPlane::key(path))
3958 .ok_or_else(|| ControlError::NotFound(format!("repository {path}")))?;
3959 repo.default_branch = default_branch.to_string();
3960 Ok(repo.clone())
3961 }
3962
3963 fn set_visibility(&self, path: &RepoPath, visibility: Visibility) -> ControlResult<Repo> {
3964 if visibility == Visibility::Unspecified {
3965 return Err(ControlError::InvalidArgument(
3966 "visibility unspecified".into(),
3967 ));
3968 }
3969 let mut inner = self.inner.lock().unwrap();
3970 let repo = inner
3971 .repos
3972 .get_mut(&MemoryControlPlane::key(path))
3973 .ok_or_else(|| ControlError::NotFound(format!("repository {path}")))?;
3974 repo.visibility = visibility as i32;
3975 Ok(repo.clone())
3976 }
3977
3978 fn set_encrypted(&self, path: &RepoPath, encrypted: bool) -> ControlResult<Repo> {
3979 let mut inner = self.inner.lock().unwrap();
3980 let repo = inner
3981 .repos
3982 .get_mut(&MemoryControlPlane::key(path))
3983 .ok_or_else(|| ControlError::NotFound(format!("repository {path}")))?;
3984 repo.encrypted = encrypted;
3985 Ok(repo.clone())
3986 }
3987
3988 fn list_refs(
3989 &self,
3990 path: &RepoPath,
3991 _prefix: &str,
3992 _peel: bool,
3993 ) -> ControlResult<Vec<RefTarget>> {
3994 self.get_repo(path)?;
3995 Err(ControlError::Unimplemented("Refs.List"))
3996 }
3997
3998 fn get_ref(&self, path: &RepoPath, _ref_name: &str, _peel: bool) -> ControlResult<RefTarget> {
3999 self.get_repo(path)?;
4000 Err(ControlError::Unimplemented("Refs.Get"))
4001 }
4002
4003 fn log(
4004 &self,
4005 path: &RepoPath,
4006 _start: &str,
4007 _limit: u32,
4008 _touching: &str,
4009 ) -> ControlResult<Vec<Commit>> {
4010 self.get_repo(path)?;
4011 Err(ControlError::Unimplemented("Refs.Log"))
4012 }
4013
4014 fn create_pull(
4015 &self,
4016 path: &RepoPath,
4017 _author: &str,
4018 _title: &str,
4019 _description: &str,
4020 _source_ref: &str,
4021 _target_ref: &str,
4022 ) -> ControlResult<PullRequest> {
4023 self.get_repo(path)?;
4024 Err(ControlError::Unimplemented("PullRequests.Create"))
4025 }
4026
4027 fn list_pulls(&self, path: &RepoPath, _state: i32) -> ControlResult<Vec<PullRequest>> {
4028 self.get_repo(path)?;
4029 Err(ControlError::Unimplemented("PullRequests.List"))
4030 }
4031
4032 fn get_pull(&self, path: &RepoPath, _number: u64) -> ControlResult<PullRequest> {
4033 self.get_repo(path)?;
4034 Err(ControlError::Unimplemented("PullRequests.Get"))
4035 }
4036
4037 fn close_pull(&self, path: &RepoPath, _number: u64) -> ControlResult<PullRequest> {
4038 self.get_repo(path)?;
4039 Err(ControlError::Unimplemented("PullRequests.Close"))
4040 }
4041
4042 fn merge_pull(
4043 &self,
4044 path: &RepoPath,
4045 _number: u64,
4046 _principal_id: &str,
4047 ) -> ControlResult<(PullRequest, MergedRef)> {
4048 self.get_repo(path)?;
4049 Err(ControlError::Unimplemented("PullRequests.Merge"))
4050 }
4051
4052 fn read_object(
4053 &self,
4054 path: &RepoPath,
4055 _oid: &str,
4056 _max_bytes: u64,
4057 ) -> ControlResult<ReadObjectResponse> {
4058 self.get_repo(path)?;
4059 Err(ControlError::Unimplemented("Refs.ReadObject"))
4060 }
4061
4062 fn diff(
4063 &self,
4064 path: &RepoPath,
4065 _from: &str,
4066 _to: &str,
4067 _path_prefixes: &[String],
4068 ) -> ControlResult<Vec<FileChange>> {
4069 self.get_repo(path)?;
4070 Err(ControlError::Unimplemented("Refs.Diff"))
4071 }
4072
4073 fn add_principal(&self, id: &str, name: &str, kind: PrincipalKind) -> ControlResult<Principal> {
4074 if name.is_empty() || id.is_empty() {
4075 return Err(ControlError::InvalidArgument(
4076 "principal name and id are required".into(),
4077 ));
4078 }
4079 let mut inner = self.inner.lock().unwrap();
4080 if inner.principals.contains_key(id) {
4081 return Err(ControlError::AlreadyExists(format!("principal {id}")));
4082 }
4083 // The same rule as the real table: a name is taken by another name
4084 // AND by another id.
4085 if let Some(holder) = Inner::name_holder(&inner, name, id) {
4086 return Err(ControlError::AlreadyExists(format!(
4087 "the name \"{name}\" is already taken by principal {holder}"
4088 )));
4089 }
4090 let principal = Principal {
4091 id: id.to_string(),
4092 name: name.to_string(),
4093 kind: kind as i32,
4094 created_at_unix_ms: now_ms(),
4095 };
4096 inner.principals.insert(id.to_string(), principal.clone());
4097 Ok(principal)
4098 }
4099
4100 fn enroll_employee(&self, account: &str, ask: &EmployeeAsk) -> ControlResult<EmployeeAnswer> {
4101 let named = ask.seats == 0;
4102 if named && (ask.name.is_empty() || ask.id.is_empty()) {
4103 return Err(ControlError::InvalidArgument(
4104 "an employee's id and name are required".into(),
4105 ));
4106 }
4107 let mut inner = self.inner.lock().unwrap();
4108 if !inner.namespaces.contains_key(account) {
4109 return Err(ControlError::NotFound(format!("account {account}")));
4110 }
4111 let open = |lots: &[(u64, u32, Vec<String>, String)]| -> u32 {
4112 lots.iter()
4113 .map(|(_, total, filled, _)| total.saturating_sub(filled.len() as u32))
4114 .sum()
4115 };
4116 let lots = inner.seats.get(account).cloned().unwrap_or_default();
4117 let total: u32 = lots.iter().map(|(_, total, _, _)| *total).sum();
4118 if !ask.order.is_empty() {
4119 if let Some((owner, principal, _, room, need)) =
4120 inner.room_orders.get(&ask.order).cloned()
4121 {
4122 if owner != account {
4123 return Err(ControlError::InvalidArgument(format!(
4124 "order {} was granted to {owner}, not to {account}",
4125 ask.order
4126 )));
4127 }
4128 return Ok(EmployeeAnswer {
4129 principal: inner.principals.get(&principal).cloned(),
4130 room_bytes: room,
4131 seats: total,
4132 seats_open: open(&lots),
4133 need_bytes: need,
4134 unused_bytes: None,
4135 shortfall_bytes: 0,
4136 unchanged: true,
4137 });
4138 }
4139 }
4140 if named {
4141 if ask.id == account {
4142 return Err(ControlError::InvalidArgument(format!(
4143 "{account} is the account itself, not an employee of it"
4144 )));
4145 }
4146 if inner.principals.contains_key(&ask.id)
4147 || inner.principals.values().any(|p| p.name == ask.name)
4148 {
4149 return Err(ControlError::AlreadyExists(format!(
4150 "principal {} already exists, and an existing principal can never become \
4151 an employee",
4152 ask.id
4153 )));
4154 }
4155 if inner.namespaces.contains_key(&ask.id) {
4156 return Err(ControlError::AlreadyExists(format!(
4157 "namespace {} exists, so an employee may not be called that",
4158 ask.id
4159 )));
4160 }
4161 }
4162 let lot = if ask.from_seat {
4163 Some(
4164 lots.iter()
4165 .position(|(_, total, filled, _)| (filled.len() as u32) < *total)
4166 .ok_or_else(|| {
4167 ControlError::FailedPrecondition(format!(
4168 "{account} has no open seat: every seat it holds is filled. Its \
4169 admin adds users first"
4170 ))
4171 })?,
4172 )
4173 } else {
4174 None
4175 };
4176 let room_bytes = lot.map_or(ask.room_bytes, |lot| lots[lot].0);
4177 let need_bytes = match (lot, ask.seats) {
4178 (Some(_), _) => 0,
4179 (None, 0) => room_bytes,
4180 (None, seats) => room_bytes.saturating_mul(u64::from(seats)),
4181 };
4182 let committed = ask.cap.map(|_| {
4183 let promised: u64 = lots
4184 .iter()
4185 .map(|(room, total, filled, _)| {
4186 room * u64::from(total.saturating_sub(filled.len() as u32))
4187 })
4188 .sum::<u64>()
4189 + inner
4190 .employers
4191 .iter()
4192 .filter(|(_, employer)| *employer == account)
4193 .filter_map(|(id, _)| inner.rooms.get(id).map(|(room, _)| *room))
4194 .sum::<u64>();
4195 promised.saturating_add(ask.stored)
4196 });
4197 let unused_bytes = ask
4198 .cap
4199 .zip(committed)
4200 .map(|(cap, committed)| cap.saturating_sub(committed));
4201 let shortfall_bytes = ask.cap.zip(committed).map_or(0, |(cap, committed)| {
4202 need_bytes.saturating_add(committed).saturating_sub(cap)
4203 });
4204 let mut answer = EmployeeAnswer {
4205 principal: None,
4206 room_bytes,
4207 seats: total,
4208 seats_open: open(&lots),
4209 need_bytes,
4210 unused_bytes,
4211 shortfall_bytes,
4212 unchanged: ask.dry_run || shortfall_bytes > 0,
4213 };
4214 if answer.unchanged {
4215 return Ok(answer);
4216 }
4217 let mut lots = lots;
4218 if named {
4219 let principal = Principal {
4220 id: ask.id.clone(),
4221 name: ask.name.clone(),
4222 kind: PrincipalKind::User as i32,
4223 created_at_unix_ms: now_ms(),
4224 };
4225 inner.principals.insert(ask.id.clone(), principal.clone());
4226 inner.employers.insert(ask.id.clone(), account.to_owned());
4227 inner.grants.push(Grant {
4228 principal_id: ask.id.clone(),
4229 namespace: account.to_owned(),
4230 repo: String::new(),
4231 level: GrantLevel::Read as i32,
4232 ..Default::default()
4233 });
4234 let order = match lot {
4235 Some(lot) => {
4236 lots[lot].2.push(ask.id.clone());
4237 lots[lot].3.clone()
4238 }
4239 None => ask.order.clone(),
4240 };
4241 if room_bytes > 0 {
4242 inner.rooms.insert(ask.id.clone(), (room_bytes, order));
4243 }
4244 answer.principal = Some(principal);
4245 } else {
4246 lots.push((room_bytes, ask.seats, Vec::new(), ask.order.clone()));
4247 }
4248 answer.seats = lots.iter().map(|(_, total, _, _)| *total).sum();
4249 answer.seats_open = open(&lots);
4250 if !lots.is_empty() {
4251 inner.seats.insert(account.to_owned(), lots);
4252 }
4253 if !ask.order.is_empty() {
4254 inner.room_orders.insert(
4255 ask.order.clone(),
4256 (
4257 account.to_owned(),
4258 if named { ask.id.clone() } else { String::new() },
4259 ask.seats,
4260 room_bytes,
4261 need_bytes,
4262 ),
4263 );
4264 }
4265 Ok(answer)
4266 }
4267
4268 fn seats(&self, account: &str) -> ControlResult<(u32, u32)> {
4269 let inner = self.inner.lock().unwrap();
4270 let lots = inner.seats.get(account).cloned().unwrap_or_default();
4271 Ok((
4272 lots.iter().map(|(_, total, _, _)| *total).sum(),
4273 lots.iter()
4274 .map(|(_, total, filled, _)| total.saturating_sub(filled.len() as u32))
4275 .sum(),
4276 ))
4277 }
4278
4279 fn employees(&self, account: &str) -> ControlResult<Vec<Principal>> {
4280 let inner = self.inner.lock().unwrap();
4281 Ok(inner
4282 .employers
4283 .iter()
4284 .filter(|(_, employer)| *employer == account)
4285 .filter_map(|(id, _)| inner.principals.get(id).cloned())
4286 .collect())
4287 }
4288
4289 /// The double keeps no feed: the operator is the ASKED name, by the same
4290 /// [`operator_name`] rule (a plane-level test of the fed half lives where
4291 /// the feed is, in `gunnar-server`).
4292 /// The double keeps the same rule as the shipped plane: the operator must
4293 /// be a person; once he holds a passkey the bootstrap is spent (and a
4294 /// leftover one withdrawn); otherwise the invite is (re)recorded.
4295 fn operator_bootstrap_invite(
4296 &self,
4297 operator: &str,
4298 secret_sha256: &str,
4299 now_unix_ms: i64,
4300 ttl_ms: i64,
4301 ) -> ControlResult<BootstrapInvite> {
4302 let hash = bootstrap_hash(secret_sha256)?;
4303 let ttl_ms = bootstrap_ttl(ttl_ms)?;
4304 let mut inner = self.inner.lock().unwrap();
4305 let principal = inner
4306 .principals
4307 .get(operator)
4308 .cloned()
4309 .ok_or_else(|| ControlError::NotFound(format!("principal {operator}")))?;
4310 if principal.kind != PrincipalKind::User as i32 {
4311 return Err(ControlError::FailedPrecondition(format!(
4312 "principal {operator} is not a person: the operator's first invite is a person's"
4313 )));
4314 }
4315 if inner
4316 .credentials
4317 .values()
4318 .any(|stored| stored.credential.principal_id == operator)
4319 {
4320 inner.first_invite = None;
4321 return Ok(BootstrapInvite::Spent);
4322 }
4323 let renewed = inner
4324 .first_invite
4325 .as_ref()
4326 .is_some_and(|held| held.secret_hash == hash && held.principal_id == operator);
4327 inner.first_invite = Some(FirstInvite {
4328 principal_id: operator.to_owned(),
4329 secret_hash: hash,
4330 created_at_unix_ms: now_unix_ms,
4331 expires_at_unix_ms: now_unix_ms + ttl_ms,
4332 });
4333 Ok(if renewed {
4334 BootstrapInvite::Renewed
4335 } else {
4336 BootstrapInvite::Recorded
4337 })
4338 }
4339 fn claim_operator(&self, asked: &str) -> ControlResult<OperatorClaim> {
4340 let (name, named_by) = operator_name(None, asked)?;
4341 let held = {
4342 let inner = self.inner.lock().unwrap();
4343 inner
4344 .principals
4345 .get(name)
4346 .cloned()
4347 .or_else(|| inner.principals.values().find(|p| p.name == name).cloned())
4348 };
4349 let (principal, created) = match held {
4350 Some(p) if p.kind != PrincipalKind::User as i32 => {
4351 return Err(ControlError::FailedPrecondition(format!(
4352 "the operator's name {name:?} is held by a principal that is not a person"
4353 )))
4354 }
4355 Some(p) => (p, false),
4356 None => (self.add_principal(name, name, PrincipalKind::User)?, true),
4357 };
4358 let mut inner = self.inner.lock().unwrap();
4359 let adopted_namespace = created && inner.namespaces.contains_key(&principal.id);
4360 if !inner.namespaces.contains_key(&principal.id) {
4361 inner.namespaces.insert(
4362 principal.id.clone(),
4363 Namespace {
4364 name: principal.id.clone(),
4365 created_at_unix_ms: now_ms(),
4366 ..Default::default()
4367 },
4368 );
4369 }
4370 inner
4371 .server_grants
4372 .insert(principal.id.clone(), GrantLevel::Admin);
4373 Ok(OperatorClaim {
4374 principal,
4375 created,
4376 adopted_namespace,
4377 named_by,
4378 })
4379 }
4380
4381 fn list_principals(&self) -> ControlResult<Vec<Principal>> {
4382 Ok(self
4383 .inner
4384 .lock()
4385 .unwrap()
4386 .principals
4387 .values()
4388 .cloned()
4389 .collect())
4390 }
4391
4392 fn principal_by_name(&self, name: &str) -> ControlResult<Option<Principal>> {
4393 Ok(self
4394 .inner
4395 .lock()
4396 .unwrap()
4397 .principals
4398 .values()
4399 .find(|p| p.name == name)
4400 .cloned())
4401 }
4402
4403 fn add_credential(
4404 &self,
4405 principal_id: &str,
4406 credential_id: &[u8],
4407 public_key: &[u8],
4408 label: &str,
4409 sign_count: u32,
4410 ) -> ControlResult<gunnar_proto::WebCredential> {
4411 let mut inner = self.inner.lock().unwrap();
4412 let principal = inner
4413 .principals
4414 .get(principal_id)
4415 .cloned()
4416 .ok_or_else(|| ControlError::NotFound(format!("principal {principal_id}")))?;
4417 if principal.kind != PrincipalKind::User as i32 {
4418 return Err(ControlError::FailedPrecondition(format!(
4419 "principal {principal_id} is not a person: no human credential on a machine principal"
4420 )));
4421 }
4422 let key = crate::webauthn::b64url(credential_id);
4423 if inner.credentials.contains_key(&key) {
4424 return Err(ControlError::AlreadyExists(format!("web credential {key}")));
4425 }
4426 let credential = gunnar_proto::WebCredential {
4427 credential_id: credential_id.to_vec(),
4428 principal_id: principal_id.to_owned(),
4429 label: label.to_owned(),
4430 created_at_unix_ms: now_ms(),
4431 last_used_unix_ms: None,
4432 sign_count,
4433 };
4434 inner.credentials.insert(
4435 key,
4436 StoredCredential {
4437 credential: credential.clone(),
4438 public_key: public_key.to_vec(),
4439 },
4440 );
4441 Ok(credential)
4442 }
4443
4444 fn credentials(
4445 &self,
4446 principal_id: Option<&str>,
4447 ) -> ControlResult<Vec<gunnar_proto::WebCredential>> {
4448 Ok(self
4449 .inner
4450 .lock()
4451 .unwrap()
4452 .credentials
4453 .values()
4454 .filter(|c| principal_id.is_none_or(|want| c.credential.principal_id == want))
4455 .map(|c| c.credential.clone())
4456 .collect())
4457 }
4458
4459 fn credential(&self, credential_id: &[u8]) -> ControlResult<Option<StoredCredential>> {
4460 Ok(self
4461 .inner
4462 .lock()
4463 .unwrap()
4464 .credentials
4465 .get(&crate::webauthn::b64url(credential_id))
4466 .cloned())
4467 }
4468
4469 fn record_passkey_use(
4470 &self,
4471 credential_id: &[u8],
4472 sign_count: u32,
4473 now_unix_ms: i64,
4474 ) -> ControlResult<gunnar_proto::WebCredential> {
4475 let key = crate::webauthn::b64url(credential_id);
4476 let mut inner = self.inner.lock().unwrap();
4477 let stored = inner
4478 .credentials
4479 .get_mut(&key)
4480 .ok_or_else(|| ControlError::NotFound(format!("web credential {key}")))?;
4481 let held = stored.credential.sign_count;
4482 if held != 0 && sign_count != 0 && sign_count <= held {
4483 return Err(ControlError::PermissionDenied(format!(
4484 "the signature counter of web credential {key} went from {held} to {sign_count}"
4485 )));
4486 }
4487 if sign_count != 0 {
4488 stored.credential.sign_count = sign_count;
4489 }
4490 stored.credential.last_used_unix_ms = Some(now_unix_ms);
4491 Ok(stored.credential.clone())
4492 }
4493
4494 fn set_server_level(&self, principal_id: &str, level: GrantLevel) -> ControlResult<()> {
4495 if !self
4496 .inner
4497 .lock()
4498 .unwrap()
4499 .principals
4500 .contains_key(principal_id)
4501 {
4502 return Err(ControlError::NotFound(format!("principal {principal_id}")));
4503 }
4504 self.grant_server(principal_id, level);
4505 Ok(())
4506 }
4507
4508 fn revoke_credential(
4509 &self,
4510 credential_id: &[u8],
4511 ) -> ControlResult<Option<gunnar_proto::WebCredential>> {
4512 let key = crate::webauthn::b64url(credential_id);
4513 let mut inner = self.inner.lock().unwrap();
4514 let removed = inner.credentials.remove(&key).map(|c| c.credential);
4515 // Its session keys go with it.
4516 for fingerprint in inner.credential_sessions.remove(&key).unwrap_or_default() {
4517 inner.keys.remove(&fingerprint);
4518 inner.openssh.remove(&fingerprint);
4519 }
4520 Ok(removed)
4521 }
4522
4523 fn rename_credential(
4524 &self,
4525 credential_id: &[u8],
4526 label: &str,
4527 ) -> ControlResult<Option<gunnar_proto::WebCredential>> {
4528 let mut inner = self.inner.lock().unwrap();
4529 Ok(inner
4530 .credentials
4531 .get_mut(&crate::webauthn::b64url(credential_id))
4532 .map(|stored| {
4533 stored.credential.label = label.to_owned();
4534 stored.credential.clone()
4535 }))
4536 }
4537
4538 fn first_invite(&self) -> ControlResult<Option<FirstInvite>> {
4539 Ok(self.inner.lock().unwrap().first_invite.clone())
4540 }
4541
4542 fn drop_first_invite(&self) -> ControlResult<()> {
4543 self.inner.lock().unwrap().first_invite = None;
4544 Ok(())
4545 }
4546
4547 fn holds_server_grant(&self, principal_id: &str) -> ControlResult<bool> {
4548 Ok(self
4549 .inner
4550 .lock()
4551 .unwrap()
4552 .server_grants
4553 .contains_key(principal_id))
4554 }
4555
4556 fn employer_of(&self, principal_id: &str) -> ControlResult<Option<String>> {
4557 Ok(self
4558 .inner
4559 .lock()
4560 .unwrap()
4561 .employers
4562 .get(principal_id)
4563 .cloned())
4564 }
4565
4566 fn approve_machine_enrolment(
4567 &self,
4568 approval: MachineApproval,
4569 ) -> ControlResult<MachineApproved> {
4570 let mut inner = self.inner.lock().unwrap();
4571 let key = crate::webauthn::b64url(&approval.credential_id);
4572 if inner.credentials.contains_key(&key) {
4573 return Err(ControlError::AlreadyExists(format!("web credential {key}")));
4574 }
4575 let mut step_up = None;
4576 if let Some((id, presented)) = &approval.step_up {
4577 let held = crate::webauthn::b64url(id);
4578 let stored = inner
4579 .credentials
4580 .get(&held)
4581 .cloned()
4582 .ok_or_else(|| ControlError::NotFound(format!("web credential {held}")))?;
4583 let count = stored.credential.sign_count;
4584 if count != 0 && *presented != 0 && *presented <= count {
4585 return Err(ControlError::PermissionDenied(format!(
4586 "the signature counter of web credential {held} went from {count} to {presented}: \
4587 a cloned authenticator, or a replay"
4588 )));
4589 }
4590 step_up = Some((held, stored, *presented));
4591 }
4592 let (principal, created) = match inner.principals.get(&approval.machine).cloned() {
4593 Some(p) if p.kind != PrincipalKind::Machine as i32 => {
4594 return Err(ControlError::FailedPrecondition(format!(
4595 "principal {} is not a machine: a machine's credential never lands on a person",
4596 approval.machine
4597 )))
4598 }
4599 Some(p) => (p, false),
4600 None => {
4601 if let Some(holder) =
4602 Inner::name_holder(&inner, &approval.machine, &approval.machine)
4603 {
4604 return Err(ControlError::AlreadyExists(format!(
4605 "the name \"{}\" is already taken by principal {holder}",
4606 approval.machine
4607 )));
4608 }
4609 let p = Principal {
4610 id: approval.machine.clone(),
4611 name: approval.machine.clone(),
4612 kind: PrincipalKind::Machine as i32,
4613 created_at_unix_ms: approval.now_unix_ms,
4614 };
4615 inner.principals.insert(p.id.clone(), p.clone());
4616 (p, true)
4617 }
4618 };
4619 if let Some((held, mut stored, presented)) = step_up {
4620 if presented != 0 {
4621 stored.credential.sign_count = presented;
4622 }
4623 stored.credential.last_used_unix_ms = Some(approval.now_unix_ms);
4624 inner.credentials.insert(held, stored);
4625 }
4626 inner
4627 .server_grants
4628 .insert(principal.id.clone(), approval.server_level);
4629 let credential = gunnar_proto::WebCredential {
4630 credential_id: approval.credential_id.clone(),
4631 principal_id: principal.id.clone(),
4632 label: approval.label,
4633 created_at_unix_ms: approval.now_unix_ms,
4634 last_used_unix_ms: None,
4635 sign_count: approval.sign_count,
4636 };
4637 inner.credentials.insert(
4638 key,
4639 StoredCredential {
4640 credential: credential.clone(),
4641 public_key: approval.public_key,
4642 },
4643 );
4644 Ok(MachineApproved {
4645 principal,
4646 credential,
4647 created,
4648 })
4649 }
4650
4651 fn rename_principal(&self, principal_id: &str, name: &str) -> ControlResult<Principal> {
4652 if name.is_empty() {
4653 return Err(ControlError::InvalidArgument(
4654 "the new name is empty".into(),
4655 ));
4656 }
4657 let mut inner = self.inner.lock().unwrap();
4658 if !inner.principals.contains_key(principal_id) {
4659 return Err(ControlError::NotFound(format!("principal {principal_id}")));
4660 }
4661 if let Some(holder) = Inner::name_holder(&inner, name, principal_id) {
4662 return Err(ControlError::AlreadyExists(format!(
4663 "the name \"{name}\" is already taken by principal {holder}"
4664 )));
4665 }
4666 let row = inner
4667 .principals
4668 .get_mut(principal_id)
4669 .expect("checked above");
4670 row.name = name.to_string();
4671 Ok(row.clone())
4672 }
4673
4674 fn remove_principal(&self, principal_id: &str) -> ControlResult<(u32, u32)> {
4675 let mut inner = self.inner.lock().unwrap();
4676 inner.employers.remove(principal_id);
4677 if inner.principals.remove(principal_id).is_none() {
4678 return Err(ControlError::NotFound(format!("principal {principal_id}")));
4679 }
4680 let before_keys = inner.keys.len();
4681 inner.keys.retain(|_, k| k.principal_id != principal_id);
4682 let live: Vec<String> = inner.keys.keys().cloned().collect();
4683 inner.openssh.retain(|fp, _| live.contains(fp));
4684 let before_grants = inner.grants.len();
4685 inner.grants.retain(|g| g.principal_id != principal_id);
4686 inner.server_grants.remove(principal_id);
4687 inner
4688 .credentials
4689 .retain(|_, c| c.credential.principal_id != principal_id);
4690 if inner
4691 .first_invite
4692 .as_ref()
4693 .is_some_and(|i| i.principal_id == principal_id)
4694 {
4695 inner.first_invite = None;
4696 }
4697 Ok((
4698 (before_keys - inner.keys.len()) as u32,
4699 (before_grants - inner.grants.len()) as u32,
4700 ))
4701 }
4702
4703 fn add_key(
4704 &self,
4705 principal_id: &str,
4706 openssh_public_key: &str,
4707 comment: &str,
4708 expires_at_unix_ms: Option<i64>,
4709 caller: &str,
4710 attestation: Option<SignedKeyAttestation>,
4711 ) -> ControlResult<PublicKey> {
4712 let mut fields = openssh_public_key.split_whitespace();
4713 let (Some(algo), Some(_blob)) = (fields.next(), fields.next()) else {
4714 return Err(ControlError::InvalidArgument(
4715 "not an OpenSSH public key line: expected `<algo> <base64> [comment]`".into(),
4716 ));
4717 };
4718 if !(algo.starts_with("ssh-") || algo.starts_with("ecdsa-") || algo.starts_with("sk-")) {
4719 return Err(ControlError::InvalidArgument(format!(
4720 "unrecognised public key algorithm {algo}"
4721 )));
4722 }
4723 let mut inner = self.inner.lock().unwrap();
4724 // Principal first, then key material: "there is no such principal" is
4725 // the more useful answer, and the ordering is pinned by
4726 // `access_rejects_the_things_that_would_break_the_ssh_side`.
4727 if !inner.principals.contains_key(principal_id) {
4728 return Err(ControlError::NotFound(format!("principal {principal_id}")));
4729 }
4730 let fingerprint = fingerprint_of_openssh(openssh_public_key).ok_or_else(|| {
4731 ControlError::InvalidArgument(format!(
4732 "the {algo} key does not parse as an OpenSSH public key line; the fingerprint \
4733 the SSH listener will match against cannot be computed from it"
4734 ))
4735 })?;
4736 if inner.keys.contains_key(&fingerprint) {
4737 return Err(ControlError::AlreadyExists(format!("key {fingerprint}")));
4738 }
4739 let holders: Vec<String> = inner
4740 .keys
4741 .values()
4742 .filter(|key| key.principal_id == principal_id)
4743 .filter(|key| {
4744 key.expires_at_unix_ms
4745 .is_none_or(|expires| now_ms() < expires)
4746 })
4747 .map(|key| key.fingerprint.clone())
4748 .collect();
4749 require_attestation(
4750 principal_id,
4751 caller,
4752 &holders,
4753 &fingerprint,
4754 attestation.as_ref(),
4755 )?;
4756 inner
4757 .openssh
4758 .insert(fingerprint.clone(), openssh_public_key.trim().to_owned());
4759 let key = PublicKey {
4760 fingerprint: fingerprint.clone(),
4761 algo: algo.to_string(),
4762 principal_id: principal_id.to_string(),
4763 comment: comment.to_string(),
4764 added_at_unix_ms: now_ms(),
4765 last_used_unix_ms: None,
4766 expires_at_unix_ms,
4767 };
4768 inner.keys.insert(fingerprint, key.clone());
4769 Ok(key)
4770 }
4771
4772 fn revoke_key(&self, fingerprint: &str) -> ControlResult<()> {
4773 let mut inner = self.inner.lock().unwrap();
4774 // Both together, by construction: a key whose material outlived its
4775 // record is a credential that still verifies.
4776 inner.openssh.remove(fingerprint);
4777 inner
4778 .keys
4779 .remove(fingerprint)
4780 .map(|_| ())
4781 .ok_or_else(|| ControlError::NotFound(format!("key {fingerprint}")))
4782 }
4783
4784 fn list_keys(&self, principal_id: Option<&str>) -> ControlResult<Vec<PublicKey>> {
4785 Ok(self
4786 .inner
4787 .lock()
4788 .unwrap()
4789 .keys
4790 .values()
4791 .filter(|k| principal_id.is_none_or(|want| want == k.principal_id))
4792 .cloned()
4793 .collect())
4794 }
4795
4796 fn grant(
4797 &self,
4798 principal_id: &str,
4799 namespace: &str,
4800 repo: &str,
4801 level: GrantLevel,
4802 ) -> ControlResult<Grant> {
4803 if level == GrantLevel::Unspecified {
4804 return Err(ControlError::InvalidArgument(
4805 "grant level unspecified".into(),
4806 ));
4807 }
4808 if namespace.is_empty() {
4809 return Err(ControlError::InvalidArgument(
4810 "a grant must name a namespace; multi-tenancy has no ungrouped repos".into(),
4811 ));
4812 }
4813 let mut inner = self.inner.lock().unwrap();
4814 if !inner.principals.contains_key(principal_id) {
4815 return Err(ControlError::NotFound(format!("principal {principal_id}")));
4816 }
4817 if !inner.namespaces.contains_key(namespace) {
4818 return Err(ControlError::NotFound(format!("namespace {namespace}")));
4819 }
4820 if !repo.is_empty()
4821 && !inner
4822 .repos
4823 .contains_key(&(namespace.to_string(), repo.to_string()))
4824 {
4825 return Err(ControlError::NotFound(format!(
4826 "repository {namespace}/{repo}"
4827 )));
4828 }
4829 let grant = Grant {
4830 principal_id: principal_id.to_string(),
4831 namespace: namespace.to_string(),
4832 repo: repo.to_string(),
4833 level: level as i32,
4834 granted_at_unix_ms: now_ms(),
4835 };
4836 inner.grants.retain(|g| {
4837 !(g.principal_id == principal_id && g.namespace == namespace && g.repo == repo)
4838 });
4839 inner.grants.push(grant.clone());
4840 Ok(grant)
4841 }
4842
4843 fn revoke(&self, principal_id: &str, namespace: &str, repo: &str) -> ControlResult<()> {
4844 let mut inner = self.inner.lock().unwrap();
4845 let before = inner.grants.len();
4846 inner.grants.retain(|g| {
4847 !(g.principal_id == principal_id && g.namespace == namespace && g.repo == repo)
4848 });
4849 if inner.grants.len() == before {
4850 return Err(ControlError::NotFound(format!(
4851 "grant for {principal_id} on {namespace}/{repo}"
4852 )));
4853 }
4854 Ok(())
4855 }
4856
4857 fn list_grants(
4858 &self,
4859 principal_id: Option<&str>,
4860 namespace: Option<&str>,
4861 repo: Option<&str>,
4862 ) -> ControlResult<Vec<Grant>> {
4863 Ok(self
4864 .inner
4865 .lock()
4866 .unwrap()
4867 .grants
4868 .iter()
4869 .filter(|g| principal_id.is_none_or(|w| w == g.principal_id))
4870 .filter(|g| namespace.is_none_or(|w| w == g.namespace))
4871 .filter(|g| repo.is_none_or(|w| w == g.repo))
4872 .cloned()
4873 .collect())
4874 }
4875
4876 fn get_protection(
4877 &self,
4878 path: &RepoPath,
4879 ref_pattern: Option<&str>,
4880 ) -> ControlResult<Vec<BranchProtection>> {
4881 self.get_repo(path)?;
4882 let inner = self.inner.lock().unwrap();
4883 let Some(rules) = inner.protections.get(&MemoryControlPlane::key(path)) else {
4884 return Ok(Vec::new());
4885 };
4886 Ok(rules
4887 .values()
4888 .filter(|p| ref_pattern.is_none_or(|w| w == p.ref_pattern))
4889 .cloned()
4890 .collect())
4891 }
4892
4893 fn set_protection(
4894 &self,
4895 path: &RepoPath,
4896 protection: BranchProtection,
4897 ) -> ControlResult<BranchProtection> {
4898 if protection.ref_pattern.is_empty() {
4899 return Err(ControlError::InvalidArgument("ref_pattern is empty".into()));
4900 }
4901 self.get_repo(path)?;
4902 let mut inner = self.inner.lock().unwrap();
4903 inner
4904 .protections
4905 .entry(MemoryControlPlane::key(path))
4906 .or_default()
4907 .insert(protection.ref_pattern.clone(), protection.clone());
4908 Ok(protection)
4909 }
4910
4911 fn delete_protection(&self, path: &RepoPath, ref_pattern: &str) -> ControlResult<()> {
4912 self.get_repo(path)?;
4913 let mut inner = self.inner.lock().unwrap();
4914 let removed = inner
4915 .protections
4916 .get_mut(&MemoryControlPlane::key(path))
4917 .and_then(|rules| rules.remove(ref_pattern));
4918 if removed.is_none() {
4919 return Err(ControlError::NotFound(format!(
4920 "protection {ref_pattern} on {path}"
4921 )));
4922 }
4923 Ok(())
4924 }
4925
4926 fn get_ref_name_policy(&self, path: &RepoPath) -> ControlResult<RefNamePolicy> {
4927 self.get_repo(path)?;
4928 Ok(self
4929 .inner
4930 .lock()
4931 .unwrap()
4932 .ref_policies
4933 .get(&MemoryControlPlane::key(path))
4934 .cloned()
4935 .unwrap_or_default())
4936 }
4937
4938 fn set_ref_name_policy(
4939 &self,
4940 path: &RepoPath,
4941 policy: RefNamePolicy,
4942 ) -> ControlResult<RefNamePolicy> {
4943 self.get_repo(path)?;
4944 self.inner
4945 .lock()
4946 .unwrap()
4947 .ref_policies
4948 .insert(MemoryControlPlane::key(path), policy.clone());
4949 Ok(policy)
4950 }
4951
4952 // ── forge ──────────────────────────────────────────────────────────────
4953 //
4954 // **Unimplemented, on purpose.** Verifying an `sshsig` is `gunnar-core`'s
4955 // job and this crate does not depend on it; a memory plane that appended
4956 // without verifying would be a second, weaker version of the one rule that
4957 // makes a review event evidence. `Unimplemented` is the honest answer, and
4958 // it is the value that must not quietly become an empty successful
4959 // response — an empty approval list is indistinguishable from a pull
4960 // request nobody has approved, and one of those may merge.
4961 fn forge_append(&self, _event: SignedForgeEvent) -> ControlResult<u64> {
4962 Err(ControlError::Unimplemented("Forge.Append"))
4963 }
4964
4965 fn forge_review(
4966 &self,
4967 _repo: &str,
4968 _pull: u64,
4969 ) -> ControlResult<(ReviewState, Vec<SignedForgeEvent>)> {
4970 Err(ControlError::Unimplemented("Forge.GetReview"))
4971 }
4972
4973 fn forge_reviews(&self, _repo: &str) -> ControlResult<Vec<ReviewState>> {
4974 Err(ControlError::Unimplemented("Forge.ListReviews"))
4975 }
4976
4977 fn forge_issue(
4978 &self,
4979 _repo: &str,
4980 _number: u64,
4981 ) -> ControlResult<Option<(IssueState, Vec<SignedForgeEvent>)>> {
4982 Err(ControlError::Unimplemented("Forge.GetIssue"))
4983 }
4984
4985 fn forge_issues(&self, _repo: &str) -> ControlResult<Vec<IssueState>> {
4986 Err(ControlError::Unimplemented("Forge.ListIssues"))
4987 }
4988
4989 fn counts(&self) -> ControlResult<ControlCounts> {
4990 let inner = self.inner.lock().unwrap();
4991 Ok(ControlCounts {
4992 namespaces: inner.namespaces.len() as u64,
4993 repos: inner.repos.len() as u64,
4994 principals: inner.principals.len() as u64,
4995 keys: inner.keys.len() as u64,
4996 grants: inner.grants.len() as u64,
4997 })
4998 }
4999
5000 fn storage_info(&self, path: Option<&RepoPath>) -> ControlResult<StorageInfoResponse> {
5001 let repos = match path {
5002 Some(path) => vec![self.get_repo(path)?],
5003 None => self.list_repos(None)?,
5004 };
5005 Ok(StorageInfoResponse {
5006 // The test double holds no bytes and therefore no tombstones.
5007 total_tombstoned_bytes: 0,
5008 tombstoned: Vec::new(),
5009 // **No machine behind this double, so no storage reality.** `None`
5010 // is the honest answer: a fabricated one here would let a console
5011 // render an installation that does not exist, which is the exact
5012 // failure the real implementation refuses by never inferring.
5013 reality: None,
5014 // Honest: this test double has no store behind it. It must not
5015 // claim "tiered(redb,znippy)" and let a UI report a backend that
5016 // is not there.
5017 object_backend: "none (MemoryControlPlane test double)".into(),
5018 ref_backend: "none (MemoryControlPlane test double)".into(),
5019 // No engine behind a test double: none is named.
5020 engine: None,
5021 total_hot_bytes: 0,
5022 total_cold_bytes: 0,
5023 // No store, so no cold tier measured.
5024 cold_measured: false,
5025 // No store behind this double, so no cache and no budget. Zero is
5026 // what is measured, not a plausible number.
5027 total_explode_bytes: 0,
5028 total_explode_budget_bytes: 0,
5029 repos: repos
5030 .into_iter()
5031 .map(|repo| RepoStorage {
5032 repo: repo.path.clone(),
5033 hash_kind: repo.hash_kind,
5034 ..Default::default()
5035 })
5036 .collect(),
5037 })
5038 }
5039
5040 fn mirror_add(
5041 &self,
5042 path: &RepoPath,
5043 remote_url: &str,
5044 prune: bool,
5045 create_repo: bool,
5046 ) -> ControlResult<MirrorConfig> {
5047 if remote_url.is_empty() {
5048 return Err(ControlError::InvalidArgument("remote_url is empty".into()));
5049 }
5050 if self.get_repo(path).is_err() {
5051 if !create_repo {
5052 return Err(ControlError::NotFound(format!("repository {path}")));
5053 }
5054 self.create_repo(NewRepo {
5055 path: path.clone(),
5056 hash_kind: HashKind::Unspecified,
5057 default_branch: String::new(),
5058 visibility: Visibility::Private,
5059 description: format!("mirror of {remote_url}"),
5060 adopt: false,
5061 })?;
5062 }
5063 let mut inner = self.inner.lock().unwrap();
5064 let key = MemoryControlPlane::key(path);
5065 if inner.mirrors.contains_key(&key) {
5066 return Err(ControlError::AlreadyExists(format!("mirror for {path}")));
5067 }
5068 let mirror = MirrorConfig {
5069 repo: Some(path.clone()),
5070 remote_url: remote_url.to_string(),
5071 prune,
5072 state: MirrorState::Idle as i32,
5073 added_at_unix_ms: now_ms(),
5074 last_sync_at_unix_ms: None,
5075 last_error: String::new(),
5076 sync_count: 0,
5077 credential: MirrorCredentialKind::Unspecified as i32,
5078 credential_username: String::new(),
5079 job: None,
5080 repo_pending: false,
5081 lfs_not_copied: false,
5082 };
5083 inner.mirrors.insert(key, mirror.clone());
5084 Ok(mirror)
5085 }
5086
5087 fn mirror_sync(&self, path: &RepoPath) -> ControlResult<MirrorConfig> {
5088 let inner = self.inner.lock().unwrap();
5089 if !inner.mirrors.contains_key(&MemoryControlPlane::key(path)) {
5090 return Err(ControlError::NotFound(format!("mirror for {path}")));
5091 }
5092 // Enqueuing needs the real queue, the real client and a real store to
5093 // write into. Answering with a job that will never run — or with "0
5094 // refs changed", which is what this used to be built to avoid — would
5095 // be a silent green of exactly the kind that proves nothing.
5096 Err(ControlError::Unimplemented("Mirror.Sync"))
5097 }
5098
5099 /// **The credential is recorded here as a KIND and the value is dropped**,
5100 /// which is not a stub: this plane has no sealed store to put it in, and
5101 /// keeping a private key in a test double's `BTreeMap` is a plaintext
5102 /// secret in a process that had no reason to hold one. What the double owes
5103 /// its callers is the `MirrorConfig` shape, and that it gives exactly.
5104 fn mirror_set_credential(
5105 &self,
5106 path: &RepoPath,
5107 kind: MirrorCredentialKind,
5108 secret: &str,
5109 username: &str,
5110 ) -> ControlResult<MirrorConfig> {
5111 credential_arguments(kind, secret)?;
5112 let mut inner = self.inner.lock().unwrap();
5113 let mirror = inner
5114 .mirrors
5115 .get_mut(&MemoryControlPlane::key(path))
5116 .ok_or_else(|| ControlError::NotFound(format!("mirror for {path}")))?;
5117 mirror.credential = kind as i32;
5118 mirror.credential_username = if kind == MirrorCredentialKind::Token {
5119 username.to_owned()
5120 } else {
5121 String::new()
5122 };
5123 Ok(mirror.clone())
5124 }
5125
5126 fn mirror_status(
5127 &self,
5128 namespace: Option<&str>,
5129 path: Option<&RepoPath>,
5130 ) -> ControlResult<Vec<MirrorConfig>> {
5131 Ok(self
5132 .inner
5133 .lock()
5134 .unwrap()
5135 .mirrors
5136 .iter()
5137 .filter(|((ns, _), _)| namespace.is_none_or(|w| w == ns))
5138 .filter(|((ns, repo), _)| path.is_none_or(|w| w.namespace == *ns && w.repo == *repo))
5139 .map(|(_, mirror)| mirror.clone())
5140 .collect())
5141 }
5142
5143 fn mirror_remove(&self, path: &RepoPath) -> ControlResult<()> {
5144 self.inner
5145 .lock()
5146 .unwrap()
5147 .mirrors
5148 .remove(&MemoryControlPlane::key(path))
5149 .map(|_| ())
5150 .ok_or_else(|| ControlError::NotFound(format!("mirror for {path}")))
5151 }
5152}
5153
5154/// **`UsageBatch.ReadMany`'s page token and page cut** (2026-10-06), shared by
5155/// the default [`ControlPlane::usage_page`] and `gunnar-server`'s ledger.
5156///
5157/// The token is opaque to the caller and carries two things: the CURSOR the
5158/// read began at (so every page of one read answers the same cursor, and a
5159/// change that lands mid-read is answered again next time), and the last
5160/// namespace served (the read resumes after it). `v1;<cursor>;<after>` — a
5161/// namespace name cannot hold `;`, and a token that does not parse starts the
5162/// read over rather than failing it.
5163pub mod usage_page_token {
5164 /// `(cursor, after)` out of a token; `("", "")` for the first page or a
5165 /// token this build did not write.
5166 pub fn decode(token: &str) -> (String, String) {
5167 let mut parts = token.splitn(3, ';');
5168 match (parts.next(), parts.next(), parts.next()) {
5169 (Some("v1"), Some(cursor), Some(after)) => (cursor.to_owned(), after.to_owned()),
5170 _ => (String::new(), String::new()),
5171 }
5172 }
5173
5174 /// The token resuming after `after`, for a read that began at `cursor`.
5175 pub fn encode(cursor: &str, after: &str) -> String {
5176 format!("v1;{cursor};{after}")
5177 }
5178
5179 /// **Cut one page** out of rows SORTED by namespace: the rows after
5180 /// `after`, at most `page_size`, and the token to the next page (empty on
5181 /// the last).
5182 pub fn page(
5183 rows: Vec<gunnar_proto::NamespaceUsage>,
5184 after: &str,
5185 page_size: usize,
5186 cursor: String,
5187 changed_only: bool,
5188 ) -> gunnar_proto::ReadManyUsageResponse {
5189 let page_size = page_size.max(1);
5190 let start = if after.is_empty() {
5191 0
5192 } else {
5193 rows.partition_point(|r| r.namespace.as_str() <= after)
5194 };
5195 let mut rest: Vec<gunnar_proto::NamespaceUsage> = rows.into_iter().skip(start).collect();
5196 let more = rest.len() > page_size;
5197 rest.truncate(page_size);
5198 let next_page_token = match (more, rest.last()) {
5199 (true, Some(last)) => encode(&cursor, &last.namespace),
5200 _ => String::new(),
5201 };
5202 gunnar_proto::ReadManyUsageResponse {
5203 usages: rest,
5204 next_page_token,
5205 cursor,
5206 changed_only,
5207 as_of_unix_ms: crate::bus::now_unix_ms(),
5208 }
5209 }
5210
5211 #[cfg(test)]
5212 mod tests {
5213 use super::*;
5214
5215 fn row(ns: &str) -> gunnar_proto::NamespaceUsage {
5216 gunnar_proto::NamespaceUsage {
5217 namespace: ns.into(),
5218 ..Default::default()
5219 }
5220 }
5221
5222 #[test]
5223 fn pages_resume_after_the_last_namespace_and_the_last_page_has_no_token() {
5224 let rows = || vec![row("a"), row("b"), row("c"), row("d"), row("e")];
5225 let one = page(rows(), "", 2, "E.1.7".into(), true);
5226 assert_eq!(
5227 one.usages
5228 .iter()
5229 .map(|r| r.namespace.as_str())
5230 .collect::<Vec<_>>(),
5231 ["a", "b"]
5232 );
5233 let (cursor, after) = decode(&one.next_page_token);
5234 assert_eq!((cursor.as_str(), after.as_str()), ("E.1.7", "b"));
5235 let two = page(rows(), &after, 2, cursor.clone(), true);
5236 assert_eq!(
5237 two.usages
5238 .iter()
5239 .map(|r| r.namespace.as_str())
5240 .collect::<Vec<_>>(),
5241 ["c", "d"]
5242 );
5243 let (_, after) = decode(&two.next_page_token);
5244 let three = page(rows(), &after, 2, cursor, true);
5245 assert_eq!(
5246 three
5247 .usages
5248 .iter()
5249 .map(|r| r.namespace.as_str())
5250 .collect::<Vec<_>>(),
5251 ["e"]
5252 );
5253 assert!(three.next_page_token.is_empty(), "the last page says so");
5254 assert_eq!(
5255 decode("garbage"),
5256 (String::new(), String::new()),
5257 "a foreign token starts over"
5258 );
5259 }
5260 }
5261}