refactor: unify bindings, introduce claims
commit
d60856drefactor: unify bindings, introduce claims
Binding (commit/tree/delta/position/hybrid) is the single typed- reference vocabulary, homed in the anchor crate; the anchor becomes Binding::Position. Kernel gains the Claim entity (signer x binding x verdict) under refs/meta/claims/*, reusing binding reachability. docs/abstractions.adoc restated as six abstractions (typed tree, binding, ledger, claim, gate, effect); meta-ref + signed commit merged into ledger, doc-only. Effect results deliberately untouched — the claim/result unification question is recorded as open.
Reviews
No reviews of this commit yet — record a verdict below.
Start a review
Cargo.lock
@@ -1232,6 +1232,7 @@
"facet",
"facet-git-tree",
"gix",
+ "gix-object",
"proptest",
"rstest",
"tempfile",
@@ -1283,6 +1284,7 @@
name = "ents-gate"
version = "0.0.0"
dependencies = [
+ "ents-anchor",
"ents-model",
"ents-testutil",
"facet",
@@ -1350,6 +1352,7 @@
name = "ents-model"
version = "0.0.0"
dependencies = [
+ "ents-anchor",
"facet",
"facet-git-tree",
"gix",
@@ -1380,6 +1383,7 @@
name = "ents-receive"
version = "0.0.0"
dependencies = [
+ "ents-anchor",
"ents-gate",
"ents-model",
"ents-query",
docs/abstractions.adoc
@@ -9,7 +9,58 @@
== The six abstractions
-=== 1. Meta-ref
+Each answers one question: what data is (1), how data points (2), how state lives (3), what actors assert (4), how the system judges (5), how the system computes (6).
+
+=== 1. Typed tree
+
+A Rust struct annotated `#[derive(Facet)]` *is* the storage schema.
+`facet-git-tree` maps struct ↔ Git tree directly; no serialization format exists to version.
+
+Changing a struct is a storage migration, not a refactor — and a migration is itself a signed commit: rewrite the tree under the new struct, commit on the ref's old tip.
+History keeps the old encoding as archive.
+Trees stay pure struct representations — no version marker entry.
+
+Ref-level metadata that is not entity content — who, when, the signature — lives in the commit header, because the commit is already the storage unit; no reserved trailer exists.
+Refname binding needs no stored metadata at all: the refname is a total function of signed content, recomputed at verification (see 3).
+
+*Tip invariant:* the tip of a meta-ref is always readable by the binary that owns the entity type; a non-owning binary treats the tree as opaque and degrades to generic display (`git store show`); history is archival.
+(Redacted entries are the one qualification: readers surface a withheld object as a redaction marker, never an error — see Derived.)
+
+=== 2. Binding
+
+A `Binding`, homed in the anchor crate (`ents-anchor`), is the single typed-reference vocabulary into the object graph.
+Exactly five variants, each named by what it is bound to:
+
+* `Commit { commit }` — history-bound.
+* `Tree { tree, path }` — content-bound.
+The path is advisory metadata, never identity; identity is the tree oid.
+* `Delta { base_tree, head_tree, path }` — transformation-bound; the same path rule applies.
+A commit range is evidence for a Delta, never a binding itself.
+(Interdiff computation — what changed between a reviewed delta and a new one — is future work; Delta today has identity and staleness semantics only.)
+* `Position { blob, lines, commit }` — position-bound and projectable: the anchor, a durable pointer into source.
+** *Retention invariant:* the tree storing a Position embeds the anchored blob, plus a context blob of the surrounding lines, as ordinary entries — content addressing makes this free.
+The anchored content is reachable from `refs/meta/*` and survives force-push, branch deletion, and gc — no gc special-casing, and no pinned ancestry for the position's own content: the anchored commit's oid is recorded as data only; embedding, not ancestry, is what retains the content.
+(Gitlinks are not reachability edges and retain nothing; embedding is the only mechanism that works.)
+Redaction (see Derived) is the sole deliberate exception to retention.
+** *Projection:* Positions project onto newer commits at read time — blame plus fuzzy matching against the context blob; binding data is never mutated.
+When the anchored commit has been gc'd, projection degrades to context matching instead of breaking.
+* `Hybrid { commit, tree }` — relational checks: a parent commit plus a body tree.
+
+*Reachability witness rule:* every binding carries at least one witness commit, and the ledger commit that stores the binding links each witness as a second parent — ordinary git reachability then keeps the bound objects alive.
+Content-typed bindings (Tree, Delta) need a witness because a subtree is reachable only through some commit; the rule generalizes the review pin (`model.review-pin`).
+The two retention mechanisms coexist without contradiction: embedding preserves a Position's content inside the binding's own tree; witness parents keep bound objects reachable from the ledger commit that stores the binding.
+Witness is provenance, not identity: two claims binding the same Tree oid via different witnesses bind the same target.
+
+*Validity:* `revalidate(binding, state) → Valid | Stale | Unknown`, judged per variant:
+
+* Commit — valid iff the commit is reachable from the ref under evaluation.
+* Tree — valid iff the tree oid appears at the recorded (or any) path in the state under evaluation; stale otherwise.
+* Delta — valid iff evaluated against the same `(base_tree, head_tree)` pair; stale otherwise.
+* Position — the existing projection semantics, unchanged.
+
+Bindings are independent of any consumer; comments use Position, claims bind any variant.
+
+=== 3. Ledger
A ref under `refs/meta/*` is simultaneously the unit of:
@@ -29,38 +80,9 @@
Both hold the same typed trees as their canonical counterparts; only the refname rule differs.
-=== 2. Typed tree
-
-A Rust struct annotated `#[derive(Facet)]` *is* the storage schema.
-`facet-git-tree` maps struct ↔ Git tree directly; no serialization format exists to version.
-
-Changing a struct is a storage migration, not a refactor — and a migration is itself a signed commit: rewrite the tree under the new struct, commit on the ref's old tip.
-History keeps the old encoding as archive.
-Trees stay pure struct representations — no version marker entry.
-
-Ref-level metadata that is not entity content — who, when, the signature — lives in the commit header, because the commit is already the storage unit; no reserved trailer exists.
-Refname binding needs no stored metadata at all: the refname is a total function of signed content, recomputed at verification (see 4).
-
-*Tip invariant:* the tip of a meta-ref is always readable by the binary that owns the entity type; a non-owning binary treats the tree as opaque and degrades to generic display (`git store show`); history is archival.
-(Redacted entries are the one qualification: readers surface a withheld object as a redaction marker, never an error — see Derived.)
-
-=== 3. Anchor
-
-A durable pointer into source: blob, optional line range, specific commit.
-
-* *Retention invariant:* the tree storing an anchor embeds the anchored blob, plus a context blob of the surrounding lines, as ordinary entries — content addressing makes this free.
-The anchored content is reachable from `refs/meta/*` and survives force-push, branch deletion, and gc — no gc special-casing, and no pinned ancestry: the anchored commit's oid is recorded as data only.
-(Gitlinks are not reachability edges and retain nothing; embedding is the only mechanism that works.)
-Redaction (see Derived) is the sole deliberate exception to retention.
-* *Projection:* anchors project onto newer commits at read time — blame plus fuzzy matching against the context blob; anchor data is never mutated.
-When the anchored commit has been gc'd, projection degrades to context matching instead of breaking.
-
-Anchors are independent of any consumer; comments use them, but reviews, TODOs, and blame overlays can too.
-
-=== 4. Signed commit
-
Every meta-ref mutation is an author-signed commit.
-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.
+Which artifact carries the authorization evidence is a carrier question internal to the ledger, not an abstraction of its own — and the answer is the commit signature: 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.
Push certificates are demoted to transport concerns; they carry no meta-ref semantics.
A commit signature proves authorship; it does not prove placement.
@@ -84,6 +106,24 @@
On `refs/heads/*`, pushing commits you did not author is legitimate, so branch refs keep transport-level auth.
"Signed push is the only write path" is therefore retired; the invariant is the tip invariant above, which is strictly stronger where it applies.
+*Recorded, never erased:* ledger history is never rewritten; a state change is recorded as new state, never erased from old — revocation, for instance, is a state on the member entity, not a deletion (see Command surface).
+Redaction (see Derived) is the sole deliberate exception.
+
+=== 4. Claim
+
+A claim is a kernel ledger entity: signer × binding × verdict, the verdict a small closed enum — affirm, deny, note — plus an opaque `kind` the kernel never interprets.
+Kind vocabularies (review, ci, …) are package policy, never kernel enumeration.
+
+Storage is one ref per claim under `refs/meta/claims/<id>`, where the id is the genesis commit's own oid — the standard sign-then-name envelope — honoring the granularity law: claims by different signers never share a ref.
+Authorship is the standard signed-commit path; no privileged writers.
+
+The claim's ledger commit carries its binding's witness commit(s) as extra parents — the binding reachability rule (2) — reusing the review-pin retention mechanism.
+
+*Open question, deliberately unresolved:* whether claims subsume effect results.
+The `results()`/`meta()` query atoms and the result taxonomy (6) are unchanged pending that decision.
+
+*(Gap: no shipped feature consumes claims yet; reviews-over-claims is follow-up work.)*
+
=== 5. Gate
Verification is a pure function over ref-store reads:
@@ -185,7 +225,7 @@
== The loop
-Abstractions 4, 5, and 6 close into each other: signed commits are the only way state changes, the gate is the only admission judgment, effects are the only side-effect path — and effect results are signed commits by workers that are just members, judged by the same gate.
+The ledger (3), the gate (5), and the effect (6) close into each other: signed ledger commits are the only way state changes, the gate is the only admission judgment, effects are the only side-effect path — and effect results are signed commits by workers that are just members, judged by the same gate.
All state changes, human or machine, flow through one verified, audited channel, and the repository is the message bus.
This closure is the design's central property.
@@ -241,6 +281,7 @@
Extends git, not the forge; carries the `gix-` prefix per the Composition section's own rule.
* *Kernel* — `ents-model`, `ents-anchor`, `ents-gate`, `ents-effect`, `ents-query`, `ents-receive`, `ents-sync`, `ents-testutil`.
Owns mechanism: typed ref-store access and refs/meta layout primitives, signing and authorship, the entity envelope (typed trees, id assignment), gate execution and composition semantics, effect dispatch, adoption/inbox mechanics, yank, sync/receive plumbing, hooks.
+`ents-model` (the entity envelope) depends on `ents-anchor` for the binding vocabulary — a kernel-internal edge the layering check already admits.
The kernel is declarative and generic — it defines *how* any entity is stored, verified, and run, never *which* entities exist.
* *Package* — `ents-forge` (issues, comments, review/release/check types as they land), `ents-kiln` (toolchains).
Owns types and policy: the domain entity structs, and any package-specific gate or effect definitions.
@@ -274,9 +315,9 @@
* *Toolchains* — typed trees under `refs/meta/toolchains/*`; a resource effects declare, not a trigger.
The repo carries its own execution environment with provenance, as ~1KB hash-pinned manifests; only the sandbox touches the bytes.
They keep a subcommand only because import/activation logic is nontrivial; if that shrinks, the subcommand dies.
-* *Members, accounts, issues, comments* — typed trees behind meta-refs (1+2), written as signed commits (4), admitted by the gate (5).
+* *Members, accounts, issues, comments* — typed trees (1) on ledger refs, written as signed ledger commits (3), admitted by the gate (5).
Issues are the plainest instance of all: assignees, labels, and states are struct fields, so multiple assignees or custom states are schema, not platform features.
-* *Fanout indexes* — discovery without ref enumeration: `refs/meta/index/*` maps object oids to the entities anchored to them, rebuilt by an effect (6) and written via the worker's signed commit (4).
+* *Fanout indexes* — discovery without ref enumeration: `refs/meta/index/*` maps object oids to the entities anchored to them, rebuilt by an effect (6) and written via the worker's signed ledger commit (3).
Clients read the index; a stale or absent index degrades to scanning ref tips, never to wrong answers.
* *Redaction* — the deliberate exception to retention: an object-level yank, scoped to content reachable only from `refs/meta/*`.
The bytes are withheld from the store and from generated packs; the oid stays in history as evidence; signatures and the tip invariant are untouched, because verification never reads the withheld bytes.
@@ -360,7 +401,7 @@
Known edge: `updateInstead` fails on a dirty worktree, so this harness path is not perfectly identical to hosted mode — metadata behavior is.
Worktree update is frontend business, after `receive` accepts; core never touches a worktree.
-Pushes to `refs/meta/*` never touch the working tree, so all metadata behaves identically in both modes: the deployment model is an implementation detail of the data model — a direct consequence of abstractions 1 and 5.
+Pushes to `refs/meta/*` never touch the working tree, so all metadata behaves identically in both modes: the deployment model is an implementation detail of the data model — a direct consequence of the ledger (3) and the gate (5).
Bootstrap remains the known gap: an empty member list admits every push so the first member can enroll.
Hosted, this leaves "first push owns the repo" open per repository; closing it — repository creation requires an existing account — needs a server-level key→account registry, because bare repos are created on the first `info/refs` request, before any push certificate exists.
crates/kernel/ents-anchor/Cargo.toml
@@ -7,11 +7,12 @@
[dependencies]
facet = { workspace = true }
+facet-git-tree = { workspace = true }
gix = { workspace = true }
+gix-object = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
-facet-git-tree = { workspace = true }
proptest = { workspace = true }
rstest = { workspace = true }
tempfile = { workspace = true }
crates/kernel/ents-gate/Cargo.toml
@@ -17,6 +17,7 @@
thiserror = { workspace = true }
[dev-dependencies]
+ents-anchor = { workspace = true }
ents-testutil = { workspace = true }
rstest = { workspace = true }
crates/kernel/ents-model/Cargo.toml
@@ -6,6 +6,7 @@
license.workspace = true
[dependencies]
+ents-anchor = { workspace = true }
facet = { workspace = true }
facet-git-tree = { workspace = true }
gix = { workspace = true }
crates/kernel/ents-receive/Cargo.toml
@@ -18,6 +18,7 @@
thiserror = { workspace = true }
[dev-dependencies]
+ents-anchor = { workspace = true }
ents-testutil = { workspace = true }
facet = { workspace = true }
rstest = { workspace = true }
crates/kernel/ents-anchor/src/error.rs
@@ -52,6 +52,23 @@
/// (`anchor.working-tree` applies only where a working tree exists).
#[error("the repository has no working tree")]
NoWorkingTree,
+ /// Encoding or decoding a [`crate::Binding`] through the underlying
+ /// `facet-git-tree` codec failed — a malformed payload tree, or a
+ /// backend error from the `gix` object store the codec was given.
+ #[error("binding codec error: {0}")]
+ Codec(#[from] facet_git_tree::Error),
+ /// A stored tree's entry names matched none of [`crate::Binding`]'s five
+ /// variant shapes ([`crate::Binding::deserialize`]'s sniffing rule):
+ /// neither `blob`+`content` (`Position`), `base_tree` (`Delta`),
+ /// `witness`+`tree` (`Tree`), exactly `{commit, tree}` (`Hybrid`), nor
+ /// exactly `{commit}` (`Commit`).
+ #[error("tree {id} does not match any known binding shape (entries: {entries:?})")]
+ UnknownBindingShape {
+ /// The tree that could not be recognized as any binding variant.
+ id: ObjectId,
+ /// The entry names actually present in that tree.
+ entries: Vec<String>,
+ },
}
/// The `Result` alias every `ents-anchor` operation returns.
crates/kernel/ents-anchor/src/lib.rs
@@ -96,6 +96,7 @@
//! ```
mod anchor;
+mod binding;
mod error;
#[cfg(test)]
mod fixture;
@@ -103,5 +104,6 @@
mod util;
pub use anchor::{Anchor, LineRange, capture, capture_worktree, snippet};
+pub use binding::{Binding, EvalState, Validity, revalidate};
pub use error::{Error, Result};
pub use projection::{Projection, project, project_exact, project_from_context, project_worktree};
crates/kernel/ents-gate/src/verify.rs
@@ -3,7 +3,7 @@
//! `gate.bootstrap`), identical at every call site (`gate.call-sites`).
use ents_model::namespace::{self, Namespace};
-use ents_model::{Member, MemberId, MemberState, Provenance, ResultRecord};
+use ents_model::{Claim, Member, MemberId, MemberState, Provenance, ResultRecord};
use facet::{Facet, Type, UserType};
use gix::refs::FullName;
use gix_hash::ObjectId;
@@ -695,6 +695,55 @@
// history, so the all-roots walk is NEVER applied to it
// (`meta-ref.identity-binding`).
Namespace::Pin => Ok(None),
+ // A claim ref is append-once: the tip IS its own genesis, so the
+ // proposed tip's own oid — never an ancestor root — must equal the
+ // refname's segment. This deliberately does NOT use `all_roots`: a
+ // claim's parents are its binding's witness commits, whose
+ // ancestry reaches into code history exactly like a pin's
+ // (`Namespace::Pin`, above), so the all-roots walk must never run
+ // on a claim.
+ Namespace::Claim => {
+ let segment = final_segment(name);
+ let Ok(expected) = ObjectId::from_hex(segment.as_bytes()) else {
+ return Ok(binding_refusal(
+ name,
+ format!("`{segment}` is not a genesis commit oid"),
+ ));
+ };
+ if new != expected {
+ return Ok(binding_refusal(
+ name,
+ format!(
+ "the proposed tip {new} is not the genesis {expected} its refname \
+ names; a changed assertion is a new claim, never an advance of an \
+ existing one"
+ ),
+ ));
+ }
+ if commit.parents.is_empty() {
+ return Ok(binding_refusal(
+ name,
+ "a claim's tip must carry at least one parent — its binding's witness; a \
+ parentless claim retains nothing"
+ .into(),
+ ));
+ }
+ // The tip is always the genesis (rule above), so strict decode
+ // always applies, unconditionally.
+ if let Some(refusal) = strict_decode::<Claim>(objects, name, commit.tree)? {
+ return Ok(Some(refusal));
+ }
+ match field_str(objects, commit.tree, "signer")? {
+ Some(value) if value == signer.as_str() => Ok(None),
+ other => Ok(binding_refusal(
+ name,
+ format!(
+ "the claim's signer field {} does not match its actual signer {signer}",
+ other.unwrap_or_else(|| "(absent)".into())
+ ),
+ )),
+ }
+ }
// An inbox ref binds by its owner segment equal to the signer
// (already enforced by `authorize`), with the canonical suffix
// bound exactly as its canonical namespace binds — recurse on the
crates/kernel/ents-gate/tests/gate.rs
@@ -10,8 +10,12 @@
reason = "integration test: fixtures panic on setup failure"
)]
+use ents_anchor::Binding;
use ents_gate::{AdmissionKind, Config, Requirement, Update, Verdict, verify};
-use ents_model::{Effect, Member, MemberId, Provenance, ResultRecord, Status, namespace};
+use ents_model::{
+ Claim, Effect, Member, MemberId, Provenance, ResultRecord, Status,
+ claim::Verdict as ClaimVerdict, namespace,
+};
use ents_testutil::{
CommitSpec, Keypair, MemRefStore, ObjectStore, empty_tree, enroll_member, write_commit,
write_member, write_meta_entity,
@@ -543,6 +547,165 @@
);
}
+// ---------------------------------------------------------------------
+// Claims: append-once, witness-retaining, signer-bound genesis refs.
+// ---------------------------------------------------------------------
+
+/// A real serialized [`Claim`] tree over a `Binding::Commit { commit: witness
+/// }`, asserted by `signer_id` — built through `Claim::new` so these tests
+/// exercise the entity as it is actually stored, not a hand-wired tree.
+fn claim_tree(f: &Forge, signer_id: &str, verdict: ClaimVerdict, witness: ObjectId) -> ObjectId {
+ let binding = Binding::Commit { commit: witness };
+ let claim = Claim::new(
+ MemberId::new(signer_id),
+ &binding,
+ verdict,
+ "review",
+ &f.objects,
+ )
+ .expect("claim serializes");
+ facet_git_tree::serialize_into(&claim, &f.objects).expect("ser")
+}
+
+#[rstest]
+// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies)
+fn a_claim_genesis_with_a_witness_parent_passes_the_tip_invariant() {
+ let f = forge();
+ let witness = commit(&f, vec![], Some(&f.admin), 250);
+ let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness);
+ let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300);
+ let refname = namespace::claim_ref(&tip.to_string()).expect("valid");
+ expect_pass(&run(&f, &refname, Some(tip)), AdmissionKind::TipInvariant);
+}
+
+#[rstest]
+// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies)
+fn a_claim_refname_not_naming_the_tips_own_oid_is_refused() {
+ let f = forge();
+ let witness = commit(&f, vec![], Some(&f.admin), 250);
+ let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness);
+ let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300);
+ // Named for the witness rather than the claim's own genesis oid.
+ let wrong = namespace::claim_ref(&witness.to_string()).expect("valid");
+ expect_fail(&run(&f, &wrong, Some(tip)), Requirement::IdentityBinding);
+}
+
+#[rstest]
+// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies)
+fn a_claim_ref_is_append_once_and_refuses_an_advance() {
+ let f = forge();
+ let witness = commit(&f, vec![], Some(&f.admin), 250);
+ let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness);
+ let genesis = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300);
+ let refname = namespace::claim_ref(&genesis.to_string()).expect("valid");
+ f.refs.set(refname.as_ref(), genesis);
+
+ // A changed assertion is a new claim, never an advance of this one:
+ // even a well-formed, correctly signed child commit under the same
+ // ref is refused, because its own oid is not the ref's segment.
+ let advance_tree = claim_tree(&f, "admin", ClaimVerdict::Deny, witness);
+ let advance = tree_commit(&f, advance_tree, vec![genesis], Some(&f.admin), 310);
+ expect_fail(
+ &run(&f, &refname, Some(advance)),
+ Requirement::IdentityBinding,
+ );
+}
+
+#[rstest]
+// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies)
+fn a_parentless_claim_tip_is_refused() {
+ let f = forge();
+ let witness = commit(&f, vec![], Some(&f.admin), 250);
+ let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness);
+ let tip = tree_commit(&f, tree, vec![], Some(&f.admin), 300);
+ let refname = namespace::claim_ref(&tip.to_string()).expect("valid");
+ expect_fail(&run(&f, &refname, Some(tip)), Requirement::IdentityBinding);
+}
+
+#[rstest]
+// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function, role=Verifies)
+fn a_claim_signer_field_mismatching_the_actual_signer_is_refused() {
+ let f = forge();
+ let witness = commit(&f, vec![], Some(&f.admin), 250);
+ // The claim's tree names a signer other than whoever actually signed
+ // the ledger commit.
+ let tree = claim_tree(&f, "someone-else", ClaimVerdict::Affirm, witness);
+ let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300);
+ let refname = namespace::claim_ref(&tip.to_string()).expect("valid");
+ expect_fail(&run(&f, &refname, Some(tip)), Requirement::IdentityBinding);
+}
+
+/// A claim tree carrying an entry that is not a `Claim` field — for the
+/// strict-decode disjointness check.
+#[derive(facet::Facet)]
+struct ClaimPlus {
+ signer: MemberId,
+ binding: facet_git_tree::RawTree,
+ verdict: ClaimVerdict,
+ kind: String,
+ surprise: String,
+}
+
+#[rstest]
+// @relation(gate.identity-binding, meta-ref.typed-tree, scope=function, role=Verifies)
+fn strict_genesis_decode_refuses_an_unknown_claim_tree_entry() {
+ let f = forge();
+ let witness = commit(&f, vec![], Some(&f.admin), 250);
+ let binding = Binding::Commit { commit: witness };
+ let binding_tree = binding
+ .serialize_into(&f.objects)
+ .expect("binding serializes");
+ let bogus = ClaimPlus {
+ signer: MemberId::new("admin"),
+ binding: facet_git_tree::RawTree::new(binding_tree),
+ verdict: ClaimVerdict::Affirm,
+ kind: "review".into(),
+ surprise: "not a claim field".into(),
+ };
+ let tree = facet_git_tree::serialize_into(&bogus, &f.objects).expect("ser");
+ let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300);
+ let refname = namespace::claim_ref(&tip.to_string()).expect("valid");
+ expect_fail(&run(&f, &refname, Some(tip)), Requirement::IdentityBinding);
+}
+
+#[rstest]
+// @relation(model.member-provenance, meta-ref.inbox, gate.tip-signed, scope=function, role=Verifies)
+fn a_self_attested_member_falls_back_to_its_inbox_for_a_claim() {
+ let f = forge();
+ let witness = commit(&f, vec![], Some(&f.guest), 250);
+ let tree = claim_tree(&f, "guest", ClaimVerdict::Note, witness);
+ let tip = tree_commit(&f, tree, vec![witness], Some(&f.guest), 300);
+
+ // The canonical claim ref is refused — self-attested provenance is not
+ // authorized for canonical refs — with the inbox alternative surfaced.
+ let canonical = namespace::claim_ref(&tip.to_string()).expect("valid");
+ let verdict = run(&f, &canonical, Some(tip));
+ expect_fail(&verdict, Requirement::TipSigned);
+ let Verdict::Fail(refusal) = &verdict else {
+ unreachable!()
+ };
+ assert!(refusal.inbox_alternative, "detail: {refusal}");
+
+ // The identical claim under the member's own inbox segment passes: the
+ // inbox arm recurses into the synthesized canonical refname
+ // (`refs/meta/claims/<id>`) and finds the same binding.
+ let inbox =
+ namespace::inbox_ref(&MemberId::new("guest"), &format!("claims/{tip}")).expect("valid");
+ expect_pass(&run(&f, &inbox, Some(tip)), AdmissionKind::TipInvariant);
+}
+
+#[rstest]
+// @relation(meta-ref.inbox, gate.tip-signed, scope=function, role=Verifies)
+fn an_inbox_claim_by_its_owner_with_a_correct_binding_passes() {
+ let f = forge();
+ let witness = commit(&f, vec![], Some(&f.admin), 250);
+ let tree = claim_tree(&f, "admin", ClaimVerdict::Affirm, witness);
+ let tip = tree_commit(&f, tree, vec![witness], Some(&f.admin), 300);
+ let inbox =
+ namespace::inbox_ref(&MemberId::new("admin"), &format!("claims/{tip}")).expect("valid");
+ expect_pass(&run(&f, &inbox, Some(tip)), AdmissionKind::TipInvariant);
+}
+
// ---------------------------------------------------------------------
// Owner mutation: an advance is keyed to ownership.
// ---------------------------------------------------------------------
crates/kernel/ents-model/src/error.rs
@@ -16,6 +16,18 @@
#[source]
source: gix::validate::reference::name::Error,
},
+
+ /// A value handed to one of this crate's `FromStr` implementations
+ /// (for example, [`crate::claim::Verdict`]'s kebab-case parse) did not
+ /// match any known form.
+ #[error("invalid argument: {0}")]
+ InvalidArgument(String),
+
+ /// A [`crate::Claim`]'s binding could not be serialized into or
+ /// deserialized from the object store
+ /// ([`crate::claim::Claim::new`], [`crate::claim::Claim::binding`]).
+ #[error("binding operation failed: {0}")]
+ Anchor(#[from] ents_anchor::Error),
}
/// The `Result` alias every `ents-model` operation returns.
crates/kernel/ents-model/src/lib.rs
@@ -40,6 +40,13 @@
//! depend on); see `ents-kiln`'s `Toolchain`.
//! - `model.redaction` — [`Redaction`].
//! - `model.account` — [`Account`].
+//!
+//! [`Claim`] (`refs/meta/claims/*`, [`namespace::claim_ref`]) is also
+//! defined here: a signer × binding × verdict × opaque-kind entity, the
+//! shared building block a comment's thread state, a review's approval, or
+//! a CI result can each be built from without the kernel enumerating what
+//! any of them mean. No spec id covers it yet — see the [`claim`] module's
+//! own doc comment.
//! - `meta-ref.namespace`, `meta-ref.granularity` — [`namespace`].
//! - `meta-ref.inbox` — [`namespace`]: the `refs/meta/inbox/<member>/<id>`
//! half ([`namespace::inbox_ref`], [`namespace::inbox_owner`],
@@ -92,6 +99,7 @@
//! ```
mod account;
+pub mod claim;
mod effect;
mod error;
mod member;
@@ -100,6 +108,7 @@
mod result;
pub use account::Account;
+pub use claim::Claim;
pub use effect::Effect;
pub use error::{Error, Result};
pub use member::{Member, MemberId, MemberState, Provenance};
@@ -123,6 +132,8 @@
/// different runtime-supplied field data.
#[rstest]
#[case::account(Account::SHAPE.type_identifier, "Account")]
+ #[case::claim(Claim::SHAPE.type_identifier, "Claim")]
+ #[case::claim_verdict(claim::Verdict::SHAPE.type_identifier, "Verdict")]
#[case::effect(Effect::SHAPE.type_identifier, "Effect")]
#[case::member(Member::SHAPE.type_identifier, "Member")]
#[case::redaction(Redaction::SHAPE.type_identifier, "Redaction")]
crates/kernel/ents-model/src/namespace.rs
@@ -323,6 +323,30 @@
build(format!("refs/meta/redactions/{id}"))
}
+/// The ref holding one claim — `refs/meta/claims/<id>`, where `<id>` is
+/// the claim's own genesis commit oid: the sign-then-name envelope, same
+/// as a comment or an issue. Unlike those, this ref is append-once — the
+/// tip IS the genesis; a changed assertion is a new claim, never an
+/// advance.
+///
+/// A claim's ledger commit carries its binding's witness commits as
+/// parents, so its ancestry deliberately reaches into code history exactly
+/// as a review pin's does — the gate's parentless-roots walk must never
+/// apply to a claim, the same carve-out [`review_pin_ref`]'s doc already
+/// describes for pins.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::namespace;
+///
+/// let name = namespace::claim_ref("deadbeef").expect("valid");
+/// assert_eq!(name.as_bstr(), "refs/meta/claims/deadbeef");
+/// ```
+pub fn claim_ref(id: &str) -> Result<FullName> {
+ build(format!("refs/meta/claims/{id}"))
+}
+
/// Which entity namespace a `refs/meta/*` refname falls in.
///
/// The inbox and self-run namespaces classify as their own variants even
@@ -367,6 +391,11 @@
Account,
/// The fixed `refs/meta/config` ref.
Config,
+ /// `refs/meta/claims/*` — one ref per claim ([`claim_ref`]), append-once:
+ /// the tip IS the genesis. A claim's ancestry reaches into code history
+ /// through its binding's witness commits, exactly as [`Namespace::Pin`]'s
+ /// does, so the all-roots walk must never apply to it either.
+ Claim,
/// Under `refs/meta/*`, but in no namespace this build of the vocabulary
/// knows. `model.extensibility` requires a stock server to carry entity
/// types it cannot parse, so the gate and `receive` must be able to
@@ -423,6 +452,7 @@
"toolchains" => Some(Namespace::Toolchain),
"redactions" => Some(Namespace::Redaction),
"inbox" => Some(Namespace::Inbox),
+ "claims" => Some(Namespace::Claim),
_ => Some(Namespace::Unknown),
}
}
@@ -481,6 +511,7 @@
#[case::toolchain("refs/meta/toolchains/rust-stable", Some(Namespace::Toolchain))]
#[case::redaction("refs/meta/redactions/abc", Some(Namespace::Redaction))]
#[case::inbox("refs/meta/inbox/jdc/issue-42", Some(Namespace::Inbox))]
+ #[case::claim("refs/meta/claims/deadbeef", Some(Namespace::Claim))]
#[case::account("refs/meta/account", Some(Namespace::Account))]
#[case::config("refs/meta/config", Some(Namespace::Config))]
#[case::outside_meta("refs/heads/main", None)]
@@ -555,6 +586,7 @@
inbox_ref(&id, "issue-42").expect("valid"),
toolchain_ref("rust-stable").expect("valid"),
redaction_ref("abc").expect("valid"),
+ claim_ref("deadbeef").expect("valid"),
];
for name in built {
assert!(
crates/kernel/ents-receive/src/lib.rs
@@ -100,7 +100,8 @@
pub use outcome::{Mode, Outcome, TxResult};
pub use proposal::{Proposal, RefTransition, TransportAuth};
pub use propose::{
- Identity, propose_delete, propose_entity, propose_entity_with_pin, propose_genesis, propose_pin,
+ Identity, propose_delete, propose_entity, propose_entity_with_pin, propose_genesis,
+ propose_genesis_retaining, propose_pin,
};
pub use receive::receive;
pub use reconcile::reconcile;
crates/kernel/ents-receive/src/propose.rs
@@ -250,6 +250,104 @@
Ok((name, outcome))
}
+/// Create a hash-identified entity whose genesis commit carries `retain` as
+/// its parents, the claim-creation path: sign-then-name exactly as
+/// [`propose_genesis`], except the genesis is not parentless. This
+/// generalizes [`propose_pin`]'s retention linkage (`model.review-pin`) to
+/// an entity-carrying commit — a claim's binding supplies its own witness
+/// commits as `retain`, so the claim's own ledger commit keeps the bound
+/// objects reachable without a separate pin ref. The only difference from
+/// [`propose_genesis`] is that parent list; [`signed_commit`] is reused
+/// unchanged.
+///
+/// There is no "advance" counterpart: a claim ref is append-once (the tip
+/// IS the genesis), so unlike [`propose_entity`] or [`propose_pin`], no
+/// second function exists here to move an existing ref forward — a changed
+/// assertion is a new claim, proposed fresh through this same function.
+///
+/// # Errors
+///
+/// See [`propose_genesis`] — identical.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::{Claim, MemberId, Provenance, claim::Verdict, namespace};
+/// use ents_receive::{Identity, Mode, NullEventSink, TxResult, propose_genesis_retaining};
+/// use ents_testutil::{CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit};
+/// use gix_ref_store::RefStoreRead as _;
+///
+/// let refs = MemRefStore::default();
+/// let objects = ObjectStore::default();
+/// let admin = Keypair::from_seed(1);
+/// enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100);
+///
+/// // The commit under claim — the content the claim keeps reachable.
+/// let tree = ents_testutil::empty_tree(&objects);
+/// let witness = write_commit(
+/// &objects,
+/// &CommitSpec { tree, parents: vec![], message: "witnessed work".into(), seconds: 200 },
+/// None,
+/// );
+///
+/// let binding = ents_anchor::Binding::Commit { commit: witness };
+/// let claim = Claim::new(MemberId::new("admin"), &binding, Verdict::Affirm, "review", &objects)
+/// .expect("serialize binding");
+///
+/// let identity = Identity {
+/// actor: gix::actor::Signature {
+/// name: "admin".into(),
+/// email: "admin@ents.test".into(),
+/// time: gix::date::Time { seconds: 300, offset: 0 },
+/// },
+/// author: None,
+/// sign: &|payload| admin.sign(payload),
+/// };
+///
+/// let (name, outcome) = propose_genesis_retaining(
+/// &refs, &objects, &NullEventSink, &claim, &[witness],
+/// |oid| namespace::claim_ref(&oid.to_string()), &identity, "Claim on witness",
+/// Mode::Advisory,
+/// )
+/// .expect("reaches an outcome");
+/// assert_eq!(outcome.result, TxResult::Applied);
+/// let tip = refs.get(name.as_ref()).expect("read").expect("ref exists");
+/// let stored = objects.get(&tip).expect("commit stored");
+/// let gix_object::Object::Commit(commit) = stored else { panic!("not a commit") };
+/// assert!(commit.parents.iter().any(|parent| *parent == witness));
+/// ```
+#[expect(
+ clippy::too_many_arguments,
+ reason = "mirrors propose_genesis's shape, plus the retained-parents slice that is this \
+ function's whole point"
+)]
+pub fn propose_genesis_retaining<T: for<'facet> facet::Facet<'facet>>(
+ refs: &dyn RefStore,
+ objects: &(impl Find + Write),
+ events: &dyn EventSink,
+ entity: &T,
+ retain: &[gix_hash::ObjectId],
+ name_from_oid: impl FnOnce(gix_hash::ObjectId) -> ents_model::Result<FullName>,
+ identity: &Identity<'_>,
+ subject: &str,
+ mode: Mode,
+) -> Result<(FullName, Outcome)> {
+ let tree = facet_git_tree::serialize_into(entity, objects)?;
+ let tip = signed_commit(objects, tree, retain.to_vec(), identity, subject)?;
+ let name = name_from_oid(tip).map_err(|source| crate::Error::Model { source })?;
+ let proposal = Proposal {
+ transitions: vec![RefTransition {
+ name: name.clone(),
+ old: None,
+ new: Some(tip),
+ }],
+ objects: vec![tip],
+ auth: None,
+ };
+ let outcome = crate::receive::receive(refs, objects, events, &proposal, mode)?;
+ Ok((name, outcome))
+}
+
/// Advance the retention pin at `name` to keep `retain` (and its ancestry)
/// reachable (`model.review-pin`): a signed commit carrying the empty tree
/// — a pin's commits anchor other content's reachability and carry no
crates/kernel/ents-anchor/src/binding.rs
@@ -1,0 +1,1137 @@
+//! [`Binding`]: the single typed-reference vocabulary into the object
+//! graph — history-bound, content-bound, transformation-bound,
+//! position-bound, or relational — plus read-time [`revalidate`] of a
+//! binding against a revision under evaluation.
+//!
+//! [`Binding::Position`] is [`crate::Anchor`] unchanged: every anchor is a
+//! binding, but not every binding is an anchor. The other four variants
+//! name a target without a line-level position at all — a commit itself, a
+//! tree (optionally at an advisory path), a `(base_tree, head_tree)`
+//! transformation, or a commit-plus-tree pair.
+//!
+//! Every binding carries at least one *witness* — a commit whose ancestry
+//! reaches the bound object(s) — so a claim's ledger commit can carry the
+//! witness as an extra parent and keep the bound objects reachable. The
+//! witness is provenance, not identity: [`Binding::same_target`] ignores it
+//! entirely.
+//!
+//! `Binding` itself is a plain Rust enum, not a `facet::Facet` type — the
+//! generic derive would encode an enum externally tagged (a tree with one
+//! variant-named entry), which would not round-trip the existing anchor
+//! storage format byte for byte. Instead [`Binding::serialize_into`] and
+//! [`Binding::deserialize`] hand-encode each variant as a *bare* tree (no
+//! variant tag), inferring the variant back from which entry names are
+//! present on read.
+
+use facet::Facet;
+use gix::ObjectId;
+use gix_object::{Find, Kind, TreeRef, Write};
+
+use crate::anchor::Anchor;
+use crate::error::{Error, Result};
+use crate::projection::{Projection, project};
+use crate::util::resolve_commit;
+
+/// The single typed-reference vocabulary into the object graph: what a
+/// claim, comment, or review is *about*.
+///
+/// # Examples
+///
+/// ```
+/// use ents_anchor::Binding;
+///
+/// let commit = gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap();
+/// let binding = Binding::Commit { commit };
+/// assert_eq!(binding.witnesses(), vec![commit]);
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Binding {
+ /// History-bound: the commit itself is the target. Its own witness is
+ /// itself.
+ Commit {
+ /// The commit named by this binding.
+ commit: ObjectId,
+ },
+ /// Content-bound: a tree, independent of any particular commit or
+ /// path. `path` is advisory metadata only — identity is `tree` alone,
+ /// [`Binding::same_target`] ignores `path` — because the same tree can
+ /// sit at different paths across history without changing what is
+ /// bound.
+ Tree {
+ /// The bound tree's identity.
+ tree: ObjectId,
+ /// Where `tree` was found when this binding was made, retained for
+ /// display only — never part of identity.
+ path: String,
+ /// A commit whose ancestry reaches `tree`.
+ witness: ObjectId,
+ },
+ /// Transformation-bound: the pair `(base_tree, head_tree)` — an edit,
+ /// not either endpoint alone. A commit range is *evidence for* a
+ /// `Delta`; it is never a binding itself. `path` is advisory, same as
+ /// [`Binding::Tree`]'s.
+ Delta {
+ /// The tree before the transformation.
+ base_tree: ObjectId,
+ /// The tree after the transformation.
+ head_tree: ObjectId,
+ /// Where the transformation was found when this binding was made,
+ /// retained for display only — never part of identity.
+ path: String,
+ /// A commit whose ancestry reaches `base_tree`.
+ base_witness: ObjectId,
+ /// A commit whose ancestry reaches `head_tree`.
+ head_witness: ObjectId,
+ },
+ /// Position-bound: [`Anchor`] verbatim — a durable pointer to specific
+ /// lines (or a whole file) at a specific blob, retained and
+ /// projectable exactly as [`crate::project`] describes.
+ Position(Anchor),
+ /// Relational: a parent commit plus a body tree, bound as a pair
+ /// distinct from either [`Binding::Commit`] or [`Binding::Tree`] alone.
+ Hybrid {
+ /// The parent commit.
+ commit: ObjectId,
+ /// The body tree.
+ tree: ObjectId,
+ },
+}
+
+/// `[u8; 20]` payload for [`Binding::Commit`], encoded bare (no variant
+/// tag) so [`Binding::serialize_into`] reproduces the exact byte layout
+/// [`Binding::deserialize`] sniffs on.
+#[derive(Debug, Clone, Facet)]
+struct CommitPayload {
+ commit: [u8; 20],
+}
+
+/// `[u8; 20]`/`String` payload for [`Binding::Tree`], encoded bare.
+#[derive(Debug, Clone, Facet)]
+struct TreePayload {
+ tree: [u8; 20],
+ path: String,
+ witness: [u8; 20],
+}
+
+/// `[u8; 20]`/`String` payload for [`Binding::Delta`], encoded bare.
+#[derive(Debug, Clone, Facet)]
+struct DeltaPayload {
+ base_tree: [u8; 20],
+ head_tree: [u8; 20],
+ path: String,
+ base_witness: [u8; 20],
+ head_witness: [u8; 20],
+}
+
+/// `[u8; 20]` payload for [`Binding::Hybrid`], encoded bare.
+#[derive(Debug, Clone, Facet)]
+struct HybridPayload {
+ commit: [u8; 20],
+ tree: [u8; 20],
+}
+
+/// `id`'s raw 20 bytes, for embedding in a `Facet`-derived payload struct —
+/// [`Anchor`]'s own `commit`/`blob` pattern, applied to every oid field a
+/// [`Binding`] variant carries.
+fn oid_bytes(id: ObjectId) -> [u8; 20] {
+ let mut bytes = [0u8; 20];
+ bytes.copy_from_slice(id.as_slice());
+ bytes
+}
+
+impl Binding {
+ /// Every commit whose ancestry must reach the bound object(s) for this
+ /// binding to stay alive — never empty: exactly the commit itself for
+ /// [`Binding::Commit`] and [`Binding::Hybrid`], the anchor's own commit
+ /// for [`Binding::Position`], the recorded witness for
+ /// [`Binding::Tree`], and both witnesses for [`Binding::Delta`].
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_anchor::Binding;
+ ///
+ /// let base_witness = gix::ObjectId::from_hex(b"1111111111111111111111111111111111111111").unwrap();
+ /// let head_witness = gix::ObjectId::from_hex(b"2222222222222222222222222222222222222222").unwrap();
+ /// let binding = Binding::Delta {
+ /// base_tree: gix::ObjectId::from_hex(b"3333333333333333333333333333333333333333").unwrap(),
+ /// head_tree: gix::ObjectId::from_hex(b"4444444444444444444444444444444444444444").unwrap(),
+ /// path: "src/lib.rs".to_owned(),
+ /// base_witness,
+ /// head_witness,
+ /// };
+ /// assert_eq!(binding.witnesses(), vec![base_witness, head_witness]);
+ /// ```
+ #[must_use]
+ pub fn witnesses(&self) -> Vec<ObjectId> {
+ match self {
+ Self::Commit { commit } | Self::Hybrid { commit, .. } => vec![*commit],
+ Self::Tree { witness, .. } => vec![*witness],
+ Self::Delta {
+ base_witness,
+ head_witness,
+ ..
+ } => vec![*base_witness, *head_witness],
+ Self::Position(anchor) => vec![anchor.commit()],
+ }
+ }
+
+ /// Whether `self` and `other` name the same target, ignoring
+ /// provenance: derived [`PartialEq`] is full structural equality (every
+ /// field, including advisory `path` and `witness`/`base_witness`/
+ /// `head_witness`), while `same_target` compares identity only —
+ /// `commit` for [`Binding::Commit`]; the tree oid alone for
+ /// [`Binding::Tree`] (`path` and `witness` ignored); the
+ /// `(base_tree, head_tree)` pair for [`Binding::Delta`] (`path` and
+ /// both witnesses ignored); the anchor's `(blob, lines, commit)` for
+ /// [`Binding::Position`]; `(commit, tree)` for [`Binding::Hybrid`].
+ /// Bindings of different variants are never the same target, even when
+ /// they happen to name overlapping objects.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_anchor::Binding;
+ ///
+ /// let tree = gix::ObjectId::from_hex(b"5555555555555555555555555555555555555555").unwrap();
+ /// let a = Binding::Tree {
+ /// tree,
+ /// path: "a.rs".to_owned(),
+ /// witness: gix::ObjectId::from_hex(b"6666666666666666666666666666666666666666").unwrap(),
+ /// };
+ /// let b = Binding::Tree {
+ /// tree,
+ /// path: "b.rs".to_owned(),
+ /// witness: gix::ObjectId::from_hex(b"7777777777777777777777777777777777777777").unwrap(),
+ /// };
+ /// assert_ne!(a, b, "different path and witness: not structurally equal");
+ /// assert!(a.same_target(&b), "same tree oid: same target regardless of path/witness");
+ /// ```
+ #[must_use]
+ pub fn same_target(&self, other: &Self) -> bool {
+ match (self, other) {
+ (Self::Commit { commit: a }, Self::Commit { commit: b }) => a == b,
+ (Self::Tree { tree: a, .. }, Self::Tree { tree: b, .. }) => a == b,
+ (
+ Self::Delta {
+ base_tree: base_a,
+ head_tree: head_a,
+ ..
+ },
+ Self::Delta {
+ base_tree: base_b,
+ head_tree: head_b,
+ ..
+ },
+ ) => base_a == base_b && head_a == head_b,
+ (Self::Position(a), Self::Position(b)) => {
+ a.blob() == b.blob() && a.lines == b.lines && a.commit() == b.commit()
+ }
+ (
+ Self::Hybrid {
+ commit: ca,
+ tree: ta,
+ },
+ Self::Hybrid {
+ commit: cb,
+ tree: tb,
+ },
+ ) => ca == cb && ta == tb,
+ _ => false,
+ }
+ }
+
+ /// Write `self` into `store` as a *bare* tree — no variant tag —
+ /// keyed by the variant's own field names: [`Binding::Position`]
+ /// writes exactly what [`facet_git_tree::serialize_into`] has always
+ /// written for an [`Anchor`] (the existing stored format, unchanged
+ /// byte for byte); every other variant writes its payload struct's
+ /// fields the same way. [`Binding::deserialize`] recovers the variant
+ /// by sniffing which entry names are present, since no discriminant is
+ /// stored.
+ ///
+ /// `store` takes the same bound `facet_git_tree::serialize_into` does:
+ /// any `gix` object-write sink — a real repository's object database,
+ /// an in-memory [`facet_git_tree::ObjectStore`], or any other
+ /// `gix_object::Write` implementation.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_anchor::Binding;
+ /// use facet_git_tree::ObjectStore;
+ ///
+ /// let store = ObjectStore::default();
+ /// let binding = Binding::Commit {
+ /// commit: gix::ObjectId::from_hex(b"8888888888888888888888888888888888888888").unwrap(),
+ /// };
+ /// let root = binding.serialize_into(&store).expect("serialize");
+ /// let back = Binding::deserialize(&root, &store).expect("deserialize");
+ /// assert_eq!(back, binding);
+ /// ```
+ ///
+ /// # Errors
+ ///
+ /// [`Error::Codec`] when the underlying `facet-git-tree` write fails
+ /// (a backend error from `store`).
+ pub fn serialize_into<W>(&self, store: &W) -> Result<ObjectId>
+ where
+ W: Write + ?Sized,
+ {
+ match self {
+ Self::Commit { commit } => {
+ let payload = CommitPayload {
+ commit: oid_bytes(*commit),
+ };
+ Ok(facet_git_tree::serialize_into(&payload, store)?)
+ }
+ Self::Tree {
+ tree,
+ path,
+ witness,
+ } => {
+ let payload = TreePayload {
+ tree: oid_bytes(*tree),
+ path: path.clone(),
+ witness: oid_bytes(*witness),
+ };
+ Ok(facet_git_tree::serialize_into(&payload, store)?)
+ }
+ Self::Delta {
+ base_tree,
+ head_tree,
+ path,
+ base_witness,
+ head_witness,
+ } => {
+ let payload = DeltaPayload {
+ base_tree: oid_bytes(*base_tree),
+ head_tree: oid_bytes(*head_tree),
+ path: path.clone(),
+ base_witness: oid_bytes(*base_witness),
+ head_witness: oid_bytes(*head_witness),
+ };
+ Ok(facet_git_tree::serialize_into(&payload, store)?)
+ }
+ Self::Position(anchor) => Ok(facet_git_tree::serialize_into(anchor, store)?),
+ Self::Hybrid { commit, tree } => {
+ let payload = HybridPayload {
+ commit: oid_bytes(*commit),
+ tree: oid_bytes(*tree),
+ };
+ Ok(facet_git_tree::serialize_into(&payload, store)?)
+ }
+ }
+ }
+
+ /// Read the [`Binding`] stored bare (no variant tag) at `id` in
+ /// `store`, inferring the variant from which entry names the tree
+ /// holds: `blob`+`content` → [`Binding::Position`] (decoded as
+ /// [`Anchor`]); `base_tree` → [`Binding::Delta`]; `witness` (with
+ /// `tree`) → [`Binding::Tree`]; exactly `{commit, tree}` →
+ /// [`Binding::Hybrid`]; exactly `{commit}` → [`Binding::Commit`].
+ ///
+ /// `store` takes the same bound `facet_git_tree::deserialize` does:
+ /// any `gix` object-read source.
+ ///
+ /// # Errors
+ ///
+ /// [`Error::Codec`] when the recognized shape fails to decode;
+ /// [`Error::UnknownBindingShape`] when the entry names match none of
+ /// the five shapes; [`Error::Object`] when `id` cannot be read as a
+ /// tree at all.
+ pub fn deserialize<F>(id: &ObjectId, store: &F) -> Result<Self>
+ where
+ F: Find + ?Sized,
+ {
+ let entries = tree_entries(id, store)?;
+ let names: std::collections::BTreeSet<&str> =
+ entries.iter().map(|(name, _)| name.as_str()).collect();
+
+ if names.contains("blob") && names.contains("content") {
+ let anchor: Anchor = facet_git_tree::deserialize(id, store)?;
+ return Ok(Self::Position(anchor));
+ }
+ if names.contains("base_tree") {
+ let payload: DeltaPayload = facet_git_tree::deserialize(id, store)?;
+ return Ok(Self::Delta {
+ base_tree: ObjectId::from_bytes_or_panic(&payload.base_tree),
+ head_tree: ObjectId::from_bytes_or_panic(&payload.head_tree),
+ path: payload.path,
+ base_witness: ObjectId::from_bytes_or_panic(&payload.base_witness),
+ head_witness: ObjectId::from_bytes_or_panic(&payload.head_witness),
+ });
+ }
+ if names.contains("witness") && names.contains("tree") {
+ let payload: TreePayload = facet_git_tree::deserialize(id, store)?;
+ return Ok(Self::Tree {
+ tree: ObjectId::from_bytes_or_panic(&payload.tree),
+ path: payload.path,
+ witness: ObjectId::from_bytes_or_panic(&payload.witness),
+ });
+ }
+ if names.len() == 2 && names.contains("commit") && names.contains("tree") {
+ let payload: HybridPayload = facet_git_tree::deserialize(id, store)?;
+ return Ok(Self::Hybrid {
+ commit: ObjectId::from_bytes_or_panic(&payload.commit),
+ tree: ObjectId::from_bytes_or_panic(&payload.tree),
+ });
+ }
+ if names.len() == 1 && names.contains("commit") {
+ let payload: CommitPayload = facet_git_tree::deserialize(id, store)?;
+ return Ok(Self::Commit {
+ commit: ObjectId::from_bytes_or_panic(&payload.commit),
+ });
+ }
+
+ Err(Error::UnknownBindingShape {
+ id: *id,
+ entries: entries.into_iter().map(|(name, _)| name).collect(),
+ })
+ }
+}
+
+/// The name and object id of every entry directly under the tree at `id` —
+/// this crate's own copy of `facet-git-tree`'s private `find_tree_entries`,
+/// needed because [`Binding::deserialize`] must inspect entry names *before*
+/// it knows which `Facet` type to hand `facet_git_tree::deserialize`.
+fn tree_entries<F>(id: &ObjectId, store: &F) -> Result<Vec<(String, ObjectId)>>
+where
+ F: Find + ?Sized,
+{
+ let mut buf = Vec::new();
+ let data = store
+ .try_find(id, &mut buf)
+ .map_err(|error| Error::Object(error.to_string()))?
+ .ok_or_else(|| Error::Object(format!("object {id} not found")))?;
+ if data.kind != Kind::Tree {
+ return Err(Error::Object(format!("object {id} is not a tree")));
+ }
+ let tree_ref = TreeRef::from_bytes(data.data, data.object_hash)
+ .map_err(|error| Error::Object(error.to_string()))?;
+ let mut out = Vec::with_capacity(tree_ref.entries.len());
+ for entry in &tree_ref.entries {
+ let name = std::str::from_utf8(entry.filename)
+ .map_err(|_error| Error::Object("tree entry name is not valid UTF-8".to_owned()))?;
+ out.push((name.to_owned(), entry.oid.to_owned()));
+ }
+ Ok(out)
+}
+
+/// How up to date a [`Binding`] is as of the revision [`EvalState`]
+/// describes, as computed by [`revalidate`].
+///
+/// # Examples
+///
+/// ```
+/// use ents_anchor::Validity;
+///
+/// assert_ne!(Validity::Valid, Validity::Stale);
+/// ```
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Validity {
+ /// The binding's target is still present as of the state under
+ /// evaluation.
+ Valid,
+ /// The binding's target no longer holds as of the state under
+ /// evaluation, though the check itself completed.
+ Stale,
+ /// Whether the binding still holds could not be determined — an
+ /// unresolvable revision, a missing object, or (for a
+ /// [`Binding::Delta`]) no delta pair supplied to check against.
+ Unknown,
+}
+
+/// The minimum state [`revalidate`] needs beyond the [`Binding`] itself: the
+/// revision every variant but [`Binding::Delta`] is checked against, plus
+/// the tree pair a [`Binding::Delta`] is checked against.
+///
+/// # Examples
+///
+/// ```
+/// use ents_anchor::EvalState;
+///
+/// let state = EvalState { at: "HEAD", delta: None };
+/// assert_eq!(state.at, "HEAD");
+/// ```
+#[derive(Debug, Clone, Copy)]
+pub struct EvalState<'a> {
+ /// The revision (hex id, ref name, or revspec) the binding is being
+ /// evaluated against.
+ pub at: &'a str,
+ /// The `(base_tree, head_tree)` pair a [`Binding::Delta`] is being
+ /// evaluated against — irrelevant to every other variant.
+ pub delta: Option<(ObjectId, ObjectId)>,
+}
+
+/// Check `binding`'s [`Validity`] against `state`.
+///
+/// Per-variant semantics: [`Binding::Commit`] is
+/// [`Validity::Valid`] iff the commit is `state.at` itself or one of its
+/// ancestors; [`Binding::Tree`] is [`Validity::Valid`] iff the tree appears
+/// — at the recorded path, checked first as a fast path, or anywhere else
+/// in `state.at`'s tree; [`Binding::Delta`] is [`Validity::Valid`] iff
+/// `state.delta` is exactly the `(base_tree, head_tree)` pair, and
+/// [`Validity::Unknown`] whenever `state.delta` is `None` (the pair being
+/// evaluated is caller context this crate has no other way to learn);
+/// [`Binding::Position`] relocates [`crate::project`]'s existing four-outcome
+/// taxonomy onto three (`Current`/`Relocated` → `Valid`,
+/// `Outdated`/`Deleted` → `Stale`); [`Binding::Hybrid`] — not one of the
+/// four listed above — is given the natural composition: `Valid` iff both
+/// its commit (checked as [`Binding::Commit`] would be) and its tree
+/// (checked as [`Binding::Tree`] would be, with no recorded path so only
+/// the anywhere-in-the-tree search applies) are `Valid`, `Unknown` if
+/// either check is, `Stale` otherwise.
+///
+/// # Errors
+///
+/// Propagates a [`crate::project`] error other than an unresolvable
+/// revision (which becomes [`Validity::Unknown`] instead, since it means
+/// the state under evaluation could not be evaluated at all, not that the
+/// binding itself is broken), and any I/O or decode error surfaced while
+/// walking `state.at`'s tree for a [`Binding::Tree`] or [`Binding::Hybrid`]
+/// check.
+///
+/// # Examples
+///
+/// ```
+/// use ents_anchor::{Binding, EvalState, Validity};
+///
+/// # let dir = tempfile::tempdir().expect("tempdir");
+/// # std::process::Command::new("git").arg("init").arg("-q").arg(dir.path()).status().unwrap();
+/// # std::fs::write(dir.path().join("file.txt"), "a\n").unwrap();
+/// # std::process::Command::new("git").arg("-C").arg(dir.path()).args(["add", "-A"]).status().unwrap();
+/// # std::process::Command::new("git").arg("-C").arg(dir.path())
+/// # .args(["-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", "one"])
+/// # .status().unwrap();
+/// let repo = gix::open(dir.path()).expect("open");
+/// let commit = repo.head_id().expect("head").detach();
+/// let binding = Binding::Commit { commit };
+/// let state = EvalState { at: "HEAD", delta: None };
+/// assert_eq!(ents_anchor::revalidate(&repo, &binding, &state).unwrap(), Validity::Valid);
+/// ```
+pub fn revalidate(
+ repo: &gix::Repository,
+ binding: &Binding,
+ state: &EvalState<'_>,
+) -> Result<Validity> {
+ match binding {
+ Binding::Commit { commit } => Ok(commit_validity(repo, *commit, state.at)),
+ Binding::Tree { tree, path, .. } => tree_validity(repo, *tree, path, state.at),
+ Binding::Delta {
+ base_tree,
+ head_tree,
+ ..
+ } => Ok(delta_validity(*base_tree, *head_tree, state.delta)),
+ Binding::Position(anchor) => position_validity(repo, anchor, state.at),
+ Binding::Hybrid { commit, tree } => {
+ let commit_v = commit_validity(repo, *commit, state.at);
+ let tree_v = tree_reachable(repo, *tree, state.at)?;
+ Ok(combine(commit_v, tree_v))
+ }
+ }
+}
+
+/// [`Validity::Unknown`] if either input is; [`Validity::Valid`] iff both
+/// are; [`Validity::Stale`] otherwise — [`Binding::Hybrid`]'s composition of
+/// its commit check and its tree check.
+fn combine(a: Validity, b: Validity) -> Validity {
+ if a == Validity::Unknown || b == Validity::Unknown {
+ Validity::Unknown
+ } else if a == Validity::Valid && b == Validity::Valid {
+ Validity::Valid
+ } else {
+ Validity::Stale
+ }
+}
+
+/// [`Binding::Commit`]'s (and [`Binding::Hybrid`]'s commit half's)
+/// [`Validity`]: [`Validity::Unknown`] when `commit` is absent from the odb
+/// or `at` cannot be resolved, else [`Validity::Valid`] iff `commit` is `at`
+/// itself or one of its ancestors (via the repository's own merge-base
+/// machinery, the same idiom `ents_forge` uses for review-target ancestry),
+/// else [`Validity::Stale`].
+fn commit_validity(repo: &gix::Repository, commit: ObjectId, at: &str) -> Validity {
+ if !repo.has_object(commit) {
+ return Validity::Unknown;
+ }
+ let Ok(target) = resolve_commit(repo, at) else {
+ return Validity::Unknown;
+ };
+ let target_id = target.id().detach();
+ if commit == target_id
+ || repo
+ .merge_base(commit, target_id)
+ .is_ok_and(|base| base.detach() == commit)
+ {
+ Validity::Valid
+ } else {
+ Validity::Stale
+ }
+}
+
+/// [`Binding::Tree`]'s [`Validity`]: the fast path (`tree` at `path` in
+/// `at`'s own tree) first, falling back to [`tree_reachable`]'s recursive
+/// anywhere-in-the-tree search.
+fn tree_validity(repo: &gix::Repository, tree: ObjectId, path: &str, at: &str) -> Result<Validity> {
+ let Ok(commit) = resolve_commit(repo, at) else {
+ return Ok(Validity::Unknown);
+ };
+ let root = commit
+ .tree()
+ .map_err(|error| Error::Object(error.to_string()))?;
+ if let Ok(Some(entry)) = root.lookup_entry_by_path(path)
+ && entry.mode().is_tree()
+ && entry.object_id() == tree
+ {
+ return Ok(Validity::Valid);
+ }
+ if tree_contains(&root, tree)? {
+ Ok(Validity::Valid)
+ } else {
+ Ok(Validity::Stale)
+ }
+}
+
+/// [`Binding::Hybrid`]'s tree-half [`Validity`]: [`tree_validity`] without a
+/// recorded path to try as a fast path first — [`Binding::Hybrid`] carries
+/// none.
+fn tree_reachable(repo: &gix::Repository, tree: ObjectId, at: &str) -> Result<Validity> {
+ let Ok(commit) = resolve_commit(repo, at) else {
+ return Ok(Validity::Unknown);
+ };
+ let root = commit
+ .tree()
+ .map_err(|error| Error::Object(error.to_string()))?;
+ if tree_contains(&root, tree)? {
+ Ok(Validity::Valid)
+ } else {
+ Ok(Validity::Stale)
+ }
+}
+
+/// Whether `target` is `tree` itself or the id of any subtree reachable
+/// from it, at any depth — git trees form a DAG with no cycles (an entry
+/// cannot name its own not-yet-written parent by content-addressed id), so
+/// this recursion terminates on any well-formed tree with no explicit depth
+/// guard needed.
+fn tree_contains(tree: &gix::Tree<'_>, target: ObjectId) -> Result<bool> {
+ if tree.id() == target {
+ return Ok(true);
+ }
+ for entry in tree.iter() {
+ let entry = entry.map_err(|error| Error::Object(error.to_string()))?;
+ if !entry.mode().is_tree() {
+ continue;
+ }
+ if entry.object_id() == target {
+ return Ok(true);
+ }
+ let subtree = entry
+ .object()
+ .map_err(|error| Error::Object(error.to_string()))?
+ .try_into_tree()
+ .map_err(|error| Error::Object(error.to_string()))?;
+ if tree_contains(&subtree, target)? {
+ return Ok(true);
+ }
+ }
+ Ok(false)
+}
+
+/// [`Binding::Delta`]'s [`Validity`]: identity comparison against
+/// `state.delta` only, per `revalidate`'s spec — no repository access at
+/// all, since a `Delta`'s evidence (the tree pair under evaluation) is
+/// caller context, not something derivable from a single revision.
+fn delta_validity(
+ base_tree: ObjectId,
+ head_tree: ObjectId,
+ delta: Option<(ObjectId, ObjectId)>,
+) -> Validity {
+ match delta {
+ Some(pair) if pair == (base_tree, head_tree) => Validity::Valid,
+ Some(_) => Validity::Stale,
+ None => Validity::Unknown,
+ }
+}
+
+/// [`Binding::Position`]'s [`Validity`]: [`crate::project`]'s four outcomes
+/// collapsed to three, with an unresolvable `at` reported as
+/// [`Validity::Unknown`] rather than propagated — every other
+/// [`crate::project`] error is a clearer sign of a broken anchor than of an
+/// unevaluable state, so those propagate.
+fn position_validity(repo: &gix::Repository, anchor: &Anchor, at: &str) -> Result<Validity> {
+ match project(repo, anchor, at) {
+ Ok(Projection::Current | Projection::Relocated { .. }) => Ok(Validity::Valid),
+ Ok(Projection::Outdated { .. } | Projection::Deleted) => Ok(Validity::Stale),
+ Err(Error::Resolve(_)) => Ok(Validity::Unknown),
+ Err(other) => Err(other),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ clippy::panic,
+ reason = "unit test"
+ )]
+
+ use facet_git_tree::ObjectStore;
+ use rstest::rstest;
+
+ use super::*;
+ use crate::LineRange;
+ use crate::anchor::capture;
+ use crate::fixture::{commit_all, numbered, repo};
+
+ fn hex(byte: u8) -> ObjectId {
+ let hex_digit = format!("{byte:x}");
+ let full = hex_digit.repeat(40);
+ ObjectId::from_hex(full.as_bytes()).unwrap()
+ }
+
+ fn sample_tree() -> Binding {
+ Binding::Tree {
+ tree: hex(1),
+ path: "src/lib.rs".to_owned(),
+ witness: hex(2),
+ }
+ }
+
+ fn sample_delta() -> Binding {
+ Binding::Delta {
+ base_tree: hex(3),
+ head_tree: hex(4),
+ path: "src/lib.rs".to_owned(),
+ base_witness: hex(5),
+ head_witness: hex(6),
+ }
+ }
+
+ fn sample_commit() -> Binding {
+ Binding::Commit { commit: hex(7) }
+ }
+
+ fn sample_hybrid() -> Binding {
+ Binding::Hybrid {
+ commit: hex(8),
+ tree: hex(9),
+ }
+ }
+
+ fn sample_position() -> Binding {
+ let dir = repo();
+ std::fs::write(dir.path().join("file.txt"), numbered(1..=5)).unwrap();
+ commit_all(dir.path(), "one");
+ let git_repo = gix::open(dir.path()).unwrap();
+ let anchor = capture(&git_repo, "HEAD", "file.txt", None).unwrap();
+ Binding::Position(anchor)
+ }
+
+ #[rstest]
+ #[case::commit(sample_commit())]
+ #[case::tree(sample_tree())]
+ #[case::delta(sample_delta())]
+ #[case::position(sample_position())]
+ #[case::hybrid(sample_hybrid())]
+ fn every_variant_round_trips_through_serialize_and_deserialize(#[case] binding: Binding) {
+ let store = ObjectStore::default();
+ let root = binding.serialize_into(&store).expect("serialize");
+ let back = Binding::deserialize(&root, &store).expect("deserialize");
+ assert_eq!(back, binding);
+ }
+
+ // @relation is intentionally absent: `binding.*` has no spec id yet.
+ #[test]
+ fn sniffing_rejects_an_unknown_entry_set() {
+ let store = ObjectStore::default();
+ let root = gix_object::Write::write(&store, &gix_object::Tree { entries: vec![] }).unwrap();
+ let error = Binding::deserialize(&root, &store).unwrap_err();
+ assert!(matches!(error, Error::UnknownBindingShape { .. }));
+ }
+
+ #[rstest]
+ #[case::commit(sample_commit(), vec![hex(7)])]
+ #[case::tree(sample_tree(), vec![hex(2)])]
+ #[case::delta(sample_delta(), vec![hex(5), hex(6)])]
+ #[case::hybrid(sample_hybrid(), vec![hex(8)])]
+ fn witnesses_are_never_empty_and_match_the_spec(
+ #[case] binding: Binding,
+ #[case] expected: Vec<ObjectId>,
+ ) {
+ assert_eq!(binding.witnesses(), expected);
+ assert!(!binding.witnesses().is_empty());
+ }
+
+ #[test]
+ fn witnesses_of_a_position_is_the_anchors_own_commit() {
+ let binding = sample_position();
+ let Binding::Position(anchor) = &binding else {
+ panic!("sample_position must build a Position");
+ };
+ assert_eq!(binding.witnesses(), vec![anchor.commit()]);
+ }
+
+ #[test]
+ fn same_target_ignores_path_and_witness_for_tree() {
+ let a = Binding::Tree {
+ tree: hex(1),
+ path: "a.rs".to_owned(),
+ witness: hex(2),
+ };
+ let b = Binding::Tree {
+ tree: hex(1),
+ path: "b.rs".to_owned(),
+ witness: hex(9),
+ };
+ assert_ne!(a, b);
+ assert!(a.same_target(&b));
+ }
+
+ #[test]
+ fn same_target_ignores_path_and_witnesses_for_delta() {
+ let a = Binding::Delta {
+ base_tree: hex(3),
+ head_tree: hex(4),
+ path: "a.rs".to_owned(),
+ base_witness: hex(5),
+ head_witness: hex(6),
+ };
+ let b = Binding::Delta {
+ base_tree: hex(3),
+ head_tree: hex(4),
+ path: "b.rs".to_owned(),
+ base_witness: hex(1),
+ head_witness: hex(2),
+ };
+ assert_ne!(a, b);
+ assert!(a.same_target(&b));
+ }
+
+ #[test]
+ fn same_target_distinguishes_different_variants_naming_overlapping_objects() {
+ let commit = Binding::Commit { commit: hex(1) };
+ let hybrid = Binding::Hybrid {
+ commit: hex(1),
+ tree: hex(2),
+ };
+ assert!(!commit.same_target(&hybrid));
+ }
+
+ #[test]
+ fn same_target_of_position_compares_blob_lines_and_commit() {
+ let dir = repo();
+ std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
+ commit_all(dir.path(), "one");
+ let git_repo = gix::open(dir.path()).unwrap();
+ let anchor = capture(
+ &git_repo,
+ "HEAD",
+ "file.txt",
+ Some(LineRange { start: 3, end: 4 }),
+ )
+ .unwrap();
+ assert!(
+ Binding::Position(anchor.clone()).same_target(&Binding::Position(anchor.clone())),
+ "an anchor is always the same target as an identical copy of itself"
+ );
+
+ std::fs::write(dir.path().join("file.txt"), numbered(1..=12)).unwrap();
+ commit_all(dir.path(), "two");
+ let git_repo = gix::open(dir.path()).unwrap();
+ let other = capture(
+ &git_repo,
+ "HEAD",
+ "file.txt",
+ Some(LineRange { start: 3, end: 4 }),
+ )
+ .unwrap();
+ assert!(!Binding::Position(anchor).same_target(&Binding::Position(other)));
+ }
+
+ #[test]
+ fn revalidate_commit_is_valid_for_self_and_ancestor_and_stale_otherwise() {
+ let dir = repo();
+ std::fs::write(dir.path().join("file.txt"), "one\n").unwrap();
+ commit_all(dir.path(), "one");
+ let git_repo = gix::open(dir.path()).unwrap();
+ let first = git_repo.head_id().unwrap().detach();
+
+ std::fs::write(dir.path().join("file.txt"), "two\n").unwrap();
+ commit_all(dir.path(), "two");
+ let git_repo = gix::open(dir.path()).unwrap();
+ let second = git_repo.head_id().unwrap().detach();
+
+ let state = EvalState {
+ at: "HEAD",
+ delta: None,
+ };
+ assert_eq!(
+ revalidate(&git_repo, &Binding::Commit { commit: second }, &state).unwrap(),
+ Validity::Valid
+ );
+ assert_eq!(
+ revalidate(&git_repo, &Binding::Commit { commit: first }, &state).unwrap(),
+ Validity::Valid,
+ "an ancestor of the revision under evaluation is still valid"
+ );
+
+ // A commit that only exists on an unrelated, unmerged branch is
+ // neither `second` nor an ancestor of it — evaluated against
+ // `second` explicitly, since `HEAD` itself is about to move to the
+ // unrelated branch.
+ std::process::Command::new("git")
+ .arg("-C")
+ .arg(dir.path())
+ .args(["checkout", "-q", "--orphan", "other"])
+ .status()
+ .unwrap();
+ std::fs::write(dir.path().join("other.txt"), "other\n").unwrap();
+ commit_all(dir.path(), "unrelated");
+ let git_repo = gix::open(dir.path()).unwrap();
+ let unrelated = git_repo.head_id().unwrap().detach();
+
+ let second_hex = second.to_string();
+ let state_at_second = EvalState {
+ at: &second_hex,
+ delta: None,
+ };
+ assert_eq!(
+ revalidate(
+ &git_repo,
+ &Binding::Commit { commit: unrelated },
+ &state_at_second
+ )
+ .unwrap(),
+ Validity::Stale
+ );
+ }
+
+ #[test]
+ fn revalidate_commit_is_unknown_when_absent_or_revision_unresolvable() {
+ let dir = repo();
+ std::fs::write(dir.path().join("file.txt"), "one\n").unwrap();
+ commit_all(dir.path(), "one");
+ let git_repo = gix::open(dir.path()).unwrap();
+
+ let missing = Binding::Commit {
+ commit: gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap(),
+ };
+ let state = EvalState {
+ at: "HEAD",
+ delta: None,
+ };
+ assert_eq!(
+ revalidate(&git_repo, &missing, &state).unwrap(),
+ Validity::Unknown
+ );
+
+ let head = Binding::Commit {
+ commit: git_repo.head_id().unwrap().detach(),
+ };
+ let unresolvable = EvalState {
+ at: "not-a-revision",
+ delta: None,
+ };
+ assert_eq!(
+ revalidate(&git_repo, &head, &unresolvable).unwrap(),
+ Validity::Unknown
+ );
+ }
+
+ #[test]
+ fn revalidate_tree_checks_the_recorded_path_then_falls_back_to_any_path() {
+ let dir = repo();
+ std::fs::create_dir(dir.path().join("sub")).unwrap();
+ std::fs::write(dir.path().join("sub/file.txt"), "one\n").unwrap();
+ commit_all(dir.path(), "one");
+ let git_repo = gix::open(dir.path()).unwrap();
+ let commit = git_repo.head_id().unwrap().detach();
+ let root = git_repo.find_commit(commit).unwrap().tree().unwrap();
+ let sub_tree = root
+ .lookup_entry_by_path("sub")
+ .unwrap()
+ .unwrap()
+ .object_id();
+
+ let state = EvalState {
+ at: "HEAD",
+ delta: None,
+ };
+
+ // Fast path: recorded at its real path.
+ let at_path = Binding::Tree {
+ tree: sub_tree,
+ path: "sub".to_owned(),
+ witness: commit,
+ };
+ assert_eq!(
+ revalidate(&git_repo, &at_path, &state).unwrap(),
+ Validity::Valid
+ );
+
+ // Anywhere fallback: recorded at a wrong path, but the same tree
+ // still sits somewhere in the target's tree.
+ let wrong_path = Binding::Tree {
+ tree: sub_tree,
+ path: "not/the/real/path".to_owned(),
+ witness: commit,
+ };
+ assert_eq!(
+ revalidate(&git_repo, &wrong_path, &state).unwrap(),
+ Validity::Valid
+ );
+
+ let missing = Binding::Tree {
+ tree: gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap(),
+ path: "sub".to_owned(),
+ witness: commit,
+ };
+ assert_eq!(
+ revalidate(&git_repo, &missing, &state).unwrap(),
+ Validity::Stale
+ );
+ }
+
+ #[test]
+ fn revalidate_tree_is_unknown_when_the_revision_is_unresolvable() {
+ let dir = repo();
+ std::fs::write(dir.path().join("file.txt"), "one\n").unwrap();
+ commit_all(dir.path(), "one");
+ let git_repo = gix::open(dir.path()).unwrap();
+ let binding = sample_tree();
+ let state = EvalState {
+ at: "not-a-revision",
+ delta: None,
+ };
+ assert_eq!(
+ revalidate(&git_repo, &binding, &state).unwrap(),
+ Validity::Unknown
+ );
+ }
+
+ #[rstest]
+ #[case::matching_pair(Some((hex(3), hex(4))), Validity::Valid)]
+ #[case::different_pair(Some((hex(1), hex(2))), Validity::Stale)]
+ #[case::no_pair(None, Validity::Unknown)]
+ fn revalidate_delta_compares_identity_against_state_delta_only(
+ #[case] delta: Option<(ObjectId, ObjectId)>,
+ #[case] expected: Validity,
+ ) {
+ let dir = repo();
+ std::fs::write(dir.path().join("file.txt"), "one\n").unwrap();
+ commit_all(dir.path(), "one");
+ let git_repo = gix::open(dir.path()).unwrap();
+
+ let binding = sample_delta();
+ let state = EvalState { at: "HEAD", delta };
+ assert_eq!(revalidate(&git_repo, &binding, &state).unwrap(), expected);
+ }
+
+ #[test]
+ fn revalidate_position_maps_current_and_relocated_to_valid() {
+ let dir = repo();
+ std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
+ commit_all(dir.path(), "one");
+ let git_repo = gix::open(dir.path()).unwrap();
+ let anchor = capture(&git_repo, "HEAD", "file.txt", None).unwrap();
+ let binding = Binding::Position(anchor);
+ let state = EvalState {
+ at: "HEAD",
+ delta: None,
+ };
+ assert_eq!(
+ revalidate(&git_repo, &binding, &state).unwrap(),
+ Validity::Valid
+ );
+ }
+
+ #[test]
+ fn revalidate_position_maps_deleted_to_stale() {
+ let dir = repo();
+ std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
+ commit_all(dir.path(), "one");
+ let git_repo = gix::open(dir.path()).unwrap();
+ let anchor = capture(&git_repo, "HEAD", "file.txt", None).unwrap();
+
+ std::fs::remove_file(dir.path().join("file.txt")).unwrap();
+ std::fs::write(dir.path().join("other.txt"), "x\n").unwrap();
+ commit_all(dir.path(), "two");
+ let git_repo = gix::open(dir.path()).unwrap();
+
+ let binding = Binding::Position(anchor);
+ let state = EvalState {
+ at: "HEAD",
+ delta: None,
+ };
+ assert_eq!(
+ revalidate(&git_repo, &binding, &state).unwrap(),
+ Validity::Stale
+ );
+ }
+
+ #[test]
+ fn revalidate_position_is_unknown_when_the_revision_is_unresolvable() {
+ let dir = repo();
+ std::fs::write(dir.path().join("file.txt"), numbered(1..=10)).unwrap();
+ commit_all(dir.path(), "one");
+ let git_repo = gix::open(dir.path()).unwrap();
+ let anchor = capture(&git_repo, "HEAD", "file.txt", None).unwrap();
+
+ let binding = Binding::Position(anchor);
+ let state = EvalState {
+ at: "not-a-revision",
+ delta: None,
+ };
+ assert_eq!(
+ revalidate(&git_repo, &binding, &state).unwrap(),
+ Validity::Unknown
+ );
+ }
+
+ #[test]
+ fn revalidate_hybrid_is_valid_iff_both_commit_and_tree_check_out() {
+ let dir = repo();
+ std::fs::write(dir.path().join("file.txt"), "one\n").unwrap();
+ commit_all(dir.path(), "one");
+ let git_repo = gix::open(dir.path()).unwrap();
+ let commit = git_repo.head_id().unwrap().detach();
+ let tree = git_repo
+ .find_commit(commit)
+ .unwrap()
+ .tree()
+ .unwrap()
+ .id()
+ .detach();
+
+ let state = EvalState {
+ at: "HEAD",
+ delta: None,
+ };
+ let valid = Binding::Hybrid { commit, tree };
+ assert_eq!(
+ revalidate(&git_repo, &valid, &state).unwrap(),
+ Validity::Valid
+ );
+
+ let stale_tree = Binding::Hybrid {
+ commit,
+ tree: gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap(),
+ };
+ assert_eq!(
+ revalidate(&git_repo, &stale_tree, &state).unwrap(),
+ Validity::Stale
+ );
+
+ let unknown_commit = Binding::Hybrid {
+ commit: gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567").unwrap(),
+ tree,
+ };
+ assert_eq!(
+ revalidate(&git_repo, &unknown_commit, &state).unwrap(),
+ Validity::Unknown
+ );
+ }
+}
crates/kernel/ents-anchor/tests/binding_roundtrip.rs
@@ -1,0 +1,169 @@
+//! Round-trip fixture for [`Binding::deserialize`]/[`Binding::serialize_into`]
+//! against the *existing* stored anchor format, captured from the current
+//! code before this phase's changes: a byte-for-byte guarantee that
+//! `Binding::Position` decodes, and re-encodes, the exact tree
+//! `facet_git_tree::serialize_into(&anchor, ...)` has always produced.
+//!
+//! Every oid below is content-addressed from the bytes reconstructed in
+//! this file, so a single corrupted byte in any hex constant — or in the
+//! reconstructed content itself — fails an `assert_eq!` here rather than
+//! silently drifting.
+
+#![allow(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ clippy::panic,
+ reason = "integration test"
+)]
+
+use ents_anchor::{Binding, LineRange};
+use facet_git_tree::ObjectStore;
+use gix_object::tree::{Entry, EntryKind, EntryMode};
+use gix_object::{Kind, Tree, Write as _};
+
+/// The root tree's oid, exactly as the current code produces it for an
+/// anchor at `file.txt`, lines 3..=4, in a 10-line numbered file.
+const ROOT: &str = "002b45e6824a3a9723ebc245104426c43ccf91be";
+
+/// `blob` entry: [`ents_anchor::Anchor::blob`]'s 20 raw bytes, embedded —
+/// equal to `CONTENT_OID`'s own bytes, by content addressing
+/// (`anchor.retention`).
+const BLOB_ENTRY_OID: &str = "4a3354a7c472ad13ffd9fb0e30d9a8fd66efd0b5";
+const BLOB_ENTRY_RAW: &str = "fa2da6e55caa540725b55c04d13f1e42b4c725ce";
+
+/// `commit` entry: [`ents_anchor::Anchor::commit`]'s 20 raw bytes, embedded
+/// (an arbitrary, best-effort commit id — it need not resolve to a real
+/// object in this fixture).
+const COMMIT_ENTRY_OID: &str = "a662e760fcd5534f59d7c7d72e401a646ac1a88f";
+const COMMIT_ENTRY_RAW: &str = "92cf309c4efcf8698a5bd8f82d56f68fd38cc963";
+
+/// `content` entry: the anchored blob's own bytes, `"line 1\n"` through
+/// `"line 10\n"`.
+const CONTENT_OID: &str = "fa2da6e55caa540725b55c04d13f1e42b4c725ce";
+/// `context` entry: a three-line margin around lines 3..=4, `"line 1\n"`
+/// through `"line 7\n"`.
+const CONTEXT_OID: &str = "734156dc73cccb9703067e6366f3d09266e090dd";
+
+const LINES_OID: &str = "b76e73cdb409fa346566f18e8f054dbdf04a7304";
+const LINES_SOME_OID: &str = "3433ad944c71f4b15c4de9e87568ae4cf03feb50";
+const LINES_END_OID: &str = "bf0d87ab1b2b0ec1a11a3973d2845b42413d9767";
+const LINES_START_OID: &str = "e440e5c842586965a7fb77deda2eca68612b1f53";
+
+const PATH_OID: &str = "4c330738cc959751fb6760a91a50d9e58cfe5cb9";
+
+fn oid(hex: &str) -> gix::ObjectId {
+ gix::ObjectId::from_hex(hex.as_bytes()).expect("valid hex oid")
+}
+
+fn numbered(range: std::ops::RangeInclusive<u32>) -> String {
+ range.map(|n| format!("line {n}\n")).collect()
+}
+
+fn write_blob(store: &ObjectStore, bytes: &[u8]) -> gix::ObjectId {
+ store.write_buf(Kind::Blob, bytes).expect("write blob")
+}
+
+fn write_tree(store: &ObjectStore, mut entries: Vec<Entry>) -> gix::ObjectId {
+ entries.sort();
+ store.write(&Tree { entries }).expect("write tree")
+}
+
+fn entry(name: &str, kind: EntryKind, id: gix::ObjectId) -> Entry {
+ Entry {
+ mode: EntryMode::from(kind),
+ filename: name.into(),
+ oid: id,
+ }
+}
+
+/// Reconstruct the fixture's object set with `gix_object::Tree` +
+/// `gix_object::Write`, asserting every intermediate oid along the way
+/// (item 1 of the fixture contract) before returning the finished store.
+fn build_fixture() -> (gix::ObjectId, ObjectStore) {
+ let store = ObjectStore::default();
+
+ let blob_entry = write_blob(&store, oid(BLOB_ENTRY_RAW).as_slice());
+ assert_eq!(blob_entry.to_string(), BLOB_ENTRY_OID);
+
+ let commit_entry = write_blob(&store, oid(COMMIT_ENTRY_RAW).as_slice());
+ assert_eq!(commit_entry.to_string(), COMMIT_ENTRY_OID);
+
+ let content = write_blob(&store, numbered(1..=10).as_bytes());
+ assert_eq!(content.to_string(), CONTENT_OID);
+
+ let context = write_blob(&store, numbered(1..=7).as_bytes());
+ assert_eq!(context.to_string(), CONTEXT_OID);
+
+ let end = write_blob(&store, b"4");
+ assert_eq!(end.to_string(), LINES_END_OID);
+ let start = write_blob(&store, b"3");
+ assert_eq!(start.to_string(), LINES_START_OID);
+ let some = write_tree(
+ &store,
+ vec![
+ entry("end", EntryKind::Blob, end),
+ entry("start", EntryKind::Blob, start),
+ ],
+ );
+ assert_eq!(some.to_string(), LINES_SOME_OID);
+ let lines = write_tree(&store, vec![entry("some", EntryKind::Tree, some)]);
+ assert_eq!(lines.to_string(), LINES_OID);
+
+ let path = write_blob(&store, b"file.txt");
+ assert_eq!(path.to_string(), PATH_OID);
+
+ let root = write_tree(
+ &store,
+ vec![
+ entry("blob", EntryKind::Blob, blob_entry),
+ entry("commit", EntryKind::Blob, commit_entry),
+ entry("content", EntryKind::Blob, content),
+ entry("context", EntryKind::Blob, context),
+ entry("lines", EntryKind::Tree, lines),
+ entry("path", EntryKind::Blob, path),
+ ],
+ );
+ (root, store)
+}
+
+/// Item 1 + 2 of the fixture contract: every reconstructed object's oid —
+/// including the root's — matches the value the current code produces.
+/// Content addressing means a single corrupted byte anywhere above fails
+/// this assertion (or one of `build_fixture`'s own, reached first).
+#[test]
+fn reconstructing_the_fixture_reproduces_every_recorded_oid() {
+ let (root, _store) = build_fixture();
+ assert_eq!(root.to_string(), ROOT);
+}
+
+/// Item 3: `Binding::deserialize` decodes the fixture as
+/// `Binding::Position`, recovering exactly the `Anchor` the current stored
+/// format has always encoded.
+#[test]
+fn the_fixture_deserializes_as_a_position_binding() {
+ let (root, store) = build_fixture();
+
+ let binding = Binding::deserialize(&root, &store).expect("deserialize");
+ let Binding::Position(anchor) = binding else {
+ panic!("the fixture must decode as Binding::Position");
+ };
+ assert_eq!(anchor.path, "file.txt");
+ assert_eq!(anchor.lines, Some(LineRange { start: 3, end: 4 }));
+ assert_eq!(anchor.content, numbered(1..=10).into_bytes());
+ assert_eq!(anchor.context, numbered(1..=7).into_bytes());
+ assert_eq!(anchor.blob().to_string(), CONTENT_OID);
+ assert_eq!(anchor.commit().to_string(), COMMIT_ENTRY_RAW);
+}
+
+/// Item 4: re-encoding the decoded binding into a fresh store reproduces
+/// the fixture's root oid exactly — the existing anchor storage format is
+/// unchanged, byte for byte, now that it decodes through `Binding`.
+#[test]
+fn re_encoding_reproduces_the_fixture_root_byte_for_byte() {
+ let (root, store) = build_fixture();
+ let binding = Binding::deserialize(&root, &store).expect("deserialize");
+
+ let fresh = ObjectStore::default();
+ let re_root = binding.serialize_into(&fresh).expect("serialize");
+ assert_eq!(re_root.to_string(), ROOT);
+}
crates/kernel/ents-model/src/claim.rs
@@ -1,0 +1,238 @@
+//! The Claim entity: a signer's verdict on an [`ents_anchor::Binding`],
+//! under an opaque kind — the kernel's shared building block for a
+//! comment's thread state, a review's approval, a CI result, or any other
+//! package's assertion about the object graph, without the kernel ever
+//! enumerating what those assertions mean.
+//!
+//! No spec id is assigned to this entity yet — claim vocabularies are
+//! being specified separately — so nothing in this module carries an
+//! `@relation` marker.
+
+use facet::Facet;
+use facet_git_tree::RawTree;
+use gix_object::{Find, Write};
+
+use crate::error::{Error, Result};
+use crate::member::MemberId;
+
+/// A claim's verdict: what its signer asserts about its binding.
+///
+/// Parses from and renders as its kebab-case convention names (`affirm`,
+/// `deny`, `note`), the same strings every surface shows — modeled on
+/// `ents_forge::review::Verdict`.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::claim::Verdict;
+///
+/// let verdict: Verdict = "deny".parse().expect("known verdict");
+/// assert_eq!(verdict, Verdict::Deny);
+/// assert_eq!(verdict.to_string(), "deny");
+/// assert!("maybe".parse::<Verdict>().is_err());
+/// ```
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Facet)]
+#[repr(u8)]
+pub enum Verdict {
+ /// The claim affirms its binding.
+ Affirm,
+ /// The claim denies its binding.
+ Deny,
+ /// Judgment withheld: the claim exists for its kind and context alone.
+ Note,
+}
+
+impl std::str::FromStr for Verdict {
+ type Err = Error;
+
+ fn from_str(text: &str) -> Result<Self> {
+ match text {
+ "affirm" => Ok(Self::Affirm),
+ "deny" => Ok(Self::Deny),
+ "note" => Ok(Self::Note),
+ other => Err(Error::InvalidArgument(format!(
+ "unknown verdict {other:?}: expected affirm, deny, or note"
+ ))),
+ }
+ }
+}
+
+impl std::fmt::Display for Verdict {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(match self {
+ Self::Affirm => "affirm",
+ Self::Deny => "deny",
+ Self::Note => "note",
+ })
+ }
+}
+
+/// A signer's verdict on a binding, under an opaque kind.
+///
+/// A `Claim` lives one-ref-per-claim at `refs/meta/claims/<id>`
+/// ([`crate::namespace::claim_ref`]), where `<id>` is the claim's own
+/// genesis commit oid: the repository's standard sign-then-name envelope,
+/// exactly like a comment or an issue. Unlike those, a claim ref is
+/// append-once — the tip IS the genesis, and a changed assertion is a new
+/// claim, never an advance — so `signer` here must equal the ledger
+/// commit's actual signer, which the gate binds
+/// (`meta-ref.identity-binding`'s natural-key shape, applied to a
+/// genesis-only ref).
+///
+/// `binding` embeds an [`ents_anchor::Binding`] by tree id, opaque to this
+/// crate exactly as `ents-forge`'s `Comment::anchor` embeds an anchor —
+/// [`Claim::new`] and [`Claim::binding`] do the round trip so no caller
+/// hand-wires the tree. The claim's ledger commit carries the
+/// binding's own [`ents_anchor::Binding::witnesses`] as its parents, which
+/// is what keeps the bound objects reachable; building that commit is a
+/// caller concern (`ents_receive`), not this struct's.
+///
+/// `kind` is a plain opaque string: kind vocabularies (`review`, `ci`, or
+/// anything else a package invents) are package policy, never kernel
+/// enumeration — this crate never matches on it.
+///
+/// # Examples
+///
+/// ```
+/// use ents_anchor::Binding;
+/// use ents_model::MemberId;
+/// use ents_model::claim::{Claim, Verdict};
+/// use facet_git_tree::ObjectStore;
+///
+/// let store = ObjectStore::default();
+/// let commit = gix::ObjectId::from_hex(b"0123456789abcdef0123456789abcdef01234567")
+/// .expect("valid hex");
+/// let binding = Binding::Commit { commit };
+///
+/// let claim = Claim::new(MemberId::new("jdc"), &binding, Verdict::Affirm, "review", &store)
+/// .expect("serialize");
+/// assert_eq!(claim.kind, "review");
+///
+/// // The binding round-trips through the claim unchanged.
+/// let back = claim.binding(&store).expect("deserialize");
+/// assert_eq!(back, binding);
+///
+/// // The claim itself is an ordinary typed tree.
+/// let root = facet_git_tree::serialize_into(&claim, &store).expect("serialize claim");
+/// let claim_back: Claim = facet_git_tree::deserialize(&root, &store).expect("deserialize claim");
+/// assert_eq!(claim_back, claim);
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq, Facet)]
+pub struct Claim {
+ /// The member who signed this claim — must equal the ledger commit's
+ /// actual signer, which the gate binds.
+ pub signer: MemberId,
+ /// The serialized [`ents_anchor::Binding`]'s tree, embedded by tree id
+ /// ([`Claim::new`], [`Claim::binding`] do the round trip).
+ pub binding: RawTree,
+ /// What the signer asserts about the binding.
+ pub verdict: Verdict,
+ /// An opaque kind string — package policy, never kernel vocabulary.
+ pub kind: String,
+}
+
+impl Claim {
+ /// Build a claim of `binding`, serializing it into `store` and
+ /// recording the resulting tree by id.
+ ///
+ /// # Errors
+ ///
+ /// [`Error::Anchor`] if `binding` cannot be serialized into `store`.
+ pub fn new<W: Write + ?Sized>(
+ signer: MemberId,
+ binding: &ents_anchor::Binding,
+ verdict: Verdict,
+ kind: impl Into<String>,
+ store: &W,
+ ) -> Result<Self> {
+ let root = binding.serialize_into(store)?;
+ Ok(Self {
+ signer,
+ binding: RawTree::new(root),
+ verdict,
+ kind: kind.into(),
+ })
+ }
+
+ /// Read this claim's binding back out of `store`.
+ ///
+ /// # Errors
+ ///
+ /// [`Error::Anchor`] if the recorded tree cannot be read or does not
+ /// match any known [`ents_anchor::Binding`] shape.
+ pub fn binding<F: Find + ?Sized>(&self, store: &F) -> Result<ents_anchor::Binding> {
+ Ok(ents_anchor::Binding::deserialize(
+ &self.binding.oid(),
+ store,
+ )?)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ clippy::panic,
+ reason = "unit test"
+ )]
+
+ use ents_anchor::Binding;
+ use facet::{Facet as _, Type, UserType};
+ use facet_git_tree::{ObjectStore, deserialize, serialize_into};
+ use gix_hash::ObjectId;
+ use rstest::rstest;
+
+ use super::*;
+
+ fn hex(byte: u8) -> ObjectId {
+ let hex_digit = format!("{byte:x}");
+ let full = hex_digit.repeat(40);
+ ObjectId::from_hex(full.as_bytes()).unwrap()
+ }
+
+ #[rstest]
+ #[case::affirm(Verdict::Affirm)]
+ #[case::deny(Verdict::Deny)]
+ #[case::note(Verdict::Note)]
+ fn claim_round_trips_with_every_verdict(#[case] verdict: Verdict) {
+ let store = ObjectStore::default();
+ let binding = Binding::Commit { commit: hex(1) };
+ let claim = Claim::new(MemberId::new("jdc"), &binding, verdict, "review", &store)
+ .expect("new claim");
+
+ let root = serialize_into(&claim, &store).expect("serialize");
+ let back: Claim = deserialize(&root, &store).expect("deserialize");
+ assert_eq!(back, claim);
+ }
+
+ #[rstest]
+ #[case::commit(Binding::Commit { commit: hex(1) })]
+ #[case::tree(Binding::Tree {
+ tree: hex(2),
+ path: "src/lib.rs".to_owned(),
+ witness: hex(3),
+ })]
+ fn new_and_binding_round_trip_an_actual_binding(#[case] binding: Binding) {
+ let store = ObjectStore::default();
+ let claim = Claim::new(
+ MemberId::new("jdc"),
+ &binding,
+ Verdict::Affirm,
+ "review",
+ &store,
+ )
+ .expect("new claim");
+ let back = claim.binding(&store).expect("read binding back");
+ assert_eq!(back, binding);
+ }
+
+ #[test]
+ fn field_order_is_signer_binding_verdict_kind() {
+ let Type::User(UserType::Struct(struct_ty)) = Claim::SHAPE.ty else {
+ panic!("Claim must reflect as a struct");
+ };
+ let names: Vec<_> = struct_ty.fields.iter().map(|f| f.name).collect();
+ assert_eq!(names, vec!["signer", "binding", "verdict", "kind"]);
+ }
+}