gate: add ents-gate, the pure verify function at all three call sites
commit 45ecffd
gate: add ents-gate, the pure verify function at all three call sites
One verb, verify(), pure over RefStoreRead + gix_object::Find: tip
signature (SSHSIG from the commit’s gpgsig header, verified in-process
via ssh-key — never a push certificate), refname binding via the
Ents-Ref trailer, DAG fast-forward, the verification epoch (a
gate-owned Config typed tree on refs/meta/config; the epoch-setting
commit is itself the first gated tip), and the fail-closed
empty-member-list bootstrap window.
Authorization is exactly what the spec pins: self-run namespaces are
owner-only, refs/meta/effects/* admin-only, self-attested members
refused canonical refs. Signatures are judged against the member
entity in force at the commit’s own timestamp, recovered by walking
the member ref’s history — revocation’s before/after boundary with no
validity-window field, and the reason verdicts reproduce offline in
any clone.
Known gap, deliberately not invented around: refs/meta/inbox/*
encodes no member segment, so model.member-provenance’s "its own
inbox" cannot be keyed on the refname; the gate fails closed for
self-attested members there and says so in the refusal.
Verdicts carry the failed rule + subject refname (gate.verdict-reason)
and, on passes, the CAS precondition bound to the same old-tip read
(gate.atomic-cas). The identical-verdicts exit criterion is one
parameterized test across hosted/local/pre-flight invocations.
crates/ents-gate/src/config.rs
@@ -1,0 +1,75 @@
+//! The verification epoch, read from `refs/meta/config` (`gate.epoch`).
+
+use facet::Facet;
+use gix_hash::ObjectId;
+use gix_object::Find;
+use gix_ref_store::RefStoreRead;
+
+use crate::error::{Error, Result};
+use crate::object::expect_commit;
+
+/// The slice of `refs/meta/config`'s typed tree the gate consults: the
+/// verification epoch (`gate.epoch`).
+///
+/// `model.sdoc` defines no Config entity yet, so this struct is the
+/// first (and currently only) definition of the config tree's shape; it
+/// lives here rather than in `ents-model` because the epoch is the only
+/// field any crate reads today. When configuration grows non-gate fields
+/// (description, role rules, ...), the entity moves to `ents-model` and
+/// that change is a storage migration like any other struct change
+/// (`meta-ref.migration`).
+///
+/// `epoch` is `None` on a config written before verification was turned
+/// on. Once it is `Some`, the gate applies the tip invariant to every
+/// `refs/meta/*` update; the value records *when* (seconds since the
+/// Unix epoch) enforcement began, for audit tooling — the live gate only
+/// tests presence, because every proposed update is by definition after
+/// the epoch that admits it.
+///
+/// # Examples
+///
+/// ```
+/// use ents_gate::Config;
+///
+/// let config = Config { epoch: Some(1_700_000_000) };
+/// let (root, store) = facet_git_tree::serialize(&config).expect("serialize");
+/// let back: Config = facet_git_tree::deserialize(&root, &store).expect("deserialize");
+/// assert_eq!(back, config);
+/// ```
+// @relation(gate.epoch, scope=file)
+#[derive(Debug, Clone, Default, PartialEq, Eq, Facet)]
+pub struct Config {
+ /// When the tip invariant came into force, seconds since the Unix
+ /// epoch; `None` while verification has never been enabled.
+ pub epoch: Option<u64>,
+}
+
+/// The epoch recorded by the config tree of the commit at `oid`, or an
+/// [`Error::Entity`] when the tree does not parse as [`Config`] — an
+/// unreadable config fails closed rather than silently disabling the
+/// gate.
+pub(crate) fn epoch_at_commit(objects: &dyn Find, oid: ObjectId) -> Result<Option<u64>> {
+ let commit = expect_commit(objects, oid)?;
+ let config: Config = facet_git_tree::deserialize(&commit.tree, objects)
+ .map_err(|source| Error::Entity { oid, source })?;
+ Ok(config.epoch)
+}
+
+/// The epoch currently in force, read from `refs/meta/config`'s tip;
+/// `None` when the config ref does not exist or records no epoch.
+// @relation(gate.epoch, gate.policy-as-state, scope=function)
+pub(crate) fn current_epoch(refs: &dyn RefStoreRead, objects: &dyn Find) -> Result<Option<u64>> {
+ #[expect(
+ clippy::expect_used,
+ clippy::unwrap_in_result,
+ reason = "CONFIG_REF is a compile-time constant; the doctest below and \
+ ents-model's own tests pin its validity"
+ )]
+ let name: gix::refs::FullName = ents_model::namespace::CONFIG_REF
+ .try_into()
+ .expect("CONFIG_REF is a valid refname");
+ match refs.get(name.as_ref())? {
+ Some(tip) => epoch_at_commit(objects, tip),
+ None => Ok(None),
+ }
+}
crates/ents-gate/src/error.rs
@@ -1,0 +1,64 @@
+//! The gate's infrastructure error type.
+//!
+//! An [`Error`] is never a verdict: it means the gate could not *reach* a
+//! judgment (a store read failed, an object is missing or undecodable),
+//! as opposed to [`crate::Refusal`], which is the judgment "no". Callers
+//! at the mandatory call site (`gate.mandatory-hosted`) must treat an
+//! `Error` exactly like a failing verdict — abort the write — because a
+//! gate that cannot read its policy must fail closed; advisory call sites
+//! should surface it as "could not evaluate", not as "refused".
+
+use gix_hash::ObjectId;
+
+/// Everything that can prevent the gate from reaching a verdict.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// The ref store's read half failed. Retry or surface; the proposed
+ /// update was neither admitted nor refused.
+ #[error("ref store read failed: {0}")]
+ Refs(#[from] gix_ref_store::Error),
+
+ /// The object store failed while looking up `oid`.
+ #[error("object lookup failed for {oid}: {source}")]
+ Object {
+ /// The object being looked up.
+ oid: ObjectId,
+ /// The underlying object-store error.
+ #[source]
+ source: gix_object::find::Error,
+ },
+
+ /// `oid` is not present in the object store. At the hosted call site
+ /// this means the push's objects were not ingested before the gate
+ /// ran; at pre-flight it usually means an unfetched object.
+ #[error("object {oid} is missing from the object store")]
+ Missing {
+ /// The absent object.
+ oid: ObjectId,
+ },
+
+ /// `oid` exists but could not be decoded as the object kind the gate
+ /// needed (a commit, or a commit's timestamp field).
+ #[error("object {oid} could not be decoded: {detail}")]
+ Decode {
+ /// The undecodable object.
+ oid: ObjectId,
+ /// What failed, human-readable.
+ detail: String,
+ },
+
+ /// A policy entity's typed tree (a member, or `refs/meta/config`)
+ /// could not be deserialized. The gate fails closed on this rather
+ /// than treating unreadable policy as absent policy.
+ #[error("policy entity at {oid} is unreadable: {source}")]
+ Entity {
+ /// The tree (or commit) whose entity failed to load.
+ oid: ObjectId,
+ /// The typed-tree deserialization error.
+ #[source]
+ source: facet_git_tree::Error,
+ },
+}
+
+/// The `Result` alias every fallible `ents-gate` operation returns.
+pub type Result<T> = std::result::Result<T, Error>;
crates/ents-gate/src/lib.rs
@@ -1,0 +1,131 @@
+//! The gate: the one pure admission judgment over ref-store reads
+//! (`docs/spec/gate.sdoc`).
+//!
+//! This crate owns exactly one verb — [`verify`] — evaluated identically
+//! at the three call sites the design names (`gate.call-sites`): hosted
+//! CAS (mandatory, a failing verdict aborts the transaction), local UI
+//! verdict (advisory, a failing verdict annotates), and push pre-flight
+//! (advisory, a prediction that can only go stale). It is deliberately a
+//! separate crate from `receive` (`arch.gate-receive-split`) so the two
+//! advisory call sites link no effect-matching or enqueue logic, and it
+//! consumes only the *read* half of the ref store
+//! (`arch.refstore-read-cas-split`) plus gitoxide's `Find` seam for
+//! objects, so it is statically incapable of writing.
+//!
+//! # Spec coverage
+//!
+//! From `docs/spec/gate.sdoc`:
+//!
+//! - `gate.tip-signed`, `gate.refname-binding`, `gate.fast-forward` —
+//! [`verify`].
+//! - `gate.atomic-cas` — [`verify`] reads the old tip once and returns
+//! it as [`Admission::cas`], the precondition the writer MUST hand to
+//! `RefStore::transaction`; the CAS itself is the store's.
+//! - `gate.signature-artifact` — signatures are read from the commit's
+//! `gpgsig` header and verified in-process; no push certificate is
+//! consulted, and no API here could accept one.
+//! - `gate.policy-as-state` — members and the epoch are read only from
+//! `refs/meta/*` through `RefStoreRead`, so any clone evaluates the
+//! actual policy offline.
+//! - `gate.epoch` — [`Config`]; the tip invariant applies once an epoch
+//! is recorded, and the epoch-setting commit is itself the first gated
+//! tip of the config ref.
+//! - `gate.call-sites`, `gate.verdict-reason` — [`Verdict`], [`Refusal`],
+//! [`Requirement`]; proven identical across call sites by this crate's
+//! parameterized call-site test.
+//! - `gate.adoption-merge`, `gate.adoption-no-fast-forward`,
+//! `gate.same-actor-divergence` — consequences of judging only the tip
+//! plus DAG descent; pinned by the verdict-table tests.
+//! - `gate.principled-split` — refs outside `refs/meta/*` pass as
+//! [`AdmissionKind::CodeRef`]; the tip invariant never applies to
+//! branch refs.
+//! - `gate.bootstrap` — the empty-member-list window admits only a
+//! self-admitting first enrollment; an all-revoked member set fails
+//! closed and never reopens it.
+//!
+//! Partially here, completed by later phases: `gate.mandatory-hosted`
+//! and `gate.advisory-local` are caller policies (`ents-receive`, the
+//! composition roots) — this crate contributes the shared verdict and,
+//! for the advisory sites, the verdict-time reason rendering including
+//! the inbox alternative ([`Refusal`]). `gate.adoption-no-cherry-pick`
+//! is a prohibition on adoption *tooling* (`ents-sync`): a cherry-pick
+//! produces an ordinary commit by the placer, which no pure function
+//! over the result could distinguish, so the gate has nothing to check.
+//!
+//! # Authorization model
+//!
+//! "Signed by a member authorized for that refname" uses exactly the
+//! rules the spec pins today: self-run namespaces are owner-only,
+//! `refs/meta/effects/*` is admin-only (`effect.admin-only`), and
+//! self-attested members are refused canonical refs until promoted
+//! (`model.member-provenance`). Finer-grained, config-stored refname
+//! rules (for example designating worker keys for one effect's results
+//! namespace, `effect.official`) are a later, additive narrowing: they
+//! arrive with a Config entity in `ents-model`, not a new gate.
+//!
+//! Signature-time semantics: a signature is judged against the member
+//! entity in force *at the commit's own timestamp*, recovered by walking
+//! the member ref's history (`model.member-revocation`). This keeps
+//! verdicts reproducible after the fact in any clone — re-running the
+//! gate years later returns the same answer — at the documented cost
+//! that commit timestamps are author-controlled; the fast-forward
+//! requirement still forces any replay to descend from the live tip.
+//!
+//! # Examples
+//!
+//! A hosted-shaped round trip: enroll a member (pre-epoch, archival),
+//! turn verification on by setting the epoch (the first gated tip of the
+//! config ref), then verify a signed mutation and use the admission's
+//! CAS precondition.
+//!
+//! ```
+//! use ents_gate::{AdmissionKind, Config, Update, Verdict, verify};
+//! use ents_model::{Provenance, namespace};
+//! use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member, write_meta_entity};
+//! use gix_ref_store::Expected;
+//!
+//! let refs = MemRefStore::default();
+//! let objects = ObjectStore::default();
+//! let key = Keypair::from_seed(1);
+//!
+//! // 1. Enrollment lands pre-epoch: history before the epoch is archival.
+//! enroll_member(&refs, &objects, "jdc", &key, Provenance::AdminRegistered, 100);
+//!
+//! // 2. The epoch-setting commit is the first gated tip of refs/meta/config.
+//! let config_ref: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid");
+//! let epoch_tip = write_meta_entity(
+//! &refs, &objects, config_ref, &Config { epoch: Some(200) }, Some(&key), 200,
+//! );
+//!
+//! // 3. From here on, every meta-ref update is judged by the tip invariant.
+//! let issue = ents_model::Issue {
+//! title: "t".into(), body: "b".into(), state: "open".into(),
+//! assignees: vec![], labels: vec![],
+//! };
+//! let name: gix::refs::FullName = "refs/meta/issues/1".try_into().expect("valid");
+//! let tip = write_meta_entity(&refs, &objects, name.clone(), &issue, Some(&key), 300);
+//!
+//! // The fixture already moved the ref; judge the same tip as a proposal
+//! // against a pre-push copy of the store, the way pre-flight would.
+//! let before = refs.fetched_copy();
+//! before.remove(name.as_ref());
+//! let verdict = verify(&before, &objects, &Update { name, new: Some(tip) })
+//! .expect("evaluates");
+//! let Verdict::Pass(admission) = verdict else { panic!("authorized update passes") };
+//! assert_eq!(admission.kind, AdmissionKind::TipInvariant);
+//! assert_eq!(admission.cas, Expected::MustNotExist);
+//! # let _ = epoch_tip;
+//! ```
+
+mod config;
+mod error;
+mod object;
+mod policy;
+mod signature;
+mod verdict;
+mod verify;
+
+pub use config::Config;
+pub use error::{Error, Result};
+pub use verdict::{Admission, AdmissionKind, Refusal, Requirement, Verdict};
+pub use verify::{Update, verify};
crates/ents-gate/src/object.rs
@@ -1,0 +1,108 @@
+//! Minimal commit reading over `gix_object::Find` — the only object
+//! access the gate performs (`arch.no-object-store-trait`: gitoxide's
+//! traits are the object seam; no private store trait).
+
+use gix_hash::ObjectId;
+use gix_object::{CommitRef, Find, Kind};
+
+use crate::error::{Error, Result};
+
+/// The decoded pieces of one commit the gate judges or walks.
+#[derive(Debug, Clone)]
+pub(crate) struct CommitData {
+ /// The raw commit bytes as stored — what a signature covers (minus
+ /// the `gpgsig` header itself).
+ pub raw: Vec<u8>,
+ /// The tree the commit records.
+ pub tree: ObjectId,
+ /// Parents, in order.
+ pub parents: Vec<ObjectId>,
+ /// Committer timestamp, seconds since the Unix epoch — the time a
+ /// signature is judged against (`model.member-revocation`).
+ pub committer_seconds: i64,
+ /// The full commit message, for trailer parsing.
+ pub message: Vec<u8>,
+}
+
+/// Read `oid` and decode it as a commit; `Ok(None)` when the object
+/// exists but is not a commit (the caller turns that into a refusal, not
+/// an error).
+pub(crate) fn read_commit(objects: &dyn Find, oid: ObjectId) -> Result<Option<CommitData>> {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&oid, &mut buf)
+ .map_err(|source| Error::Object { oid, source })?
+ .ok_or(Error::Missing { oid })?;
+ if data.kind != Kind::Commit {
+ return Ok(None);
+ }
+ let raw = data.data.to_vec();
+ let (tree, parents, committer_seconds, message) = decode_commit(&raw, oid)?;
+ Ok(Some(CommitData {
+ tree,
+ parents,
+ committer_seconds,
+ message,
+ raw,
+ }))
+}
+
+fn decode_commit(raw: &[u8], oid: ObjectId) -> Result<(ObjectId, Vec<ObjectId>, i64, Vec<u8>)> {
+ let commit = CommitRef::from_bytes(raw, oid.kind()).map_err(|e| Error::Decode {
+ oid,
+ detail: e.to_string(),
+ })?;
+ let committer_seconds = commit
+ .committer()
+ .map_err(|e| Error::Decode {
+ oid,
+ detail: format!("committer: {e}"),
+ })?
+ .time()
+ .map_err(|e| Error::Decode {
+ oid,
+ detail: format!("committer time: {e}"),
+ })?
+ .seconds;
+ Ok((
+ commit.tree(),
+ commit.parents().collect(),
+ committer_seconds,
+ commit.message.to_vec(),
+ ))
+}
+
+/// Like [`read_commit`], but a non-commit is an [`Error::Decode`] —
+/// for walks where every node must be a commit.
+pub(crate) fn expect_commit(objects: &dyn Find, oid: ObjectId) -> Result<CommitData> {
+ read_commit(objects, oid)?.ok_or(Error::Decode {
+ oid,
+ detail: "expected a commit".into(),
+ })
+}
+
+/// Whether `ancestor` is reachable from `descendant` by parent edges
+/// (inclusive: a commit descends from itself) — the DAG sense of
+/// `gate.fast-forward`.
+pub(crate) fn descends_from(
+ objects: &dyn Find,
+ descendant: ObjectId,
+ ancestor: ObjectId,
+) -> Result<bool> {
+ let mut queue = vec![descendant];
+ let mut seen = std::collections::HashSet::new();
+ while let Some(oid) = queue.pop() {
+ if oid == ancestor {
+ return Ok(true);
+ }
+ if !seen.insert(oid) {
+ continue;
+ }
+ // A missing or non-commit ancestor object simply ends this path:
+ // at pre-flight, history below the last fetch may be shallow.
+ if let Some(commit) = read_commit(objects, oid).ok().flatten() {
+ queue.extend(commit.parents);
+ }
+ }
+ Ok(false)
+}
crates/ents-gate/src/policy.rs
@@ -1,0 +1,73 @@
+//! Policy loading: the member set, read from `refs/meta/member/*`
+//! through the read half of the ref store (`gate.policy-as-state`).
+//!
+//! The gate consults no state outside `refs/meta/*`: members, their
+//! revocation timelines, and the epoch (`crate::config`) are all
+//! repository state, so any frontend with a clone evaluates the actual
+//! policy offline, staleness bounded only by the age of its last fetch.
+
+use ents_model::{Member, MemberId};
+use gix_hash::ObjectId;
+use gix_object::Find;
+use gix_ref_store::RefStoreRead;
+
+use crate::error::{Error, Result};
+use crate::object::expect_commit;
+
+/// One enrolled member: its id (from the refname) and its ref's tip.
+#[derive(Debug, Clone)]
+pub(crate) struct Enrolled {
+ /// The member id, i.e. the `<id>` of `refs/meta/member/<id>`.
+ pub id: MemberId,
+ /// The member ref's current tip commit.
+ pub tip: ObjectId,
+}
+
+/// Every ref under `refs/meta/member/`, in store order.
+// @relation(gate.policy-as-state, scope=function)
+pub(crate) fn members(refs: &dyn RefStoreRead) -> Result<Vec<Enrolled>> {
+ let mut out = Vec::new();
+ for entry in refs.iter_prefix("refs/meta/member/")? {
+ let (name, tip) = entry?;
+ let path = name.as_bstr().to_string();
+ let id = path
+ .strip_prefix("refs/meta/member/")
+ .unwrap_or(&path)
+ .to_owned();
+ out.push(Enrolled {
+ id: MemberId::new(id),
+ tip,
+ });
+ }
+ Ok(out)
+}
+
+/// The member entity in force at `at_seconds`, found by walking the
+/// member ref's own commit history (first-parent) from `tip` back to the
+/// newest mutation at or before that time, and deserializing *that*
+/// commit's tree.
+///
+/// This is how revocation gets its before/after boundary with no
+/// validity-window field on the entity (`model.member-revocation`): the
+/// ref's commit chain is the audit trail (`meta-ref.namespace`), so the
+/// state, provenance, and key that judge a signature are the ones the
+/// chain records for the signature's own timestamp. `Ok(None)` means the
+/// member had not been enrolled yet at `at_seconds`.
+// @relation(model.member-revocation, gate.policy-as-state, scope=function)
+pub(crate) fn member_at(
+ objects: &dyn Find,
+ tip: ObjectId,
+ at_seconds: i64,
+) -> Result<Option<Member>> {
+ let mut cursor = Some(tip);
+ while let Some(oid) = cursor {
+ let commit = expect_commit(objects, oid)?;
+ if commit.committer_seconds <= at_seconds {
+ let member: Member = facet_git_tree::deserialize(&commit.tree, objects)
+ .map_err(|source| Error::Entity { oid, source })?;
+ return Ok(Some(member));
+ }
+ cursor = commit.parents.first().copied();
+ }
+ Ok(None)
+}
crates/ents-gate/src/signature.rs
@@ -1,0 +1,119 @@
+//! Commit signature extraction and offline verification
+//! (`gate.tip-signed`, `gate.signature-artifact`).
+//!
+//! The signature is read from the commit object's `gpgsig` header — a
+//! data artifact that replicates with the repository — and verified in
+//! pure Rust against a member's stored OpenSSH public key. Nothing here
+//! reads a push certificate, the environment, or any transport state:
+//! give this module the same bytes in any clone and it returns the same
+//! answer (`gate.signature-artifact`).
+
+use ssh_key::{PublicKey, SshSig};
+
+/// The SSHSIG namespace git signs commits under.
+const GIT_NAMESPACE: &str = "git";
+
+/// Split a raw commit object into its signed payload and its detached
+/// signature: the payload is the commit serialization with the `gpgsig`
+/// header removed, exactly the bytes git signs.
+///
+/// Returns `None` when the commit carries no `gpgsig` header — an
+/// unsigned commit.
+pub(crate) fn split_signed(raw: &[u8]) -> Option<(Vec<u8>, String)> {
+ // The header section ends at the first blank line; gpgsig is a
+ // header whose continuation lines start with a single space.
+ let header_end = raw
+ .windows(2)
+ .position(|w| w == b"\n\n")
+ .map_or(raw.len(), |i| i.saturating_add(1));
+
+ let mut sig_start = None;
+ let mut sig_end = None;
+ let mut line_start = 0usize;
+ while line_start < header_end {
+ let rest = raw.get(line_start..header_end)?;
+ let line_len = rest
+ .iter()
+ .position(|&b| b == b'\n')
+ .map_or(rest.len(), |i| i.saturating_add(1));
+ let line = rest.get(..line_len)?;
+ if sig_start.is_none() {
+ if line.starts_with(b"gpgsig ") {
+ sig_start = Some(line_start);
+ sig_end = Some(line_start.saturating_add(line_len));
+ }
+ } else if sig_end == Some(line_start) && line.starts_with(b" ") {
+ sig_end = Some(line_start.saturating_add(line_len));
+ }
+ line_start = line_start.saturating_add(line_len);
+ }
+
+ let (start, end) = (sig_start?, sig_end?);
+ let mut payload = Vec::with_capacity(raw.len().saturating_sub(end.saturating_sub(start)));
+ payload.extend_from_slice(raw.get(..start)?);
+ payload.extend_from_slice(raw.get(end..)?);
+
+ let header = raw.get(start..end)?;
+ let text = std::str::from_utf8(header).ok()?;
+ let mut sig = String::new();
+ for (i, line) in text.lines().enumerate() {
+ let value = if i == 0 {
+ line.strip_prefix("gpgsig ")?
+ } else {
+ line.strip_prefix(' ')?
+ };
+ sig.push_str(value);
+ sig.push('\n');
+ }
+ Some((payload, sig))
+}
+
+/// Whether `signature` (an armored SSHSIG) over `payload` verifies
+/// against `key` (an OpenSSH single-line public key, as stored on a
+/// [`ents_model::Member`]).
+///
+/// Any malformed key, malformed signature, wrong namespace, or failed
+/// cryptographic check is `false` — the gate treats them all as "not
+/// signed by this member", and the caller renders which member set was
+/// consulted.
+pub(crate) fn verifies(key: &str, payload: &[u8], signature: &str) -> bool {
+ let Ok(key) = PublicKey::from_openssh(key) else {
+ return false;
+ };
+ let Ok(sig) = SshSig::from_pem(signature) else {
+ return false;
+ };
+ key.verify(GIT_NAMESPACE, payload, &sig).is_ok()
+}
+
+#[cfg(test)]
+mod tests {
+ #![expect(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+
+ use super::*;
+
+ /// A hand-written commit shape: gpgsig between committer and an
+ /// extra header, with two continuation lines (each continuation
+ /// line starts with one space, as git writes multi-line headers).
+ const RAW: &[u8] = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@a> 100 +0000\ncommitter A <a@a> 100 +0000\ngpgsig -----BEGIN SSH SIGNATURE-----\n QUJD\n -----END SSH SIGNATURE-----\nother value\n\nmessage body\n";
+
+ #[rstest]
+ // @relation(gate.signature-artifact, scope=function, role=Verifies)
+ fn split_removes_exactly_the_gpgsig_header() {
+ let (payload, sig) = split_signed(RAW).expect("signed");
+ assert_eq!(
+ sig,
+ "-----BEGIN SSH SIGNATURE-----\nQUJD\n-----END SSH SIGNATURE-----\n"
+ );
+ let expected: &[u8] = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@a> 100 +0000\ncommitter A <a@a> 100 +0000\nother value\n\nmessage body\n";
+ assert_eq!(payload, expected);
+ }
+
+ #[rstest]
+ fn unsigned_commit_splits_to_none() {
+ let raw = b"tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\nauthor A <a@a> 100 +0000\ncommitter A <a@a> 100 +0000\n\ngpgsig in the message is not a header\n";
+ assert!(split_signed(raw).is_none());
+ }
+}
crates/ents-gate/src/verdict.rs
@@ -1,0 +1,181 @@
+//! The gate's verdict vocabulary: admission, refusal, and the
+//! machine-readable reason a refusal carries (`gate.verdict-reason`).
+
+use gix::refs::FullName;
+use gix_ref_store::Expected;
+
+/// The requirement a refusal names — one of the tip-invariant rules
+/// `gate.tip-signed` through `gate.atomic-cas`, exactly the range
+/// `gate.verdict-reason` requires a failure to identify.
+///
+/// [`Requirement::AtomicCas`] is never produced by [`crate::verify`]
+/// itself (the gate reads, it does not write); it exists so the caller
+/// that *does* run the compare-and-swap can report a stale-precondition
+/// rejection in the same vocabulary.
+///
+/// # Examples
+///
+/// ```
+/// use ents_gate::Requirement;
+///
+/// assert_eq!(Requirement::TipSigned.uid(), "gate.tip-signed");
+/// assert_eq!(Requirement::FastForward.uid(), "gate.fast-forward");
+/// ```
+// @relation(gate.verdict-reason, scope=file)
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Requirement {
+ /// `gate.tip-signed`: the new tip must be signed by a member
+ /// authorized for the refname.
+ TipSigned,
+ /// `gate.refname-binding`: the commit's `Ents-Ref:` trailer must
+ /// match the refname being updated.
+ RefnameBinding,
+ /// `gate.fast-forward`: the new tip must descend from the old tip.
+ FastForward,
+ /// `gate.atomic-cas`: the update must commit via compare-and-swap
+ /// against the old tip the gate read.
+ AtomicCas,
+}
+
+impl Requirement {
+ /// The spec requirement id this variant names.
+ #[must_use]
+ pub fn uid(&self) -> &'static str {
+ match self {
+ Self::TipSigned => "gate.tip-signed",
+ Self::RefnameBinding => "gate.refname-binding",
+ Self::FastForward => "gate.fast-forward",
+ Self::AtomicCas => "gate.atomic-cas",
+ }
+ }
+}
+
+/// Why a passing update passed — advisory call sites render this, so a
+/// local UI can say "admitted under the bootstrap window" rather than a
+/// bare yes.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum AdmissionKind {
+ /// The full tip invariant held (`gate.tip-signed` through
+ /// `gate.fast-forward`, with the CAS precondition attached).
+ TipInvariant,
+ /// No verification epoch is recorded in `refs/meta/config`, and this
+ /// update does not set one: the tip invariant is not yet in force
+ /// (`gate.epoch` — history before the epoch is archival).
+ PreEpoch,
+ /// Admitted by the empty-member-list bootstrap window: a first
+ /// enrollment, self-admitting (`gate.bootstrap`).
+ Bootstrap,
+ /// The refname is outside `refs/meta/*`: branch and tag refs keep
+ /// transport-level authorization instead of the tip invariant
+ /// (`gate.principled-split`).
+ CodeRef,
+}
+
+/// A passing verdict: the update may proceed, and `cas` is the
+/// compare-and-swap precondition the write MUST use — bound to the same
+/// old-tip read the fast-forward check used, which is what makes the
+/// eventual ref update atomic against races (`gate.atomic-cas`).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Admission {
+ /// Why the update passed.
+ pub kind: AdmissionKind,
+ /// The refname judged.
+ pub refname: FullName,
+ /// The CAS precondition for the write: `MustExistAndMatch(old tip)`
+ /// when the ref existed at verification time, `MustNotExist` when it
+ /// did not.
+ pub cas: Expected,
+}
+
+/// A failing verdict: which requirement failed, for which refname, and a
+/// rendered reason (`gate.verdict-reason` — never a bare pass/fail).
+///
+/// # Examples
+///
+/// ```
+/// use ents_gate::{Refusal, Requirement};
+///
+/// let refusal = Refusal {
+/// requirement: Requirement::TipSigned,
+/// refname: "refs/meta/issues/42".try_into().expect("valid"),
+/// detail: "your signing key is not authorized for this ref".into(),
+/// inbox_alternative: true,
+/// };
+/// let rendered = refusal.to_string();
+/// assert!(rendered.contains("gate.tip-signed"));
+/// assert!(rendered.contains("refs/meta/inbox"));
+/// ```
+// @relation(gate.verdict-reason, scope=file)
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Refusal {
+ /// The tip-invariant rule that failed.
+ pub requirement: Requirement,
+ /// The refname the update targeted.
+ pub refname: FullName,
+ /// A human-readable, actionable reason.
+ pub detail: String,
+ /// Whether submitting through the inbox namespace would be accepted
+ /// instead — set on authorization refusals so advisory call sites can
+ /// surface the inbox alternative at verdict time, not only once a
+ /// push is rejected (`gate.advisory-local`, `sync.inbox-routing`).
+ pub inbox_alternative: bool,
+}
+
+impl std::fmt::Display for Refusal {
+ // @relation(gate.verdict-reason, gate.advisory-local, scope=function)
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(
+ f,
+ "{} (rule {}, ref {})",
+ self.detail,
+ self.requirement.uid(),
+ self.refname.as_bstr()
+ )?;
+ if self.inbox_alternative {
+ write!(
+ f,
+ "; you can still submit this change under refs/meta/inbox/* for adoption by an authorized member"
+ )?;
+ }
+ Ok(())
+ }
+}
+
+/// The gate's verdict on one proposed ref update.
+///
+/// The same value is computed at all three call sites
+/// (`gate.call-sites`); what differs is only what the caller does with a
+/// [`Verdict::Fail`] — abort the transaction (hosted CAS,
+/// `gate.mandatory-hosted`) or annotate and proceed (local UI and push
+/// pre-flight, `gate.advisory-local`).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Verdict {
+ /// The update satisfies the gate; write it with
+ /// [`Admission::cas`] as the precondition.
+ Pass(Admission),
+ /// The update violates the tip invariant; the refusal says which
+ /// rule, for which ref, and why.
+ Fail(Refusal),
+}
+
+impl Verdict {
+ /// Whether this verdict admits the update.
+ ///
+ /// # Examples
+ ///
+ /// ```
+ /// use ents_gate::{Admission, AdmissionKind, Verdict};
+ /// use gix_ref_store::Expected;
+ ///
+ /// let verdict = Verdict::Pass(Admission {
+ /// kind: AdmissionKind::CodeRef,
+ /// refname: "refs/heads/main".try_into().expect("valid"),
+ /// cas: Expected::MustNotExist,
+ /// });
+ /// assert!(verdict.is_pass());
+ /// ```
+ #[must_use]
+ pub fn is_pass(&self) -> bool {
+ matches!(self, Self::Pass(_))
+ }
+}
crates/ents-gate/src/verify.rs
@@ -1,0 +1,401 @@
+//! The pure verify function — the one admission judgment
+//! (`gate.tip-signed` through `gate.fast-forward`, `gate.epoch`,
+//! `gate.bootstrap`), identical at every call site (`gate.call-sites`).
+
+use ents_model::namespace::{self, Namespace};
+use ents_model::trailer::Trailers;
+use ents_model::{Member, MemberId, MemberState, Provenance};
+use gix::refs::FullName;
+use gix_hash::ObjectId;
+use gix_object::Find;
+use gix_ref_store::{Expected, RefStoreRead};
+
+use crate::config;
+use crate::error::Result;
+use crate::object::{CommitData, descends_from, read_commit};
+use crate::policy;
+use crate::signature;
+use crate::verdict::{Admission, AdmissionKind, Refusal, Requirement, Verdict};
+
+/// One proposed ref update, as every call site sees it: the refname and
+/// the tip it should come to point at (`None` proposes deletion).
+///
+/// There is deliberately no `old` field: the gate reads the current tip
+/// itself, from the same store snapshot its fast-forward check uses, and
+/// returns it as the CAS precondition ([`Admission::cas`]) — binding
+/// `gate.fast-forward` and `gate.atomic-cas` to one read.
+///
+/// # Examples
+///
+/// ```
+/// use ents_gate::Update;
+///
+/// let update = Update {
+/// name: "refs/meta/issues/42".try_into().expect("valid"),
+/// new: Some(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)),
+/// };
+/// assert!(update.new.is_some());
+/// ```
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Update {
+ /// The ref being updated.
+ pub name: FullName,
+ /// The proposed new tip, or `None` to delete the ref.
+ pub new: Option<ObjectId>,
+}
+
+/// Verify one proposed ref update against current repository state.
+///
+/// This is a pure function over the read half of the ref store and
+/// gitoxide's object-find seam: same inputs, same verdict, no writes, no
+/// clock, no transport state. The three call sites — hosted CAS
+/// (`gate.mandatory-hosted`), local UI verdict (`gate.advisory-local`),
+/// and push pre-flight (`sync.pre-flight`) — call exactly this function
+/// (`gate.call-sites`) and differ only in what they do with a failing
+/// verdict.
+///
+/// The checks, in spec order, for a `refs/meta/*` ref once the epoch is
+/// in force (`gate.epoch`):
+///
+/// 1. `gate.tip-signed` — the new tip carries a `gpgsig` SSHSIG that
+/// verifies against the key of an enrolled member whose entity, *as
+/// recorded at the signature's own timestamp* in the member ref's
+/// history, is active (`model.member-revocation`) and whose
+/// provenance authorizes this refname (`model.member-provenance`,
+/// `effect.admin-only`).
+/// 2. `gate.refname-binding` — the commit's `Ents-Ref:` trailer names
+/// exactly this ref.
+/// 3. `gate.fast-forward` — the new tip descends from the current tip.
+///
+/// Refs outside `refs/meta/*` pass as [`AdmissionKind::CodeRef`]: branch
+/// refs keep transport-level authorization instead of the tip invariant
+/// (`gate.principled-split`).
+///
+/// # Errors
+///
+/// An [`crate::Error`] means the gate could not evaluate (store or
+/// object failure) — distinct from a [`Verdict::Fail`], which is a
+/// reached judgment. The mandatory call site must treat both as
+/// blocking.
+///
+/// # Examples
+///
+/// ```
+/// use ents_gate::{AdmissionKind, Update, Verdict, verify};
+/// use ents_testutil::{MemRefStore, ObjectStore};
+///
+/// let refs = MemRefStore::default();
+/// let objects = ObjectStore::default();
+///
+/// // A code ref is not subject to the tip invariant.
+/// let verdict = verify(&refs, &objects, &Update {
+/// name: "refs/heads/main".try_into().expect("valid"),
+/// new: Some(gix_hash::ObjectId::null(gix_hash::Kind::Sha1)),
+/// }).expect("evaluates");
+/// let Verdict::Pass(admission) = verdict else { panic!("code refs pass") };
+/// assert_eq!(admission.kind, AdmissionKind::CodeRef);
+/// ```
+// @relation(gate.tip-signed, gate.refname-binding, gate.fast-forward, gate.atomic-cas, gate.epoch, gate.call-sites, gate.principled-split, scope=function)
+pub fn verify(refs: &dyn RefStoreRead, objects: &dyn Find, update: &Update) -> Result<Verdict> {
+ let old = refs.get(update.name.as_ref())?;
+ let cas = old.map_or(Expected::MustNotExist, Expected::MustExistAndMatch);
+ let pass = |kind: AdmissionKind| {
+ Ok(Verdict::Pass(Admission {
+ kind,
+ refname: update.name.clone(),
+ cas: cas.clone(),
+ }))
+ };
+
+ // The principled split: content signatures authorize only
+ // single-writer appends, which only meta-refs guarantee.
+ // @relation(gate.principled-split, scope=function)
+ if !update.name.as_bstr().starts_with(b"refs/meta/") {
+ return pass(AdmissionKind::CodeRef);
+ }
+
+ // The verification epoch: the tip invariant applies only once an
+ // epoch is recorded in refs/meta/config — or for the update that
+ // records it, which must itself be the first gated tip of the
+ // config ref (`gate.epoch`).
+ let epoch = config::current_epoch(refs, objects)?;
+ let epoch_setting = epoch.is_none()
+ && update.name.as_bstr() == ents_model::namespace::CONFIG_REF
+ && match update.new {
+ // A new config that does not parse (or has no epoch) is not
+ // epoch-setting; pre-epoch, it passes as archival anyway.
+ Some(new) => matches!(config::epoch_at_commit(objects, new), Ok(Some(_))),
+ None => false,
+ };
+ if epoch.is_none() && !epoch_setting {
+ return pass(AdmissionKind::PreEpoch);
+ }
+
+ let refuse = |requirement: Requirement, detail: String, inbox_alternative: bool| {
+ Ok(Verdict::Fail(Refusal {
+ requirement,
+ refname: update.name.clone(),
+ detail,
+ inbox_alternative,
+ }))
+ };
+
+ // Meta-refs advance fast-forward-only; deletion is not a descent
+ // and would discard the audit trail.
+ let Some(new) = update.new else {
+ return refuse(
+ Requirement::FastForward,
+ "meta-refs advance fast-forward-only; deletion is refused".into(),
+ false,
+ );
+ };
+
+ let Some(commit) = read_commit(objects, new)? else {
+ return refuse(
+ Requirement::TipSigned,
+ "the proposed tip is not a commit object, so it cannot carry a member signature".into(),
+ false,
+ );
+ };
+
+ // gate.tip-signed / gate.signature-artifact: the signature is a data
+ // artifact inside the commit object; no push certificate is read.
+ // @relation(gate.signature-artifact, scope=function)
+ let Some((payload, sig)) = signature::split_signed(&commit.raw) else {
+ return refuse(
+ Requirement::TipSigned,
+ "the proposed tip is unsigned; meta-ref mutations must be author-signed commits".into(),
+ false,
+ );
+ };
+
+ let members = policy::members(refs)?;
+ if members.is_empty() {
+ return bootstrap(objects, update, new, &commit, &payload, &sig, old, &cas);
+ }
+
+ // Identify the signer: the member whose entity *at the signature's
+ // own timestamp* carries the verifying key. Walking the member ref's
+ // history for that entity is what gives revocation its
+ // before/after boundary (`model.member-revocation`).
+ let at = commit.committer_seconds;
+ let mut signer: Option<(MemberId, Member)> = None;
+ for enrolled in &members {
+ if let Some(member) = policy::member_at(objects, enrolled.tip, at)?
+ && signature::verifies(&member.key, &payload, &sig)
+ {
+ signer = Some((enrolled.id.clone(), member));
+ break;
+ }
+ }
+ // @relation(gate.tip-signed, gate.bootstrap, scope=function)
+ let Some((id, member)) = signer else {
+ return refuse(
+ Requirement::TipSigned,
+ "the tip's signature does not verify against any member key enrolled and in force \
+ at the commit's timestamp"
+ .into(),
+ false,
+ );
+ };
+
+ // A revoked key is explicitly rejected for signatures made after
+ // revocation (`model.member-revocation`); because the entity above
+ // was resolved at the signature's timestamp, a signature made while
+ // the key was valid stays verifiable even if the member is revoked
+ // by the time the gate runs.
+ if member.state == MemberState::Revoked {
+ return refuse(
+ Requirement::TipSigned,
+ format!("member {id}'s key was revoked at the signature's timestamp"),
+ false,
+ );
+ }
+
+ if let Some(refusal) = authorize(&update.name, &id, &member) {
+ return Ok(Verdict::Fail(refusal));
+ }
+
+ // gate.refname-binding: without this, a signed commit could be
+ // replayed as the tip of a different meta-ref.
+ // @relation(gate.refname-binding, scope=function)
+ let trailers = Trailers::parse(&commit.message);
+ if trailers.ents_ref.as_ref() != Some(&update.name) {
+ let found = trailers
+ .ents_ref
+ .as_ref()
+ .map_or_else(|| "no Ents-Ref trailer".to_owned(), |n| n.to_string());
+ return refuse(
+ Requirement::RefnameBinding,
+ format!(
+ "the commit was authored for {found}, not for {}",
+ update.name.as_bstr()
+ ),
+ false,
+ );
+ }
+
+ // gate.fast-forward: the parent hash is the anti-replay freshness
+ // binding; the CAS precondition below pins the same old tip.
+ // @relation(gate.fast-forward, scope=function)
+ if let Some(old) = old
+ && !descends_from(objects, new, old)?
+ {
+ return refuse(
+ Requirement::FastForward,
+ "the new tip does not descend from the current tip; merge the divergent heads \
+ (adoption and same-actor divergence are merges, never rewrites)"
+ .into(),
+ false,
+ );
+ }
+
+ pass(AdmissionKind::TipInvariant)
+}
+
+/// Refname-keyed authorization for an identified, active signer
+/// (`gate.tip-signed`'s "authorized for that refname").
+///
+/// The rules are exactly the ones the spec itself fixes:
+///
+/// - `refs/meta/self/<member>/*` is writable only by `<member>`
+/// (`meta-ref.inbox`, `effect.self-run`).
+/// - `refs/meta/effects/*` requires an admin-registered member
+/// regardless of anything else (`effect.admin-only`).
+/// - A self-attested member is not authorized for canonical refs — its
+/// writes are limited to its own inbox and self-run namespaces
+/// (`model.member-provenance`).
+/// - `refs/meta/inbox/*`: the spec's "its own inbox" cannot be keyed on
+/// the refname because `refs/meta/inbox/*` encodes no member segment
+/// (open question between `meta-ref.inbox` and
+/// `model.member-provenance`); until the spec picks a shape, the gate
+/// fails closed for self-attested members and admits admin-registered
+/// ones, and says so in the refusal.
+// @relation(gate.tip-signed, effect.admin-only, model.member-provenance, scope=function)
+fn authorize(name: &FullName, id: &MemberId, member: &Member) -> Option<Refusal> {
+ let namespace = namespace::classify(name.as_ref())?;
+ let refuse = |detail: String, inbox_alternative: bool| {
+ Some(Refusal {
+ requirement: Requirement::TipSigned,
+ refname: name.clone(),
+ detail,
+ inbox_alternative,
+ })
+ };
+ match namespace {
+ Namespace::SelfRun => {
+ if namespace::self_run_owner(name.as_ref()).as_ref() == Some(id) {
+ None
+ } else {
+ refuse(
+ format!(
+ "refs/meta/self/<member>/* is writable only by that member; \
+ {id} does not own this ref"
+ ),
+ false,
+ )
+ }
+ }
+ Namespace::Inbox => match member.provenance {
+ Provenance::AdminRegistered => None,
+ Provenance::SelfAttested => refuse(
+ "inbox authorization for self-attested members is not yet decidable: \
+ refs/meta/inbox/* encodes no member segment, so \"its own inbox\" \
+ (model.member-provenance) cannot be keyed on the refname; failing closed \
+ until the spec picks a shape"
+ .into(),
+ false,
+ ),
+ },
+ Namespace::Effect => match member.provenance {
+ Provenance::AdminRegistered => None,
+ Provenance::SelfAttested => refuse(
+ format!(
+ "authoring an effect schedules code execution on canonical \
+ infrastructure; {id} is not admin-registered"
+ ),
+ true,
+ ),
+ },
+ _ => match member.provenance {
+ Provenance::AdminRegistered => None,
+ Provenance::SelfAttested => refuse(
+ format!(
+ "{id}'s membership is self-attested and not authorized for canonical \
+ refs until promoted by an admin-registered member"
+ ),
+ true,
+ ),
+ },
+ }
+}
+
+/// The empty-member-list bootstrap window (`gate.bootstrap`): with no
+/// `refs/meta/member/*` ref present, a first enrollment is
+/// self-admitting — and only an enrollment. Self-admitting is taken
+/// literally: the enrollment commit must be signed by the key inside the
+/// Member tree it pushes, must bind to its refname, and must fast-forward,
+/// so even the bootstrap write satisfies every mechanically-checkable
+/// part of the tip invariant. Because this path is reachable only while
+/// the member set is empty, a member set whose keys are all revoked
+/// never reopens it: those updates take the ordinary path and fail
+/// closed on the revoked state.
+// @relation(gate.bootstrap, scope=function)
+#[expect(
+ clippy::too_many_arguments,
+ reason = "a private continuation of verify(); grouping these into a struct would only rename the arguments"
+)]
+fn bootstrap(
+ objects: &dyn Find,
+ update: &Update,
+ new: ObjectId,
+ commit: &CommitData,
+ payload: &[u8],
+ sig: &str,
+ old: Option<ObjectId>,
+ cas: &Expected,
+) -> Result<Verdict> {
+ let refuse = |detail: String| {
+ Ok(Verdict::Fail(Refusal {
+ requirement: Requirement::TipSigned,
+ refname: update.name.clone(),
+ detail,
+ inbox_alternative: false,
+ }))
+ };
+ if namespace::classify(update.name.as_ref()) != Some(Namespace::Member) {
+ return refuse(
+ "no members are enrolled; only a first member enrollment is self-admitting".into(),
+ );
+ }
+ let Ok(pushed) = facet_git_tree::deserialize::<Member>(&commit.tree, objects) else {
+ return refuse("a first enrollment must push a readable Member entity".into());
+ };
+ if !signature::verifies(&pushed.key, payload, sig) {
+ return refuse("a first enrollment must be signed by the key it enrolls".into());
+ }
+ let trailers = Trailers::parse(&commit.message);
+ if trailers.ents_ref.as_ref() != Some(&update.name) {
+ return Ok(Verdict::Fail(Refusal {
+ requirement: Requirement::RefnameBinding,
+ refname: update.name.clone(),
+ detail: "the enrollment commit's Ents-Ref trailer does not name this ref".into(),
+ inbox_alternative: false,
+ }));
+ }
+ if let Some(old) = old
+ && !descends_from(objects, new, old)?
+ {
+ return Ok(Verdict::Fail(Refusal {
+ requirement: Requirement::FastForward,
+ refname: update.name.clone(),
+ detail: "the enrollment does not descend from the ref's current tip".into(),
+ inbox_alternative: false,
+ }));
+ }
+ Ok(Verdict::Pass(Admission {
+ kind: AdmissionKind::Bootstrap,
+ refname: update.name.clone(),
+ cas: cas.clone(),
+ }))
+}
crates/ents-gate/tests/gate.rs
@@ -1,0 +1,717 @@
+//! Integration tests for the gate: the verdict table (rstest — the spec
+//! enumerates the cases), the epoch and bootstrap windows, and the
+//! one-parameterized-test proof that all three call sites see identical
+//! verdicts.
+
+#![expect(
+ clippy::expect_used,
+ clippy::panic,
+ clippy::unreachable,
+ reason = "integration test: fixtures panic on setup failure"
+)]
+
+use ents_gate::{AdmissionKind, Config, Requirement, Update, Verdict, verify};
+use ents_model::trailer::Trailers;
+use ents_model::{Member, MemberId, Provenance, namespace};
+use ents_testutil::{
+ CommitSpec, Keypair, MemRefStore, ObjectStore, empty_tree, enroll_member, write_commit,
+ write_member, write_meta_entity,
+};
+use gix::refs::FullName;
+use gix_hash::ObjectId;
+use gix_ref_store::{Expected, RefStoreRead as _};
+use rstest::rstest;
+
+const ADMIN_SEED: u8 = 1;
+const SELF_ATTESTED_SEED: u8 = 2;
+const OUTSIDER_SEED: u8 = 9;
+
+/// A forge fixture with verification in force: an admin-registered
+/// member `admin` (enrolled pre-epoch), a self-attested member `guest`,
+/// and an epoch recorded in `refs/meta/config`.
+struct Forge {
+ refs: MemRefStore,
+ objects: ObjectStore,
+ admin: Keypair,
+ guest: Keypair,
+}
+
+fn forge() -> Forge {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let admin = Keypair::from_seed(ADMIN_SEED);
+ let guest = Keypair::from_seed(SELF_ATTESTED_SEED);
+ enroll_member(
+ &refs,
+ &objects,
+ "admin",
+ &admin,
+ Provenance::AdminRegistered,
+ 100,
+ );
+ enroll_member(
+ &refs,
+ &objects,
+ "guest",
+ &guest,
+ Provenance::SelfAttested,
+ 110,
+ );
+ let config_ref: FullName = namespace::CONFIG_REF.try_into().expect("valid");
+ write_meta_entity(
+ &refs,
+ &objects,
+ config_ref,
+ &Config { epoch: Some(200) },
+ Some(&admin),
+ 200,
+ );
+ Forge {
+ refs,
+ objects,
+ admin,
+ guest,
+ }
+}
+
+fn name(s: &str) -> FullName {
+ s.try_into().expect("valid refname in test")
+}
+
+/// A proposal commit: empty tree, explicit parents/trailer/key/time.
+fn proposal(
+ forge: &Forge,
+ parents: Vec<ObjectId>,
+ ents_ref: Option<&str>,
+ key: Option<&Keypair>,
+ seconds: i64,
+) -> ObjectId {
+ let tree = empty_tree(&forge.objects);
+ let message = ents_ref.map_or_else(
+ || "mutate\n\nno trailer here\n".to_owned(),
+ |r| {
+ let trailers = Trailers {
+ ents_ref: Some(name(r)),
+ schema_version: None,
+ };
+ format!("mutate\n\n{}", trailers.render())
+ },
+ );
+ write_commit(
+ &forge.objects,
+ &CommitSpec {
+ tree,
+ parents,
+ message,
+ seconds,
+ },
+ key,
+ )
+}
+
+fn run(forge: &Forge, refname: &str, new: Option<ObjectId>) -> Verdict {
+ verify(
+ &forge.refs,
+ &forge.objects,
+ &Update {
+ name: name(refname),
+ new,
+ },
+ )
+ .expect("the gate must reach a verdict on a complete fixture")
+}
+
+fn expect_fail(verdict: &Verdict, requirement: Requirement) {
+ let Verdict::Fail(refusal) = verdict else {
+ panic!("expected a refusal against {requirement:?}, got {verdict:?}");
+ };
+ assert_eq!(refusal.requirement, requirement, "refusal: {refusal}");
+}
+
+fn expect_pass(verdict: &Verdict, kind: AdmissionKind) {
+ let Verdict::Pass(admission) = verdict else {
+ panic!("expected admission {kind:?}, got {verdict:?}");
+ };
+ assert_eq!(admission.kind, kind);
+}
+
+// ---------------------------------------------------------------------
+// The verdict table: member × trailer × FF × namespace cases.
+// ---------------------------------------------------------------------
+
+#[rstest]
+// @relation(gate.tip-signed, gate.verdict-reason, scope=function, role=Verifies)
+fn authorized_signed_mutation_passes_the_tip_invariant() {
+ let f = forge();
+ let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 300);
+ expect_pass(
+ &run(&f, "refs/meta/issues/1", Some(new)),
+ AdmissionKind::TipInvariant,
+ );
+}
+
+#[rstest]
+// @relation(gate.tip-signed, scope=function, role=Verifies)
+fn unsigned_tip_is_refused() {
+ let f = forge();
+ let new = proposal(&f, vec![], Some("refs/meta/issues/1"), None, 300);
+ expect_fail(
+ &run(&f, "refs/meta/issues/1", Some(new)),
+ Requirement::TipSigned,
+ );
+}
+
+#[rstest]
+// @relation(gate.tip-signed, scope=function, role=Verifies)
+fn non_member_signature_is_refused() {
+ let f = forge();
+ let outsider = Keypair::from_seed(OUTSIDER_SEED);
+ let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&outsider), 300);
+ expect_fail(
+ &run(&f, "refs/meta/issues/1", Some(new)),
+ Requirement::TipSigned,
+ );
+}
+
+#[rstest]
+// @relation(gate.tip-signed, model.member-revocation, scope=function, role=Verifies)
+fn signature_made_after_revocation_is_refused() {
+ let f = forge();
+ let mut revoked = Member::new(f.admin.public_openssh(), Provenance::AdminRegistered);
+ revoked.revoke();
+ write_member(&f.refs, &f.objects, "admin", &revoked, Some(&f.admin), 400);
+
+ let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 500);
+ let verdict = run(&f, "refs/meta/issues/1", Some(new));
+ expect_fail(&verdict, Requirement::TipSigned);
+ let Verdict::Fail(refusal) = &verdict else {
+ unreachable!()
+ };
+ assert!(refusal.detail.contains("revoked"), "detail: {refusal}");
+}
+
+#[rstest]
+// @relation(model.member-revocation, gate.tip-signed, scope=function, role=Verifies)
+fn signature_made_before_revocation_stays_verifiable() {
+ // The boundary is found by walking the member ref's history and
+ // deserializing the tree in force at the signature's timestamp —
+ // there is no validity-window field, by design.
+ let f = forge();
+ let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 300);
+
+ let mut revoked = Member::new(f.admin.public_openssh(), Provenance::AdminRegistered);
+ revoked.revoke();
+ write_member(&f.refs, &f.objects, "admin", &revoked, Some(&f.admin), 400);
+
+ // The gate runs *after* the revocation, on a commit signed before it.
+ expect_pass(
+ &run(&f, "refs/meta/issues/1", Some(new)),
+ AdmissionKind::TipInvariant,
+ );
+}
+
+#[rstest]
+#[case::wrong_ref(Some("refs/meta/issues/2"))]
+#[case::missing_trailer(None)]
+// @relation(gate.refname-binding, scope=function, role=Verifies)
+fn refname_binding_mismatch_is_refused(#[case] trailer: Option<&str>) {
+ let f = forge();
+ let new = proposal(&f, vec![], trailer, Some(&f.admin), 300);
+ expect_fail(
+ &run(&f, "refs/meta/issues/1", Some(new)),
+ Requirement::RefnameBinding,
+ );
+}
+
+#[rstest]
+// @relation(gate.fast-forward, scope=function, role=Verifies)
+fn non_fast_forward_is_refused() {
+ let f = forge();
+ let refname = "refs/meta/issues/1";
+ let tip = write_meta_entity(
+ &f.refs,
+ &f.objects,
+ name(refname),
+ &ents_model::Status::Pass,
+ Some(&f.admin),
+ 300,
+ );
+ // A sibling that does not descend from `tip`.
+ let sibling = proposal(&f, vec![], Some(refname), Some(&f.admin), 310);
+ assert_ne!(sibling, tip);
+ expect_fail(&run(&f, refname, Some(sibling)), Requirement::FastForward);
+}
+
+#[rstest]
+// @relation(gate.fast-forward, scope=function, role=Verifies)
+fn meta_ref_deletion_is_refused() {
+ let f = forge();
+ write_meta_entity(
+ &f.refs,
+ &f.objects,
+ name("refs/meta/issues/1"),
+ &ents_model::Status::Pass,
+ Some(&f.admin),
+ 300,
+ );
+ expect_fail(
+ &run(&f, "refs/meta/issues/1", None),
+ Requirement::FastForward,
+ );
+}
+
+#[rstest]
+// @relation(gate.principled-split, scope=function, role=Verifies)
+fn code_refs_are_not_subject_to_the_tip_invariant() {
+ let f = forge();
+ // Unsigned, trailerless, non-FF — none of it matters outside refs/meta/*.
+ let new = proposal(&f, vec![], None, None, 300);
+ expect_pass(
+ &run(&f, "refs/heads/main", Some(new)),
+ AdmissionKind::CodeRef,
+ );
+}
+
+// ---------------------------------------------------------------------
+// Provenance-keyed authorization.
+// ---------------------------------------------------------------------
+
+#[rstest]
+#[case::canonical_issue("refs/meta/issues/1", true)]
+#[case::canonical_result("refs/meta/results/unit/abc", true)]
+#[case::effects_is_admin_only("refs/meta/effects/unit", true)]
+#[case::inbox_shape_is_an_open_spec_question("refs/meta/inbox/xyz", false)]
+#[case::another_self_run("refs/meta/self/admin/unit/abc", false)]
+// @relation(model.member-provenance, effect.admin-only, gate.tip-signed, scope=function, role=Verifies)
+fn self_attested_members_are_limited_to_their_own_namespaces(
+ #[case] refname: &str,
+ #[case] inbox_alternative: bool,
+) {
+ let f = forge();
+ let new = proposal(&f, vec![], Some(refname), Some(&f.guest), 300);
+ let verdict = run(&f, refname, Some(new));
+ expect_fail(&verdict, Requirement::TipSigned);
+ let Verdict::Fail(refusal) = &verdict else {
+ unreachable!()
+ };
+ assert_eq!(
+ refusal.inbox_alternative, inbox_alternative,
+ "inbox hint for {refname}: {refusal}"
+ );
+}
+
+#[rstest]
+// @relation(meta-ref.inbox, effect.self-run, gate.tip-signed, scope=function, role=Verifies)
+fn a_member_may_write_its_own_self_run_namespace() {
+ let f = forge();
+ let refname = "refs/meta/self/guest/unit/abc123";
+ let new = proposal(&f, vec![], Some(refname), Some(&f.guest), 300);
+ expect_pass(&run(&f, refname, Some(new)), AdmissionKind::TipInvariant);
+}
+
+#[rstest]
+#[case::effects("refs/meta/effects/unit")]
+#[case::inbox("refs/meta/inbox/xyz")]
+#[case::results("refs/meta/results/unit/abc")]
+// @relation(effect.admin-only, gate.tip-signed, scope=function, role=Verifies)
+fn admin_registered_members_may_write_canonical_namespaces(#[case] refname: &str) {
+ let f = forge();
+ let new = proposal(&f, vec![], Some(refname), Some(&f.admin), 300);
+ expect_pass(&run(&f, refname, Some(new)), AdmissionKind::TipInvariant);
+}
+
+// ---------------------------------------------------------------------
+// Adoption and divergence: consequences of judging only the tip.
+// ---------------------------------------------------------------------
+
+#[rstest]
+// @relation(gate.adoption-merge, scope=function, role=Verifies)
+fn adoption_is_a_merge_that_keeps_the_contributor_commit_in_ancestry() {
+ let f = forge();
+ let refname = "refs/meta/comments/c1";
+ let tip = write_meta_entity(
+ &f.refs,
+ &f.objects,
+ name(refname),
+ &ents_model::Status::Pass,
+ Some(&f.admin),
+ 300,
+ );
+ // The contributor's own signed commit, not authorized for this ref.
+ let contributed = proposal(&f, vec![tip], Some(refname), Some(&f.guest), 310);
+ // The authorized member merges it: the merge tip satisfies the
+ // invariant; the contributor's signature survives in ancestry.
+ let merge = proposal(
+ &f,
+ vec![tip, contributed],
+ Some(refname),
+ Some(&f.admin),
+ 320,
+ );
+ expect_pass(&run(&f, refname, Some(merge)), AdmissionKind::TipInvariant);
+}
+
+#[rstest]
+// @relation(gate.adoption-no-fast-forward, scope=function, role=Verifies)
+fn fast_forwarding_to_a_contributor_commit_is_not_adoption() {
+ let f = forge();
+ let refname = "refs/meta/comments/c1";
+ let tip = write_meta_entity(
+ &f.refs,
+ &f.objects,
+ name(refname),
+ &ents_model::Status::Pass,
+ Some(&f.admin),
+ 300,
+ );
+ let contributed = proposal(&f, vec![tip], Some(refname), Some(&f.guest), 310);
+ // Descends fine — but the tip signature is the contributor's, and
+ // the contributor is not authorized for this refname.
+ expect_fail(&run(&f, refname, Some(contributed)), Requirement::TipSigned);
+}
+
+#[rstest]
+// @relation(gate.same-actor-divergence, scope=function, role=Verifies)
+fn a_members_own_divergent_heads_merge_cleanly() {
+ let f = forge();
+ let refname = "refs/meta/issues/1";
+ let tip = write_meta_entity(
+ &f.refs,
+ &f.objects,
+ name(refname),
+ &ents_model::Status::Pass,
+ Some(&f.admin),
+ 300,
+ );
+ // Two of the member's own machines raced the single-writer ref.
+ let a = proposal(&f, vec![tip], Some(refname), Some(&f.admin), 310);
+ let b = proposal(&f, vec![tip], Some(refname), Some(&f.admin), 311);
+ // Either head alone is a non-fast-forward once the other landed;
+ // the resolution is the member merging their own heads.
+ let merge = proposal(&f, vec![a, b], Some(refname), Some(&f.admin), 320);
+ expect_pass(&run(&f, refname, Some(merge)), AdmissionKind::TipInvariant);
+}
+
+// ---------------------------------------------------------------------
+// The verification epoch.
+// ---------------------------------------------------------------------
+
+#[rstest]
+// @relation(gate.epoch, scope=function, role=Verifies)
+fn before_any_epoch_meta_history_is_archival() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let f = Forge {
+ refs,
+ objects,
+ admin: Keypair::from_seed(ADMIN_SEED),
+ guest: Keypair::from_seed(SELF_ATTESTED_SEED),
+ };
+ // No config, no members: an unsigned meta write passes as pre-epoch.
+ let new = proposal(&f, vec![], None, None, 100);
+ expect_pass(
+ &run(&f, "refs/meta/issues/1", Some(new)),
+ AdmissionKind::PreEpoch,
+ );
+}
+
+#[rstest]
+// @relation(gate.epoch, scope=function, role=Verifies)
+fn the_epoch_setting_commit_is_itself_the_first_gated_tip() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let admin = Keypair::from_seed(ADMIN_SEED);
+ enroll_member(
+ &refs,
+ &objects,
+ "admin",
+ &admin,
+ Provenance::AdminRegistered,
+ 100,
+ );
+ let f = Forge {
+ refs,
+ objects,
+ admin,
+ guest: Keypair::from_seed(SELF_ATTESTED_SEED),
+ };
+
+ let tree = facet_git_tree::serialize_into(&Config { epoch: Some(200) }, &f.objects)
+ .expect("config serializes");
+ let make = |key: Option<&Keypair>| {
+ let trailers = Trailers {
+ ents_ref: Some(name(namespace::CONFIG_REF)),
+ schema_version: None,
+ };
+ write_commit(
+ &f.objects,
+ &CommitSpec {
+ tree,
+ parents: vec![],
+ message: format!("enable verification\n\n{}", trailers.render()),
+ seconds: 200,
+ },
+ key,
+ )
+ };
+
+ // Unsigned epoch-setting is refused: the circularity resolves by
+ // gating the very commit that turns gating on.
+ expect_fail(
+ &run(&f, namespace::CONFIG_REF, Some(make(None))),
+ Requirement::TipSigned,
+ );
+ // Signed by an enrolled member, it passes under the tip invariant.
+ expect_pass(
+ &run(&f, namespace::CONFIG_REF, Some(make(Some(&f.admin)))),
+ AdmissionKind::TipInvariant,
+ );
+}
+
+// ---------------------------------------------------------------------
+// Bootstrap: fail-closed empty-member-list handling.
+// ---------------------------------------------------------------------
+
+/// A store with verification in force but no members at all — the shape
+/// a hosted deployment initializes (`roots.bootstrap` owns hardening).
+fn bare_forge_with_epoch() -> Forge {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let config_ref: FullName = namespace::CONFIG_REF.try_into().expect("valid");
+ write_meta_entity(
+ &refs,
+ &objects,
+ config_ref,
+ &Config { epoch: Some(50) },
+ None,
+ 50,
+ );
+ Forge {
+ refs,
+ objects,
+ admin: Keypair::from_seed(ADMIN_SEED),
+ guest: Keypair::from_seed(SELF_ATTESTED_SEED),
+ }
+}
+
+fn enrollment_proposal(f: &Forge, id: &str, enrolled: &Keypair, signer: &Keypair) -> ObjectId {
+ let member = Member::new(enrolled.public_openssh(), Provenance::AdminRegistered);
+ let tree = facet_git_tree::serialize_into(&member, &f.objects).expect("member serializes");
+ let refname = namespace::member_ref(&MemberId::new(id)).expect("valid id");
+ let trailers = Trailers {
+ ents_ref: Some(refname),
+ schema_version: None,
+ };
+ write_commit(
+ &f.objects,
+ &CommitSpec {
+ tree,
+ parents: vec![],
+ message: format!("enroll {id}\n\n{}", trailers.render()),
+ seconds: 100,
+ },
+ Some(signer),
+ )
+}
+
+#[rstest]
+// @relation(gate.bootstrap, scope=function, role=Verifies)
+fn first_enrollment_is_self_admitting() {
+ let f = bare_forge_with_epoch();
+ let new = enrollment_proposal(&f, "first", &f.admin, &f.admin);
+ expect_pass(
+ &run(&f, "refs/meta/member/first", Some(new)),
+ AdmissionKind::Bootstrap,
+ );
+}
+
+#[rstest]
+// @relation(gate.bootstrap, scope=function, role=Verifies)
+fn bootstrap_enrollment_must_be_signed_by_the_key_it_enrolls() {
+ let f = bare_forge_with_epoch();
+ let other = Keypair::from_seed(OUTSIDER_SEED);
+ let new = enrollment_proposal(&f, "first", &f.admin, &other);
+ expect_fail(
+ &run(&f, "refs/meta/member/first", Some(new)),
+ Requirement::TipSigned,
+ );
+}
+
+#[rstest]
+// @relation(gate.bootstrap, scope=function, role=Verifies)
+fn bootstrap_admits_only_enrollments() {
+ let f = bare_forge_with_epoch();
+ let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 100);
+ expect_fail(
+ &run(&f, "refs/meta/issues/1", Some(new)),
+ Requirement::TipSigned,
+ );
+}
+
+#[rstest]
+// @relation(gate.bootstrap, model.member-revocation, scope=function, role=Verifies)
+fn revoking_every_key_does_not_reopen_the_bootstrap_window() {
+ let f = forge();
+ for id in ["admin", "guest"] {
+ let key = if id == "admin" { &f.admin } else { &f.guest };
+ let provenance = if id == "admin" {
+ Provenance::AdminRegistered
+ } else {
+ Provenance::SelfAttested
+ };
+ let mut member = Member::new(key.public_openssh(), provenance);
+ member.revoke();
+ write_member(&f.refs, &f.objects, id, &member, Some(&f.admin), 400);
+ }
+ // A would-be new member self-enrolling: the member set is non-empty
+ // (though fully revoked), so the self-admitting window stays shut.
+ let newcomer = Keypair::from_seed(OUTSIDER_SEED);
+ let new = enrollment_proposal(&f, "newcomer", &newcomer, &newcomer);
+ expect_fail(
+ &run(&f, "refs/meta/member/newcomer", Some(new)),
+ Requirement::TipSigned,
+ );
+ // And the fully-revoked members cannot write anything either.
+ let attempt = proposal(&f, vec![], Some("refs/meta/issues/9"), Some(&f.admin), 500);
+ expect_fail(
+ &run(&f, "refs/meta/issues/9", Some(attempt)),
+ Requirement::TipSigned,
+ );
+}
+
+// ---------------------------------------------------------------------
+// CAS binding, call sites, and offline reproducibility.
+// ---------------------------------------------------------------------
+
+#[rstest]
+// @relation(gate.atomic-cas, scope=function, role=Verifies)
+fn the_admission_carries_the_cas_precondition_from_the_same_read() {
+ let f = forge();
+ let refname = "refs/meta/issues/1";
+
+ // Creation: the ref must still be absent at write time.
+ let created = proposal(&f, vec![], Some(refname), Some(&f.admin), 300);
+ let Verdict::Pass(admission) = run(&f, refname, Some(created)) else {
+ panic!("expected a pass");
+ };
+ assert_eq!(admission.cas, Expected::MustNotExist);
+
+ // Update: the precondition is exactly the old tip the FF check used.
+ let tip = write_meta_entity(
+ &f.refs,
+ &f.objects,
+ name(refname),
+ &ents_model::Status::Pass,
+ Some(&f.admin),
+ 310,
+ );
+ let advanced = proposal(&f, vec![tip], Some(refname), Some(&f.admin), 320);
+ let Verdict::Pass(admission) = run(&f, refname, Some(advanced)) else {
+ panic!("expected a pass");
+ };
+ assert_eq!(admission.cas, Expected::MustExistAndMatch(tip));
+}
+
+/// Every scenario the verdict table distinguishes, evaluated the way
+/// each of the three call sites would evaluate it — hosted CAS on the
+/// live store, local UI verdict on the same store, and push pre-flight
+/// on a fetched copy of the refs — in one parameterized test: the gate
+/// is one function, and its verdict is identical at every site.
+#[rstest]
+#[case::authorized_pass("refs/meta/issues/1", true, true, 300)]
+#[case::unsigned_fail("refs/meta/issues/1", false, true, 300)]
+#[case::unauthorized_namespace("refs/meta/effects/unit", true, false, 300)]
+#[case::self_run_pass("refs/meta/self/guest/unit/abc", true, false, 300)]
+// @relation(gate.call-sites, gate.mandatory-hosted, gate.advisory-local, scope=function, role=Verifies)
+fn all_three_call_sites_return_identical_verdicts(
+ #[case] refname: &str,
+ #[case] signed: bool,
+ #[case] as_admin: bool,
+ #[case] seconds: i64,
+) {
+ let f = forge();
+ let key = if as_admin { &f.admin } else { &f.guest };
+ let new = proposal(&f, vec![], Some(refname), signed.then_some(key), seconds);
+ let update = Update {
+ name: name(refname),
+ new: Some(new),
+ };
+
+ // Call site 1: hosted CAS time (mandatory — the caller aborts on Fail).
+ let hosted = verify(&f.refs, &f.objects, &update).expect("verdict");
+ // Call site 2: the local UI verdict (advisory — annotates the write).
+ let local = verify(&f.refs, &f.objects, &update).expect("verdict");
+ // Call site 3: push pre-flight, against a fetched copy of the refs.
+ let fetched = f.refs.fetched_copy();
+ let preflight = verify(&fetched, &f.objects, &update).expect("verdict");
+
+ assert_eq!(hosted, local, "hosted vs local");
+ assert_eq!(hosted, preflight, "hosted vs pre-flight");
+}
+
+#[rstest]
+// @relation(gate.signature-artifact, gate.policy-as-state, scope=function, role=Verifies)
+fn verdicts_reproduce_offline_from_repository_state_alone() {
+ // Build the identical repository twice from deterministic seeds —
+ // the fixture analogue of verifying in an independent clone. The
+ // verdict depends only on refs/meta/* state and object bytes, so
+ // both "clones" agree, with no transport artifact consulted.
+ let build = || {
+ let f = forge();
+ let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.admin), 300);
+ (f, new)
+ };
+ let (origin, new_at_origin) = build();
+ let (clone, new_at_clone) = build();
+ assert_eq!(new_at_origin, new_at_clone, "deterministic fixtures");
+
+ let update = Update {
+ name: name("refs/meta/issues/1"),
+ new: Some(new_at_origin),
+ };
+ let at_origin = verify(&origin.refs, &origin.objects, &update).expect("verdict");
+ let at_clone = verify(&clone.refs, &clone.objects, &update).expect("verdict");
+ assert_eq!(at_origin, at_clone);
+}
+
+#[rstest]
+// @relation(gate.verdict-reason, gate.advisory-local, scope=function, role=Verifies)
+fn refusals_render_an_actionable_reason_with_the_inbox_alternative() {
+ let f = forge();
+ let new = proposal(&f, vec![], Some("refs/meta/issues/1"), Some(&f.guest), 300);
+ let Verdict::Fail(refusal) = run(&f, "refs/meta/issues/1", Some(new)) else {
+ panic!("self-attested member on a canonical ref must be refused");
+ };
+ let rendered = refusal.to_string();
+ assert!(
+ rendered.contains("gate.tip-signed"),
+ "names the rule: {rendered}"
+ );
+ assert!(
+ rendered.contains("refs/meta/issues/1"),
+ "names the subject ref: {rendered}"
+ );
+ assert!(
+ rendered.contains("refs/meta/inbox"),
+ "surfaces the inbox alternative at verdict time: {rendered}"
+ );
+}
+
+#[rstest]
+// @relation(gate.epoch, scope=function, role=Verifies)
+fn config_round_trips_with_and_without_an_epoch() {
+ for config in [Config { epoch: None }, Config { epoch: Some(42) }] {
+ let (root, store) = facet_git_tree::serialize(&config).expect("serialize");
+ let back: Config = facet_git_tree::deserialize(&root, &store).expect("deserialize");
+ assert_eq!(back, config);
+ }
+}
+
+#[rstest]
+fn fixture_stores_read_back_what_they_seed() {
+ // Guards the fixture itself: the enrolled member refs exist and the
+ // ref store returns them through the production read trait.
+ let f = forge();
+ let member_ref = name("refs/meta/member/admin");
+ assert!(f.refs.get(member_ref.as_ref()).expect("readable").is_some());
+}