receive: add propose_pin for empty-tree retention-pin commits
commit
b9d08a8receive: 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
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
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);
+}