Replace the Advance-ref trailer compare with identity_binding
(gate.identity-binding): recompute the expected refname per namespace
from the tip’s signed content and refuse a mismatch — a natural-key
tree field, a hash-identified genesis oid enforced by the all-roots
parentless-commit walk (which blocks replaying a signed mutation as a
doppelgänger genesis and holds across sync-created merges), a composite
review/result key read generically by tree-entry name, an inbox owner,
and strict genesis decode of a result. Add owner_mutation
(gate.owner-mutation) keying an advance to the genesis signer (∪
admins) or, for a review, the member the refname names. The bootstrap
window binds by the enrolled member’s own id field.
feat: add identity_binding recomputing refnames, renaming the verdict
variant RefnameBinding to IdentityBinding (gate.identity-binding)
feat: add gate.owner-mutation keying advances to genesis signer or
named reviewer
remove: the gate.refname-binding trailer parse and bootstrap re-check
Assisted-by: Claude:claude-opus-4-8
No reviews of this commit yet — record a verdict below.
Start a review
crates/kernel/ents-gate/src/lib.rs
@@ -16,8 +16,15 @@
//!
//! From `docs/spec/gate.sdoc`:
//!
-//! - `gate.tip-signed`, `gate.refname-binding`, `gate.fast-forward` —
-//! [`verify`].
+//! - `gate.tip-signed`, `gate.identity-binding`, `gate.owner-mutation`,
+//! `gate.fast-forward` — [`verify`]. Identity binding recomputes each
+//! refname from the tip's signed content per namespace
+//! (`meta-ref.identity-binding`) — a natural-key tree field, a
+//! hash-identified genesis oid enforced by the all-roots walk, a
+//! composite review/result key, an inbox owner — and strictly decodes a
+//! genesis tree, an unknown entry refusing; owner mutation keys an
+//! advance to the genesis signer (∪ admins) or, for a review, the
+//! member the refname names.
//! - `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.
crates/kernel/ents-gate/src/object.rs
@@ -3,7 +3,7 @@
//! traits are the object seam; no private store trait).
use gix_hash::ObjectId;
-use gix_object::{CommitRef, Find, Kind};
+use gix_object::{CommitRef, Find, Kind, TreeRef};
use crate::error::{Error, Result};
@@ -17,8 +17,6 @@
pub tree: ObjectId,
/// Parents, in order.
pub parents: Vec<ObjectId>,
- /// The full commit message, for trailer parsing.
- pub message: Vec<u8>,
}
/// Read `oid` and decode it as a commit; `Ok(None)` when the object
@@ -34,25 +32,16 @@
return Ok(None);
}
let raw = data.data.to_vec();
- let (tree, parents, message) = decode_commit(&raw, oid)?;
- Ok(Some(CommitData {
- tree,
- parents,
- message,
- raw,
- }))
+ let (tree, parents) = decode_commit(&raw, oid)?;
+ Ok(Some(CommitData { tree, parents, raw }))
}
-fn decode_commit(raw: &[u8], oid: ObjectId) -> Result<(ObjectId, Vec<ObjectId>, Vec<u8>)> {
+fn decode_commit(raw: &[u8], oid: ObjectId) -> Result<(ObjectId, Vec<ObjectId>)> {
let commit = CommitRef::from_bytes(raw, oid.kind()).map_err(|e| Error::Decode {
oid,
detail: e.to_string(),
})?;
- Ok((
- commit.tree(),
- commit.parents().collect(),
- commit.message.to_vec(),
- ))
+ Ok((commit.tree(), commit.parents().collect()))
}
/// Like [`read_commit`], but a non-commit is an [`Error::Decode`] —
@@ -64,6 +53,103 @@
})
}
+/// The raw bytes of the top-level tree entry named `entry`, or `None`
+/// when `tree` has no such entry (`gate.identity-binding`: the gate reads
+/// a binding field by tree-entry name, generically — it never decodes a
+/// non-kernel entity type to recompute a refname, so it can bind a
+/// review's `target` or a toolchain's `name` without depending on the
+/// crate that owns that struct).
+///
+/// A named struct field serializes to a tree entry keyed by the field
+/// name (`facet-git-tree`'s struct-to-tree mapping); a scalar field's
+/// blob is its textual form, and a raw `[u8; 20]` oid field's blob is the
+/// 20 raw bytes. This reads exactly that blob.
+pub(crate) fn read_tree_entry(
+ objects: &dyn Find,
+ tree: ObjectId,
+ entry: &str,
+) -> Result<Option<Vec<u8>>> {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&tree, &mut buf)
+ .map_err(|source| Error::Object { oid: tree, source })?
+ .ok_or(Error::Missing { oid: tree })?;
+ if data.kind != Kind::Tree {
+ return Ok(None);
+ }
+ let tree = TreeRef::from_bytes(data.data, tree.kind()).map_err(|e| Error::Decode {
+ oid: tree,
+ detail: e.to_string(),
+ })?;
+ let child = tree
+ .entries
+ .iter()
+ .find(|e| e.filename == entry.as_bytes())
+ .map(|e| e.oid.to_owned());
+ let Some(child) = child else {
+ return Ok(None);
+ };
+ let mut blob_buf = Vec::new();
+ let blob = objects
+ .try_find(&child, &mut blob_buf)
+ .map_err(|source| Error::Object { oid: child, source })?
+ .ok_or(Error::Missing { oid: child })?;
+ Ok(Some(blob.data.to_vec()))
+}
+
+/// The names of every top-level entry in `tree`, for the strict-decode
+/// disjointness check (`gate.identity-binding`: a genesis tree with an
+/// entry that is not one of its entity type's fields refuses).
+pub(crate) fn tree_entry_names(objects: &dyn Find, tree: ObjectId) -> Result<Vec<String>> {
+ let mut buf = Vec::new();
+ let data = objects
+ .try_find(&tree, &mut buf)
+ .map_err(|source| Error::Object { oid: tree, source })?
+ .ok_or(Error::Missing { oid: tree })?;
+ if data.kind != Kind::Tree {
+ return Ok(Vec::new());
+ }
+ let parsed = TreeRef::from_bytes(data.data, tree.kind()).map_err(|e| Error::Decode {
+ oid: tree,
+ detail: e.to_string(),
+ })?;
+ Ok(parsed
+ .entries
+ .iter()
+ .map(|e| String::from_utf8_lossy(e.filename).into_owned())
+ .collect())
+}
+
+/// Every parentless commit reachable from `tip` by parent edges — the
+/// genesis roots of a hash-identified entity's history
+/// (`meta-ref.identity-binding`'s all-roots rule, `gate.identity-binding`).
+///
+/// Replaying a signed mutation commit as a fresh genesis is refused
+/// because the walk reaches the original genesis (that mutation's own
+/// parentless ancestor), not the replayed commit — a creation-time-only
+/// check could not tell them apart. The walk descends through *every*
+/// parent, so it holds across the merge commits divergence resolution and
+/// adoption create (`gate.same-actor-divergence`, `gate.adoption-merge`).
+/// A missing or non-commit object simply ends its path, exactly as
+/// [`descends_from`] treats one: at pre-flight, history below the last
+/// fetch may be shallow, and this is an advisory prediction there.
+pub(crate) fn all_roots(objects: &dyn Find, tip: ObjectId) -> Result<Vec<ObjectId>> {
+ let mut queue = vec![tip];
+ let mut seen = std::collections::HashSet::new();
+ let mut roots = Vec::new();
+ while let Some(oid) = queue.pop() {
+ if !seen.insert(oid) {
+ continue;
+ }
+ match read_commit(objects, oid)? {
+ Some(commit) if commit.parents.is_empty() => roots.push(oid),
+ Some(commit) => queue.extend(commit.parents),
+ None => {}
+ }
+ }
+ Ok(roots)
+}
+
/// Whether `ancestor` is reachable from `descendant` by parent edges
/// (inclusive: a commit descends from itself) — the DAG sense of
/// `gate.fast-forward`.
crates/kernel/ents-gate/src/verdict.rs
@@ -27,9 +27,12 @@
/// `gate.tip-signed`: the new tip must be signed by a member
/// authorized for the refname.
TipSigned,
- /// `gate.refname-binding`: the commit's `Advance-ref:` trailer must
- /// match the refname being updated.
- RefnameBinding,
+ /// `gate.identity-binding`: the refname must be recomputable from the
+ /// proposed tip's signed content, per namespace
+ /// (`meta-ref.identity-binding`) — a mismatch, a doppelgänger genesis
+ /// (the all-roots walk), or a genesis tree with an unknown entry
+ /// (strict decode) all refuse here.
+ IdentityBinding,
/// `gate.fast-forward`: the new tip must descend from the old tip.
FastForward,
/// `gate.atomic-cas`: the update must commit via compare-and-swap
@@ -43,7 +46,7 @@
pub fn uid(&self) -> &'static str {
match self {
Self::TipSigned => "gate.tip-signed",
- Self::RefnameBinding => "gate.refname-binding",
+ Self::IdentityBinding => "gate.identity-binding",
Self::FastForward => "gate.fast-forward",
Self::AtomicCas => "gate.atomic-cas",
}
crates/kernel/ents-gate/src/verify.rs
@@ -3,8 +3,8 @@
//! `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 ents_model::{Member, MemberId, MemberState, Provenance, ResultRecord};
+use facet::{Facet, Type, UserType};
use gix::refs::FullName;
use gix_hash::ObjectId;
use gix_object::Find;
@@ -12,8 +12,10 @@
use crate::config;
use crate::error::Result;
-use crate::object::{CommitData, descends_from, read_commit};
-use crate::policy;
+use crate::object::{
+ CommitData, all_roots, descends_from, read_commit, read_tree_entry, tree_entry_names,
+};
+use crate::policy::{self, Enrolled};
use crate::signature;
use crate::verdict::{Admission, AdmissionKind, Refusal, Requirement, Verdict};
@@ -64,9 +66,18 @@
/// semantics — no commit-supplied timestamp participates) and whose
/// provenance authorizes this refname (`model.member-provenance`,
/// `effect.admin-only`).
-/// 2. `gate.refname-binding` — the commit's `Advance-ref:` trailer names
-/// exactly this ref.
-/// 3. `gate.fast-forward` — the new tip descends from the current tip.
+/// 2. `gate.identity-binding` — the refname is recomputed from the
+/// proposed tip's signed content, per namespace exactly as
+/// `meta-ref.identity-binding` tabulates (a natural-key tree field, a
+/// hash-identified genesis oid enforced by the all-roots walk, a
+/// composite review/result key, an inbox owner), and must match
+/// `update.name`; a hash-identified or composite genesis additionally
+/// strictly decodes as its entity type, an unknown tree entry
+/// refusing.
+/// 3. `gate.owner-mutation` — a hash-identified entity's ref advances
+/// only under its genesis signer (∪ admins); a review advances only
+/// under the member its refname names. Creation stays provenance-keyed.
+/// 4. `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
@@ -96,7 +107,7 @@
/// 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)
+// @relation(gate.tip-signed, gate.identity-binding, gate.owner-mutation, 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);
@@ -221,23 +232,26 @@
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 Advance-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.identity-binding: recompute the refname from the tip's signed
+ // content per namespace and refuse a mismatch — a signed commit
+ // cannot be replayed as the tip of a different meta-ref than the one
+ // its content names. This is what the retired Advance-ref trailer
+ // used to assert by side channel; now the refname is a total function
+ // of the tree, the genesis oid, and the signer.
+ // @relation(gate.identity-binding, scope=function)
+ if let Some(refusal) = identity_binding(objects, &update.name, new, &commit, &id)? {
+ return Ok(Verdict::Fail(refusal));
+ }
+
+ // gate.owner-mutation: a hash-identified entity's ref advances only
+ // under its genesis signer (∪ admins); a review advances only under
+ // the member its refname names. Creation stays provenance-keyed,
+ // already judged by `authorize` above.
+ // @relation(gate.owner-mutation, scope=function)
+ if let Some(refusal) =
+ owner_mutation(objects, &update.name, old, new, &members, &id, &member)?
+ {
+ return Ok(Verdict::Fail(refusal));
}
// gate.fast-forward: the parent hash is the anti-replay freshness
@@ -391,12 +405,23 @@
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) {
+ // The same natural-key identity binding as the ordinary path
+ // (`gate.identity-binding`, `model.member-identity`): the enrolled
+ // member's own id field must recompute the refname being written, so
+ // even a bootstrap write names its ref from signed content, not a
+ // trailer.
+ // @relation(gate.identity-binding, scope=function)
+ let bound = namespace::member_ref(&pushed.id).ok();
+ if bound.as_ref() != Some(&update.name) {
return Ok(Verdict::Fail(Refusal {
- requirement: Requirement::RefnameBinding,
+ requirement: Requirement::IdentityBinding,
refname: update.name.clone(),
- detail: "the enrollment commit's Advance-ref trailer does not name this ref".into(),
+ detail: format!(
+ "the enrollment's id field is {}, which names {}, not {}",
+ pushed.id,
+ bound.map_or_else(|| "an invalid ref".to_owned(), |n| n.as_bstr().to_string()),
+ update.name.as_bstr()
+ ),
inbox_alternative: false,
}));
}
@@ -416,3 +441,377 @@
cas: cas.clone(),
}))
}
+
+/// Build an [`Requirement::IdentityBinding`] refusal for `name`.
+fn binding_refusal(name: &FullName, detail: String) -> Option<Refusal> {
+ Some(Refusal {
+ requirement: Requirement::IdentityBinding,
+ refname: name.clone(),
+ detail,
+ inbox_alternative: false,
+ })
+}
+
+/// The value of a scalar tree field as UTF-8, or `None` if the entry is
+/// absent or not valid UTF-8.
+fn field_str(objects: &dyn Find, tree: ObjectId, field: &str) -> Result<Option<String>> {
+ Ok(read_tree_entry(objects, tree, field)?
+ .and_then(|bytes| String::from_utf8(bytes).ok()))
+}
+
+/// The hex form of a raw-oid (`[u8; 20]`) tree field, or `None` when the
+/// entry is absent or not 20 bytes.
+fn field_oid_hex(objects: &dyn Find, tree: ObjectId, field: &str) -> Result<Option<String>> {
+ Ok(read_tree_entry(objects, tree, field)?.and_then(|bytes| {
+ (bytes.len() == 20).then(|| ObjectId::from_bytes_or_panic(&bytes).to_string())
+ }))
+}
+
+/// The final `/`-delimited segment of a refname.
+fn final_segment(name: &FullName) -> String {
+ name.as_bstr()
+ .to_string()
+ .rsplit('/')
+ .next()
+ .unwrap_or_default()
+ .to_owned()
+}
+
+/// A natural-key binding: the tree field `field` must equal `expected`
+/// (the refname's final segment).
+fn bind_natural_key(
+ objects: &dyn Find,
+ name: &FullName,
+ tree: ObjectId,
+ field: &str,
+ expected: &str,
+) -> Result<Option<Refusal>> {
+ match field_str(objects, tree, field)? {
+ Some(value) if value == expected => Ok(None),
+ Some(value) => Ok(binding_refusal(
+ name,
+ format!(
+ "the tree's `{field}` field is `{value}`, which names a different ref than {}",
+ name.as_bstr()
+ ),
+ )),
+ None => Ok(binding_refusal(
+ name,
+ format!("the tree carries no `{field}` field to bind {}", name.as_bstr()),
+ )),
+ }
+}
+
+/// A hash-identified binding: the refname's final segment must be the
+/// genesis commit's oid, and every parentless commit reachable from the
+/// proposed tip must be that genesis (`meta-ref.identity-binding`'s
+/// all-roots rule). This, not a creation-time-only check, is what refuses
+/// replaying a signed mutation commit as a doppelgänger genesis.
+fn bind_hash_identified(
+ objects: &dyn Find,
+ name: &FullName,
+ new: ObjectId,
+) -> Result<Option<Refusal>> {
+ 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"),
+ ));
+ };
+ let roots = all_roots(objects, new)?;
+ if roots.is_empty() {
+ return Ok(binding_refusal(
+ name,
+ "the proposed tip has no readable genesis root to bind its id".into(),
+ ));
+ }
+ for root in roots {
+ if root != expected {
+ return Ok(binding_refusal(
+ name,
+ format!(
+ "a parentless commit {root} reachable from the proposed tip is not the \
+ genesis {expected} the refname names — a signed mutation cannot be replayed \
+ as a new entity's genesis"
+ ),
+ ));
+ }
+ }
+ Ok(None)
+}
+
+/// Reject a genesis tree that carries an entry which is not a field of its
+/// namespace's entity type, or that does not decode as that type at all
+/// (`gate.identity-binding`: strict genesis decode — the pairwise-disjoint
+/// structs, held by test, are what let this stand in for a stored
+/// `.schema` marker).
+fn strict_decode<T: for<'facet> Facet<'facet>>(
+ objects: &dyn Find,
+ name: &FullName,
+ tree: ObjectId,
+) -> Result<Option<Refusal>> {
+ let Type::User(UserType::Struct(st)) = T::SHAPE.ty else {
+ return Ok(None);
+ };
+ let fields: Vec<&str> = st.fields.iter().map(|f| f.name).collect();
+ for entry in tree_entry_names(objects, tree)? {
+ if !fields.contains(&entry.as_str()) {
+ return Ok(binding_refusal(
+ name,
+ format!(
+ "the genesis tree carries an unknown entry `{entry}` for a \
+ {} entity; strict decode refuses it",
+ T::SHAPE.type_identifier
+ ),
+ ));
+ }
+ }
+ if facet_git_tree::deserialize::<T>(&tree, objects).is_err() {
+ return Ok(binding_refusal(
+ name,
+ format!(
+ "the genesis tree does not decode as a {} entity",
+ T::SHAPE.type_identifier
+ ),
+ ));
+ }
+ Ok(None)
+}
+
+/// Recompute `name` from the proposed tip's signed content, per namespace
+/// exactly as `meta-ref.identity-binding` tabulates, returning a refusal
+/// on mismatch (`gate.identity-binding`). `None` means the binding holds.
+///
+/// The recomputation reads binding fields by tree-entry name generically
+/// (`read_tree_entry`), so it never depends on a non-kernel entity crate
+/// to bind a review's target or a toolchain's name; the one exception is
+/// strict genesis decode of a result, whose type this crate owns.
+// @relation(gate.identity-binding, meta-ref.identity-binding, scope=function)
+fn identity_binding(
+ objects: &dyn Find,
+ name: &FullName,
+ new: ObjectId,
+ commit: &CommitData,
+ signer: &MemberId,
+) -> Result<Option<Refusal>> {
+ let Some(namespace) = namespace::classify(name.as_ref()) else {
+ return Ok(None);
+ };
+ let is_genesis = commit.parents.is_empty();
+ match namespace {
+ // Singleton state binds by its fixed name, which `classify`
+ // already established, and an unknown namespace cannot be bound by
+ // a vocabulary that does not know it (`model.extensibility`).
+ Namespace::Account | Namespace::Config | Namespace::Unknown => Ok(None),
+ Namespace::Member => {
+ bind_natural_key(objects, name, commit.tree, "id", &final_segment(name))
+ }
+ Namespace::Effect | Namespace::Toolchain => {
+ bind_natural_key(objects, name, commit.tree, "name", &final_segment(name))
+ }
+ Namespace::Comment | Namespace::Issue => bind_hash_identified(objects, name, new),
+ Namespace::Review => {
+ let Some((target, member)) = namespace::parse_review_ref(name.as_ref()) else {
+ return Ok(binding_refusal(
+ name,
+ "not a well-formed reviews/<target>/<member> refname".into(),
+ ));
+ };
+ match field_oid_hex(objects, commit.tree, "target")? {
+ Some(hex) if hex == target => {}
+ other => {
+ return Ok(binding_refusal(
+ name,
+ format!(
+ "the review's target field {} does not name the reviewed commit \
+ {target} in its refname",
+ other.unwrap_or_else(|| "(absent)".into())
+ ),
+ ));
+ }
+ }
+ if signer.as_str() != member.as_str() {
+ return Ok(binding_refusal(
+ name,
+ format!(
+ "the review is signed by {signer}, not the reviewer {member} its \
+ refname names"
+ ),
+ ));
+ }
+ Ok(None)
+ }
+ Namespace::Result | Namespace::SelfRun => {
+ let Some((effect, short_oid)) = namespace::parse_result_ref(name.as_ref()) else {
+ return Ok(binding_refusal(
+ name,
+ "not a well-formed results/<effect>/<short-oid> refname".into(),
+ ));
+ };
+ match field_str(objects, commit.tree, "effect")? {
+ Some(value) if value == effect => {}
+ other => {
+ return Ok(binding_refusal(
+ name,
+ format!(
+ "the result's effect field {} does not name {effect} in its refname",
+ other.unwrap_or_else(|| "(absent)".into())
+ ),
+ ));
+ }
+ }
+ match field_oid_hex(objects, commit.tree, "target")? {
+ Some(hex) if hex.starts_with(&short_oid) => {}
+ other => {
+ return Ok(binding_refusal(
+ name,
+ format!(
+ "the result's target field {} does not begin with the short oid \
+ {short_oid} in its refname",
+ other.unwrap_or_else(|| "(absent)".into())
+ ),
+ ));
+ }
+ }
+ if namespace == Namespace::SelfRun
+ && namespace::self_run_owner(name.as_ref()).as_ref() != Some(signer)
+ {
+ return Ok(binding_refusal(
+ name,
+ format!("a self-run result under refs/meta/self/* must be signed by {signer}"),
+ ));
+ }
+ if is_genesis
+ && let Some(refusal) = strict_decode::<ResultRecord>(objects, name, commit.tree)?
+ {
+ return Ok(Some(refusal));
+ }
+ Ok(None)
+ }
+ // A pin mirrors its entity's segments by construction and carries
+ // the empty tree; its ancestry deliberately reaches into code
+ // history, so the all-roots walk is NEVER applied to it
+ // (`meta-ref.identity-binding`).
+ Namespace::Pin => Ok(None),
+ // 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
+ // synthesized canonical refname.
+ Namespace::Inbox => {
+ if namespace::inbox_owner(name.as_ref()).as_ref() != Some(signer) {
+ return Ok(binding_refusal(
+ name,
+ format!("an inbox ref's owner segment must equal its signer {signer}"),
+ ));
+ }
+ let path = name.as_bstr().to_string();
+ let Some(rest) = path.strip_prefix("refs/meta/inbox/") else {
+ return Ok(None);
+ };
+ let Some((_, suffix)) = rest.split_once('/') else {
+ return Ok(None);
+ };
+ let Ok(canonical) = FullName::try_from(format!("refs/meta/{suffix}")) else {
+ return Ok(None);
+ };
+ identity_binding(objects, &canonical, new, commit, signer)
+ }
+ // `Namespace` is `#[non_exhaustive]`; a variant this build does
+ // not know is treated like `Unknown` — unbindable by a vocabulary
+ // that cannot interpret it (`model.extensibility`).
+ _ => Ok(None),
+ }
+}
+
+/// Ownership keys mutation (`gate.owner-mutation`): a hash-identified
+/// entity's ref advances only under its genesis signer or an
+/// admin-registered member; a review advances only under the member its
+/// refname names. Creation stays provenance-keyed (judged by `authorize`),
+/// so this fires only on an advance.
+// @relation(gate.owner-mutation, scope=function)
+fn owner_mutation(
+ objects: &dyn Find,
+ name: &FullName,
+ old: Option<ObjectId>,
+ new: ObjectId,
+ members: &[Enrolled],
+ signer_id: &MemberId,
+ signer: &Member,
+) -> Result<Option<Refusal>> {
+ let Some(namespace) = namespace::classify(name.as_ref()) else {
+ return Ok(None);
+ };
+ let refuse = |detail: String| {
+ Some(Refusal {
+ requirement: Requirement::TipSigned,
+ refname: name.clone(),
+ detail,
+ inbox_alternative: false,
+ })
+ };
+ let is_admin = signer.provenance == Provenance::AdminRegistered;
+ match namespace {
+ Namespace::Comment | Namespace::Issue => {
+ // Creation is provenance-keyed; only an advance is owner-keyed.
+ if old.is_none() {
+ return Ok(None);
+ }
+ if is_admin {
+ return Ok(None);
+ }
+ let genesis = all_roots(objects, new)?;
+ let genesis_signer = match genesis.first() {
+ Some(root) => commit_signer(objects, members, *root)?,
+ None => None,
+ };
+ if genesis_signer.as_ref() == Some(signer_id) {
+ Ok(None)
+ } else {
+ Ok(refuse(format!(
+ "{signer_id} is neither the member whose signature this entity's genesis \
+ carries nor an admin-registered member, so may not advance {}",
+ name.as_bstr()
+ )))
+ }
+ }
+ Namespace::Review => {
+ let Some((_, member)) = namespace::parse_review_ref(name.as_ref()) else {
+ return Ok(None);
+ };
+ if signer_id.as_str() == member.as_str() {
+ Ok(None)
+ } else {
+ Ok(refuse(format!(
+ "a review advances only under the signature of {member}, the reviewer its \
+ refname names, not {signer_id}"
+ )))
+ }
+ }
+ _ => Ok(None),
+ }
+}
+
+/// The enrolled member whose currently-in-force key signed `oid`, or
+/// `None` when the commit is unsigned or signed by no enrolled member —
+/// used to recover a hash-identified entity's genesis signer
+/// (`gate.owner-mutation`).
+fn commit_signer(
+ objects: &dyn Find,
+ members: &[Enrolled],
+ oid: ObjectId,
+) -> Result<Option<MemberId>> {
+ let Some(commit) = read_commit(objects, oid)? else {
+ return Ok(None);
+ };
+ let Some((payload, sig)) = signature::split_signed(&commit.raw) else {
+ return Ok(None);
+ };
+ for enrolled in members {
+ let member = policy::member_current(objects, enrolled.tip)?;
+ if signature::verifies(&member.key, &payload, &sig) {
+ return Ok(Some(enrolled.id.clone()));
+ }
+ }
+ Ok(None)
+}