git-ents.gitmain
⌘K
foforge
receive.rs246 lines · 10.0 KB · rusthistorycomment on this file
1//! `receive`: the sole entry point through which a meta-ref or branch ref
2//! is mutated (`receive.unit`).
3
4use std::collections::HashSet;
5
6use ents_gate::{Update, verify};
7use ents_model::Redaction;
8use ents_query::Transition;
9use gix_hash::ObjectId;
10use gix_object::Find;
11use gix_ref_store::{Expected, RefEdit, RefStore, TxOutcome};
12
13use crate::error::Result;
14use crate::outcome::{Mode, Outcome, TxResult};
15use crate::proposal::Proposal;
16use crate::reconcile::{commit_tree, enqueue_matches};
17use crate::sink::EventSink;
18
19const REDACTIONS_PREFIX: &str = "refs/meta/redactions/";
20
21/// The sole entry point through which a meta-ref or branch ref is mutated
22/// (`receive.unit`).
23///
24/// Gate evaluation, redaction enforcement, effect-footprint matching, and
25/// enqueue all live here, above the `RefStore` (`refs`), object-store
26/// (`objects`), and [`EventSink`] (`events`) traits this function is
27/// handed — never duplicated in a caller (`receive.unit`,
28/// `arch.gate-receive-split`). Every mutation frontend (the CLI, the local
29/// UI, a hosted smart-HTTP hook) MUST call exactly this function
30/// in-process, with only the trait implementations and `mode` differing
31/// (`receive.shared-path`): a `LooseRefStore` and a null sink locally, a
32/// Postgres-backed store and a durable queue hosted.
33///
34/// # Order of operations
35///
36/// 1. **Redaction ingest** (`receive.redaction-ingest`): every object id
37/// `proposal.objects` introduces is checked against the redaction
38/// targets recorded under `refs/meta/redactions/*`. A match refuses the
39/// *entire* batch before any verdict is even evaluated — a redacted
40/// hole cannot be silently refilled by re-pushing the same bytes.
41/// 2. **Gate evaluation** (`receive.refstore-seam`): every proposed
42/// transition is judged by the identical [`ents_gate::verify`] every
43/// other call site uses (`gate.call-sites`), read against `refs`'s read
44/// half only.
45/// 3. **Gate policy** (`Mode`): [`Mode::Mandatory`] aborts the whole batch
46/// before writing anything if any verdict failed
47/// (`gate.mandatory-hosted`); [`Mode::Advisory`] writes every transition
48/// regardless of its verdict (`gate.advisory-local`) — the verdicts are
49/// still returned for the caller to render.
50/// 4. **Atomic write**: every transition lands as one
51/// [`RefStore::transaction`] call, so either the whole batch applies or
52/// none of it does (`gate.atomic-cas`). A stale precondition — another
53/// writer moved a ref between step 2 and here — surfaces as
54/// [`TxResult::Rejected`], in the gate's own vocabulary
55/// (`Requirement::AtomicCas`).
56/// 5. **Enqueue** (`receive.event-sink`, `receive.never-blocks`): once the
57/// batch is durably applied, every known effect's static footprint is
58/// matched against each transition and the entry set — `trigger −
59/// results(self, any)`, `query.workset` — is enqueued into `events`.
60/// This is the entire synchronous cost added to the write; no effect is
61/// evaluated here.
62///
63/// # Object access
64///
65/// Per `receive.object-access`, object access here uses only gitoxide's
66/// own traits — [`Find`] and [`gix_object::Write`] — never a private
67/// object-access trait. `gix_object::Exists` is gitoxide's third named
68/// trait for this seam; it is omitted from this signature because the
69/// shared fixture (`ents_testutil::ObjectStore`, from the external
70/// `facet-git-tree` crate) does not implement it — every existence check
71/// this crate needs goes through `Find` instead (`try_find(..).is_some()`),
72/// which is not a private trait and keeps `arch.no-object-store-trait`
73/// intact.
74///
75/// Which object directory `objects` resolves through (the common odb, never
76/// a git hook's quarantine directory, until its transaction commits) is the
77/// composition root's responsibility to wire, not this function's: `receive`
78/// only ever sees the seam it is handed.
79///
80/// # Errors
81///
82/// A [`crate::Error`] means `receive` could not reach an outcome at all
83/// (a store or object read failed) — distinct from every variant of
84/// [`Outcome`], which is a reached judgment.
85///
86/// # Examples
87///
88/// A minimal advisory, null-sink round trip: enroll an admin, set the
89/// epoch, then land a signed issue mutation.
90///
91/// ```
92/// use ents_gate::Config;
93/// use ents_model::{Provenance, namespace};
94/// use ents_receive::{Mode, NullEventSink, Proposal, RefTransition, TxResult, receive};
95/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member, write_meta_entity};
96///
97/// // A stand-in for `ents-forge`'s `Issue` (this crate cannot depend on
98/// // `ents-forge`, which itself depends on this crate): any
99/// // Facet-derived entity exercises `receive`.
100/// # #[derive(facet::Facet)]
101/// # struct Issue { title: String, body: String, state: String }
102/// #
103/// let refs = MemRefStore::default();
104/// let objects = ObjectStore::default();
105/// let admin = Keypair::from_seed(1);
106///
107/// enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100);
108/// let config_ref: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid");
109/// write_meta_entity(&refs, &objects, config_ref, &Config { epoch: Some(200) }, Some(&admin), 200);
110///
111/// let issue = Issue {
112/// title: "t".into(), body: "b".into(), state: "open".into(),
113/// };
114/// // A hash-identified entity is created by sign-then-name: build the
115/// // genesis commit first, then name the ref from its own oid
116/// // (`meta-ref.identity-binding`).
117/// let tip = {
118/// let tree = facet_git_tree::serialize_into(&issue, &objects).expect("serializes");
119/// ents_testutil::write_commit(&objects, &ents_testutil::CommitSpec {
120/// tree, parents: vec![], message: "Open an issue".into(), seconds: 300,
121/// }, Some(&admin))
122/// };
123/// let name: gix::refs::FullName = format!("refs/meta/issues/{tip}").try_into().expect("valid");
124///
125/// let proposal = Proposal {
126/// transitions: vec![RefTransition { name: name.clone(), old: None, new: Some(tip) }],
127/// objects: vec![tip],
128/// auth: None,
129/// };
130///
131/// let outcome = receive(&refs, &objects, &NullEventSink, &proposal, Mode::Advisory).expect("evaluates");
132/// assert_eq!(outcome.result, TxResult::Applied);
133/// assert!(outcome.verdicts[0].1.is_pass());
134/// ```
135// @relation(receive.unit, receive.shared-path, receive.refstore-seam, receive.object-access, scope=function)
136pub fn receive(
137 refs: &dyn RefStore,
138 objects: &(impl Find + gix_object::Write),
139 events: &dyn EventSink,
140 proposal: &Proposal,
141 mode: Mode,
142) -> Result<Outcome> {
143 // @relation(receive.redaction-ingest, scope=function)
144 if let Some(oid) = first_redacted(refs, objects, proposal)? {
145 return Ok(Outcome {
146 verdicts: Vec::new(),
147 result: TxResult::Redacted { oid },
148 });
149 }
150
151 let mut verdicts = Vec::with_capacity(proposal.transitions.len());
152 let mut edits = Vec::with_capacity(proposal.transitions.len());
153 let mut query_transitions = Vec::with_capacity(proposal.transitions.len());
154 let mut any_failed = false;
155
156 for transition in &proposal.transitions {
157 let old = refs.get(transition.name.as_ref())?;
158 // gate.call-sites: the identical function every other call site uses.
159 // receive.redaction-admin-only is a consequence of this composition:
160 // `verify` already refuses refs/meta/redactions/* to a non-admin
161 // signer via its default namespace-authorization arm, regardless of
162 // any refs/meta/config role rule, so no separate check is needed
163 // here.
164 // @relation(gate.call-sites, receive.redaction-admin-only, scope=function)
165 let verdict = verify(
166 refs,
167 objects,
168 &Update {
169 name: transition.name.clone(),
170 new: transition.new,
171 },
172 )?;
173 any_failed |= !verdict.is_pass();
174 verdicts.push((transition.name.clone(), verdict));
175
176 let expected = old.map_or(Expected::MustNotExist, Expected::MustExistAndMatch);
177 edits.push(RefEdit {
178 name: transition.name.clone(),
179 expected,
180 new: transition.new,
181 });
182 query_transitions.push(Transition {
183 name: transition.name.clone(),
184 old,
185 new: transition.new,
186 });
187 }
188
189 // gate.mandatory-hosted: abort the whole batch before writing anything.
190 // gate.advisory-local is the fallthrough: every transition is written
191 // below regardless of `any_failed`.
192 // @relation(gate.mandatory-hosted, scope=function)
193 if any_failed && mode == Mode::Mandatory {
194 return Ok(Outcome {
195 verdicts,
196 result: TxResult::Refused,
197 });
198 }
199
200 let result = if edits.is_empty() {
201 TxResult::Applied
202 } else {
203 match refs.transaction(&edits)? {
204 TxOutcome::Applied => TxResult::Applied,
205 TxOutcome::Rejected { name } => TxResult::Rejected { name },
206 }
207 };
208
209 // receive.event-sink, receive.never-blocks: enqueue is the entire
210 // synchronous cost; no effect is evaluated here.
211 if result == TxResult::Applied {
212 for transition in &query_transitions {
213 enqueue_matches(refs, objects, events, transition)?;
214 }
215 }
216
217 Ok(Outcome { verdicts, result })
218}
219
220/// The first object in `proposal.objects` that matches a target recorded
221/// under `refs/meta/redactions/*`, if any (`receive.redaction-ingest`).
222fn first_redacted(
223 refs: &dyn gix_ref_store::RefStoreRead,
224 objects: &impl Find,
225 proposal: &Proposal,
226) -> Result<Option<ObjectId>> {
227 if proposal.objects.is_empty() {
228 return Ok(None);
229 }
230 let mut targets = HashSet::new();
231 for entry in refs.iter_prefix(REDACTIONS_PREFIX)? {
232 let (_, tip) = entry?;
233 let Some(tree) = commit_tree(objects, tip)? else {
234 continue;
235 };
236 let Ok(redaction) = facet_git_tree::deserialize::<Redaction>(&tree, objects) else {
237 continue;
238 };
239 targets.insert(redaction.target());
240 }
241 Ok(proposal
242 .objects
243 .iter()
244 .copied()
245 .find(|oid| targets.contains(oid)))
246}