git-ents.gitmain
⌘K
foforge
claim.rs238 lines · 8.0 KB · rusthistorycomment on this file
1//! The Claim entity: a signer's verdict on an [`ents_anchor::Binding`],
2//! under an opaque kind — the kernel's shared building block for a
3//! comment's thread state, a review's approval, a CI result, or any other
4//! package's assertion about the object graph, without the kernel ever
5//! enumerating what those assertions mean.
6//!
7//! No spec id is assigned to this entity yet — claim vocabularies are
8//! being specified separately — so nothing in this module carries an
9//! `@relation` marker.
10
11use facet::Facet;
12use facet_git_tree::RawTree;
13use gix_object::{Find, Write};
14
15use crate::error::{Error, Result};
16use crate::member::MemberId;
17
18/// A claim's verdict: what its signer asserts about its binding.
19///
20/// Parses from and renders as its kebab-case convention names (`affirm`,
21/// `deny`, `note`), the same strings every surface shows — modeled on
22/// `ents_forge::review::Verdict`.
23///
24/// # Examples
25///
26/// ```
27/// use ents_model::claim::Verdict;
28///
29/// let verdict: Verdict = "deny".parse().expect("known verdict");
30/// assert_eq!(verdict, Verdict::Deny);
31/// assert_eq!(verdict.to_string(), "deny");
32/// assert!("maybe".parse::<Verdict>().is_err());
33/// ```
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)]
35#[repr(u8)]
36pub enum Verdict {
37 /// The claim affirms its binding.
38 Affirm,
39 /// The claim denies its binding.
40 Deny,
41 /// Judgment withheld: the claim exists for its kind and context alone.
42 Note,
43}
44
45impl std::str::FromStr for Verdict {
46 type Err = Error;
47
48 fn from_str(text: &str) -> Result<Self> {
49 match text {
50 "affirm" => Ok(Self::Affirm),
51 "deny" => Ok(Self::Deny),
52 "note" => Ok(Self::Note),
53 other => Err(Error::InvalidArgument(format!(
54 "unknown verdict {other:?}: expected affirm, deny, or note"
55 ))),
56 }
57 }
58}
59
60impl std::fmt::Display for Verdict {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 f.write_str(match self {
63 Self::Affirm => "affirm",
64 Self::Deny => "deny",
65 Self::Note => "note",
66 })
67 }
68}
69
70/// A signer's verdict on a binding, under an opaque kind.
71///
72/// A `Claim` lives one-ref-per-claim at `refs/meta/claims/<id>`
73/// ([`crate::namespace::claim_ref`]), where `<id>` is the claim's own
74/// genesis commit oid: the repository's standard sign-then-name envelope,
75/// exactly like a comment or an issue. Unlike those, a claim ref is
76/// append-once — the tip IS the genesis, and a changed assertion is a new
77/// claim, never an advance — so `signer` here must equal the ledger
78/// commit's actual signer, which the gate binds
79/// (`meta-ref.identity-binding`'s natural-key shape, applied to a
80/// genesis-only ref).
81///
82/// `binding` embeds an [`ents_anchor::Binding`] by tree id, opaque to this
83/// crate exactly as `ents-forge`'s `Comment::anchor` embeds an anchor —
84/// [`Claim::new`] and [`Claim::binding`] do the round trip so no caller
85/// hand-wires the tree. The claim's ledger commit carries the
86/// binding's own [`ents_anchor::Binding::witnesses`] as its parents, which
87/// is what keeps the bound objects reachable; building that commit is a
88/// caller concern (`ents_receive`), not this struct's.
89///
90/// `kind` is a plain opaque string: kind vocabularies (`review`, `ci`, or
91/// anything else a package invents) are package policy, never kernel
92/// enumeration — this crate never matches on it.
93///
94/// # Examples
95///
96/// ```
97/// use ents_anchor::Binding;
98/// use ents_model::MemberId;
99/// use ents_model::claim::{Claim, Verdict};
100/// use facet_git_tree::ObjectStore;
101///
102/// let store = ObjectStore::default();
103/// let commit = gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567")
104/// .expect("valid hex");
105/// let binding = Binding::Commit { commit };
106///
107/// let claim = Claim::new(MemberId::new("jdc"), &binding, Verdict::Affirm, "review", &store)
108/// .expect("serialize");
109/// assert_eq!(claim.kind, "review");
110///
111/// // The binding round-trips through the claim unchanged.
112/// let back = claim.binding(&store).expect("deserialize");
113/// assert_eq!(back, binding);
114///
115/// // The claim itself is an ordinary typed tree.
116/// let root = facet_git_tree::serialize_into(&claim, &store).expect("serialize claim");
117/// let claim_back: Claim = facet_git_tree::deserialize(&root, &store).expect("deserialize claim");
118/// assert_eq!(claim_back, claim);
119/// ```
120#[derive(Debug, Clone, PartialEq, Eq, Facet)]
121pub struct Claim {
122 /// The member who signed this claim — must equal the ledger commit's
123 /// actual signer, which the gate binds.
124 pub signer: MemberId,
125 /// The serialized [`ents_anchor::Binding`]'s tree, embedded by tree id
126 /// ([`Claim::new`], [`Claim::binding`] do the round trip).
127 pub binding: RawTree,
128 /// What the signer asserts about the binding.
129 pub verdict: Verdict,
130 /// An opaque kind string — package policy, never kernel vocabulary.
131 pub kind: String,
132}
133
134impl Claim {
135 /// Build a claim of `binding`, serializing it into `store` and
136 /// recording the resulting tree by id.
137 ///
138 /// # Errors
139 ///
140 /// [`Error::Anchor`] if `binding` cannot be serialized into `store`.
141 pub fn new<W: Write + ?Sized>(
142 signer: MemberId,
143 binding: &ents_anchor::Binding,
144 verdict: Verdict,
145 kind: impl Into<String>,
146 store: &W,
147 ) -> Result<Self> {
148 let root = binding.serialize_into(store)?;
149 Ok(Self {
150 signer,
151 binding: RawTree::new(root),
152 verdict,
153 kind: kind.into(),
154 })
155 }
156
157 /// Read this claim's binding back out of `store`.
158 ///
159 /// # Errors
160 ///
161 /// [`Error::Anchor`] if the recorded tree cannot be read or does not
162 /// match any known [`ents_anchor::Binding`] shape.
163 pub fn binding<F: Find + ?Sized>(&self, store: &F) -> Result<ents_anchor::Binding> {
164 Ok(ents_anchor::Binding::deserialize(
165 &self.binding.oid(),
166 store,
167 )?)
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 #![allow(
174 clippy::unwrap_used,
175 clippy::expect_used,
176 clippy::panic,
177 reason = "unit test"
178 )]
179
180 use ents_anchor::Binding;
181 use facet::{Facet as _, Type, UserType};
182 use facet_git_tree::{ObjectStore, deserialize, serialize_into};
183 use gix_hash::ObjectId;
184 use rstest::rstest;
185
186 use super::*;
187
188 fn hex(byte: u8) -> ObjectId {
189 let hex_digit = format!("{byte:x}");
190 let full = hex_digit.repeat(40);
191 ObjectId::from_hex(full.as_bytes()).unwrap()
192 }
193
194 #[rstest]
195 #[case::affirm(Verdict::Affirm)]
196 #[case::deny(Verdict::Deny)]
197 #[case::note(Verdict::Note)]
198 fn claim_round_trips_with_every_verdict(#[case] verdict: Verdict) {
199 let store = ObjectStore::default();
200 let binding = Binding::Commit { commit: hex(1) };
201 let claim = Claim::new(MemberId::new("jdc"), &binding, verdict, "review", &store)
202 .expect("new claim");
203
204 let root = serialize_into(&claim, &store).expect("serialize");
205 let back: Claim = deserialize(&root, &store).expect("deserialize");
206 assert_eq!(back, claim);
207 }
208
209 #[rstest]
210 #[case::commit(Binding::Commit { commit: hex(1) })]
211 #[case::tree(Binding::Tree {
212 tree: hex(2),
213 path: "src/lib.rs".to_owned(),
214 witness: hex(3),
215 })]
216 fn new_and_binding_round_trip_an_actual_binding(#[case] binding: Binding) {
217 let store = ObjectStore::default();
218 let claim = Claim::new(
219 MemberId::new("jdc"),
220 &binding,
221 Verdict::Affirm,
222 "review",
223 &store,
224 )
225 .expect("new claim");
226 let back = claim.binding(&store).expect("read binding back");
227 assert_eq!(back, binding);
228 }
229
230 #[test]
231 fn field_order_is_signer_binding_verdict_kind() {
232 let Type::User(UserType::Struct(struct_ty)) = Claim::SHAPE.ty else {
233 panic!("Claim must reflect as a struct");
234 };
235 let names: Vec<_> = struct_ty.fields.iter().map(|f| f.name).collect();
236 assert_eq!(names, vec!["signer", "binding", "verdict", "kind"]);
237 }
238}