git-ents.gitmain
⌘K
foforge
gate.rs1219 lines · 45.0 KB · rusthistorycomment on this file
1//! Integration tests for the gate: the verdict table (rstest — the spec
2//! enumerates the cases), identity binding and owner mutation, the epoch
3//! and bootstrap windows, and the one-parameterized-test proof that all
4//! three call sites see identical verdicts.
5
6#![expect(
7 clippy::expect_used,
8 clippy::panic,
9 clippy::unreachable,
10 reason = "integration test: fixtures panic on setup failure"
11)]
12
13use ents_anchor::Binding;
14use ents_gate::{AdmissionKind, Config, Requirement, Update, Verdict, verify};
15use ents_model::{
16 Claim, Effect, Member, MemberId, Provenance, ResultRecord, Status,
17 claim::Verdict as ClaimVerdict, namespace,
18};
19use ents_testutil::{
20 CommitSpec, Keypair, MemRefStore, ObjectStore, empty_tree, enroll_member, write_commit,
21 write_member, write_meta_entity,
22};
23use gix::refs::FullName;
24use gix_hash::ObjectId;
25use gix_ref_store::{Expected, RefStoreRead as _};
26use rstest::rstest;
27
28const ADMIN_SEED: u8 = 1;
29const SELF_ATTESTED_SEED: u8 = 2;
30const OUTSIDER_SEED: u8 = 9;
31
32/// A forge fixture with verification in force: an admin-registered
33/// member `admin` (enrolled pre-epoch), a self-attested member `guest`,
34/// and an epoch recorded in `refs/meta/config`.
35struct Forge {
36 refs: MemRefStore,
37 objects: ObjectStore,
38 admin: Keypair,
39 guest: Keypair,
40}
41
42fn forge() -> Forge {
43 let refs = MemRefStore::default();
44 let objects = ObjectStore::default();
45 let admin = Keypair::from_seed(ADMIN_SEED);
46 let guest = Keypair::from_seed(SELF_ATTESTED_SEED);
47 enroll_member(
48 &refs,
49 &objects,
50 "admin",
51 &admin,
52 Provenance::AdminRegistered,
53 100,
54 );
55 enroll_member(
56 &refs,
57 &objects,
58 "guest",
59 &guest,
60 Provenance::SelfAttested,
61 110,
62 );
63 let config_ref: FullName = namespace::CONFIG_REF.try_into().expect("valid");
64 write_meta_entity(
65 &refs,
66 &objects,
67 config_ref,
68 &Config { epoch: Some(200) },
69 Some(&admin),
70 200,
71 );
72 Forge {
73 refs,
74 objects,
75 admin,
76 guest,
77 }
78}
79
80fn name(s: &str) -> FullName {
81 s.try_into().expect("valid refname in test")
82}
83
84/// The oid-keyed refname of a hash-identified issue whose genesis is
85/// `genesis` (`meta-ref.identity-binding`).
86fn issue_ref(genesis: ObjectId) -> FullName {
87 name(&format!("refs/meta/issues/{genesis}"))
88}
89
90/// A signed empty-tree commit — a generic meta-mutation body. A
91/// hash-identified entity binds by its genesis oid and the all-roots
92/// walk, not by tree content, so an empty tree exercises the tip
93/// invariant on `refs/meta/issues/*` and `refs/meta/comments/*` fully.
94fn commit(forge: &Forge, parents: Vec<ObjectId>, key: Option<&Keypair>, seconds: i64) -> ObjectId {
95 let tree = empty_tree(&forge.objects);
96 write_commit(
97 &forge.objects,
98 &CommitSpec {
99 tree,
100 parents,
101 message: "mutate".into(),
102 seconds,
103 },
104 key,
105 )
106}
107
108/// A signed commit whose tree is `tree` — for the namespaces whose binding
109/// reads a tree field (natural-key, composite).
110fn tree_commit(
111 forge: &Forge,
112 tree: ObjectId,
113 parents: Vec<ObjectId>,
114 key: Option<&Keypair>,
115 seconds: i64,
116) -> ObjectId {
117 write_commit(
118 &forge.objects,
119 &CommitSpec {
120 tree,
121 parents,
122 message: "mutate".into(),
123 seconds,
124 },
125 key,
126 )
127}
128
129fn run(forge: &Forge, refname: &FullName, new: Option<ObjectId>) -> Verdict {
130 verify(
131 &forge.refs,
132 &forge.objects,
133 &Update {
134 name: refname.clone(),
135 new,
136 },
137 )
138 .expect("the gate must reach a verdict on a complete fixture")
139}
140
141fn expect_fail(verdict: &Verdict, requirement: Requirement) {
142 let Verdict::Fail(refusal) = verdict else {
143 panic!("expected a refusal against {requirement:?}, got {verdict:?}");
144 };
145 assert_eq!(refusal.requirement, requirement, "refusal: {refusal}");
146}
147
148fn expect_pass(verdict: &Verdict, kind: AdmissionKind) {
149 let Verdict::Pass(admission) = verdict else {
150 panic!("expected admission {kind:?}, got {verdict:?}");
151 };
152 assert_eq!(admission.kind, kind);
153}
154
155// ---------------------------------------------------------------------
156// The verdict table: member × signature × FF × namespace cases.
157// ---------------------------------------------------------------------
158
159#[rstest]
160// @relation(gate.tip-signed, gate.verdict-reason, scope=function, role=Verifies)
161fn authorized_signed_mutation_passes_the_tip_invariant() {
162 let f = forge();
163 let new = commit(&f, vec![], Some(&f.admin), 300);
164 expect_pass(
165 &run(&f, &issue_ref(new), Some(new)),
166 AdmissionKind::TipInvariant,
167 );
168}
169
170#[rstest]
171// @relation(gate.tip-signed, scope=function, role=Verifies)
172fn unsigned_tip_is_refused() {
173 let f = forge();
174 let new = commit(&f, vec![], None, 300);
175 expect_fail(&run(&f, &issue_ref(new), Some(new)), Requirement::TipSigned);
176}
177
178#[rstest]
179// @relation(gate.tip-signed, scope=function, role=Verifies)
180fn non_member_signature_is_refused() {
181 let f = forge();
182 let outsider = Keypair::from_seed(OUTSIDER_SEED);
183 let new = commit(&f, vec![], Some(&outsider), 300);
184 expect_fail(&run(&f, &issue_ref(new), Some(new)), Requirement::TipSigned);
185}
186
187/// Revoke `id`'s key in the fixture, as an admin-signed mutation of the
188/// member's ref.
189fn revoke(f: &Forge, id: &str, key: &Keypair, provenance: Provenance, seconds: i64) {
190 let mut revoked = Member::new(id, key.public_openssh(), provenance);
191 revoked.revoke();
192 write_member(&f.refs, &f.objects, id, &revoked, Some(&f.admin), seconds);
193}
194
195#[rstest]
196// @relation(gate.tip-signed, model.member-revocation, scope=function, role=Verifies)
197fn a_revoked_members_new_push_is_refused() {
198 let f = forge();
199 revoke(&f, "admin", &f.admin, Provenance::AdminRegistered, 400);
200
201 let new = commit(&f, vec![], Some(&f.admin), 500);
202 let verdict = run(&f, &issue_ref(new), Some(new));
203 expect_fail(&verdict, Requirement::TipSigned);
204 let Verdict::Fail(refusal) = &verdict else {
205 unreachable!()
206 };
207 assert!(refusal.detail.contains("revoked"), "detail: {refusal}");
208}
209
210#[rstest]
211// @relation(model.member-revocation, gate.tip-signed, scope=function, role=Verifies)
212fn a_backdated_commit_cannot_reach_past_a_revocation() {
213 // The security-review regression: admission consults the member
214 // entity currently in force, so a revoked key authoring a NEW commit
215 // with a committer timestamp claimed from before the revocation —
216 // descending cleanly from the live tip — is still refused.
217 let f = forge();
218 let genesis = commit(&f, vec![], Some(&f.admin), 300);
219 let refname = issue_ref(genesis);
220 f.refs.set(refname.as_ref(), genesis);
221 revoke(&f, "admin", &f.admin, Provenance::AdminRegistered, 400);
222
223 // Authored "at 300", pushed after the revocation at 400.
224 let backdated = commit(&f, vec![genesis], Some(&f.admin), 300);
225 expect_fail(&run(&f, &refname, Some(backdated)), Requirement::TipSigned);
226}
227
228#[rstest]
229// @relation(model.member-revocation, gate.fast-forward, scope=function, role=Verifies)
230fn refs_accepted_before_a_revocation_are_never_rejudged() {
231 // A second admin keeps working on a ref whose current tip was placed
232 // by a member revoked afterwards: the accepted tip stays valid
233 // history, and fast-forwarding over it is ordinary descent.
234 let f = forge();
235 let second = Keypair::from_seed(OUTSIDER_SEED);
236 enroll_member(
237 &f.refs,
238 &f.objects,
239 "second",
240 &second,
241 Provenance::AdminRegistered,
242 310,
243 );
244
245 let genesis = commit(&f, vec![], Some(&f.admin), 320);
246 let refname = issue_ref(genesis);
247 f.refs.set(refname.as_ref(), genesis);
248 revoke(&f, "admin", &f.admin, Provenance::AdminRegistered, 400);
249
250 let continued = commit(&f, vec![genesis], Some(&second), 500);
251 expect_pass(
252 &run(&f, &refname, Some(continued)),
253 AdmissionKind::TipInvariant,
254 );
255}
256
257#[rstest]
258// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies)
259fn a_refname_not_naming_the_genesis_oid_is_refused() {
260 // The identity binding replaces the retired Advance-ref trailer: a
261 // signed commit proposed under a refname whose final segment is not
262 // its genesis oid is refused, so it cannot be replayed as the tip of a
263 // different meta-ref than the one its content names.
264 let f = forge();
265 let genesis = commit(&f, vec![], Some(&f.admin), 300);
266 // A different, valid oid that is not this commit's genesis.
267 let wrong =
268 issue_ref(ObjectId::from_hex(b"00000000000000000000000000000000deadbeef").expect("hex"));
269 expect_fail(
270 &run(&f, &wrong, Some(genesis)),
271 Requirement::IdentityBinding,
272 );
273}
274
275#[rstest]
276// @relation(gate.fast-forward, scope=function, role=Verifies)
277fn non_fast_forward_is_refused() {
278 let f = forge();
279 // A genesis and two children of it, all correctly bound to the same
280 // oid-keyed ref; the current tip is one child, the proposal the
281 // sibling — a genuine fast-forward refusal, not an identity mismatch.
282 let genesis = commit(&f, vec![], Some(&f.admin), 300);
283 let refname = issue_ref(genesis);
284 let current = commit(&f, vec![genesis], Some(&f.admin), 310);
285 let sibling = commit(&f, vec![genesis], Some(&f.admin), 311);
286 f.refs.set(refname.as_ref(), current);
287 assert_ne!(sibling, current);
288 expect_fail(&run(&f, &refname, Some(sibling)), Requirement::FastForward);
289}
290
291#[rstest]
292// @relation(gate.fast-forward, scope=function, role=Verifies)
293fn meta_ref_deletion_is_refused() {
294 let f = forge();
295 let genesis = commit(&f, vec![], Some(&f.admin), 300);
296 let refname = issue_ref(genesis);
297 f.refs.set(refname.as_ref(), genesis);
298 expect_fail(&run(&f, &refname, None), Requirement::FastForward);
299}
300
301#[rstest]
302// @relation(gate.principled-split, scope=function, role=Verifies)
303fn code_refs_are_not_subject_to_the_tip_invariant() {
304 let f = forge();
305 // Unsigned, non-FF — none of it matters outside refs/meta/*.
306 let new = commit(&f, vec![], None, 300);
307 expect_pass(
308 &run(&f, &name("refs/heads/main"), Some(new)),
309 AdmissionKind::CodeRef,
310 );
311}
312
313// ---------------------------------------------------------------------
314// Identity binding: the refname is a function of signed content.
315// ---------------------------------------------------------------------
316
317#[rstest]
318// @relation(gate.identity-binding, meta-ref.identity-binding, model.member-identity, scope=function, role=Verifies)
319fn a_natural_key_member_binds_by_its_id_field() {
320 let f = forge();
321 // A member entity whose id field disagrees with the refname's final
322 // segment is refused; agreeing, it passes.
323 let mismatched = Member::new(
324 "someone-else",
325 f.admin.public_openssh(),
326 Provenance::AdminRegistered,
327 );
328 let tree = facet_git_tree::serialize_into(&mismatched, &f.objects).expect("ser");
329 let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300);
330 expect_fail(
331 &run(&f, &name("refs/meta/member/newcomer"), Some(tip)),
332 Requirement::IdentityBinding,
333 );
334
335 let matched = Member::new(
336 "newcomer",
337 f.admin.public_openssh(),
338 Provenance::AdminRegistered,
339 );
340 let tree = facet_git_tree::serialize_into(&matched, &f.objects).expect("ser");
341 let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300);
342 expect_pass(
343 &run(&f, &name("refs/meta/member/newcomer"), Some(tip)),
344 AdmissionKind::TipInvariant,
345 );
346}
347
348#[rstest]
349// @relation(gate.identity-binding, meta-ref.identity-binding, model.effect-definition, scope=function, role=Verifies)
350fn a_natural_key_effect_binds_by_its_name_field() {
351 let f = forge();
352 let effect = Effect {
353 name: "unit".into(),
354 trigger: "rev(refs/heads/main)".into(),
355 toolchains: vec![],
356 run: "true".into(),
357 };
358 let tree = facet_git_tree::serialize_into(&effect, &f.objects).expect("ser");
359 // Named `unit` in the tree — refused under a ref that names `lint`.
360 let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300);
361 expect_fail(
362 &run(&f, &name("refs/meta/effects/lint"), Some(tip)),
363 Requirement::IdentityBinding,
364 );
365 // Under the ref its name field recomputes, it passes.
366 expect_pass(
367 &run(&f, &name("refs/meta/effects/unit"), Some(tip)),
368 AdmissionKind::TipInvariant,
369 );
370}
371
372/// A commit recording `result` at `results/<effect>/<short>`.
373fn result_commit(
374 f: &Forge,
375 effect: &str,
376 target: ObjectId,
377 parents: Vec<ObjectId>,
378 key: Option<&Keypair>,
379 seconds: i64,
380) -> ObjectId {
381 let record = ResultRecord::new(effect, target, Status::Pass);
382 let tree = facet_git_tree::serialize_into(&record, &f.objects).expect("ser");
383 tree_commit(f, tree, parents, key, seconds)
384}
385
386#[rstest]
387// @relation(gate.identity-binding, model.result-identity, scope=function, role=Verifies)
388fn a_composite_result_binds_by_its_effect_and_target_fields() {
389 let f = forge();
390 let target = ObjectId::from_hex(b"abc1230000000000000000000000000000000000").expect("hex");
391 let tip = result_commit(&f, "unit", target, vec![], Some(&f.admin), 300);
392
393 // Correct effect and short-oid prefix: passes.
394 expect_pass(
395 &run(
396 &f,
397 &namespace::result_ref("unit", "abc123").expect("valid"),
398 Some(tip),
399 ),
400 AdmissionKind::TipInvariant,
401 );
402 // Wrong effect segment: refused.
403 expect_fail(
404 &run(
405 &f,
406 &namespace::result_ref("lint", "abc123").expect("valid"),
407 Some(tip),
408 ),
409 Requirement::IdentityBinding,
410 );
411 // Short oid that is not a prefix of the target field: refused.
412 expect_fail(
413 &run(
414 &f,
415 &namespace::result_ref("unit", "ffffff").expect("valid"),
416 Some(tip),
417 ),
418 Requirement::IdentityBinding,
419 );
420}
421
422/// A stand-in Review tree: the gate reads only the `target` field
423/// generically, so any struct carrying it exercises the composite key.
424#[derive(facet::Facet)]
425struct Review {
426 target: [u8; 20],
427 verdict: String,
428}
429
430fn review_commit(f: &Forge, target: ObjectId, key: Option<&Keypair>, seconds: i64) -> ObjectId {
431 let mut bytes = [0u8; 20];
432 bytes.copy_from_slice(target.as_slice());
433 let review = Review {
434 target: bytes,
435 verdict: "approve".into(),
436 };
437 let tree = facet_git_tree::serialize_into(&review, &f.objects).expect("ser");
438 tree_commit(f, tree, vec![], key, seconds)
439}
440
441#[rstest]
442// @relation(gate.identity-binding, model.review, scope=function, role=Verifies)
443fn a_composite_review_binds_by_its_target_field_and_signer() {
444 let f = forge();
445 let target = ObjectId::from_hex(b"deadbeef00000000000000000000000000000000").expect("hex");
446 let tip = review_commit(&f, target, Some(&f.admin), 300);
447 let good = namespace::review_ref(&target.to_string(), &MemberId::new("admin")).expect("valid");
448 expect_pass(&run(&f, &good, Some(tip)), AdmissionKind::TipInvariant);
449
450 // Right target, wrong reviewer segment: the signer is admin, not guest.
451 let wrong_member =
452 namespace::review_ref(&target.to_string(), &MemberId::new("guest")).expect("valid");
453 expect_fail(
454 &run(&f, &wrong_member, Some(tip)),
455 Requirement::IdentityBinding,
456 );
457
458 // Right reviewer, wrong target segment.
459 let other = ObjectId::from_hex(b"0000000000000000000000000000000000000001").expect("hex");
460 let wrong_target =
461 namespace::review_ref(&other.to_string(), &MemberId::new("admin")).expect("valid");
462 expect_fail(
463 &run(&f, &wrong_target, Some(tip)),
464 Requirement::IdentityBinding,
465 );
466}
467
468/// A result tree carrying an entry that is not a `ResultRecord` field —
469/// for the strict-decode disjointness check.
470#[derive(facet::Facet)]
471struct ResultPlus {
472 effect: String,
473 target: [u8; 20],
474 status: Status,
475 surprise: String,
476}
477
478#[rstest]
479// @relation(gate.identity-binding, meta-ref.typed-tree, scope=function, role=Verifies)
480fn strict_genesis_decode_refuses_an_unknown_tree_entry() {
481 let f = forge();
482 let target = ObjectId::from_hex(b"abc1230000000000000000000000000000000000").expect("hex");
483 let mut bytes = [0u8; 20];
484 bytes.copy_from_slice(target.as_slice());
485 let bogus = ResultPlus {
486 effect: "unit".into(),
487 target: bytes,
488 status: Status::Pass,
489 surprise: "not a result field".into(),
490 };
491 let tree = facet_git_tree::serialize_into(&bogus, &f.objects).expect("ser");
492 let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300);
493 // Even though effect and short-oid recompute, the extra tree entry
494 // makes strict decode refuse the genesis.
495 expect_fail(
496 &run(
497 &f,
498 &namespace::result_ref("unit", "abc123").expect("valid"),
499 Some(tip),
500 ),
501 Requirement::IdentityBinding,
502 );
503}
504
505#[rstest]
506// @relation(gate.identity-binding, gate.same-actor-divergence, scope=function, role=Verifies)
507fn the_all_roots_walk_holds_across_a_sync_created_merge() {
508 // Two children of the same genesis, merged: the merge has two parents
509 // but a single parentless root (the genesis), so the hash-identified
510 // binding still recomputes the genesis oid across the merge commit.
511 let f = forge();
512 let genesis = commit(&f, vec![], Some(&f.admin), 300);
513 let refname = issue_ref(genesis);
514 let a = commit(&f, vec![genesis], Some(&f.admin), 310);
515 let b = commit(&f, vec![genesis], Some(&f.admin), 311);
516 f.refs.set(refname.as_ref(), a);
517 let merge = commit(&f, vec![a, b], Some(&f.admin), 320);
518 expect_pass(&run(&f, &refname, Some(merge)), AdmissionKind::TipInvariant);
519
520 // The same merge proposed under a doppelgänger genesis id (the merge's
521 // own oid) is refused: its parentless root is still the genesis.
522 expect_fail(
523 &run(&f, &issue_ref(merge), Some(merge)),
524 Requirement::IdentityBinding,
525 );
526}
527
528#[rstest]
529// @relation(gate.identity-binding, model.review-pin, scope=function, role=Verifies)
530fn a_pin_is_never_subjected_to_the_all_roots_walk() {
531 // A pin's ancestry reaches into code history (its parents include the
532 // reviewed commit), so the parentless-roots walk must not apply. A pin
533 // commit whose parent is an arbitrary reviewed commit — not a genesis
534 // whose oid is the pin's id — still binds.
535 let f = forge();
536 let reviewed = commit(&f, vec![], Some(&f.admin), 250);
537 let target = reviewed.to_string();
538 let pin_ref = namespace::review_pin_ref(&target, &MemberId::new("admin")).expect("valid");
539 // The pin's tip is a signed commit whose parents include the reviewed
540 // commit; its tree is empty. The all-roots walk would reject it (the
541 // root is `reviewed`, not the pin's segments), so this passing verdict
542 // proves the walk is skipped for pins.
543 let pin_tip = commit(&f, vec![reviewed], Some(&f.admin), 300);
544 expect_pass(
545 &run(&f, &pin_ref, Some(pin_tip)),
546 AdmissionKind::TipInvariant,
547 );
548}
549
550// ---------------------------------------------------------------------
551// Claims: append-once, witness-retaining, signer-bound genesis refs.
552// ---------------------------------------------------------------------
553
554/// A real serialized [`Claim`] tree over a `Binding::Commit { commit: witness
555/// }`, asserted by `signer_id` — built through `Claim::new` so these tests
556/// exercise the entity as it is actually stored, not a hand-wired tree.
557fn claim_tree(f: &Forge, signer_id: &str, verdict: ClaimVerdict, witness: ObjectId) -> ObjectId {
558 let binding = Binding::Commit { commit: witness };
559 let claim = Claim::new(
560 MemberId::new(signer_id),
561 &binding,
562 verdict,
563 "review",
564 &f.objects,
565 )
566 .expect("claim serializes");
567 facet_git_tree::serialize_into(&claim, &f.objects).expect("ser")
568}
569
570#[rstest]
571// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies)
572fn a_claim_genesis_with_a_witness_parent_passes_the_tip_invariant() {
573 let f = forge();
574 let witness = commit(&f, vec![], Some(&f.admin), 250);
575 let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness);
576 let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300);
577 let refname = namespace::claim_ref(&tip.to_string()).expect("valid");
578 expect_pass(&run(&f, &refname, Some(tip)), AdmissionKind::TipInvariant);
579}
580
581#[rstest]
582// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies)
583fn a_claim_refname_not_naming_the_tips_own_oid_is_refused() {
584 let f = forge();
585 let witness = commit(&f, vec![], Some(&f.admin), 250);
586 let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness);
587 let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300);
588 // Named for the witness rather than the claim's own genesis oid.
589 let wrong = namespace::claim_ref(&witness.to_string()).expect("valid");
590 expect_fail(&run(&f, &wrong, Some(tip)), Requirement::IdentityBinding);
591}
592
593#[rstest]
594// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies)
595fn a_claim_ref_is_append_once_and_refuses_an_advance() {
596 let f = forge();
597 let witness = commit(&f, vec![], Some(&f.admin), 250);
598 let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness);
599 let genesis = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300);
600 let refname = namespace::claim_ref(&genesis.to_string()).expect("valid");
601 f.refs.set(refname.as_ref(), genesis);
602
603 // A changed assertion is a new claim, never an advance of this one:
604 // even a well-formed, correctly signed child commit under the same
605 // ref is refused, because its own oid is not the ref's segment.
606 let advance_tree = claim_tree(&f, "admin", ClaimVerdict::Deny, witness);
607 let advance = tree_commit(&f, advance_tree, vec![genesis], Some(&f.admin), 310);
608 expect_fail(
609 &run(&f, &refname, Some(advance)),
610 Requirement::IdentityBinding,
611 );
612}
613
614#[rstest]
615// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies)
616fn a_parentless_claim_tip_is_refused() {
617 let f = forge();
618 let witness = commit(&f, vec![], Some(&f.admin), 250);
619 let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness);
620 let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300);
621 let refname = namespace::claim_ref(&tip.to_string()).expect("valid");
622 expect_fail(&run(&f, &refname, Some(tip)), Requirement::IdentityBinding);
623}
624
625#[rstest]
626// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies)
627fn a_claim_signer_field_mismatching_the_actual_signer_is_refused() {
628 let f = forge();
629 let witness = commit(&f, vec![], Some(&f.admin), 250);
630 // The claim's tree names a signer other than whoever actually signed
631 // the ledger commit.
632 let tree = claim_tree(&f, "someone-else", ClaimVerdict::Affirm, witness);
633 let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300);
634 let refname = namespace::claim_ref(&tip.to_string()).expect("valid");
635 expect_fail(&run(&f, &refname, Some(tip)), Requirement::IdentityBinding);
636}
637
638/// A claim tree carrying an entry that is not a `Claim` field — for the
639/// strict-decode disjointness check.
640#[derive(facet::Facet)]
641struct ClaimPlus {
642 signer: MemberId,
643 binding: facet_git_tree::RawTree,
644 verdict: ClaimVerdict,
645 kind: String,
646 surprise: String,
647}
648
649#[rstest]
650// @relation(gate.identity-binding, meta-ref.typed-tree, scope=function, role=Verifies)
651fn strict_genesis_decode_refuses_an_unknown_claim_tree_entry() {
652 let f = forge();
653 let witness = commit(&f, vec![], Some(&f.admin), 250);
654 let binding = Binding::Commit { commit: witness };
655 let binding_tree = binding
656 .serialize_into(&f.objects)
657 .expect("binding serializes");
658 let bogus = ClaimPlus {
659 signer: MemberId::new("admin"),
660 binding: facet_git_tree::RawTree::new(binding_tree),
661 verdict: ClaimVerdict::Affirm,
662 kind: "review".into(),
663 surprise: "not a claim field".into(),
664 };
665 let tree = facet_git_tree::serialize_into(&bogus, &f.objects).expect("ser");
666 let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300);
667 let refname = namespace::claim_ref(&tip.to_string()).expect("valid");
668 expect_fail(&run(&f, &refname, Some(tip)), Requirement::IdentityBinding);
669}
670
671#[rstest]
672// @relation(model.member-provenance, meta-ref.inbox, gate.tip-signed, scope=function, role=Verifies)
673fn a_self_attested_member_falls_back_to_its_inbox_for_a_claim() {
674 let f = forge();
675 let witness = commit(&f, vec![], Some(&f.guest), 250);
676 let tree = claim_tree(&f, "guest", ClaimVerdict::Note, witness);
677 let tip = tree_commit(&f, tree, vec![witness], Some(&f.guest), 300);
678
679 // The canonical claim ref is refused — self-attested provenance is not
680 // authorized for canonical refs — with the inbox alternative surfaced.
681 let canonical = namespace::claim_ref(&tip.to_string()).expect("valid");
682 let verdict = run(&f, &canonical, Some(tip));
683 expect_fail(&verdict, Requirement::TipSigned);
684 let Verdict::Fail(refusal) = &verdict else {
685 unreachable!()
686 };
687 assert!(refusal.inbox_alternative, "detail: {refusal}");
688
689 // The identical claim under the member's own inbox segment passes: the
690 // inbox arm recurses into the synthesized canonical refname
691 // (`refs/meta/claims/<id>`) and finds the same binding.
692 let inbox =
693 namespace::inbox_ref(&MemberId::new("guest"), &format!("claims/{tip}")).expect("valid");
694 expect_pass(&run(&f, &inbox, Some(tip)), AdmissionKind::TipInvariant);
695}
696
697#[rstest]
698// @relation(meta-ref.inbox, gate.tip-signed, scope=function, role=Verifies)
699fn an_inbox_claim_by_its_owner_with_a_correct_binding_passes() {
700 let f = forge();
701 let witness = commit(&f, vec![], Some(&f.admin), 250);
702 let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness);
703 let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300);
704 let inbox =
705 namespace::inbox_ref(&MemberId::new("admin"), &format!("claims/{tip}")).expect("valid");
706 expect_pass(&run(&f, &inbox, Some(tip)), AdmissionKind::TipInvariant);
707}
708
709// ---------------------------------------------------------------------
710// Owner mutation: an advance is keyed to ownership.
711// ---------------------------------------------------------------------
712
713#[rstest]
714// @relation(gate.owner-mutation, scope=function, role=Verifies)
715fn an_admin_may_advance_another_members_hash_identified_entity() {
716 // Ownership of a hash-identified entity is intrinsic to its id, but an
717 // admin-registered member may advance it too (∪ admins). A second
718 // admin advances the first admin's comment.
719 let f = forge();
720 let second = Keypair::from_seed(OUTSIDER_SEED);
721 enroll_member(
722 &f.refs,
723 &f.objects,
724 "second",
725 &second,
726 Provenance::AdminRegistered,
727 210,
728 );
729
730 let genesis = commit(&f, vec![], Some(&f.admin), 300);
731 let refname = name(&format!("refs/meta/comments/{genesis}"));
732 f.refs.set(refname.as_ref(), genesis);
733 let advance = commit(&f, vec![genesis], Some(&second), 310);
734 expect_pass(
735 &run(&f, &refname, Some(advance)),
736 AdmissionKind::TipInvariant,
737 );
738}
739
740#[rstest]
741// @relation(gate.owner-mutation, model.member-provenance, scope=function, role=Verifies)
742fn a_self_attested_non_owner_cannot_advance_a_comment() {
743 // A self-attested member is not authorized for canonical refs at all
744 // (creation stays provenance-keyed, routed to the inbox); it therefore
745 // cannot advance someone else's comment either.
746 let f = forge();
747 let genesis = commit(&f, vec![], Some(&f.admin), 300);
748 let refname = name(&format!("refs/meta/comments/{genesis}"));
749 f.refs.set(refname.as_ref(), genesis);
750 let advance = commit(&f, vec![genesis], Some(&f.guest), 310);
751 let verdict = run(&f, &refname, Some(advance));
752 expect_fail(&verdict, Requirement::TipSigned);
753}
754
755#[rstest]
756// @relation(gate.owner-mutation, model.review, scope=function, role=Verifies)
757fn the_wrong_member_cannot_advance_a_review() {
758 // A review advances only under the member its refname names. `guest`
759 // (were it registered) could not advance admin's review; here we prove
760 // the composite key + owner rule refuse a mismatched signer.
761 let f = forge();
762 let second = Keypair::from_seed(OUTSIDER_SEED);
763 enroll_member(
764 &f.refs,
765 &f.objects,
766 "second",
767 &second,
768 Provenance::AdminRegistered,
769 210,
770 );
771 let target = ObjectId::from_hex(b"deadbeef00000000000000000000000000000000").expect("hex");
772 // A review ref named for admin, but signed by `second`.
773 let tip = review_commit(&f, target, Some(&second), 300);
774 let refname =
775 namespace::review_ref(&target.to_string(), &MemberId::new("admin")).expect("valid");
776 let verdict = run(&f, &refname, Some(tip));
777 let Verdict::Fail(_) = verdict else {
778 panic!(
779 "a review signed by a member other than the one it names must be refused: {verdict:?}"
780 );
781 };
782}
783
784// ---------------------------------------------------------------------
785// Provenance-keyed authorization.
786// ---------------------------------------------------------------------
787
788#[rstest]
789#[case::canonical_issue("refs/meta/issues/1", true)]
790#[case::canonical_result("refs/meta/results/unit/abc", true)]
791#[case::effects_is_admin_only("refs/meta/effects/unit", true)]
792#[case::another_self_run("refs/meta/self/admin/unit/abc", false)]
793// @relation(model.member-provenance, effect.admin-only, gate.tip-signed, scope=function, role=Verifies)
794fn self_attested_members_are_limited_to_their_own_namespaces(
795 #[case] refname: &str,
796 #[case] inbox_alternative: bool,
797) {
798 // Authorization is judged before identity binding, so an empty-tree
799 // commit under any of these canonical refs is refused on provenance.
800 let f = forge();
801 let new = commit(&f, vec![], Some(&f.guest), 300);
802 let verdict = run(&f, &name(refname), Some(new));
803 expect_fail(&verdict, Requirement::TipSigned);
804 let Verdict::Fail(refusal) = &verdict else {
805 unreachable!()
806 };
807 assert_eq!(
808 refusal.inbox_alternative, inbox_alternative,
809 "inbox hint for {refname}: {refusal}"
810 );
811}
812
813#[rstest]
814// @relation(meta-ref.inbox, effect.self-run, gate.tip-signed, scope=function, role=Verifies)
815fn a_member_may_write_its_own_self_run_namespace() {
816 let f = forge();
817 let target = ObjectId::from_hex(b"abc1230000000000000000000000000000000000").expect("hex");
818 let tip = result_commit(&f, "unit", target, vec![], Some(&f.guest), 300);
819 let refname =
820 namespace::self_result_ref(&MemberId::new("guest"), "unit", "abc123").expect("valid");
821 expect_pass(&run(&f, &refname, Some(tip)), AdmissionKind::TipInvariant);
822}
823
824#[rstest]
825#[case::own_segment_self_attested(false, "refs/meta/inbox/guest/issue-1", true)]
826#[case::own_segment_admin(true, "refs/meta/inbox/admin/issue-1", true)]
827#[case::foreign_segment_self_attested(false, "refs/meta/inbox/admin/issue-1", false)]
828#[case::foreign_segment_even_for_admins(true, "refs/meta/inbox/guest/issue-1", false)]
829#[case::unscoped_legacy_shape_owns_nothing(true, "refs/meta/inbox/issue-1", false)]
830// @relation(meta-ref.inbox, model.member-provenance, gate.tip-signed, scope=function, role=Verifies)
831fn inbox_segments_are_owner_only_for_both_provenances(
832 #[case] as_admin: bool,
833 #[case] refname: &str,
834 #[case] admitted: bool,
835) {
836 let f = forge();
837 let key = if as_admin { &f.admin } else { &f.guest };
838 let new = commit(&f, vec![], Some(key), 300);
839 let verdict = run(&f, &name(refname), Some(new));
840 if admitted {
841 expect_pass(&verdict, AdmissionKind::TipInvariant);
842 } else {
843 expect_fail(&verdict, Requirement::TipSigned);
844 }
845}
846
847#[rstest]
848// @relation(effect.admin-only, gate.tip-signed, scope=function, role=Verifies)
849fn admin_registered_members_may_write_the_effects_namespace() {
850 let f = forge();
851 let effect = Effect {
852 name: "unit".into(),
853 trigger: "rev(refs/heads/main)".into(),
854 toolchains: vec![],
855 run: "true".into(),
856 };
857 let tree = facet_git_tree::serialize_into(&effect, &f.objects).expect("ser");
858 let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300);
859 expect_pass(
860 &run(&f, &name("refs/meta/effects/unit"), Some(tip)),
861 AdmissionKind::TipInvariant,
862 );
863}
864
865// ---------------------------------------------------------------------
866// Adoption and divergence: consequences of judging only the tip.
867// ---------------------------------------------------------------------
868
869#[rstest]
870// @relation(gate.adoption-merge, scope=function, role=Verifies)
871fn adoption_is_a_merge_that_keeps_the_contributor_commit_in_ancestry() {
872 let f = forge();
873 let genesis = commit(&f, vec![], Some(&f.admin), 300);
874 let refname = name(&format!("refs/meta/comments/{genesis}"));
875 f.refs.set(refname.as_ref(), genesis);
876 // The contributor's own signed commit, not authorized for this ref.
877 let contributed = commit(&f, vec![genesis], Some(&f.guest), 310);
878 // The authorized member merges it: the merge tip satisfies the
879 // invariant; the contributor's signature survives in ancestry.
880 let merge = commit(&f, vec![genesis, contributed], Some(&f.admin), 320);
881 expect_pass(&run(&f, &refname, Some(merge)), AdmissionKind::TipInvariant);
882}
883
884#[rstest]
885// @relation(gate.adoption-no-fast-forward, scope=function, role=Verifies)
886fn fast_forwarding_to_a_contributor_commit_is_not_adoption() {
887 let f = forge();
888 let genesis = commit(&f, vec![], Some(&f.admin), 300);
889 let refname = name(&format!("refs/meta/comments/{genesis}"));
890 f.refs.set(refname.as_ref(), genesis);
891 let contributed = commit(&f, vec![genesis], Some(&f.guest), 310);
892 // Descends fine — but the tip signature is the contributor's, and the
893 // contributor is not authorized for this refname.
894 expect_fail(
895 &run(&f, &refname, Some(contributed)),
896 Requirement::TipSigned,
897 );
898}
899
900#[rstest]
901// @relation(gate.same-actor-divergence, scope=function, role=Verifies)
902fn a_members_own_divergent_heads_merge_cleanly() {
903 let f = forge();
904 let genesis = commit(&f, vec![], Some(&f.admin), 300);
905 let refname = issue_ref(genesis);
906 f.refs.set(refname.as_ref(), genesis);
907 // Two of the member's own machines raced the single-writer ref.
908 let a = commit(&f, vec![genesis], Some(&f.admin), 310);
909 let b = commit(&f, vec![genesis], Some(&f.admin), 311);
910 let merge = commit(&f, vec![a, b], Some(&f.admin), 320);
911 expect_pass(&run(&f, &refname, Some(merge)), AdmissionKind::TipInvariant);
912}
913
914// ---------------------------------------------------------------------
915// The verification epoch.
916// ---------------------------------------------------------------------
917
918#[rstest]
919// @relation(gate.epoch, scope=function, role=Verifies)
920fn before_any_epoch_meta_history_is_archival() {
921 let refs = MemRefStore::default();
922 let objects = ObjectStore::default();
923 let f = Forge {
924 refs,
925 objects,
926 admin: Keypair::from_seed(ADMIN_SEED),
927 guest: Keypair::from_seed(SELF_ATTESTED_SEED),
928 };
929 // No config, no members: an unsigned meta write passes as pre-epoch.
930 let new = commit(&f, vec![], None, 100);
931 expect_pass(
932 &run(&f, &issue_ref(new), Some(new)),
933 AdmissionKind::PreEpoch,
934 );
935}
936
937#[rstest]
938// @relation(gate.epoch, scope=function, role=Verifies)
939fn the_epoch_setting_commit_is_itself_the_first_gated_tip() {
940 let refs = MemRefStore::default();
941 let objects = ObjectStore::default();
942 let admin = Keypair::from_seed(ADMIN_SEED);
943 enroll_member(
944 &refs,
945 &objects,
946 "admin",
947 &admin,
948 Provenance::AdminRegistered,
949 100,
950 );
951 let f = Forge {
952 refs,
953 objects,
954 admin,
955 guest: Keypair::from_seed(SELF_ATTESTED_SEED),
956 };
957
958 let tree = facet_git_tree::serialize_into(
959 &Config { epoch: Some(200) },
960 &f.objects,
961 )
962 .expect("config serializes");
963 let make = |key: Option<&Keypair>| {
964 write_commit(
965 &f.objects,
966 &CommitSpec {
967 tree,
968 parents: vec![],
969 message: "enable verification".into(),
970 seconds: 200,
971 },
972 key,
973 )
974 };
975
976 // Unsigned epoch-setting is refused: the circularity resolves by
977 // gating the very commit that turns gating on.
978 expect_fail(
979 &run(&f, &name(namespace::CONFIG_REF), Some(make(None))),
980 Requirement::TipSigned,
981 );
982 // Signed by an enrolled member, it passes under the tip invariant.
983 expect_pass(
984 &run(&f, &name(namespace::CONFIG_REF), Some(make(Some(&f.admin)))),
985 AdmissionKind::TipInvariant,
986 );
987}
988
989// ---------------------------------------------------------------------
990// Bootstrap: fail-closed empty-member-list handling.
991// ---------------------------------------------------------------------
992
993/// A store with verification in force but no members at all.
994fn bare_forge_with_epoch() -> Forge {
995 let refs = MemRefStore::default();
996 let objects = ObjectStore::default();
997 let config_ref: FullName = namespace::CONFIG_REF.try_into().expect("valid");
998 write_meta_entity(
999 &refs,
1000 &objects,
1001 config_ref,
1002 &Config { epoch: Some(50) },
1003 None,
1004 50,
1005 );
1006 Forge {
1007 refs,
1008 objects,
1009 admin: Keypair::from_seed(ADMIN_SEED),
1010 guest: Keypair::from_seed(SELF_ATTESTED_SEED),
1011 }
1012}
1013
1014fn enrollment_proposal(f: &Forge, id: &str, enrolled: &Keypair, signer: &Keypair) -> ObjectId {
1015 let member = Member::new(id, enrolled.public_openssh(), Provenance::AdminRegistered);
1016 let tree = facet_git_tree::serialize_into(&member, &f.objects).expect("member serializes");
1017 write_commit(
1018 &f.objects,
1019 &CommitSpec {
1020 tree,
1021 parents: vec![],
1022 message: format!("enroll {id}"),
1023 seconds: 100,
1024 },
1025 Some(signer),
1026 )
1027}
1028
1029#[rstest]
1030// @relation(gate.bootstrap, scope=function, role=Verifies)
1031fn first_enrollment_is_self_admitting() {
1032 let f = bare_forge_with_epoch();
1033 let new = enrollment_proposal(&f, "first", &f.admin, &f.admin);
1034 expect_pass(
1035 &run(&f, &name("refs/meta/member/first"), Some(new)),
1036 AdmissionKind::Bootstrap,
1037 );
1038}
1039
1040#[rstest]
1041// @relation(gate.bootstrap, gate.identity-binding, scope=function, role=Verifies)
1042fn a_bootstrap_enrollment_naming_the_wrong_ref_is_refused() {
1043 // Even the self-admitting bootstrap write is bound by the member's own
1044 // id field, not a trailer: an enrollment whose id is `first` cannot
1045 // land on `refs/meta/member/other`.
1046 let f = bare_forge_with_epoch();
1047 let new = enrollment_proposal(&f, "first", &f.admin, &f.admin);
1048 expect_fail(
1049 &run(&f, &name("refs/meta/member/other"), Some(new)),
1050 Requirement::IdentityBinding,
1051 );
1052}
1053
1054#[rstest]
1055// @relation(gate.bootstrap, scope=function, role=Verifies)
1056fn bootstrap_enrollment_must_be_signed_by_the_key_it_enrolls() {
1057 let f = bare_forge_with_epoch();
1058 let other = Keypair::from_seed(OUTSIDER_SEED);
1059 let new = enrollment_proposal(&f, "first", &f.admin, &other);
1060 expect_fail(
1061 &run(&f, &name("refs/meta/member/first"), Some(new)),
1062 Requirement::TipSigned,
1063 );
1064}
1065
1066#[rstest]
1067// @relation(gate.bootstrap, scope=function, role=Verifies)
1068fn bootstrap_admits_only_enrollments() {
1069 let f = bare_forge_with_epoch();
1070 let new = commit(&f, vec![], Some(&f.admin), 100);
1071 expect_fail(&run(&f, &issue_ref(new), Some(new)), Requirement::TipSigned);
1072}
1073
1074#[rstest]
1075// @relation(gate.bootstrap, model.member-revocation, scope=function, role=Verifies)
1076fn revoking_every_key_does_not_reopen_the_bootstrap_window() {
1077 let f = forge();
1078 for id in ["admin", "guest"] {
1079 let key = if id == "admin" { &f.admin } else { &f.guest };
1080 let provenance = if id == "admin" {
1081 Provenance::AdminRegistered
1082 } else {
1083 Provenance::SelfAttested
1084 };
1085 let mut member = Member::new(id, key.public_openssh(), provenance);
1086 member.revoke();
1087 write_member(&f.refs, &f.objects, id, &member, Some(&f.admin), 400);
1088 }
1089 // A would-be new member self-enrolling: the member set is non-empty
1090 // (though fully revoked), so the self-admitting window stays shut.
1091 let newcomer = Keypair::from_seed(OUTSIDER_SEED);
1092 let new = enrollment_proposal(&f, "newcomer", &newcomer, &newcomer);
1093 expect_fail(
1094 &run(&f, &name("refs/meta/member/newcomer"), Some(new)),
1095 Requirement::TipSigned,
1096 );
1097 // And the fully-revoked members cannot write anything either.
1098 let attempt = commit(&f, vec![], Some(&f.admin), 500);
1099 expect_fail(
1100 &run(&f, &issue_ref(attempt), Some(attempt)),
1101 Requirement::TipSigned,
1102 );
1103}
1104
1105// ---------------------------------------------------------------------
1106// CAS binding, call sites, and offline reproducibility.
1107// ---------------------------------------------------------------------
1108
1109#[rstest]
1110// @relation(gate.atomic-cas, scope=function, role=Verifies)
1111fn the_admission_carries_the_cas_precondition_from_the_same_read() {
1112 let f = forge();
1113
1114 // Creation: the ref must still be absent at write time.
1115 let created = commit(&f, vec![], Some(&f.admin), 300);
1116 let refname = issue_ref(created);
1117 let Verdict::Pass(admission) = run(&f, &refname, Some(created)) else {
1118 panic!("expected a pass");
1119 };
1120 assert_eq!(admission.cas, Expected::MustNotExist);
1121
1122 // Update: the precondition is exactly the old tip the FF check used.
1123 f.refs.set(refname.as_ref(), created);
1124 let advanced = commit(&f, vec![created], Some(&f.admin), 320);
1125 let Verdict::Pass(admission) = run(&f, &refname, Some(advanced)) else {
1126 panic!("expected a pass");
1127 };
1128 assert_eq!(admission.cas, Expected::MustExistAndMatch(created));
1129}
1130
1131/// Every scenario the verdict table distinguishes, evaluated the way each
1132/// of the three call sites would evaluate it, in one parameterized test.
1133#[rstest]
1134#[case::authorized_pass(true, true)]
1135#[case::unsigned_fail(false, true)]
1136#[case::unauthorized_namespace_guest(true, false)]
1137// @relation(gate.call-sites, gate.mandatory-hosted, gate.advisory-local, scope=function, role=Verifies)
1138fn all_three_call_sites_return_identical_verdicts(#[case] signed: bool, #[case] as_admin: bool) {
1139 let f = forge();
1140 let key = if as_admin { &f.admin } else { &f.guest };
1141 let new = commit(&f, vec![], signed.then_some(key), 300);
1142 let update = Update {
1143 name: issue_ref(new),
1144 new: Some(new),
1145 };
1146
1147 // Call site 1: hosted CAS time (mandatory — the caller aborts on Fail).
1148 let hosted = verify(&f.refs, &f.objects, &update).expect("verdict");
1149 // Call site 2: the local UI verdict (advisory — annotates the write).
1150 let local = verify(&f.refs, &f.objects, &update).expect("verdict");
1151 // Call site 3: push pre-flight, against a fetched copy of the refs.
1152 let fetched = f.refs.fetched_copy();
1153 let preflight = verify(&fetched, &f.objects, &update).expect("verdict");
1154
1155 assert_eq!(hosted, local, "hosted vs local");
1156 assert_eq!(hosted, preflight, "hosted vs pre-flight");
1157}
1158
1159#[rstest]
1160// @relation(gate.signature-artifact, gate.policy-as-state, scope=function, role=Verifies)
1161fn verdicts_reproduce_offline_from_repository_state_alone() {
1162 let build = || {
1163 let f = forge();
1164 let new = commit(&f, vec![], Some(&f.admin), 300);
1165 (f, new)
1166 };
1167 let (origin, new_at_origin) = build();
1168 let (clone, new_at_clone) = build();
1169 assert_eq!(new_at_origin, new_at_clone, "deterministic fixtures");
1170
1171 let update = Update {
1172 name: issue_ref(new_at_origin),
1173 new: Some(new_at_origin),
1174 };
1175 let at_origin = verify(&origin.refs, &origin.objects, &update).expect("verdict");
1176 let at_clone = verify(&clone.refs, &clone.objects, &update).expect("verdict");
1177 assert_eq!(at_origin, at_clone);
1178}
1179
1180#[rstest]
1181// @relation(gate.verdict-reason, gate.advisory-local, scope=function, role=Verifies)
1182fn refusals_render_an_actionable_reason_with_the_inbox_alternative() {
1183 let f = forge();
1184 let new = commit(&f, vec![], Some(&f.guest), 300);
1185 let refname = issue_ref(new);
1186 let Verdict::Fail(refusal) = run(&f, &refname, Some(new)) else {
1187 panic!("self-attested member on a canonical ref must be refused");
1188 };
1189 let rendered = refusal.to_string();
1190 assert!(
1191 rendered.contains("gate.tip-signed"),
1192 "names the rule: {rendered}"
1193 );
1194 assert!(
1195 rendered.contains("refs/meta/issues/"),
1196 "names the subject ref: {rendered}"
1197 );
1198 assert!(
1199 rendered.contains("refs/meta/inbox"),
1200 "surfaces the inbox alternative at verdict time: {rendered}"
1201 );
1202}
1203
1204#[rstest]
1205// @relation(gate.epoch, scope=function, role=Verifies)
1206fn config_round_trips_with_and_without_an_epoch() {
1207 for config in [Config { epoch: None }, Config { epoch: Some(42) }] {
1208 let (root, store) = facet_git_tree::serialize(&config).expect("serialize");
1209 let back: Config = facet_git_tree::deserialize(&root, &store).expect("deserialize");
1210 assert_eq!(back, config);
1211 }
1212}
1213
1214#[rstest]
1215fn fixture_stores_read_back_what_they_seed() {
1216 let f = forge();
1217 let member_ref = name("refs/meta/member/admin");
1218 assert!(f.refs.get(member_ref.as_ref()).expect("readable").is_some());
1219}