git-ents.gitmain
⌘K
foforge
verdict.rs184 lines · 6.5 KB · rusthistorycomment on this file
1//! The gate's verdict vocabulary: admission, refusal, and the
2//! machine-readable reason a refusal carries (`gate.verdict-reason`).
3
4use gix::refs::FullName;
5use gix_ref_store::Expected;
6
7/// The requirement a refusal names — one of the tip-invariant rules
8/// `gate.tip-signed` through `gate.atomic-cas`, exactly the range
9/// `gate.verdict-reason` requires a failure to identify.
10///
11/// [`Requirement::AtomicCas`] is never produced by [`crate::verify`]
12/// itself (the gate reads, it does not write); it exists so the caller
13/// that *does* run the compare-and-swap can report a stale-precondition
14/// rejection in the same vocabulary.
15///
16/// # Examples
17///
18/// ```
19/// use ents_gate::Requirement;
20///
21/// assert_eq!(Requirement::TipSigned.uid(), "gate.tip-signed");
22/// assert_eq!(Requirement::FastForward.uid(), "gate.fast-forward");
23/// ```
24// @relation(gate.verdict-reason, scope=file)
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Requirement {
27 /// `gate.tip-signed`: the new tip must be signed by a member
28 /// authorized for the refname.
29 TipSigned,
30 /// `gate.identity-binding`: the refname must be recomputable from the
31 /// proposed tip's signed content, per namespace
32 /// (`meta-ref.identity-binding`) — a mismatch, a doppelgänger genesis
33 /// (the all-roots walk), or a genesis tree with an unknown entry
34 /// (strict decode) all refuse here.
35 IdentityBinding,
36 /// `gate.fast-forward`: the new tip must descend from the old tip.
37 FastForward,
38 /// `gate.atomic-cas`: the update must commit via compare-and-swap
39 /// against the old tip the gate read.
40 AtomicCas,
41}
42
43impl Requirement {
44 /// The spec requirement id this variant names.
45 #[must_use]
46 pub fn uid(&self) -> &'static str {
47 match self {
48 Self::TipSigned => "gate.tip-signed",
49 Self::IdentityBinding => "gate.identity-binding",
50 Self::FastForward => "gate.fast-forward",
51 Self::AtomicCas => "gate.atomic-cas",
52 }
53 }
54}
55
56/// Why a passing update passed — advisory call sites render this, so a
57/// local UI can say "admitted under the bootstrap window" rather than a
58/// bare yes.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum AdmissionKind {
61 /// The full tip invariant held (`gate.tip-signed` through
62 /// `gate.fast-forward`, with the CAS precondition attached).
63 TipInvariant,
64 /// No verification epoch is recorded in `refs/meta/config`, and this
65 /// update does not set one: the tip invariant is not yet in force
66 /// (`gate.epoch` — history before the epoch is archival).
67 PreEpoch,
68 /// Admitted by the empty-member-list bootstrap window: a first
69 /// enrollment, self-admitting (`gate.bootstrap`).
70 Bootstrap,
71 /// The refname is outside `refs/meta/*`: branch and tag refs keep
72 /// transport-level authorization instead of the tip invariant
73 /// (`gate.principled-split`).
74 CodeRef,
75}
76
77/// A passing verdict: the update may proceed, and `cas` is the
78/// compare-and-swap precondition the write MUST use — bound to the same
79/// old-tip read the fast-forward check used, which is what makes the
80/// eventual ref update atomic against races (`gate.atomic-cas`).
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Admission {
83 /// Why the update passed.
84 pub kind: AdmissionKind,
85 /// The refname judged.
86 pub refname: FullName,
87 /// The CAS precondition for the write: `MustExistAndMatch(old tip)`
88 /// when the ref existed at verification time, `MustNotExist` when it
89 /// did not.
90 pub cas: Expected,
91}
92
93/// A failing verdict: which requirement failed, for which refname, and a
94/// rendered reason (`gate.verdict-reason` — never a bare pass/fail).
95///
96/// # Examples
97///
98/// ```
99/// use ents_gate::{Refusal, Requirement};
100///
101/// let refusal = Refusal {
102/// requirement: Requirement::TipSigned,
103/// refname: "refs/meta/issues/42".try_into().expect("valid"),
104/// detail: "your signing key is not authorized for this ref".into(),
105/// inbox_alternative: true,
106/// };
107/// let rendered = refusal.to_string();
108/// assert!(rendered.contains("gate.tip-signed"));
109/// assert!(rendered.contains("refs/meta/inbox"));
110/// ```
111// @relation(gate.verdict-reason, scope=file)
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct Refusal {
114 /// The tip-invariant rule that failed.
115 pub requirement: Requirement,
116 /// The refname the update targeted.
117 pub refname: FullName,
118 /// A human-readable, actionable reason.
119 pub detail: String,
120 /// Whether submitting through the inbox namespace would be accepted
121 /// instead — set on authorization refusals so advisory call sites can
122 /// surface the inbox alternative at verdict time, not only once a
123 /// push is rejected (`gate.advisory-local`, `sync.inbox-routing`).
124 pub inbox_alternative: bool,
125}
126
127impl std::fmt::Display for Refusal {
128 // @relation(gate.verdict-reason, gate.advisory-local, scope=function)
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 write!(
131 f,
132 "{} (rule {}, ref {})",
133 self.detail,
134 self.requirement.uid(),
135 self.refname.as_bstr()
136 )?;
137 if self.inbox_alternative {
138 write!(
139 f,
140 "; you can still submit this change under your own refs/meta/inbox/<member>/* segment for adoption by an authorized member"
141 )?;
142 }
143 Ok(())
144 }
145}
146
147/// The gate's verdict on one proposed ref update.
148///
149/// The same value is computed at all three call sites
150/// (`gate.call-sites`); what differs is only what the caller does with a
151/// [`Verdict::Fail`] — abort the transaction (hosted CAS,
152/// `gate.mandatory-hosted`) or annotate and proceed (local UI and push
153/// pre-flight, `gate.advisory-local`).
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum Verdict {
156 /// The update satisfies the gate; write it with
157 /// [`Admission::cas`] as the precondition.
158 Pass(Admission),
159 /// The update violates the tip invariant; the refusal says which
160 /// rule, for which ref, and why.
161 Fail(Refusal),
162}
163
164impl Verdict {
165 /// Whether this verdict admits the update.
166 ///
167 /// # Examples
168 ///
169 /// ```
170 /// use ents_gate::{Admission, AdmissionKind, Verdict};
171 /// use gix_ref_store::Expected;
172 ///
173 /// let verdict = Verdict::Pass(Admission {
174 /// kind: AdmissionKind::CodeRef,
175 /// refname: "refs/heads/main".try_into().expect("valid"),
176 /// cas: Expected::MustNotExist,
177 /// });
178 /// assert!(verdict.is_pass());
179 /// ```
180 #[must_use]
181 pub fn is_pass(&self) -> bool {
182 matches!(self, Self::Pass(_))
183 }
184}