crates/kernel/ents-model/src/claim.rs
claim.rshistorycomment 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 | |
| 11 | use facet::Facet; |
| 12 | use facet_git_tree::RawTree; |
| 13 | use gix_object::{Find, Write}; |
| 14 | |
| 15 | use crate::error::{Error, Result}; |
| 16 | use 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)] |
| 36 | pub 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 | |
| 45 | impl 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 | |
| 60 | impl 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)] |
| 121 | pub 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 | |
| 134 | impl 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)] |
| 172 | mod 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 | } |