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