verify: scaffold the formal stocktake harness
commit
cba33fbverify: scaffold the formal stocktake harness
verify/ holds the Alloy/TLA+ models and the verdict ledger; stubs match ents-gate-rules' fact vocabulary one-to-one, with the seven denial rules translated as the refinement anchor. ents-gate-rules gains gap-pinning ledger tests (cross-ref replay pinned open). Checkers wired as an optional, skippable CI job. No shipped-crate src changes; no proofs — the exercise is done on paper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
.config/committed.toml
@@ -18,6 +18,7 @@
"receive",
"roots",
"sync",
+ "verify",
"docs",
"spec",
"checks",
.config/rumdl.toml
@@ -7,6 +7,8 @@
"dist",
"build",
"CHANGELOG.md",
+ # Copied verbatim from the source exercise; must not be reformatted.
+ "verify/exercise.md",
]
respect-gitignore = true
.config/typos.toml
@@ -48,3 +48,5 @@
[default.extend-words]
# COSE (CBOR Object Signing and Encryption) — a real WebAuthn term, not a typo
cose = "cose"
+# als — the Alloy model file extension (verify/alloy/*.als), not "also"
+als = "als"
.github/workflows/CI.yml
@@ -125,6 +125,35 @@
- 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/tests/ledger.rs
@@ -1,0 +1,68 @@
+//! Counterexample tests for `verify/ledger.adoc` — the formal-stocktake
+//! verdict ledger's landing strip in code.
+//!
+//! Convention (see `verify/README.adoc`): a ledger row found FALSIFIED or
+//! DIVERGED with a concrete, transaction-shaped counterexample lands here
+//! first, expressed in this crate's own [`Facts`] vocabulary. While the
+//! gap is open, the test is a *gap-pinning* test: it asserts the current
+//! (wrong-per-the-docs) behavior, so the suite stays green and the gap
+//! stays visible. When the missing denial rule is added — one rule at a
+//! 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.
+
+use ents_gate_rules::{Facts, Role, gate};
+
+/// Cross-ref replay through the missing refname-binding rule.
+///
+/// Ledger row: `docs/abstractions.adoc` §4 / `docs/spec/meta-ref.adoc`
+/// `meta-ref.identity-binding` — "the refname is a total function of
+/// signed content, recomputed at verification". Verdict: DIVERGED (the
+/// doc claims it; no rule checks it; no gap marker declares the
+/// omission). Missing rule: `binding_violation`. Alloy witness:
+/// `verify/alloy/gate_rules.als`, check `binding_refname_recomputed`.
+///
+/// The transaction: an admin-signed, parentless commit whose signed
+/// content is a *comment* (anchor + context blobs present and resolving),
+/// replayed as the creation of `refs/meta/effects/x`. Every current rule
+/// is satisfied: `genesis` (parentless), `unsigned` (member-signed),
+/// `effect_admin` (admin-signed), `ff` (vacuous — creation), the root and
+/// anchor rules likewise. Nothing recomputes the refname from the signed
+/// content, so the comment is admitted as an effect definition.
+///
+/// This test PINS the open gap: it asserts the replay is admitted today.
+/// Fixing it is out of scope for the stocktake scaffolding. When
+/// `binding_violation` lands, this assertion flips — swap it for the
+/// commented one below.
+#[test]
+fn cross_ref_replay_of_comment_as_effect_is_admitted_today() {
+ let mut facts = Facts {
+ member: vec![("key:admin".into(), Role::Admin)],
+ ..Facts::default()
+ };
+ // A comment-shaped genesis: parentless, admin-signed, embedding its
+ // anchored blob and context blob — but pushed as the creation of an
+ // effects ref, a namespace its signed content does not derive.
+ facts.ref_update = vec![("refs/meta/effects/x".into(), None, "g2".into())];
+ facts.signed_by = vec![("g2".into(), "key:admin".into())];
+ facts.anchor = vec![("g2".into(), "blob:a".into())];
+ facts.context = vec![("g2".into(), "blob:ctx".into())];
+ facts.object_exists = vec![("blob:a".into(),), ("blob:ctx".into(),)];
+
+ let verdicts = gate(facts);
+
+ // Pinned current behavior: admitted. This is the gap, kept green on
+ // purpose so CI never normalizes ignoring it.
+ assert!(
+ verdicts.is_empty(),
+ "the binding gap appears to have closed: a rule now denies the \
+ cross-ref replay ({verdicts:?}) — flip this test to the inverted \
+ assertion below and update verify/ledger.adoc"
+ );
+
+ // Ready to swap in when `binding_violation` exists:
+ // assert!(
+ // verdicts.iter().any(|v| v.starts_with("binding:")),
+ // "binding_violation must deny the cross-ref replay: {verdicts:?}"
+ // );
+}
verify/README.adoc
@@ -1,0 +1,48 @@
+= 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.
+
+== 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.
+
+`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.
+
+`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.
+
+`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.
+
+== 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.
+
+== 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.
+
+== Running the checkers
+
+[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
+----
+
+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`.
verify/alloy/binding.als
@@ -1,0 +1,148 @@
+// Phase 2 — refname binding as a total function (verify/exercise.md,
+// "Phase 2").
+//
+// STUB. This file parses, declares the vocabulary, and names one
+// derivation predicate per meta-ref namespace — it checks NOTHING. Every
+// predicate body is deliberately empty (trivially true) until the human
+// exercise writes each derivation from the code, not from memory.
+//
+// Discharges, once filled in: docs/abstractions.adoc §4 ("the refname is
+// a total function of signed content, recomputed at verification");
+// docs/spec/meta-ref.adoc meta-ref.identity-binding and meta-ref.inbox.
+// The namespace list below is enumerated from meta-ref.adoc's own
+// binding taxonomy: fixed-name singletons, natural-key, hash-identified,
+// composite-keyed, inbox/self signer-bound, and pins.
+//
+// Vocabulary: signatures mirror the EDB relations of
+// crates/kernel/ents-gate-rules/src/lib.rs one-to-one, so the binding
+// model composes with gate_rules.als — the composition is exactly the
+// cross-ref replay check (Phase 4, obligation 3).
+
+module binding
+
+// ---- EDB vocabulary, one signature/field per ents-gate-rules relation ----
+
+sig Oid {
+ parent: set Oid, // parent(child, parent)
+ signed_by: set Key, // signed_by(commit, key)
+ anchor: set Oid, // anchor(entity commit, anchored blob)
+ context: set Oid // context(entity commit, context blob)
+}
+
+sig Key { role: lone Role } // member(Key, Role)
+abstract sig Role {}
+one sig Admin, Member extends Role {}
+
+abstract sig RefName {}
+sig EffectsRef, OtherRef extends RefName {}
+
+one sig Store { object_exists: set Oid } // object_exists(Oid)
+
+sig RefUpdate { // ref_update(Ref, Option<Oid>, Oid)
+ ref: one RefName,
+ old: lone Oid,
+ new: one Oid
+}
+
+// The binding function under study: refname derived from signed content.
+// The exercise fills in its definition per namespace; here it is a free
+// relation so the file parses.
+sig Binding { binds: Oid -> lone RefName }
+
+// ---- Per-namespace derivation stubs (meta-ref.identity-binding) ----
+// Each states, once written, how that namespace's refname derives from
+// signed content. All STUBS — they check nothing.
+
+// refs/meta/account — fixed name (singleton state).
+pred binding_account {
+ // TODO(exercise)
+}
+
+// refs/meta/config — fixed name (singleton state).
+pred binding_config {
+ // TODO(exercise)
+}
+
+// refs/meta/member/* — natural key: designated tree field equals the
+// refname's final segment.
+pred binding_member {
+ // TODO(exercise)
+}
+
+// refs/meta/effects/* — natural key: the effect's name.
+pred binding_effects {
+ // TODO(exercise)
+}
+
+// refs/meta/issues/* — hash-identified: final segment equals the genesis
+// commit oid; all parentless commits reachable from the tip are that
+// genesis.
+pred binding_issues {
+ // TODO(exercise)
+}
+
+// refs/meta/comments/* — hash-identified, same rule as issues.
+pred binding_comments {
+ // TODO(exercise)
+}
+
+// refs/meta/reviews/<target>/<member> — composite-keyed: genesis tree's
+// target field + genesis signer's member id.
+pred binding_reviews {
+ // TODO(exercise)
+}
+
+// refs/meta/results/<effect>/<short-oid> — composite-keyed: derived from
+// the result's own tree fields.
+pred binding_results {
+ // TODO(exercise)
+}
+
+// refs/meta/inbox/<member>/<canonical-suffix> — owner segment equals the
+// signer; suffix bound as its canonical namespace binds.
+pred binding_inbox {
+ // TODO(exercise)
+}
+
+// refs/meta/self/<member>/<effect>/<short-oid> — member segment equals
+// the signer, mirroring the canonical results pattern.
+pred binding_self {
+ // TODO(exercise)
+}
+
+// refs/meta/pins/* — mirrors its entity's segments; parentless-roots walk
+// deliberately not applied.
+pred binding_pins {
+ // TODO(exercise)
+}
+
+// ---- Phase 2 obligation stubs ----
+
+// Obligation 1 aggregate: the binding is a TOTAL function over every
+// namespace above. STUB — checks nothing.
+pred binding_total_function {
+ // TODO(exercise)
+}
+
+// Obligation 2: inbox is the one allowed second image of the same signed
+// commit; nothing else is. STUB — checks nothing.
+pred inbox_allowed_second_image {
+ // TODO(exercise)
+}
+
+// Obligation 3: self/<member> derives from the SIGNATURE, not a tree
+// field an author could forge. STUB — checks nothing.
+pred self_member_from_signature {
+ // TODO(exercise)
+}
+
+// Obligation 4: is repo identity anywhere in signed content? Cross-repo
+// replay. CONDITIONAL either way — write the condition. STUB — checks
+// nothing.
+pred cross_repo_replay {
+ // TODO(exercise)
+}
+
+// Parse-only smoke command so `check-alloy` has something to execute;
+// it asserts nothing about the system.
+run { some Binding } for 3
verify/alloy/gate_rules.als
@@ -1,0 +1,196 @@
+// Phase 0.5 — verify the verifier (verify/exercise.md, "Phase 0.5").
+//
+// This file is NOT a stub. It translates the seven denial rules of
+// crates/kernel/ents-gate-rules/src/lib.rs into Alloy, one predicate per
+// rule, same names, and then runs the check the crate cannot run on
+// itself: search for transactions with zero violations that break a doc
+// invariant (docs/abstractions.adoc; docs/spec/meta-ref.adoc,
+// meta-ref.identity-binding; docs/spec/gate.adoc).
+//
+// The crate is ground truth for the translation: every signature below
+// mirrors one EDB relation of ents-gate-rules one-to-one, and every
+// denial predicate is a mechanical transcription of the corresponding
+// ascent rule. The one extra field, `kind`, models what the *signed
+// content* of a commit says it is — the derivation input for refname
+// recomputation. It is deliberately absent from the rule vocabulary,
+// because that absence is the gap under test.
+//
+// Expected outcome: every check passes EXCEPT binding_refname_recomputed,
+// which must produce the known cross-ref replay counterexample (an
+// admin-signed parentless comment commit created at refs/meta/effects/x).
+// That failure is the harness working, not the harness broken. It is
+// pinned on the code side by crates/kernel/ents-gate-rules/tests/ledger.rs
+// and recorded in verify/ledger.adoc (verdict DIVERGED).
+
+module gate_rules
+
+// ---- EDB vocabulary, one signature/field per ents-gate-rules relation ----
+
+// Oid: an object id. `parent`, `signed_by`, `anchor`, `context` mirror the
+// crate's relations parent(Oid, Oid), signed_by(Oid, Key), anchor(Oid, Oid),
+// context(Oid, Oid).
+sig Oid {
+ parent: set Oid,
+ signed_by: set Key,
+ anchor: set Oid,
+ context: set Oid,
+ // NOT part of the crate's vocabulary: the entity kind the signed
+ // content carries, from which meta-ref.identity-binding says the
+ // refname recomputes. Modeling it here is what lets Alloy state the
+ // doc invariant the rules do not check.
+ kind: lone Kind
+}
+
+// member(Key, Role): a key is enrolled iff `role` is nonempty.
+sig Key { role: lone Role }
+abstract sig Role {}
+one sig Admin, Member extends Role {}
+
+abstract sig Kind {}
+one sig CommentKind, IssueKind, EffectKind extends Kind {}
+
+// Refnames, abstracted to the one distinction the rules make:
+// `r.starts_with("refs/meta/effects/")`.
+abstract sig RefName {}
+sig EffectsRef, OtherRef extends RefName {}
+
+// object_exists(Oid): objects the repository already has, or that arrive
+// in this pack.
+one sig Store { object_exists: set Oid }
+
+// ref_update(Ref, Option<Oid>, Oid): `no old` is entity creation.
+sig RefUpdate {
+ ref: one RefName,
+ old: lone Oid,
+ new: one Oid
+}
+
+// ---- IDB: derived relations, transcribed ----
+
+// ancestor: transitive ancestry.
+fun ancestors[c: Oid]: set Oid { c.^parent }
+
+// has_parent(Oid)
+pred has_parent[c: Oid] { some c.parent }
+
+// covered(Ref, Oid): commits already covered by a ref's old tip.
+fun covered[u: RefUpdate]: set Oid { u.old + ancestors[u.old] }
+
+// introduced(Ref, Oid): the new tip and its ancestors, minus everything
+// the old tip already reached.
+fun introduced[u: RefUpdate]: set Oid { (u.new + ancestors[u.new]) - covered[u] }
+
+// member_signed(Oid)
+pred member_signed[c: Oid] { some k: c.signed_by | some k.role }
+
+// admin_signed(Oid)
+pred admin_signed[c: Oid] { some k: c.signed_by | k.role = Admin }
+
+// ---- The seven denial rules, same names as the crate ----
+
+// Fast-forward-only: the new tip must descend from the old tip.
+pred ff_violation[u: RefUpdate] {
+ some u.old and u.old != u.new and u.old not in ancestors[u.new]
+}
+
+// Creation must point at a parentless genesis commit.
+pred genesis_violation[u: RefUpdate] {
+ no u.old and has_parent[u.new]
+}
+
+// One entity, one root: past genesis, an update may not introduce a
+// second parentless commit.
+pred second_root_violation[u: RefUpdate] {
+ some u.old and some c: introduced[u] | not has_parent[c]
+}
+
+// Every introduced commit must carry a signature from a currently
+// enrolled member.
+pred unsigned_violation[u: RefUpdate] {
+ some c: introduced[u] | not member_signed[c]
+}
+
+// An anchored blob must resolve to an object the repository will contain.
+pred dangling_anchor_violation[u: RefUpdate] {
+ some c: introduced[u] | some (c.anchor - Store.object_exists)
+}
+
+// The paired context blob must resolve too.
+pred dangling_context_violation[u: RefUpdate] {
+ some c: introduced[u] | some (c.context - Store.object_exists)
+}
+
+// A write to refs/meta/effects/* must be signed by an admin-registered
+// member.
+pred effect_admin_violation[u: RefUpdate] {
+ u.ref in EffectsRef and some c: introduced[u] | not admin_signed[c]
+}
+
+// admitted: the crate's `gate(facts).is_empty()` — no denial relation
+// holds any row for this update.
+pred admitted[u: RefUpdate] {
+ not ff_violation[u]
+ not genesis_violation[u]
+ not second_root_violation[u]
+ not unsigned_violation[u]
+ not dangling_anchor_violation[u]
+ not dangling_context_violation[u]
+ not effect_admin_violation[u]
+}
+
+// ---- Checks: one per doc invariant the rules claim to cover ----
+
+// abstractions §4 / gate: fast-forward-only advance (ff_violation).
+assert ff_only_advance {
+ all u: RefUpdate | (admitted[u] and some u.old and u.old != u.new)
+ implies u.old in ancestors[u.new]
+}
+check ff_only_advance for 6
+
+// abstractions §2 / meta-ref.identity-binding all-roots walk: an admitted
+// update never introduces a second parentless commit
+// (genesis_violation + second_root_violation).
+assert single_root_identity {
+ all u: RefUpdate | (admitted[u] and some u.old)
+ implies (no c: introduced[u] | not has_parent[c])
+}
+check single_root_identity for 6
+
+// abstractions §5 tip invariant, admission half: every commit an admitted
+// transaction introduces is member-signed (unsigned_violation).
+assert introduced_commits_member_signed {
+ all u: RefUpdate | admitted[u]
+ implies (all c: introduced[u] | member_signed[c])
+}
+check introduced_commits_member_signed for 6
+
+// abstractions §3 / anchor.retention: both embedded objects of every
+// introduced anchor resolve (dangling_anchor_violation +
+// dangling_context_violation).
+assert anchor_retention_resolves {
+ all u: RefUpdate | admitted[u]
+ implies (all c: introduced[u] | (c.anchor + c.context) in Store.object_exists)
+}
+check anchor_retention_resolves for 6
+
+// abstractions §6 / effect.admin-only: an admitted write to
+// refs/meta/effects/* is admin-signed (effect_admin_violation).
+assert effects_writes_admin_signed {
+ all u: RefUpdate | (admitted[u] and u.ref in EffectsRef)
+ implies (all c: introduced[u] | admin_signed[c])
+}
+check effects_writes_admin_signed for 6
+
+// abstractions §4 / meta-ref.identity-binding: "the refname is a total
+// function of signed content, recomputed at verification." No rule in
+// ents-gate-rules covers this, and no gap marker declares the omission.
+// EXPECTED TO FAIL with the cross-ref replay counterexample: an
+// admin-signed parentless commit whose signed content is a comment
+// (kind = CommentKind, anchor + context present and resolving), replayed
+// as the creation of an effects ref — genesis, unsigned, and effect_admin
+// are all satisfied, ff vacuously. Ledger row: DIVERGED.
+assert binding_refname_recomputed {
+ all u: RefUpdate | (admitted[u] and no u.old and u.ref in EffectsRef)
+ implies u.new.kind = EffectKind
+}
+check binding_refname_recomputed for 6
verify/alloy/objects.als
@@ -1,0 +1,86 @@
+// Phase 1 — the object-graph substrate (verify/exercise.md, "Phase 1").
+//
+// STUB. This file is scaffolding for a paper-first exercise: it parses,
+// it declares the vocabulary, and it names the obligations — it checks
+// NOTHING. Every predicate below is deliberately empty (trivially true)
+// until a human writes the model longhand and transcribes it here.
+//
+// Discharges, once filled in: docs/abstractions.adoc §2 (typed tree,
+// schema pinning), §3 (anchor retention, redaction); docs/spec/anchor.adoc
+// anchor.retention; the schema-pinning string-OID/gitlink negatives.
+//
+// Vocabulary: the signatures mirror the EDB relations of
+// crates/kernel/ents-gate-rules/src/lib.rs one-to-one (ref_update, parent,
+// signed_by, member, anchor, context, object_exists), so a claim proved
+// here composes with gate_rules.als without renaming. Phase 1 will refine
+// Oid into Blob + Tree + Commit with tree entries; that refinement is the
+// human's first move, not the harness's.
+
+module objects
+
+// ---- EDB vocabulary, one signature/field per ents-gate-rules relation ----
+
+sig Oid {
+ parent: set Oid, // parent(child, parent)
+ signed_by: set Key, // signed_by(commit, key)
+ anchor: set Oid, // anchor(entity commit, anchored blob)
+ context: set Oid // context(entity commit, context blob)
+}
+
+sig Key { role: lone Role } // member(Key, Role)
+abstract sig Role {}
+one sig Admin, Member extends Role {}
+
+abstract sig RefName {}
+sig EffectsRef, OtherRef extends RefName {}
+
+one sig Store { object_exists: set Oid } // object_exists(Oid)
+
+sig RefUpdate { // ref_update(Ref, Option<Oid>, Oid)
+ ref: one RefName,
+ old: lone Oid,
+ new: one Oid
+}
+
+// ---- Obligation stubs: named, empty, trivially true. Not checked. ----
+
+// Obligation 1: entity tree embeds schema as a real entry => schema in
+// reach[entityTip]. STUB — checks nothing.
+pred schema_pinning {
+ // TODO(exercise)
+}
+
+// Obligation 1, negative: the string-OID variant does NOT retain the
+// schema (reachability fails). STUB — checks nothing.
+pred schema_pinning_string_oid_fails {
+ // TODO(exercise)
+}
+
+// Obligation 1, negative: the gitlink variant does NOT retain the schema.
+// STUB — checks nothing.
+pred schema_pinning_gitlink_fails {
+ // TODO(exercise)
+}
+
+// Obligation 2: embedded anchored blob + context blob reachable from the
+// meta-ref (anchor.retention). STUB — checks nothing.
+pred anchor_retention {
+ // TODO(exercise)
+}
+
+// Obligation 2, degraded trace: anchored commit gc'd, anchor still
+// projects from its embedded objects. STUB — checks nothing.
+pred anchor_degraded_projection {
+ // TODO(exercise)
+}
+
+// Obligation 3: redaction as object withholding — can withholding one
+// entity's blob break another entity's closure via dedup sharing?
+// CONDITIONAL candidate. STUB — checks nothing.
+pred redaction_vs_retention {
+ // TODO(exercise)
+}
+
+// Parse-only smoke command so `check-alloy` has something to execute;
+// it asserts nothing about the system.
+run { some Oid } for 3
verify/bin/check-alloy
@@ -1,0 +1,60 @@
+#!/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,0 +1,56 @@
+#!/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/exercise.md
@@ -1,0 +1,266 @@
+# Stocktaking git-ents by formal verification
+
+A paper exercise. The repo's own claim — `docs/abstractions.adoc` opens with
+"the load-bearing abstractions, stated as invariants" — is taken literally:
+every invariant becomes a formal obligation, the code becomes the
+implementation you check the model against, and the output is a verdict
+ledger that *is* the stocktake.
+
+Tools: **Alloy 6** for structural/relational claims (object graph,
+reachability, refname binding — small-scope model finding excels at
+producing the counterexample you didn't think of) and **TLA+** for the
+protocol claims (receive/CAS, adoption races, effect dispatch). Paper-first:
+write every model longhand before touching a checker; the checker is for
+the claims you can't settle by hand.
+
+The repo now contains its own formalization artifact:
+`crates/kernel/ents-gate-rules` states seven admission invariants as
+compiled Datalog (ascent). Treat it as a **fourth source** with a specific
+epistemic status: it is executable and type-checked, but it *evaluates*
+rules over supplied facts — it cannot search for the transaction you
+didn't think of, prove inductiveness, or reason about time. Alloy/TLA+
+still own those jobs; the rules crate becomes the refinement anchor the
+paper models are checked against.
+
+Rules of engagement:
+- The **doc is the claim source**, the **code is ground truth**, and *you*
+ (your recent design conversations) are a third, unwritten source. Every
+ obligation gets a verdict from: PROVED (inductive in the model),
+ FALSIFIED (counterexample, written out as a concrete Git-object diagram),
+ CONDITIONAL (holds only under an assumption the doc doesn't state — write
+ the assumption down; these are the real findings), or DIVERGED (doc, code,
+ and/or head disagree — cite file and line).
+- Scope discipline: Alloy scopes of 4–6 atoms per signature. Almost every
+ bug in a system like this appears with two members, two refs, three
+ commits.
+- No fixing. This exercise produces the ledger, not patches.
+
+---
+
+## Phase 0 — Adjudicate abstraction 4 (half a day)
+
+Two write-path models are in play:
+
+- **Model C (doc, §4):** author-signed commits; refname a total function of
+ signed content; FF-only + CAS as anti-replay; certs are transport only.
+- **Model P (stated in conversation):** `push --signed`; the cert signs
+ `(refname, old, new)`; certs archived reachably in a server-side op-log.
+
+Formalize both in Alloy: signatures `Commit`, `Tree`, `Ref`, `Member`,
+`Sig`, plus `Cert` in Model P. Define one predicate each system claims:
+
+> `Auditable`: for every historical tip of every meta-ref, a verifier
+> holding only the object closure of `refs/meta/*` (plus, in P, the op-log
+> ref) can decide *who* placed it *there*.
+
+Obligations:
+1. Prove or refute `Auditable` in each model.
+2. In P, state precisely what the op-log ref's own integrity rests on —
+ what signs the op-log tip, and is that circular with the gate?
+3. In C, check the doc's own caveat: "a commit signature proves authorship;
+ it does not prove placement" — verify the recovery (refname recomputation)
+ is *total* (Phase 2 depends on this).
+4. Verdict: are C and P equivalent for `Auditable`? If not, which property
+ separates them — and which one does the repo actually implement? Read
+ `ents-gate/src/{verify,signature}.rs` and `ents-receive/src/receive.rs`
+ and cite the lines. Update the ledger with DIVERGED entries as needed.
+
+Standing evidence: `ents-gate-rules::unsigned_violation` requires every
+introduced *commit* to be member-signed, and no `Cert` fact exists in the
+schema — the executable rules encode Model C. The doc and the rules agree;
+the conversational claim (`push --signed`, certs in the op-log) is the
+outlier. Phase 0's burden is therefore: either produce the argument that
+overturns two shipped artifacts, or record Model C as canonical and file
+the op-log-cert idea as transport forensics only.
+
+Everything downstream assumes whichever model wins. Do not proceed with
+both.
+
+## Phase 0.5 — Verify the verifier (one day)
+
+`ents-gate-rules` is small enough to model whole. Translate each EDB
+relation into an Alloy signature and each denial rule into a predicate,
+then run the check the crate cannot run on itself: **search for
+transactions with zero violations that break a doc invariant.**
+
+Obligations:
+1. **Refname binding is missing and unmarked.** The module docs declare
+ two deliberate gaps (granularity, effect dedup) — but §4's placement
+ recovery ("refname recomputes from signed content") has no rule and no
+ gap marker. Confirm the concrete counterexample: an admin-signed
+ parentless entity commit authored as a comment, replayed as the
+ creation of `refs/meta/effects/x`, passes `genesis`, `unsigned`, and
+ `effect_admin` (vacuous FF). Write it as a red test in the crate's own
+ `Facts` vocabulary. Verdict for §4's binding claim: DIVERGED
+ (doc claims it, rules don't check it, and per Phase 0 decide whether
+ `ents-gate` proper does).
+2. **Extractor contract = trusted computing base.** Every negated
+ relation (`!ancestor`, `!covered`, `!member_signed`, `!object_exists`)
+ is sound only under closed-world completeness of the supplied facts.
+ The EDB comments say `parent` is "bounded at the old tips." For each
+ negation, state the completeness assumption and its failure direction:
+ under-supplied `parent` makes `ff_violation` fail *closed* (spurious
+ deny — safe), but under-supplied `member`/`signed_by` also denies, while
+ over-supplied `object_exists` admits dangling anchors — fail *open*.
+ The table of (relation, assumption, failure direction) is a ledger
+ deliverable; the fail-open rows are extractor obligations that
+ currently live nowhere.
+3. **Creation admits exactly one commit.** `genesis_violation` denies any
+ creation whose tip has parents, and `ref_update` cannot express
+ create-then-advance in one tuple — so an entity with initial history
+ cannot be pushed atomically. Decide: intended (creation is always a
+ bare genesis) or gap. If intended, it belongs in §2/§4 as a stated
+ invariant; it currently isn't.
+4. **Merge-commit signatures.** `unsigned_violation` requires *every*
+ introduced commit signed, which answers Phase 3's sync question in the
+ strict direction: an unsigned auto-merge from `ents-sync` would be
+ denied. Check whether sync actually signs its merges; if not, the rules
+ crate and the sync path are on a collision course — DIVERGED, with the
+ resolution belonging to Phase 3 obligation 1.
+
+## Phase 1 — The object-graph substrate (one day)
+
+Alloy model of just enough Git: `Obj = Blob + Tree + Commit`, `Tree`
+entries as a relation `entries: Tree -> Name -> Obj`, `parents: Commit ->
+set Commit`, `reach = ^(parents + tree-closure)`. Facts: acyclicity,
+content addressing as atom identity (two structurally identical trees are
+the same atom — model this deliberately; dedup is load-bearing for schema
+pinning).
+
+Obligations (all from §2, §3, and the schema-pinning decision):
+1. **Schema pinning:** entity tree embeds schema as a real entry ⇒ schema
+ in `reach[entityTip]`. Then the negative: model the *string-OID* and
+ *gitlink* variants and show reachability fails — the counterexample you
+ already reasoned about informally becomes a checked fact.
+2. **Anchor retention (§3):** embedded blob + context blob reachable from
+ the meta-ref; anchored *commit* oid recorded as data only ⇒ show a trace
+ where the anchored commit is gc'd but the anchor still projects
+ (degraded). Then check the doc's parenthetical: gitlinks retain nothing.
+3. **Redaction vs. retention:** §3 calls redaction "the sole deliberate
+ exception." Model redaction as object withholding and check: can
+ withholding one entity's blob break *another* entity's closure (shared
+ blob via dedup)? This is a CONDITIONAL candidate — dedup and redaction
+ pull in opposite directions.
+
+## Phase 2 — Refname binding as a total function (one day)
+
+The doc (§2, §4) claims: "the refname is a total function of signed
+content, recomputed at verification." Totality is the whole game — one
+entity kind whose refname carries information not in its signed content
+reopens cross-ref replay for that kind.
+
+In Alloy: `binding: Commit -> lone RefName` derived from content atoms.
+Enumerate every namespace in `docs/spec/meta-ref.adoc` and
+`ents-model/src`:
+`member/*, issues/*, comments/*, effects/*, results/*, inbox/*,
+self/<member>/*, account, config`.
+
+Obligations:
+1. For each namespace, write the derivation (genesis oid / natural key /
+ signer composite) from the code, not from memory. Any namespace where
+ you cannot write it: DIVERGED or FALSIFIED.
+2. **Inbox:** an entity sits at `refs/meta/inbox/*` *before* adoption and
+ at canonical *after*, with the same signed commit in ancestry. The
+ function is therefore not injective over placement — check that the gate
+ treats inbox as an allowed second image, and that nothing else is.
+3. **`self/<member>`:** the member component derives from the signer —
+ confirm in code that it derives from the *signature*, not from a tree
+ field an author could set to someone else.
+4. **Cross-repo replay:** is repo identity anywhere in signed content? If
+ not, a validly signed genesis commit for repo A verifies in repo B.
+ State whether that is a real threat in your deployment model
+ (multi-tenant hosted store!) or acceptable. CONDITIONAL either way —
+ write the condition.
+
+## Phase 3 — Gate and receive as a protocol (two days)
+
+TLA+ spec. Variables: `refs` (name → oid), `objects` (set), `members`,
+`epoch`; actions: `Propose`, `GateCheck`, `CAS`, `AdoptMerge`,
+`SelfMerge`. `GateCheck`'s enabling condition is now concrete: it is
+`gate(facts) = {}` — transcribe the seven denial rules from
+`ents-gate-rules` verbatim, plus the §5 rules the crate omits (refname
+recomputation, epoch). The refinement mapping between this spec and the
+crate is Phase 0.5's model reused, which is the point. Model *two* writers
+and *one* hosted store minimum; add a local store (advisory gate) as a
+second instance with no enforcement.
+
+Obligations:
+1. **Tip invariant is inductive:** "the tip of a meta-ref is signed by a
+ member authorized for that refname" — prove it's preserved by every
+ action. Pay attention to `SelfMerge` (two of your machines racing):
+ *is the merge commit itself signed in the implementation?* Check
+ `ents-sync/src` and `ents-receive/src/reconcile.rs`. If sync
+ auto-merges without a signature, the invariant breaks at exactly the
+ step the doc waves through.
+2. **Adoption preserves it:** contributor commit in ancestry, adopter
+ signature at tip. Then the sharper check: contributor's commit is
+ itself a merge of unauthorized commits — still fine? (It should be;
+ prove it, don't assume it.)
+3. **Anti-replay:** doc claims parent-hash freshness ⇒ no nonce needed.
+ Model a replayed genesis (parentless!) — CAS with old = ∅ against an
+ existing ref fails, but against a *not-yet-created* ref? Combined with
+ Phase 2 totality this should be safe; the proof forces you to state the
+ exact conjunction that makes it safe.
+4. **Epoch bootstrap (§5):** "the epoch-setting commit is the first gated
+ tip of the config ref." Model the store from empty: is there a state
+ where the gate must read the epoch from a ref whose tip is not yet
+ gated? Either the bootstrap is a real fixpoint or there's an ungated
+ first write — find which, cite `ents-gate/src/{config,policy}.rs`.
+5. **Revocation:** revoked member's key "must never validate again" — but
+ the tip invariant is checked against member state *when*? At gate time
+ or by later re-verifiers against *current* member state? A member valid
+ at admission and revoked later makes historical tips fail naive
+ re-verification. The epoch mechanism is supposed to answer this —
+ check that it actually does.
+
+## Phase 4 — Effects (one day)
+
+TLA+, building on Phase 3's state. Actions: `RefAdvance`,
+`TriggerEval` (commit *enters* the set, §6), `Enqueue` (at-least-once),
+`Execute`, `ResultPush` (a gated write like any other).
+
+Obligations:
+1. **Exactly-once observable effect from at-least-once queue:** the dedup
+ key is `(effect, refname, new_oid)` — prove result-ref idempotency under
+ duplicate delivery and executor crash-restart.
+2. **"Fires once per commit that enters the set":** model a ref deleted
+ and re-pushed to the same oid. Does the commit *re-enter* the set?
+ The spec's answer defines whether triggers are monotone; if they aren't,
+ the cached-query-advanceability argument (FF-only ⇒ results advance)
+ has a hole. This connects to the op-log deletion-as-data design.
+3. **Authorization asymmetry:** effects are admin-writable
+ (authoring an effect schedules execution); results are written by
+ executor member keys. Prove no sequence lets a non-admin cause
+ execution of content they authored *as* an effect (the Phase 2 binding
+ should close this — compose the two models and check, since this was
+ the original cross-ref replay scenario).
+
+## Phase 5 — Durability ordering (half a day, optional)
+
+TLA+ with crash faults for the hosted deployment: `TigrisWrite`,
+`PgCAS`, `Crash` at any point. Prove the invariant "no ref in Postgres
+points outside the durable object set," and show the recovery obligation
+if the write order were inverted. Small spec, high value — it's the
+invariant your whole hosted story rests on.
+
+## Deliverable
+
+One ledger table: claim · source (doc §, spec file, code file:line) ·
+model (Alloy/TLA+) · **encoded in ents-gate-rules?** (rule name / gap
+marked / gap unmarked) · verdict · assumption-or-counterexample. Plus the
+Phase 0 decision memo — which write-path model is canonical — because
+every DIVERGED entry downstream resolves against it.
+
+Rules found FALSIFIED or gap-unmarked have a natural landing spot the
+exercise didn't have before: each becomes a red test plus a new denial
+rule in `ents-gate-rules`, in the crate's own one-rule-at-a-time
+discipline. Still out of scope for the exercise itself — record them as
+ledger rows with a `rule-candidate` note.
+
+Expected yield, honestly: the first FALSIFIED is already in hand
+(cross-ref replay through the missing binding rule, Phase 0.5.1); expect
+1–3 more (likeliest: unsigned sync merges colliding with
+`unsigned_violation`, inbox binding edge, trigger re-entry), a handful of
+CONDITIONAL that become one-line doc amendments or extractor-contract
+rows, and one genuine decision (Phase 0) that no amount of model checking
+makes for you — though the rules crate has already cast its vote.
verify/ledger.adoc
@@ -1,0 +1,177 @@
+= verify: the formal-stocktake verdict ledger
+
+This is the deliverable of the formal-stocktake exercise.
+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).
+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`.
+
+[cols="3,3,2,2,1,2", options="header"]
+|===
+|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 |—
+|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.
+|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 |—
+|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 |—
+|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 |—
+|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 |—
+|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 |—
+|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 |—
+|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 |—
+|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 |—
+|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 |—
+|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 |—
+|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 |—
+|`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 |—
+|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 |—
+|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 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 |—
+|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 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 |—
+|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 |—
+|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 |—
+|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 |—
+|===
verify/tla/Durability.cfg
@@ -1,0 +1,8 @@
+\* 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,0 +1,72 @@
+--------------------------- 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,0 +1,9 @@
+\* 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,0 +1,106 @@
+---------------------------- 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,0 +1,11 @@
+\* 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,0 +1,195 @@
+---------------------------- 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
+
+=============================================================================