git-ents.gitmain
⌘K
foforge
commit b9d08a8
receive: add propose_pin for empty-tree retention-pin commits

A retention pin (model.review-pin) is the one meta-ref mutation that carries no entity: a signed commit with the empty tree whose parents are the pin’s current tip (if any) followed by the commit being retained, so a first pin roots at the reviewed commit and every re-review is the merge-shaped fast-forward the gate’s any-parent descent check already admits. The commit build/sign plumbing is extracted into signed_commit, shared with propose_entity, keeping one place that builds the trailer block and one place that signs.

Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

crates/kernel/ents-receive/src/lib.rs @@ -96,7 +96,7 @@ pub use error::{Error, Result}; pub use outcome::{Mode, Outcome, TxResult}; pub use proposal::{Proposal, RefTransition, TransportAuth}; -pub use propose::{Identity, propose_delete, propose_entity}; +pub use propose::{Identity, propose_delete, propose_entity, propose_pin}; pub use receive::receive; pub use reconcile::reconcile; pub use sink::{EventSink, MemoryEventSink, NullEventSink};
crates/kernel/ents-receive/src/propose.rs @@ -12,6 +12,11 @@ //! frontend crate because it is mechanism, not policy: every mutation //! frontend across every composition root shares it (`arch.no-object-store-trait`'s //! sibling rule — signing and commit-building plumbing is kernel material). +//! +//! [`propose_pin`] is the same mechanism for the one commit shape that +//! carries no entity: a retention pin's empty-tree, merge-shaped commit +//! (`model.review-pin`), built and signed by the identical +//! [`signed_commit`] plumbing and admitted through the identical gate. use ents_model::trailer::Trailers; use gix::refs::FullName; @@ -105,7 +110,124 @@ ) -> Result<Outcome> { let tree = facet_git_tree::serialize_into(entity, objects)?; let old = refs.get(name.as_ref())?; + let parents: Vec<_> = old.into_iter().collect(); + let tip = signed_commit(objects, &name, tree, parents, identity, subject)?; + let proposal = Proposal { + transitions: vec![RefTransition { + name, + old, + new: Some(tip), + }], + objects: vec![tip], + auth: None, + }; + crate::receive::receive(refs, objects, events, &proposal, mode) +} + +/// Advance the retention pin at `name` to keep `retain` (and its ancestry) +/// reachable (`model.review-pin`): a signed commit carrying the empty tree +/// — a pin's commits anchor other content's reachability and carry no +/// entity, the sole exception to `meta-ref.namespace`'s +/// tree-is-the-entity shape — whose parents are the pin's current tip (if +/// any) followed by `retain`, proposed through [`crate::receive`] exactly +/// like an entity mutation. +/// +/// A first pin has `retain` as its only parent; every later advance is the +/// merge-shaped fast-forward `model.review-pin` requires (previous pin +/// tip, newly retained commit), so every retained round stays in the +/// pin's own history and the gate's descent check (`gate.fast-forward`, +/// descent through *any* parent) admits it unchanged. +/// +/// # Errors +/// +/// See [`propose_entity`] — identical, minus the serialization failure a +/// pin cannot have (there is no entity to serialize). +/// +/// # Examples +/// +/// ``` +/// use ents_model::{Provenance, namespace}; +/// use ents_receive::{Identity, Mode, NullEventSink, TxResult, propose_pin}; +/// use ents_testutil::{CommitSpec, Keypair, MemRefStore, ObjectStore, empty_tree, enroll_member}; +/// use gix_object::Write as _; +/// +/// let refs = MemRefStore::default(); +/// let objects = ObjectStore::default(); +/// let admin = Keypair::from_seed(1); +/// enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100); +/// +/// // The commit under review — the content the pin keeps reachable. +/// let tree = empty_tree(&objects); +/// let reviewed = ents_testutil::write_commit( +/// &objects, +/// &CommitSpec { tree, parents: vec![], message: "reviewed work".into(), seconds: 200 }, +/// None, +/// ); +/// +/// let name = namespace::review_pin_ref("7").expect("valid"); +/// let identity = Identity { +/// actor: gix::actor::Signature { +/// name: "admin".into(), +/// email: "admin@ents.test".into(), +/// time: gix::date::Time { seconds: 300, offset: 0 }, +/// }, +/// sign: &|payload| admin.sign(payload), +/// }; +/// +/// let outcome = propose_pin( +/// &refs, &objects, &NullEventSink, name, reviewed, &identity, "Pin review 7", +/// Mode::Advisory, +/// ) +/// .expect("reaches an outcome"); +/// assert_eq!(outcome.result, TxResult::Applied); +/// ``` +// @relation(model.review-pin, meta-ref.namespace, scope=function) +#[expect( + clippy::too_many_arguments, + reason = "one field per pin-mutation shape (refname, retained commit, identity, message, \ + mode), mirroring propose_entity's identical, identically-justified shape" +)] +pub fn propose_pin( + refs: &dyn RefStore, + objects: &(impl Find + Write), + events: &dyn EventSink, + name: FullName, + retain: gix_hash::ObjectId, + identity: &Identity<'_>, + subject: &str, + mode: Mode, +) -> Result<Outcome> { + let tree = objects.write(&gix_object::Tree { entries: vec![] })?; + let old = refs.get(name.as_ref())?; + let parents: Vec<_> = old.into_iter().chain(std::iter::once(retain)).collect(); + let tip = signed_commit(objects, &name, tree, parents, identity, subject)?; + + let proposal = Proposal { + transitions: vec![RefTransition { + name, + old, + new: Some(tip), + }], + objects: vec![tip], + auth: None, + }; + crate::receive::receive(refs, objects, events, &proposal, mode) +} + +/// Build, sign, and write the mutation commit both proposal shapes share: +/// `tree` under a message whose `Advance-ref:` trailer binds it to `name` +/// (`meta-ref.trailers`, `gate.refname-binding`), authored and signed by +/// `identity` — the one place a mutation commit is built, whatever its +/// tree and parents. +fn signed_commit( + objects: &impl Write, + name: &FullName, + tree: gix_hash::ObjectId, + parents: Vec<gix_hash::ObjectId>, + identity: &Identity<'_>, + subject: &str, +) -> Result<gix_hash::ObjectId> { let trailers = Trailers { ents_ref: Some(name.clone()), schema_version: None, @@ -114,7 +236,7 @@ let mut commit = Commit { tree, - parents: old.into_iter().collect::<Vec<_>>().into(), + parents: parents.into(), author: identity.actor.clone(), committer: identity.actor.clone(), encoding: None, @@ -146,18 +268,7 @@ commit .write_to(&mut raw) .expect("serializing a commit to a Vec cannot fail"); - let tip = objects.write_buf(Kind::Commit, &raw)?; - - let proposal = Proposal { - transitions: vec![RefTransition { - name, - old, - new: Some(tip), - }], - objects: vec![tip], - auth: None, - }; - crate::receive::receive(refs, objects, events, &proposal, mode) + Ok(objects.write_buf(Kind::Commit, &raw)?) } /// Delete the entity at `name` (a `new: None` transition) through
crates/kernel/ents-receive/tests/receive.rs @@ -6,6 +6,7 @@ #![expect( clippy::expect_used, + clippy::indexing_slicing, reason = "integration test: fixtures panic on setup failure" )] @@ -410,3 +411,137 @@ "one obligation per commit that entered the trigger's set" ); } + +// --------------------------------------------------------------------- +// model.review-pin: the retention pin's commit shape — empty tree, +// parents include the retained commit, merge-shaped fast-forward on +// every advance — admitted by the identical mandatory gate every entity +// mutation faces. +// --------------------------------------------------------------------- + +/// Read a commit's `(tree, parents)` back out of the store. +fn commit_shape(objects: &ObjectStore, oid: ObjectId) -> (ObjectId, Vec<ObjectId>) { + use gix_object::Find as _; + + let mut buf = Vec::new(); + let data = objects + .try_find(&oid, &mut buf) + .expect("readable") + .expect("present"); + assert_eq!(data.kind, Kind::Commit); + let commit = gix_object::CommitRef::from_bytes(data.data, oid.kind()).expect("parses"); + (commit.tree(), commit.parents().collect()) +} + +/// A first pin retains the reviewed commit as its only parent and carries +/// the empty tree; a re-review advances the pin fast-forward with a +/// merge-shaped commit `(previous pin tip, newly reviewed commit)` — and +/// the *mandatory* gate admits both shapes (`gate.tip-signed`, +/// `gate.fast-forward`: descent through any parent). +// @relation(model.review-pin, meta-ref.namespace, gate.fast-forward, scope=function, role=Verifies) +#[test] +fn pin_retains_every_reviewed_round_and_passes_the_mandatory_gate() { + let forge = forge(); + let identity = ents_receive::Identity { + actor: gix::actor::Signature { + name: "admin".into(), + email: "admin@ents.test".into(), + time: gix::date::Time { + seconds: 300, + offset: 0, + }, + }, + sign: &|payload| forge.admin.sign(payload), + }; + 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 empty = ents_testutil::empty_tree(&forge.objects); + + // First review: the pin's tip has the reviewed commit as its only + // parent and carries no entity — the empty tree. + let outcome = ents_receive::propose_pin( + &forge.refs, + &forge.objects, + &NullEventSink, + pin.clone(), + first_round, + &identity, + "Pin review 7", + Mode::Mandatory, + ) + .expect("reaches an outcome"); + assert_eq!(outcome.result, TxResult::Applied); + assert!(outcome.verdicts[0].1.is_pass(), "mandatory gate admits it"); + let first_tip = forge + .refs + .get(pin.as_ref()) + .expect("readable") + .expect("set"); + let (tree, parents) = commit_shape(&forge.objects, first_tip); + assert_eq!(tree, empty, "a pin commit carries the empty tree"); + assert_eq!(parents, vec![first_round]); + + // Re-review after the target moved: merge-shaped fast-forward + // (previous pin tip, newly reviewed commit) — every reviewed round + // stays retained in the pin's own history. + let outcome = ents_receive::propose_pin( + &forge.refs, + &forge.objects, + &NullEventSink, + pin.clone(), + second_round, + &identity, + "Pin review 7 again", + Mode::Mandatory, + ) + .expect("reaches an outcome"); + assert_eq!(outcome.result, TxResult::Applied); + assert!(outcome.verdicts[0].1.is_pass()); + let second_tip = forge + .refs + .get(pin.as_ref()) + .expect("readable") + .expect("set"); + let (tree, parents) = commit_shape(&forge.objects, second_tip); + assert_eq!(tree, empty); + assert_eq!( + parents, + vec![first_tip, second_round], + "previous pin tip first, newly reviewed commit second" + ); +} + +/// A self-attested member is not authorized for the pin namespace — the +/// gate's canonical-ref arm applies to `refs/meta/pins/*` unchanged. +// @relation(model.review-pin, gate.tip-signed, scope=function, role=Verifies) +#[test] +fn pin_writes_face_the_same_authorization_as_any_canonical_ref() { + let forge = forge(); + let identity = ents_receive::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 reviewed = chain_commits(&forge.objects, 1, 250)[0]; + let pin = namespace::review_pin_ref("7").expect("valid"); + + let outcome = ents_receive::propose_pin( + &forge.refs, + &forge.objects, + &NullEventSink, + pin, + reviewed, + &identity, + "Pin review 7", + Mode::Mandatory, + ) + .expect("reaches an outcome"); + assert_eq!(outcome.result, TxResult::Refused); +}