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