crates/verify/ents-verify/src/search.rs
search.rshistorycomment on this file
| 1 | //! Phase 0.5 — verify the verifier (`verify/exercise.md`, "Phase 0.5"), |
| 2 | //! replacing `verify/alloy/gate_rules.als`'s `check` commands with an |
| 3 | //! exhaustive [`stateright`] search over the crate's own vocabulary. |
| 4 | //! |
| 5 | //! `ents_gate_rules` can *evaluate* its seven denial rules over one |
| 6 | //! supplied transaction; it cannot search for the transaction nobody |
| 7 | //! thought of. This module runs that search: [`SearchModel`]'s states |
| 8 | //! are a transaction under construction, one choice at a time, over the |
| 9 | //! bounded universe in `crate::{REFS, OIDS, KEYS}` plus the shadow |
| 10 | //! [`Kind`](crate::Kind) annotation, and its six [`Property::always`] |
| 11 | //! obligations restate — independently, in hand-written Rust, not by |
| 12 | //! calling `gate` a second time — exactly the five doc invariants the |
| 13 | //! seven rules claim to cover, plus the refname-binding claim they do |
| 14 | //! not. A property's *discovery* is a transaction `gate` admits that |
| 15 | //! violates the corresponding independent check. |
| 16 | //! |
| 17 | //! # The bound, and why it stays this small |
| 18 | //! |
| 19 | //! A state is exactly five choices, made in a fixed order: refname, |
| 20 | //! transaction [`Shape`], [`Signer`], [`Retention`], and the new tip's |
| 21 | //! [`Kind`]. That is `4 x 4 x 3 x 4 x 3 = 576` leaf states — small enough |
| 22 | //! for [`Model::checker`] to explore exhaustively in well under a |
| 23 | //! second. A naive "add any single EDB atom from the domain" search (a |
| 24 | //! literal transcription of Alloy's relational style) was tried first |
| 25 | //! and rejected: with `parent`/`signed_by`/`anchor`/`context`/ |
| 26 | //! `object_exists` each ranging freely over `OIDS x OIDS` or `OIDS x |
| 27 | //! KEYS`, the reachable state count is the size of the powerset of every |
| 28 | //! possible atom, not the five-transaction-shapes count above — tens of |
| 29 | //! thousands of atoms' worth of subsets, intractable for exhaustive BFS. |
| 30 | //! [`Shape`] instead enumerates the four transaction shapes |
| 31 | //! `ents_gate_rules`' own unit tests already hand-build (creation, |
| 32 | //! fast-forward advance, non-fast-forward, and the second-root merge), |
| 33 | //! which is everything the seven rules' *logic* actually branches on; |
| 34 | //! membership is fixed background ([`crate::enroll_all`]) rather than a |
| 35 | //! search dimension, since membership *lifecycle* is Phase 3's concern |
| 36 | //! (`receive.rs`), not Phase 0.5's. |
| 37 | |
| 38 | use ents_gate_rules::{Facts, gate}; |
| 39 | use stateright::{Model, Property}; |
| 40 | |
| 41 | use crate::{ADMIN_KEY, Kind, MEMBER_KEY_1, REFS, enroll_all}; |
| 42 | |
| 43 | /// The shape of the proposed transaction — the one dimension along which |
| 44 | /// the seven denial rules' *logic* actually branches, standing in for |
| 45 | /// the full `parent`/`ref_update` relations' combinatorics. |
| 46 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 47 | pub enum Shape { |
| 48 | /// `ref_update(r, None, new)` with `new` parentless: entity creation. |
| 49 | Genesis, |
| 50 | /// `ref_update(r, Some(old), new)` with `new` a descendant of `old` |
| 51 | /// via one intermediate commit — the admitted case. |
| 52 | FastForward, |
| 53 | /// `ref_update(r, Some(old), new)` with `new` unrelated to `old` — |
| 54 | /// `ff_violation`'s witness. |
| 55 | NonFf, |
| 56 | /// `ref_update(r, Some(old), new)` with `new` a merge of the |
| 57 | /// fast-forward chain and an unrelated parentless commit — |
| 58 | /// `second_root_violation`'s witness |
| 59 | /// (`ents_gate_rules::tests::merged_in_second_root_is_rejected`). |
| 60 | SecondRoot, |
| 61 | } |
| 62 | |
| 63 | /// All four transaction shapes, in a fixed enumeration order. |
| 64 | pub const SHAPES: [Shape; 4] = [ |
| 65 | Shape::Genesis, |
| 66 | Shape::FastForward, |
| 67 | Shape::NonFf, |
| 68 | Shape::SecondRoot, |
| 69 | ]; |
| 70 | |
| 71 | /// Who signs every commit the transaction introduces — one signer |
| 72 | /// applies uniformly to keep the state small; per-commit signer |
| 73 | /// variation is Phase 3's concern, not this search's. |
| 74 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 75 | pub enum Signer { |
| 76 | /// Every introduced commit signed by [`ADMIN_KEY`]. |
| 77 | Admin, |
| 78 | /// Every introduced commit signed by [`MEMBER_KEY_1`]. |
| 79 | Member, |
| 80 | /// No introduced commit carries a signature. |
| 81 | Unsigned, |
| 82 | } |
| 83 | |
| 84 | /// All three signer choices, in a fixed enumeration order. |
| 85 | pub const SIGNERS: [Signer; 3] = [Signer::Admin, Signer::Member, Signer::Unsigned]; |
| 86 | |
| 87 | /// Whether the new tip carries anchor/context retention, and whether it |
| 88 | /// resolves — only meaningful for [`Shape::Genesis`] (a comment-shaped |
| 89 | /// creation); every other shape treats this as absent. |
| 90 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 91 | pub enum Retention { |
| 92 | /// No anchor or context blob. |
| 93 | Absent, |
| 94 | /// Anchor and context present, both resolving. |
| 95 | Resolving, |
| 96 | /// Anchor and context present; the anchored blob does not resolve. |
| 97 | DanglingAnchor, |
| 98 | /// Anchor and context present; the context blob does not resolve. |
| 99 | DanglingContext, |
| 100 | } |
| 101 | |
| 102 | /// All four retention choices, in a fixed enumeration order. |
| 103 | pub const RETENTIONS: [Retention; 4] = [ |
| 104 | Retention::Absent, |
| 105 | Retention::Resolving, |
| 106 | Retention::DanglingAnchor, |
| 107 | Retention::DanglingContext, |
| 108 | ]; |
| 109 | |
| 110 | /// All three [`Kind`] choices, in a fixed enumeration order. |
| 111 | pub const KINDS: [Kind; 3] = [Kind::Comment, Kind::Issue, Kind::Effect]; |
| 112 | |
| 113 | /// A transaction under construction: five independent choices, each |
| 114 | /// `None` until [`SearchModel::actions`] offers it. A state with every |
| 115 | /// field `Some` is complete; [`SearchModel::actions`] then offers |
| 116 | /// nothing further, so the search terminates at exactly 576 leaves. |
| 117 | #[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] |
| 118 | pub struct State { |
| 119 | ref_name: Option<&'static str>, |
| 120 | shape: Option<Shape>, |
| 121 | signer: Option<Signer>, |
| 122 | retention: Option<Retention>, |
| 123 | kind: Option<Kind>, |
| 124 | } |
| 125 | |
| 126 | /// One choice, made against exactly one of [`State`]'s five `None` |
| 127 | /// fields, in the fixed order the field declarations above list. |
| 128 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] |
| 129 | pub enum Action { |
| 130 | /// Choose the transaction's refname. |
| 131 | ChooseRef(&'static str), |
| 132 | /// Choose the transaction's [`Shape`]. |
| 133 | ChooseShape(Shape), |
| 134 | /// Choose the transaction's [`Signer`]. |
| 135 | ChooseSigner(Signer), |
| 136 | /// Choose the new tip's [`Retention`]. |
| 137 | ChooseRetention(Retention), |
| 138 | /// Choose the new tip's signed-content [`Kind`]. |
| 139 | ChooseKind(Kind), |
| 140 | } |
| 141 | |
| 142 | /// A fully-chosen transaction, built once all five [`State`] fields are |
| 143 | /// `Some` — the point at which the properties below have anything to |
| 144 | /// check. |
| 145 | pub struct Complete { |
| 146 | ref_name: &'static str, |
| 147 | shape: Shape, |
| 148 | signer: Signer, |
| 149 | retention: Retention, |
| 150 | kind: Kind, |
| 151 | } |
| 152 | |
| 153 | impl State { |
| 154 | /// `Some` once every field is chosen, `None` while the transaction |
| 155 | /// is still under construction — the properties below treat `None` |
| 156 | /// as vacuously satisfying every obligation, so no discovery is |
| 157 | /// reported before there is a whole transaction to judge. |
| 158 | fn complete(&self) -> Option<Complete> { |
| 159 | Some(Complete { |
| 160 | ref_name: self.ref_name?, |
| 161 | shape: self.shape?, |
| 162 | signer: self.signer?, |
| 163 | retention: self.retention?, |
| 164 | kind: self.kind?, |
| 165 | }) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | impl Complete { |
| 170 | /// Translate this transaction into `ents_gate_rules::Facts`, exactly |
| 171 | /// as a real extractor would for each shape — same commit oids and |
| 172 | /// structure the crate's own unit tests build by hand. |
| 173 | fn to_facts(&self) -> Facts { |
| 174 | let mut f = Facts::default(); |
| 175 | enroll_all(&mut f); |
| 176 | |
| 177 | let signed_key = match self.signer { |
| 178 | Signer::Admin => Some(ADMIN_KEY), |
| 179 | Signer::Member => Some(MEMBER_KEY_1), |
| 180 | Signer::Unsigned => None, |
| 181 | }; |
| 182 | let sign = |f: &mut Facts, oid: &str| { |
| 183 | if let Some(k) = signed_key { |
| 184 | f.signed_by.push((oid.to_string(), k.to_string())); |
| 185 | } |
| 186 | }; |
| 187 | |
| 188 | match self.shape { |
| 189 | Shape::Genesis => { |
| 190 | f.ref_update = vec![(self.ref_name.to_string(), None, "g2".to_string())]; |
| 191 | sign(&mut f, "g2"); |
| 192 | match self.retention { |
| 193 | Retention::Absent => {} |
| 194 | Retention::Resolving => { |
| 195 | f.anchor = vec![("g2".to_string(), "blob-a".to_string())]; |
| 196 | f.context = vec![("g2".to_string(), "blob-ctx".to_string())]; |
| 197 | f.object_exists = vec![("blob-a".to_string(),), ("blob-ctx".to_string(),)]; |
| 198 | } |
| 199 | Retention::DanglingAnchor => { |
| 200 | f.anchor = vec![("g2".to_string(), "blob-a".to_string())]; |
| 201 | f.context = vec![("g2".to_string(), "blob-ctx".to_string())]; |
| 202 | f.object_exists = vec![("blob-ctx".to_string(),)]; |
| 203 | } |
| 204 | Retention::DanglingContext => { |
| 205 | f.anchor = vec![("g2".to_string(), "blob-a".to_string())]; |
| 206 | f.context = vec![("g2".to_string(), "blob-ctx".to_string())]; |
| 207 | f.object_exists = vec![("blob-a".to_string(),)]; |
| 208 | } |
| 209 | } |
| 210 | } |
| 211 | Shape::FastForward => { |
| 212 | f.ref_update = vec![( |
| 213 | self.ref_name.to_string(), |
| 214 | Some("g".to_string()), |
| 215 | "c1".to_string(), |
| 216 | )]; |
| 217 | f.parent = vec![("c1".to_string(), "g".to_string())]; |
| 218 | sign(&mut f, "g"); |
| 219 | sign(&mut f, "c1"); |
| 220 | } |
| 221 | Shape::NonFf => { |
| 222 | f.ref_update = vec![( |
| 223 | self.ref_name.to_string(), |
| 224 | Some("g".to_string()), |
| 225 | "x".to_string(), |
| 226 | )]; |
| 227 | sign(&mut f, "x"); |
| 228 | } |
| 229 | Shape::SecondRoot => { |
| 230 | f.ref_update = vec![( |
| 231 | self.ref_name.to_string(), |
| 232 | Some("g".to_string()), |
| 233 | "m".to_string(), |
| 234 | )]; |
| 235 | f.parent = vec![ |
| 236 | ("c1".to_string(), "g".to_string()), |
| 237 | ("m".to_string(), "c1".to_string()), |
| 238 | ("m".to_string(), "z".to_string()), |
| 239 | ]; |
| 240 | for oid in ["g", "c1", "m", "z"] { |
| 241 | sign(&mut f, oid); |
| 242 | } |
| 243 | } |
| 244 | } |
| 245 | f |
| 246 | } |
| 247 | |
| 248 | /// `gate(facts).is_empty()` for this transaction — the enabling |
| 249 | /// condition every property below is stated as a consequent of. |
| 250 | fn admitted(&self) -> bool { |
| 251 | gate(self.to_facts()).is_empty() |
| 252 | } |
| 253 | |
| 254 | /// abstractions.adoc §4 / gate.adoc: an admitted advance descends |
| 255 | /// from its old tip. [`Shape::NonFf`] is the sole shape that |
| 256 | /// violates this; every other shape satisfies it by construction. |
| 257 | fn ff_holds(&self) -> bool { |
| 258 | !matches!(self.shape, Shape::NonFf) |
| 259 | } |
| 260 | |
| 261 | /// abstractions.adoc §2 / meta-ref.identity-binding's all-roots |
| 262 | /// walk: an admitted advance introduces no second parentless commit. |
| 263 | /// [`Shape::SecondRoot`] is the sole shape that violates this. |
| 264 | fn single_root_holds(&self) -> bool { |
| 265 | !matches!(self.shape, Shape::SecondRoot) |
| 266 | } |
| 267 | |
| 268 | /// abstractions.adoc §5 tip invariant, admission half: every commit |
| 269 | /// an admitted transaction introduces is signed by an enrolled |
| 270 | /// member. |
| 271 | fn tip_signed_holds(&self) -> bool { |
| 272 | !matches!(self.signer, Signer::Unsigned) |
| 273 | } |
| 274 | |
| 275 | /// abstractions.adoc §3 / anchor.retention: an admitted genesis's |
| 276 | /// anchor and context both resolve. Only [`Shape::Genesis`] carries |
| 277 | /// retention in this model; every other shape is vacuously fine. |
| 278 | fn retention_holds(&self) -> bool { |
| 279 | if !matches!(self.shape, Shape::Genesis) { |
| 280 | return true; |
| 281 | } |
| 282 | !matches!( |
| 283 | self.retention, |
| 284 | Retention::DanglingAnchor | Retention::DanglingContext |
| 285 | ) |
| 286 | } |
| 287 | |
| 288 | /// abstractions.adoc §6 / effect.admin-only: an admitted write to |
| 289 | /// the effects namespace is admin-signed. |
| 290 | fn effect_admin_holds(&self) -> bool { |
| 291 | if !self.ref_name.starts_with("refs/meta/effects/") { |
| 292 | return true; |
| 293 | } |
| 294 | matches!(self.signer, Signer::Admin) |
| 295 | } |
| 296 | |
| 297 | /// abstractions.adoc §2 / meta-ref.identity-binding: the refname's |
| 298 | /// namespace matches the signed content's own declared |
| 299 | /// [`Kind`](crate::Kind) — the claim ledger row DIVERGED covers. Only |
| 300 | /// engages at [`Shape::Genesis`]: binding is a claim about a fresh |
| 301 | /// entity's placement, not about advancing one that already exists. |
| 302 | /// [`crate::INBOX_REF`] is deliberately outside this model's binding |
| 303 | /// scope (Phase 2 obligation 2's allowed second image, not a fresh |
| 304 | /// binding decision), so it always holds trivially here. |
| 305 | fn binding_holds(&self) -> bool { |
| 306 | if !matches!(self.shape, Shape::Genesis) { |
| 307 | return true; |
| 308 | } |
| 309 | let expected = if self.ref_name.starts_with("refs/meta/effects/") { |
| 310 | Kind::Effect |
| 311 | } else if self.ref_name.starts_with("refs/meta/comments/") { |
| 312 | Kind::Comment |
| 313 | } else if self.ref_name.starts_with("refs/meta/issues/") { |
| 314 | Kind::Issue |
| 315 | } else { |
| 316 | return true; |
| 317 | }; |
| 318 | self.kind == expected |
| 319 | } |
| 320 | } |
| 321 | |
| 322 | /// The Phase 0.5 search model. Stateless: every choice comes from the |
| 323 | /// bounded universe in `crate`, not from any field here. |
| 324 | pub struct SearchModel; |
| 325 | |
| 326 | impl Model for SearchModel { |
| 327 | type State = State; |
| 328 | type Action = Action; |
| 329 | |
| 330 | fn init_states(&self) -> Vec<Self::State> { |
| 331 | vec![State::default()] |
| 332 | } |
| 333 | |
| 334 | fn actions(&self, state: &Self::State, actions: &mut Vec<Self::Action>) { |
| 335 | if state.ref_name.is_none() { |
| 336 | actions.extend(REFS.iter().map(|r| Action::ChooseRef(r))); |
| 337 | } else if state.shape.is_none() { |
| 338 | actions.extend(SHAPES.iter().map(|s| Action::ChooseShape(*s))); |
| 339 | } else if state.signer.is_none() { |
| 340 | actions.extend(SIGNERS.iter().map(|s| Action::ChooseSigner(*s))); |
| 341 | } else if state.retention.is_none() { |
| 342 | actions.extend(RETENTIONS.iter().map(|r| Action::ChooseRetention(*r))); |
| 343 | } else if state.kind.is_none() { |
| 344 | actions.extend(KINDS.iter().map(|k| Action::ChooseKind(*k))); |
| 345 | } |
| 346 | // A complete state (every field `Some`) offers nothing further: |
| 347 | // this is a leaf, and the search terminates there. |
| 348 | } |
| 349 | |
| 350 | fn next_state(&self, last_state: &Self::State, action: Self::Action) -> Option<Self::State> { |
| 351 | let mut state = last_state.clone(); |
| 352 | match action { |
| 353 | Action::ChooseRef(r) if state.ref_name.is_none() => state.ref_name = Some(r), |
| 354 | Action::ChooseShape(s) if state.ref_name.is_some() && state.shape.is_none() => { |
| 355 | state.shape = Some(s) |
| 356 | } |
| 357 | Action::ChooseSigner(s) if state.shape.is_some() && state.signer.is_none() => { |
| 358 | state.signer = Some(s) |
| 359 | } |
| 360 | Action::ChooseRetention(r) if state.signer.is_some() && state.retention.is_none() => { |
| 361 | state.retention = Some(r); |
| 362 | } |
| 363 | Action::ChooseKind(k) if state.retention.is_some() && state.kind.is_none() => { |
| 364 | state.kind = Some(k) |
| 365 | } |
| 366 | _ => return None, |
| 367 | } |
| 368 | Some(state) |
| 369 | } |
| 370 | |
| 371 | fn properties(&self) -> Vec<Property<Self>> { |
| 372 | /// `gate(facts).is_empty()` implies `check(complete)`, vacuously |
| 373 | /// true on an incomplete state — one property per doc invariant |
| 374 | /// the seven denial rules claim to cover, named to match |
| 375 | /// `verify/alloy/gate_rules.als`'s `check` commands one-to-one. |
| 376 | fn implication(state: &State, check: fn(&Complete) -> bool) -> bool { |
| 377 | state.complete().is_none_or(|c| !c.admitted() || check(&c)) |
| 378 | } |
| 379 | |
| 380 | vec![ |
| 381 | Property::always("ff_only_advance", |_, s| implication(s, Complete::ff_holds)), |
| 382 | Property::always("single_root_identity", |_, s| { |
| 383 | implication(s, Complete::single_root_holds) |
| 384 | }), |
| 385 | Property::always("introduced_commits_member_signed", |_, s| { |
| 386 | implication(s, Complete::tip_signed_holds) |
| 387 | }), |
| 388 | Property::always("anchor_retention_resolves", |_, s| { |
| 389 | implication(s, Complete::retention_holds) |
| 390 | }), |
| 391 | Property::always("effects_writes_admin_signed", |_, s| { |
| 392 | implication(s, Complete::effect_admin_holds) |
| 393 | }), |
| 394 | // The one property expected to have a discovery: the known, |
| 395 | // ledger-recorded DIVERGED gap. See tests::rediscovers_cross_ref_replay_by_search. |
| 396 | Property::always("binding_refname_recomputed", |_, s| { |
| 397 | implication(s, Complete::binding_holds) |
| 398 | }), |
| 399 | ] |
| 400 | } |
| 401 | } |
| 402 | |
| 403 | #[cfg(test)] |
| 404 | mod tests { |
| 405 | use stateright::Checker; |
| 406 | |
| 407 | use super::*; |
| 408 | |
| 409 | /// The migration's acceptance test: search, not hand-construction, |
| 410 | /// rediscovers the cross-ref replay (ledger row DIVERGED, |
| 411 | /// `docs/abstractions.adoc` §2). Every other property must have no |
| 412 | /// discovery — the seven rules are faithful to the other five doc |
| 413 | /// invariants within this bounded universe. |
| 414 | #[test] |
| 415 | fn rediscovers_cross_ref_replay_by_search() { |
| 416 | let checker = SearchModel.checker().spawn_bfs().join(); |
| 417 | |
| 418 | let path = checker.assert_any_discovery("binding_refname_recomputed"); |
| 419 | // Printed so a run's witness can be copied into the PR |
| 420 | // description as confirmation the harness has teeth. |
| 421 | println!( |
| 422 | "binding_refname_recomputed witness: {:?}", |
| 423 | path.into_actions() |
| 424 | ); |
| 425 | |
| 426 | checker.assert_no_discovery("ff_only_advance"); |
| 427 | checker.assert_no_discovery("single_root_identity"); |
| 428 | checker.assert_no_discovery("introduced_commits_member_signed"); |
| 429 | checker.assert_no_discovery("anchor_retention_resolves"); |
| 430 | checker.assert_no_discovery("effects_writes_admin_signed"); |
| 431 | } |
| 432 | } |