crates/kernel/ents-gate-rules/src/lib.rs
| 1 | //! The six abstractions (`docs/abstractions.adoc`), the ones expressible as |
| 2 | //! facts about a proposed ref transaction, restated as compiled Datalog. |
| 3 | //! |
| 4 | //! Every load-bearing rule here is a statement over facts the real |
| 5 | //! extractor would pull from a pack plus current repository state: which |
| 6 | //! refs move from what to what, which commits exist with which parents, |
| 7 | //! who signed what, which keys are enrolled members. [`ascent`] embeds |
| 8 | //! Datalog in Rust via a proc macro, so rustc checks the rules' types, |
| 9 | //! arities, variable bindings, and stratification; a violation is simply a |
| 10 | //! non-empty relation, and [`gate`] collects them. |
| 11 | //! |
| 12 | //! # Why this is a separate crate from `ents-gate` |
| 13 | //! |
| 14 | //! `ents-gate` is the one pure admission judgment actually wired into the |
| 15 | //! three real call sites (hosted CAS, local UI verdict, push pre-flight; |
| 16 | //! its own module docs cite `gate.call-sites`) — it reads a live |
| 17 | //! `RefStoreRead`, decodes typed trees, and renders actionable refusals. |
| 18 | //! This crate consumes none of that; it takes plain facts and is meant to |
| 19 | //! be cheap to grow one denial rule at a time while an invariant is still |
| 20 | //! being worked out, exactly as the source technique note that motivated |
| 21 | //! this crate puts it: rules here are fixed at compile time, which is a |
| 22 | //! feature for load-bearing, human-authored invariants, not a runtime |
| 23 | //! query surface over live entity data. |
| 24 | //! |
| 25 | //! It is not itself one of the three enforcement points today. Carrying a |
| 26 | //! rule proven out here into `ents-gate`'s actual fact extraction (or |
| 27 | //! `ents-effect`'s trigger/dedup bookkeeping) is future work, one rule at |
| 28 | //! a time, the same way the technique note describes: "a rule without a |
| 29 | //! red test is a rule you don't know fires." |
| 30 | //! |
| 31 | //! # Coverage and gaps |
| 32 | //! |
| 33 | //! Five rules restate abstractions 2, 3, 4, and 5 directly: |
| 34 | //! |
| 35 | //! - [`ff_violation`](GateRules::ff_violation) — fast-forward-only advance |
| 36 | //! is the anti-replay binding a signed commit relies on (abstraction 4). |
| 37 | //! - [`genesis_violation`](GateRules::genesis_violation) and |
| 38 | //! [`second_root_violation`](GateRules::second_root_violation) — a |
| 39 | //! hash-identified entity's ref has exactly one parentless commit |
| 40 | //! reachable from its tip (abstraction 2's typed tree, `meta-ref. |
| 41 | //! identity-binding`'s all-roots walk). |
| 42 | //! - [`unsigned_violation`](GateRules::unsigned_violation) — every commit |
| 43 | //! a transaction introduces must carry a member signature (abstraction |
| 44 | //! 5's tip invariant). |
| 45 | //! - [`dangling_anchor_violation`](GateRules::dangling_anchor_violation) |
| 46 | //! and [`dangling_context_violation`](GateRules::dangling_context_violation) |
| 47 | //! — an anchor's embedded retention is two objects, not one: the |
| 48 | //! anchored blob and a context blob of the surrounding lines |
| 49 | //! (abstraction 3, `anchor.retention`); both must resolve. |
| 50 | //! |
| 51 | //! One rule grows the set past the original five, following the technique |
| 52 | //! note's own suggested next step ("role-scoped authorization, |
| 53 | //! `member(Key, Role)` plus per-namespace requirements"): |
| 54 | //! |
| 55 | //! - [`effect_admin_violation`](GateRules::effect_admin_violation) — a |
| 56 | //! commit introduced onto `refs/meta/effects/*` must be signed by an |
| 57 | //! admin-registered member, never merely any member (abstraction 6, |
| 58 | //! `effect.admin-only`: authoring an effect schedules code execution on |
| 59 | //! canonical infrastructure, which needs more trust than an ordinary |
| 60 | //! append). |
| 61 | //! |
| 62 | //! Two invariants are deliberately *not* encoded here, the gap marked |
| 63 | //! rather than papered over: |
| 64 | //! |
| 65 | //! - Abstraction 1's granularity rule ("one ref per independently-authored |
| 66 | //! entity") is a ref-layout convention checked by which refname a write |
| 67 | //! targets, not a property of the commits within one transaction's |
| 68 | //! facts — it has no shape as a per-transaction Datalog fact here. |
| 69 | //! - Abstraction 6's monotone, exactly-once effect semantics needs the |
| 70 | //! dedup key `(effect, oid)` checked against the results namespace |
| 71 | //! *across* transactions and time, which is queue/materialization state |
| 72 | //! this crate's fact set does not carry. |
| 73 | //! |
| 74 | //! # Examples |
| 75 | //! |
| 76 | //! ``` |
| 77 | //! use ents_gate_rules::{Facts, Role, gate}; |
| 78 | //! |
| 79 | //! let mut facts = Facts { |
| 80 | //! member: vec![("key:joey".into(), Role::Member)], |
| 81 | //! ..Facts::default() |
| 82 | //! }; |
| 83 | //! facts.ref_update = vec![("refs/meta/issues/g".into(), Some("g".into()), "c1".into())]; |
| 84 | //! facts.parent = vec![("c1".into(), "g".into())]; |
| 85 | //! facts.signed_by = vec![("g".into(), "key:joey".into()), ("c1".into(), "key:joey".into())]; |
| 86 | //! assert!(gate(facts).is_empty()); |
| 87 | //! ``` |
| 88 | |
| 89 | use ascent::ascent; |
| 90 | |
| 91 | /// An object id, standing in for `gix_hash::ObjectId` — a plain `String` |
| 92 | /// here so a rule's facts stay readable in tests, the same simplification |
| 93 | /// the technique note that motivated this crate makes with `&'static |
| 94 | /// str`; any `Clone + Eq + Hash` type works once this is wired to a real |
| 95 | /// extractor. |
| 96 | pub type Oid = String; |
| 97 | /// A refname, standing in for `gix::refs::FullName`. |
| 98 | pub type Ref = String; |
| 99 | /// A signing key's identity, standing in for a member's enrolled public |
| 100 | /// key material. |
| 101 | pub type Key = String; |
| 102 | |
| 103 | /// A member's provenance, exactly the two cases `ents_model::Provenance` |
| 104 | /// carries — kept as a local, minimal fact rather than a dependency on |
| 105 | /// `ents-model` itself, so this crate stays a standalone place to iterate |
| 106 | /// on invariants rather than a second consumer of the kernel's real |
| 107 | /// types. |
| 108 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] |
| 109 | pub enum Role { |
| 110 | /// Enrolled by an admin-registered member — the only provenance |
| 111 | /// `effect.admin-only` accepts for a write to `refs/meta/effects/*`. |
| 112 | Admin, |
| 113 | /// Any other enrolled member, admin-registered or self-attested, for |
| 114 | /// rules that only need "signed by someone currently enrolled." |
| 115 | Member, |
| 116 | } |
| 117 | |
| 118 | ascent! { |
| 119 | /// The compiled rule set: an `ascent`-generated struct whose fields |
| 120 | /// are `Vec`-backed relations, populated from [`Facts`] and run to a |
| 121 | /// fixpoint by [`gate`]. Not part of this crate's public surface — |
| 122 | /// [`Facts`] and [`gate`] are the two things a caller needs. |
| 123 | struct GateRules; |
| 124 | |
| 125 | // ---- EDB: facts a real extractor would pull from the pack, the |
| 126 | // proposed ref transaction, and current repository state ---- |
| 127 | |
| 128 | /// Proposed ref transaction: (ref, old tip, new tip). `None` old tip |
| 129 | /// means entity creation. |
| 130 | relation ref_update(Ref, Option<Oid>, Oid); |
| 131 | /// (child, parent) commit edges for the new tips' ancestry, bounded at |
| 132 | /// the old tips — the frontier the update can reach beyond what the |
| 133 | /// old tip already covers. |
| 134 | relation parent(Oid, Oid); |
| 135 | /// (commit, signing key), emitted only after signature verification |
| 136 | /// succeeds — the crypto lives in the extractor, never in a rule. |
| 137 | relation signed_by(Oid, Key); |
| 138 | /// Keys currently enrolled, and each one's provenance. |
| 139 | relation member(Key, Role); |
| 140 | /// (entity commit, anchored blob) — the first of the two objects |
| 141 | /// `anchor.retention` requires a comment (or any anchor consumer) to |
| 142 | /// embed. |
| 143 | relation anchor(Oid, Oid); |
| 144 | /// (entity commit, context blob) — the second embedded object |
| 145 | /// `anchor.retention` requires: a context blob of the surrounding |
| 146 | /// source lines, written fresh alongside the anchored blob. |
| 147 | relation context(Oid, Oid); |
| 148 | /// Objects the repository already has, or that arrive in this pack. |
| 149 | relation object_exists(Oid); |
| 150 | |
| 151 | // ---- IDB: derived relations ---- |
| 152 | |
| 153 | /// Transitive ancestry. |
| 154 | relation ancestor(Oid, Oid); |
| 155 | ancestor(c.clone(), p.clone()) <-- parent(c, p); |
| 156 | ancestor(c.clone(), a.clone()) <-- parent(c, p), ancestor(p, a); |
| 157 | |
| 158 | /// Whether a commit has any recorded parent. |
| 159 | relation has_parent(Oid); |
| 160 | has_parent(c.clone()) <-- parent(c, _p); |
| 161 | |
| 162 | /// Commits already covered by a ref's old tip. |
| 163 | relation covered(Ref, Oid); |
| 164 | covered(r.clone(), o.clone()) <-- |
| 165 | ref_update(r, old, _new), if let Some(o) = old; |
| 166 | covered(r.clone(), a.clone()) <-- |
| 167 | ref_update(r, old, _new), if let Some(o) = old, ancestor(o, a); |
| 168 | |
| 169 | /// Commits this transaction introduces to a ref: the new tip and its |
| 170 | /// ancestors, minus everything the old tip already reached. |
| 171 | relation introduced(Ref, Oid); |
| 172 | introduced(r.clone(), n.clone()) <-- |
| 173 | ref_update(r, _old, n), !covered(r, n); |
| 174 | introduced(r.clone(), c.clone()) <-- |
| 175 | ref_update(r, _old, n), ancestor(n, c), !covered(r, c); |
| 176 | |
| 177 | /// A commit signed by any currently enrolled member, of any |
| 178 | /// provenance. |
| 179 | relation member_signed(Oid); |
| 180 | member_signed(c.clone()) <-- signed_by(c, k), member(k, _role); |
| 181 | |
| 182 | /// A commit signed by an admin-registered member specifically. |
| 183 | relation admin_signed(Oid); |
| 184 | admin_signed(c.clone()) <-- signed_by(c, k), member(k, Role::Admin); |
| 185 | |
| 186 | // ---- Denial rules: any row here rejects the transaction ---- |
| 187 | |
| 188 | /// Fast-forward-only: the new tip must descend from the old tip. |
| 189 | relation ff_violation(Ref); |
| 190 | ff_violation(r.clone()) <-- |
| 191 | ref_update(r, old, new), if let Some(o) = old, |
| 192 | if o != new, !ancestor(new, o); |
| 193 | |
| 194 | /// Creation must point at a parentless genesis commit. |
| 195 | relation genesis_violation(Ref); |
| 196 | genesis_violation(r.clone()) <-- |
| 197 | ref_update(r, old, new), if old.is_none(), has_parent(new); |
| 198 | |
| 199 | /// One entity, one root: past genesis, an update may not introduce a |
| 200 | /// second parentless commit — merging in an unrelated chain would |
| 201 | /// satisfy fast-forward while smuggling in a doppelgänger identity. |
| 202 | relation second_root_violation(Ref, Oid); |
| 203 | second_root_violation(r.clone(), c.clone()) <-- |
| 204 | ref_update(r, old, _new), if old.is_some(), |
| 205 | introduced(r, c), !has_parent(c); |
| 206 | |
| 207 | /// Every introduced commit must carry a signature from a currently |
| 208 | /// enrolled member. |
| 209 | relation unsigned_violation(Ref, Oid); |
| 210 | unsigned_violation(r.clone(), c.clone()) <-- |
| 211 | introduced(r, c), !member_signed(c); |
| 212 | |
| 213 | /// An anchored blob must resolve to an object the repository will |
| 214 | /// contain. |
| 215 | relation dangling_anchor_violation(Ref, Oid); |
| 216 | dangling_anchor_violation(r.clone(), t.clone()) <-- |
| 217 | introduced(r, c), anchor(c, t), !object_exists(t); |
| 218 | |
| 219 | /// The paired context blob must resolve too — `anchor.retention` |
| 220 | /// requires both, not only the anchored blob. |
| 221 | relation dangling_context_violation(Ref, Oid); |
| 222 | dangling_context_violation(r.clone(), t.clone()) <-- |
| 223 | introduced(r, c), context(c, t), !object_exists(t); |
| 224 | |
| 225 | /// A write to `refs/meta/effects/*` must be signed by an |
| 226 | /// admin-registered member, regardless of any other role rule |
| 227 | /// (`effect.admin-only`). |
| 228 | relation effect_admin_violation(Ref, Oid); |
| 229 | effect_admin_violation(r.clone(), c.clone()) <-- |
| 230 | introduced(r, c), if r.starts_with("refs/meta/effects/"), |
| 231 | !admin_signed(c); |
| 232 | } |
| 233 | |
| 234 | /// Facts for one proposed transaction. In the real system these would be |
| 235 | /// extracted with gix from the pack and the current ref/member state; here |
| 236 | /// they are supplied directly so a rule's behavior can be pinned by a |
| 237 | /// test. |
| 238 | #[derive(Debug, Clone, Default)] |
| 239 | pub struct Facts { |
| 240 | /// See [`GateRules::ref_update`]. |
| 241 | pub ref_update: Vec<(Ref, Option<Oid>, Oid)>, |
| 242 | /// See [`GateRules::parent`]. |
| 243 | pub parent: Vec<(Oid, Oid)>, |
| 244 | /// See [`GateRules::signed_by`]. |
| 245 | pub signed_by: Vec<(Oid, Key)>, |
| 246 | /// See [`GateRules::member`]. |
| 247 | pub member: Vec<(Key, Role)>, |
| 248 | /// See [`GateRules::anchor`]. |
| 249 | pub anchor: Vec<(Oid, Oid)>, |
| 250 | /// See [`GateRules::context`]. |
| 251 | pub context: Vec<(Oid, Oid)>, |
| 252 | /// See [`GateRules::object_exists`]. |
| 253 | pub object_exists: Vec<(Oid,)>, |
| 254 | } |
| 255 | |
| 256 | /// Run every denial rule to a fixpoint over `facts`. An empty result means |
| 257 | /// the transaction is admitted under every invariant this crate currently |
| 258 | /// states. |
| 259 | #[must_use] |
| 260 | pub fn gate(facts: Facts) -> Vec<String> { |
| 261 | let mut rules = GateRules { |
| 262 | ref_update: facts.ref_update, |
| 263 | parent: facts.parent, |
| 264 | signed_by: facts.signed_by, |
| 265 | member: facts.member, |
| 266 | anchor: facts.anchor, |
| 267 | context: facts.context, |
| 268 | object_exists: facts.object_exists, |
| 269 | ..GateRules::default() |
| 270 | }; |
| 271 | rules.run(); |
| 272 | |
| 273 | let mut out = Vec::new(); |
| 274 | for (r,) in &rules.ff_violation { |
| 275 | out.push(format!("ff: {r}: new tip does not descend from old tip")); |
| 276 | } |
| 277 | for (r,) in &rules.genesis_violation { |
| 278 | out.push(format!("genesis: {r}: creation tip has parents")); |
| 279 | } |
| 280 | for (r, c) in &rules.second_root_violation { |
| 281 | out.push(format!("root: {r}: introduces second root {c}")); |
| 282 | } |
| 283 | for (r, c) in &rules.unsigned_violation { |
| 284 | out.push(format!( |
| 285 | "signature: {r}: {c} not signed by an enrolled member" |
| 286 | )); |
| 287 | } |
| 288 | for (r, t) in &rules.dangling_anchor_violation { |
| 289 | out.push(format!("anchor: {r}: anchored object {t} does not exist")); |
| 290 | } |
| 291 | for (r, t) in &rules.dangling_context_violation { |
| 292 | out.push(format!("context: {r}: context object {t} does not exist")); |
| 293 | } |
| 294 | for (r, c) in &rules.effect_admin_violation { |
| 295 | out.push(format!( |
| 296 | "effect-admin: {r}: {c} not signed by an admin-registered member" |
| 297 | )); |
| 298 | } |
| 299 | out.sort(); |
| 300 | out |
| 301 | } |
| 302 | |
| 303 | #[cfg(test)] |
| 304 | mod tests { |
| 305 | use super::*; |
| 306 | |
| 307 | const ISSUE: &str = "refs/meta/issues/g"; |
| 308 | const COMMENT: &str = "refs/meta/comments/g2"; |
| 309 | const EFFECT: &str = "refs/meta/effects/ci"; |
| 310 | |
| 311 | fn base() -> Facts { |
| 312 | Facts { |
| 313 | member: vec![("key:joey".into(), Role::Member)], |
| 314 | ..Facts::default() |
| 315 | } |
| 316 | } |
| 317 | |
| 318 | #[test] |
| 319 | fn creation_and_ff_update_pass() { |
| 320 | // genesis g, then g <- c1 pushed as an update. |
| 321 | let mut f = base(); |
| 322 | f.ref_update = vec![(ISSUE.into(), Some("g".into()), "c1".into())]; |
| 323 | f.parent = vec![("c1".into(), "g".into())]; |
| 324 | f.signed_by = vec![ |
| 325 | ("g".into(), "key:joey".into()), |
| 326 | ("c1".into(), "key:joey".into()), |
| 327 | ]; |
| 328 | assert!(gate(f).is_empty()); |
| 329 | |
| 330 | let mut f = base(); |
| 331 | f.ref_update = vec![(COMMENT.into(), None, "g2".into())]; |
| 332 | f.signed_by = vec![("g2".into(), "key:joey".into())]; |
| 333 | f.anchor = vec![("g2".into(), "blob:a".into())]; |
| 334 | f.context = vec![("g2".into(), "blob:ctx".into())]; |
| 335 | f.object_exists = vec![("blob:a".into(),), ("blob:ctx".into(),)]; |
| 336 | assert!(gate(f).is_empty()); |
| 337 | } |
| 338 | |
| 339 | #[test] |
| 340 | fn non_ff_is_rejected() { |
| 341 | let mut f = base(); |
| 342 | f.ref_update = vec![(ISSUE.into(), Some("g".into()), "x".into())]; // x unrelated to g |
| 343 | f.signed_by = vec![("x".into(), "key:joey".into())]; |
| 344 | let v = gate(f); |
| 345 | assert!(v.iter().any(|m| m.starts_with("ff:")), "{v:?}"); |
| 346 | } |
| 347 | |
| 348 | #[test] |
| 349 | fn parented_genesis_is_rejected() { |
| 350 | let mut f = base(); |
| 351 | f.ref_update = vec![(ISSUE.into(), None, "c1".into())]; |
| 352 | f.parent = vec![("c1".into(), "elsewhere".into())]; |
| 353 | f.signed_by = vec![("c1".into(), "key:joey".into())]; |
| 354 | let v = gate(f); |
| 355 | assert!(v.iter().any(|m| m.starts_with("genesis:")), "{v:?}"); |
| 356 | } |
| 357 | |
| 358 | #[test] |
| 359 | fn merged_in_second_root_is_rejected() { |
| 360 | // old tip g; new tip m is a merge of c1 (descends from g) and z |
| 361 | // (an unrelated parentless chain). FF holds; root rule fires. |
| 362 | let mut f = base(); |
| 363 | f.ref_update = vec![(ISSUE.into(), Some("g".into()), "m".into())]; |
| 364 | f.parent = vec![ |
| 365 | ("c1".into(), "g".into()), |
| 366 | ("m".into(), "c1".into()), |
| 367 | ("m".into(), "z".into()), |
| 368 | ]; |
| 369 | f.signed_by = vec![ |
| 370 | ("c1".into(), "key:joey".into()), |
| 371 | ("m".into(), "key:joey".into()), |
| 372 | ("z".into(), "key:joey".into()), |
| 373 | ]; |
| 374 | let v = gate(f); |
| 375 | assert!( |
| 376 | v.iter().any(|m| m.starts_with("root:") && m.contains('z')), |
| 377 | "{v:?}" |
| 378 | ); |
| 379 | assert!(!v.iter().any(|m| m.starts_with("ff:")), "{v:?}"); |
| 380 | } |
| 381 | |
| 382 | #[test] |
| 383 | fn non_member_signature_is_rejected() { |
| 384 | let mut f = base(); |
| 385 | f.ref_update = vec![(ISSUE.into(), Some("g".into()), "c1".into())]; |
| 386 | f.parent = vec![("c1".into(), "g".into())]; |
| 387 | f.signed_by = vec![("c1".into(), "key:mallory".into())]; |
| 388 | let v = gate(f); |
| 389 | assert!( |
| 390 | v.iter() |
| 391 | .any(|m| m.starts_with("signature:") && m.contains("c1")), |
| 392 | "{v:?}" |
| 393 | ); |
| 394 | } |
| 395 | |
| 396 | #[test] |
| 397 | fn dangling_anchor_is_rejected() { |
| 398 | let mut f = base(); |
| 399 | f.ref_update = vec![(COMMENT.into(), None, "g2".into())]; |
| 400 | f.signed_by = vec![("g2".into(), "key:joey".into())]; |
| 401 | f.anchor = vec![("g2".into(), "blob:missing".into())]; |
| 402 | f.context = vec![("g2".into(), "blob:ctx".into())]; |
| 403 | f.object_exists = vec![("blob:ctx".into(),)]; |
| 404 | let v = gate(f); |
| 405 | assert!(v.iter().any(|m| m.starts_with("anchor:")), "{v:?}"); |
| 406 | } |
| 407 | |
| 408 | #[test] |
| 409 | fn dangling_context_is_rejected() { |
| 410 | let mut f = base(); |
| 411 | f.ref_update = vec![(COMMENT.into(), None, "g2".into())]; |
| 412 | f.signed_by = vec![("g2".into(), "key:joey".into())]; |
| 413 | f.anchor = vec![("g2".into(), "blob:a".into())]; |
| 414 | f.context = vec![("g2".into(), "blob:missing-ctx".into())]; |
| 415 | f.object_exists = vec![("blob:a".into(),)]; |
| 416 | let v = gate(f); |
| 417 | assert!(v.iter().any(|m| m.starts_with("context:")), "{v:?}"); |
| 418 | } |
| 419 | |
| 420 | #[test] |
| 421 | fn effect_definition_by_admin_passes() { |
| 422 | let mut f = Facts { |
| 423 | member: vec![("key:admin".into(), Role::Admin)], |
| 424 | ..Facts::default() |
| 425 | }; |
| 426 | f.ref_update = vec![(EFFECT.into(), None, "e1".into())]; |
| 427 | f.signed_by = vec![("e1".into(), "key:admin".into())]; |
| 428 | assert!(gate(f).is_empty()); |
| 429 | } |
| 430 | |
| 431 | #[test] |
| 432 | fn effect_definition_by_non_admin_is_rejected() { |
| 433 | // Signed by a currently enrolled member, so `unsigned_violation` |
| 434 | // does not fire — only the effects-specific admin rule should. |
| 435 | let mut f = base(); |
| 436 | f.ref_update = vec![(EFFECT.into(), None, "e1".into())]; |
| 437 | f.signed_by = vec![("e1".into(), "key:joey".into())]; |
| 438 | let v = gate(f); |
| 439 | assert!(v.iter().any(|m| m.starts_with("effect-admin:")), "{v:?}"); |
| 440 | assert!(!v.iter().any(|m| m.starts_with("signature:")), "{v:?}"); |
| 441 | } |
| 442 | } |