git-ents.gitmain
⌘K
foforge
commit eb79d4a
gate: retarget the kernel test suites at identity binding

Rewrite the gate, receive, sync, and query integration tests for oid-keyed hash-identified refnames and trailer-free commits, and add the phase-10 identity cases: the doppelgänger replay (a signed mutation re-proposed as a genesis) and the result replay (a signed pass for another effect or commit) are refused through receive; the all-roots walk holds across a sync-created merge and is never applied to a pin; natural-key (member, effect) and composite (review, result) refnames recompute; strict decode refuses an unknown genesis tree entry; owner-mutation refuses the wrong reviewer and a non-owner advance; creation via the inbox still works.

test: doppelgänger + result replay refused via receive (gate.identity-binding, model.result-identity) test: all-roots across a merge; pins skip the walk (meta-ref.identity-binding) test: natural-key/composite recompute and strict decode (gate.identity-binding) test: owner-mutation review + comment advance cases (gate.owner-mutation) Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/kernel/ents-gate/tests/gate.rs @@ -1,7 +1,7 @@ //! Integration tests for the gate: the verdict table (rstest — the spec -//! enumerates the cases), the epoch and bootstrap windows, and the -//! one-parameterized-test proof that all three call sites see identical -//! verdicts. +//! enumerates the cases), identity binding and owner mutation, the epoch +//! and bootstrap windows, and the one-parameterized-test proof that all +//! three call sites see identical verdicts. #![expect( clippy::expect_used, @@ -11,8 +11,7 @@ )] use ents_gate::{AdmissionKind, Config, Requirement, Update, Verdict, verify}; -use ents_model::trailer::Trailers; -use ents_model::{Member, MemberId, Provenance, namespace}; +use ents_model::{Effect, Member, MemberId, Provenance, ResultRecord, Status, namespace}; use ents_testutil::{ CommitSpec, Keypair, MemRefStore, ObjectStore, empty_tree, enroll_member, write_commit, write_member, write_meta_entity, @@ -78,43 +77,57 @@ s.try_into().expect("valid refname in test") } -/// A proposal commit: empty tree, explicit parents/trailer/key/time. -fn proposal( - forge: &Forge, - parents: Vec<ObjectId>, - ents_ref: Option<&str>, - key: Option<&Keypair>, - seconds: i64, -) -> ObjectId { +/// The oid-keyed refname of a hash-identified issue whose genesis is +/// `genesis` (`meta-ref.identity-binding`). +fn issue_ref(genesis: ObjectId) -> FullName { + name(&format!("refs/meta/issues/{genesis}")) +} + +/// A signed empty-tree commit — a generic meta-mutation body. A +/// hash-identified entity binds by its genesis oid and the all-roots +/// walk, not by tree content, so an empty tree exercises the tip +/// invariant on `refs/meta/issues/*` and `refs/meta/comments/*` fully. +fn commit(forge: &Forge, parents: Vec<ObjectId>, key: Option<&Keypair>, seconds: i64) -> ObjectId { let tree = empty_tree(&forge.objects); - let message = ents_ref.map_or_else( - || "mutate\n\nno trailer here\n".to_owned(), - |r| { - let trailers = Trailers { - ents_ref: Some(name(r)), - schema_version: None, - }; - format!("mutate\n\n{}", trailers.render()) - }, - ); write_commit( &forge.objects, &CommitSpec { tree, parents, - message, + message: "mutate".into(), seconds, }, key, ) } -fn run(forge: &Forge, refname: &str, new: Option<ObjectId>) -> Verdict { +/// A signed commit whose tree is `tree` — for the namespaces whose binding +/// reads a tree field (natural-key, composite). +fn tree_commit( + forge: &Forge, + tree: ObjectId, + parents: Vec<ObjectId>, + key: Option<&Keypair>, + seconds: i64, +) -> ObjectId { + write_commit( + &forge.objects, + &CommitSpec { + tree, + parents, + message: "mutate".into(), + seconds, + }, + key, + ) +} + +fn run(forge: &Forge, refname: &FullName, new: Option<ObjectId>) -> Verdict { verify( &forge.refs, &forge.objects, &Update { - name: name(refname), + name: refname.clone(), new, }, ) @@ -136,16 +149,16 @@ } // --------------------------------------------------------------------- -// The verdict table: member × trailer × FF × namespace cases. +// The verdict table: member × signature × FF × namespace cases. // --------------------------------------------------------------------- #[rstest] // @relation(gate.tip-signed, gate.verdict-reason, scope=function, role=Verifies) fn authorized_signed_mutation_passes_the_tip_invariant() { let f = forge(); - let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 300); + let new = commit(&f, vec![], Some(&f.admin), 300); expect_pass( - &run(&f, "refs/meta/issues/1", Some(new)), + &run(&f, &issue_ref(new), Some(new)), AdmissionKind::TipInvariant, ); } @@ -154,11 +167,8 @@ // @relation(gate.tip-signed, scope=function, role=Verifies) fn unsigned_tip_is_refused() { let f = forge(); - let new = proposal(&f, vec![], Some("refs/meta/issues/1"), None, 300); - expect_fail( - &run(&f, "refs/meta/issues/1", Some(new)), - Requirement::TipSigned, - ); + let new = commit(&f, vec![], None, 300); + expect_fail(&run(&f, &issue_ref(new), Some(new)), Requirement::TipSigned); } #[rstest] @@ -166,17 +176,14 @@ fn non_member_signature_is_refused() { let f = forge(); let outsider = Keypair::from_seed(OUTSIDER_SEED); - let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&outsider), 300); - expect_fail( - &run(&f, "refs/meta/issues/1", Some(new)), - Requirement::TipSigned, - ); + let new = commit(&f, vec![], Some(&outsider), 300); + expect_fail(&run(&f, &issue_ref(new), Some(new)), Requirement::TipSigned); } /// Revoke `id`'s key in the fixture, as an admin-signed mutation of the /// member's ref. fn revoke(f: &Forge, id: &str, key: &Keypair, provenance: Provenance, seconds: i64) { - let mut revoked = Member::new(key.public_openssh(), provenance); + let mut revoked = Member::new(id, key.public_openssh(), provenance); revoked.revoke(); write_member(&f.refs, &f.objects, id, &revoked, Some(&f.admin), seconds); } @@ -187,8 +194,8 @@ let f = forge(); revoke(&f, "admin", &f.admin, Provenance::AdminRegistered, 400); - let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 500); - let verdict = run(&f, "refs/meta/issues/1", Some(new)); + let new = commit(&f, vec![], Some(&f.admin), 500); + let verdict = run(&f, &issue_ref(new), Some(new)); expect_fail(&verdict, Requirement::TipSigned); let Verdict::Fail(refusal) = &verdict else { unreachable!() @@ -200,35 +207,26 @@ // @relation(model.member-revocation, gate.tip-signed, scope=function, role=Verifies) fn a_backdated_commit_cannot_reach_past_a_revocation() { // The security-review regression: admission consults the member - // entity currently in force, so a revoked key authoring a NEW - // commit with a committer timestamp claimed from before the - // revocation — descending cleanly from the live tip — is still - // refused. No commit-supplied time participates in the judgment. + // entity currently in force, so a revoked key authoring a NEW commit + // with a committer timestamp claimed from before the revocation — + // descending cleanly from the live tip — is still refused. let f = forge(); - let refname = "refs/meta/issues/1"; - let tip = write_meta_entity( - &f.refs, - &f.objects, - name(refname), - &ents_model::Status::Pass, - Some(&f.admin), - 300, - ); + let genesis = commit(&f, vec![], Some(&f.admin), 300); + let refname = issue_ref(genesis); + f.refs.set(refname.as_ref(), genesis); revoke(&f, "admin", &f.admin, Provenance::AdminRegistered, 400); // Authored "at 300", pushed after the revocation at 400. - let backdated = proposal(&f, vec![tip], Some(refname), Some(&f.admin), 300); - expect_fail(&run(&f, refname, Some(backdated)), Requirement::TipSigned); + let backdated = commit(&f, vec![genesis], Some(&f.admin), 300); + expect_fail(&run(&f, &refname, Some(backdated)), Requirement::TipSigned); } #[rstest] // @relation(model.member-revocation, gate.fast-forward, scope=function, role=Verifies) fn refs_accepted_before_a_revocation_are_never_rejudged() { - // A second admin keeps working on a ref whose current tip was - // placed by a member revoked afterwards: the accepted tip stays - // valid history — the gate judges only the proposed update, and - // fast-forwarding over ancestry signed by a now-revoked key is - // ordinary descent, not a re-judgment of past acceptance. + // A second admin keeps working on a ref whose current tip was placed + // by a member revoked afterwards: the accepted tip stays valid + // history, and fast-forwarding over it is ordinary descent. let f = forge(); let second = Keypair::from_seed(OUTSIDER_SEED); enroll_member( @@ -240,34 +238,33 @@ 310, ); - let refname = "refs/meta/issues/1"; - let tip = write_meta_entity( - &f.refs, - &f.objects, - name(refname), - &ents_model::Status::Pass, - Some(&f.admin), - 320, - ); + let genesis = commit(&f, vec![], Some(&f.admin), 320); + let refname = issue_ref(genesis); + f.refs.set(refname.as_ref(), genesis); revoke(&f, "admin", &f.admin, Provenance::AdminRegistered, 400); - let continued = proposal(&f, vec![tip], Some(refname), Some(&second), 500); + let continued = commit(&f, vec![genesis], Some(&second), 500); expect_pass( - &run(&f, refname, Some(continued)), + &run(&f, &refname, Some(continued)), AdmissionKind::TipInvariant, ); } #[rstest] -#[case::wrong_ref(Some("refs/meta/issues/2"))] -#[case::missing_trailer(None)] -// @relation(gate.refname-binding, scope=function, role=Verifies) -fn refname_binding_mismatch_is_refused(#[case] trailer: Option<&str>) { +// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies) +fn a_refname_not_naming_the_genesis_oid_is_refused() { + // The identity binding replaces the retired Advance-ref trailer: a + // signed commit proposed under a refname whose final segment is not + // its genesis oid is refused, so it cannot be replayed as the tip of a + // different meta-ref than the one its content names. let f = forge(); - let new = proposal(&f, vec![], trailer, Some(&f.admin), 300); + let genesis = commit(&f, vec![], Some(&f.admin), 300); + // A different, valid oid that is not this commit's genesis. + let wrong = + issue_ref(ObjectId::from_hex(b"00000000000000000000000000000000deadbeef").expect("hex")); expect_fail( - &run(&f, "refs/meta/issues/1", Some(new)), - Requirement::RefnameBinding, + &run(&f, &wrong, Some(genesis)), + Requirement::IdentityBinding, ); } @@ -275,51 +272,352 @@ // @relation(gate.fast-forward, scope=function, role=Verifies) fn non_fast_forward_is_refused() { let f = forge(); - let refname = "refs/meta/issues/1"; - let tip = write_meta_entity( - &f.refs, - &f.objects, - name(refname), - &ents_model::Status::Pass, - Some(&f.admin), - 300, - ); - // A sibling that does not descend from `tip`. - let sibling = proposal(&f, vec![], Some(refname), Some(&f.admin), 310); - assert_ne!(sibling, tip); - expect_fail(&run(&f, refname, Some(sibling)), Requirement::FastForward); + // A genesis and two children of it, all correctly bound to the same + // oid-keyed ref; the current tip is one child, the proposal the + // sibling — a genuine fast-forward refusal, not an identity mismatch. + let genesis = commit(&f, vec![], Some(&f.admin), 300); + let refname = issue_ref(genesis); + let current = commit(&f, vec![genesis], Some(&f.admin), 310); + let sibling = commit(&f, vec![genesis], Some(&f.admin), 311); + f.refs.set(refname.as_ref(), current); + assert_ne!(sibling, current); + expect_fail(&run(&f, &refname, Some(sibling)), Requirement::FastForward); } #[rstest] // @relation(gate.fast-forward, scope=function, role=Verifies) fn meta_ref_deletion_is_refused() { let f = forge(); - write_meta_entity( - &f.refs, - &f.objects, - name("refs/meta/issues/1"), - &ents_model::Status::Pass, - Some(&f.admin), - 300, - ); - expect_fail( - &run(&f, "refs/meta/issues/1", None), - Requirement::FastForward, - ); + let genesis = commit(&f, vec![], Some(&f.admin), 300); + let refname = issue_ref(genesis); + f.refs.set(refname.as_ref(), genesis); + expect_fail(&run(&f, &refname, None), Requirement::FastForward); } #[rstest] // @relation(gate.principled-split, scope=function, role=Verifies) fn code_refs_are_not_subject_to_the_tip_invariant() { let f = forge(); - // Unsigned, trailerless, non-FF — none of it matters outside refs/meta/*. - let new = proposal(&f, vec![], None, None, 300); + // Unsigned, non-FF — none of it matters outside refs/meta/*. + let new = commit(&f, vec![], None, 300); expect_pass( - &run(&f, "refs/heads/main", Some(new)), + &run(&f, &name("refs/heads/main"), Some(new)), AdmissionKind::CodeRef, ); } +// --------------------------------------------------------------------- +// Identity binding: the refname is a function of signed content. +// --------------------------------------------------------------------- + +#[rstest] +// @relation(gate.identity-binding, meta-ref.identity-binding, model.member-identity, scope=function, role=Verifies) +fn a_natural_key_member_binds_by_its_id_field() { + let f = forge(); + // A member entity whose id field disagrees with the refname's final + // segment is refused; agreeing, it passes. + let mismatched = Member::new( + "someone-else", + f.admin.public_openssh(), + Provenance::AdminRegistered, + ); + let tree = facet_git_tree::serialize_into(&mismatched, &f.objects).expect("ser"); + let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300); + expect_fail( + &run(&f, &name("refs/meta/member/newcomer"), Some(tip)), + Requirement::IdentityBinding, + ); + + let matched = Member::new( + "newcomer", + f.admin.public_openssh(), + Provenance::AdminRegistered, + ); + let tree = facet_git_tree::serialize_into(&matched, &f.objects).expect("ser"); + let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300); + expect_pass( + &run(&f, &name("refs/meta/member/newcomer"), Some(tip)), + AdmissionKind::TipInvariant, + ); +} + +#[rstest] +// @relation(gate.identity-binding, meta-ref.identity-binding, model.effect-definition, scope=function, role=Verifies) +fn a_natural_key_effect_binds_by_its_name_field() { + let f = forge(); + let effect = Effect { + name: "unit".into(), + trigger: "rev(refs/heads/main)".into(), + toolchains: vec![], + run: "true".into(), + }; + let tree = facet_git_tree::serialize_into(&effect, &f.objects).expect("ser"); + // Named `unit` in the tree — refused under a ref that names `lint`. + let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300); + expect_fail( + &run(&f, &name("refs/meta/effects/lint"), Some(tip)), + Requirement::IdentityBinding, + ); + // Under the ref its name field recomputes, it passes. + expect_pass( + &run(&f, &name("refs/meta/effects/unit"), Some(tip)), + AdmissionKind::TipInvariant, + ); +} + +/// A commit recording `result` at `results/<effect>/<short>`. +fn result_commit( + f: &Forge, + effect: &str, + target: ObjectId, + parents: Vec<ObjectId>, + key: Option<&Keypair>, + seconds: i64, +) -> ObjectId { + let record = ResultRecord::new(effect, target, Status::Pass); + let tree = facet_git_tree::serialize_into(&record, &f.objects).expect("ser"); + tree_commit(f, tree, parents, key, seconds) +} + +#[rstest] +// @relation(gate.identity-binding, model.result-identity, scope=function, role=Verifies) +fn a_composite_result_binds_by_its_effect_and_target_fields() { + let f = forge(); + let target = ObjectId::from_hex(b"abc1230000000000000000000000000000000000").expect("hex"); + let tip = result_commit(&f, "unit", target, vec![], Some(&f.admin), 300); + + // Correct effect and short-oid prefix: passes. + expect_pass( + &run( + &f, + &namespace::result_ref("unit", "abc123").expect("valid"), + Some(tip), + ), + AdmissionKind::TipInvariant, + ); + // Wrong effect segment: refused. + expect_fail( + &run( + &f, + &namespace::result_ref("lint", "abc123").expect("valid"), + Some(tip), + ), + Requirement::IdentityBinding, + ); + // Short oid that is not a prefix of the target field: refused. + expect_fail( + &run( + &f, + &namespace::result_ref("unit", "ffffff").expect("valid"), + Some(tip), + ), + Requirement::IdentityBinding, + ); +} + +/// A stand-in Review tree: the gate reads only the `target` field +/// generically, so any struct carrying it exercises the composite key. +#[derive(facet::Facet)] +struct Review { + target: [u8; 20], + verdict: String, +} + +fn review_commit(f: &Forge, target: ObjectId, key: Option<&Keypair>, seconds: i64) -> ObjectId { + let mut bytes = [0u8; 20]; + bytes.copy_from_slice(target.as_slice()); + let review = Review { + target: bytes, + verdict: "approve".into(), + }; + let tree = facet_git_tree::serialize_into(&review, &f.objects).expect("ser"); + tree_commit(f, tree, vec![], key, seconds) +} + +#[rstest] +// @relation(gate.identity-binding, model.review, scope=function, role=Verifies) +fn a_composite_review_binds_by_its_target_field_and_signer() { + let f = forge(); + let target = ObjectId::from_hex(b"deadbeef00000000000000000000000000000000").expect("hex"); + let tip = review_commit(&f, target, Some(&f.admin), 300); + let good = namespace::review_ref(&target.to_string(), &MemberId::new("admin")).expect("valid"); + expect_pass(&run(&f, &good, Some(tip)), AdmissionKind::TipInvariant); + + // Right target, wrong reviewer segment: the signer is admin, not guest. + let wrong_member = + namespace::review_ref(&target.to_string(), &MemberId::new("guest")).expect("valid"); + expect_fail( + &run(&f, &wrong_member, Some(tip)), + Requirement::IdentityBinding, + ); + + // Right reviewer, wrong target segment. + let other = ObjectId::from_hex(b"0000000000000000000000000000000000000001").expect("hex"); + let wrong_target = + namespace::review_ref(&other.to_string(), &MemberId::new("admin")).expect("valid"); + expect_fail( + &run(&f, &wrong_target, Some(tip)), + Requirement::IdentityBinding, + ); +} + +/// A result tree carrying an entry that is not a `ResultRecord` field — +/// for the strict-decode disjointness check. +#[derive(facet::Facet)] +struct ResultPlus { + effect: String, + target: [u8; 20], + status: Status, + surprise: String, +} + +#[rstest] +// @relation(gate.identity-binding, meta-ref.typed-tree, scope=function, role=Verifies) +fn strict_genesis_decode_refuses_an_unknown_tree_entry() { + let f = forge(); + let target = ObjectId::from_hex(b"abc1230000000000000000000000000000000000").expect("hex"); + let mut bytes = [0u8; 20]; + bytes.copy_from_slice(target.as_slice()); + let bogus = ResultPlus { + effect: "unit".into(), + target: bytes, + status: Status::Pass, + surprise: "not a result field".into(), + }; + let tree = facet_git_tree::serialize_into(&bogus, &f.objects).expect("ser"); + let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300); + // Even though effect and short-oid recompute, the extra tree entry + // makes strict decode refuse the genesis. + expect_fail( + &run( + &f, + &namespace::result_ref("unit", "abc123").expect("valid"), + Some(tip), + ), + Requirement::IdentityBinding, + ); +} + +#[rstest] +// @relation(gate.identity-binding, gate.same-actor-divergence, scope=function, role=Verifies) +fn the_all_roots_walk_holds_across_a_sync_created_merge() { + // Two children of the same genesis, merged: the merge has two parents + // but a single parentless root (the genesis), so the hash-identified + // binding still recomputes the genesis oid across the merge commit. + let f = forge(); + let genesis = commit(&f, vec![], Some(&f.admin), 300); + let refname = issue_ref(genesis); + let a = commit(&f, vec![genesis], Some(&f.admin), 310); + let b = commit(&f, vec![genesis], Some(&f.admin), 311); + f.refs.set(refname.as_ref(), a); + let merge = commit(&f, vec![a, b], Some(&f.admin), 320); + expect_pass(&run(&f, &refname, Some(merge)), AdmissionKind::TipInvariant); + + // The same merge proposed under a doppelgänger genesis id (the merge's + // own oid) is refused: its parentless root is still the genesis. + expect_fail( + &run(&f, &issue_ref(merge), Some(merge)), + Requirement::IdentityBinding, + ); +} + +#[rstest] +// @relation(gate.identity-binding, model.review-pin, scope=function, role=Verifies) +fn a_pin_is_never_subjected_to_the_all_roots_walk() { + // A pin's ancestry reaches into code history (its parents include the + // reviewed commit), so the parentless-roots walk must not apply. A pin + // commit whose parent is an arbitrary reviewed commit — not a genesis + // whose oid is the pin's id — still binds. + let f = forge(); + let reviewed = commit(&f, vec![], Some(&f.admin), 250); + let target = reviewed.to_string(); + let pin_ref = namespace::review_pin_ref(&target, &MemberId::new("admin")).expect("valid"); + // The pin's tip is a signed commit whose parents include the reviewed + // commit; its tree is empty. The all-roots walk would reject it (the + // root is `reviewed`, not the pin's segments), so this passing verdict + // proves the walk is skipped for pins. + let pin_tip = commit(&f, vec![reviewed], Some(&f.admin), 300); + expect_pass( + &run(&f, &pin_ref, Some(pin_tip)), + AdmissionKind::TipInvariant, + ); +} + +// --------------------------------------------------------------------- +// Owner mutation: an advance is keyed to ownership. +// --------------------------------------------------------------------- + +#[rstest] +// @relation(gate.owner-mutation, scope=function, role=Verifies) +fn an_admin_may_advance_another_members_hash_identified_entity() { + // Ownership of a hash-identified entity is intrinsic to its id, but an + // admin-registered member may advance it too (∪ admins). A second + // admin advances the first admin's comment. + let f = forge(); + let second = Keypair::from_seed(OUTSIDER_SEED); + enroll_member( + &f.refs, + &f.objects, + "second", + &second, + Provenance::AdminRegistered, + 210, + ); + + let genesis = commit(&f, vec![], Some(&f.admin), 300); + let refname = name(&format!("refs/meta/comments/{genesis}")); + f.refs.set(refname.as_ref(), genesis); + let advance = commit(&f, vec![genesis], Some(&second), 310); + expect_pass( + &run(&f, &refname, Some(advance)), + AdmissionKind::TipInvariant, + ); +} + +#[rstest] +// @relation(gate.owner-mutation, model.member-provenance, scope=function, role=Verifies) +fn a_self_attested_non_owner_cannot_advance_a_comment() { + // A self-attested member is not authorized for canonical refs at all + // (creation stays provenance-keyed, routed to the inbox); it therefore + // cannot advance someone else's comment either. + let f = forge(); + let genesis = commit(&f, vec![], Some(&f.admin), 300); + let refname = name(&format!("refs/meta/comments/{genesis}")); + f.refs.set(refname.as_ref(), genesis); + let advance = commit(&f, vec![genesis], Some(&f.guest), 310); + let verdict = run(&f, &refname, Some(advance)); + expect_fail(&verdict, Requirement::TipSigned); +} + +#[rstest] +// @relation(gate.owner-mutation, model.review, scope=function, role=Verifies) +fn the_wrong_member_cannot_advance_a_review() { + // A review advances only under the member its refname names. `guest` + // (were it registered) could not advance admin's review; here we prove + // the composite key + owner rule refuse a mismatched signer. + let f = forge(); + let second = Keypair::from_seed(OUTSIDER_SEED); + enroll_member( + &f.refs, + &f.objects, + "second", + &second, + Provenance::AdminRegistered, + 210, + ); + let target = ObjectId::from_hex(b"deadbeef00000000000000000000000000000000").expect("hex"); + // A review ref named for admin, but signed by `second`. + let tip = review_commit(&f, target, Some(&second), 300); + let refname = + namespace::review_ref(&target.to_string(), &MemberId::new("admin")).expect("valid"); + let verdict = run(&f, &refname, Some(tip)); + let Verdict::Fail(_) = verdict else { + panic!( + "a review signed by a member other than the one it names must be refused: {verdict:?}" + ); + }; +} + // --------------------------------------------------------------------- // Provenance-keyed authorization. // --------------------------------------------------------------------- @@ -334,9 +632,11 @@ #[case] refname: &str, #[case] inbox_alternative: bool, ) { + // Authorization is judged before identity binding, so an empty-tree + // commit under any of these canonical refs is refused on provenance. let f = forge(); - let new = proposal(&f, vec![], Some(refname), Some(&f.guest), 300); - let verdict = run(&f, refname, Some(new)); + let new = commit(&f, vec![], Some(&f.guest), 300); + let verdict = run(&f, &name(refname), Some(new)); expect_fail(&verdict, Requirement::TipSigned); let Verdict::Fail(refusal) = &verdict else { unreachable!() @@ -351,9 +651,11 @@ // @relation(meta-ref.inbox, effect.self-run, gate.tip-signed, scope=function, role=Verifies) fn a_member_may_write_its_own_self_run_namespace() { let f = forge(); - let refname = "refs/meta/self/guest/unit/abc123"; - let new = proposal(&f, vec![], Some(refname), Some(&f.guest), 300); - expect_pass(&run(&f, refname, Some(new)), AdmissionKind::TipInvariant); + let target = ObjectId::from_hex(b"abc1230000000000000000000000000000000000").expect("hex"); + let tip = result_commit(&f, "unit", target, vec![], Some(&f.guest), 300); + let refname = + namespace::self_result_ref(&MemberId::new("guest"), "unit", "abc123").expect("valid"); + expect_pass(&run(&f, &refname, Some(tip)), AdmissionKind::TipInvariant); } #[rstest] @@ -370,8 +672,8 @@ ) { let f = forge(); let key = if as_admin { &f.admin } else { &f.guest }; - let new = proposal(&f, vec![], Some(refname), Some(key), 300); - let verdict = run(&f, refname, Some(new)); + let new = commit(&f, vec![], Some(key), 300); + let verdict = run(&f, &name(refname), Some(new)); if admitted { expect_pass(&verdict, AdmissionKind::TipInvariant); } else { @@ -380,13 +682,21 @@ } #[rstest] -#[case::effects("refs/meta/effects/unit")] -#[case::results("refs/meta/results/unit/abc")] // @relation(effect.admin-only, gate.tip-signed, scope=function, role=Verifies) -fn admin_registered_members_may_write_canonical_namespaces(#[case] refname: &str) { +fn admin_registered_members_may_write_the_effects_namespace() { let f = forge(); - let new = proposal(&f, vec![], Some(refname), Some(&f.admin), 300); - expect_pass(&run(&f, refname, Some(new)), AdmissionKind::TipInvariant); + let effect = Effect { + name: "unit".into(), + trigger: "rev(refs/heads/main)".into(), + toolchains: vec![], + run: "true".into(), + }; + let tree = facet_git_tree::serialize_into(&effect, &f.objects).expect("ser"); + let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300); + expect_pass( + &run(&f, &name("refs/meta/effects/unit"), Some(tip)), + AdmissionKind::TipInvariant, + ); } // --------------------------------------------------------------------- @@ -397,68 +707,45 @@ // @relation(gate.adoption-merge, scope=function, role=Verifies) fn adoption_is_a_merge_that_keeps_the_contributor_commit_in_ancestry() { let f = forge(); - let refname = "refs/meta/comments/c1"; - let tip = write_meta_entity( - &f.refs, - &f.objects, - name(refname), - &ents_model::Status::Pass, - Some(&f.admin), - 300, - ); + let genesis = commit(&f, vec![], Some(&f.admin), 300); + let refname = name(&format!("refs/meta/comments/{genesis}")); + f.refs.set(refname.as_ref(), genesis); // The contributor's own signed commit, not authorized for this ref. - let contributed = proposal(&f, vec![tip], Some(refname), Some(&f.guest), 310); + let contributed = commit(&f, vec![genesis], Some(&f.guest), 310); // The authorized member merges it: the merge tip satisfies the // invariant; the contributor's signature survives in ancestry. - let merge = proposal( - &f, - vec![tip, contributed], - Some(refname), - Some(&f.admin), - 320, - ); - expect_pass(&run(&f, refname, Some(merge)), AdmissionKind::TipInvariant); + let merge = commit(&f, vec![genesis, contributed], Some(&f.admin), 320); + expect_pass(&run(&f, &refname, Some(merge)), AdmissionKind::TipInvariant); } #[rstest] // @relation(gate.adoption-no-fast-forward, scope=function, role=Verifies) fn fast_forwarding_to_a_contributor_commit_is_not_adoption() { let f = forge(); - let refname = "refs/meta/comments/c1"; - let tip = write_meta_entity( - &f.refs, - &f.objects, - name(refname), - &ents_model::Status::Pass, - Some(&f.admin), - 300, + let genesis = commit(&f, vec![], Some(&f.admin), 300); + let refname = name(&format!("refs/meta/comments/{genesis}")); + f.refs.set(refname.as_ref(), genesis); + let contributed = commit(&f, vec![genesis], Some(&f.guest), 310); + // Descends fine — but the tip signature is the contributor's, and the + // contributor is not authorized for this refname. + expect_fail( + &run(&f, &refname, Some(contributed)), + Requirement::TipSigned, ); - let contributed = proposal(&f, vec![tip], Some(refname), Some(&f.guest), 310); - // Descends fine — but the tip signature is the contributor's, and - // the contributor is not authorized for this refname. - expect_fail(&run(&f, refname, Some(contributed)), Requirement::TipSigned); } #[rstest] // @relation(gate.same-actor-divergence, scope=function, role=Verifies) fn a_members_own_divergent_heads_merge_cleanly() { let f = forge(); - let refname = "refs/meta/issues/1"; - let tip = write_meta_entity( - &f.refs, - &f.objects, - name(refname), - &ents_model::Status::Pass, - Some(&f.admin), - 300, - ); + let genesis = commit(&f, vec![], Some(&f.admin), 300); + let refname = issue_ref(genesis); + f.refs.set(refname.as_ref(), genesis); // Two of the member's own machines raced the single-writer ref. - let a = proposal(&f, vec![tip], Some(refname), Some(&f.admin), 310); - let b = proposal(&f, vec![tip], Some(refname), Some(&f.admin), 311); - // Either head alone is a non-fast-forward once the other landed; - // the resolution is the member merging their own heads. - let merge = proposal(&f, vec![a, b], Some(refname), Some(&f.admin), 320); - expect_pass(&run(&f, refname, Some(merge)), AdmissionKind::TipInvariant); + let a = commit(&f, vec![genesis], Some(&f.admin), 310); + let b = commit(&f, vec![genesis], Some(&f.admin), 311); + let merge = commit(&f, vec![a, b], Some(&f.admin), 320); + expect_pass(&run(&f, &refname, Some(merge)), AdmissionKind::TipInvariant); } // --------------------------------------------------------------------- @@ -477,9 +764,9 @@ guest: Keypair::from_seed(SELF_ATTESTED_SEED), }; // No config, no members: an unsigned meta write passes as pre-epoch. - let new = proposal(&f, vec![], None, None, 100); + let new = commit(&f, vec![], None, 100); expect_pass( - &run(&f, "refs/meta/issues/1", Some(new)), + &run(&f, &issue_ref(new), Some(new)), AdmissionKind::PreEpoch, ); } @@ -508,16 +795,12 @@ let tree = facet_git_tree::serialize_into(&Config { epoch: Some(200) }, &f.objects) .expect("config serializes"); let make = |key: Option<&Keypair>| { - let trailers = Trailers { - ents_ref: Some(name(namespace::CONFIG_REF)), - schema_version: None, - }; write_commit( &f.objects, &CommitSpec { tree, parents: vec![], - message: format!("enable verification\n\n{}", trailers.render()), + message: "enable verification".into(), seconds: 200, }, key, @@ -527,12 +810,12 @@ // Unsigned epoch-setting is refused: the circularity resolves by // gating the very commit that turns gating on. expect_fail( - &run(&f, namespace::CONFIG_REF, Some(make(None))), + &run(&f, &name(namespace::CONFIG_REF), Some(make(None))), Requirement::TipSigned, ); // Signed by an enrolled member, it passes under the tip invariant. expect_pass( - &run(&f, namespace::CONFIG_REF, Some(make(Some(&f.admin)))), + &run(&f, &name(namespace::CONFIG_REF), Some(make(Some(&f.admin)))), AdmissionKind::TipInvariant, ); } @@ -541,8 +824,7 @@ // Bootstrap: fail-closed empty-member-list handling. // --------------------------------------------------------------------- -/// A store with verification in force but no members at all — the shape -/// a hosted deployment initializes (`roots.bootstrap` owns hardening). +/// A store with verification in force but no members at all. fn bare_forge_with_epoch() -> Forge { let refs = MemRefStore::default(); let objects = ObjectStore::default(); @@ -564,19 +846,14 @@ } fn enrollment_proposal(f: &Forge, id: &str, enrolled: &Keypair, signer: &Keypair) -> ObjectId { - let member = Member::new(enrolled.public_openssh(), Provenance::AdminRegistered); + let member = Member::new(id, enrolled.public_openssh(), Provenance::AdminRegistered); let tree = facet_git_tree::serialize_into(&member, &f.objects).expect("member serializes"); - let refname = namespace::member_ref(&MemberId::new(id)).expect("valid id"); - let trailers = Trailers { - ents_ref: Some(refname), - schema_version: None, - }; write_commit( &f.objects, &CommitSpec { tree, parents: vec![], - message: format!("enroll {id}\n\n{}", trailers.render()), + message: format!("enroll {id}"), seconds: 100, }, Some(signer), @@ -589,11 +866,25 @@ let f = bare_forge_with_epoch(); let new = enrollment_proposal(&f, "first", &f.admin, &f.admin); expect_pass( - &run(&f, "refs/meta/member/first", Some(new)), + &run(&f, &name("refs/meta/member/first"), Some(new)), AdmissionKind::Bootstrap, ); } +#[rstest] +// @relation(gate.bootstrap, gate.identity-binding, scope=function, role=Verifies) +fn a_bootstrap_enrollment_naming_the_wrong_ref_is_refused() { + // Even the self-admitting bootstrap write is bound by the member's own + // id field, not a trailer: an enrollment whose id is `first` cannot + // land on `refs/meta/member/other`. + let f = bare_forge_with_epoch(); + let new = enrollment_proposal(&f, "first", &f.admin, &f.admin); + expect_fail( + &run(&f, &name("refs/meta/member/other"), Some(new)), + Requirement::IdentityBinding, + ); +} + #[rstest] // @relation(gate.bootstrap, scope=function, role=Verifies) fn bootstrap_enrollment_must_be_signed_by_the_key_it_enrolls() { @@ -601,7 +892,7 @@ let other = Keypair::from_seed(OUTSIDER_SEED); let new = enrollment_proposal(&f, "first", &f.admin, &other); expect_fail( - &run(&f, "refs/meta/member/first", Some(new)), + &run(&f, &name("refs/meta/member/first"), Some(new)), Requirement::TipSigned, ); } @@ -610,11 +901,8 @@ // @relation(gate.bootstrap, scope=function, role=Verifies) fn bootstrap_admits_only_enrollments() { let f = bare_forge_with_epoch(); - let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 100); - expect_fail( - &run(&f, "refs/meta/issues/1", Some(new)), - Requirement::TipSigned, - ); + let new = commit(&f, vec![], Some(&f.admin), 100); + expect_fail(&run(&f, &issue_ref(new), Some(new)), Requirement::TipSigned); } #[rstest] @@ -628,7 +916,7 @@ } else { Provenance::SelfAttested }; - let mut member = Member::new(key.public_openssh(), provenance); + let mut member = Member::new(id, key.public_openssh(), provenance); member.revoke(); write_member(&f.refs, &f.objects, id, &member, Some(&f.admin), 400); } @@ -637,13 +925,13 @@ let newcomer = Keypair::from_seed(OUTSIDER_SEED); let new = enrollment_proposal(&f, "newcomer", &newcomer, &newcomer); expect_fail( - &run(&f, "refs/meta/member/newcomer", Some(new)), + &run(&f, &name("refs/meta/member/newcomer"), Some(new)), Requirement::TipSigned, ); // And the fully-revoked members cannot write anything either. - let attempt = proposal(&f, vec![], Some("refs/meta/issues/9"), Some(&f.admin), 500); + let attempt = commit(&f, vec![], Some(&f.admin), 500); expect_fail( - &run(&f, "refs/meta/issues/9", Some(attempt)), + &run(&f, &issue_ref(attempt), Some(attempt)), Requirement::TipSigned, ); } @@ -656,53 +944,37 @@ // @relation(gate.atomic-cas, scope=function, role=Verifies) fn the_admission_carries_the_cas_precondition_from_the_same_read() { let f = forge(); - let refname = "refs/meta/issues/1"; // Creation: the ref must still be absent at write time. - let created = proposal(&f, vec![], Some(refname), Some(&f.admin), 300); - let Verdict::Pass(admission) = run(&f, refname, Some(created)) else { + let created = commit(&f, vec![], Some(&f.admin), 300); + let refname = issue_ref(created); + let Verdict::Pass(admission) = run(&f, &refname, Some(created)) else { panic!("expected a pass"); }; assert_eq!(admission.cas, Expected::MustNotExist); // Update: the precondition is exactly the old tip the FF check used. - let tip = write_meta_entity( - &f.refs, - &f.objects, - name(refname), - &ents_model::Status::Pass, - Some(&f.admin), - 310, - ); - let advanced = proposal(&f, vec![tip], Some(refname), Some(&f.admin), 320); - let Verdict::Pass(admission) = run(&f, refname, Some(advanced)) else { + f.refs.set(refname.as_ref(), created); + let advanced = commit(&f, vec![created], Some(&f.admin), 320); + let Verdict::Pass(admission) = run(&f, &refname, Some(advanced)) else { panic!("expected a pass"); }; - assert_eq!(admission.cas, Expected::MustExistAndMatch(tip)); + assert_eq!(admission.cas, Expected::MustExistAndMatch(created)); } -/// Every scenario the verdict table distinguishes, evaluated the way -/// each of the three call sites would evaluate it — hosted CAS on the -/// live store, local UI verdict on the same store, and push pre-flight -/// on a fetched copy of the refs — in one parameterized test: the gate -/// is one function, and its verdict is identical at every site. +/// Every scenario the verdict table distinguishes, evaluated the way each +/// of the three call sites would evaluate it, in one parameterized test. #[rstest] -#[case::authorized_pass("refs/meta/issues/1", true, true, 300)] -#[case::unsigned_fail("refs/meta/issues/1", false, true, 300)] -#[case::unauthorized_namespace("refs/meta/effects/unit", true, false, 300)] -#[case::self_run_pass("refs/meta/self/guest/unit/abc", true, false, 300)] +#[case::authorized_pass(true, true)] +#[case::unsigned_fail(false, true)] +#[case::unauthorized_namespace_guest(true, false)] // @relation(gate.call-sites, gate.mandatory-hosted, gate.advisory-local, scope=function, role=Verifies) -fn all_three_call_sites_return_identical_verdicts( - #[case] refname: &str, - #[case] signed: bool, - #[case] as_admin: bool, - #[case] seconds: i64, -) { +fn all_three_call_sites_return_identical_verdicts(#[case] signed: bool, #[case] as_admin: bool) { let f = forge(); let key = if as_admin { &f.admin } else { &f.guest }; - let new = proposal(&f, vec![], Some(refname), signed.then_some(key), seconds); + let new = commit(&f, vec![], signed.then_some(key), 300); let update = Update { - name: name(refname), + name: issue_ref(new), new: Some(new), }; @@ -721,13 +993,9 @@ #[rstest] // @relation(gate.signature-artifact, gate.policy-as-state, scope=function, role=Verifies) fn verdicts_reproduce_offline_from_repository_state_alone() { - // Build the identical repository twice from deterministic seeds — - // the fixture analogue of verifying in an independent clone. The - // verdict depends only on refs/meta/* state and object bytes, so - // both "clones" agree, with no transport artifact consulted. let build = || { let f = forge(); - let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 300); + let new = commit(&f, vec![], Some(&f.admin), 300); (f, new) }; let (origin, new_at_origin) = build(); @@ -735,7 +1003,7 @@ assert_eq!(new_at_origin, new_at_clone, "deterministic fixtures"); let update = Update { - name: name("refs/meta/issues/1"), + name: issue_ref(new_at_origin), new: Some(new_at_origin), }; let at_origin = verify(&origin.refs, &origin.objects, &update).expect("verdict"); @@ -747,8 +1015,9 @@ // @relation(gate.verdict-reason, gate.advisory-local, scope=function, role=Verifies) fn refusals_render_an_actionable_reason_with_the_inbox_alternative() { let f = forge(); - let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.guest), 300); - let Verdict::Fail(refusal) = run(&f, "refs/meta/issues/1", Some(new)) else { + let new = commit(&f, vec![], Some(&f.guest), 300); + let refname = issue_ref(new); + let Verdict::Fail(refusal) = run(&f, &refname, Some(new)) else { panic!("self-attested member on a canonical ref must be refused"); }; let rendered = refusal.to_string(); @@ -757,7 +1026,7 @@ "names the rule: {rendered}" ); assert!( - rendered.contains("refs/meta/issues/1"), + rendered.contains("refs/meta/issues/"), "names the subject ref: {rendered}" ); assert!( @@ -778,8 +1047,6 @@ #[rstest] fn fixture_stores_read_back_what_they_seed() { - // Guards the fixture itself: the enrolled member refs exist and the - // ref store returns them through the production read trait. let f = forge(); let member_ref = name("refs/meta/member/admin"); assert!(f.refs.get(member_ref.as_ref()).expect("readable").is_some());
crates/kernel/ents-query/tests/monotone.rs @@ -135,8 +135,9 @@ let tree = CommitRef::from_bytes(data.data, tip.kind()) .expect("commit") .tree(); - let status: Status = facet_git_tree::deserialize(&tree, objects).expect("status tree"); - (want.is_none() || want == Some(status)).then(|| short.to_owned()) + let record: ents_model::ResultRecord = + facet_git_tree::deserialize(&tree, objects).expect("result tree"); + (want.is_none() || want == Some(record.status)).then(|| short.to_owned()) }) .collect() } @@ -293,7 +294,7 @@ Op::Member { who } => { let id = MEMBERS[who]; seconds += 100; - let member = Member::new(format!("key-{id}"), Provenance::AdminRegistered); + let member = Member::new(id, format!("key-{id}"), Provenance::AdminRegistered); write_member(&refs, &objects, id, &member, None, seconds); Some(format!("refs/meta/member/{id}")) }
crates/kernel/ents-receive/tests/receive.rs @@ -7,13 +7,15 @@ #![expect( clippy::expect_used, clippy::indexing_slicing, + clippy::panic, reason = "integration test: fixtures panic on setup failure" )] use ents_gate::Config; -use ents_model::{Effect, Provenance, Redaction, namespace, trailer::Trailers}; +use ents_model::{Effect, MemberId, Provenance, Redaction, ResultRecord, Status, namespace}; use ents_receive::{ - MemoryEventSink, Mode, NullEventSink, Proposal, RefTransition, TxResult, receive, reconcile, + Identity, MemoryEventSink, Mode, NullEventSink, Proposal, RefTransition, TxResult, + propose_genesis, receive, reconcile, }; use ents_testutil::{ CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity, @@ -90,35 +92,45 @@ s.try_into().expect("valid refname in test") } -/// Build a signed (or unsigned) mutation commit that binds itself to -/// `refname` via the `Advance-ref:` trailer, *without* moving the ref — -/// unlike `ents_testutil::write_meta_entity`, so the test can hand the -/// result to `receive` and observe whether *it* moves the ref. +/// Build a signed (or unsigned) parentless genesis commit for `entity`, +/// *without* moving any ref — unlike `ents_testutil::write_meta_entity`, so +/// the test can hand the result to `receive` and observe whether *it* moves +/// the ref. The commit names no ref (the gate recomputes the binding from +/// signed content); callers derive the refname from the returned oid where +/// the namespace is hash-identified. fn build_mutation<T: for<'facet> facet::Facet<'facet>>( objects: &ObjectStore, - refname: &FullName, entity: &T, signer: Option<&Keypair>, seconds: i64, ) -> ObjectId { let tree = facet_git_tree::serialize_into(entity, objects).expect("serializes"); - let trailers = Trailers { - ents_ref: Some(refname.clone()), - schema_version: None, - }; - let message = format!("Mutate {}\n\n{}", refname.as_bstr(), trailers.render()); write_commit( objects, &CommitSpec { tree, parents: vec![], - message, + message: "Mutate entity".into(), seconds, }, signer, ) } +/// The oid-keyed refname of a hash-identified issue genesis +/// (`meta-ref.identity-binding`). +fn issue_ref(genesis: ObjectId) -> FullName { + name(&format!("refs/meta/issues/{genesis}")) +} + +fn sample_issue() -> Issue { + Issue { + title: "t".into(), + body: "b".into(), + state: "open".into(), + } +} + fn single(transition: RefTransition, objects: Vec<ObjectId>) -> Proposal { Proposal { transitions: vec![transition], @@ -146,18 +158,13 @@ #[case] expect_verdict_pass: bool, ) { let forge = forge(); - let refname = namespace::issue_ref("1").expect("valid"); let signer = if authorized { &forge.admin } else { &forge.guest }; - let issue = Issue { - title: "t".into(), - body: "b".into(), - state: "open".into(), - }; - let tip = build_mutation(&forge.objects, &refname, &issue, Some(signer), 300); + let tip = build_mutation(&forge.objects, &sample_issue(), Some(signer), 300); + let refname = issue_ref(tip); let outcome = receive( &forge.refs, @@ -204,7 +211,7 @@ let refname = namespace::redaction_ref("r1").expect("valid"); let signer = if as_admin { &forge.admin } else { &forge.guest }; let redaction = Redaction::new(ObjectId::null(gix_hash::Kind::Sha1), "leaked credential"); - let tip = build_mutation(&forge.objects, &refname, &redaction, Some(signer), 300); + let tip = build_mutation(&forge.objects, &redaction, Some(signer), 300); let outcome = receive( &forge.refs, @@ -252,13 +259,8 @@ // Someone tries to push it back in, as part of an ordinary issue // mutation's object graph. - let issue_ref = namespace::issue_ref("1").expect("valid"); - let issue = Issue { - title: "t".into(), - body: "b".into(), - state: "open".into(), - }; - let tip = build_mutation(&forge.objects, &issue_ref, &issue, Some(&forge.admin), 300); + let tip = build_mutation(&forge.objects, &sample_issue(), Some(&forge.admin), 300); + let issue_ref = issue_ref(tip); let outcome = receive( &forge.refs, @@ -348,6 +350,7 @@ // @relation(receive.reconstructible, receive.event-sink, receive.never-blocks, query.workset, scope=function, role=Verifies) fn reconcile_matches_incremental_delivery() { let effect = Effect { + name: "unit".to_owned(), trigger: "rev(refs/heads/main)".to_owned(), toolchains: vec![], run: "true".to_owned(), @@ -455,7 +458,7 @@ }; let rounds = chain_commits(&forge.objects, 2, 250); let (first_round, second_round) = (rounds[0], rounds[1]); - let pin = namespace::review_pin_ref("7").expect("valid"); + let pin = namespace::review_pin_ref("deadbeef", &MemberId::new("admin")).expect("valid"); let empty = ents_testutil::empty_tree(&forge.objects); // First review: the pin's tip has the reviewed commit as its only @@ -530,7 +533,7 @@ sign: &|payload| forge.guest.sign(payload), }; let reviewed = chain_commits(&forge.objects, 1, 250)[0]; - let pin = namespace::review_pin_ref("7").expect("valid"); + let pin = namespace::review_pin_ref("deadbeef", &MemberId::new("admin")).expect("valid"); let outcome = ents_receive::propose_pin( &forge.refs, @@ -545,3 +548,154 @@ .expect("reaches an outcome"); assert_eq!(outcome.result, TxResult::Refused); } + +// --------------------------------------------------------------------- +// Identity binding driven through receive: the replays the binding closes. +// --------------------------------------------------------------------- + +fn comment_ref(genesis: ObjectId) -> FullName { + name(&format!("refs/meta/comments/{genesis}")) +} + +/// The doppelgänger replay: a signed *mutation* commit (one with a parent) +/// re-proposed as the genesis of a fresh entity is refused, because the +/// all-roots walk reaches the original genesis, not the replayed commit +/// (`gate.identity-binding`, `meta-ref.identity-binding`). This, not a +/// creation-time-only check, is what makes the replay impossible. +// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies) +#[test] +fn a_signed_mutation_replayed_as_a_fresh_genesis_is_refused() { + let forge = forge(); + // A legitimate comment genesis and one signed mutation of it. + let genesis = build_mutation(&forge.objects, &sample_issue(), Some(&forge.admin), 300); + forge.refs.set(comment_ref(genesis).as_ref(), genesis); + let mut edited = sample_issue(); + edited.state = "resolved".into(); + let tree = facet_git_tree::serialize_into(&edited, &forge.objects).expect("ser"); + let mutation = write_commit( + &forge.objects, + &CommitSpec { + tree, + parents: vec![genesis], + message: "Resolve".into(), + seconds: 310, + }, + Some(&forge.admin), + ); + + // Re-propose that mutation commit as the genesis of a brand-new + // comment named for its own oid. + let outcome = receive( + &forge.refs, + &forge.objects, + &NullEventSink, + &single( + RefTransition { + name: comment_ref(mutation), + old: None, + new: Some(mutation), + }, + vec![mutation], + ), + Mode::Mandatory, + ) + .expect("evaluates"); + + assert_eq!(outcome.result, TxResult::Refused); + let (_, verdict) = &outcome.verdicts[0]; + let ents_gate::Verdict::Fail(refusal) = verdict else { + panic!("the replay must be refused: {verdict:?}"); + }; + assert_eq!(refusal.requirement, ents_gate::Requirement::IdentityBinding); + assert!( + forge + .refs + .get(comment_ref(mutation).as_ref()) + .expect("readable") + .is_none(), + "no doppelgänger entity was created" + ); +} + +/// The result replay: a signed `pass` re-proposed for a different effect, +/// or a different commit, is refused — the result tree carries its own +/// effect and target, so the refname is a function of signed content +/// (`model.result-identity`, `gate.identity-binding`). +// @relation(model.result-identity, gate.identity-binding, scope=function, role=Verifies) +#[rstest] +#[case::wrong_effect("lint", "abc123")] +#[case::wrong_commit("unit", "ffffff")] +fn a_signed_pass_replayed_for_another_effect_or_commit_is_refused( + #[case] effect_seg: &str, + #[case] short: &str, +) { + let forge = forge(); + let target = + ObjectId::from_hex(b"abc1230000000000000000000000000000000000").expect("valid hex"); + let record = ResultRecord::new("unit", target, Status::Pass); + let tip = build_mutation(&forge.objects, &record, Some(&forge.admin), 300); + + // The honest ref is results/unit/abc123; the replay targets a + // different effect or commit segment. + let replay = namespace::result_ref(effect_seg, short).expect("valid"); + let outcome = receive( + &forge.refs, + &forge.objects, + &NullEventSink, + &single( + RefTransition { + name: replay.clone(), + old: None, + new: Some(tip), + }, + vec![tip], + ), + Mode::Mandatory, + ) + .expect("evaluates"); + + assert_eq!(outcome.result, TxResult::Refused); + assert!(forge.refs.get(replay.as_ref()).expect("readable").is_none()); +} + +/// Creation via the inbox still works: a self-attested member creates a +/// hash-identified entity under its own inbox segment, awaiting adoption +/// (`gate.owner-mutation`: creation stays provenance-keyed). The +/// sign-then-name genesis flow names the ref from the commit's own oid. +// @relation(gate.owner-mutation, meta-ref.inbox, meta-ref.identity-binding, scope=function, role=Verifies) +#[test] +fn a_self_attested_member_creates_a_comment_in_its_inbox() { + let forge = forge(); + let identity = Identity { + actor: gix::actor::Signature { + name: "guest".into(), + email: "guest@ents.test".into(), + time: gix::date::Time { + seconds: 300, + offset: 0, + }, + }, + sign: &|payload| forge.guest.sign(payload), + }; + + let (landed, outcome) = propose_genesis( + &forge.refs, + &forge.objects, + &NullEventSink, + &sample_issue(), + |oid| namespace::inbox_ref(&MemberId::new("guest"), &format!("comments/{oid}")), + &identity, + "Comment awaiting adoption", + Mode::Mandatory, + ) + .expect("reaches an outcome"); + + assert_eq!(outcome.result, TxResult::Applied, "{:?}", outcome.verdicts); + assert!( + landed + .as_bstr() + .starts_with(b"refs/meta/inbox/guest/comments/"), + "created under the contributor's own inbox segment: {landed}" + ); + assert!(forge.refs.get(landed.as_ref()).expect("readable").is_some()); +}
crates/kernel/ents-sync/tests/preflight.rs @@ -11,13 +11,13 @@ #![expect(clippy::unwrap_used, reason = "tests")] use ents_gate::{Config, Update, verify}; -use ents_model::trailer::Trailers; use ents_model::{MemberId, Provenance, namespace}; use ents_sync::{inbox_route, preflight}; use ents_testutil::{ CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity, }; use gix::refs::FullName; +use gix_hash::ObjectId; use gix_ref_store::{Expected, RefEdit, RefStore, RefStoreRead}; use rstest::rstest; @@ -39,6 +39,47 @@ } } +/// Build a parentless, signed genesis issue commit and the oid-keyed +/// refname it binds (`meta-ref.identity-binding`). Does not touch the ref +/// store — pre-flight judges a proposal against a snapshot. +fn genesis_issue(objects: &ObjectStore, signer: &Keypair, seconds: i64) -> (FullName, ObjectId) { + let tree = facet_git_tree::serialize_into(&issue(), objects).unwrap(); + let tip = write_commit( + objects, + &CommitSpec { + tree, + parents: vec![], + message: "Open issue".into(), + seconds, + }, + Some(signer), + ); + (format!("refs/meta/issues/{tip}").try_into().unwrap(), tip) +} + +/// A child issue commit of `parent`, signed, for divergence scenarios. +fn child_issue( + objects: &ObjectStore, + parent: ObjectId, + state: &str, + signer: &Keypair, + seconds: i64, +) -> ObjectId { + let mut edited = issue(); + edited.state = state.into(); + let tree = facet_git_tree::serialize_into(&edited, objects).unwrap(); + write_commit( + objects, + &CommitSpec { + tree, + parents: vec![parent], + message: "Edit issue".into(), + seconds, + }, + Some(signer), + ) +} + /// A booted forge (admin enrolled, epoch set) plus a self-attested bob. fn forge() -> (MemRefStore, ObjectStore, Keypair, Keypair) { let refs = MemRefStore::default(); @@ -82,26 +123,25 @@ let signer = if seed == 1 { &admin } else { &bob }; let author = if seed == 1 { "admin" } else { "bob" }; - let name: FullName = "refs/meta/issues/9".try_into().unwrap(); - let tip = write_meta_entity(&refs, &objects, name.clone(), &issue(), Some(signer), 300); - - let before = refs.fetched_copy(); - before.remove(name.as_ref()); + // The issue's id is its genesis commit's own oid; the ref does not yet + // exist, so pre-flight judges a fresh creation. + let (name, tip) = genesis_issue(&objects, signer, 300); let pf = preflight( - &before, + &refs, &objects, &Update { - name, + name: name.clone(), new: Some(tip), }, &MemberId::new(author), ) .unwrap(); - assert_eq!(pf.is_pass(), expect_pass); + assert_eq!(pf.is_pass(), expect_pass, "{:?}", pf.verdict); assert_eq!(pf.inbox.is_some(), expect_inbox); if let Some(inbox) = pf.inbox { - assert_eq!(inbox.as_bstr(), "refs/meta/inbox/bob/issues/9"); + let expected = format!("refs/meta/inbox/bob/issues/{tip}"); + assert_eq!(inbox.as_bstr(), expected.as_str()); } } @@ -112,32 +152,15 @@ #[test] fn a_divergence_refusal_offers_no_inbox() { let (refs, objects, admin, _bob) = forge(); - let name: FullName = "refs/meta/issues/3".try_into().unwrap(); - write_meta_entity(&refs, &objects, name.clone(), &issue(), Some(&admin), 300); - - // An independent root correctly bound to the same ref: signed by an - // authorized member with a matching trailer, but with no parents, so it - // cannot descend from the current tip — a genuine fast-forward refusal. - let mut other = issue(); - other.state = "closed".into(); - let sibling = { - let tree = facet_git_tree::serialize_into(&other, &objects).unwrap(); - let trailers = Trailers { - ents_ref: Some(name.clone()), - schema_version: None, - }; - let message = format!("Mutate {}\n\n{}", name.as_bstr(), trailers.render()); - write_commit( - &objects, - &CommitSpec { - tree, - parents: vec![], - message, - seconds: 350, - }, - Some(&admin), - ) - }; + // A genesis and two divergent children of it, all correctly bound to + // the same oid-keyed ref (both descend from the same genesis, so + // identity binding holds); the current tip is one child, and the + // proposal is the sibling, which cannot descend from it — a genuine + // fast-forward refusal, not an identity mismatch. + let (name, genesis) = genesis_issue(&objects, &admin, 300); + let current = child_issue(&objects, genesis, "open", &admin, 350); + let sibling = child_issue(&objects, genesis, "closed", &admin, 350); + refs.set(name.as_ref(), current); let pf = preflight( &refs, @@ -149,7 +172,7 @@ &MemberId::new("admin"), ) .unwrap(); - assert!(!pf.is_pass()); + assert!(!pf.is_pass(), "{:?}", pf.verdict); assert!( pf.inbox.is_none(), "a divergence routes to a merge, not the inbox" @@ -166,17 +189,14 @@ let (refs, objects, admin, bob) = forge(); for (author, signer) in [("admin", &admin), ("bob", &bob)] { - let name: FullName = format!("refs/meta/issues/{author}").try_into().unwrap(); - let tip = write_meta_entity(&refs, &objects, name.clone(), &issue(), Some(signer), 300); - let before = refs.fetched_copy(); - before.remove(name.as_ref()); + let (name, tip) = genesis_issue(&objects, signer, 300); let update = Update { name, new: Some(tip), }; - let pf = preflight(&before, &objects, &update, &MemberId::new(author)).unwrap(); - let gate = verify(&before, &objects, &update).unwrap(); + let pf = preflight(&refs, &objects, &update, &MemberId::new(author)).unwrap(); + let gate = verify(&refs, &objects, &update).unwrap(); assert_eq!( pf.verdict, gate, "pre-flight must be the gate, not a copy of it" @@ -192,13 +212,10 @@ #[test] fn a_failing_preflight_never_blocks_the_local_write() { let (refs, objects, _admin, bob) = forge(); - let name: FullName = "refs/meta/issues/9".try_into().unwrap(); - let tip = write_meta_entity(&refs, &objects, name.clone(), &issue(), Some(&bob), 300); + let (name, tip) = genesis_issue(&objects, &bob, 300); - let before = refs.fetched_copy(); - before.remove(name.as_ref()); let pf = preflight( - &before, + &refs, &objects, &Update { name: name.clone(), @@ -214,14 +231,14 @@ ); // Nothing sync did prevents the local write from applying. - let outcome = before + let outcome = refs .transaction(&[RefEdit { name: name.clone(), expected: Expected::MustNotExist, new: Some(tip), }]) .unwrap(); - assert_eq!(before.get(name.as_ref()).unwrap(), Some(tip)); + assert_eq!(refs.get(name.as_ref()).unwrap(), Some(tip)); let _ = outcome; }
crates/kernel/ents-sync/tests/resolve.rs @@ -18,7 +18,6 @@ )] use ents_gate::{Config, Update, Verdict, verify}; -use ents_model::trailer::Trailers; use ents_model::{Provenance, namespace}; use ents_sync::{Heads, Merged, merge_heads}; use ents_testutil::{ @@ -45,34 +44,35 @@ } } -/// Build a signed commit recording `entity`, bound to `refname`, with the -/// given parents — the general shape [`write_meta_entity`] specializes. +/// Build a signed commit recording `entity` with the given parents. The +/// commit names no ref of its own — the gate recomputes the binding from +/// signed content (`gate.identity-binding`). fn signed_commit( objects: &ObjectStore, - refname: &FullName, entity: &Issue, parents: Vec<ObjectId>, key: &Keypair, seconds: i64, ) -> ObjectId { let tree = facet_git_tree::serialize_into(entity, objects).unwrap(); - let trailers = Trailers { - ents_ref: Some(refname.clone()), - schema_version: None, - }; - let message = format!("Mutate {}\n\n{}", refname.as_bstr(), trailers.render()); write_commit( objects, &CommitSpec { tree, parents, - message, + message: "Mutate issue".into(), seconds, }, Some(key), ) } +/// The canonical issue refname for a genesis commit, keyed by its oid +/// (`meta-ref.identity-binding`). +fn issue_ref(genesis: ObjectId) -> FullName { + format!("refs/meta/issues/{genesis}").try_into().unwrap() +} + fn author(seconds: i64) -> gix::actor::Signature { gix::actor::Signature { name: "placer".into(), @@ -121,14 +121,15 @@ let jdc = Keypair::from_seed(1); boot(&refs, &objects, &jdc); - let name: FullName = "refs/meta/issues/1".try_into().unwrap(); - let base = signed_commit(&objects, &name, &issue("open"), vec![], &jdc, 300); + let base = signed_commit(&objects, &issue("open"), vec![], &jdc, 300); + // The issue's id is its genesis commit's own oid. + let name = issue_ref(base); // Two divergent children of the same base, editing different fields. let mut ours_issue = issue("open"); ours_issue.title = "renamed".into(); - let ours = signed_commit(&objects, &name, &ours_issue, vec![base], &jdc, 400); - let theirs = signed_commit(&objects, &name, &issue("closed"), vec![base], &jdc, 400); + let ours = signed_commit(&objects, &ours_issue, vec![base], &jdc, 400); + let theirs = signed_commit(&objects, &issue("closed"), vec![base], &jdc, 400); let heads = Heads { refname: name.clone(), @@ -187,12 +188,12 @@ enroll_member(&refs, &objects, "bob", &bob, Provenance::SelfAttested, 250); // Bob submits an issue under his own inbox segment (all he may write). - let inbox: FullName = "refs/meta/inbox/bob/issues/5".try_into().unwrap(); - let contribution = signed_commit(&objects, &inbox, &issue("open"), vec![], &bob, 300); + let contribution = signed_commit(&objects, &issue("open"), vec![], &bob, 300); // The maintainer adopts it onto the canonical ref via the *same* - // machinery divergence uses — only the heads differ. - let canonical: FullName = "refs/meta/issues/5".try_into().unwrap(); + // machinery divergence uses — only the heads differ. The canonical id + // is bob's genesis commit oid, which stays the history's sole root. + let canonical = issue_ref(contribution); let heads = Heads { refname: canonical.clone(), ours: None, @@ -238,14 +239,13 @@ boot(&refs, &objects, &admin); enroll_member(&refs, &objects, "bob", &bob, Provenance::SelfAttested, 250); - let canonical: FullName = "refs/meta/issues/7".try_into().unwrap(); - let base = signed_commit(&objects, &canonical, &issue("open"), vec![], &admin, 300); + let base = signed_commit(&objects, &issue("open"), vec![], &admin, 300); + let canonical = issue_ref(base); // Bob branches from the canonical base and edits a field in his inbox. - let inbox: FullName = "refs/meta/inbox/bob/issues/7".try_into().unwrap(); let mut contributed = issue("open"); contributed.title = "bob's title".into(); - let contribution = signed_commit(&objects, &inbox, &contributed, vec![base], &bob, 350); + let contribution = signed_commit(&objects, &contributed, vec![base], &bob, 350); let heads = Heads { refname: canonical.clone(),
crates/kernel/ents-sync/tests/transfer.rs @@ -22,6 +22,7 @@ CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity, }; use gix::refs::FullName; +use gix_hash::ObjectId; use gix_ref_store::RefStoreRead; /// A stand-in for `ents-forge`'s `Issue` (this crate cannot depend on @@ -42,6 +43,31 @@ } } +/// Write a parentless, signed genesis issue commit, set its oid-keyed ref +/// (`meta-ref.identity-binding`), and return `(name, oid)`. +fn genesis( + refs: &MemRefStore, + objects: &ObjectStore, + state: &str, + signer: &Keypair, + seconds: i64, +) -> (FullName, ObjectId) { + let tree = facet_git_tree::serialize_into(&issue(state), objects).unwrap(); + let tip = write_commit( + objects, + &CommitSpec { + tree, + parents: vec![], + message: "Open issue".into(), + seconds, + }, + Some(signer), + ); + let name: FullName = format!("refs/meta/issues/{tip}").try_into().unwrap(); + refs.set(name.as_ref(), tip); + (name, tip) +} + /// Enroll `admin` (and optionally `bob`) and record the epoch. fn boot(refs: &MemRefStore, objects: &ObjectStore, admin: &Keypair, bob: Option<&Keypair>) { enroll_member( @@ -77,16 +103,8 @@ let admin = Keypair::from_seed(1); boot(&remote_refs, &remote_objects, &admin, None); - // An issue with two commits of history. - let name: FullName = "refs/meta/issues/1".try_into().unwrap(); - let parent = write_meta_entity( - &remote_refs, - &remote_objects, - name.clone(), - &issue("open"), - Some(&admin), - 300, - ); + // An issue with two commits of history; its id is the genesis oid. + let (name, parent) = genesis(&remote_refs, &remote_objects, "open", &admin, 300); let tip = write_meta_entity( &remote_refs, &remote_objects, @@ -204,15 +222,7 @@ let local_objects = ObjectStore::default(); boot(&local_refs, &local_objects, &admin, None); - let name: FullName = "refs/meta/issues/1".try_into().unwrap(); - let tip = write_meta_entity( - &local_refs, - &local_objects, - name.clone(), - &issue("open"), - Some(&admin), - 300, - ); + let (name, tip) = genesis(&local_refs, &local_objects, "open", &admin, 300); let pushed = push( &remote_refs, @@ -336,15 +346,7 @@ let local_objects = ObjectStore::default(); boot(&local_refs, &local_objects, &admin, None); - let name: FullName = "refs/meta/issues/1".try_into().unwrap(); - let ours = write_meta_entity( - &local_refs, - &local_objects, - name.clone(), - &issue("open"), - Some(&admin), - 300, - ); + let (name, ours) = genesis(&local_refs, &local_objects, "open", &admin, 300); // The racing writer's competing tip, landed on the remote the instant // push's transaction begins — after pre-flight has already passed.