git-ents.gitmain
⌘K
foforge
commit b5b708b
gate: state the admission invariants as compiled Datalog

A new kernel crate, ents-gate-rules, restates the admission rules the abstractions pin — fast-forward-only advance, parentless genesis and the single-root walk, member-signed introduced commits, anchor plus context retention, admin-only effect definitions — as ascent Datalog that rustc type-checks and cargo test runs. A violation is a non-empty denial relation; the gate() entry point collects them as rendered reasons.

The crate is an executable specification to iterate on, deliberately not a fourth call site: ents-gate’s verify remains the one admission judgment (gate.call-sites), and this crate depends on nothing so it can be dev-depended by any kernel crate’s tests once a fact extractor makes differential testing against the real gate worthwhile. The granularity rule and the effect work-set semantics are documented as out of scope: neither is a fact about a single proposed transaction.

Assisted-by: Claude:claude-sonnet-5 Assisted-by: Claude:claude-fable-5

Joseph D. Carpinelli · 1 month ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -73,6 +73,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -393,6 +405,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "ascent" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1f021eb502b2d503783f992e99c7a099910c95c548008c51f6380259836260c" +dependencies = [ + "ascent_base", + "ascent_macro", + "boxcar", + "cfg-if", + "dashmap 5.5.3", + "hashbrown 0.14.5", + "instant", + "once_cell", + "paste", + "rayon", + "rustc-hash", +] + +[[package]] +name = "ascent_base" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df836580627d8774d2a573bbfb5612c013004afcde89675b6a054825618de8dc" +dependencies = [ + "paste", +] + +[[package]] +name = "ascent_macro" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7d782b676e47b3657e5ae8ad3abe74b73a67a2cf73b3df85d1764e004724646" +dependencies = [ + "ascent_base", + "derive-syn-parse", + "duplicate", + "itertools", + "lazy_static", + "petgraph", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "asciicast-rs" version = "0.3.0" @@ -542,6 +599,12 @@ "hybrid-array", ] +[[package]] +name = "boxcar" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c99613cb3cd7429889a08dfcf651721ca971c86afa30798461f8eee994de47" + [[package]] name = "bstr" version = "1.12.3" @@ -764,6 +827,25 @@ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.22" @@ -875,6 +957,20 @@ "syn", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", + "rayon", +] + [[package]] name = "dashmap" version = "6.2.1" @@ -936,6 +1032,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive-syn-parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d65d7ce8132b7c0e54497a4d9a55a1c2a0912a0d786cf894472ba818fba45762" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1018,6 +1125,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "duplicate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e92f10a49176cbffacaedabfaa11d51db1ea0f80a83c26e1873b43cd1742c24" + [[package]] name = "ecdsa" version = "0.16.9" @@ -1053,6 +1166,12 @@ "subtle", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "elliptic-curve" version = "0.13.8" @@ -1158,6 +1277,13 @@ "thiserror 2.0.18", ] +[[package]] +name = "ents-gate-rules" +version = "0.0.0" +dependencies = [ + "ascent", +] + [[package]] name = "ents-kiln" version = "0.0.0" @@ -1595,6 +1721,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flate2" version = "1.1.9" @@ -2529,7 +2661,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ef60812443484e67bf84e444cc71b4c78ae62deb822221774a4fa0c57fdb17f" dependencies = [ - "dashmap", + "dashmap 6.2.1", "gix-fs", "libc", "parking_lot", @@ -2694,6 +2826,11 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", + "rayon", +] [[package]] name = "hashbrown" @@ -3007,6 +3144,15 @@ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "int-enum" version = "1.2.0" @@ -3046,6 +3192,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -3491,6 +3646,12 @@ "windows-link", ] +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "peg" version = "0.8.6" @@ -3533,6 +3694,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3808,6 +3979,26 @@ "rand_core 0.9.5", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18"
Cargo.toml @@ -4,6 +4,7 @@ "crates/kernel/ents-anchor", "crates/kernel/ents-effect", "crates/kernel/ents-gate", + "crates/kernel/ents-gate-rules", "crates/kernel/ents-model", "crates/kernel/ents-query", "crates/kernel/ents-receive", @@ -31,6 +32,7 @@ ents-effect = { path = "crates/kernel/ents-effect" } ents-forge = { path = "crates/forge/ents-forge" } ents-gate = { path = "crates/kernel/ents-gate" } +ents-gate-rules = { path = "crates/kernel/ents-gate-rules" } ents-kiln = { path = "crates/kiln/ents-kiln" } ents-lens = { path = "crates/cli/ents-lens" } ents-model = { path = "crates/kernel/ents-model" } @@ -63,6 +65,7 @@ "terminal", ] } acdc-converters-terminal = { git = "https://github.com/nlopes/acdc", rev = "6ae19bc2e6f0fa4254a3e9ebd1c3d2f6c7caafde" } +ascent = "0.8" askama = "0.16" axum = "0.8" cargo_metadata = "0.23"
crates/kernel/ents-gate-rules/Cargo.toml @@ -1,0 +1,12 @@ +[package] +name = "ents-gate-rules" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +ascent = { workspace = true } + +[lints] +workspace = true
crates/kernel/ents-gate-rules/src/lib.rs @@ -1,0 +1,442 @@ +//! The six abstractions (`docs/abstractions.adoc`), the ones expressible as +//! facts about a proposed ref transaction, restated as compiled Datalog. +//! +//! Every load-bearing rule here is a statement over facts the real +//! extractor would pull from a pack plus current repository state: which +//! refs move from what to what, which commits exist with which parents, +//! who signed what, which keys are enrolled members. [`ascent`] embeds +//! Datalog in Rust via a proc macro, so rustc checks the rules' types, +//! arities, variable bindings, and stratification; a violation is simply a +//! non-empty relation, and [`gate`] collects them. +//! +//! # Why this is a separate crate from `ents-gate` +//! +//! `ents-gate` is the one pure admission judgment actually wired into the +//! three real call sites (hosted CAS, local UI verdict, push pre-flight; +//! its own module docs cite `gate.call-sites`) — it reads a live +//! `RefStoreRead`, decodes typed trees, and renders actionable refusals. +//! This crate consumes none of that; it takes plain facts and is meant to +//! be cheap to grow one denial rule at a time while an invariant is still +//! being worked out, exactly as the source technique note that motivated +//! this crate puts it: rules here are fixed at compile time, which is a +//! feature for load-bearing, human-authored invariants, not a runtime +//! query surface over live entity data. +//! +//! It is not itself one of the three enforcement points today. Carrying a +//! rule proven out here into `ents-gate`'s actual fact extraction (or +//! `ents-effect`'s trigger/dedup bookkeeping) is future work, one rule at +//! a time, the same way the technique note describes: "a rule without a +//! red test is a rule you don't know fires." +//! +//! # Coverage and gaps +//! +//! Five rules restate abstractions 2, 3, 4, and 5 directly: +//! +//! - [`ff_violation`](GateRules::ff_violation) — fast-forward-only advance +//! is the anti-replay binding a signed commit relies on (abstraction 4). +//! - [`genesis_violation`](GateRules::genesis_violation) and +//! [`second_root_violation`](GateRules::second_root_violation) — a +//! hash-identified entity's ref has exactly one parentless commit +//! reachable from its tip (abstraction 2's typed tree, `meta-ref. +//! identity-binding`'s all-roots walk). +//! - [`unsigned_violation`](GateRules::unsigned_violation) — every commit +//! a transaction introduces must carry a member signature (abstraction +//! 5's tip invariant). +//! - [`dangling_anchor_violation`](GateRules::dangling_anchor_violation) +//! and [`dangling_context_violation`](GateRules::dangling_context_violation) +//! — an anchor's embedded retention is two objects, not one: the +//! anchored blob and a context blob of the surrounding lines +//! (abstraction 3, `anchor.retention`); both must resolve. +//! +//! One rule grows the set past the original five, following the technique +//! note's own suggested next step ("role-scoped authorization, +//! `member(Key, Role)` plus per-namespace requirements"): +//! +//! - [`effect_admin_violation`](GateRules::effect_admin_violation) — a +//! commit introduced onto `refs/meta/effects/*` must be signed by an +//! admin-registered member, never merely any member (abstraction 6, +//! `effect.admin-only`: authoring an effect schedules code execution on +//! canonical infrastructure, which needs more trust than an ordinary +//! append). +//! +//! Two invariants are deliberately *not* encoded here, the gap marked +//! rather than papered over: +//! +//! - Abstraction 1's granularity rule ("one ref per independently-authored +//! entity") is a ref-layout convention checked by which refname a write +//! targets, not a property of the commits within one transaction's +//! facts — it has no shape as a per-transaction Datalog fact here. +//! - Abstraction 6's monotone, exactly-once effect semantics needs the +//! dedup key `(effect, oid)` checked against the results namespace +//! *across* transactions and time, which is queue/materialization state +//! this crate's fact set does not carry. +//! +//! # Examples +//! +//! ``` +//! use ents_gate_rules::{Facts, Role, gate}; +//! +//! let mut facts = Facts { +//! member: vec![("key:joey".into(), Role::Member)], +//! ..Facts::default() +//! }; +//! facts.ref_update = vec![("refs/meta/issues/g".into(), Some("g".into()), "c1".into())]; +//! facts.parent = vec![("c1".into(), "g".into())]; +//! facts.signed_by = vec![("g".into(), "key:joey".into()), ("c1".into(), "key:joey".into())]; +//! assert!(gate(facts).is_empty()); +//! ``` + +use ascent::ascent; + +/// An object id, standing in for `gix_hash::ObjectId` — a plain `String` +/// here so a rule's facts stay readable in tests, the same simplification +/// the technique note that motivated this crate makes with `&'static +/// str`; any `Clone + Eq + Hash` type works once this is wired to a real +/// extractor. +pub type Oid = String; +/// A refname, standing in for `gix::refs::FullName`. +pub type Ref = String; +/// A signing key's identity, standing in for a member's enrolled public +/// key material. +pub type Key = String; + +/// A member's provenance, exactly the two cases `ents_model::Provenance` +/// carries — kept as a local, minimal fact rather than a dependency on +/// `ents-model` itself, so this crate stays a standalone place to iterate +/// on invariants rather than a second consumer of the kernel's real +/// types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Role { + /// Enrolled by an admin-registered member — the only provenance + /// `effect.admin-only` accepts for a write to `refs/meta/effects/*`. + Admin, + /// Any other enrolled member, admin-registered or self-attested, for + /// rules that only need "signed by someone currently enrolled." + Member, +} + +ascent! { + /// The compiled rule set: an `ascent`-generated struct whose fields + /// are `Vec`-backed relations, populated from [`Facts`] and run to a + /// fixpoint by [`gate`]. Not part of this crate's public surface — + /// [`Facts`] and [`gate`] are the two things a caller needs. + struct GateRules; + + // ---- EDB: facts a real extractor would pull from the pack, the + // proposed ref transaction, and current repository state ---- + + /// Proposed ref transaction: (ref, old tip, new tip). `None` old tip + /// means entity creation. + relation ref_update(Ref, Option<Oid>, Oid); + /// (child, parent) commit edges for the new tips' ancestry, bounded at + /// the old tips — the frontier the update can reach beyond what the + /// old tip already covers. + relation parent(Oid, Oid); + /// (commit, signing key), emitted only after signature verification + /// succeeds — the crypto lives in the extractor, never in a rule. + relation signed_by(Oid, Key); + /// Keys currently enrolled, and each one's provenance. + relation member(Key, Role); + /// (entity commit, anchored blob) — the first of the two objects + /// `anchor.retention` requires a comment (or any anchor consumer) to + /// embed. + relation anchor(Oid, Oid); + /// (entity commit, context blob) — the second embedded object + /// `anchor.retention` requires: a context blob of the surrounding + /// source lines, written fresh alongside the anchored blob. + relation context(Oid, Oid); + /// Objects the repository already has, or that arrive in this pack. + relation object_exists(Oid); + + // ---- IDB: derived relations ---- + + /// Transitive ancestry. + relation ancestor(Oid, Oid); + ancestor(c.clone(), p.clone()) <-- parent(c, p); + ancestor(c.clone(), a.clone()) <-- parent(c, p), ancestor(p, a); + + /// Whether a commit has any recorded parent. + relation has_parent(Oid); + has_parent(c.clone()) <-- parent(c, _p); + + /// Commits already covered by a ref's old tip. + relation covered(Ref, Oid); + covered(r.clone(), o.clone()) <-- + ref_update(r, old, _new), if let Some(o) = old; + covered(r.clone(), a.clone()) <-- + ref_update(r, old, _new), if let Some(o) = old, ancestor(o, a); + + /// Commits this transaction introduces to a ref: the new tip and its + /// ancestors, minus everything the old tip already reached. + relation introduced(Ref, Oid); + introduced(r.clone(), n.clone()) <-- + ref_update(r, _old, n), !covered(r, n); + introduced(r.clone(), c.clone()) <-- + ref_update(r, _old, n), ancestor(n, c), !covered(r, c); + + /// A commit signed by any currently enrolled member, of any + /// provenance. + relation member_signed(Oid); + member_signed(c.clone()) <-- signed_by(c, k), member(k, _role); + + /// A commit signed by an admin-registered member specifically. + relation admin_signed(Oid); + admin_signed(c.clone()) <-- signed_by(c, k), member(k, Role::Admin); + + // ---- Denial rules: any row here rejects the transaction ---- + + /// Fast-forward-only: the new tip must descend from the old tip. + relation ff_violation(Ref); + ff_violation(r.clone()) <-- + ref_update(r, old, new), if let Some(o) = old, + if o != new, !ancestor(new, o); + + /// Creation must point at a parentless genesis commit. + relation genesis_violation(Ref); + genesis_violation(r.clone()) <-- + ref_update(r, old, new), if old.is_none(), has_parent(new); + + /// One entity, one root: past genesis, an update may not introduce a + /// second parentless commit — merging in an unrelated chain would + /// satisfy fast-forward while smuggling in a doppelgänger identity. + relation second_root_violation(Ref, Oid); + second_root_violation(r.clone(), c.clone()) <-- + ref_update(r, old, _new), if old.is_some(), + introduced(r, c), !has_parent(c); + + /// Every introduced commit must carry a signature from a currently + /// enrolled member. + relation unsigned_violation(Ref, Oid); + unsigned_violation(r.clone(), c.clone()) <-- + introduced(r, c), !member_signed(c); + + /// An anchored blob must resolve to an object the repository will + /// contain. + relation dangling_anchor_violation(Ref, Oid); + dangling_anchor_violation(r.clone(), t.clone()) <-- + introduced(r, c), anchor(c, t), !object_exists(t); + + /// The paired context blob must resolve too — `anchor.retention` + /// requires both, not only the anchored blob. + relation dangling_context_violation(Ref, Oid); + dangling_context_violation(r.clone(), t.clone()) <-- + introduced(r, c), context(c, t), !object_exists(t); + + /// A write to `refs/meta/effects/*` must be signed by an + /// admin-registered member, regardless of any other role rule + /// (`effect.admin-only`). + relation effect_admin_violation(Ref, Oid); + effect_admin_violation(r.clone(), c.clone()) <-- + introduced(r, c), if r.starts_with("refs/meta/effects/"), + !admin_signed(c); +} + +/// Facts for one proposed transaction. In the real system these would be +/// extracted with gix from the pack and the current ref/member state; here +/// they are supplied directly so a rule's behavior can be pinned by a +/// test. +#[derive(Debug, Clone, Default)] +pub struct Facts { + /// See [`GateRules::ref_update`]. + pub ref_update: Vec<(Ref, Option<Oid>, Oid)>, + /// See [`GateRules::parent`]. + pub parent: Vec<(Oid, Oid)>, + /// See [`GateRules::signed_by`]. + pub signed_by: Vec<(Oid, Key)>, + /// See [`GateRules::member`]. + pub member: Vec<(Key, Role)>, + /// See [`GateRules::anchor`]. + pub anchor: Vec<(Oid, Oid)>, + /// See [`GateRules::context`]. + pub context: Vec<(Oid, Oid)>, + /// See [`GateRules::object_exists`]. + pub object_exists: Vec<(Oid,)>, +} + +/// Run every denial rule to a fixpoint over `facts`. An empty result means +/// the transaction is admitted under every invariant this crate currently +/// states. +#[must_use] +pub fn gate(facts: Facts) -> Vec<String> { + let mut rules = GateRules { + ref_update: facts.ref_update, + parent: facts.parent, + signed_by: facts.signed_by, + member: facts.member, + anchor: facts.anchor, + context: facts.context, + object_exists: facts.object_exists, + ..GateRules::default() + }; + rules.run(); + + let mut out = Vec::new(); + for (r,) in &rules.ff_violation { + out.push(format!("ff: {r}: new tip does not descend from old tip")); + } + for (r,) in &rules.genesis_violation { + out.push(format!("genesis: {r}: creation tip has parents")); + } + for (r, c) in &rules.second_root_violation { + out.push(format!("root: {r}: introduces second root {c}")); + } + for (r, c) in &rules.unsigned_violation { + out.push(format!( + "signature: {r}: {c} not signed by an enrolled member" + )); + } + for (r, t) in &rules.dangling_anchor_violation { + out.push(format!("anchor: {r}: anchored object {t} does not exist")); + } + for (r, t) in &rules.dangling_context_violation { + out.push(format!("context: {r}: context object {t} does not exist")); + } + for (r, c) in &rules.effect_admin_violation { + out.push(format!( + "effect-admin: {r}: {c} not signed by an admin-registered member" + )); + } + out.sort(); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + const ISSUE: &str = "refs/meta/issues/g"; + const COMMENT: &str = "refs/meta/comments/g2"; + const EFFECT: &str = "refs/meta/effects/ci"; + + fn base() -> Facts { + Facts { + member: vec![("key:joey".into(), Role::Member)], + ..Facts::default() + } + } + + #[test] + fn creation_and_ff_update_pass() { + // genesis g, then g <- c1 pushed as an update. + let mut f = base(); + f.ref_update = vec![(ISSUE.into(), Some("g".into()), "c1".into())]; + f.parent = vec![("c1".into(), "g".into())]; + f.signed_by = vec![ + ("g".into(), "key:joey".into()), + ("c1".into(), "key:joey".into()), + ]; + assert!(gate(f).is_empty()); + + let mut f = base(); + f.ref_update = vec![(COMMENT.into(), None, "g2".into())]; + f.signed_by = vec![("g2".into(), "key:joey".into())]; + f.anchor = vec![("g2".into(), "blob:a".into())]; + f.context = vec![("g2".into(), "blob:ctx".into())]; + f.object_exists = vec![("blob:a".into(),), ("blob:ctx".into(),)]; + assert!(gate(f).is_empty()); + } + + #[test] + fn non_ff_is_rejected() { + let mut f = base(); + f.ref_update = vec![(ISSUE.into(), Some("g".into()), "x".into())]; // x unrelated to g + f.signed_by = vec![("x".into(), "key:joey".into())]; + let v = gate(f); + assert!(v.iter().any(|m| m.starts_with("ff:")), "{v:?}"); + } + + #[test] + fn parented_genesis_is_rejected() { + let mut f = base(); + f.ref_update = vec![(ISSUE.into(), None, "c1".into())]; + f.parent = vec![("c1".into(), "elsewhere".into())]; + f.signed_by = vec![("c1".into(), "key:joey".into())]; + let v = gate(f); + assert!(v.iter().any(|m| m.starts_with("genesis:")), "{v:?}"); + } + + #[test] + fn merged_in_second_root_is_rejected() { + // old tip g; new tip m is a merge of c1 (descends from g) and z + // (an unrelated parentless chain). FF holds; root rule fires. + let mut f = base(); + f.ref_update = vec![(ISSUE.into(), Some("g".into()), "m".into())]; + f.parent = vec![ + ("c1".into(), "g".into()), + ("m".into(), "c1".into()), + ("m".into(), "z".into()), + ]; + f.signed_by = vec![ + ("c1".into(), "key:joey".into()), + ("m".into(), "key:joey".into()), + ("z".into(), "key:joey".into()), + ]; + let v = gate(f); + assert!( + v.iter().any(|m| m.starts_with("root:") && m.contains('z')), + "{v:?}" + ); + assert!(!v.iter().any(|m| m.starts_with("ff:")), "{v:?}"); + } + + #[test] + fn non_member_signature_is_rejected() { + let mut f = base(); + f.ref_update = vec![(ISSUE.into(), Some("g".into()), "c1".into())]; + f.parent = vec![("c1".into(), "g".into())]; + f.signed_by = vec![("c1".into(), "key:mallory".into())]; + let v = gate(f); + assert!( + v.iter() + .any(|m| m.starts_with("signature:") && m.contains("c1")), + "{v:?}" + ); + } + + #[test] + fn dangling_anchor_is_rejected() { + let mut f = base(); + f.ref_update = vec![(COMMENT.into(), None, "g2".into())]; + f.signed_by = vec![("g2".into(), "key:joey".into())]; + f.anchor = vec![("g2".into(), "blob:missing".into())]; + f.context = vec![("g2".into(), "blob:ctx".into())]; + f.object_exists = vec![("blob:ctx".into(),)]; + let v = gate(f); + assert!(v.iter().any(|m| m.starts_with("anchor:")), "{v:?}"); + } + + #[test] + fn dangling_context_is_rejected() { + let mut f = base(); + f.ref_update = vec![(COMMENT.into(), None, "g2".into())]; + f.signed_by = vec![("g2".into(), "key:joey".into())]; + f.anchor = vec![("g2".into(), "blob:a".into())]; + f.context = vec![("g2".into(), "blob:missing-ctx".into())]; + f.object_exists = vec![("blob:a".into(),)]; + let v = gate(f); + assert!(v.iter().any(|m| m.starts_with("context:")), "{v:?}"); + } + + #[test] + fn effect_definition_by_admin_passes() { + let mut f = Facts { + member: vec![("key:admin".into(), Role::Admin)], + ..Facts::default() + }; + f.ref_update = vec![(EFFECT.into(), None, "e1".into())]; + f.signed_by = vec![("e1".into(), "key:admin".into())]; + assert!(gate(f).is_empty()); + } + + #[test] + fn effect_definition_by_non_admin_is_rejected() { + // Signed by a currently enrolled member, so `unsigned_violation` + // does not fire — only the effects-specific admin rule should. + let mut f = base(); + f.ref_update = vec![(EFFECT.into(), None, "e1".into())]; + f.signed_by = vec![("e1".into(), "key:joey".into())]; + let v = gate(f); + assert!(v.iter().any(|m| m.starts_with("effect-admin:")), "{v:?}"); + assert!(!v.iter().any(|m| m.starts_with("signature:")), "{v:?}"); + } +}