crates/kernel/ents-receive/src/receive.rs
receive.rshistorycomment on this file
| 1 | //! `receive`: the sole entry point through which a meta-ref or branch ref |
| 2 | //! is mutated (`receive.unit`). |
| 3 | |
| 4 | use std::collections::HashSet; |
| 5 | |
| 6 | use ents_gate::{Update, verify}; |
| 7 | use ents_model::Redaction; |
| 8 | use ents_query::Transition; |
| 9 | use gix_hash::ObjectId; |
| 10 | use gix_object::Find; |
| 11 | use gix_ref_store::{Expected, RefEdit, RefStore, TxOutcome}; |
| 12 | |
| 13 | use crate::error::Result; |
| 14 | use crate::outcome::{Mode, Outcome, TxResult}; |
| 15 | use crate::proposal::Proposal; |
| 16 | use crate::reconcile::{commit_tree, enqueue_matches}; |
| 17 | use crate::sink::EventSink; |
| 18 | |
| 19 | const 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) |
| 136 | pub 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`). |
| 222 | fn 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 | } |