git-ents.gitmain
⌘K
foforge
preflight.rs167 lines · 7.3 KB · rusthistorycomment on this file
1//! Push pre-flight and inbox routing — turning the gate's verdict into a
2//! decision the user acts on before pushing.
3//!
4//! Two requirements live here. [`preflight`] runs the *identical* gate
5//! function every other call site runs ([`ents_gate::verify`],
6//! `gate.call-sites`), so a pre-flight verdict is a prediction that can
7//! only go stale between pre-flight and the hosted CAS, never one that is
8//! wrong about the rules (`sync.pre-flight`). And [`inbox_route`] computes
9//! the alternative a negative verdict offers: the same commit re-homed
10//! under the author's own `refs/meta/inbox/<member>/*` segment, awaiting
11//! adoption (`sync.inbox-routing`).
12//!
13//! The offer is a function of the verdict alone, so it is available the
14//! moment the verdict goes negative — at all three advisory sites the spec
15//! names (the local UI verdict at commit time, push pre-flight, and the
16//! canonical store's actual rejection, `sync.inbox-routing`) — not only
17//! after a push is attempted and refused. Sync never blocks a *local*
18//! write on a failing verdict (`sync.local-advisory`); the consequence it
19//! owns is exactly this inbox offer.
20
21use ents_gate::{Update, Verdict, verify};
22use ents_model::{MemberId, namespace};
23use gix::refs::{FullName, FullNameRef};
24use gix_object::Find;
25use gix_ref_store::RefStoreRead;
26
27use crate::error::Result;
28
29/// A pre-flight prediction: the gate's verdict on a proposed update, plus
30/// the inbox alternative a negative verdict offers (`sync.pre-flight`,
31/// `sync.inbox-routing`).
32///
33/// `verdict` is produced by the same [`ents_gate::verify`] the hosted store
34/// runs at CAS time, so it predicts that outcome exactly up to staleness of
35/// the last fetch — it cannot disagree about the rules (`gate.call-sites`).
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct PreFlight {
38 /// The gate's verdict on the proposed update.
39 pub verdict: Verdict,
40 /// Where the same commit could instead be routed — the author's own
41 /// inbox ref — when the verdict is a negative one the inbox can absorb
42 /// (`sync.inbox-routing`). `None` on a pass, and on refusals the inbox
43 /// cannot help (a divergence, whose answer is a merge, or a refname
44 /// mismatch).
45 pub inbox: Option<FullName>,
46}
47
48impl PreFlight {
49 /// Whether the gate admits the update. A caller pushing to a hosted
50 /// store treats a false here as a prediction the push will be refused
51 /// — never as authority to block a *local* write (`sync.local-advisory`):
52 /// this type carries no write access at all, so a failing verdict is
53 /// structurally incapable of vetoing one; the rejection consequence
54 /// sync owns instead is [`PreFlight::inbox`] (`sync.inbox-routing`).
55 // @relation(sync.local-advisory, scope=function)
56 #[must_use]
57 pub fn is_pass(&self) -> bool {
58 self.verdict.is_pass()
59 }
60}
61
62/// Evaluate push pre-flight for one proposed update (`sync.pre-flight`).
63///
64/// Runs the identical gate function the hosted store runs
65/// (`gate.call-sites`) against the local (last-fetched) snapshot, and, when
66/// the verdict is a refusal the inbox can absorb, attaches the route the
67/// author would use instead (`sync.inbox-routing`). `author` is the member
68/// whose inbox segment such a routed commit would land under — its own, and
69/// only its own (`meta-ref.inbox`).
70///
71/// # Errors
72///
73/// Propagates [`ents_gate::Error`] when the gate cannot evaluate (a store
74/// or object read failed) and a refname error if the inbox route cannot be
75/// built.
76///
77/// # Examples
78///
79/// ```
80/// use ents_model::{MemberId, Provenance, namespace};
81/// use ents_sync::preflight::preflight;
82/// use ents_gate::Update;
83/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member, write_meta_entity};
84///
85/// // A stand-in for `ents-forge`'s `Issue` (this crate cannot depend on
86/// // `ents-forge`): any Facet-derived entity exercises pre-flight.
87/// # #[derive(facet::Facet)]
88/// # struct Issue { title: String, body: String, state: String }
89/// #
90/// let refs = MemRefStore::default();
91/// let objects = ObjectStore::default();
92/// let admin = Keypair::from_seed(1);
93/// enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100);
94/// let config: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid");
95/// write_meta_entity(&refs, &objects, config, &ents_gate::Config { epoch: Some(200) }, Some(&admin), 200);
96///
97/// // A self-attested contributor's canonical issue push fails pre-flight,
98/// // and the inbox route is offered at once.
99/// let bob = Keypair::from_seed(2);
100/// enroll_member(&refs, &objects, "bob", &bob, Provenance::SelfAttested, 250);
101/// let name: gix::refs::FullName = "refs/meta/issues/9".try_into().expect("valid");
102/// let issue = Issue { title: "t".into(), body: "b".into(), state: "open".into() };
103/// let tip = write_meta_entity(&refs, &objects, name.clone(), &issue, Some(&bob), 300);
104///
105/// let before = refs.fetched_copy();
106/// before.remove(name.as_ref());
107/// let pf = preflight(&before, &objects, &Update { name, new: Some(tip) }, &MemberId::new("bob")).expect("evaluates");
108/// assert!(!pf.is_pass());
109/// assert_eq!(pf.inbox.expect("offered").as_bstr(), "refs/meta/inbox/bob/issues/9");
110/// ```
111// @relation(sync.pre-flight, sync.inbox-routing, scope=function)
112pub fn preflight(
113 refs: &dyn RefStoreRead,
114 objects: &dyn Find,
115 update: &Update,
116 author: &MemberId,
117) -> Result<PreFlight> {
118 let verdict = verify(refs, objects, update)?;
119 let inbox = match &verdict {
120 // The gate already decided whether the inbox is the alternative:
121 // `inbox_alternative` is set exactly on authorization refusals
122 // against a canonical ref, and cleared for divergences (answer: a
123 // merge) and refname mismatches (`gate.verdict-reason`,
124 // `gate.advisory-local`).
125 Verdict::Fail(refusal) if refusal.inbox_alternative => {
126 Some(inbox_route(update.name.as_ref(), author)?)
127 }
128 _ => None,
129 };
130 Ok(PreFlight { verdict, inbox })
131}
132
133/// The inbox ref a rejected commit against `canonical` would be routed to,
134/// under `author`'s own segment (`sync.inbox-routing`, `meta-ref.inbox`).
135///
136/// The canonical ref's suffix below `refs/meta/` becomes the inbox id, so
137/// `refs/meta/issues/42` routes to `refs/meta/inbox/<author>/issues/42`:
138/// the destination records both who is submitting and what they submit,
139/// and stays under the author's own segment, the only place a member may
140/// write (`meta-ref.inbox`). A refname already outside `refs/meta/`, or an
141/// already-inbox ref, is returned unchanged — there is nothing to re-route.
142///
143/// # Errors
144///
145/// Propagates a refname error if the composed inbox refname is invalid.
146///
147/// # Examples
148///
149/// ```
150/// use ents_model::MemberId;
151/// use ents_sync::preflight::inbox_route;
152///
153/// let canonical: gix::refs::FullName = "refs/meta/issues/42".try_into().expect("valid");
154/// let routed = inbox_route(canonical.as_ref(), &MemberId::new("jdc")).expect("valid");
155/// assert_eq!(routed.as_bstr(), "refs/meta/inbox/jdc/issues/42");
156/// ```
157// @relation(sync.inbox-routing, scope=function)
158pub fn inbox_route(canonical: &FullNameRef, author: &MemberId) -> Result<FullName> {
159 if namespace::is_inbox(canonical) {
160 return Ok(canonical.to_owned());
161 }
162 let path = canonical.as_bstr().to_string();
163 let Some(suffix) = path.strip_prefix("refs/meta/") else {
164 return Ok(canonical.to_owned());
165 };
166 Ok(namespace::inbox_ref(author, suffix)?)
167}