git-ents.gitmain
⌘K
foforge
propose.rs667 lines · 26.1 KB · rusthistorycomment on this file
1//! One shared primitive every entity-mutation command uses: serialize a
2//! typed tree, wrap it in a signed commit, and hand it to
3//! [`crate::receive`] — the sole path a meta-ref mutation may enter the
4//! repository (`receive.unit`). No commit trailer binds the commit to its
5//! refname anymore: the gate recomputes the refname from the signed
6//! content (`meta-ref.identity-binding`, `gate.identity-binding`).
7//!
8//! An owner-keyed mutation advances an existing ref, whose name the caller
9//! already knows ([`propose_entity`]). The creation of a hash-identified
10//! entity instead runs sign-then-name ([`propose_genesis`]): build and
11//! sign the genesis commit first, then name the ref from that commit's own
12//! oid — there is no circularity, because no commit names its own ref.
13//!
14//! Every porcelain command that writes an entity (`members`, `account`,
15//! `effect`, `toolchain`, `comment`, `redact`) goes through one of these
16//! rather than repeating the shape, so there is exactly one place that
17//! signs and one place that calls `receive`. This lives in `ents-receive`
18//! rather than a frontend crate because it is mechanism, not policy: every
19//! mutation frontend across every composition root shares it
20//! (`arch.no-object-store-trait`'s sibling rule — signing and
21//! commit-building plumbing is kernel material).
22//!
23//! [`propose_pin`] is the same mechanism for the one commit shape that
24//! carries no entity: a retention pin's empty-tree, merge-shaped commit
25//! (`model.review-pin`), built and signed by the identical
26//! [`signed_commit`] plumbing and admitted through the identical gate.
27
28use gix::refs::FullName;
29use gix_object::{Commit, Find, Kind, Write, WriteTo as _};
30use gix_ref_store::RefStore;
31
32use crate::error::Result;
33use crate::outcome::{Mode, Outcome};
34use crate::proposal::{Proposal, RefTransition};
35use crate::sink::EventSink;
36
37/// Everything [`propose_entity`] needs about the acting identity: the
38/// commit committer signature, an optional attributed author, and a
39/// signing function producing the `gpgsig` header's armored payload.
40// @relation(receive.attributed-author, scope=type)
41pub struct Identity<'a> {
42 /// The committer signature every mutation commit carries — always the
43 /// signing identity (`receive.attributed-author`).
44 pub actor: gix::actor::Signature,
45 /// A distinct attributed author, so history reads "member via the
46 /// web" (`receive.attributed-author`, `roots.web-signing`); `None`
47 /// means the author is `actor`, the unattributed common case. The
48 /// gate keys authorization off the signer, never this field.
49 pub author: Option<gix::actor::Signature>,
50 /// Signs a commit payload, returning the armored SSHSIG PEM block git
51 /// stores in the `gpgsig` header — a closure rather than a concrete
52 /// signer type so this crate never depends on how a caller loads or
53 /// holds its signing key (a CLI's on-disk key, a test fixture's
54 /// deterministic keypair, ...).
55 pub sign: &'a dyn Fn(&[u8]) -> String,
56}
57
58/// Serialize `entity` into `objects`, wrap it in a signed commit whose
59/// only parent is `name`'s current tip, and propose the transition through
60/// [`crate::receive`]. This is the owner-keyed advance of an existing ref
61/// (`members`, `account`, `effect`, ...); the ref name is bound to the
62/// signed content by the gate (`gate.identity-binding`), not a trailer.
63/// For the creation of a hash-identified entity, use [`propose_genesis`].
64///
65/// `name`'s current tip is read fresh from `refs` immediately before
66/// building the commit, so the proposed transition's `old` is always
67/// current — the CAS precondition `receive` (via `ents_gate::verify`)
68/// checks is against this same read.
69///
70/// # Errors
71///
72/// [`crate::Error::Tree`] if `entity` cannot be serialized; [`crate::Error::Refs`]
73/// if reading `name`'s current tip fails; other [`crate::Error`] variants if
74/// `receive` itself could not reach an outcome. A reached-but-negative
75/// outcome (refusal, staleness, redaction) is returned as `Ok` — callers
76/// translate [`Outcome`] to their own user-facing error (the CLI's
77/// `outcome_to_result`, for instance).
78///
79/// # Examples
80///
81/// ```
82/// use ents_model::{Provenance, Redaction, namespace};
83/// use ents_receive::{Identity, Mode, NullEventSink, TxResult, propose_entity};
84/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member};
85///
86/// let refs = MemRefStore::default();
87/// let objects = ObjectStore::default();
88/// let admin = Keypair::from_seed(1);
89/// enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100);
90///
91/// let redaction = Redaction::new(
92/// gix_hash::ObjectId::null(gix_hash::Kind::Sha1),
93/// "leaked credential",
94/// );
95/// let name = namespace::redaction_ref("r1").expect("valid");
96/// let identity = Identity {
97/// actor: gix::actor::Signature {
98/// name: "admin".into(),
99/// email: "admin@ents.test".into(),
100/// time: gix::date::Time { seconds: 300, offset: 0 },
101/// },
102/// author: None,
103/// sign: &|payload| admin.sign(payload),
104/// };
105///
106/// let outcome = propose_entity(
107/// &refs, &objects, &NullEventSink, name, &redaction, &identity, "Redact leaked secret",
108/// Mode::Advisory,
109/// )
110/// .expect("reaches an outcome");
111/// assert_eq!(outcome.result, TxResult::Applied);
112/// ```
113#[expect(
114 clippy::too_many_arguments,
115 reason = "one field per entity-mutation shape (refname, entity, identity, message, mode); \
116 this is the crate's one shared primitive rather than one per caller"
117)]
118pub fn propose_entity<T: for<'facet> facet::Facet<'facet>>(
119 refs: &dyn RefStore,
120 objects: &(impl Find + Write),
121 events: &dyn EventSink,
122 name: FullName,
123 entity: &T,
124 identity: &Identity<'_>,
125 subject: &str,
126 mode: Mode,
127) -> Result<Outcome> {
128 let (transition, tip) = entity_transition(refs, objects, &name, entity, identity, subject)?;
129 let proposal = Proposal {
130 transitions: vec![transition],
131 objects: vec![tip],
132 auth: None,
133 };
134 crate::receive::receive(refs, objects, events, &proposal, mode)
135}
136
137/// Build the entity-mutation [`RefTransition`] `propose_entity` and
138/// `propose_entity_with_pin` share: serialize `entity`, wrap it in a signed
139/// commit whose only parent is `name`'s current tip, and return that
140/// transition alongside the tip oid the [`Proposal`] must carry.
141///
142/// Public so a caller assembling a larger, bespoke atomic multi-ref
143/// proposal (`receive.multi-ref-atomicity`) than [`propose_entity_with_pin`]
144/// covers can build one of its transitions with the identical signing
145/// plumbing every entity mutation uses, then bundle it into its own
146/// [`Proposal`] and call [`crate::receive`] exactly once. This does not
147/// widen `receive`'s own contract: the transition still only becomes
148/// durable through that one call.
149pub fn entity_transition<T: for<'facet> facet::Facet<'facet>>(
150 refs: &dyn RefStore,
151 objects: &(impl Find + Write),
152 name: &FullName,
153 entity: &T,
154 identity: &Identity<'_>,
155 subject: &str,
156) -> Result<(RefTransition, gix_hash::ObjectId)> {
157 let tree = facet_git_tree::serialize_into(entity, objects)?;
158 let old = refs.get(name.as_ref())?;
159 let parents: Vec<_> = old.into_iter().collect();
160 let tip = signed_commit(objects, tree, parents, identity, subject)?;
161 Ok((
162 RefTransition {
163 name: name.clone(),
164 old,
165 new: Some(tip),
166 },
167 tip,
168 ))
169}
170
171/// Create a hash-identified entity by sign-then-name (`model.comment`,
172/// `model.issue`, `meta-ref.identity-binding`): serialize `entity`, build
173/// and sign a *parentless* genesis commit, then name the ref from that
174/// commit's own oid via `name_from_oid` (for example
175/// `ents_model::namespace::comment_ref`) and propose the creation through
176/// [`crate::receive`]. Returns the derived refname alongside the outcome,
177/// since the caller cannot know the id until the commit is signed.
178///
179/// There is no circularity — the genesis commit carries no reference to
180/// the ref it will name (no trailer names it) — so the id is git's own
181/// hash over the genesis tree, author, timestamp, and signature, exactly
182/// as `model.comment` requires, and the gate's all-roots walk binds it
183/// (`gate.identity-binding`).
184///
185/// # Errors
186///
187/// [`crate::Error::Tree`] if `entity` cannot be serialized;
188/// [`crate::Error::Refs`] if building the refname fails (an invalid oid
189/// segment cannot occur for a real oid, but the closure is fallible);
190/// other [`crate::Error`] variants if `receive` could not reach an
191/// outcome.
192///
193/// # Examples
194///
195/// ```
196/// use ents_model::{Provenance, namespace};
197/// use ents_receive::{Identity, Mode, NullEventSink, TxResult, propose_genesis};
198/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member};
199/// use gix_ref_store::RefStoreRead as _;
200///
201/// # #[derive(facet::Facet)]
202/// # struct Comment { body: String }
203/// let refs = MemRefStore::default();
204/// let objects = ObjectStore::default();
205/// let admin = Keypair::from_seed(1);
206/// enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100);
207///
208/// let identity = Identity {
209/// actor: gix::actor::Signature {
210/// name: "admin".into(),
211/// email: "admin@ents.test".into(),
212/// time: gix::date::Time { seconds: 300, offset: 0 },
213/// },
214/// author: None,
215/// sign: &|payload| admin.sign(payload),
216/// };
217///
218/// let (name, outcome) = propose_genesis(
219/// &refs, &objects, &NullEventSink, &Comment { body: "first".into() },
220/// |oid| namespace::comment_ref(&oid.to_string()), &identity, "Comment on X",
221/// Mode::Advisory,
222/// )
223/// .expect("reaches an outcome");
224/// assert_eq!(outcome.result, TxResult::Applied);
225/// // The ref is named from the genesis commit's own oid.
226/// assert!(name.as_bstr().starts_with(b"refs/meta/comments/"));
227/// assert!(refs.get(name.as_ref()).expect("read").is_some());
228/// ```
229// @relation(meta-ref.identity-binding, model.comment, model.issue, scope=function)
230#[expect(
231 clippy::too_many_arguments,
232 reason = "mirrors propose_entity's shape, plus the name-from-oid closure that is the whole \
233 point of the sign-then-name flow"
234)]
235pub fn propose_genesis<T: for<'facet> facet::Facet<'facet>>(
236 refs: &dyn RefStore,
237 objects: &(impl Find + Write),
238 events: &dyn EventSink,
239 entity: &T,
240 name_from_oid: impl FnOnce(gix_hash::ObjectId) -> ents_model::Result<FullName>,
241 identity: &Identity<'_>,
242 subject: &str,
243 mode: Mode,
244) -> Result<(FullName, Outcome)> {
245 let tree = facet_git_tree::serialize_into(entity, objects)?;
246 let tip = signed_commit(objects, tree, Vec::new(), identity, subject)?;
247 let name = name_from_oid(tip).map_err(|source| crate::Error::Model { source })?;
248 let proposal = Proposal {
249 transitions: vec![RefTransition {
250 name: name.clone(),
251 old: None,
252 new: Some(tip),
253 }],
254 objects: vec![tip],
255 auth: None,
256 };
257 let outcome = crate::receive::receive(refs, objects, events, &proposal, mode)?;
258 Ok((name, outcome))
259}
260
261/// Create a hash-identified entity whose genesis commit carries `retain` as
262/// its parents, the claim-creation path: sign-then-name exactly as
263/// [`propose_genesis`], except the genesis is not parentless. This
264/// generalizes [`propose_pin`]'s retention linkage (`model.review-pin`) to
265/// an entity-carrying commit — a claim's binding supplies its own witness
266/// commits as `retain`, so the claim's own ledger commit keeps the bound
267/// objects reachable without a separate pin ref. The only difference from
268/// [`propose_genesis`] is that parent list; [`signed_commit`] is reused
269/// unchanged.
270///
271/// There is no "advance" counterpart: a claim ref is append-once (the tip
272/// IS the genesis), so unlike [`propose_entity`] or [`propose_pin`], no
273/// second function exists here to move an existing ref forward — a changed
274/// assertion is a new claim, proposed fresh through this same function.
275///
276/// # Errors
277///
278/// See [`propose_genesis`] — identical.
279///
280/// # Examples
281///
282/// ```
283/// use ents_model::{Claim, MemberId, Provenance, claim::Verdict, namespace};
284/// use ents_receive::{Identity, Mode, NullEventSink, TxResult, propose_genesis_retaining};
285/// use ents_testutil::{CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit};
286/// use gix_ref_store::RefStoreRead as _;
287///
288/// let refs = MemRefStore::default();
289/// let objects = ObjectStore::default();
290/// let admin = Keypair::from_seed(1);
291/// enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100);
292///
293/// // The commit under claim — the content the claim keeps reachable.
294/// let tree = ents_testutil::empty_tree(&objects);
295/// let witness = write_commit(
296/// &objects,
297/// &CommitSpec { tree, parents: vec![], message: "witnessed work".into(), seconds: 200 },
298/// None,
299/// );
300///
301/// let binding = ents_anchor::Binding::Commit { commit: witness };
302/// let claim = Claim::new(MemberId::new("admin"), &binding, Verdict::Affirm, "review", &objects)
303/// .expect("serialize binding");
304///
305/// let identity = Identity {
306/// actor: gix::actor::Signature {
307/// name: "admin".into(),
308/// email: "admin@ents.test".into(),
309/// time: gix::date::Time { seconds: 300, offset: 0 },
310/// },
311/// author: None,
312/// sign: &|payload| admin.sign(payload),
313/// };
314///
315/// let (name, outcome) = propose_genesis_retaining(
316/// &refs, &objects, &NullEventSink, &claim, &[witness],
317/// |oid| namespace::claim_ref(&oid.to_string()), &identity, "Claim on witness",
318/// Mode::Advisory,
319/// )
320/// .expect("reaches an outcome");
321/// assert_eq!(outcome.result, TxResult::Applied);
322/// let tip = refs.get(name.as_ref()).expect("read").expect("ref exists");
323/// let stored = objects.get(&tip).expect("commit stored");
324/// let gix_object::Object::Commit(commit) = stored else { panic!("not a commit") };
325/// assert!(commit.parents.iter().any(|parent| *parent == witness));
326/// ```
327#[expect(
328 clippy::too_many_arguments,
329 reason = "mirrors propose_genesis's shape, plus the retained-parents slice that is this \
330 function's whole point"
331)]
332pub fn propose_genesis_retaining<T: for<'facet> facet::Facet<'facet>>(
333 refs: &dyn RefStore,
334 objects: &(impl Find + Write),
335 events: &dyn EventSink,
336 entity: &T,
337 retain: &[gix_hash::ObjectId],
338 name_from_oid: impl FnOnce(gix_hash::ObjectId) -> ents_model::Result<FullName>,
339 identity: &Identity<'_>,
340 subject: &str,
341 mode: Mode,
342) -> Result<(FullName, Outcome)> {
343 let tree = facet_git_tree::serialize_into(entity, objects)?;
344 let tip = signed_commit(objects, tree, retain.to_vec(), identity, subject)?;
345 let name = name_from_oid(tip).map_err(|source| crate::Error::Model { source })?;
346 let proposal = Proposal {
347 transitions: vec![RefTransition {
348 name: name.clone(),
349 old: None,
350 new: Some(tip),
351 }],
352 objects: vec![tip],
353 auth: None,
354 };
355 let outcome = crate::receive::receive(refs, objects, events, &proposal, mode)?;
356 Ok((name, outcome))
357}
358
359/// Advance the retention pin at `name` to keep `retain` (and its ancestry)
360/// reachable (`model.review-pin`): a signed commit carrying the empty tree
361/// — a pin's commits anchor other content's reachability and carry no
362/// entity, the sole exception to `meta-ref.namespace`'s
363/// tree-is-the-entity shape — whose parents are the pin's current tip (if
364/// any) followed by `retain`, proposed through [`crate::receive`] exactly
365/// like an entity mutation.
366///
367/// A first pin has `retain` as its only parent; every later advance is the
368/// merge-shaped fast-forward `model.review-pin` requires (previous pin
369/// tip, newly retained commit), so every retained round stays in the
370/// pin's own history and the gate's descent check (`gate.fast-forward`,
371/// descent through *any* parent) admits it unchanged.
372///
373/// # Errors
374///
375/// See [`propose_entity`] — identical, minus the serialization failure a
376/// pin cannot have (there is no entity to serialize).
377///
378/// # Examples
379///
380/// ```
381/// use ents_model::{MemberId, Provenance, namespace};
382/// use ents_receive::{Identity, Mode, NullEventSink, TxResult, propose_pin};
383/// use ents_testutil::{CommitSpec, Keypair, MemRefStore, ObjectStore, empty_tree, enroll_member};
384/// use gix_object::Write as _;
385///
386/// let refs = MemRefStore::default();
387/// let objects = ObjectStore::default();
388/// let admin = Keypair::from_seed(1);
389/// enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100);
390///
391/// // The commit under review — the content the pin keeps reachable.
392/// let tree = empty_tree(&objects);
393/// let reviewed = ents_testutil::write_commit(
394/// &objects,
395/// &CommitSpec { tree, parents: vec![], message: "reviewed work".into(), seconds: 200 },
396/// None,
397/// );
398///
399/// let name = namespace::review_pin_ref("7", &MemberId::new("admin")).expect("valid");
400/// let identity = Identity {
401/// actor: gix::actor::Signature {
402/// name: "admin".into(),
403/// email: "admin@ents.test".into(),
404/// time: gix::date::Time { seconds: 300, offset: 0 },
405/// },
406/// author: None,
407/// sign: &|payload| admin.sign(payload),
408/// };
409///
410/// let outcome = propose_pin(
411/// &refs, &objects, &NullEventSink, name, reviewed, &identity, "Pin review 7",
412/// Mode::Advisory,
413/// )
414/// .expect("reaches an outcome");
415/// assert_eq!(outcome.result, TxResult::Applied);
416/// ```
417// @relation(model.review-pin, meta-ref.namespace, scope=function)
418#[expect(
419 clippy::too_many_arguments,
420 reason = "one field per pin-mutation shape (refname, retained commit, identity, message, \
421 mode), mirroring propose_entity's identical, identically-justified shape"
422)]
423pub fn propose_pin(
424 refs: &dyn RefStore,
425 objects: &(impl Find + Write),
426 events: &dyn EventSink,
427 name: FullName,
428 retain: gix_hash::ObjectId,
429 identity: &Identity<'_>,
430 subject: &str,
431 mode: Mode,
432) -> Result<Outcome> {
433 let (transition, tip) = pin_transition(refs, objects, &name, retain, identity, subject)?;
434 let proposal = Proposal {
435 transitions: vec![transition],
436 objects: vec![tip],
437 auth: None,
438 };
439 crate::receive::receive(refs, objects, events, &proposal, mode)
440}
441
442/// Build the retention-pin [`RefTransition`] `propose_pin` and
443/// `propose_entity_with_pin` share: an empty-tree, merge-shaped signed
444/// commit whose parents are `name`'s current tip (if any) followed by
445/// `retain` (`model.review-pin`), returned alongside the tip oid the
446/// [`Proposal`] must carry.
447fn pin_transition(
448 refs: &dyn RefStore,
449 objects: &(impl Find + Write),
450 name: &FullName,
451 retain: gix_hash::ObjectId,
452 identity: &Identity<'_>,
453 subject: &str,
454) -> Result<(RefTransition, gix_hash::ObjectId)> {
455 let tree = objects.write(&gix_object::Tree { entries: vec![] })?;
456 let old = refs.get(name.as_ref())?;
457 let parents: Vec<_> = old.into_iter().chain(std::iter::once(retain)).collect();
458 let tip = signed_commit(objects, tree, parents, identity, subject)?;
459 Ok((
460 RefTransition {
461 name: name.clone(),
462 old,
463 new: Some(tip),
464 },
465 tip,
466 ))
467}
468
469/// Write an entity and its retention pin as one atomic mutation
470/// (`receive.multi-ref-atomicity`): the entity commit at `entity_name` and
471/// the empty-tree pin commit at `pin_name` (retaining `retain`) travel in a
472/// single [`Proposal`] through one [`crate::receive`] call, so the
473/// ref-store's atomic multi-ref compare-and-swap admits or refuses both
474/// together — a review is never observable with its entity written but its
475/// pin missing (`model.review`, `model.review-pin`).
476///
477/// # Errors
478///
479/// As [`propose_entity`] and [`propose_pin`], for either ref; a
480/// reached-but-negative [`Outcome`] on either transition refuses the whole
481/// batch and is returned as `Ok`.
482///
483/// # Examples
484///
485/// ```
486/// use ents_model::{MemberId, Provenance, namespace};
487/// use ents_receive::{Identity, Mode, NullEventSink, TxResult, propose_entity_with_pin};
488/// use ents_testutil::{CommitSpec, Keypair, MemRefStore, ObjectStore, empty_tree, enroll_member};
489/// use facet::Facet;
490/// use gix_ref_store::RefStoreRead as _;
491///
492/// # #[derive(Facet)]
493/// # struct Review { verdict: String }
494/// let refs = MemRefStore::default();
495/// let objects = ObjectStore::default();
496/// let admin = Keypair::from_seed(1);
497/// enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100);
498///
499/// let tree = empty_tree(&objects);
500/// let reviewed = ents_testutil::write_commit(
501/// &objects,
502/// &CommitSpec { tree, parents: vec![], message: "reviewed work".into(), seconds: 200 },
503/// None,
504/// );
505///
506/// let identity = Identity {
507/// actor: gix::actor::Signature {
508/// name: "admin".into(),
509/// email: "admin@ents.test".into(),
510/// time: gix::date::Time { seconds: 300, offset: 0 },
511/// },
512/// author: None,
513/// sign: &|payload| admin.sign(payload),
514/// };
515///
516/// let member = MemberId::new("admin");
517/// let outcome = propose_entity_with_pin(
518/// &refs, &objects, &NullEventSink,
519/// namespace::review_ref("7", &member).expect("valid"), &Review { verdict: "approve".into() },
520/// namespace::review_pin_ref("7", &member).expect("valid"), reviewed,
521/// &identity, "Review 7", "Pin review 7", Mode::Advisory,
522/// )
523/// .expect("reaches an outcome");
524/// assert_eq!(outcome.result, TxResult::Applied);
525/// // Both refs advanced together.
526/// assert!(refs.get(namespace::review_ref("7", &member).expect("valid").as_ref()).expect("read").is_some());
527/// assert!(refs.get(namespace::review_pin_ref("7", &member).expect("valid").as_ref()).expect("read").is_some());
528/// ```
529// @relation(receive.multi-ref-atomicity, model.review, model.review-pin, scope=function)
530#[expect(
531 clippy::too_many_arguments,
532 reason = "one field per ref this entity spans (entity name+value, pin name+retained commit) \
533 plus the shared identity/subjects/mode; the atomic counterpart of propose_entity \
534 and propose_pin, which carry the same justification"
535)]
536pub fn propose_entity_with_pin<T: for<'facet> facet::Facet<'facet>>(
537 refs: &dyn RefStore,
538 objects: &(impl Find + Write),
539 events: &dyn EventSink,
540 entity_name: FullName,
541 entity: &T,
542 pin_name: FullName,
543 retain: gix_hash::ObjectId,
544 identity: &Identity<'_>,
545 entity_subject: &str,
546 pin_subject: &str,
547 mode: Mode,
548) -> Result<Outcome> {
549 let (entity_transition, entity_tip) = entity_transition(
550 refs,
551 objects,
552 &entity_name,
553 entity,
554 identity,
555 entity_subject,
556 )?;
557 let (pin_transition, pin_tip) =
558 pin_transition(refs, objects, &pin_name, retain, identity, pin_subject)?;
559 let proposal = Proposal {
560 transitions: vec![entity_transition, pin_transition],
561 objects: vec![entity_tip, pin_tip],
562 auth: None,
563 };
564 crate::receive::receive(refs, objects, events, &proposal, mode)
565}
566
567/// Build, sign, and write the commit every proposal shape shares: `tree`
568/// under `subject`, authored and signed by `identity` — the one place a
569/// commit is built, whatever its tree and parents. The commit carries no
570/// reference to the ref it will name; the gate recomputes that name from
571/// the signed content (`gate.identity-binding`), which is exactly what
572/// lets [`propose_genesis`] name a ref from this commit's own oid without
573/// circularity.
574fn signed_commit(
575 objects: &impl Write,
576 tree: gix_hash::ObjectId,
577 parents: Vec<gix_hash::ObjectId>,
578 identity: &Identity<'_>,
579 subject: &str,
580) -> Result<gix_hash::ObjectId> {
581 let message = subject.to_owned();
582
583 let mut commit = Commit {
584 tree,
585 parents: parents.into(),
586 author: identity
587 .author
588 .clone()
589 .unwrap_or_else(|| identity.actor.clone()),
590 committer: identity.actor.clone(),
591 encoding: None,
592 message: message.into(),
593 extra_headers: Vec::new(),
594 };
595 let mut payload = Vec::new();
596 #[expect(
597 clippy::expect_used,
598 clippy::unwrap_in_result,
599 reason = "writing a gix_object::Commit to an in-memory Vec cannot fail; mirrors \
600 `ents_testutil::write_commit`'s identical, unguarded call"
601 )]
602 commit
603 .write_to(&mut payload)
604 .expect("serializing a commit to a Vec cannot fail");
605 let pem = (identity.sign)(&payload);
606 commit
607 .extra_headers
608 .push(("gpgsig".into(), pem.trim_end().into()));
609
610 let mut raw = Vec::new();
611 #[expect(
612 clippy::expect_used,
613 clippy::unwrap_in_result,
614 reason = "writing a gix_object::Commit to an in-memory Vec cannot fail; mirrors \
615 `ents_testutil::write_commit`'s identical, unguarded call"
616 )]
617 commit
618 .write_to(&mut raw)
619 .expect("serializing a commit to a Vec cannot fail");
620 Ok(objects.write_buf(Kind::Commit, &raw)?)
621}
622
623/// Delete the entity at `name` (a `new: None` transition) through
624/// `receive`, the same shared path [`propose_entity`] uses for writes.
625///
626/// # Errors
627///
628/// See [`propose_entity`].
629///
630/// # Examples
631///
632/// ```
633/// use ents_model::{MemberId, Provenance, namespace};
634/// use ents_receive::{Mode, NullEventSink, TxResult, propose_delete};
635/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member};
636/// use gix_ref_store::RefStoreRead as _;
637///
638/// let refs = MemRefStore::default();
639/// let objects = ObjectStore::default();
640/// let admin = Keypair::from_seed(1);
641/// enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100);
642///
643/// let name = namespace::member_ref(&MemberId::new("admin")).expect("valid");
644/// let outcome = propose_delete(&refs, &objects, &NullEventSink, name.clone(), Mode::Advisory)
645/// .expect("reaches an outcome");
646/// assert_eq!(outcome.result, TxResult::Applied);
647/// assert_eq!(refs.get(name.as_ref()).expect("readable"), None);
648/// ```
649pub fn propose_delete(
650 refs: &dyn RefStore,
651 objects: &(impl Find + Write),
652 events: &dyn EventSink,
653 name: FullName,
654 mode: Mode,
655) -> Result<Outcome> {
656 let old = refs.get(name.as_ref())?;
657 let proposal = Proposal {
658 transitions: vec![RefTransition {
659 name,
660 old,
661 new: None,
662 }],
663 objects: vec![],
664 auth: None,
665 };
666 crate::receive::receive(refs, objects, events, &proposal, mode)
667}