1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
//! `Repos`: namespaces and repositories.
//!
//! The two mutations that change what exists, `Create` and `Delete`, publish
//! `RepoCreated` / `RepoDeleted` on the event bus. That is what lets a UI show
//! a new repo without polling (the event stream).
use std::pin::Pin;
use std::sync::Arc;
use gunnar_proto::repos_server::Repos;
use gunnar_proto::{
event, ArchiveOutChunk, ArchiveOutRequest, CreateNamespaceRequest, CreateNamespaceResponse,
CreateRepoRequest, CreateRepoResponse, DeleteNamespaceRequest, DeleteNamespaceResponse,
DeleteRepoRequest, DeleteRepoResponse, DeletedRepo, GcRepoRequest, GcRepoResponse,
GcStatusRequest, GcStatusResponse, GetRepoRequest, GetRepoResponse, HashKind,
ListDeletedRequest, ListDeletedResponse,
ListNamespacesRequest, ListNamespacesResponse, ListOrphansRequest, ListOrphansResponse,
ListReposRequest, ListReposResponse, OrphanStore,
PurgeRepoRequest, PurgeRepoResponse, ReclaimOrphanRequest, ReclaimOrphanResponse, RepoCreated,
RepoDeleted, SetDefaultBranchRequest, SetDefaultBranchResponse, SetNamespaceSettingsRequest,
RecipientKeysRequest, RecipientKeysResponse, SetEncryptedRequest, SetEncryptedResponse,
SetNamespaceSettingsResponse, SetVisibilityRequest,
SetVisibilityResponse,
UndeleteRepoRequest, UndeleteRepoResponse, Visibility,
};
use tokio_stream::Stream;
use tonic::{Request, Response, Status};
use crate::auth::Access;
use crate::seam::NewRepo;
use crate::service::ReposService;
use crate::status::{opt_str, repo_path};
use gunnar_proto::GrantLevel;
/// Payload bytes one `ArchiveOutChunk` carries.
///
/// 256 KiB: comfortably inside tonic's 4 MiB default message ceiling with room
/// for framing, and big enough that a 230 MiB corpus is ~900 messages rather
/// than a syscall storm. The archive writer's own copy buffer is 1 MiB, so one
/// of its writes becomes four messages and nothing has to be re-buffered twice.
pub const ARCHIVE_CHUNK_BYTES: usize = 256 * 1024;
/// How many chunks may sit between the blocking cut and the socket.
///
/// Bounded, and small. This is the backpressure: a client that stops reading
/// blocks the `spawn_blocking` thread on `blocking_send` instead of letting the
/// server buffer a repository into memory on its behalf.
const ARCHIVE_QUEUE: usize = 4;
/// The `Write` an archive cut is handed: it chunks and sends, and it never
/// grows past one chunk.
struct ChunkSink {
tx: tokio::sync::mpsc::Sender<Result<ArchiveOutChunk, Status>>,
buf: Vec<u8>,
}
impl ChunkSink {
fn new(tx: tokio::sync::mpsc::Sender<Result<ArchiveOutChunk, Status>>) -> Self {
ChunkSink {
tx,
buf: Vec::with_capacity(ARCHIVE_CHUNK_BYTES),
}
}
/// **A client that hung up is an error, not a quiet success.** An archive
/// whose stream was dropped half way is a truncated archive, and the one
/// thing a backup must never do is report a cut it did not finish.
fn send(&mut self) -> std::io::Result<()> {
let data = std::mem::replace(&mut self.buf, Vec::with_capacity(ARCHIVE_CHUNK_BYTES));
self.tx
.blocking_send(Ok(ArchiveOutChunk {
data,
..ArchiveOutChunk::default()
}))
.map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::BrokenPipe,
"the client stopped reading the archive stream before it ended",
)
})
}
}
impl std::io::Write for ChunkSink {
fn write(&mut self, mut buf: &[u8]) -> std::io::Result<usize> {
let total = buf.len();
while !buf.is_empty() {
let room = ARCHIVE_CHUNK_BYTES - self.buf.len();
let n = room.min(buf.len());
self.buf.extend_from_slice(&buf[..n]);
buf = &buf[n..];
if self.buf.len() == ARCHIVE_CHUNK_BYTES {
self.send()?;
}
}
Ok(total)
}
fn flush(&mut self) -> std::io::Result<()> {
if !self.buf.is_empty() {
self.send()?;
}
Ok(())
}
}
#[tonic::async_trait]
impl Repos for ReposService {
type ArchiveOutStream =
Pin<Box<dyn Stream<Item = Result<ArchiveOutChunk, Status>> + Send + 'static>>;
/// **`Repos.ArchiveOut`: a repository's bytes, off the box.**
///
/// Authorisation is `Read` on the repository — what a clone of a private
/// repository takes over SSH, because that is what this hands over. It is
/// spelled with [`ServiceContext::require`](crate::service::ServiceContext)
/// like every other RPC, so a principal without the grant gets a named
/// `PERMISSION_DENIED` before a byte is read, and the audit line is the
/// same shape as every other denial.
///
/// The cut runs on the blocking pool. The channel is bounded at
/// [`ARCHIVE_QUEUE`], so a slow reader slows the cut instead of buffering a
/// repository into the server's memory.
async fn archive_out(
&self,
request: Request<ArchiveOutRequest>,
) -> Result<Response<Self::ArchiveOutStream>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.path, "path")?;
self.ctx.require(
&caller,
Access::repo(&path),
GrantLevel::Read,
"Repos.ArchiveOut",
)?;
// Resolve BEFORE the stream is opened. A `NOT_FOUND` that arrives as
// the first item of an accepted stream has already made the client
// create its output file; as the call's own status it has not.
self.ctx.control.get_repo(&path)?;
// ── the admission permit, on the control plane's one transfer ────────
//
// `ArchiveOut` streams a whole repository's stored bytes down this
// channel — a transfer in everything but name — so it takes a slot from
// the SAME gate a `git-upload-pack` does (one implementation), through the
// `TransferGate` seam `gunnar-server` wired the SSH listener's own
// `Arc<Admission>` into. After authorisation and the existence check,
// for the same reason the SSH and HTTP paths gate after theirs: "the
// server is busy" must not leak that a repository a caller may not read
// exists. Over budget it is **refused** with `RESOURCE_EXHAUSTED` (the
// gRPC idiom for 429) — the client and any proxy read it as transient —
// rather than queued behind a socket that goes quiet.
//
// `None` is the ungated path (a plaintext test router, or a
// listener built without a gate); the permit is then simply absent and
// the stream runs as before. That absence is the red the test drives.
let permit = match &self.ctx.transfer_gate {
Some(gate) => match gate.admit_transfer(&caller.principal_id, &path.namespace) {
Ok(permit) => Some(permit),
Err(refused) => {
// The SAME audit shape the SSH and HTTP refusals file
// (`event="*.transfer.refused"`), on the git target so an
// operator greps one place across all three front doors.
tracing::warn!(
target: "gunnar::git",
event = "grpc.transfer.refused",
reason = "admission-gate-full",
rpc = "Repos.ArchiveOut",
repo = %path,
principal = %caller.principal_id,
in_flight = refused.in_flight,
peak_in_flight = refused.peak_in_flight,
limit = refused.limit,
reserved_bytes = refused.reserved_bytes,
refused_total = refused.refused_total,
"control-plane ArchiveOut REFUSED: the admission gate is full"
);
return Err(Status::resource_exhausted(refused.message));
}
},
None => None,
};
let (tx, rx) = tokio::sync::mpsc::channel(ARCHIVE_QUEUE);
let control = Arc::clone(&self.ctx.control);
let from_generation = request.from_generation;
let principal = caller.principal_id.clone();
let named = format!("{path}");
// A32-14, stays a task: one cut's producer under its permit; it ends with its stream.
tokio::task::spawn_blocking(move || {
use std::io::Write;
// The permit is held for the WHOLE cut and released when this task
// ends — the same lifetime the SSH/HTTP guard has. `entered_service`
// files the sojourn sample the governor reads: everything before
// here (the blocking-pool handoff) was the transfer waiting.
if let Some(permit) = &permit {
permit.0.entered_service();
}
let mut sink = ChunkSink::new(tx.clone());
let outcome = control
.archive_out(&path, from_generation, &mut sink)
.map_err(Status::from)
.and_then(|cut| {
sink.flush()
.map_err(|e| Status::aborted(format!("streaming the archive: {e}")))?;
Ok(cut)
});
let last = match outcome {
Ok(cut) => {
tracing::info!(
target: "gunnar::archive",
event = "repo.archive_out",
repo = %named,
principal = %principal,
from_generation,
to_generation = cut.to_generation,
bytes_out = cut.bytes_out,
"streamed a repository archive off the box"
);
Ok(ArchiveOutChunk {
data: Vec::new(),
last: true,
to_generation: cut.to_generation,
bytes_out: cut.bytes_out,
file_sha256: cut.file_sha256.to_vec(),
})
}
Err(status) => {
tracing::error!(
target: "gunnar::archive",
event = "repo.archive_out_failed",
repo = %named,
principal = %principal,
from_generation,
error = %status.message(),
"a repository archive stream failed; the client has a TRUNCATED file"
);
Err(status)
}
};
// The receiver is gone when the client hung up; there is nobody
// left to tell, and that is not itself a failure to log twice.
let _ = tx.blocking_send(last);
});
Ok(Response::new(Box::pin(
tokio_stream::wrappers::ReceiverStream::new(rx),
)))
}
async fn create_namespace(
&self,
request: Request<CreateNamespaceRequest>,
) -> Result<Response<CreateNamespaceResponse>, Status> {
// Creating a tenant is a server-wide act: there is no namespace to
// scope it to, which is exactly why `Scope::Server` exists (the ACL model).
let caller = self.ctx.authorize(
&request,
Access::Server,
GrantLevel::Admin,
"Repos.CreateNamespace",
)?;
let request = request.into_inner();
// The name filter (2026-10-05): a namespace is named here. Reserved
// names are the operator's to use (a PERSON with server Admin names
// the estate's own namespaces); a machine relaying a customer's
// choice is held to them.
// Only a server admin gets here, naming the estate's own namespaces
// (the kit seeds `nordisk`): reserved names are theirs to use; an
// offensive one is refused, and a held one waits for the account
// admin when the namespace is inside an account.
let account = self.ctx.account_of_namespace(&request.name);
let last = request.name.rsplit('/').next().unwrap_or(&request.name).to_owned();
self.ctx.filter_name(&caller, "namespace", &last, account.clone(), true)?;
let namespace = self
.ctx
.control
.create_namespace(&request.name, &request.description)?;
self.ctx.account_event(
account,
crate::enterprise::AccountAuditEvent::by(&caller, "namespace.created", request.name.clone()),
);
Ok(Response::new(CreateNamespaceResponse {
namespace: Some(namespace),
}))
}
async fn list_namespaces(
&self,
request: Request<ListNamespacesRequest>,
) -> Result<Response<ListNamespacesResponse>, Status> {
// **Every namespace to a server reader; to anybody else, the ones they
// are inside and the PUBLIC ones** (owner, 2026-10-03: namespaces are
// not public by default, "such things has to be set"; a gated account
// or a gated caller sees no public one, see
// `ControlPlane::namespace_for_outsider`). Inside is `Read`
// over the namespace: its owner, a member, a grant. A private namespace
// an outsider is not inside is not listed, even when it holds a public
// repository — that repository is reachable by its path, and its
// namespace shows it (and only it) to whoever asks for it by name.
let caller = self.ctx.caller(&request)?;
let listing = self
.ctx
.require_or_narrow(&caller, Access::Server, "Repos.ListNamespaces")?;
let mut namespaces = self.ctx.control.list_namespaces()?;
// **A server-wide reader is still not inside a customer's account**
// (2026-10-05): a namespace closed to the caller's server grant is
// shown as it is to any outsider — only when its admin made it public.
let mut closed = Vec::new();
if listing == crate::service::Listing::Everything {
let mut open = Vec::with_capacity(namespaces.len());
for ns in namespaces {
if self.ctx.shielded(&caller, &ns.name)? {
closed.push(ns);
} else {
open.push(ns);
}
}
namespaces = open;
for ns in closed.drain(..) {
if ns.public
&& self
.ctx
.control
.namespace_for_outsider(&caller.principal_id, &ns.name)?
.is_some_and(|outside| outside.public)
{
namespaces.push(ns);
}
}
namespaces.sort_by(|a, b| a.name.cmp(&b.name));
}
if listing != crate::service::Listing::Everything {
let mut shown = Vec::new();
for ns in namespaces {
let inside = self
.ctx
.control
.may(&caller.principal_id, &Access::namespace(&ns.name), GrantLevel::Read)
.map_err(|err| {
Status::unavailable(format!("the identity table could not be read: {err}"))
})?;
// Outside: only what the account admin opened, and nothing of
// a gated account or to a gated caller (the gate outranks the
// setting, as it outranks a public repository).
let opened = inside
|| (ns.public
&& self
.ctx
.control
.namespace_for_outsider(&caller.principal_id, &ns.name)?
.is_some_and(|outside| outside.public));
if opened {
shown.push(ns);
}
}
namespaces = shown;
}
Ok(Response::new(ListNamespacesResponse {
namespaces,
// Paging is a store concern; until gunnar-store lands, one page.
next_page_token: String::new(),
}))
}
async fn delete_namespace(
&self,
request: Request<DeleteNamespaceRequest>,
) -> Result<Response<DeleteNamespaceResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
self.ctx.require(
&caller,
Access::namespace(&request.name),
GrantLevel::Admin,
"Repos.DeleteNamespace",
)?;
let account = self.ctx.account_of_namespace(&request.name);
let repos_deleted = self
.ctx
.control
.delete_namespace(&request.name, request.delete_repos)?;
self.ctx.account_event(
account,
crate::enterprise::AccountAuditEvent::by(&caller, "namespace.deleted", request.name.clone())
.detail(format!("{repos_deleted} repositories with it")),
);
Ok(Response::new(DeleteNamespaceResponse { repos_deleted }))
}
async fn create(
&self,
request: Request<CreateRepoRequest>,
) -> Result<Response<CreateRepoResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.path, "path")?;
// Namespace-scoped, not repo-scoped: the repository does not exist
// yet, so there is nothing narrower to hold a grant on.
self.ctx.require(
&caller,
Access::namespace(&path.namespace),
create_level(&self.ctx, &caller, &path.namespace, request.visibility),
"Repos.Create",
)?;
let hash_kind = HashKind::try_from(request.hash_kind)
.map_err(|_| Status::invalid_argument("unknown hash_kind"))?;
let visibility = Visibility::try_from(request.visibility)
.map_err(|_| Status::invalid_argument("unknown visibility"))?;
// A repository born PUBLIC is a publish, and publishing is the account
// admin's (as on `SetVisibility`): the namespace `Admin` above already
// asked exactly that, so a member cannot create its way round it.
// The name filter (2026-10-05): a repository is named here.
let account = self.ctx.account_of_namespace(&path.namespace);
self.ctx.filter_name(&caller, "repo", &path.repo, account.clone(), false)?;
// The account's room, as a push-to-create meets it (2026-10-06): a
// namespace its plan refuses writes to, or at its cap, gets no new
// repository — refused with the plan's own line.
self.ctx.control.create_room(&path.namespace, caller.machine)?;
let repo = self.ctx.control.create_repo(NewRepo {
path,
hash_kind,
default_branch: request.default_branch,
visibility,
description: request.description,
adopt: request.adopt,
})?;
// The first commit, written by the server, when asked (a sold estate's welcome).
if !request.readme.is_empty() {
let path = repo_path(repo.path.clone(), "repo")?;
let commit = self.ctx.control.write_first_commit(&path, &request.readme)?;
tracing::info!(repo = %format!("{}/{}", path.namespace, path.repo), commit = %commit, "repos: the first commit (README.md) written by the server");
}
self.ctx
.bus
.publish(event::Payload::RepoCreated(RepoCreated {
repo: Some(repo.clone()),
// Named, now that there is a name to give: the event carries
// the principal that created the repository.
principal_id: caller.principal_id.clone(),
}));
self.ctx.account_event(
account,
crate::enterprise::AccountAuditEvent::by(
&caller,
"repo.created",
format!("{}/{}", repo.path.as_ref().map(|p| p.namespace.as_str()).unwrap_or(""), repo.path.as_ref().map(|p| p.repo.as_str()).unwrap_or("")),
)
.detail(if repo.visibility == Visibility::Public as i32 { "born public" } else { "private" }),
);
Ok(Response::new(CreateRepoResponse { repo: Some(repo) }))
}
/// **A dated tombstone by default; a reclaim only when asked.**
///
/// The event is published either way and says the same thing — the
/// repository is gone from the catalog, and a subscriber (a UI, a mirror)
/// must stop showing it. What differs is whether the bytes went, and that
/// is in the response rather than the event because it is the caller's
/// business and not the world's.
async fn delete(
&self,
request: Request<DeleteRepoRequest>,
) -> Result<Response<DeleteRepoResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.path, "path")?;
// **Repository scope, then namespace scope** — the narrowest that CAN
// hold a grant. A plain `Access::repo` here refused everyone on the
// appliance's orphaned `alice/linux` (`175daccc0000000b`), because
// `may` answers `false` for a repository that is not in the catalog
// and the store had lost its row. Delete is the verb that gets you out
// of that, so it must not be gated on the record that has gone. Same
// rule as `Repos.Undelete` and `Repos.Purge`, written once.
self.ctx.require_repo_or_namespace(
&caller,
&path,
GrantLevel::Admin,
"Repos.Delete",
)?;
// A plain 0 is what a client that never heard of the field sends, so
// it means "the server's default". Meaning it literally takes saying
// so — see `zero_retention` in the proto.
let retention_days = if request.zero_retention {
Some(0)
} else if request.retention_days > 0 {
Some(request.retention_days)
} else {
None
};
let outcome = self.ctx.control.delete_repo(
&path,
crate::seam::DeleteOptions {
purge: request.purge,
retention_days,
principal_id: caller.principal_id.clone(),
},
)?;
self.ctx.account_event(
self.ctx.account_of_namespace(&path.namespace),
crate::enterprise::AccountAuditEvent::by(&caller, "repo.deleted", format!("{}/{}", path.namespace, path.repo))
.detail(match &outcome {
crate::seam::DeleteOutcome::Purged { .. } => "purged".to_owned(),
crate::seam::DeleteOutcome::Tombstoned { purge_after_unix_ms, .. } => {
format!("kept until {purge_after_unix_ms} (undeletable)")
}
}),
);
self.ctx
.bus
.publish(event::Payload::RepoDeleted(RepoDeleted {
repo: Some(path),
principal_id: caller.principal_id.clone(),
}));
Ok(Response::new(match outcome {
crate::seam::DeleteOutcome::Purged { bytes_reclaimed } => DeleteRepoResponse {
purged: true,
bytes_reclaimed,
bytes_kept: 0,
purge_after_unix_ms: 0,
},
crate::seam::DeleteOutcome::Tombstoned {
purge_after_unix_ms,
bytes_kept,
} => DeleteRepoResponse {
purged: false,
bytes_reclaimed: 0,
bytes_kept,
purge_after_unix_ms,
},
}))
}
/// **`Repos.Undelete`: put a soft-deleted repository back.**
///
/// **The narrowest scope that CAN hold a grant**
/// ([`ServiceContext::require_repo_or_namespace`]) — which for a deleted
/// repository is the namespace, and that is not a widening: a deleted
/// repository is not in the catalog, and `may` answers `false` for a grant
/// on a repository that does not exist — deliberately, so a caller cannot
/// probe the ACL of repositories it cannot see. A repo-scoped check here
/// would therefore refuse *everyone*, including the account's owner.
///
/// Seen red: the first cut used `Access::repo` and the drill's
/// gunnar-admin got `principal admin has no admin grant on
/// repo:alice/knut` on a repository it had just deleted. 2026-08-30 found
/// the same hole still open on `Repos.Delete` and moved the rule into the
/// one helper all three now call, so there is one answer and not three.
async fn undelete(
&self,
request: Request<UndeleteRepoRequest>,
) -> Result<Response<UndeleteRepoResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let path = repo_path(request.into_inner().path, "path")?;
self.ctx.require_repo_or_namespace(
&caller,
&path,
GrantLevel::Admin,
"Repos.Undelete",
)?;
let repo = self.ctx.control.undelete_repo(&path)?;
self.ctx.account_event(
self.ctx.account_of_namespace(&path.namespace),
crate::enterprise::AccountAuditEvent::by(&caller, "repo.undeleted", format!("{}/{}", path.namespace, path.repo)),
);
// The same event a create publishes, because to everything watching
// this IS a repository appearing.
self.ctx
.bus
.publish(event::Payload::RepoCreated(RepoCreated {
repo: Some(repo.clone()),
principal_id: caller.principal_id.clone(),
}));
Ok(Response::new(UndeleteRepoResponse { repo: Some(repo) }))
}
/// **`Repos.Purge`: end the window early.** Admin, and there is no undo
/// after it.
///
/// Scoped by [`ServiceContext::require_repo_or_namespace`] for the same
/// reason [`Repos::undelete`] is: the repository is already out of the
/// catalog, so there is no repo-scoped grant left for anyone to hold and
/// the namespace is the narrowest scope that can answer.
async fn purge(
&self,
request: Request<PurgeRepoRequest>,
) -> Result<Response<PurgeRepoResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let path = repo_path(request.into_inner().path, "path")?;
self.ctx.require_repo_or_namespace(
&caller,
&path,
GrantLevel::Admin,
"Repos.Purge",
)?;
let bytes_reclaimed = self.ctx.control.purge_repo(&path)?;
self.ctx.account_event(
self.ctx.account_of_namespace(&path.namespace),
crate::enterprise::AccountAuditEvent::by(&caller, "repo.purged", format!("{}/{}", path.namespace, path.repo)),
);
Ok(Response::new(PurgeRepoResponse { bytes_reclaimed }))
}
/// **`Repos.Gc`: maintain a live repository. `Write`, deliberately, and not
/// `Admin`.**
///
/// Every other reclaim verb on this service is Admin because every other
/// one destroys something a caller cannot get back — `Purge` ends a
/// retention window, `ReclaimOrphan` unlinks a store. **This one destroys
/// nothing.** `gunnar-server`'s `storage.rs` says what it takes: "every
/// lazily-exploded object unlinked, the commit-and-tree pool kept because
/// that is the graph". Everything unlinked is re-derivable from the packs
/// the same run just compacted, so the entire downside is a slower next
/// read while the cache re-explodes. There is no state a gc can reach that
/// an operator would have to restore from a backup.
///
/// So the right gate is the one that says *this is your repository*: a
/// principal who may push to it may reclaim its cache. `Write` is exactly
/// that, and `require_repo_or_namespace` keeps it scoped to the repository
/// or the namespace that holds it — nobody reclaims a stranger's bytes.
///
/// **The Admin gate was not a safe default, it was the bug.** With gc
/// reachable only from a shell on the box, 26 repositories on the live
/// estate stood at `Last repacked = —`: never once maintained, holding an
/// exploded read cache nothing was going to reclaim, because the one
/// principal allowed to ask was the one principal not sitting in front of
/// the console. A verb gated tighter than its blast radius does not get
/// used more carefully; it gets used never.
/// **It ENQUEUES.** It does not run the gc and it returns before one has —
/// see [`crate::seam::ControlPlane::gc_repo`] and the queue behind it. A
/// handler that blocked for the length of a gc would hold a tonic worker
/// for minutes and make the button that called it look broken.
async fn gc(&self, request: Request<GcRepoRequest>) -> Result<Response<GcRepoResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let path = repo_path(request.into_inner().path, "path")?;
self.ctx
.require_repo_or_namespace(&caller, &path, GrantLevel::Write, "Repos.Gc")?;
let job = self.ctx.control.gc_repo(&path)?;
Ok(Response::new(GcRepoResponse {
job: Some(gc_job_out(job)),
}))
}
/// **`Repos.GcStatus`: the read half, gated at `Read`.**
///
/// Asking *whether* maintenance ran is not asking *for* it, so this sits a
/// level below [`Repos::gc`]: anyone who may see the repository may see
/// that its cache was reclaimed and what it freed. A console that could
/// press the button but not watch it would be the worse half of the pair.
///
/// An empty `job` means the server is holding nothing for this repository.
/// That is distinct from a gc that reclaimed zero bytes, which comes back
/// as a `DONE` job saying so.
async fn gc_status(
&self,
request: Request<GcStatusRequest>,
) -> Result<Response<GcStatusResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let path = repo_path(request.into_inner().path, "path")?;
self.ctx
.require(&caller, Access::repo(&path), GrantLevel::Read, "Repos.GcStatus")?;
let job = self.ctx.control.gc_status(&path)?;
Ok(Response::new(GcStatusResponse {
job: job.map(gc_job_out),
}))
}
/// **`Repos.ListDeleted`: what is being held, and what it costs.**
///
/// Scoped exactly like `Repos.List`: one namespace needs a grant on it,
/// every namespace needs a server-wide one, because the answer spans
/// tenants.
async fn list_deleted(
&self,
request: Request<ListDeletedRequest>,
) -> Result<Response<ListDeletedResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let access = match opt_str(&request.namespace) {
Some(namespace) => Access::namespace(namespace),
None => Access::Server,
};
self.ctx
.require(&caller, access, GrantLevel::Read, "Repos.ListDeleted")?;
let rows = self.ctx.control.list_deleted(opt_str(&request.namespace))?;
let rows = self.ctx.retain_unshielded(&caller, rows, |row| Some(row.path.namespace.clone()))?;
let total_bytes = rows.iter().map(|row| row.bytes).sum();
Ok(Response::new(ListDeletedResponse {
repos: rows
.into_iter()
.map(|row| DeletedRepo {
repo: Some(row.path),
deleted_at_unix_ms: row.deleted_at_unix_ms,
purge_after_unix_ms: row.purge_after_unix_ms,
principal_id: row.principal_id,
bytes: row.bytes,
})
.collect(),
total_bytes,
}))
}
/// **`Repos.ListOrphans`: the store directories nothing else can see.**
///
/// Scoped exactly like [`Repos::list_deleted`] and [`Repos::list`] — one
/// namespace needs a grant on it, every namespace needs a server-wide one,
/// because the answer spans tenants. It is a `Read`: it says what is on
/// the disk, it does not touch it.
///
/// The three listings partition the disk. Anything under the repository
/// root is catalogued (`List`), inside a retention window (`ListDeleted`),
/// or stranded (here). Before this the third case had no listing, which is
/// how ~25-39 GB stood at `alice/linux` on the appliance with every
/// reclaim verb refusing it.
async fn list_orphans(
&self,
request: Request<ListOrphansRequest>,
) -> Result<Response<ListOrphansResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let access = match opt_str(&request.namespace) {
Some(namespace) => Access::namespace(namespace),
None => Access::Server,
};
self.ctx
.require(&caller, access, GrantLevel::Read, "Repos.ListOrphans")?;
let rows = self
.ctx
.control
.list_orphans(opt_str(&request.namespace))?;
let rows = self.ctx.retain_unshielded(&caller, rows, |row| Some(row.path.namespace.clone()))?;
let total_bytes = rows.iter().map(|row| row.bytes).sum();
Ok(Response::new(ListOrphansResponse {
orphans: rows
.into_iter()
.map(|row| OrphanStore {
repo: Some(row.path),
dir: row.dir,
bytes: row.bytes,
hash_marker: row.hash_marker,
modified_unix_ms: row.modified_unix_ms,
failed_reclaim: row.failed_reclaim,
})
.collect(),
total_bytes,
}))
}
/// **`Repos.ReclaimOrphan`: the exit that did not exist.** Admin, no undo.
///
/// Authorised by [`ServiceContext::require_repo_or_namespace`](crate::service::ServiceContext::require_repo_or_namespace),
/// which is the whole point: an orphan has no catalog row, so a repo-scoped
/// check refuses everyone — including the account's owner and
/// gunnar-admin — and that refusal is one of the three walls that made
/// `alice/linux` inescapable.
///
/// The bytes go through `purge_repo`, the same verb `--purge` and the
/// expiry sweep use (one implementation). To KEEP an orphan's objects, use
/// `Repos.Create` with `adopt` and the hash `ListOrphans` reports.
async fn reclaim_orphan(
&self,
request: Request<ReclaimOrphanRequest>,
) -> Result<Response<ReclaimOrphanResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let path = repo_path(request.into_inner().path, "path")?;
self.ctx.require_repo_or_namespace(
&caller,
&path,
GrantLevel::Admin,
"Repos.ReclaimOrphan",
)?;
let bytes_reclaimed = self.ctx.control.reclaim_orphan(&path)?;
// The same event a delete publishes: to everything watching, a name
// has stopped existing on this box.
self.ctx
.bus
.publish(event::Payload::RepoDeleted(RepoDeleted {
repo: Some(path),
principal_id: caller.principal_id.clone(),
}));
Ok(Response::new(ReclaimOrphanResponse { bytes_reclaimed }))
}
async fn list(
&self,
request: Request<ListReposRequest>,
) -> Result<Response<ListReposResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
// Listing a single namespace needs a grant on that namespace; listing
// every namespace needs a server-wide one, because the answer spans
// tenants (multi-tenancy).
let access = match opt_str(&request.namespace) {
Some(namespace) => Access::namespace(namespace),
None => Access::Server,
};
// **`require_or_narrow`, not `require`** — the one RPC on this service
// that answers with a COLLECTION rather than about a thing, and
// therefore the one where "you hold no grant here" has a better answer
// than a refusal (see [`ServiceContext::require_or_narrow`]).
//
// Every other read on this service names a repository and so already
// went through `AuthTable::may`, which has known since anonymous read that a
// public repository is readable with no grant. This one had no
// repository to name, so it asked about the *scope* and refused — and
// that refusal was the whole reason a published repository could be
// cloned by a stranger who was told its path and discovered by nobody.
let listing = self.ctx.require_or_narrow(&caller, access, "Repos.List")?;
let repos = self.ctx.control.list_repos(opt_str(&request.namespace))?;
let mut repos = self.ctx.retain_readable(&caller, repos, listing)?;
// **The caller's filters, on the caller's own listing** (2026-10-04):
// applied AFTER the narrowing, so they can only ever remove rows — a
// query is a question about what this caller may already see, never a
// way to see more. Over the whole answer, so a console's search is not
// a search of whatever first page it fetched.
let query = request.query.trim().to_lowercase();
if !query.is_empty() {
repos.retain(|repo| {
repo.path.as_ref().is_some_and(|path| {
format!("{}/{}", path.namespace, path.repo)
.to_lowercase()
.contains(&query)
})
});
}
if request.mirrored_only {
let mirrored: std::collections::BTreeSet<String> = match self
.ctx
.control
.mirror_status(opt_str(&request.namespace), None)
{
Ok(mirrors) => mirrors
.into_iter()
.filter_map(|m| m.repo.map(|p| format!("{}/{}", p.namespace, p.repo)))
.collect(),
Err(crate::seam::ControlError::Unimplemented(_)) => Default::default(),
Err(err) => return Err(err.into()),
};
repos.retain(|repo| {
repo.path
.as_ref()
.is_some_and(|p| mirrored.contains(&format!("{}/{}", p.namespace, p.repo)))
});
}
Ok(Response::new(ListReposResponse {
repos,
next_page_token: String::new(),
}))
}
async fn get(
&self,
request: Request<GetRepoRequest>,
) -> Result<Response<GetRepoResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let path = repo_path(request.into_inner().path, "path")?;
self.ctx
.require(&caller, Access::repo(&path), GrantLevel::Read, "Repos.Get")?;
let repo = self.ctx.control.get_repo(&path)?;
Ok(Response::new(GetRepoResponse { repo: Some(repo) }))
}
async fn set_default_branch(
&self,
request: Request<SetDefaultBranchRequest>,
) -> Result<Response<SetDefaultBranchResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.path, "path")?;
self.ctx.require(
&caller,
Access::repo(&path),
GrantLevel::Admin,
"Repos.SetDefaultBranch",
)?;
let repo = self
.ctx
.control
.set_default_branch(&path, &request.default_branch)?;
Ok(Response::new(SetDefaultBranchResponse { repo: Some(repo) }))
}
async fn set_visibility(
&self,
request: Request<SetVisibilityRequest>,
) -> Result<Response<SetVisibilityResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.path, "path")?;
// **The ACCOUNT's decision, not the repository's** (owner, 2026-10-03:
// members "cant make repos public", only the account admin = the first
// person; gunnar's default for every account, not a tier and not a setting). Visibility decides who reads EVERY
// reference in the repository, on behalf of the account whose name is
// on it, so it takes `Admin` over the NAMESPACE: the principal the
// namespace is named after (`AuthTable::owns_account`), a
// namespace-scoped admin grant (directly or through a group), or a
// server-scoped one. Until 2026-10-03 it took `Admin` on the
// repository, and a repository-level admin could publish it.
let account = self.ctx.account_of_namespace(&path.namespace);
if let Err(status) = self.ctx.require(
&caller,
Access::namespace(&path.namespace),
GrantLevel::Admin,
"Repos.SetVisibility",
) {
if status.code() == tonic::Code::PermissionDenied {
self.ctx.account_event(
account,
crate::enterprise::AccountAuditEvent::by(&caller, "visibility.changed", format!("{}/{}", path.namespace, path.repo))
.detail("only the account admin changes public and private")
.refused(),
);
}
return Err(account_admin_only(status, &path));
}
let visibility = Visibility::try_from(request.visibility)
.map_err(|_| Status::invalid_argument("unknown visibility"))?;
// `from` is read before the write so the event can say what moved;
// a set to the visibility the repository already has is not a change
// and publishes nothing — a PublicOnly replica (the browse replica)
// acts on this event, and must not be told to forget or fetch a
// repository whose visibility did not move.
let before = self.ctx.control.get_repo(&path)?.visibility;
let repo = self.ctx.control.set_visibility(&path, visibility)?;
if repo.visibility != before {
let word = |v: i32| if v == Visibility::Public as i32 { "public" } else { "private" };
self.ctx.account_event(
account,
crate::enterprise::AccountAuditEvent::by(&caller, "visibility.changed", format!("{}/{}", path.namespace, path.repo))
.detail(format!("{} → {}", word(before), word(repo.visibility))),
);
self.ctx
.bus
.publish(event::Payload::VisibilityChanged(
gunnar_proto::VisibilityChanged {
repo: Some(path),
from: before,
to: repo.visibility,
principal_id: caller.principal_id.clone(),
},
));
}
Ok(Response::new(SetVisibilityResponse { repo: Some(repo) }))
}
/// **A member's SSH public keys, for wrapping a key ring to them.**
async fn recipient_keys(
&self,
request: Request<RecipientKeysRequest>,
) -> Result<Response<RecipientKeysResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.path, "path")?;
let repo = Access::repo(&path);
self.ctx
.require(&caller, repo.clone(), GrantLevel::Read, "Repos.RecipientKeys")?;
// Only for someone who could read the repository anyway: never a
// directory of everybody's keys.
if !self
.ctx
.control
.may(&request.principal_id, &repo, GrantLevel::Read)?
{
return Err(Status::permission_denied(format!(
"{} cannot read {}/{}: grant them access before adding them as a member of its \
key ring",
request.principal_id, path.namespace, path.repo
)));
}
let keys = self.ctx.control.recipient_keys(&request.principal_id)?;
Ok(Response::new(RecipientKeysResponse { keys }))
}
/// **Encrypted repository on or off** — the account admin's decision.
async fn set_encrypted(
&self,
request: Request<SetEncryptedRequest>,
) -> Result<Response<SetEncryptedResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let path = repo_path(request.path, "path")?;
// The ACCOUNT's decision, as visibility is: whether the server may hold
// plaintext for this repository is decided for every member of the
// account, so it takes `Admin` over the namespace, not the repository.
let account = self.ctx.account_of_namespace(&path.namespace);
if let Err(status) = self.ctx.require(
&caller,
Access::namespace(&path.namespace),
GrantLevel::Admin,
"Repos.SetEncrypted",
) {
// **In the account's audit log** (wiring tracer B 2026-10-08), as a
// visibility change is: refused and made alike.
if status.code() == tonic::Code::PermissionDenied {
self.ctx.account_event(
account,
crate::enterprise::AccountAuditEvent::by(&caller, "encrypted.changed", format!("{}/{}", path.namespace, path.repo))
.detail("only the account admin turns encryption on or off")
.refused(),
);
}
return Err(account_admin_only_for(status, &path.namespace, "encrypted repositories in account"));
}
let before = self.ctx.control.get_repo(&path)?.encrypted;
// **A mirror is never turned encrypted** (wiring tracer B 2026-10-08):
// its content is an upstream's, fetched with no encrypted gate, never
// sealed by the account's members. Off is never refused.
if request.encrypted && !before {
let a_mirror = match self.ctx.control.mirror_status(None, Some(&path)) {
Ok(mirrors) => !mirrors.is_empty(),
Err(crate::seam::ControlError::NotFound(_))
| Err(crate::seam::ControlError::Unimplemented(_)) => false,
Err(err) => return Err(err.into()),
};
if a_mirror {
return Err(Status::failed_precondition(format!(
"{}/{} is a mirror: its content is copied from another server and was never \
sealed by your members, so it cannot be encrypted (remove the mirror first, \
then push sealed content)",
path.namespace, path.repo
)));
}
}
let repo = self.ctx.control.set_encrypted(&path, request.encrypted)?;
if repo.encrypted != before {
let word = |on: bool| if on { "encrypted" } else { "plaintext allowed" };
self.ctx.account_event(
account,
crate::enterprise::AccountAuditEvent::by(&caller, "encrypted.changed", format!("{}/{}", path.namespace, path.repo))
.detail(format!("{} → {}", word(before), word(repo.encrypted))),
);
// …and an event, as a visibility change is: a webhook or a watcher hears it.
self.ctx.bus.publish(event::Payload::EncryptedChanged(gunnar_proto::EncryptedChanged {
repo: Some(path.clone()),
from: before,
to: repo.encrypted,
principal_id: caller.principal_id.clone(),
}));
}
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "control.repo_encrypted",
principal = %caller.principal_id,
repo = %format!("{}/{}", path.namespace, path.repo),
from = before,
to = repo.encrypted,
"the repository's encrypted flag was set"
);
Ok(Response::new(SetEncryptedResponse { repo: Some(repo) }))
}
/// **The account admin's two settings**: is the namespace public, are its
/// events public. Private, both, until the account admin says otherwise —
/// and only the account admin may: `Admin` over the namespace, the rule
/// `SetVisibility` keeps, refused with the same sentence.
async fn set_namespace_settings(
&self,
request: Request<SetNamespaceSettingsRequest>,
) -> Result<Response<SetNamespaceSettingsResponse>, Status> {
let caller = self.ctx.caller(&request)?;
let request = request.into_inner();
let name = request.name.trim().to_owned();
if name.is_empty() {
return Err(Status::invalid_argument("`name`: the namespace is required"));
}
let account = self.ctx.account_of_namespace(&name);
if let Err(status) = self.ctx.require(
&caller,
Access::namespace(&name),
GrantLevel::Admin,
"Repos.SetNamespaceSettings",
) {
if status.code() == tonic::Code::PermissionDenied {
self.ctx.account_event(
account,
crate::enterprise::AccountAuditEvent::by(&caller, "visibility.namespace", name.clone())
.detail("only the account admin changes the namespace's settings")
.refused(),
);
}
return Err(account_admin_only_for(status, &name, "the settings of account"));
}
if request.public.is_none() && request.events_public.is_none() {
return Err(Status::invalid_argument(
"nothing to set: name `public`, `events_public` or both",
));
}
let namespace =
self.ctx
.control
.set_namespace_settings(&name, request.public, request.events_public)?;
self.ctx.account_event(
account,
crate::enterprise::AccountAuditEvent::by(&caller, "visibility.namespace", name.clone()).detail(format!(
"namespace {}, events {}",
if namespace.public { "public" } else { "private" },
if namespace.events_public { "public" } else { "private" }
)),
);
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "control.namespace_settings",
principal = %caller.principal_id,
namespace = %name,
public = namespace.public,
events_public = namespace.events_public,
"the account admin changed the namespace's settings"
);
Ok(Response::new(SetNamespaceSettingsResponse {
namespace: Some(namespace),
}))
}
}
/// A `PERMISSION_DENIED` from `Repos.SetVisibility`'s namespace check, said as
/// what it is: the ACCOUNT admin's decision. gunnar's own sentence (which
/// names the principal) is kept in front; every other refusal — a browse
/// replica, a narrowed principal, an unreadable identity table — passes
/// through untouched.
fn account_admin_only(status: Status, path: &gunnar_proto::RepoPath) -> Status {
if status.code() != tonic::Code::PermissionDenied {
return status;
}
Status::permission_denied(format!(
"{}: changing the visibility of {}/{} is the account admin's decision (admin of account \
{}); an admin grant on the repository alone does not reach it",
status.message(),
path.namespace,
path.repo,
path.namespace
))
}
/// [`account_admin_only`] for an account-wide decision (`what` names it).
pub(crate) fn account_admin_only_for(status: Status, account: &str, what: &str) -> Status {
if status.code() != tonic::Code::PermissionDenied {
return status;
}
Status::permission_denied(format!(
"{}: {what} {account} are the account admin's decision (admin of account {account})",
status.message()
))
}
/// **The one place a [`crate::seam::GcJob`] becomes wire bytes.**
///
/// The seam's job type and `gunnar.v1.GcJob` are deliberately separate — an
/// adapter implements the trait without linking the protos — so exactly one
/// function knows both, and it is this one. A second copy of this mapping is
/// how a state ends up meaning `UNSPECIFIED` on one path and `DONE` on another.
fn gc_job_out(job: crate::seam::GcJob) -> gunnar_proto::GcJob {
gunnar_proto::GcJob {
job_id: job.job_id,
path: Some(job.path),
state: match job.state {
crate::seam::GcState::Queued => gunnar_proto::GcState::Queued,
crate::seam::GcState::Running => gunnar_proto::GcState::Running,
crate::seam::GcState::Done => gunnar_proto::GcState::Done,
crate::seam::GcState::Failed => gunnar_proto::GcState::Failed,
} as i32,
queue_position: job.queue_position,
bytes_reclaimed: job.bytes_reclaimed,
error: job.error.unwrap_or_default(),
enqueued_at_unix_ms: job.enqueued_at_unix_ms,
finished_at_unix_ms: job.finished_at_unix_ms,
}
}
/// **The level `Repos.Create` asks** (clans, 2026-10-05): `Admin` over the
/// namespace — the account admin's — except that a CLAN's member creates
/// PRIVATE repositories in the clan's namespace with the `Write` its
/// membership gives it. A repository born public is a publish, and that is
/// the clan admin's, as on an account. The decision who is in the clan is the
/// identity table's; this only picks which question to ask it.
fn create_level(
ctx: &crate::service::ServiceContext,
caller: &crate::auth::Caller,
namespace: &str,
visibility: i32,
) -> GrantLevel {
if visibility == gunnar_proto::Visibility::Public as i32 {
return GrantLevel::Admin;
}
let member = ctx
.control
.clans()
.and_then(|clans| clans.clan_role(&caller.principal_id, namespace).ok().flatten())
.is_some_and(|role| role == gunnar_proto::ClanRole::Member);
if member {
GrantLevel::Write
} else {
GrantLevel::Admin
}
}