git-ents.gitmain
⌘K
foforge
commit d36a2f3
verify: migrate harness to Rust-native model checking

stateright + proptest replace TLA+/Alloy-in-CI; the gate condition and every checked property call ents_gate_rules::gate() directly, so model/code refinement is by construction. New sink-layer crate ents-verify (depends only on ents-gate-rules; nothing depends on it, enforced). Search model rediscovers the cross-ref replay by exhaustive search within documented bounds. Alloy files retained as paper tools for design-only questions; TLA+ skeletons ported to stateright stubs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Joseph D. Carpinelli · 1 month ago

Reviews

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

Start a review

verdict

Cargo.lock @@ -80,6 +80,7 @@ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -450,6 +451,12 @@ "syn", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "asciicast-rs" version = "0.3.0" @@ -695,6 +702,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "choice" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3b71fc821deaf602a933ada5c845d088156d0cdf2ebf43ede390afe93466553" + [[package]] name = "chrono" version = "0.4.45" @@ -708,6 +721,12 @@ "windows-link", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "cipher" version = "0.4.4" @@ -1282,6 +1301,7 @@ version = "0.0.0" dependencies = [ "ascent", + "proptest", ] [[package]] @@ -1407,6 +1427,15 @@ "ssh-key", ] +[[package]] +name = "ents-verify" +version = "0.0.0" +dependencies = [ + "ents-gate-rules", + "proptest", + "stateright", +] + [[package]] name = "ents-web" version = "0.0.0" @@ -3086,6 +3115,12 @@ "zerovec", ] +[[package]] +name = "id-set" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9633fadf6346456cf8531119ba4838bc6d82ac4ce84d9852126dd2aa34d49264" + [[package]] name = "iddqd" version = "0.4.5" @@ -3480,6 +3515,12 @@ "libc", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -4493,6 +4534,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stateright" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd1157f21b11916f90fe1f2ac9a8d0e09a8813b28701584141060f414eedf6ba" +dependencies = [ + "ahash", + "choice", + "crossbeam-utils", + "dashmap 6.2.1", + "id-set", + "log", + "nohash-hasher", + "parking_lot", + "rand 0.9.4", + "serde", + "serde_json", + "tiny_http", +] + [[package]] name = "static_assertions" version = "1.1.0" @@ -4689,6 +4750,18 @@ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.3"
Cargo.toml @@ -16,6 +16,7 @@ "crates/forge/ents-forge", "crates/kiln/ents-kiln", "crates/substrate/gix-ref-store", + "crates/verify/ents-verify", ] [workspace.package] @@ -40,6 +41,7 @@ ents-receive = { path = "crates/kernel/ents-receive" } ents-sync = { path = "crates/kernel/ents-sync" } ents-testutil = { path = "crates/kernel/ents-testutil" } +ents-verify = { path = "crates/verify/ents-verify" } ents-web = { path = "crates/cli/ents-web" } gix-ref-store = { path = "crates/substrate/gix-ref-store" } arborium = { version = "2.18", default-features = false, features = [ @@ -99,6 +101,7 @@ serde_json = "1" spdx = "0.13" ssh-key = { version = "0.6", features = ["ed25519"] } +stateright = "0.31" target-lexicon = "0.13" tempfile = "3" thiserror = "2"
verify/README.adoc @@ -1,48 +1,67 @@ = verify: the formal stocktake harness This directory holds the scaffolding for the formal stocktaking exercise described in `exercise.md` (copied verbatim; it is the source of truth for phases, obligations, and verdict rules). -It is documentation-and-tooling only: nothing here is a crate, nothing here appears in any `Cargo.toml`, and nothing here touches refs, trailers, tree layouts, or any string that ends up in a Git object. The exercise itself is done by a human, on paper first; these files are the harness, not the proofs. +This harness is Rust-native. +`crates/verify/ents-verify` and `crates/kernel/ents-gate-rules/tests/prop.rs` call `ents_gate_rules::gate` directly from every layer that checks anything, so the refinement mapping between a model and the code is the function call itself, not a hand-maintained translation of it. +Nothing in CI depends on anything outside `cargo`. + == What lives here `ledger.adoc`:: The verdict ledger — the exercise's one deliverable. One row per invariant claim extracted from `docs/abstractions.adoc` and `docs/spec/*.adoc`, with verdicts filled in as the exercise progresses. +The `model` column names which layer below discharges the claim: `search`, `stateright`, `proptest`, or `alloy-paper`. `alloy/`:: -Alloy 6 models for the structural claims. -`objects.als` (Phase 1) and `binding.als` (Phase 2) are deliberately empty stubs that parse but check nothing. -`gate_rules.als` (Phase 0.5) is the exception: it translates the seven denial rules of `crates/kernel/ents-gate-rules` faithfully and its binding check is expected to produce the known cross-ref replay counterexample. +Alloy 6 models, demoted to a paper-only design tool — see `alloy/README.adoc`. +Nothing here runs in CI. -`tla/`:: -TLA+ skeletons for the protocol claims: `Receive.tla` (Phase 3), `Effects.tla` (Phase 4), `Durability.tla` (Phase 5), each with a small TLC `.cfg`. -Variables and action signatures only; `Receive.tla`'s `GateAdmits` transcribes the seven rules as the gate's enabling condition. +`../crates/verify/ents-verify`:: +The Rust-native replacement for the old TLA+ skeletons and the Alloy search. +`src/search.rs` is Phase 0.5's real replacement for `alloy/gate_rules.als`'s `check` commands: an exhaustive `stateright` search over a small bounded universe of transactions, calling `gate()` on each and checking the result against hand-written doc-invariant predicates. +`src/receive.rs`, `src/effects.rs`, `src/durability.rs` are Phase 3/4/5 skeletons — state and action *signatures* only, `todo!()` bodies, except `receive.rs`'s `gate_admits`, which is real code calling `gate()` directly. +Run with `cargo test -p ents-verify`. -`bin/`:: -`check-alloy` and `check-tla`, best-effort wrappers that run every model headless when a checker jar is available and warn-and-skip when it is not. +`../crates/kernel/ents-gate-rules/tests/prop.rs`:: +The cheap floor: `proptest` samples transactions over a small domain (duplicated from `ents-verify`'s vocabulary — the dependency stays one-way) and asserts the same doc-invariant predicates on every run, not just the bounded search's 576 leaves. +Run with `cargo test -p ents-gate-rules`. + +`../crates/kernel/ents-gate-rules/tests/ledger.rs`:: +Gap-pinning tests: a ledger row found FALSIFIED or DIVERGED with a concrete counterexample lands here first, in the crate's own `Facts` vocabulary, before it becomes a denial rule. == The epistemic split -Three tools, three jobs, deliberately not interchangeable. -The Datalog in `ents-gate-rules` *evaluates* the admission rules over one supplied transaction's facts; it is executable and type-checked, but it cannot search for the transaction you didn't think of. -Alloy *searches* for counterexamples: small-scope model finding over the same vocabulary, looking for admitted transactions that break a doc invariant. -TLA+ checks the *protocol over time*: receive/CAS races, adoption, epochs, effect dispatch, durability ordering — claims about traces, not single transactions. +Three tools, three jobs, deliberately not interchangeable — none of them substitutes for a human proof, and none of them is Alloy or TLA+ running in CI anymore. + +The Datalog in `ents-gate-rules` *evaluates* the seven denial rules over one supplied transaction's facts; it is executable and type-checked, but it cannot search for the transaction nobody thought of. + +`tests/prop.rs` *samples* that same search space cheaply, on every `cargo test` run, trading exhaustiveness for near-zero cost — it is the floor, not the ceiling. + +`ents-verify::search` *exhausts* a small bounded universe of transactions (`stateright`'s `Model::checker`), the direct replacement for what `verify/alloy/gate_rules.als`'s `check` commands did by external tool; it is what actually rediscovers the cross-ref replay counterexample, by search, not by hand-construction. + +`ents-verify::receive`/`effects`/`durability` are where protocol-over-time claims (receive/CAS races, adoption, epochs, effect dispatch, durability ordering) will live once filled in — `stateright` model-checks traces the same way TLC did, just in the same language and dependency graph as the code under test. + +Alloy survives only as a paper tool for design-only questions (Phases 1 and 2's structural claims about the object graph and refname binding) — see `alloy/README.adoc`. +Kani is noted here as a future experiment, not wired: `ascent`'s generated code hashes relations in ways that would need an `Oid` newtype (rather than a bare `String`) to make bounded model checking of the rules themselves tractable, and that is out of scope for this migration. == The refinement anchor -Models are checked against `ents-gate-rules`, not the other way around. -Every signature and variable here mirrors the crate's EDB relations one-to-one (`ref_update`, `parent`, `signed_by`, `member`, `anchor`, `context`, `object_exists`), and the seven denial rules are transcribed under their crate names, so a finding in a model is expressible as a `Facts` test in the crate. -That is also the landing convention: a FALSIFIED or DIVERGED ledger row with a concrete counterexample becomes a test in `crates/kernel/ents-gate-rules/tests/ledger.rs` first (gap-pinning while open), then a denial rule, one rule at a time. +Models are checked against `ents-gate-rules`, not the other way around — literally, now: `ents-verify` and `tests/prop.rs` both import `ents_gate_rules::gate` and call it. +The landing convention for a new finding stays the same as before this migration: a FALSIFIED or DIVERGED ledger row with a concrete, transaction-shaped counterexample lands in `crates/kernel/ents-gate-rules/tests/ledger.rs` first (gap-pinning while open), then — where the counterexample is transaction-shaped — loses its exemption in `tests/prop.rs` and its discovery in `ents-verify::search`, and only then becomes a denial rule, one rule at a time. -== Running the checkers +== Layering + +`crates/verify/ents-verify` is a sink in the workspace's crate layering (`docs/abstractions.adoc`'s "Layering" section): it depends on `ents-gate-rules` only, and nothing in the workspace may depend on it. +`crates/cli/git-ents/tests/layering.rs` enforces both directions mechanically. + +== Running the checks [source,shell] ---- -verify/bin/check-alloy # every .als, headless; needs ALLOY_JAR or a well-known path -verify/bin/check-tla # SANY-parse every .tla, TLC-run every .cfg; needs TLA_TOOLS_JAR +cargo test -p ents-gate-rules # the crate's own tests, plus tests/ledger.rs and tests/prop.rs +cargo test -p ents-verify # the search model's acceptance test (rediscovers the replay) ---- -Both scripts exit 0 with a warning when no jar or no Java runtime is found, so they never block anything; CI runs them in an optional, non-required job. -Do not vendor the jars into the repo. -When `check-alloy` reports a counterexample for `binding_refname_recomputed` in `gate_rules.als`, that is the documented open gap, not a harness failure — see the DIVERGED row in `ledger.adoc`. +`cargo test --workspace` runs both, since `ents-verify` and the `ents-gate-rules` test targets are ordinary workspace members — no separate CI job, no jar, no optional/skippable step.
verify/ledger.adoc @@ -4,6 +4,7 @@ Each row is one invariant claim extracted from `docs/abstractions.adoc` and `docs/spec/*.adoc`, paraphrased faithfully with key phrases quoted. Every verdict stays `OPEN` until the paper exercise discharges the claim against a formal model. The code is ground truth: `crates/kernel/ents-gate-rules` is the compiled Datalog rule set, and the `encoded-in-ents-gate-rules?` column classifies each claim against its seven denial rules (`ff_violation`, `genesis_violation`, `second_root_violation`, `unsigned_violation`, `dangling_anchor_violation`, `dangling_context_violation`, `effect_admin_violation`) and its two deliberately-marked gaps (abstraction 1's granularity rule, abstraction 6's monotone exactly-once effect dedup). +The harness is Rust-native, and the `model` column names which check discharges each claim: `search` is crates/verify/ents-verify/src/search.rs's exhaustive bounded search calling `gate()` directly; `stateright` is crates/verify/ents-verify's `receive.rs`/`effects.rs`/`durability.rs` protocol-over-time skeletons; `proptest` is crates/kernel/ents-gate-rules/tests/prop.rs's cheap sampled floor; `alloy-paper` marks a claim demoted to verify/alloy/*.als as a paper-only design tool, no longer run in CI; and `—` means no model is assigned yet. A row that turns `FALSIFIED` or `DIVERGED` lands a concrete counterexample as a test in `crates/kernel/ents-gate-rules/tests/ledger.rs` first, before anything else. The ledger carries 163 rows; exactly one is non-`OPEN` — the refname-binding claim, marked `DIVERGED`. @@ -11,167 +12,167 @@ |=== |claim |source |model |encoded-in-ents-gate-rules? |verdict |assumption-or-counterexample -|A ref under `refs/meta/*` is simultaneously the unit of storage, sync, authorization, and history. |abstractions.adoc, §1 Meta-ref, ~L14-19 |alloy/objects.als |gap-unmarked |OPEN |— +|A ref under `refs/meta/*` is simultaneously the unit of storage, sync, authorization, and history. |abstractions.adoc, §1 Meta-ref, ~L14-19 |alloy-paper |gap-unmarked |OPEN |— |Granularity rule: one ref per independently-authored entity; entities different actors write concurrently must not share a ref; writes stay conflict-free. |abstractions.adoc, §1 Meta-ref, ~L21-23 |— |gap-marked |OPEN |— -|Trees stay pure struct representations — no version marker entry. |abstractions.adoc, §2 Typed tree, ~L39 |alloy/objects.als |gap-unmarked |OPEN |— -|"Refname binding needs no stored metadata at all: the refname is a total function of signed content, recomputed at verification." |abstractions.adoc, §2 Typed tree, L42 (normative twin: meta-ref.identity-binding) |alloy/binding.als |gap-unmarked |DIVERGED |Cross-ref replay: an admin-signed parentless comment commit (anchor+context resolving) replayed as creation of refs/meta/effects/x passes genesis, unsigned, effect_admin (ff vacuous). Missing rule: binding_violation. Pinned by crates/kernel/ents-gate-rules/tests/ledger.rs; Alloy witness verify/alloy/gate_rules.als check binding_refname_recomputed. +|Trees stay pure struct representations — no version marker entry. |abstractions.adoc, §2 Typed tree, ~L39 |alloy-paper |gap-unmarked |OPEN |— +|"Refname binding needs no stored metadata at all: the refname is a total function of signed content, recomputed at verification." |abstractions.adoc, §2 Typed tree, L42 (normative twin: meta-ref.identity-binding) |search, proptest |gap-unmarked |DIVERGED |Cross-ref replay: an admin-signed parentless comment commit (anchor+context resolving) replayed as creation of refs/meta/effects/x passes genesis, unsigned, effect_admin (ff vacuous). Missing rule: binding_violation. Pinned by crates/kernel/ents-gate-rules/tests/ledger.rs; Alloy witness verify/alloy/gate_rules.als check binding_refname_recomputed. |Tip invariant: the tip of a meta-ref is always readable by the binary that owns the entity type; non-owning binary degrades to generic display, never an error (redaction is the sole qualification). |abstractions.adoc, §2 Typed tree, ~L44-45 |— |gap-unmarked |OPEN |— -|Retention invariant: the tree storing an anchor embeds the anchored blob plus a context blob; anchored content is reachable from `refs/meta/*` and survives force-push/branch-deletion/gc, with no gc special-casing and no pinned ancestry. |abstractions.adoc, §3 Anchor, ~L51-53 |alloy/objects.als |dangling_anchor_violation, dangling_context_violation |OPEN |— -|Gitlinks are not reachability edges and retain nothing; embedding is the only mechanism that works. |abstractions.adoc, §3 Anchor, L53 |alloy/objects.als |gap-unmarked |OPEN |— -|Redaction is the sole deliberate exception to retention. |abstractions.adoc, §3 Anchor, L54 |alloy/objects.als |gap-unmarked |OPEN |— -|Anchor data is never mutated. |abstractions.adoc, §3 Anchor, L55 |alloy/objects.als |gap-unmarked |OPEN |— +|Retention invariant: the tree storing an anchor embeds the anchored blob plus a context blob; anchored content is reachable from `refs/meta/*` and survives force-push/branch-deletion/gc, with no gc special-casing and no pinned ancestry. |abstractions.adoc, §3 Anchor, ~L51-53 |alloy-paper |dangling_anchor_violation, dangling_context_violation |OPEN |— +|Gitlinks are not reachability edges and retain nothing; embedding is the only mechanism that works. |abstractions.adoc, §3 Anchor, L53 |alloy-paper |gap-unmarked |OPEN |— +|Redaction is the sole deliberate exception to retention. |abstractions.adoc, §3 Anchor, L54 |alloy-paper |gap-unmarked |OPEN |— +|Anchor data is never mutated. |abstractions.adoc, §3 Anchor, L55 |alloy-paper |gap-unmarked |OPEN |— |Anchors project onto newer commits at read time via blame plus fuzzy matching; when the anchored commit is gc'd, projection degrades to context matching instead of breaking. |abstractions.adoc, §3 Anchor, L55-56 |— |gap-unmarked |OPEN |— -|Every meta-ref mutation is an author-signed commit. |abstractions.adoc, §4 Signed commit, L62 |alloy/gate_rules.als |unsigned_violation |OPEN |— -|The signature is a data artifact, not a transport artifact: it replicates with the repo and verifies offline in every clone, so verification evidence is itself repository state. |abstractions.adoc, §4 Signed commit, L63 |alloy/gate_rules.als |gap-unmarked |OPEN |— -|Push certificates are demoted to transport concerns; they carry no meta-ref semantics. |abstractions.adoc, §4 Signed commit, L64 |alloy/gate_rules.als |gap-unmarked |OPEN |— -|Refname binding is recomputed from the commit's own signed content; a mismatch refuses (without this a signed commit could be replayed as the tip of a different meta-ref). |abstractions.adoc, §4 Signed commit, L69 |alloy/binding.als |gap-unmarked |OPEN |— -|Anti-replay: meta-refs advance fast-forward-only (new tip descends from old), enforced by atomic CAS; parent hash is the freshness binding, no nonce needed. |abstractions.adoc, §4 Signed commit, L70 |alloy/gate_rules.als |ff_violation (FF part only; CAS mechanism itself not a Datalog rule) |OPEN |— -|Tip invariant: the tip of a meta-ref is signed by a member authorized for that refname; checkable after the fact by anyone with a clone. |abstractions.adoc, §4 Signed commit, L72-73 |alloy/gate_rules.als |unsigned_violation (partial: enrolled-member only, not refname-specific authorization) |OPEN |— -|Adoption is always a merge, never rewrite: when author and placer differ, the authorized member merges the contributor's commit onto the canonical ref, even trivially; the merge commit satisfies the tip invariant. |abstractions.adoc, §4 Signed commit, L75-76,78 |tla/Receive.tla |gap-unmarked |OPEN |— -|Cherry-picking is forbidden as an adoption mechanism: it creates a new commit object and destroys the author's signature. |abstractions.adoc, §4 Signed commit, L77 |tla/Receive.tla |gap-unmarked |OPEN |— -|Same-actor divergence: two of a member's own machines racing a single-writer ref is resolved by merging own heads, never erroring; typed trees merge schema-aware, not textually. |abstractions.adoc, §4 Signed commit, L80-81 |tla/Receive.tla |gap-unmarked |OPEN |— -|Principled split: content signatures carry authorization only where mutations are author-signed single-writer appends (granularity-guaranteed); `refs/heads/*` keeps transport-level auth instead. |abstractions.adoc, §4 Signed commit, L83-84 |alloy/gate_rules.als |gap-unmarked |OPEN |— -|Gate's four-part verification: tip signed by authorized member; refname recomputes from signed content; new tip descends from old tip; update commits via atomic CAS. |abstractions.adoc, §5 Gate, L92-95 |alloy/gate_rules.als |ff_violation (descent part); rest gap-unmarked |OPEN |— -|Because members/refname rules live under `refs/meta/*`, policy is repository state: any frontend evaluates the actual policy offline, staleness bounded by last fetch. |abstractions.adoc, §5 Gate, L97 |tla/Receive.tla |gap-unmarked |OPEN |— -|Verification epoch: gate applies the tip invariant from an epoch recorded in `refs/meta/config`; history before is archival; epoch-setting commit is the first gated tip of the config ref. |abstractions.adoc, §5 Gate, L99-100 |tla/Receive.tla |gap-unmarked |OPEN |— -|Gate is a property of the store: hosted runs the gate at CAS time and aborts on failure (mandatory); local accepts any write and runs the gate as an annotating verdict (advisory). |abstractions.adoc, §5 Gate, L104-106 |tla/Receive.tla |gap-unmarked |OPEN |— -|The moment a verdict predicts rejection, sync offers to route the commit to the inbox instead — not only after actual rejection. |abstractions.adoc, §5 Gate, L108 |tla/Receive.tla |gap-unmarked |OPEN |— -|One function, three call sites: hosted CAS, local UI verdict, push pre-flight. |abstractions.adoc, §5 Gate, L110 |tla/Receive.tla |gap-unmarked |OPEN |— +|Every meta-ref mutation is an author-signed commit. |abstractions.adoc, §4 Signed commit, L62 |search, proptest |unsigned_violation |OPEN |— +|The signature is a data artifact, not a transport artifact: it replicates with the repo and verifies offline in every clone, so verification evidence is itself repository state. |abstractions.adoc, §4 Signed commit, L63 |search |gap-unmarked |OPEN |— +|Push certificates are demoted to transport concerns; they carry no meta-ref semantics. |abstractions.adoc, §4 Signed commit, L64 |search |gap-unmarked |OPEN |— +|Refname binding is recomputed from the commit's own signed content; a mismatch refuses (without this a signed commit could be replayed as the tip of a different meta-ref). |abstractions.adoc, §4 Signed commit, L69 |alloy-paper |gap-unmarked |OPEN |— +|Anti-replay: meta-refs advance fast-forward-only (new tip descends from old), enforced by atomic CAS; parent hash is the freshness binding, no nonce needed. |abstractions.adoc, §4 Signed commit, L70 |search |ff_violation (FF part only; CAS mechanism itself not a Datalog rule) |OPEN |— +|Tip invariant: the tip of a meta-ref is signed by a member authorized for that refname; checkable after the fact by anyone with a clone. |abstractions.adoc, §4 Signed commit, L72-73 |search, proptest |unsigned_violation (partial: enrolled-member only, not refname-specific authorization) |OPEN |— +|Adoption is always a merge, never rewrite: when author and placer differ, the authorized member merges the contributor's commit onto the canonical ref, even trivially; the merge commit satisfies the tip invariant. |abstractions.adoc, §4 Signed commit, L75-76,78 |stateright |gap-unmarked |OPEN |— +|Cherry-picking is forbidden as an adoption mechanism: it creates a new commit object and destroys the author's signature. |abstractions.adoc, §4 Signed commit, L77 |stateright |gap-unmarked |OPEN |— +|Same-actor divergence: two of a member's own machines racing a single-writer ref is resolved by merging own heads, never erroring; typed trees merge schema-aware, not textually. |abstractions.adoc, §4 Signed commit, L80-81 |stateright |gap-unmarked |OPEN |— +|Principled split: content signatures carry authorization only where mutations are author-signed single-writer appends (granularity-guaranteed); `refs/heads/*` keeps transport-level auth instead. |abstractions.adoc, §4 Signed commit, L83-84 |search |gap-unmarked |OPEN |— +|Gate's four-part verification: tip signed by authorized member; refname recomputes from signed content; new tip descends from old tip; update commits via atomic CAS. |abstractions.adoc, §5 Gate, L92-95 |search |ff_violation (descent part); rest gap-unmarked |OPEN |— +|Because members/refname rules live under `refs/meta/*`, policy is repository state: any frontend evaluates the actual policy offline, staleness bounded by last fetch. |abstractions.adoc, §5 Gate, L97 |stateright |gap-unmarked |OPEN |— +|Verification epoch: gate applies the tip invariant from an epoch recorded in `refs/meta/config`; history before is archival; epoch-setting commit is the first gated tip of the config ref. |abstractions.adoc, §5 Gate, L99-100 |stateright |gap-unmarked |OPEN |— +|Gate is a property of the store: hosted runs the gate at CAS time and aborts on failure (mandatory); local accepts any write and runs the gate as an annotating verdict (advisory). |abstractions.adoc, §5 Gate, L104-106 |stateright |gap-unmarked |OPEN |— +|The moment a verdict predicts rejection, sync offers to route the commit to the inbox instead — not only after actual rejection. |abstractions.adoc, §5 Gate, L108 |stateright |gap-unmarked |OPEN |— +|One function, three call sites: hosted CAS, local UI verdict, push pre-flight. |abstractions.adoc, §5 Gate, L110 |stateright |gap-unmarked |OPEN |— |A verdict is never a bare pass/fail: on failure it carries which rule failed and for which refname. |abstractions.adoc, §5 Gate, L111 |— |gap-unmarked |OPEN |— |Local web UI signs as the user with the user's own member key; server-key signing is a hosted-only necessity, must not be imported locally. |abstractions.adoc, §5 Gate, L114-115 |— |gap-unmarked |OPEN |— -|Results location is derived by convention; an effect cannot choose where its verdicts land. |abstractions.adoc, §6 Effect, L132 |alloy/binding.als |gap-unmarked |OPEN |— -|Trigger semantics: the trigger denotes a set of commits; the effect fires once per commit that enters the set. |abstractions.adoc, §6 Effect, L134 |tla/Effects.tla |gap-marked |OPEN |— -|Meta-refs are outside `rev()`'s domain by definition. |abstractions.adoc, §6 Effect, L138 |tla/Effects.tla |gap-unmarked |OPEN |— -|`meta(glob)` can never match effect-written namespaces (`refs/meta/results/*`, `refs/meta/index/*`); those are reachable only through `results(…)`. |abstractions.adoc, §6 Effect, L140-141 |tla/Effects.tla |gap-unmarked |OPEN |— -|`RefPattern` survives as the degenerate query `rev(<glob>)`; nothing shipped changes meaning. |abstractions.adoc, §6 Effect, L144 |tla/Effects.tla |gap-unmarked |OPEN |— -|Monotone, entry-only: a force-push can shrink a set, but a commit leaving the set retracts nothing — results are immutable history. |abstractions.adoc, §6 Effect, L147 |tla/Effects.tla |gap-marked |OPEN |— -|No pipeline state: the work set is `trigger − results(self, any)` — the results ref is the sole materialization marker. |abstractions.adoc, §6 Effect, L150 |tla/Effects.tla |gap-marked |OPEN |— -|Dedup key `(effect, oid)` over an at-least-once queue yields exactly-once outcomes with zero state outside the repository. |abstractions.adoc, §6 Effect, L151 |tla/Effects.tla |gap-marked |OPEN |— -|Result taxonomy: a result is `pass`, `fail`, or `error`; exit status is always a result. |abstractions.adoc, §6 Effect, L153-154 |tla/Effects.tla |gap-unmarked |OPEN |— -|Infrastructure failure is not a result; only retry exhaustion writes a terminal `error`, signed by the worker's key like any other. |abstractions.adoc, §6 Effect, L155 |tla/Durability.tla |gap-unmarked |OPEN |— -|Retry bounds are deployment configuration, never effect data. |abstractions.adoc, §6 Effect, L156 |alloy/objects.als |gap-unmarked |OPEN |— -|A transient outage can neither retry forever nor permanently discharge an obligation. |abstractions.adoc, §6 Effect, L157 |tla/Durability.tla |gap-unmarked |OPEN |— -|Recursion is structure: downstream-of-effects is syntactically visible; because `rev()`/`meta()` cannot name an effect-written ref, an accidental fork bomb is unreachable by construction. |abstractions.adoc, §6 Effect, L159-160 |tla/Effects.tla |gap-unmarked |OPEN |— -|`post-receive` remains a dumb matcher: ref footprint is statically extractable; set entry computed incrementally from `old..new`, bounded by generation numbers. |abstractions.adoc, §6 Effect, L165-166 |tla/Effects.tla |gap-unmarked |OPEN |— -|Pushes are never blocked; the durable enqueue is the entire synchronous cost. |abstractions.adoc, §6 Effect, L167 |tla/Durability.tla |gap-unmarked |OPEN |— +|Results location is derived by convention; an effect cannot choose where its verdicts land. |abstractions.adoc, §6 Effect, L132 |alloy-paper |gap-unmarked |OPEN |— +|Trigger semantics: the trigger denotes a set of commits; the effect fires once per commit that enters the set. |abstractions.adoc, §6 Effect, L134 |stateright |gap-marked |OPEN |— +|Meta-refs are outside `rev()`'s domain by definition. |abstractions.adoc, §6 Effect, L138 |stateright |gap-unmarked |OPEN |— +|`meta(glob)` can never match effect-written namespaces (`refs/meta/results/*`, `refs/meta/index/*`); those are reachable only through `results(…)`. |abstractions.adoc, §6 Effect, L140-141 |stateright |gap-unmarked |OPEN |— +|`RefPattern` survives as the degenerate query `rev(<glob>)`; nothing shipped changes meaning. |abstractions.adoc, §6 Effect, L144 |stateright |gap-unmarked |OPEN |— +|Monotone, entry-only: a force-push can shrink a set, but a commit leaving the set retracts nothing — results are immutable history. |abstractions.adoc, §6 Effect, L147 |stateright |gap-marked |OPEN |— +|No pipeline state: the work set is `trigger − results(self, any)` — the results ref is the sole materialization marker. |abstractions.adoc, §6 Effect, L150 |stateright |gap-marked |OPEN |— +|Dedup key `(effect, oid)` over an at-least-once queue yields exactly-once outcomes with zero state outside the repository. |abstractions.adoc, §6 Effect, L151 |stateright |gap-marked |OPEN |— +|Result taxonomy: a result is `pass`, `fail`, or `error`; exit status is always a result. |abstractions.adoc, §6 Effect, L153-154 |stateright |gap-unmarked |OPEN |— +|Infrastructure failure is not a result; only retry exhaustion writes a terminal `error`, signed by the worker's key like any other. |abstractions.adoc, §6 Effect, L155 |stateright |gap-unmarked |OPEN |— +|Retry bounds are deployment configuration, never effect data. |abstractions.adoc, §6 Effect, L156 |alloy-paper |gap-unmarked |OPEN |— +|A transient outage can neither retry forever nor permanently discharge an obligation. |abstractions.adoc, §6 Effect, L157 |stateright |gap-unmarked |OPEN |— +|Recursion is structure: downstream-of-effects is syntactically visible; because `rev()`/`meta()` cannot name an effect-written ref, an accidental fork bomb is unreachable by construction. |abstractions.adoc, §6 Effect, L159-160 |stateright |gap-unmarked |OPEN |— +|`post-receive` remains a dumb matcher: ref footprint is statically extractable; set entry computed incrementally from `old..new`, bounded by generation numbers. |abstractions.adoc, §6 Effect, L165-166 |stateright |gap-unmarked |OPEN |— +|Pushes are never blocked; the durable enqueue is the entire synchronous cost. |abstractions.adoc, §6 Effect, L167 |stateright |gap-unmarked |OPEN |— |A worker dequeues, materializes toolchains, executes in a sandbox; host-direct requires explicit `--unsandboxed`. |abstractions.adoc, §6 Effect, L168-169 |— |gap-unmarked |OPEN |— -|Results return only as signed commits pushed to one ref per tested commit (`refs/meta/results/<effect>/<short-oid>`), so concurrent results never conflict. |abstractions.adoc, §6 Effect, L170 |alloy/binding.als |gap-marked |OPEN |— -|Identity discipline: the runner is a member, not an ambient authority; official results are official only because canonical refs are writable solely by designated worker keys. |abstractions.adoc, §6 Effect, L172-173 |alloy/binding.als |gap-unmarked |OPEN |— -|Any member may self-run any effect; results land in `refs/meta/self/<member>/*` or the inbox, adoptable by merge with the trust decision explicit. |abstractions.adoc, §6 Effect, L174 |alloy/binding.als |gap-unmarked |OPEN |— -|No content predicates beyond `results(…, status)`, no time atoms, no external-event atoms. |abstractions.adoc, §6 Effect, L177 |tla/Effects.tla |gap-unmarked |OPEN |— -|Admin-only write rule on `refs/meta/effects/*` bounds who can schedule execution on canonical infrastructure. |abstractions.adoc, §6 Effect, L181 |alloy/gate_rules.als |effect_admin_violation |OPEN |— -|Abstractions 4/5/6 close: all state changes, human or machine, flow through one verified, audited channel; the repository is the message bus. |abstractions.adoc, "The loop", L188-189 |tla/Receive.tla |gap-unmarked |OPEN |— +|Results return only as signed commits pushed to one ref per tested commit (`refs/meta/results/<effect>/<short-oid>`), so concurrent results never conflict. |abstractions.adoc, §6 Effect, L170 |alloy-paper |gap-marked |OPEN |— +|Identity discipline: the runner is a member, not an ambient authority; official results are official only because canonical refs are writable solely by designated worker keys. |abstractions.adoc, §6 Effect, L172-173 |alloy-paper |gap-unmarked |OPEN |— +|Any member may self-run any effect; results land in `refs/meta/self/<member>/*` or the inbox, adoptable by merge with the trust decision explicit. |abstractions.adoc, §6 Effect, L174 |alloy-paper |gap-unmarked |OPEN |— +|No content predicates beyond `results(…, status)`, no time atoms, no external-event atoms. |abstractions.adoc, §6 Effect, L177 |stateright |gap-unmarked |OPEN |— +|Admin-only write rule on `refs/meta/effects/*` bounds who can schedule execution on canonical infrastructure. |abstractions.adoc, §6 Effect, L181 |search, proptest |effect_admin_violation |OPEN |— +|Abstractions 4/5/6 close: all state changes, human or machine, flow through one verified, audited channel; the repository is the message bus. |abstractions.adoc, "The loop", L188-189 |stateright |gap-unmarked |OPEN |— |No code knows where it is running; an `if hosted` branch inside the library is the design failing. |abstractions.adoc, "Composition", L196-197 |— |gap-unmarked |OPEN |— |Crates that extend git carry the `gix-` prefix, import nothing from the forge, and stay upstream-shaped by construction. |abstractions.adoc, "Composition", L209-210 |— |gap-unmarked |OPEN |— -|The unit the library exposes is `receive(refs, objects, events, proposal)`: gate evaluation, effect matching, enqueue live inside it. |abstractions.adoc, "Composition", L213 |tla/Receive.tla |gap-unmarked |OPEN |— -|Local and hosted do not share a push path; they share `receive`, with only trait impls swapped — the correctness anchor for writes. |abstractions.adoc, "Composition", L215-216 |tla/Receive.tla |gap-unmarked |OPEN |— +|The unit the library exposes is `receive(refs, objects, events, proposal)`: gate evaluation, effect matching, enqueue live inside it. |abstractions.adoc, "Composition", L213 |stateright |gap-unmarked |OPEN |— +|Local and hosted do not share a push path; they share `receive`, with only trait impls swapped — the correctness anchor for writes. |abstractions.adoc, "Composition", L215-216 |stateright |gap-unmarked |OPEN |— |Dependencies point one way; a lower layer never depends on a higher one, checked mechanically. |abstractions.adoc, "Layering", L238 |— |gap-unmarked |OPEN |— |A package depends on kernel crates freely and on other packages never (`ents-forge`/`ents-kiln` never depend on each other). |abstractions.adoc, "Layering", L247 |— |gap-unmarked |OPEN |— |`ents-web`'s generic rendering path must never match on which concrete entity type it was handed. |abstractions.adoc, "Layering", L252-253 |— |gap-unmarked |OPEN |— |`ents-web` never depends back on `git-ents`. |abstractions.adoc, "Layering", L254 |— |gap-unmarked |OPEN |— |A kernel crate must not depend on a package crate in any form (normal, dev, build, or feature-flagged). |abstractions.adoc, "Layering", L256 |— |gap-unmarked |OPEN |— |`ents-testutil` must not know a package's types. |abstractions.adoc, "Layering", L257 |— |gap-unmarked |OPEN |— -|Every ref namespace is minted by one function in `ents_model::namespace`; a package calls that function rather than inventing its own layout. |abstractions.adoc, "Layering", L259 |alloy/binding.als |gap-unmarked |OPEN |— -|Fanout index: a stale or absent index degrades to scanning ref tips, never to wrong answers. |abstractions.adoc, "Derived", L280 |tla/Effects.tla |gap-unmarked |OPEN |— -|Redacted bytes are withheld from the store and generated packs; the oid stays in history as evidence; signatures and the tip invariant are untouched because verification never reads withheld bytes. |abstractions.adoc, "Derived", L282 |alloy/objects.als |gap-unmarked |OPEN |— -|Redaction is enforced at ingest so content addressing cannot let anyone refill the hole exactly. |abstractions.adoc, "Derived", L283 |tla/Receive.tla |gap-unmarked |OPEN |— -|Readers surface a redaction marker, never an error. |abstractions.adoc, "Derived", L284 |alloy/objects.als |gap-unmarked |OPEN |— +|Every ref namespace is minted by one function in `ents_model::namespace`; a package calls that function rather than inventing its own layout. |abstractions.adoc, "Layering", L259 |alloy-paper |gap-unmarked |OPEN |— +|Fanout index: a stale or absent index degrades to scanning ref tips, never to wrong answers. |abstractions.adoc, "Derived", L280 |stateright |gap-unmarked |OPEN |— +|Redacted bytes are withheld from the store and generated packs; the oid stays in history as evidence; signatures and the tip invariant are untouched because verification never reads withheld bytes. |abstractions.adoc, "Derived", L282 |alloy-paper |gap-unmarked |OPEN |— +|Redaction is enforced at ingest so content addressing cannot let anyone refill the hole exactly. |abstractions.adoc, "Derived", L283 |stateright |gap-unmarked |OPEN |— +|Readers surface a redaction marker, never an error. |abstractions.adoc, "Derived", L284 |alloy-paper |gap-unmarked |OPEN |— |Embeddable server: keeping authorization/effect-matching logic in the library, never smeared across subprocess boundaries. |abstractions.adoc, "Derived", L289-290 |— |gap-unmarked |OPEN |— -|`git effect run` shares the identical materialization and sandbox path with the hosted worker. |abstractions.adoc, "Command surface", L324 |tla/Effects.tla |gap-unmarked |OPEN |— -|Every mutation frontend shares the identical `receive` with the hosted server. |abstractions.adoc, "Command surface", L324 |tla/Receive.tla |gap-unmarked |OPEN |— -|Revocation is a state on the member entity, not deletion; a revoked key must be explicitly rejected. |abstractions.adoc, "Command surface", L341 |tla/Receive.tla |gap-unmarked |OPEN |— +|`git effect run` shares the identical materialization and sandbox path with the hosted worker. |abstractions.adoc, "Command surface", L324 |stateright |gap-unmarked |OPEN |— +|Every mutation frontend shares the identical `receive` with the hosted server. |abstractions.adoc, "Command surface", L324 |stateright |gap-unmarked |OPEN |— +|Revocation is a state on the member entity, not deletion; a revoked key must be explicitly rejected. |abstractions.adoc, "Command surface", L341 |stateright |gap-unmarked |OPEN |— |Pushes to `refs/meta/*` never touch the working tree, so metadata behaves identically in both deployment modes. |abstractions.adoc, "Deployment", L363 |— |gap-unmarked |OPEN |— |Worktree update happens only after `receive` accepts; core never touches a worktree. |abstractions.adoc, "Deployment", L361 |— |gap-unmarked |OPEN |— -|Bootstrap gap: an empty member list admits every push so the first member can enroll. |abstractions.adoc, "Deployment", L365 |tla/Receive.tla |gap-unmarked |OPEN |— -|An anchor MUST identify the exact content it was captured against (commit, path, blob oid, optional 1-based inclusive range); creation MUST validate path and range against the revision's actual content. |docs/spec/anchor.adoc, [#anchor.definition], ~L9-17 |alloy/objects.als |gap-unmarked |OPEN |— -|Anchored text MUST be fully derivable from the blob and line range, derived at read time, never stored redundantly; the anchored commit's id is recorded only as a plain data field, and MAY be gc'd. |docs/spec/anchor.adoc, [#anchor.immutable], ~L19-28 |alloy/objects.als |gap-unmarked |OPEN |— -|The anchoring document MUST embed the anchored blob "referenced by the existing blob's own object id rather than copied" plus a context blob "written fresh", as ordinary tree entries, keeping content reachable "for as long as the document's ref exists". |docs/spec/anchor.adoc, [#anchor.retention], ~L30-45 |alloy/objects.als |dangling_anchor_violation, dangling_context_violation |OPEN |— +|Bootstrap gap: an empty member list admits every push so the first member can enroll. |abstractions.adoc, "Deployment", L365 |stateright |gap-unmarked |OPEN |— +|An anchor MUST identify the exact content it was captured against (commit, path, blob oid, optional 1-based inclusive range); creation MUST validate path and range against the revision's actual content. |docs/spec/anchor.adoc, [#anchor.definition], ~L9-17 |alloy-paper |gap-unmarked |OPEN |— +|Anchored text MUST be fully derivable from the blob and line range, derived at read time, never stored redundantly; the anchored commit's id is recorded only as a plain data field, and MAY be gc'd. |docs/spec/anchor.adoc, [#anchor.immutable], ~L19-28 |alloy-paper |gap-unmarked |OPEN |— +|The anchoring document MUST embed the anchored blob "referenced by the existing blob's own object id rather than copied" plus a context blob "written fresh", as ordinary tree entries, keeping content reachable "for as long as the document's ref exists". |docs/spec/anchor.adoc, [#anchor.retention], ~L30-45 |alloy-paper |dangling_anchor_violation, dangling_context_violation |OPEN |— |Projection MUST report one of exactly four outcomes (current/relocated/outdated/deleted), MUST follow renames, and MUST work between any two commits — forwards, backwards, or across unrelated history — while the anchor's own commit exists. |docs/spec/anchor.adoc, [#anchor.projection], ~L48-61 |— |gap-unmarked |OPEN |— |After the anchored commit is gc'd, fuzzy fallback recovers the same four outcomes approximately; "an outdated or deleted projection MUST NOT lose the anchor". |docs/spec/anchor.adoc, [#anchor.fuzzy-fallback], ~L63-74 |— |gap-unmarked |OPEN |— -|A working-tree anchor survives its content being "committed, amended, or discarded"; it records HEAD only as a "best-effort, never-load-bearing" field; projection MUST support the working tree as target. |docs/spec/anchor.adoc, [#anchor.working-tree], ~L76-93 |alloy/objects.als |gap-unmarked |OPEN |— -|An effect definition MUST be rejected before storage when a toolchain name is not a valid ref-path segment or the trigger fails to parse (including `rev()` naming `refs/meta/*` or `meta()` naming an effect-written namespace). |docs/spec/effect.adoc, [#effect.validation], ~L41-50 |alloy/gate_rules.als |gap-unmarked |OPEN |— +|A working-tree anchor survives its content being "committed, amended, or discarded"; it records HEAD only as a "best-effort, never-load-bearing" field; projection MUST support the working tree as target. |docs/spec/anchor.adoc, [#anchor.working-tree], ~L76-93 |alloy-paper |gap-unmarked |OPEN |— +|An effect definition MUST be rejected before storage when a toolchain name is not a valid ref-path segment or the trigger fails to parse (including `rev()` naming `refs/meta/*` or `meta()` naming an effect-written namespace). |docs/spec/effect.adoc, [#effect.validation], ~L41-50 |search |gap-unmarked |OPEN |— |Host-direct execution MUST require an explicit `--unsandboxed` flag and MUST be "available only locally, never on canonical hosted infrastructure". |docs/spec/effect.adoc, [#effect.execution], ~L54-68 |— |gap-unmarked |OPEN |— |An effect's stored data MUST NOT select its own executor, demand `--unsandboxed`, or set retry bounds; those are deployment configuration, "never a field an effect definition can carry". |docs/spec/effect.adoc, [#effect.deployment-property], ~L70-78 |— |gap-unmarked |OPEN |— -|A member running `git effect run` locally MUST see "the same outcome a canonical worker would record"; only the queue is skipped, and the queue carries no correctness content. |docs/spec/effect.adoc, [#effect.local-run], ~L80-89 |tla/Effects.tla |gap-unmarked |OPEN |— -|Result write-back MUST be "an ordinary receive client, never a privileged write outside the gate". |docs/spec/effect.adoc, [#effect.results-writeback], ~L93-106 |tla/Receive.tla |gap-unmarked |OPEN |— +|A member running `git effect run` locally MUST see "the same outcome a canonical worker would record"; only the queue is skipped, and the queue carries no correctness content. |docs/spec/effect.adoc, [#effect.local-run], ~L80-89 |stateright |gap-unmarked |OPEN |— +|Result write-back MUST be "an ordinary receive client, never a privileged write outside the gate". |docs/spec/effect.adoc, [#effect.results-writeback], ~L93-106 |stateright |gap-unmarked |OPEN |— |Only the sandbox MAY touch a toolchain's extracted bytes; declared components MUST be resolved during effect execution, "never by any other code path". |docs/spec/effect.adoc, [#effect.toolchains], ~L161-168 |— |gap-unmarked |OPEN |— -|A fanout index MUST be rebuilt only by an effect and written back only as a worker-signed commit, "never by any privileged out-of-band writer". |docs/spec/effect.adoc, [#effect.fanout-index], ~L174-184 |tla/Effects.tla |gap-unmarked |OPEN |— -|Verification MUST depend only on the read half of the RefStore seam, never on write access or on any state outside `refs/meta/*`. |docs/spec/gate.adoc, intro (also [#arch.refstore-read-cas-split]), ~L3-5 |alloy/gate_rules.als |gap-unmarked |OPEN |— -|Authorization is judged against the member entity in force at acceptance time — the member ref's tip in the same snapshot the gate reads. |docs/spec/gate.adoc, [#gate.tip-signed], ~L12-23 |tla/Receive.tla |unsigned_violation (partial: enrolled-membership only, not snapshot-scoped refname authorization) |OPEN |— -|A gate-owned hash-identified entity's creation MUST strictly decode as its type, an unknown tree entry refusing; the gate-owned entity structs MUST stay pairwise disjoint under this decode. |docs/spec/gate.adoc, [#gate.identity-binding], ~L36-40 |alloy/binding.als |gap-unmarked |OPEN |— -|When an object the binding needs is withheld by redaction, the binding MUST be vouched by the admin-signed redaction record, and a redacted object MUST NOT be re-admitted. |docs/spec/gate.adoc, [#gate.identity-binding], ~L47-51 |tla/Receive.tla |gap-unmarked |OPEN |— -|Advancing a hash-identified entity's ref is authorized only for the genesis signer or an admin; a review ref advances only under the signature of the member its refname names. |docs/spec/gate.adoc, [#gate.owner-mutation], ~L54-67 |alloy/gate_rules.als |gap-unmarked |OPEN |— -|A pre-flight verdict "is a prediction that can only go stale; it MUST NOT diverge from the rules the hosted store will actually apply". |docs/spec/gate.adoc, [#gate.call-sites], ~L188-196 |tla/Receive.tla |gap-unmarked |OPEN |— -|Transport-auth evidence MUST be threaded through uninterpreted — "never substituted for the tip invariant on a `refs/meta/*` update"; no advisory call site MAY render a `refs/heads/*` verdict meanwhile. |docs/spec/gate.adoc, [#gate.branch-acl-undefined], ~L267-284 |tla/Receive.tla |gap-unmarked |OPEN |— -|With no `refs/meta/member/*` ref, first enrollment is self-admitting; but a member set whose keys are all revoked "MUST fail closed: revoking every key MUST NOT reopen this self-admitting window". |docs/spec/gate.adoc, [#gate.bootstrap], ~L288-296 |tla/Receive.tla |gap-unmarked |OPEN |— +|A fanout index MUST be rebuilt only by an effect and written back only as a worker-signed commit, "never by any privileged out-of-band writer". |docs/spec/effect.adoc, [#effect.fanout-index], ~L174-184 |stateright |gap-unmarked |OPEN |— +|Verification MUST depend only on the read half of the RefStore seam, never on write access or on any state outside `refs/meta/*`. |docs/spec/gate.adoc, intro (also [#arch.refstore-read-cas-split]), ~L3-5 |search |gap-unmarked |OPEN |— +|Authorization is judged against the member entity in force at acceptance time — the member ref's tip in the same snapshot the gate reads. |docs/spec/gate.adoc, [#gate.tip-signed], ~L12-23 |stateright |unsigned_violation (partial: enrolled-membership only, not snapshot-scoped refname authorization) |OPEN |— +|A gate-owned hash-identified entity's creation MUST strictly decode as its type, an unknown tree entry refusing; the gate-owned entity structs MUST stay pairwise disjoint under this decode. |docs/spec/gate.adoc, [#gate.identity-binding], ~L36-40 |alloy-paper |gap-unmarked |OPEN |— +|When an object the binding needs is withheld by redaction, the binding MUST be vouched by the admin-signed redaction record, and a redacted object MUST NOT be re-admitted. |docs/spec/gate.adoc, [#gate.identity-binding], ~L47-51 |stateright |gap-unmarked |OPEN |— +|Advancing a hash-identified entity's ref is authorized only for the genesis signer or an admin; a review ref advances only under the signature of the member its refname names. |docs/spec/gate.adoc, [#gate.owner-mutation], ~L54-67 |search |gap-unmarked |OPEN |— +|A pre-flight verdict "is a prediction that can only go stale; it MUST NOT diverge from the rules the hosted store will actually apply". |docs/spec/gate.adoc, [#gate.call-sites], ~L188-196 |stateright |gap-unmarked |OPEN |— +|Transport-auth evidence MUST be threaded through uninterpreted — "never substituted for the tip invariant on a `refs/meta/*` update"; no advisory call site MAY render a `refs/heads/*` verdict meanwhile. |docs/spec/gate.adoc, [#gate.branch-acl-undefined], ~L267-284 |stateright |gap-unmarked |OPEN |— +|With no `refs/meta/member/*` ref, first enrollment is self-admitting; but a member set whose keys are all revoked "MUST fail closed: revoking every key MUST NOT reopen this self-admitting window". |docs/spec/gate.adoc, [#gate.bootstrap], ~L288-296 |stateright |gap-unmarked |OPEN |— |`git ents lsp` MUST NOT bind a network socket, MUST NOT add a git-serving transport, and MUST receive its signing identity by injection from the composition root. |docs/spec/lens.adoc, [#lens.serve], ~L13-24 |— |gap-unmarked |OPEN |— |Lens ranges are derived at request time via projection and "never cached across mutations of the comment's ref"; non-`open` comments omitted unless asked. |docs/spec/lens.adoc, [#lens.lenses], ~L26-39 |— |gap-unmarked |OPEN |— |Comment diagnostics "MUST NEVER use warning or error severity" and MUST be suppressible without affecting the lenses. |docs/spec/lens.adoc, [#lens.diagnostics], ~L41-50 |— |gap-unmarked |OPEN |— |Composing MUST require no client-specific extension; a richer input surface "MUST be sugar over the same create operation, never a second mechanism". |docs/spec/lens.adoc, [#lens.compose], ~L61-75 |— |gap-unmarked |OPEN |— -|Editor-composed comments MUST anchor to the working tree's content when it differs from HEAD — "exactly the bytes the author was reading". |docs/spec/lens.adoc, [#lens.working-tree], ~L77-87 |alloy/objects.als |gap-unmarked |OPEN |— +|Editor-composed comments MUST anchor to the working tree's content when it differs from HEAD — "exactly the bytes the author was reading". |docs/spec/lens.adoc, [#lens.working-tree], ~L77-87 |alloy-paper |gap-unmarked |OPEN |— |Every lens operation MUST be the same library call the `git ents comment` porcelain exposes; the CLI listing MUST offer a machine-readable form sufficient for an agent with no editor attached. |docs/spec/lens.adoc, [#lens.parity], ~L89-101 |— |gap-unmarked |OPEN |— -|All forge state MUST live under `refs/meta/*`; retention pins under `refs/meta/pins/*` are "the sole exception" to tree-is-the-entity, carrying the empty tree, never an entity. |docs/spec/meta-ref.adoc, [#meta-ref.namespace], ~L15-27 |alloy/objects.als |gap-unmarked |OPEN |— -|Inbox routing preserves the canonical ref's entire path below `refs/meta/` so "two different entity kinds can never collide under the same inbox id"; `<member>` is the leading segment so authorization keys off the refname alone. |docs/spec/meta-ref.adoc, [#meta-ref.inbox], ~L45-61 |alloy/binding.als |gap-unmarked |OPEN |— -|A member is authorized only for its own `refs/meta/inbox/<member>/*` segment; "no member, including an admin-registered one, MAY write into another member's inbox segment". |docs/spec/meta-ref.adoc, [#meta-ref.inbox], ~L62-68 |alloy/gate_rules.als |gap-unmarked |OPEN |— -|An inbox ref MUST NOT be deleted on adoption or at any other time — it remains the contributor's audit trail. |docs/spec/meta-ref.adoc, [#meta-ref.inbox], ~L69-71 |tla/Receive.tla |gap-unmarked |OPEN |— -|`self` is its own top-level namespace, keeping the canonical results glob and the self-run glob "disjoint by construction"; both namespaces hold the same typed trees as canonical, only the refname rule differs. |docs/spec/meta-ref.adoc, [#meta-ref.inbox], ~L72-85 |alloy/binding.als |gap-unmarked |OPEN |— -|For a hash-identified entity, "every parentless commit reachable from the proposed tip MUST be that genesis" — the reachability form makes doppelgänger replay impossible and holds across divergence merges. |docs/spec/meta-ref.adoc, [#meta-ref.identity-binding], ~L117-124 |alloy/binding.als |genesis_violation, second_root_violation |OPEN |— -|The parentless-roots walk MUST NOT be applied to pins: a pin's ancestry deliberately reaches into code history. |docs/spec/meta-ref.adoc, [#meta-ref.identity-binding], ~L132-135 |alloy/binding.als |gap-unmarked |OPEN |— -|Who authored a state, and when, MUST come from the commit itself, never from duplicated tree fields: "each datum has exactly one signed home". |docs/spec/meta-ref.adoc, [#meta-ref.identity-binding], ~L144-147 |alloy/objects.als |gap-unmarked |OPEN |— -|Within a meta-ref's history a commit parent means exactly one thing — the prior state of the same entity (pin retained-commit parents sole exception); a cross-entity relationship MUST be tree data, never a parent edge. |docs/spec/meta-ref.adoc, [#meta-ref.identity-binding], ~L147-150 |alloy/objects.als |gap-unmarked |OPEN |— -|A genesis commit is frozen by the identity derived from it, so hash-identified/composite-keyed structs MUST evolve additively only — "new fields optional, required fields never added, renamed, or removed". |docs/spec/meta-ref.adoc, [#meta-ref.identity-binding], ~L149-153 |alloy/objects.als |gap-unmarked |OPEN |— -|A struct change is a migration committed on top of the old tip; history keeps the old encoding, and a struct change "MUST NOT rewrite or delete a prior commit on the ref". |docs/spec/meta-ref.adoc, [#meta-ref.migration], ~L172-180 |alloy/objects.als |gap-unmarked |OPEN |— -|The gate and `receive` are content-agnostic: verification depends on "signature, refname, trailer, and DAG descent, never on tree contents", so a stock server MUST carry entity types it cannot parse. |docs/spec/model.adoc, [#model.extensibility], ~L8-22 |alloy/gate_rules.als |gap-unmarked |OPEN |— +|All forge state MUST live under `refs/meta/*`; retention pins under `refs/meta/pins/*` are "the sole exception" to tree-is-the-entity, carrying the empty tree, never an entity. |docs/spec/meta-ref.adoc, [#meta-ref.namespace], ~L15-27 |alloy-paper |gap-unmarked |OPEN |— +|Inbox routing preserves the canonical ref's entire path below `refs/meta/` so "two different entity kinds can never collide under the same inbox id"; `<member>` is the leading segment so authorization keys off the refname alone. |docs/spec/meta-ref.adoc, [#meta-ref.inbox], ~L45-61 |alloy-paper |gap-unmarked |OPEN |— +|A member is authorized only for its own `refs/meta/inbox/<member>/*` segment; "no member, including an admin-registered one, MAY write into another member's inbox segment". |docs/spec/meta-ref.adoc, [#meta-ref.inbox], ~L62-68 |search |gap-unmarked |OPEN |— +|An inbox ref MUST NOT be deleted on adoption or at any other time — it remains the contributor's audit trail. |docs/spec/meta-ref.adoc, [#meta-ref.inbox], ~L69-71 |stateright |gap-unmarked |OPEN |— +|`self` is its own top-level namespace, keeping the canonical results glob and the self-run glob "disjoint by construction"; both namespaces hold the same typed trees as canonical, only the refname rule differs. |docs/spec/meta-ref.adoc, [#meta-ref.inbox], ~L72-85 |alloy-paper |gap-unmarked |OPEN |— +|For a hash-identified entity, "every parentless commit reachable from the proposed tip MUST be that genesis" — the reachability form makes doppelgänger replay impossible and holds across divergence merges. |docs/spec/meta-ref.adoc, [#meta-ref.identity-binding], ~L117-124 |alloy-paper |genesis_violation, second_root_violation |OPEN |— +|The parentless-roots walk MUST NOT be applied to pins: a pin's ancestry deliberately reaches into code history. |docs/spec/meta-ref.adoc, [#meta-ref.identity-binding], ~L132-135 |alloy-paper |gap-unmarked |OPEN |— +|Who authored a state, and when, MUST come from the commit itself, never from duplicated tree fields: "each datum has exactly one signed home". |docs/spec/meta-ref.adoc, [#meta-ref.identity-binding], ~L144-147 |alloy-paper |gap-unmarked |OPEN |— +|Within a meta-ref's history a commit parent means exactly one thing — the prior state of the same entity (pin retained-commit parents sole exception); a cross-entity relationship MUST be tree data, never a parent edge. |docs/spec/meta-ref.adoc, [#meta-ref.identity-binding], ~L147-150 |alloy-paper |gap-unmarked |OPEN |— +|A genesis commit is frozen by the identity derived from it, so hash-identified/composite-keyed structs MUST evolve additively only — "new fields optional, required fields never added, renamed, or removed". |docs/spec/meta-ref.adoc, [#meta-ref.identity-binding], ~L149-153 |alloy-paper |gap-unmarked |OPEN |— +|A struct change is a migration committed on top of the old tip; history keeps the old encoding, and a struct change "MUST NOT rewrite or delete a prior commit on the ref". |docs/spec/meta-ref.adoc, [#meta-ref.migration], ~L172-180 |alloy-paper |gap-unmarked |OPEN |— +|The gate and `receive` are content-agnostic: verification depends on "signature, refname, trailer, and DAG descent, never on tree contents", so a stock server MUST carry entity types it cannot parse. |docs/spec/model.adoc, [#model.extensibility], ~L8-22 |search |gap-unmarked |OPEN |— |A facet shape MUST be compile-time-defined: runtime-readable, never runtime-constructible. |docs/spec/model.adoc, [#model.extensibility], ~L13-15 |— |gap-unmarked |OPEN |— -|A member's id binds to its key-carrying entity's refname final segment; enrollment occurs as a signed commit — the member is forge state "with no user database separate from the repository". |docs/spec/model.adoc, [#model.member-identity], ~L31-40 |alloy/binding.als |gap-unmarked |OPEN |— -|Admission consults the member entity currently in force, so a revoked key's new pushes are refused "from the moment the revocation lands, regardless of any committer timestamp the pushed commit claims". |docs/spec/model.adoc, [#model.member-revocation], ~L47-50 |tla/Receive.tla |gap-unmarked |OPEN |— -|A ref accepted before the revocation landed remains valid: "acceptance is never re-judged". |docs/spec/model.adoc, [#model.member-revocation], ~L51-52 |tla/Receive.tla |gap-unmarked |OPEN |— -|Unrevoking returns the key to authorizing new signatures "without altering the record of the period it was revoked". |docs/spec/model.adoc, [#model.member-revocation], ~L60-61 |tla/Receive.tla |gap-unmarked |OPEN |— -|A self-attested member MUST NOT be authorized for canonical refs — writes limited to its own inbox and self-run namespaces — until an admin-registered member promotes it. |docs/spec/model.adoc, [#model.member-provenance], ~L64-75 |alloy/gate_rules.als |gap-unmarked (effect_admin_violation covers only the effects namespace, not the provenance tier generally) |OPEN |— -|A machine actor is an ordinary Member entity, revocable like any human key; "a privileged write path for a machine actor MUST NOT exist outside this model". |docs/spec/model.adoc, [#model.member-worker], ~L77-86 |tla/Receive.tla |gap-unmarked |OPEN |— -|A comment's identity is the oid of its genesis commit and "MUST NEVER change afterward: edits advance the ref, they do not rename it". |docs/spec/model.adoc, [#model.comment], ~L99-102 |alloy/binding.als |gap-unmarked |OPEN |— +|A member's id binds to its key-carrying entity's refname final segment; enrollment occurs as a signed commit — the member is forge state "with no user database separate from the repository". |docs/spec/model.adoc, [#model.member-identity], ~L31-40 |alloy-paper |gap-unmarked |OPEN |— +|Admission consults the member entity currently in force, so a revoked key's new pushes are refused "from the moment the revocation lands, regardless of any committer timestamp the pushed commit claims". |docs/spec/model.adoc, [#model.member-revocation], ~L47-50 |stateright |gap-unmarked |OPEN |— +|A ref accepted before the revocation landed remains valid: "acceptance is never re-judged". |docs/spec/model.adoc, [#model.member-revocation], ~L51-52 |stateright |gap-unmarked |OPEN |— +|Unrevoking returns the key to authorizing new signatures "without altering the record of the period it was revoked". |docs/spec/model.adoc, [#model.member-revocation], ~L60-61 |stateright |gap-unmarked |OPEN |— +|A self-attested member MUST NOT be authorized for canonical refs — writes limited to its own inbox and self-run namespaces — until an admin-registered member promotes it. |docs/spec/model.adoc, [#model.member-provenance], ~L64-75 |search |gap-unmarked (effect_admin_violation covers only the effects namespace, not the provenance tier generally) |OPEN |— +|A machine actor is an ordinary Member entity, revocable like any human key; "a privileged write path for a machine actor MUST NOT exist outside this model". |docs/spec/model.adoc, [#model.member-worker], ~L77-86 |stateright |gap-unmarked |OPEN |— +|A comment's identity is the oid of its genesis commit and "MUST NEVER change afterward: edits advance the ref, they do not rename it". |docs/spec/model.adoc, [#model.comment], ~L99-102 |alloy-paper |gap-unmarked |OPEN |— |A comment about nothing MUST be refused at creation by the writing tool, "though never by the gate, which stays content-agnostic". |docs/spec/model.adoc, [#model.comment], ~L93-98 |— |gap-unmarked |OPEN |— |A new comment's state is `open`; resolving records `resolved` as an ordinary mutation commit — "never a deletion, so the conversation stays auditable" — and reopening is supported the same way. |docs/spec/model.adoc, [#model.comment-state], ~L109-121 |— |gap-unmarked |OPEN |— |A state-changing mutation by an enrolled key MUST carry a `Key-for-<member-id>` trailer whose value is the member ref's tip oid at mutation time, pinning the enrolled record across later rotation or revocation. |docs/spec/model.adoc, [#model.comment-provenance], ~L123-129 |— |gap-unmarked |OPEN |— -|An entity's thread MUST be an aggregation query over comment refs; a context entity MUST NOT store its comment list, so concurrent commenters "never race a shared ref". |docs/spec/model.adoc, [#model.comment-context], ~L132-142 |alloy/objects.als |gap-marked (instance of abstraction 1's granularity rule) |OPEN |— -|A reply's parent MUST exist when the reply is created; aboutness is inherited from the thread root; no comment stores a list of its replies. |docs/spec/model.adoc, [#model.comment-thread], ~L144-154 |alloy/objects.als |gap-unmarked |OPEN |— -|An issue's identity is its genesis oid — no sequential counter exists — and machine-readable output (including `--porcelain`) MUST carry the full id. |docs/spec/model.adoc, [#model.issue], ~L158-177 |alloy/binding.als |gap-unmarked |OPEN |— -|A review binds by composite key `reviews/<target>/<member>`; at genesis the reviewed-commit tree field equals the `<target>` segment and binds it; re-reviewing advances the field while the refname stays keyed by genesis. |docs/spec/model.adoc, [#model.review], ~L181-198 |alloy/binding.als |gap-unmarked |OPEN |— -|Every review MUST occupy exactly two refs: the entity ref and its retention pin. |docs/spec/model.adoc, [#model.review], ~L191-193 |alloy/objects.als |gap-unmarked |OPEN |— +|An entity's thread MUST be an aggregation query over comment refs; a context entity MUST NOT store its comment list, so concurrent commenters "never race a shared ref". |docs/spec/model.adoc, [#model.comment-context], ~L132-142 |alloy-paper |gap-marked (instance of abstraction 1's granularity rule) |OPEN |— +|A reply's parent MUST exist when the reply is created; aboutness is inherited from the thread root; no comment stores a list of its replies. |docs/spec/model.adoc, [#model.comment-thread], ~L144-154 |alloy-paper |gap-unmarked |OPEN |— +|An issue's identity is its genesis oid — no sequential counter exists — and machine-readable output (including `--porcelain`) MUST carry the full id. |docs/spec/model.adoc, [#model.issue], ~L158-177 |alloy-paper |gap-unmarked |OPEN |— +|A review binds by composite key `reviews/<target>/<member>`; at genesis the reviewed-commit tree field equals the `<target>` segment and binds it; re-reviewing advances the field while the refname stays keyed by genesis. |docs/spec/model.adoc, [#model.review], ~L181-198 |alloy-paper |gap-unmarked |OPEN |— +|Every review MUST occupy exactly two refs: the entity ref and its retention pin. |docs/spec/model.adoc, [#model.review], ~L191-193 |alloy-paper |gap-unmarked |OPEN |— |A review's verdict MUST be a hard enum — `approve`, `request-changes`, or `comment`: "a verdict gates decisions, so its vocabulary is platform, not schema". |docs/spec/model.adoc, [#model.review], ~L199-202 |— |gap-unmarked |OPEN |— -|A pin's tip is a reviewer-signed commit whose parents include the reviewed commit, so that commit and its ancestry "survive force-push, branch deletion, and gc for as long as the review exists". |docs/spec/model.adoc, [#model.review-pin], ~L213-224 |alloy/objects.als |gap-unmarked |OPEN |— -|Re-reviewing MUST advance the pin fast-forward with parents = previous pin tip + newly reviewed commit, so every reviewed round stays retained and the pin's history is the audit trail. |docs/spec/model.adoc, [#model.review-pin], ~L225-229 |alloy/objects.als |gap-unmarked |OPEN |— -|A result MUST carry the effect's name and the judged commit's full oid as tree fields from which the refname derives — "a result MUST mean something with the refname stripped away" (a signed `pass` cannot be replayed against another effect/commit). |docs/spec/model.adoc, [#model.result-identity], ~L260-270 |alloy/binding.als |gap-unmarked |OPEN |— -|Result fields are not parent edges: a result ref's parents stay prior states of the same result, and a result MUST NOT retain the judged commit's ancestry the way a pin does. |docs/spec/model.adoc, [#model.result-identity], ~L271-274 |alloy/objects.als |gap-unmarked |OPEN |— +|A pin's tip is a reviewer-signed commit whose parents include the reviewed commit, so that commit and its ancestry "survive force-push, branch deletion, and gc for as long as the review exists". |docs/spec/model.adoc, [#model.review-pin], ~L213-224 |alloy-paper |gap-unmarked |OPEN |— +|Re-reviewing MUST advance the pin fast-forward with parents = previous pin tip + newly reviewed commit, so every reviewed round stays retained and the pin's history is the audit trail. |docs/spec/model.adoc, [#model.review-pin], ~L225-229 |alloy-paper |gap-unmarked |OPEN |— +|A result MUST carry the effect's name and the judged commit's full oid as tree fields from which the refname derives — "a result MUST mean something with the refname stripped away" (a signed `pass` cannot be replayed against another effect/commit). |docs/spec/model.adoc, [#model.result-identity], ~L260-270 |alloy-paper |gap-unmarked |OPEN |— +|Result fields are not parent edges: a result ref's parents stay prior states of the same result, and a result MUST NOT retain the judged commit's ancestry the way a pin does. |docs/spec/model.adoc, [#model.result-identity], ~L271-274 |alloy-paper |gap-unmarked |OPEN |— |A toolchain is a hash-pinned ~1KB manifest carrying its own provenance, "a resource an effect declares as a dependency … never a trigger condition in its own right". |docs/spec/model.adoc, [#model.toolchain], ~L279-289 |— |gap-unmarked |OPEN |— -|A Redaction entity carries the target oid, a reason, and the admin signature; it "MUST NOT carry the redacted content itself". |docs/spec/model.adoc, [#model.redaction], ~L294-300 |alloy/objects.als |gap-unmarked |OPEN |— +|A Redaction entity carries the target oid, a reason, and the admin signature; it "MUST NOT carry the redacted content itself". |docs/spec/model.adoc, [#model.redaction], ~L294-300 |alloy-paper |gap-unmarked |OPEN |— |Authentication state MUST live in the repository as ordinary forge state; "a session database or token table MUST NOT back it". |docs/spec/model.adoc, [#model.account], ~L308-313 |— |gap-unmarked |OPEN |— |The library MUST NOT define its own ObjectStore trait — gitoxide's `Find`/`Exists`/`Write` ARE the seam; a new trait exists only where gitoxide is silent. |docs/spec/overview.adoc, [#arch.no-object-store-trait], ~L180-189 |— |gap-unmarked |OPEN |— |The gate's pure verify function MUST live in a crate separate from `receive`; advisory call sites MUST be answerable without linking effect-matching and enqueue logic. |docs/spec/overview.adoc, [#arch.gate-receive-split], ~L191-199 |— |gap-unmarked |OPEN |— |The query algebra MUST live apart from executor code; `receive` MUST NOT depend on the effect crate, "so no push path links executor code". |docs/spec/overview.adoc, [#arch.query-effect-split], ~L201-208 |— |gap-unmarked |OPEN |— |A concrete store implementation is wired only inside a composition root, never a library crate, and is not promoted to a shared library crate until a second root consumes it. |docs/spec/overview.adoc, [#arch.store-composition-root], ~L211-219 |— |gap-unmarked |OPEN |— -|A loose-ref RefStore MUST write refs through its own CAS discipline and MUST NOT shell out to `git update-ref`, so local mutations honor the same CAS guarantee as hosted. |docs/spec/overview.adoc, [#arch.loose-cas-discipline], ~L239-245 |tla/Receive.tla |gap-unmarked |OPEN |— -|A CommitQuery MUST parse per the fixed grammar; `\|`/`&`/`-` denote union/intersection/difference, left-associative at a single precedence level, parentheses the only override. |docs/spec/query.adoc, [#query.grammar] (+ [#query.set-ops]), ~L12-35 |tla/Effects.tla |gap-unmarked |OPEN |— -|Unsupported rev syntax (`~n`/`^n`, `A...B`, `@{...}`, abbreviated hex) and any `refs/meta/*` pattern MUST be rejected as malformed — "never silently evaluated to the empty set or to the wrong set". |docs/spec/query.adoc, [#query.rev], ~L39-58 |tla/Effects.tla |gap-unmarked |OPEN |— -|Membership in `results(effect, status)` is decided solely by existence of a matching results ref — never by reachability from `refs/heads/*` or any ref outside the query's footprint — and resolution is a refname scan, never a history walk. |docs/spec/query.adoc, [#query.results], ~L61-79 |tla/Effects.tla |gap-unmarked |OPEN |— -|The fanout index "MUST NOT be addressable by any query atom at all". |docs/spec/query.adoc, [#query.meta], ~L82-91 |tla/Effects.tla |gap-unmarked |OPEN |— -|`self` in the work set is evaluation-time notation, "not a keyword an author may write in a trigger"; work-set evaluation inherits incremental bounds and refname-scan resolution, never a full-history walk. |docs/spec/query.adoc, [#query.workset], ~L159-173 |tla/Effects.tla |gap-marked (work-set/dedup materialization is the declared cross-transaction gap) |OPEN |— -|Replicating refs a trusted remote already admitted MAY apply directly (re-verification on fetch is opt-in audit); commits the replicating machinery authors itself — divergence and adoption merges — are origination, not replication. |docs/spec/receive.adoc, [#receive.unit], ~L10-27 |tla/Receive.tla |gap-unmarked |OPEN |— -|Gate evaluation MUST be checkable against exactly the proposal shape, frontend-independent; meta-ref admission "MUST ignore [transport-auth evidence] entirely and MUST NOT consult it in place of the tip invariant". |docs/spec/receive.adoc, [#receive.proposal-shape], ~L29-47 |tla/Receive.tla |gap-unmarked |OPEN |— -|An entity declared across multiple refs MUST write them in a single Proposal through one `receive` call; the atomic multi-ref CAS admits or refuses the whole batch, "so such an entity is never observable with only some of its refs written". |docs/spec/receive.adoc, [#receive.multi-ref-atomicity], ~L49-66 |tla/Receive.tla |gap-unmarked |OPEN |— -|A `receive` inside a git hook reads existing state through the common object directory, never git's quarantine directory: quarantined objects "MUST NOT be treated as stored until the transaction commits". |docs/spec/receive.adoc, [#receive.object-access], ~L93-105 |tla/Durability.tla |gap-unmarked |OPEN |— -|Pending obligations MUST be derivable from repository state alone; an EventSink MAY lose events on crash provided the root reconciles at startup before serving pushes; "a durable queue MUST be treated as a performance optimization, never a correctness requirement". |docs/spec/receive.adoc, [#receive.reconstructible], ~L134-146 |tla/Durability.tla |gap-marked (cross-transaction queue/materialization state is the declared dedup gap) |OPEN |— -|A push to `refs/meta/redactions/*` MUST be rejected unless the member is admin-registered, regardless of any other role rule, statable as a single refname glob. |docs/spec/receive.adoc, [#receive.redaction-admin-only], ~L148-158 |alloy/gate_rules.als |gap-unmarked (effect_admin_violation covers only `refs/meta/effects/*`; no redactions-namespace rule exists) |OPEN |— +|A loose-ref RefStore MUST write refs through its own CAS discipline and MUST NOT shell out to `git update-ref`, so local mutations honor the same CAS guarantee as hosted. |docs/spec/overview.adoc, [#arch.loose-cas-discipline], ~L239-245 |stateright |gap-unmarked |OPEN |— +|A CommitQuery MUST parse per the fixed grammar; `\|`/`&`/`-` denote union/intersection/difference, left-associative at a single precedence level, parentheses the only override. |docs/spec/query.adoc, [#query.grammar] (+ [#query.set-ops]), ~L12-35 |stateright |gap-unmarked |OPEN |— +|Unsupported rev syntax (`~n`/`^n`, `A...B`, `@{...}`, abbreviated hex) and any `refs/meta/*` pattern MUST be rejected as malformed — "never silently evaluated to the empty set or to the wrong set". |docs/spec/query.adoc, [#query.rev], ~L39-58 |stateright |gap-unmarked |OPEN |— +|Membership in `results(effect, status)` is decided solely by existence of a matching results ref — never by reachability from `refs/heads/*` or any ref outside the query's footprint — and resolution is a refname scan, never a history walk. |docs/spec/query.adoc, [#query.results], ~L61-79 |stateright |gap-unmarked |OPEN |— +|The fanout index "MUST NOT be addressable by any query atom at all". |docs/spec/query.adoc, [#query.meta], ~L82-91 |stateright |gap-unmarked |OPEN |— +|`self` in the work set is evaluation-time notation, "not a keyword an author may write in a trigger"; work-set evaluation inherits incremental bounds and refname-scan resolution, never a full-history walk. |docs/spec/query.adoc, [#query.workset], ~L159-173 |stateright |gap-marked (work-set/dedup materialization is the declared cross-transaction gap) |OPEN |— +|Replicating refs a trusted remote already admitted MAY apply directly (re-verification on fetch is opt-in audit); commits the replicating machinery authors itself — divergence and adoption merges — are origination, not replication. |docs/spec/receive.adoc, [#receive.unit], ~L10-27 |stateright |gap-unmarked |OPEN |— +|Gate evaluation MUST be checkable against exactly the proposal shape, frontend-independent; meta-ref admission "MUST ignore [transport-auth evidence] entirely and MUST NOT consult it in place of the tip invariant". |docs/spec/receive.adoc, [#receive.proposal-shape], ~L29-47 |stateright |gap-unmarked |OPEN |— +|An entity declared across multiple refs MUST write them in a single Proposal through one `receive` call; the atomic multi-ref CAS admits or refuses the whole batch, "so such an entity is never observable with only some of its refs written". |docs/spec/receive.adoc, [#receive.multi-ref-atomicity], ~L49-66 |stateright |gap-unmarked |OPEN |— +|A `receive` inside a git hook reads existing state through the common object directory, never git's quarantine directory: quarantined objects "MUST NOT be treated as stored until the transaction commits". |docs/spec/receive.adoc, [#receive.object-access], ~L93-105 |stateright |gap-unmarked |OPEN |— +|Pending obligations MUST be derivable from repository state alone; an EventSink MAY lose events on crash provided the root reconciles at startup before serving pushes; "a durable queue MUST be treated as a performance optimization, never a correctness requirement". |docs/spec/receive.adoc, [#receive.reconstructible], ~L134-146 |stateright |gap-marked (cross-transaction queue/materialization state is the declared dedup gap) |OPEN |— +|A push to `refs/meta/redactions/*` MUST be rejected unless the member is admin-registered, regardless of any other role rule, statable as a single refname glob. |docs/spec/receive.adoc, [#receive.redaction-admin-only], ~L148-158 |search |gap-unmarked (effect_admin_violation covers only `refs/meta/effects/*`; no redactions-namespace rule exists) |OPEN |— |The local root wires loose refs, the odb, Docker, null EventSink, and the advisory gate; `git ents serve` MUST NOT expose git's smart-HTTP wire protocol; local effect execution is pull, "never a daemon watching refs". |docs/spec/roots.adoc, [#roots.local], ~L20-32 |— |gap-unmarked |OPEN |— -|The single-node hosted root's hooks call the gate and reconcile around `receive-pack`'s own ref update, "never through this crate's own `RefStore::transaction`, to avoid a double-write race". |docs/spec/roots.adoc, [#roots.single-node-hosted], ~L34-52 |tla/Receive.tla |gap-unmarked |OPEN |— -|The worker MUST NOT share in-process state with `receive`. |docs/spec/roots.adoc, [#roots.hosted], ~L55-63 |tla/Effects.tla |gap-unmarked |OPEN |— +|The single-node hosted root's hooks call the gate and reconcile around `receive-pack`'s own ref update, "never through this crate's own `RefStore::transaction`, to avoid a double-write race". |docs/spec/roots.adoc, [#roots.single-node-hosted], ~L34-52 |stateright |gap-unmarked |OPEN |— +|The worker MUST NOT share in-process state with `receive`. |docs/spec/roots.adoc, [#roots.hosted], ~L55-63 |stateright |gap-unmarked |OPEN |— |Wiring the scale-out root (Postgres/Tigris/durable queue) MUST require zero modification to any library crate — the seam design's honesty test, proven by an actual production migration. |docs/spec/roots.adoc, [#roots.honesty-test], ~L66-75 |— |gap-unmarked |OPEN |— |Configuration selects trait implementations only at the composition root and MUST NOT leak past it. |docs/spec/roots.adoc, [#roots.config-isolation], ~L78-83 |— |gap-unmarked |OPEN |— |`ents-web` receives its signing identity by injection and MUST NOT assume a network; in-process webview embedding remains a supported deployment. |docs/spec/roots.adoc, [#roots.web-agnostic], ~L96-103 |— |gap-unmarked |OPEN |— |A hosted web session is held only in server memory; every state-changing request carries a per-session CSRF token verified before acting; a web edit is signed only on behalf of an authenticated session. |docs/spec/roots.adoc, [#roots.web-session], ~L141-148 |— |gap-unmarked |OPEN |— |Every repository-path segment MUST be validated before filesystem or subprocess use, rejecting escapes, nesting inside an existing repository, or namespace-directory collisions. |docs/spec/roots.adoc, [#roots.path-validation], ~L151-158 |— |gap-unmarked |OPEN |— -|Fetch authorization MUST be refname-keyed, using the same authorization model as write authorization. |docs/spec/roots.adoc, [#roots.fetch-auth], ~L160-167 |alloy/gate_rules.als |gap-unmarked |OPEN |— -|A clone plus `refs/meta/*` MUST carry the complete audit history and the signatures needed to verify it, "with no server-side data left behind". |docs/spec/sync.adoc, [#sync.forge-transfer], ~L9-16 |alloy/objects.als |gap-unmarked |OPEN |— -|Inbox adoption and self-run adoption MUST both go through the same merge machinery as divergence resolution, "not a separate adoption code path". |docs/spec/sync.adoc, [#sync.adoption-machinery], ~L50-57 |tla/Receive.tla |gap-unmarked |OPEN |— -|"Nothing in the resulting ref-store state lets a pure verifier tell" a cherry-picked adoption from a hand-authored commit — attribution preservation binds the merge machinery, never the gate. |docs/spec/sync.adoc, [#sync.adoption-no-cherry-pick] (also gate.adoc Adoption intro ~L218-224), ~L59-73 |tla/Receive.tla |gap-unmarked |OPEN |— +|Fetch authorization MUST be refname-keyed, using the same authorization model as write authorization. |docs/spec/roots.adoc, [#roots.fetch-auth], ~L160-167 |search |gap-unmarked |OPEN |— +|A clone plus `refs/meta/*` MUST carry the complete audit history and the signatures needed to verify it, "with no server-side data left behind". |docs/spec/sync.adoc, [#sync.forge-transfer], ~L9-16 |alloy-paper |gap-unmarked |OPEN |— +|Inbox adoption and self-run adoption MUST both go through the same merge machinery as divergence resolution, "not a separate adoption code path". |docs/spec/sync.adoc, [#sync.adoption-machinery], ~L50-57 |stateright |gap-unmarked |OPEN |— +|"Nothing in the resulting ref-store state lets a pure verifier tell" a cherry-picked adoption from a hand-authored commit — attribution preservation binds the merge machinery, never the gate. |docs/spec/sync.adoc, [#sync.adoption-no-cherry-pick] (also gate.adoc Adoption intro ~L218-224), ~L59-73 |stateright |gap-unmarked |OPEN |— |===
.github/workflows/CI.yml @@ -125,35 +125,6 @@ - name: Run doctests run: cargo test --doc --workspace --all-features - # Formal-model harness (verify/). Best-effort and optional: the check - # scripts warn and exit 0 when the jars are absent, downloads are - # tolerated to fail, and the job is continue-on-error and deliberately - # NOT in the `ci` aggregator's needs — it must never block the - # pipeline or become a required check. Note: gate_rules.als's binding - # check is EXPECTED to report the known cross-ref replay counterexample - # (see verify/ledger.adoc), so a red step here can be the harness - # working as intended. - formal-models: - runs-on: ubuntu-latest - if: ${{ github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false) }} - continue-on-error: true - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '21' - - name: Fetch checker jars (best-effort, never vendored) - run: | - curl -fsSL -o /tmp/tla2tools.jar \ - https://github.com/tlaplus/tlaplus/releases/latest/download/tla2tools.jar || true - curl -fsSL -o /tmp/org.alloytools.alloy.dist.jar \ - https://github.com/AlloyTools/org.alloytools.alloy/releases/latest/download/org.alloytools.alloy.dist.jar || true - - name: Check TLA+ models - run: TLA_TOOLS_JAR=/tmp/tla2tools.jar verify/bin/check-tla - - name: Check Alloy models - run: ALLOY_JAR=/tmp/org.alloytools.alloy.dist.jar verify/bin/check-alloy - ci: name: CI if: ${{ always() && (github.event_name == 'workflow_dispatch' || github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.draft == false)) }}
crates/kernel/ents-gate-rules/Cargo.toml @@ -8,5 +8,8 @@ [dependencies] ascent = { workspace = true } +[dev-dependencies] +proptest = { workspace = true } + [lints] workspace = true
crates/cli/git-ents/tests/layering.rs @@ -1,7 +1,10 @@ //! Mechanical check of the one-way crate layering documented in //! `docs/abstractions.adoc`'s "Layering" section: substrate -> kernel -> //! {forge, kiln} -> cli, with forge and kiln forbidden from depending on -//! each other. +//! each other. Extended with one more rule for `crates/verify/*` +//! (`verify/README.adoc`): that layer is a pure sink above everything +//! else — `ents-verify` may depend only on `ents-gate-rules`, and no +//! crate anywhere in the workspace may depend on `ents-verify`. //! //! The prose in `docs/abstractions.adoc` states the rule, but nothing //! stops a future `Cargo.toml` edit from quietly violating it (a kernel @@ -24,12 +27,15 @@ /// Layer rank for a workspace package, derived from the `crates/<layer>/*` /// prefix of its manifest path: substrate (0) -> kernel (1) -> -/// forge/kiln (2) -> cli (3). +/// forge/kiln (2) -> cli (3) -> verify (4). /// /// A dependency edge is only legal when the depending package's rank is /// greater than or equal to the depended-on package's rank — e.g. the CLI /// (3) may depend on `ents-forge` (2), but a kernel crate (1) may never -/// depend on a package crate (2). +/// depend on a package crate (2). `verify` sits at the top rank alone, so +/// this general rule already forbids every other layer from depending on +/// it; [`ents_verify_depends_only_on_ents_gate_rules`] adds the sharper +/// rule that its own outgoing edges are restricted too. fn layer_rank(package: &Package, workspace_root: &Utf8Path) -> u8 { let relative = package .manifest_path @@ -52,6 +58,7 @@ Some("kernel") => 1, Some("forge") | Some("kiln") => 2, Some("cli") => 3, + Some("verify") => 4, other => panic!( "{}'s manifest path {relative} has an unrecognized layer directory {other:?}", package.name @@ -111,6 +118,15 @@ if package.name == "ents-kiln" && dependency.name == "ents-forge" { kiln_depends_on_forge = true; } + + if package.name == "ents-verify" { + assert_eq!( + dependency.name, "ents-gate-rules", + "ents-verify (the verify/ layer's sink crate) may depend on ents-gate-rules only, \ + but its manifest also names {}", + dependency.name + ); + } } } @@ -123,3 +139,32 @@ "ents-kiln must not depend on ents-forge: they are sibling package crates" ); } + +/// `ents-verify` is a pure sink: nothing in the workspace may depend on +/// it. The general rank rule in [`dependencies_never_point_upward`] +/// already forbids this (every other layer ranks below `verify`), but +/// this test states the sink property directly against the real +/// dependency graph, rather than relying on that rank arithmetic alone. +/// +/// See `crates/kernel/ents-model` and the root `Cargo.toml` for the +/// self-verification this test is designed to catch: temporarily adding +/// `ents-verify` as a dependency of any other crate must fail this test. +#[test] +fn nothing_depends_on_ents_verify() { + let metadata = MetadataCommand::new() + .manifest_path(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")) + .no_deps() + .exec() + .expect("cargo metadata"); + + for package in metadata.workspace_packages() { + if package.name == "ents-verify" { + continue; + } + assert!( + package.dependencies.iter().all(|dependency| dependency.name != "ents-verify"), + "{} must not depend on ents-verify: verify/ is a sink layer nothing else may depend on", + package.name + ); + } +}
crates/kernel/ents-gate-rules/tests/ledger.rs @@ -10,6 +10,14 @@ //! time, red test first, per this crate's own discipline — the pinned //! assertion is swapped for the inverted one kept alongside it, and the //! ledger row's verdict is updated. +//! +//! Where the counterexample is transaction-shaped like this one, the +//! same landing happens in two more places, all three updated together: +//! `tests/prop.rs`'s matching property loses its exemption branch (it +//! currently proves the exemption load-bearing by failing without it), +//! and `crates/verify/ents-verify/src/search.rs`'s matching +//! [`stateright::Property::always`] — which today has a discovery — is +//! expected to have none once the rule lands. use ents_gate_rules::{Facts, Role, gate};
crates/kernel/ents-gate-rules/tests/prop.rs @@ -1,0 +1,117 @@ +//! Property-based floor for the ledger's invariant claims +//! (`verify/ledger.adoc`): `proptest` samples `Facts` over a small +//! bounded domain — deliberately duplicated here from +//! `crates/verify/ents-verify`'s vocabulary rather than depending on +//! that crate, since the sink-layer rule in +//! `crates/cli/git-ents/tests/layering.rs` is one-way: nothing may +//! depend on `ents-verify`. +//! +//! Two properties below have no exemption — the rules that cover them +//! (`unsigned_violation`, `effect_admin_violation`) are believed sound. +//! The third carries the one *known* exemption in this crate: the +//! refname-binding gap (`verify/ledger.adoc`, DIVERGED row; +//! `docs/abstractions.adoc` §2). A naive "admitted implies bound +//! correctly" property would fail on every run while that gap is open; +//! the exemption proves it is load-bearing, not dead code, and per +//! `tests/ledger.rs`'s convention, gets deleted in the same commit that +//! adds `binding_violation`. + +use ents_gate_rules::{Facts, Role, gate}; +use proptest::prelude::*; + +/// The signed content's own declared kind — the same shadow annotation +/// `ents-verify`'s search model and `verify/alloy/gate_rules.als`'s +/// `kind` field use, kept outside `Facts` because its absence from the +/// crate's real vocabulary *is* the gap under test. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Comment, + Issue, + Effect, +} + +const ADMIN_KEY: &str = "key:admin"; +const MEMBER_KEY: &str = "key:m1"; + +const ISSUE_REF: &str = "refs/meta/issues/g"; +const COMMENT_REF: &str = "refs/meta/comments/g2"; +const EFFECT_REF: &str = "refs/meta/effects/x"; + +fn refs() -> impl Strategy<Value = &'static str> { + prop_oneof![Just(ISSUE_REF), Just(COMMENT_REF), Just(EFFECT_REF)] +} + +fn signers() -> impl Strategy<Value = Option<&'static str>> { + prop_oneof![Just(Some(ADMIN_KEY)), Just(Some(MEMBER_KEY)), Just(None)] +} + +fn kinds() -> impl Strategy<Value = Kind> { + prop_oneof![Just(Kind::Comment), Just(Kind::Issue), Just(Kind::Effect)] +} + +/// Build a genesis transaction — the only shape the binding gap +/// concerns, since binding is a claim about a fresh entity's placement +/// — from sampled atoms, alongside whether the doc's binding invariant +/// actually holds for this sample. +fn build(ref_name: &str, signer: Option<&str>, kind: Kind) -> (Facts, bool) { + let mut facts = Facts { + member: vec![(ADMIN_KEY.to_string(), Role::Admin), (MEMBER_KEY.to_string(), Role::Member)], + ..Facts::default() + }; + facts.ref_update = vec![(ref_name.to_string(), None, "g2".to_string())]; + if let Some(key) = signer { + facts.signed_by = vec![("g2".to_string(), key.to_string())]; + } + + let binding_holds = match kind { + Kind::Effect => ref_name.starts_with("refs/meta/effects/"), + Kind::Comment => ref_name.starts_with("refs/meta/comments/"), + Kind::Issue => ref_name.starts_with("refs/meta/issues/"), + }; + (facts, binding_holds) +} + +proptest! { + /// Ledger floor (abstractions.adoc §5 tip invariant, admission + /// half): an admitted genesis is signed by an enrolled member. No + /// exemption — `unsigned_violation` covers this today. + #[test] + fn admitted_genesis_is_signed(r in refs(), signer in signers(), kind in kinds()) { + let (facts, _binding_holds) = build(r, signer, kind); + if gate(facts).is_empty() { + prop_assert!(signer.is_some()); + } + } + + /// Ledger floor (abstractions.adoc §6 / effect.admin-only): an + /// admitted write to `refs/meta/effects/*` is admin-signed. No + /// exemption — `effect_admin_violation` covers this today. + #[test] + fn admitted_effects_write_is_admin_signed(signer in signers(), kind in kinds()) { + let (facts, _binding_holds) = build(EFFECT_REF, signer, kind); + if gate(facts).is_empty() { + prop_assert_eq!(signer, Some(ADMIN_KEY)); + } + } + + /// Ledger row (DIVERGED, `docs/abstractions.adoc` §2 / + /// `meta-ref.identity-binding`): an admitted genesis's refname + /// namespace should match its signed content's declared kind. + /// EXEMPTED while the gap is open — delete the early return (and + /// this comment) in the same commit that adds `binding_violation`, + /// per `tests/ledger.rs`'s gap-pinning convention. + #[test] + fn admitted_genesis_binds_its_namespace(r in refs(), signer in signers(), kind in kinds()) { + let (facts, binding_holds) = build(r, signer, kind); + if gate(facts.clone()).is_empty() && !binding_holds { + // KNOWN GAP (verify/ledger.adoc: DIVERGED). Keeping this + // branch, rather than deleting the property outright, is + // what proves the exemption is load-bearing: comment it out + // locally and this property fails immediately. + return Ok(()); + } + if gate(facts).is_empty() { + prop_assert!(binding_holds); + } + } +}
crates/verify/ents-verify/Cargo.toml @@ -1,0 +1,16 @@ +[package] +name = "ents-verify" +version = "0.0.0" +edition.workspace = true +publish.workspace = true +license.workspace = true + +[dependencies] +ents-gate-rules = { workspace = true } +stateright = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } + +[lints] +workspace = true
crates/verify/ents-verify/src/durability.rs @@ -1,0 +1,92 @@ +//! Phase 5 — durability ordering (`verify/exercise.md`, "Phase 5"), +//! replacing the deleted `verify/tla/Durability.tla`. +//! +//! SKELETON. Every action body is `todo!()`; filling in +//! [`Model::actions`] and [`Model::next_state`] for real is the human +//! exercise. +//! +//! Deployment note: the exercise document frames this phase around a +//! hosted Tigris-object-store + Postgres-CAS split. The project +//! currently deploys neither — serving is plain git http-backend over +//! one filesystem — so the state and actions here are named for the +//! general shape (object write, ref CAS, crash), and the Tigris/Pg +//! instantiation is deferred until such a deployment exists. The +//! invariant under study is unchanged: no ref points outside the +//! durable object set. + +#![expect(clippy::todo, reason = "Phase 5 skeleton — filling this in is the human exercise, not this scaffold's job")] + +use stateright::{Model, Property}; + +/// Durability state: which objects have reached durable storage, and +/// the ref store's current tips. SKELETON. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct State { + /// Objects durably written so far. + pub durable: Vec<&'static str>, + /// Current tip of each ref in `crate::REFS`, in the same order; + /// `None` means the ref is unborn. + pub refs: [Option<&'static str>; 4], +} + +/// Durability actions (`verify/exercise.md`, Phase 5): `ObjectWrite`, +/// `RefCAS`, `Crash` at any point. SKELETON. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Action { + /// An object (or pack) reaches durable storage. + ObjectWrite, + /// The ref store compare-and-swaps a tip — the write-order question + /// this phase exists to settle lives here. + RefCas, + /// Crash at any point; recovery obligations follow from what + /// survives. + Crash, +} + +/// The Phase 5 durability model. SKELETON. +pub struct DurabilityModel; + +impl Model for DurabilityModel { + type State = State; + type Action = Action; + + fn init_states(&self) -> Vec<Self::State> { + vec![State::default()] + } + + fn actions(&self, _state: &Self::State, _actions: &mut Vec<Self::Action>) { + todo!("exercise: enumerate ObjectWrite/RefCas/Crash per verify/exercise.md Phase 5") + } + + fn next_state(&self, _last_state: &Self::State, _action: Self::Action) -> Option<Self::State> { + todo!("exercise: Phase 5's transition relation, including crash faults") + } + + fn properties(&self) -> Vec<Property<Self>> { + vec![ + // The invariant this phase exists to prove: no ref in the + // ref store points outside the durable object set. STATED + // here so the ledger row has a formal object to point at; + // NOT proved — the transition relation above is a stub, so + // checking this today says nothing. + Property::always("refs_point_durable", |_, _| true /* TODO(exercise) */), + ] + } +} + +#[cfg(test)] +mod tests { + use stateright::Checker; + + use super::*; + + /// Running this model requires [`Model::actions`] and + /// [`Model::next_state`], both `todo!()` until the human exercise + /// fills them in. Ignored so `cargo test --workspace` stays green + /// while the skeleton exists. + #[test] + #[ignore = "exercise stub: Phase 5's transition relation is unwritten"] + fn model_runs() { + let _ = DurabilityModel.checker().spawn_bfs().join(); + } +}
crates/verify/ents-verify/src/effects.rs @@ -1,0 +1,112 @@ +//! Phase 4 — effects (`verify/exercise.md`, "Phase 4"), replacing the +//! deleted `verify/tla/Effects.tla`, building on [`crate::receive`]'s +//! state. +//! +//! SKELETON. Every action body is `todo!()`; filling in +//! [`Model::actions`] and [`Model::next_state`] for real is the human +//! exercise. +//! +//! Discharges, once filled in: `docs/abstractions.adoc` §6 (monotone, +//! exactly-once effects); `docs/spec/effect.adoc` (trigger set, dedup +//! key `(effect, refname, new_oid)`, results write-back, admin-only +//! authoring). Note `ents_gate_rules`' module docs mark the +//! cross-transaction dedup obligation as a deliberate gap — this model +//! is where that obligation lives. + +#![expect(clippy::todo, reason = "Phase 4 skeleton — filling this in is the human exercise, not this scaffold's job")] + +use stateright::{Model, Property}; + +/// Protocol state, extending [`crate::receive::State`] with effect +/// bookkeeping: which commits have entered the trigger set (§6), the +/// at-least-once delivery queue's dedup keys, and the result-ref writes +/// performed so far. SKELETON. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct State { + /// The Phase 3 protocol state this phase builds on. + pub receive: crate::receive::State, + /// Commits that have entered the trigger set (§6: "fires once per + /// commit that enters the set"). + pub triggered: Vec<&'static str>, + /// At-least-once delivery: dedup keys `(effect, refname, new_oid)` + /// currently enqueued. + pub queue: Vec<(&'static str, &'static str, &'static str)>, + /// Result-ref writes performed so far, keyed the same way as + /// [`Self::queue`]. + pub results: Vec<(&'static str, &'static str, &'static str)>, +} + +/// Protocol actions (`verify/exercise.md`, Phase 4): `RefAdvance`, +/// `TriggerEval`, `Enqueue`, `Execute`, `ResultPush`. SKELETON. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Action { + /// A gated ref advance (reuses [`crate::receive::gate_admits`] when + /// composed with Phase 3). + RefAdvance, + /// A commit enters the trigger set (§6). The delete-and-repush + /// re-entry question — is triggering monotone? — lives here. + TriggerEval, + /// At-least-once enqueue of a dedup key. + Enqueue, + /// The executor runs an effect; may crash and restart (duplicate + /// delivery). + Execute, + /// Result write-back: a gated write like any other, by an executor + /// member key (`effect.results-writeback`). + ResultPush, +} + +/// The Phase 4 effects model. SKELETON. +pub struct EffectsModel; + +impl Model for EffectsModel { + type State = State; + type Action = Action; + + fn init_states(&self) -> Vec<Self::State> { + vec![State::default()] + } + + fn actions(&self, _state: &Self::State, _actions: &mut Vec<Self::Action>) { + todo!("exercise: enumerate RefAdvance/TriggerEval/Enqueue/Execute/ResultPush per verify/exercise.md Phase 4") + } + + fn next_state(&self, _last_state: &Self::State, _action: Self::Action) -> Option<Self::State> { + todo!("exercise: Phase 4's transition relation") + } + + fn properties(&self) -> Vec<Property<Self>> { + vec![ + // Obligation 1: the dedup key (effect, refname, new_oid) + // yields result-ref idempotency under duplicate delivery + // and executor crash-restart. + Property::always("exactly_once_observable_effect", |_, _| true /* TODO(exercise) */), + // Obligation 2: a ref deleted and re-pushed to the same oid + // — does the commit re-enter the trigger set? Defines + // whether triggers are monotone. + Property::always("trigger_set_monotone", |_, _| true /* TODO(exercise) */), + // Obligation 3: no sequence lets a non-admin cause execution + // of content they authored as an effect (composes with + // crate::search's binding_refname_recomputed property — + // this was the original cross-ref replay scenario). + Property::always("authorization_asymmetry", |_, _| true /* TODO(exercise) */), + ] + } +} + +#[cfg(test)] +mod tests { + use stateright::Checker; + + use super::*; + + /// Running this model requires [`Model::actions`] and + /// [`Model::next_state`], both `todo!()` until the human exercise + /// fills them in. Ignored so `cargo test --workspace` stays green + /// while the skeleton exists. + #[test] + #[ignore = "exercise stub: Phase 4's transition relation is unwritten"] + fn model_runs() { + let _ = EffectsModel.checker().spawn_bfs().join(); + } +}
crates/verify/ents-verify/src/lib.rs @@ -1,0 +1,128 @@ +//! Rust-native model checking for the formal stocktake (`verify/`, +//! `verify/exercise.md`): [`stateright`] models that call +//! [`ents_gate_rules::gate`] directly, so the refinement mapping between +//! model and code is the function call, not a hand-maintained +//! translation. +//! +//! This crate is a sink in the workspace's layering (`docs/abstractions. +//! adoc`'s "Layering" section, extended by `crates/cli/git-ents/tests/ +//! layering.rs`): it depends on [`ents_gate_rules`] and nothing else in +//! the workspace, and nothing in the workspace may depend on it. It +//! exists to be run by `cargo test -p ents-verify`, never linked into a +//! shipped binary. +//! +//! # Modules +//! +//! - [`search`] — the Phase 0.5 replacement: an exhaustive search over a +//! small bounded universe of transactions, checking that every +//! admitted transaction (`gate(facts).is_empty()`) also satisfies the +//! doc invariants the rules are supposed to encode. This is where the +//! cross-ref replay counterexample (ledger row: DIVERGED, +//! `docs/abstractions.adoc` §2) is rediscovered by search rather than +//! asserted by hand. +//! - [`receive`] — Phase 3 skeleton: state/action signatures for the +//! gate-and-receive protocol, with the gate-check action's enabling +//! condition wired to the real `gate()` call (the one deliberate +//! exception to "skeleton, not solution"). +//! - [`effects`] — Phase 4 skeleton: trigger/dedup/results state shape. +//! - [`durability`] — Phase 5 skeleton: crash/durability ordering. +//! +//! # The bounded universe +//! +//! Every model in this crate builds transactions from the same tiny, +//! fixed set of atoms — the Rust-native analogue of Alloy's scope +//! discipline (`verify/exercise.md`: "Alloy scopes of 4-6 atoms per +//! signature. Almost every bug in a system like this appears with two +//! members, two refs, three commits."). Keeping every model's universe +//! this small is what makes exhaustive search (Phase 0.5) and bounded +//! model checking (Phases 3-5) tractable at all. + +pub mod durability; +pub mod effects; +pub mod receive; +pub mod search; + +use ents_gate_rules::{Facts, Role}; + +/// The one admin-registered key in the bounded universe — the only +/// signer [`effect_admin_violation`](ents_gate_rules) and the +/// redaction-admin-only rule (`docs/spec/receive.adoc` +/// `receive.redaction-admin-only`) accept for their namespaces. +pub const ADMIN_KEY: &str = "key:admin"; +/// An ordinary enrolled, non-admin member key. +pub const MEMBER_KEY_1: &str = "key:m1"; +/// A second ordinary member key — the bounded universe's "two members" +/// atom, needed for adoption/divergence scenarios where a single key +/// isn't enough to tell two actors apart. +pub const MEMBER_KEY_2: &str = "key:m2"; + +/// Every key in the bounded universe, admin first. +pub const KEYS: [&str; 3] = [ADMIN_KEY, MEMBER_KEY_1, MEMBER_KEY_2]; + +/// The fixed role a key in the bounded universe carries. Roles are not +/// an independent search dimension here: enrolling a key is a single +/// fact (present or absent), never a choice of role, exactly so search +/// states can dedupe on plain sets instead of tracking role assignment +/// as extra state — [`ents_gate_rules::Role`] itself has no `Ord`, which +/// would otherwise complicate that dedup. +#[must_use] +pub fn role_of(key: &str) -> Role { + if key == ADMIN_KEY { + Role::Admin + } else { + Role::Member + } +} + +/// A hash-identified namespace (`docs/spec/meta-ref.adoc` +/// `meta-ref.identity-binding`) — genesis-oid binding, the shape +/// `issues/*` and `comments/*` share. +pub const ISSUE_REF: &str = "refs/meta/issues/g"; +/// The other hash-identified namespace in the bounded universe, kept +/// distinct from [`ISSUE_REF`] so a model can distinguish "which +/// hash-identified namespace" without adding a third dimension. +pub const COMMENT_REF: &str = "refs/meta/comments/g2"; +/// The admin-only namespace (`effect.admin-only`) — the one the crate's +/// `effect_admin_violation` rule protects, and the namespace the known +/// cross-ref replay counterexample targets. +pub const EFFECT_REF: &str = "refs/meta/effects/x"; +/// The one namespace `meta-ref.inbox` declares as an allowed *second* +/// image of an already-bound signed commit — Phase 2 obligation 2. +pub const INBOX_REF: &str = "refs/meta/inbox/m1/comments/g2"; + +/// Every refname in the bounded universe. +pub const REFS: [&str; 4] = [ISSUE_REF, COMMENT_REF, EFFECT_REF, INBOX_REF]; + +/// Object ids in the bounded universe: enough to build a genesis +/// (`g2`), a fast-forward advance of an existing tip (`g` -> `c1`), and +/// a two-parent merge that smuggles in an unrelated root (`z`) — the +/// three transaction shapes `ents_gate_rules`' own unit tests already +/// exercise by hand, plus two blobs for anchor/context retention. +pub const OIDS: [&str; 6] = ["g", "c1", "m", "z", "blob-a", "blob-ctx"]; + +/// The signed content's own kind, as the *author's* signed content +/// declares it — the derivation input `docs/abstractions.adoc` §2 says +/// the refname recomputes from. Deliberately absent from +/// [`ents_gate_rules::Facts`] itself: that absence is the gap under +/// test. Every model that checks the binding invariant carries this as a +/// shadow annotation alongside a built [`Facts`] value, never inside it, +/// exactly mirroring `verify/alloy/gate_rules.als`'s `kind` field. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Kind { + /// The signed content is a comment (`docs/spec/model.adoc` + /// `model.comment`). + Comment, + /// The signed content is an issue (`model.issue`). + Issue, + /// The signed content is an effect definition (`model.effect- + /// definition`, `effect.admin-only`). + Effect, +} + +/// Enroll every key in [`KEYS`] into `facts.member`, at its fixed +/// [`role_of`]. Every model in this crate treats membership as +/// background, not a search dimension — Phase 3's `receive` skeleton is +/// where membership *lifecycle* (enrollment, revocation) belongs. +pub fn enroll_all(facts: &mut Facts) { + facts.member = KEYS.iter().map(|k| ((*k).to_string(), role_of(k))).collect(); +}
crates/verify/ents-verify/src/receive.rs @@ -1,0 +1,140 @@ +//! Phase 3 — gate and receive as a protocol (`verify/exercise.md`, +//! "Phase 3"), replacing the deleted `verify/tla/Receive.tla`. +//! +//! SKELETON, with one deliberate exception: [`gate_admits`] calls +//! [`ents_gate_rules::gate`] directly on a transaction's `Facts` — that +//! direct call *is* the refinement mapping this module's `GateCheck` +//! action would enable on, replacing what `Receive.tla`'s `GateAdmits` +//! transcribed by hand. Every other action body is `todo!()`; filling in +//! [`Model::actions`] and [`Model::next_state`] for real is the human +//! exercise, not this scaffold's job. +//! +//! Discharges, once filled in: `docs/abstractions.adoc` §5 (tip +//! invariant, adoption, revocation), §4 (anti-replay); +//! `docs/spec/receive.adoc`; `docs/spec/gate.adoc` epoch bootstrap. + +#![expect(clippy::todo, reason = "Phase 3 skeleton — filling this in is the human exercise, not this scaffold's job")] + +use ents_gate_rules::{Facts, gate}; +use stateright::{Model, Property}; + +/// `gate(facts) = {}`: the enabling condition a filled-in `GateCheck` +/// action would use. This is real code, not a stub — it is the seven +/// denial rules, called directly, standing in for `Receive.tla`'s +/// hand-transcribed `GateAdmits`. +#[must_use] +pub fn gate_admits(facts: Facts) -> bool { + gate(facts).is_empty() + // TODO(exercise): refname recomputation from signed content (the §4 + // binding rule ents-gate-rules omits; ledger row DIVERGED, + // crate::search::SearchModel's `binding_refname_recomputed` + // property) and the epoch rule (§5) are not part of `gate()`, so + // this enabling condition inherits both gaps. Phase 3 decides + // whether `ents-gate` proper's check composes in here. +} + +/// Protocol state (§5): the current tip of every ref in the bounded +/// universe (`crate::REFS`), the object set, enrolled members, and the +/// config ref's epoch. SKELETON — named for the exercise's obligations, +/// not yet wired to a transition relation. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct State { + /// Current tip of each ref in `crate::REFS`, in the same order; + /// `None` means the ref is unborn. + pub refs: [Option<&'static str>; 4], + /// Objects the store currently has. + pub objects: Vec<&'static str>, + /// Enrolled member keys. + pub members: Vec<&'static str>, + /// The config ref's epoch (§5: "the epoch-setting commit is the + /// first gated tip of the config ref"). + pub epoch: u32, +} + +/// Protocol actions (`verify/exercise.md`, Phase 3): `Propose`, +/// `GateCheck`, `CAS`, `AdoptMerge`, `SelfMerge`. SKELETON. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Action { + /// A writer proposes a transaction (two writers minimum, per the + /// exercise's model). + Propose, + /// The gate evaluates a proposed transaction; a filled-in + /// implementation enables this action exactly when [`gate_admits`] + /// holds for the proposal in play. + GateCheck, + /// Compare-and-swap the ref tip — the anti-replay mechanism §4 + /// relies on parent-hash freshness for. + Cas, + /// Adoption: contributor commit in ancestry, adopter signature at + /// tip, always a merge (never a rewrite). + AdoptMerge, + /// Two of one member's own machines racing a single-writer ref — + /// `docs/abstractions.adoc` §4's same-actor divergence. + SelfMerge, +} + +/// The Phase 3 protocol model. SKELETON. +pub struct ReceiveModel; + +impl Model for ReceiveModel { + type State = State; + type Action = Action; + + fn init_states(&self) -> Vec<Self::State> { + vec![State::default()] + } + + fn actions(&self, _state: &Self::State, _actions: &mut Vec<Self::Action>) { + todo!("exercise: enumerate Propose/GateCheck/Cas/AdoptMerge/SelfMerge per verify/exercise.md Phase 3") + } + + fn next_state(&self, _last_state: &Self::State, _action: Self::Action) -> Option<Self::State> { + todo!("exercise: Phase 3's transition relation, using gate_admits as GateCheck's enabling condition") + } + + fn properties(&self) -> Vec<Property<Self>> { + vec![ + // Obligation 1: "the tip of a meta-ref is signed by a + // member authorized for that refname" is preserved by every + // action — pay attention to SelfMerge (is the merge commit + // itself signed in the implementation? see ents-sync/src + // and ents-receive/src/reconcile.rs). + Property::always("tip_invariant_inductive", |_, _| true /* TODO(exercise) */), + // Obligation 2: adoption preserves the tip invariant, even + // when the contributor's commit is itself a merge of + // unauthorized commits. + Property::always("adoption_preserves_tip_invariant", |_, _| true /* TODO(exercise) */), + // Obligation 3: a replayed genesis against a not-yet-created + // ref is safe only in conjunction with Phase 2's binding + // totality — state the exact conjunction. + Property::always("anti_replay", |_, _| true /* TODO(exercise) */), + // Obligation 4: from an empty store, is there a state where + // the gate must read the epoch from a ref whose tip is not + // yet gated? Cite ents-gate/src/{config,policy}.rs. + Property::always("epoch_bootstrap", |_, _| true /* TODO(exercise) */), + // Obligation 5: a member valid at admission and revoked + // later — does naive re-verification of historical tips + // fail, and does the epoch mechanism actually prevent that? + Property::always("revocation", |_, _| true /* TODO(exercise) */), + ] + } +} + +#[cfg(test)] +mod tests { + use stateright::Checker; + + use super::*; + + /// Running this model requires [`Model::actions`] and + /// [`Model::next_state`], both `todo!()` until the human exercise + /// fills them in. Ignored so `cargo test --workspace` stays green + /// while the skeleton exists — the one permitted use of `#[ignore]` + /// in this codebase, because this is a declared stub, not a passed- + /// off result. + #[test] + #[ignore = "exercise stub: Phase 3's transition relation is unwritten"] + fn model_runs() { + let _ = ReceiveModel.checker().spawn_bfs().join(); + } +}
crates/verify/ents-verify/src/search.rs @@ -1,0 +1,393 @@ +//! Phase 0.5 — verify the verifier (`verify/exercise.md`, "Phase 0.5"), +//! replacing `verify/alloy/gate_rules.als`'s `check` commands with an +//! exhaustive [`stateright`] search over the crate's own vocabulary. +//! +//! `ents_gate_rules` can *evaluate* its seven denial rules over one +//! supplied transaction; it cannot search for the transaction nobody +//! thought of. This module runs that search: [`SearchModel`]'s states +//! are a transaction under construction, one choice at a time, over the +//! bounded universe in `crate::{REFS, OIDS, KEYS}` plus the shadow +//! [`Kind`](crate::Kind) annotation, and its six [`Property::always`] +//! obligations restate — independently, in hand-written Rust, not by +//! calling `gate` a second time — exactly the five doc invariants the +//! seven rules claim to cover, plus the refname-binding claim they do +//! not. A property's *discovery* is a transaction `gate` admits that +//! violates the corresponding independent check. +//! +//! # The bound, and why it stays this small +//! +//! A state is exactly five choices, made in a fixed order: refname, +//! transaction [`Shape`], [`Signer`], [`Retention`], and the new tip's +//! [`Kind`]. That is `4 x 4 x 3 x 4 x 3 = 576` leaf states — small enough +//! for [`Model::checker`] to explore exhaustively in well under a +//! second. A naive "add any single EDB atom from the domain" search (a +//! literal transcription of Alloy's relational style) was tried first +//! and rejected: with `parent`/`signed_by`/`anchor`/`context`/ +//! `object_exists` each ranging freely over `OIDS x OIDS` or `OIDS x +//! KEYS`, the reachable state count is the size of the powerset of every +//! possible atom, not the five-transaction-shapes count above — tens of +//! thousands of atoms' worth of subsets, intractable for exhaustive BFS. +//! [`Shape`] instead enumerates the four transaction shapes +//! `ents_gate_rules`' own unit tests already hand-build (creation, +//! fast-forward advance, non-fast-forward, and the second-root merge), +//! which is everything the seven rules' *logic* actually branches on; +//! membership is fixed background ([`crate::enroll_all`]) rather than a +//! search dimension, since membership *lifecycle* is Phase 3's concern +//! (`receive.rs`), not Phase 0.5's. + +use ents_gate_rules::{Facts, gate}; +use stateright::{Model, Property}; + +use crate::{ADMIN_KEY, Kind, MEMBER_KEY_1, REFS, enroll_all}; + +/// The shape of the proposed transaction — the one dimension along which +/// the seven denial rules' *logic* actually branches, standing in for +/// the full `parent`/`ref_update` relations' combinatorics. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Shape { + /// `ref_update(r, None, new)` with `new` parentless: entity creation. + Genesis, + /// `ref_update(r, Some(old), new)` with `new` a descendant of `old` + /// via one intermediate commit — the admitted case. + FastForward, + /// `ref_update(r, Some(old), new)` with `new` unrelated to `old` — + /// `ff_violation`'s witness. + NonFf, + /// `ref_update(r, Some(old), new)` with `new` a merge of the + /// fast-forward chain and an unrelated parentless commit — + /// `second_root_violation`'s witness + /// (`ents_gate_rules::tests::merged_in_second_root_is_rejected`). + SecondRoot, +} + +/// All four transaction shapes, in a fixed enumeration order. +pub const SHAPES: [Shape; 4] = [Shape::Genesis, Shape::FastForward, Shape::NonFf, Shape::SecondRoot]; + +/// Who signs every commit the transaction introduces — one signer +/// applies uniformly to keep the state small; per-commit signer +/// variation is Phase 3's concern, not this search's. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Signer { + /// Every introduced commit signed by [`ADMIN_KEY`]. + Admin, + /// Every introduced commit signed by [`MEMBER_KEY_1`]. + Member, + /// No introduced commit carries a signature. + Unsigned, +} + +/// All three signer choices, in a fixed enumeration order. +pub const SIGNERS: [Signer; 3] = [Signer::Admin, Signer::Member, Signer::Unsigned]; + +/// Whether the new tip carries anchor/context retention, and whether it +/// resolves — only meaningful for [`Shape::Genesis`] (a comment-shaped +/// creation); every other shape treats this as absent. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Retention { + /// No anchor or context blob. + Absent, + /// Anchor and context present, both resolving. + Resolving, + /// Anchor and context present; the anchored blob does not resolve. + DanglingAnchor, + /// Anchor and context present; the context blob does not resolve. + DanglingContext, +} + +/// All four retention choices, in a fixed enumeration order. +pub const RETENTIONS: [Retention; 4] = + [Retention::Absent, Retention::Resolving, Retention::DanglingAnchor, Retention::DanglingContext]; + +/// All three [`Kind`] choices, in a fixed enumeration order. +pub const KINDS: [Kind; 3] = [Kind::Comment, Kind::Issue, Kind::Effect]; + +/// A transaction under construction: five independent choices, each +/// `None` until [`SearchModel::actions`] offers it. A state with every +/// field `Some` is complete; [`SearchModel::actions`] then offers +/// nothing further, so the search terminates at exactly 576 leaves. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct State { + ref_name: Option<&'static str>, + shape: Option<Shape>, + signer: Option<Signer>, + retention: Option<Retention>, + kind: Option<Kind>, +} + +/// One choice, made against exactly one of [`State`]'s five `None` +/// fields, in the fixed order the field declarations above list. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Action { + /// Choose the transaction's refname. + ChooseRef(&'static str), + /// Choose the transaction's [`Shape`]. + ChooseShape(Shape), + /// Choose the transaction's [`Signer`]. + ChooseSigner(Signer), + /// Choose the new tip's [`Retention`]. + ChooseRetention(Retention), + /// Choose the new tip's signed-content [`Kind`]. + ChooseKind(Kind), +} + +/// A fully-chosen transaction, built once all five [`State`] fields are +/// `Some` — the point at which the properties below have anything to +/// check. +pub struct Complete { + ref_name: &'static str, + shape: Shape, + signer: Signer, + retention: Retention, + kind: Kind, +} + +impl State { + /// `Some` once every field is chosen, `None` while the transaction + /// is still under construction — the properties below treat `None` + /// as vacuously satisfying every obligation, so no discovery is + /// reported before there is a whole transaction to judge. + fn complete(&self) -> Option<Complete> { + Some(Complete { + ref_name: self.ref_name?, + shape: self.shape?, + signer: self.signer?, + retention: self.retention?, + kind: self.kind?, + }) + } +} + +impl Complete { + /// Translate this transaction into `ents_gate_rules::Facts`, exactly + /// as a real extractor would for each shape — same commit oids and + /// structure the crate's own unit tests build by hand. + fn to_facts(&self) -> Facts { + let mut f = Facts::default(); + enroll_all(&mut f); + + let signed_key = match self.signer { + Signer::Admin => Some(ADMIN_KEY), + Signer::Member => Some(MEMBER_KEY_1), + Signer::Unsigned => None, + }; + let sign = |f: &mut Facts, oid: &str| { + if let Some(k) = signed_key { + f.signed_by.push((oid.to_string(), k.to_string())); + } + }; + + match self.shape { + Shape::Genesis => { + f.ref_update = vec![(self.ref_name.to_string(), None, "g2".to_string())]; + sign(&mut f, "g2"); + match self.retention { + Retention::Absent => {} + Retention::Resolving => { + f.anchor = vec![("g2".to_string(), "blob-a".to_string())]; + f.context = vec![("g2".to_string(), "blob-ctx".to_string())]; + f.object_exists = vec![("blob-a".to_string(),), ("blob-ctx".to_string(),)]; + } + Retention::DanglingAnchor => { + f.anchor = vec![("g2".to_string(), "blob-a".to_string())]; + f.context = vec![("g2".to_string(), "blob-ctx".to_string())]; + f.object_exists = vec![("blob-ctx".to_string(),)]; + } + Retention::DanglingContext => { + f.anchor = vec![("g2".to_string(), "blob-a".to_string())]; + f.context = vec![("g2".to_string(), "blob-ctx".to_string())]; + f.object_exists = vec![("blob-a".to_string(),)]; + } + } + } + Shape::FastForward => { + f.ref_update = vec![(self.ref_name.to_string(), Some("g".to_string()), "c1".to_string())]; + f.parent = vec![("c1".to_string(), "g".to_string())]; + sign(&mut f, "g"); + sign(&mut f, "c1"); + } + Shape::NonFf => { + f.ref_update = vec![(self.ref_name.to_string(), Some("g".to_string()), "x".to_string())]; + sign(&mut f, "x"); + } + Shape::SecondRoot => { + f.ref_update = vec![(self.ref_name.to_string(), Some("g".to_string()), "m".to_string())]; + f.parent = vec![ + ("c1".to_string(), "g".to_string()), + ("m".to_string(), "c1".to_string()), + ("m".to_string(), "z".to_string()), + ]; + for oid in ["g", "c1", "m", "z"] { + sign(&mut f, oid); + } + } + } + f + } + + /// `gate(facts).is_empty()` for this transaction — the enabling + /// condition every property below is stated as a consequent of. + fn admitted(&self) -> bool { + gate(self.to_facts()).is_empty() + } + + /// abstractions.adoc §4 / gate.adoc: an admitted advance descends + /// from its old tip. [`Shape::NonFf`] is the sole shape that + /// violates this; every other shape satisfies it by construction. + fn ff_holds(&self) -> bool { + !matches!(self.shape, Shape::NonFf) + } + + /// abstractions.adoc §2 / meta-ref.identity-binding's all-roots + /// walk: an admitted advance introduces no second parentless commit. + /// [`Shape::SecondRoot`] is the sole shape that violates this. + fn single_root_holds(&self) -> bool { + !matches!(self.shape, Shape::SecondRoot) + } + + /// abstractions.adoc §5 tip invariant, admission half: every commit + /// an admitted transaction introduces is signed by an enrolled + /// member. + fn tip_signed_holds(&self) -> bool { + !matches!(self.signer, Signer::Unsigned) + } + + /// abstractions.adoc §3 / anchor.retention: an admitted genesis's + /// anchor and context both resolve. Only [`Shape::Genesis`] carries + /// retention in this model; every other shape is vacuously fine. + fn retention_holds(&self) -> bool { + if !matches!(self.shape, Shape::Genesis) { + return true; + } + !matches!(self.retention, Retention::DanglingAnchor | Retention::DanglingContext) + } + + /// abstractions.adoc §6 / effect.admin-only: an admitted write to + /// the effects namespace is admin-signed. + fn effect_admin_holds(&self) -> bool { + if !self.ref_name.starts_with("refs/meta/effects/") { + return true; + } + matches!(self.signer, Signer::Admin) + } + + /// abstractions.adoc §2 / meta-ref.identity-binding: the refname's + /// namespace matches the signed content's own declared + /// [`Kind`](crate::Kind) — the claim ledger row DIVERGED covers. Only + /// engages at [`Shape::Genesis`]: binding is a claim about a fresh + /// entity's placement, not about advancing one that already exists. + /// [`crate::INBOX_REF`] is deliberately outside this model's binding + /// scope (Phase 2 obligation 2's allowed second image, not a fresh + /// binding decision), so it always holds trivially here. + fn binding_holds(&self) -> bool { + if !matches!(self.shape, Shape::Genesis) { + return true; + } + let expected = if self.ref_name.starts_with("refs/meta/effects/") { + Kind::Effect + } else if self.ref_name.starts_with("refs/meta/comments/") { + Kind::Comment + } else if self.ref_name.starts_with("refs/meta/issues/") { + Kind::Issue + } else { + return true; + }; + self.kind == expected + } +} + +/// The Phase 0.5 search model. Stateless: every choice comes from the +/// bounded universe in `crate`, not from any field here. +pub struct SearchModel; + +impl Model for SearchModel { + type State = State; + type Action = Action; + + fn init_states(&self) -> Vec<Self::State> { + vec![State::default()] + } + + fn actions(&self, state: &Self::State, actions: &mut Vec<Self::Action>) { + if state.ref_name.is_none() { + actions.extend(REFS.iter().map(|r| Action::ChooseRef(r))); + } else if state.shape.is_none() { + actions.extend(SHAPES.iter().map(|s| Action::ChooseShape(*s))); + } else if state.signer.is_none() { + actions.extend(SIGNERS.iter().map(|s| Action::ChooseSigner(*s))); + } else if state.retention.is_none() { + actions.extend(RETENTIONS.iter().map(|r| Action::ChooseRetention(*r))); + } else if state.kind.is_none() { + actions.extend(KINDS.iter().map(|k| Action::ChooseKind(*k))); + } + // A complete state (every field `Some`) offers nothing further: + // this is a leaf, and the search terminates there. + } + + fn next_state(&self, last_state: &Self::State, action: Self::Action) -> Option<Self::State> { + let mut state = last_state.clone(); + match action { + Action::ChooseRef(r) if state.ref_name.is_none() => state.ref_name = Some(r), + Action::ChooseShape(s) if state.ref_name.is_some() && state.shape.is_none() => state.shape = Some(s), + Action::ChooseSigner(s) if state.shape.is_some() && state.signer.is_none() => state.signer = Some(s), + Action::ChooseRetention(r) if state.signer.is_some() && state.retention.is_none() => { + state.retention = Some(r); + } + Action::ChooseKind(k) if state.retention.is_some() && state.kind.is_none() => state.kind = Some(k), + _ => return None, + } + Some(state) + } + + fn properties(&self) -> Vec<Property<Self>> { + /// `gate(facts).is_empty()` implies `check(complete)`, vacuously + /// true on an incomplete state — one property per doc invariant + /// the seven denial rules claim to cover, named to match + /// `verify/alloy/gate_rules.als`'s `check` commands one-to-one. + fn implication(state: &State, check: fn(&Complete) -> bool) -> bool { + state.complete().is_none_or(|c| !c.admitted() || check(&c)) + } + + vec![ + Property::always("ff_only_advance", |_, s| implication(s, Complete::ff_holds)), + Property::always("single_root_identity", |_, s| implication(s, Complete::single_root_holds)), + Property::always("introduced_commits_member_signed", |_, s| { + implication(s, Complete::tip_signed_holds) + }), + Property::always("anchor_retention_resolves", |_, s| implication(s, Complete::retention_holds)), + Property::always("effects_writes_admin_signed", |_, s| { + implication(s, Complete::effect_admin_holds) + }), + // The one property expected to have a discovery: the known, + // ledger-recorded DIVERGED gap. See tests::rediscovers_cross_ref_replay_by_search. + Property::always("binding_refname_recomputed", |_, s| implication(s, Complete::binding_holds)), + ] + } +} + +#[cfg(test)] +mod tests { + use stateright::Checker; + + use super::*; + + /// The migration's acceptance test: search, not hand-construction, + /// rediscovers the cross-ref replay (ledger row DIVERGED, + /// `docs/abstractions.adoc` §2). Every other property must have no + /// discovery — the seven rules are faithful to the other five doc + /// invariants within this bounded universe. + #[test] + fn rediscovers_cross_ref_replay_by_search() { + let checker = SearchModel.checker().spawn_bfs().join(); + + let path = checker.assert_any_discovery("binding_refname_recomputed"); + // Printed so a run's witness can be copied into the PR + // description as confirmation the harness has teeth. + println!("binding_refname_recomputed witness: {:?}", path.into_actions()); + + checker.assert_no_discovery("ff_only_advance"); + checker.assert_no_discovery("single_root_identity"); + checker.assert_no_discovery("introduced_commits_member_signed"); + checker.assert_no_discovery("anchor_retention_resolves"); + checker.assert_no_discovery("effects_writes_admin_signed"); + } +}
verify/alloy/README.adoc @@ -1,0 +1,5 @@ += alloy/: paper tool only + +These three `.als` files are kept as a design-only paper tool for Phases 1 and 2's structural questions (object graph reachability, refname binding) — nothing here runs in CI. +`crates/verify/ents-verify` and `crates/kernel/ents-gate-rules/tests/prop.rs` are the harness that actually runs, calling `ents_gate_rules::gate` directly instead of a hand-maintained Alloy translation of it. +If a human works through Phase 1 or Phase 2 by hand and wants small-scope model finding to double-check a step, the Alloy Analyzer can still open these files directly; see `verify/README.adoc` for the fuller epistemic split.
verify/bin/check-alloy @@ -1,60 +1,0 @@ -#!/bin/sh -# Run every Alloy model in verify/alloy/ headless. Best-effort and -# optional: if no Alloy jar (or no java) is available this script warns -# and exits 0 so it never blocks a pipeline. -# -# Jar discovery: $ALLOY_JAR, then a few well-known paths. Do NOT vendor -# the jar into the repo. -# -# Expected today: gate_rules.als's binding_refname_recomputed check FAILS -# with the cross-ref replay counterexample. That is the harness working — -# see verify/ledger.adoc (DIVERGED row) and -# crates/kernel/ents-gate-rules/tests/ledger.rs. -set -u - -dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) - -warn_skip() { - echo "check-alloy: $1" >&2 - echo "check-alloy: skipping (this is a warning, not a failure)." >&2 - echo "check-alloy: install: download org.alloytools.alloy.dist.jar from" >&2 - echo " https://github.com/AlloyTools/org.alloytools.alloy/releases" >&2 - echo " and set ALLOY_JAR=/path/to/org.alloytools.alloy.dist.jar" >&2 - exit 0 -} - -jar="${ALLOY_JAR:-}" -if [ -z "$jar" ]; then - for candidate in \ - "$HOME/.local/share/alloy/org.alloytools.alloy.dist.jar" \ - /usr/local/share/alloy/org.alloytools.alloy.dist.jar \ - /opt/homebrew/share/alloy/org.alloytools.alloy.dist.jar \ - /usr/share/java/org.alloytools.alloy.dist.jar; do - if [ -f "$candidate" ]; then - jar=$candidate - break - fi - done -fi -[ -n "$jar" ] && [ -f "$jar" ] || warn_skip "no Alloy jar found (ALLOY_JAR unset, no well-known path hit)" -command -v java >/dev/null 2>&1 || warn_skip "no java runtime on PATH" - -# `exec` writes per-command solution files; send them to a scratch dir so -# they never litter the working tree, and clean up on exit. -out=$(mktemp -d "${TMPDIR:-/tmp}/check-alloy.XXXXXX") -trap 'rm -rf "$out"' EXIT - -status=0 -for als in "$dir"/alloy/*.als; do - echo "== check-alloy: $als" - # The Alloy 6 dist jar ships a CLI; `exec` runs every command in the - # file headless. A found counterexample for a `check` is reported in - # the output — read it against the expectations in the file headers. - # A `check` that finds a counterexample is still a successful run - # (exit 0), so only a parse error or a jar failure fails this script. - if ! java -jar "$jar" exec -f -o "$out/$(basename "$als")" "$als"; then - echo "check-alloy: $als: non-zero exit (parse error or jar failure — see above)" >&2 - status=1 - fi -done -exit "$status"
verify/bin/check-tla @@ -1,56 +1,0 @@ -#!/bin/sh -# Parse every TLA+ module in verify/tla/ with SANY, then model-check each -# module that has a .cfg with TLC. Best-effort and optional: if no -# tla2tools jar (or no java) is available this script warns and exits 0 -# so it never blocks a pipeline. -# -# Jar discovery: $TLA_TOOLS_JAR, then a few well-known paths. Do NOT -# vendor the jar into the repo. -set -u - -dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) - -warn_skip() { - echo "check-tla: $1" >&2 - echo "check-tla: skipping (this is a warning, not a failure)." >&2 - echo "check-tla: install: download tla2tools.jar from" >&2 - echo " https://github.com/tlaplus/tlaplus/releases" >&2 - echo " and set TLA_TOOLS_JAR=/path/to/tla2tools.jar" >&2 - exit 0 -} - -jar="${TLA_TOOLS_JAR:-}" -if [ -z "$jar" ]; then - for candidate in \ - "$HOME/.local/share/tlaplus/tla2tools.jar" \ - /usr/local/share/tlaplus/tla2tools.jar \ - /opt/homebrew/share/tlaplus/tla2tools.jar \ - /usr/share/java/tla2tools.jar; do - if [ -f "$candidate" ]; then - jar=$candidate - break - fi - done -fi -[ -n "$jar" ] && [ -f "$jar" ] || warn_skip "no tla2tools jar found (TLA_TOOLS_JAR unset, no well-known path hit)" -command -v java >/dev/null 2>&1 || warn_skip "no java runtime on PATH" - -status=0 -cd "$dir/tla" || exit 1 -for tla in *.tla; do - echo "== check-tla: SANY $tla" - if ! java -cp "$jar" tla2sany.SANY "$tla"; then - echo "check-tla: $tla: SANY parse failure" >&2 - status=1 - continue - fi - cfg="${tla%.tla}.cfg" - if [ -f "$cfg" ]; then - echo "== check-tla: TLC $tla ($cfg)" - if ! java -cp "$jar" tlc2.TLC -config "$cfg" -deadlock "$tla"; then - echo "check-tla: $tla: TLC failure" >&2 - status=1 - fi - fi -done -exit "$status"
verify/tla/Durability.cfg @@ -1,8 +1,0 @@ -\* TLC configuration for the Durability skeleton (verify/exercise.md, -\* Phase 5). -SPECIFICATION Spec -CONSTANTS - Oids = {o1, o2} - RefNames = {r1} - NoOid = NoOid -INVARIANTS TypeOK RefsPointDurable
verify/tla/Durability.tla @@ -1,72 +1,0 @@ ---------------------------- MODULE Durability --------------------------- -(***************************************************************************) -(* Phase 5 — durability ordering (verify/exercise.md, "Phase 5"). *) -(* *) -(* SKELETON. Variables and action signatures only; every body is a *) -(* TODO(exercise) stub that changes no state. This module checks NOTHING *) -(* until the human exercise fills it in. *) -(* *) -(* Deployment note: the exercise document frames this phase around a *) -(* hosted Tigris-object-store + Postgres-CAS split. The project currently *) -(* deploys neither — serving is plain git http-backend over one *) -(* filesystem — so the actions here are named for the general shape *) -(* (object write, ref CAS, crash), and the Tigris/Pg instantiation is *) -(* deferred until such a deployment exists. The invariant under study is *) -(* unchanged: no ref points outside the durable object set. *) -(* *) -(* Vocabulary: object ids and refnames as in the other modules, matching *) -(* ents-gate-rules' EDB vocabulary. *) -(***************************************************************************) -EXTENDS Naturals, FiniteSets - -CONSTANTS - Oids, \* object ids in play - RefNames, \* meta-ref names in play - NoOid \* model value: unborn ref - -OldOids == Oids \cup {NoOid} - -VARIABLES - durable, \* SUBSET Oids: objects durably written - refstore \* RefNames -> OldOids: the ref store's current tips - -vars == <<durable, refstore>> - ------------------------------------------------------------------------------ -(* Actions. SKELETONS: signatures and intent only. *) - -\* An object (or pack) reaches durable storage. -ObjectWrite == - /\ TRUE \* TODO(exercise) - /\ UNCHANGED vars - -\* The ref store compare-and-swaps a tip. -RefCAS == - /\ TRUE \* TODO(exercise): the write-order question lives here - /\ UNCHANGED vars - -\* Crash at any point; recovery obligations follow from what survives. -Crash == - /\ TRUE \* TODO(exercise) - /\ UNCHANGED vars - ------------------------------------------------------------------------------ -Init == - /\ durable = {} - /\ refstore = [r \in RefNames |-> NoOid] - -Next == ObjectWrite \/ RefCAS \/ Crash - -Spec == Init /\ [][Next]_vars - -TypeOK == - /\ durable \subseteq Oids - /\ refstore \in [RefNames -> OldOids] - -\* The invariant this phase exists to prove. STATED here so the ledger -\* row has a formal object to point at; NOT proved — the actions above -\* are stubs, so checking it today says nothing. TODO(exercise). -RefsPointDurable == - \A r \in RefNames : refstore[r] # NoOid => refstore[r] \in durable - -=============================================================================
verify/tla/Effects.cfg @@ -1,9 +1,0 @@ -\* TLC configuration for the Effects skeleton (verify/exercise.md, Phase 4). -SPECIFICATION Spec -CONSTANTS - Oids = {o1, o2, o3} - RefNames = {r1, r2} - EffectRefs = {r2} - Keys = {k1, k2} - NoOid = NoOid -INVARIANT TypeOK
verify/tla/Effects.tla @@ -1,106 +1,0 @@ ----------------------------- MODULE Effects ---------------------------- -(***************************************************************************) -(* Phase 4 — effects (verify/exercise.md, "Phase 4"). *) -(* *) -(* SKELETON. Variables and action signatures only; every body is a *) -(* TODO(exercise) stub that changes no state. This module checks NOTHING *) -(* until the human exercise fills it in on top of Phase 3's state. *) -(* *) -(* Discharges, once filled in: docs/abstractions.adoc §6 (monotone, *) -(* exactly-once effects); docs/spec/effect.adoc (trigger set, dedup key *) -(* (effect, refname, new_oid), results write-back, admin-only authoring). *) -(* Note ents-gate-rules marks the cross-transaction dedup obligation as *) -(* a deliberate gap — this model is where that obligation lives. *) -(* *) -(* Vocabulary: the transaction record mirrors ents-gate-rules' EDB *) -(* relations one-to-one, as in Receive.tla. *) -(***************************************************************************) -EXTENDS Naturals, FiniteSets - -CONSTANTS - Oids, \* object ids in play - RefNames, \* meta-ref names in play - EffectRefs, \* the RefNames under refs/meta/effects/* - Keys, \* signing keys in play - NoOid \* model value: absent old tip / unborn ref - -ASSUME EffectRefs \subseteq RefNames - -Roles == {"admin", "member"} -OldOids == Oids \cup {NoOid} - -VARIABLES - refs, \* RefNames -> OldOids: current tips (Phase 3 state) - objects, \* SUBSET Oids - members, \* SUBSET (Keys \X Roles) - epoch, \* Nat - triggered, \* commits that have entered the trigger set (§6) - queue, \* at-least-once delivery: dedup keys <<effect, ref, new_oid>> - results \* result-ref writes performed so far - -vars == <<refs, objects, members, epoch, triggered, queue, results>> - -(* The transaction record type: ents-gate-rules' Facts, field for field. *) -Txns == [ref_update : SUBSET (RefNames \X OldOids \X Oids), - parent : SUBSET (Oids \X Oids), - signed_by : SUBSET (Oids \X Keys), - anchor : SUBSET (Oids \X Oids), - context : SUBSET (Oids \X Oids), - object_exists : SUBSET Oids] - -\* The dedup key the doc claims yields exactly-once observable effects. -DedupKeys == EffectRefs \X RefNames \X Oids - ------------------------------------------------------------------------------ -(* Actions. SKELETONS: signatures and intent only. *) - -\* A gated ref advance (reuses Phase 3's GateAdmits when composed). -RefAdvance == - /\ TRUE \* TODO(exercise) - /\ UNCHANGED vars - -\* A commit ENTERS the trigger set (§6 "fires once per commit that enters -\* the set") — the delete-and-repush re-entry question lives here. -TriggerEval == - /\ TRUE \* TODO(exercise) - /\ UNCHANGED vars - -\* At-least-once enqueue of a dedup key. -Enqueue == - /\ TRUE \* TODO(exercise) - /\ UNCHANGED vars - -\* Executor runs an effect; may crash and restart (duplicate delivery). -Execute == - /\ TRUE \* TODO(exercise) - /\ UNCHANGED vars - -\* Result write-back: a gated write like any other, by an executor key. -ResultPush == - /\ TRUE \* TODO(exercise) - /\ UNCHANGED vars - ------------------------------------------------------------------------------ -Init == - /\ refs = [r \in RefNames |-> NoOid] - /\ objects = {} - /\ members = {} - /\ epoch = 0 - /\ triggered = {} - /\ queue = {} - /\ results = {} - -Next == RefAdvance \/ TriggerEval \/ Enqueue \/ Execute \/ ResultPush - -Spec == Init /\ [][Next]_vars - -TypeOK == - /\ refs \in [RefNames -> OldOids] - /\ objects \subseteq Oids - /\ members \subseteq (Keys \X Roles) - /\ epoch \in Nat - /\ triggered \subseteq Oids - /\ queue \subseteq DedupKeys - /\ results \subseteq DedupKeys - -=============================================================================
verify/tla/Receive.cfg @@ -1,11 +1,0 @@ -\* TLC configuration for the Receive skeleton (verify/exercise.md, Phase 3). -\* Scope discipline per the exercise: tiny universes; two members, two -\* refs, three commits is where the bugs live. -SPECIFICATION Spec -CONSTANTS - Oids = {o1, o2, o3} - RefNames = {r1, r2} - EffectRefs = {r2} - Keys = {k1, k2} - NoOid = NoOid -INVARIANT TypeOK
verify/tla/Receive.tla @@ -1,195 +1,0 @@ ----------------------------- MODULE Receive ---------------------------- -(***************************************************************************) -(* Phase 3 — gate and receive as a protocol (verify/exercise.md, *) -(* "Phase 3"). *) -(* *) -(* SKELETON. Variables and action signatures only. The one definition *) -(* with real content is GateAdmits: the seven denial rules of *) -(* crates/kernel/ents-gate-rules/src/lib.rs transcribed mechanically — *) -(* that transcription is the refinement anchor (Phase 0.5's model *) -(* reused). Every action body is a TODO(exercise) stub that changes no *) -(* state; this module checks NOTHING about the protocol until the human *) -(* exercise fills the actions in. *) -(* *) -(* Discharges, once filled in: docs/abstractions.adoc §5 (tip invariant, *) -(* adoption, revocation), §4 (anti-replay); docs/spec/receive.adoc; *) -(* docs/spec/gate.adoc epoch bootstrap. *) -(* *) -(* Vocabulary: a transaction is a record whose fields mirror the EDB *) -(* relations of ents-gate-rules one-to-one: ref_update, parent, *) -(* signed_by, anchor, context, object_exists — with member supplied from *) -(* the protocol-state variable `members`, exactly as the crate's *) -(* extractor would supply it. *) -(***************************************************************************) -EXTENDS Naturals, FiniteSets - -CONSTANTS - Oids, \* object ids in play - RefNames, \* meta-ref names in play - EffectRefs, \* the RefNames under refs/meta/effects/* - Keys, \* signing keys in play - NoOid \* model value: absent old tip (entity creation) - -ASSUME EffectRefs \subseteq RefNames - -Roles == {"admin", "member"} -OldOids == Oids \cup {NoOid} - -VARIABLES - refs, \* RefNames -> OldOids: current tips (NoOid = unborn) - objects, \* SUBSET Oids: the store's object set - members, \* SUBSET (Keys \X Roles): enrolled keys and provenance - epoch, \* Nat: the config ref's epoch (§5) — placeholder until Phase 3 - proposed \* transactions in flight, each a record over the EDB fields - -vars == <<refs, objects, members, epoch, proposed>> - -(* The transaction record type: ents-gate-rules' Facts, field for field. *) -Txns == [ref_update : SUBSET (RefNames \X OldOids \X Oids), - parent : SUBSET (Oids \X Oids), - signed_by : SUBSET (Oids \X Keys), - anchor : SUBSET (Oids \X Oids), - context : SUBSET (Oids \X Oids), - object_exists : SUBSET Oids] - ------------------------------------------------------------------------------ -(* IDB relations of ents-gate-rules, transcribed. *) - -\* ancestor: transitive ancestry over the transaction's parent edges. -TC(R) == - LET N == {p[1] : p \in R} \cup {p[2] : p \in R} - IN { ab \in N \X N : - \E n \in 1..Cardinality(N) : - \E f \in [1..(n+1) -> N] : - /\ f[1] = ab[1] - /\ f[n+1] = ab[2] - /\ \A i \in 1..n : <<f[i], f[i+1]>> \in R } - -Ancestors(t, c) == {a \in Oids : <<c, a>> \in TC(t.parent)} - -HasParent(t, c) == \E p \in Oids : <<c, p>> \in t.parent - -\* covered: commits already covered by a ref's old tip. -Covered(t, old) == IF old = NoOid THEN {} ELSE {old} \cup Ancestors(t, old) - -\* introduced: the new tip and its ancestors, minus everything covered. -Introduced(t, old, new) == ({new} \cup Ancestors(t, new)) \ Covered(t, old) - -MemberSigned(t, mem, c) == - \E k \in Keys : /\ <<c, k>> \in t.signed_by - /\ \E r \in Roles : <<k, r>> \in mem - -AdminSigned(t, mem, c) == - \E k \in Keys : /\ <<c, k>> \in t.signed_by - /\ <<k, "admin">> \in mem - ------------------------------------------------------------------------------ -(* The seven denial rules, same names as the crate. *) - -\* Fast-forward-only: the new tip must descend from the old tip. -FfViolation(t) == - \E u \in t.ref_update : /\ u[2] # NoOid - /\ u[2] # u[3] - /\ u[2] \notin Ancestors(t, u[3]) - -\* Creation must point at a parentless genesis commit. -GenesisViolation(t) == - \E u \in t.ref_update : /\ u[2] = NoOid - /\ HasParent(t, u[3]) - -\* One entity, one root: past genesis, no second parentless commit. -SecondRootViolation(t) == - \E u \in t.ref_update : - /\ u[2] # NoOid - /\ \E c \in Introduced(t, u[2], u[3]) : ~HasParent(t, c) - -\* Every introduced commit carries an enrolled member's signature. -UnsignedViolation(t, mem) == - \E u \in t.ref_update : - \E c \in Introduced(t, u[2], u[3]) : ~MemberSigned(t, mem, c) - -\* An anchored blob must resolve. -DanglingAnchorViolation(t) == - \E u \in t.ref_update : - \E c \in Introduced(t, u[2], u[3]) : - \E b \in Oids : /\ <<c, b>> \in t.anchor - /\ b \notin t.object_exists - -\* The paired context blob must resolve too. -DanglingContextViolation(t) == - \E u \in t.ref_update : - \E c \in Introduced(t, u[2], u[3]) : - \E b \in Oids : /\ <<c, b>> \in t.context - /\ b \notin t.object_exists - -\* A write to refs/meta/effects/* must be admin-signed. -EffectAdminViolation(t, mem) == - \E u \in t.ref_update : - /\ u[1] \in EffectRefs - /\ \E c \in Introduced(t, u[2], u[3]) : ~AdminSigned(t, mem, c) - -(* gate(facts) = {}: the conjunction that admits a transaction. *) -GateAdmits(t, mem) == - /\ ~FfViolation(t) - /\ ~GenesisViolation(t) - /\ ~SecondRootViolation(t) - /\ ~UnsignedViolation(t, mem) - /\ ~DanglingAnchorViolation(t) - /\ ~DanglingContextViolation(t) - /\ ~EffectAdminViolation(t, mem) - \* TODO(exercise): refname recomputation from signed content — the §4 - \* binding rule ents-gate-rules omits (ledger row: DIVERGED). Phase 3 - \* decides whether ents-gate proper enforces it and adds it here. - \* TODO(exercise): epoch rule (§5) — also omitted from the crate. - ------------------------------------------------------------------------------ -(* Actions. SKELETONS: signatures and intent only; every body leaves the *) -(* state unchanged. TODO(exercise) throughout — nothing here is checked. *) - -\* A writer proposes a transaction (two writers minimum in the model). -Propose == - /\ TRUE \* TODO(exercise): choose t \in Txns, add to proposed - /\ UNCHANGED vars - -\* The gate evaluates a proposed transaction; enabling condition is the -\* seven-rule conjunction above. -GateCheck == - /\ \E t \in proposed : GateAdmits(t, members) - /\ TRUE \* TODO(exercise): mark t admitted for CAS - /\ UNCHANGED vars - -\* Compare-and-swap on the ref tip (anti-replay, §4). -CAS == - /\ TRUE \* TODO(exercise): refs' = [refs EXCEPT ...] guarded on old tip - /\ UNCHANGED vars - -\* Adoption: contributor commit in ancestry, adopter signature at tip. -AdoptMerge == - /\ TRUE \* TODO(exercise) - /\ UNCHANGED vars - -\* Two of one member's machines racing (is the merge commit signed?). -SelfMerge == - /\ TRUE \* TODO(exercise): check ents-sync/ents-receive reconcile path - /\ UNCHANGED vars - ------------------------------------------------------------------------------ -Init == - /\ refs = [r \in RefNames |-> NoOid] - /\ objects = {} - /\ members = {} - /\ epoch = 0 - /\ proposed = {} - -Next == Propose \/ GateCheck \/ CAS \/ AdoptMerge \/ SelfMerge - -Spec == Init /\ [][Next]_vars - -TypeOK == - /\ refs \in [RefNames -> OldOids] - /\ objects \subseteq Oids - /\ members \subseteq (Keys \X Roles) - /\ epoch \in Nat - /\ \A t \in proposed : t \in Txns - -=============================================================================