sync: add ents-sync, remote synchronization on one schema-aware merge machinery
commit 2508421
sync: add ents-sync, remote synchronization on one schema-aware merge machinery
Fetch and push move the whole forge: each refs/meta/* ref’s full object
closure travels, commit objects verbatim so signatures verify offline in
the destination (sync.forge-transfer). Push pre-flight runs the identical
ents_gate::verify every call site runs, so a verdict is a prediction that
can only go stale (sync.pre-flight, gate.call-sites), and any negative
advisory verdict offers routing to the author’s own inbox segment the
moment it lands, never blocking a local write (sync.inbox-routing,
sync.local-advisory).
The hard part is merge::three_way: a schema-aware three-way merge over
the typed tree, field by field via content addressing, never textual
(sync.divergence-merge) — absorbing git-store’s merge role. Same-actor
divergence and adoption ride the single resolve::merge_heads machinery
(sync.adoption-machinery); the folded-in head always stays a parent, so
the contributor’s signed commit survives in ancestry with attribution
intact — a merge, never a cherry-pick (sync.adoption-no-cherry-pick).
Merge properties (disjoint-edit fusion, same-field conflict,
commutativity, one-sided adoption) are proptest-fuzzed over divergent
typed trees, the sync half of the phase-4 exit gate; verdict tables and
end-to-end gate-accepts-the-merge-tip scenarios cover the rest.
crates/ents-sync/src/error.rs
@@ -1,0 +1,67 @@
+//! `ents-sync`'s infrastructure error type.
+//!
+//! An [`Error`] means sync could not *reach* a result — an object could
+//! not be read or written, a ref store failed, a commit did not decode.
+//! It is never a merge conflict or a negative verdict: a
+//! [`crate::Conflict`] and a [`ents_gate::Verdict::Fail`] are both reached
+//! results the caller acts on, not failures to compute one.
+
+use gix_hash::ObjectId;
+
+/// Everything that can prevent sync from reaching a result.
+#[derive(Debug, thiserror::Error)]
+pub enum Error {
+ /// A ref store (local or remote) failed. The transfer or verdict was
+ /// neither completed nor refused; retry or surface.
+ #[error("ref store failed: {0}")]
+ Refs(#[from] gix_ref_store::Error),
+
+ /// The gate could not evaluate during push pre-flight
+ /// (`sync.pre-flight`). Distinct from a failing verdict, which is a
+ /// reached prediction; this is the gate itself failing to compute one.
+ #[error("gate evaluation failed: {0}")]
+ Gate(#[from] ents_gate::Error),
+
+ /// An object lookup failed while reading `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,
+ },
+
+ /// Writing an object into the destination store failed while
+ /// transferring the forge (`sync.forge-transfer`).
+ #[error("object write failed: {0}")]
+ Write(#[from] gix_object::write::Error),
+
+ /// `oid` is absent from the store it was expected in. During transfer
+ /// this means the source is missing an object its own ref reaches;
+ /// during a merge it means a proposed head is not present locally.
+ #[error("object {oid} is missing")]
+ Missing {
+ /// The absent object.
+ oid: ObjectId,
+ },
+
+ /// `oid` exists but could not be decoded as the object kind sync
+ /// needed (a commit while walking history, or a tree while merging).
+ #[error("object {oid} could not be decoded: {detail}")]
+ Decode {
+ /// The undecodable object.
+ oid: ObjectId,
+ /// What failed, human-readable.
+ detail: String,
+ },
+
+ /// A refname could not be constructed while routing to the inbox
+ /// (`sync.inbox-routing`) — for example, a canonical suffix that is not
+ /// a valid ref component.
+ #[error("invalid refname while routing to inbox: {0}")]
+ RefName(#[from] ents_model::Error),
+}
+
+/// The `Result` alias every fallible `ents-sync` operation returns.
+pub type Result<T> = std::result::Result<T, Error>;
crates/ents-sync/src/lib.rs
@@ -1,0 +1,121 @@
+//! `sync`: remote synchronization for the forge — the one capability
+//! `git ents` adds over the local primitives (`docs/spec/sync.sdoc`).
+//!
+//! Sync fetches and pushes `refs/meta/*` and, crucially, turns the gate's
+//! verdict into a decision the user acts on before pushing. Its single hard
+//! responsibility is the schema-aware three-way merge over typed trees
+//! (`crate::merge`); everything else — transfer, pre-flight, inbox routing —
+//! is plumbing above traits that already exist by this phase (`RefStore`,
+//! `Find`/`Write`, and `ents_gate::verify`). This crate never re-implements
+//! the gate's judgment (it *calls* [`ents_gate::verify`], `gate.call-sites`)
+//! and never writes to a ref except through the `RefStore` seam it is handed.
+//!
+//! Divergence resolution and adoption are deliberately *one* machinery
+//! (`crate::resolve`), not two code paths (`sync.adoption-machinery`): a
+//! member merging their own racing machines, a maintainer folding an inbox
+//! entity onto its canonical ref, and a maintainer adopting a contributor's
+//! self-run results all go through the same [`resolve::merge_heads`], which
+//! keeps the folded-in head as a parent so its author's signature survives —
+//! a merge, never a cherry-pick (`sync.adoption-no-cherry-pick`).
+//!
+//! # Spec coverage
+//!
+//! From `docs/spec/sync.sdoc`:
+//!
+//! - `sync.forge-transfer` — [`transfer::fetch`], [`transfer::push`]: both
+//! copy each meta-ref's full object closure, commit objects verbatim, so
+//! history and signatures move with the ref.
+//! - `sync.pre-flight` — [`preflight::preflight`]: the identical
+//! [`ents_gate::verify`] every call site runs, so a pre-flight verdict is
+//! a prediction that can only be stale (`gate.call-sites`).
+//! - `sync.inbox-routing` — [`preflight::inbox_route`], surfaced by
+//! [`preflight::PreFlight::inbox`] and [`transfer::Pushed::Inbox`]: any
+//! negative advisory verdict offers the author's own inbox segment.
+//! - `sync.divergence-merge` — [`merge::three_way`] and
+//! [`resolve::merge_heads`]: a schema-aware three-way merge whose tip,
+//! once signed, satisfies the tip invariant.
+//! - `sync.adoption-machinery` — [`resolve::merge_heads`] is the *same*
+//! function divergence uses; adoption is only a different pair of heads.
+//! - `sync.adoption-no-cherry-pick` — [`resolve::merge_heads`] always keeps
+//! `theirs` as a parent; it never re-authors the contributor's commit.
+//! - `sync.local-advisory` — sync never blocks a local write on a verdict;
+//! the consequence it owns is the inbox offer ([`mod@preflight`],
+//! [`transfer::push`] is the sole place a verdict gates a *remote* write).
+//!
+//! # Examples
+//!
+//! A same-actor divergence — two of one member's machines each editing a
+//! *different* field of the same issue — resolved into a signed merge tip
+//! the gate then accepts (`sync.divergence-merge`).
+//!
+//! ```
+//! use ents_gate::{Config, Update, Verdict, verify};
+//! use ents_model::{Issue, Provenance, namespace, trailer::Trailers};
+//! use ents_sync::resolve::{Heads, Merged, merge_heads};
+//! use ents_testutil::{
+//! CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity,
+//! };
+//!
+//! let refs = MemRefStore::default();
+//! let objects = ObjectStore::default();
+//! let jdc = Keypair::from_seed(1);
+//!
+//! // Enroll (bootstrap) and turn verification on by setting the epoch.
+//! enroll_member(&refs, &objects, "jdc", &jdc, Provenance::AdminRegistered, 100);
+//! let config: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid");
+//! write_meta_entity(&refs, &objects, config, &Config { epoch: Some(200) }, Some(&jdc), 200);
+//!
+//! let name: gix::refs::FullName = "refs/meta/issues/1".try_into().expect("valid");
+//! let issue = Issue {
+//! title: "t".into(), body: "b".into(), state: "open".into(),
+//! assignees: vec![], labels: vec![],
+//! };
+//! let trailers = Trailers { ents_ref: Some(name.clone()), schema_version: None };
+//! let msg = |s: &str| format!("{s}\n\n{}", trailers.render());
+//!
+//! // A common base, then two divergent children editing disjoint fields.
+//! let base_tree = facet_git_tree::serialize_into(&issue, &objects).expect("ser");
+//! let base = write_commit(&objects, &CommitSpec { tree: base_tree, parents: vec![], message: msg("Open"), seconds: 300 }, Some(&jdc));
+//!
+//! let mut renamed = issue.clone();
+//! renamed.title = "renamed".into();
+//! let ours_tree = facet_git_tree::serialize_into(&renamed, &objects).expect("ser");
+//! let ours = write_commit(&objects, &CommitSpec { tree: ours_tree, parents: vec![base], message: msg("Rename"), seconds: 400 }, Some(&jdc));
+//!
+//! let mut closed = issue.clone();
+//! closed.state = "closed".into();
+//! let theirs_tree = facet_git_tree::serialize_into(&closed, &objects).expect("ser");
+//! let theirs = write_commit(&objects, &CommitSpec { tree: theirs_tree, parents: vec![base], message: msg("Close"), seconds: 400 }, Some(&jdc));
+//!
+//! let author = gix::actor::Signature {
+//! name: "jdc".into(), email: "jdc@ents.test".into(),
+//! time: gix::date::Time { seconds: 500, offset: 0 },
+//! };
+//! let heads = Heads { refname: name.clone(), ours: Some(ours), theirs };
+//! let Merged::Tip(tip) =
+//! merge_heads(&objects, &heads, &author, "Merge divergent heads", |p| jdc.sign(p)).expect("merges")
+//! else { panic!("a same-actor divergence merges cleanly") };
+//!
+//! // The merged tree carries *both* disjoint edits — the schema-aware
+//! // property this crate's tests pin field-by-field. Here we show the
+//! // consequence that matters to sync: the merge tip satisfies the tip
+//! // invariant, so the gate accepts it advancing the ref from `ours`.
+//! let snapshot = refs.fetched_copy();
+//! snapshot.set(name.as_ref(), ours);
+//! let verdict = verify(&snapshot, &objects, &Update { name, new: Some(tip) }).expect("evaluates");
+//! assert!(matches!(verdict, Verdict::Pass(_)));
+//! ```
+
+mod error;
+mod objects;
+
+pub mod merge;
+pub mod preflight;
+pub mod resolve;
+pub mod transfer;
+
+pub use error::{Error, Result};
+pub use merge::{Merge, three_way};
+pub use preflight::{PreFlight, inbox_route, preflight};
+pub use resolve::{Heads, Merged, merge_heads};
+pub use transfer::{Diverged, FetchReport, Pushed, fetch, push};
crates/ents-sync/src/merge.rs
@@ -1,0 +1,243 @@
+//! The schema-aware three-way merge over typed trees — the one hard part
+//! of sync (`sync.divergence-merge`).
+//!
+//! A meta-ref's tip is a git tree that `facet-git-tree` mapped directly
+//! from a `#[derive(Facet)]` struct (`meta-ref.typed-tree`): a struct's
+//! fields are tree entries, a nested struct or collection is a sub-tree,
+//! and a scalar or string is a blob. Because the tree *is* the schema, a
+//! structural three-way merge over the tree is a field-by-field merge over
+//! the entity — never a textual merge over serialized bytes, which
+//! `sync.divergence-merge` forbids.
+//!
+//! The merge is content-addressed, so it needs no diff heuristics: two
+//! sides agree exactly when their object ids are equal. For each entry
+//! (each field, each collection element) the classic three-way rule
+//! applies against the merge-base tree `base`:
+//!
+//! - both sides equal → keep it (includes both-deleted and both-made-the-
+//! same-change);
+//! - one side equals `base` → the *other* side changed it, so take the
+//! other (a deletion included);
+//! - both sides changed it differently → recurse if both are sub-trees
+//! (a nested struct or collection merges field-by-field), otherwise it
+//! is a genuine conflict at that path.
+//!
+//! The result is either a clean merged tree (a new [`ObjectId`], ready to
+//! become a merge tip whose signature makes it satisfy the tip invariant)
+//! or the set of conflicting paths for a human to resolve.
+
+use std::collections::{BTreeSet, HashMap};
+
+use gix::bstr::{BString, ByteVec as _};
+use gix_hash::ObjectId;
+use gix_object::tree::{Entry as TreeEntry, EntryKind, EntryMode};
+use gix_object::{Find, TreeRef, Write};
+
+use crate::error::{Error, Result};
+
+/// The outcome of a schema-aware three-way merge ([`three_way`]).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Merge {
+ /// The two sides merged cleanly into this tree. A merge tip recording
+ /// it, signed by an authorized member, satisfies the tip invariant
+ /// (`sync.divergence-merge`).
+ Clean(ObjectId),
+ /// The two sides changed the same leaf differently. Each entry is a
+ /// slash-joined path into the typed tree — a field name, or a field
+ /// name and a collection index, exactly as `facet-git-tree` names
+ /// them — so a caller can report *which* piece of the entity clashed.
+ Conflict(Vec<BString>),
+}
+
+impl Merge {
+ /// The clean merged tree, or `None` if the merge conflicted.
+ #[must_use]
+ pub fn tree(&self) -> Option<ObjectId> {
+ match self {
+ Merge::Clean(oid) => Some(*oid),
+ Merge::Conflict(_) => None,
+ }
+ }
+}
+
+/// One entry of a tree, reduced to what the merge compares: its object id
+/// and whether it is a sub-tree (so recursion is possible) or a leaf.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct Slot {
+ oid: ObjectId,
+ mode: EntryMode,
+}
+
+impl Slot {
+ fn is_tree(self) -> bool {
+ self.mode.is_tree()
+ }
+}
+
+/// Schema-aware three-way merge of two typed trees against their merge
+/// base (`sync.divergence-merge`).
+///
+/// `base` is the tree of the merge-base commit — `None` when the two heads
+/// share no common ancestor, which the merge treats as an empty base (every
+/// entry looks added on both sides, so any difference is a conflict rather
+/// than a silent pick). `ours` and `theirs` are the two divergent trees;
+/// the merge is commutative, so their roles are symmetric.
+///
+/// Merged sub-trees and blobs are written into `objects`; the returned
+/// [`Merge::Clean`] id is the root of the new tree.
+///
+/// # Errors
+///
+/// [`Error::Decode`] if a purported tree does not decode, [`Error::Object`]
+/// or [`Error::Missing`] if one cannot be read, [`Error::Write`] if the
+/// merged tree cannot be stored.
+///
+/// # Examples
+///
+/// A one-sided change is adopted wholesale — the field-level analogue of a
+/// fast-forward:
+///
+/// ```
+/// use ents_model::Issue;
+/// use ents_sync::merge::{Merge, three_way};
+/// use ents_testutil::ObjectStore;
+///
+/// let objects = ObjectStore::default();
+/// let issue = Issue {
+/// title: "t".into(), body: "b".into(), state: "open".into(),
+/// assignees: vec![], labels: vec![],
+/// };
+/// let base = facet_git_tree::serialize_into(&issue, &objects).expect("ser");
+/// let mut closed = issue.clone();
+/// closed.state = "closed".into();
+/// let theirs = facet_git_tree::serialize_into(&closed, &objects).expect("ser");
+///
+/// // ours == base (we changed nothing); theirs advanced.
+/// let merged = three_way(&objects, Some(base), base, theirs).expect("merges");
+/// assert_eq!(merged, Merge::Clean(theirs));
+/// ```
+// @relation(sync.divergence-merge, scope=function)
+pub fn three_way(
+ objects: &(impl Find + Write),
+ base: Option<ObjectId>,
+ ours: ObjectId,
+ theirs: ObjectId,
+) -> Result<Merge> {
+ // Content addressing makes the fast path exact: equal ids are equal
+ // subtrees, so identical sides need no walk at all.
+ if ours == theirs {
+ return Ok(Merge::Clean(ours));
+ }
+
+ let base_entries = match base {
+ Some(oid) => read_tree(objects, oid)?,
+ None => HashMap::new(),
+ };
+ let ours_entries = read_tree(objects, ours)?;
+ let theirs_entries = read_tree(objects, theirs)?;
+
+ let mut names: BTreeSet<&BString> = BTreeSet::new();
+ names.extend(base_entries.keys());
+ names.extend(ours_entries.keys());
+ names.extend(theirs_entries.keys());
+
+ let mut merged: Vec<TreeEntry> = Vec::new();
+ let mut conflicts: Vec<BString> = Vec::new();
+
+ for name in names {
+ let o = ours_entries.get(name).copied();
+ let t = theirs_entries.get(name).copied();
+ let b = base_entries.get(name).copied();
+
+ if o == t {
+ // Both sides agree, including both-absent and both-identical-
+ // change. Keep it when present.
+ push_slot(&mut merged, name, o);
+ } else if o == b {
+ // Ours is unchanged from base, so theirs owns this entry —
+ // a deletion included (`t == None`).
+ push_slot(&mut merged, name, t);
+ } else if t == b {
+ // Symmetric: theirs is unchanged, ours owns this entry.
+ push_slot(&mut merged, name, o);
+ } else {
+ // Both sides changed the same entry differently. A sub-tree on
+ // both sides is a nested struct or collection that can itself
+ // be merged field-by-field; anything else is a leaf conflict.
+ match (o, t) {
+ (Some(so), Some(st)) if so.is_tree() && st.is_tree() => {
+ let sub_base = b.filter(|s| s.is_tree()).map(|s| s.oid);
+ match three_way(objects, sub_base, so.oid, st.oid)? {
+ Merge::Clean(sub) => merged.push(TreeEntry {
+ mode: EntryKind::Tree.into(),
+ filename: name.clone(),
+ oid: sub,
+ }),
+ Merge::Conflict(paths) => {
+ for p in paths {
+ conflicts.push(join(name, &p));
+ }
+ }
+ }
+ }
+ _ => conflicts.push(name.clone()),
+ }
+ }
+ }
+
+ if conflicts.is_empty() {
+ // git tree entries are canonically sorted; `TreeEntry`'s own `Ord`
+ // is that order, matching how `facet-git-tree` writes trees.
+ merged.sort();
+ let oid = objects.write(&gix_object::Tree { entries: merged })?;
+ Ok(Merge::Clean(oid))
+ } else {
+ conflicts.sort();
+ Ok(Merge::Conflict(conflicts))
+ }
+}
+
+/// Read the entries of the tree at `oid` into a name-keyed map.
+fn read_tree(objects: &impl Find, oid: ObjectId) -> Result<HashMap<BString, Slot>> {
+ 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 })?;
+ let tree = TreeRef::from_bytes(data.data, oid.kind()).map_err(|e| Error::Decode {
+ oid,
+ detail: e.to_string(),
+ })?;
+ let mut map = HashMap::with_capacity(tree.entries.len());
+ for entry in &tree.entries {
+ map.insert(
+ entry.filename.to_owned(),
+ Slot {
+ oid: entry.oid.to_owned(),
+ mode: entry.mode,
+ },
+ );
+ }
+ Ok(map)
+}
+
+/// Append `slot` to `merged` under `name`, if it is present (a `None` slot
+/// is an entry deleted on the winning side, so nothing is written).
+fn push_slot(merged: &mut Vec<TreeEntry>, name: &BString, slot: Option<Slot>) {
+ if let Some(slot) = slot {
+ merged.push(TreeEntry {
+ mode: slot.mode,
+ filename: name.clone(),
+ oid: slot.oid,
+ });
+ }
+}
+
+/// Join a parent entry name and a child path with `/`, the separator
+/// `facet-git-tree` uses for nested tree paths.
+fn join(parent: &BString, child: &BString) -> BString {
+ let mut path = parent.clone();
+ path.push_char('/');
+ path.push_str(child);
+ path
+}
crates/ents-sync/src/objects.rs
@@ -1,0 +1,111 @@
+//! Object-graph walks over gitoxide's `Find`/`Write` seams — the plumbing
+//! shared by the merge machinery ([`crate::resolve`]) and forge transfer
+//! ([`crate::transfer`]). No private object-access trait: gitoxide's own
+//! traits are the seam (`arch.no-object-store-trait`).
+
+use std::collections::HashSet;
+
+use gix_hash::ObjectId;
+use gix_object::{CommitRef, Find, Kind, TreeRef, Write};
+
+use crate::error::{Error, Result};
+
+/// The tree recorded by the commit at `oid`.
+pub(crate) fn commit_tree(objects: &impl Find, oid: ObjectId) -> Result<ObjectId> {
+ 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 })?;
+ let commit = CommitRef::from_bytes(data.data, oid.kind()).map_err(|e| Error::Decode {
+ oid,
+ detail: e.to_string(),
+ })?;
+ Ok(commit.tree())
+}
+
+/// The parents of the commit at `oid`; an empty vec for a non-commit or a
+/// root commit, so an incomplete (shallow) history simply ends a walk.
+pub(crate) fn parents(objects: &impl Find, oid: ObjectId) -> Result<Vec<ObjectId>> {
+ let mut buf = Vec::new();
+ let Some(data) = objects
+ .try_find(&oid, &mut buf)
+ .map_err(|source| Error::Object { oid, source })?
+ else {
+ return Ok(Vec::new());
+ };
+ if data.kind != Kind::Commit {
+ return Ok(Vec::new());
+ }
+ let commit = CommitRef::from_bytes(data.data, oid.kind()).map_err(|e| Error::Decode {
+ oid,
+ detail: e.to_string(),
+ })?;
+ Ok(commit.parents().collect())
+}
+
+/// Whether `descendant` reaches `ancestor` by parent edges (inclusive) —
+/// the DAG sense of a fast-forward. Mirrors the gate's own descent check so
+/// fetch advances a ref only when the remote truly descends from the local
+/// tip (`gate.fast-forward`).
+pub(crate) fn descends_from(
+ objects: &impl Find,
+ descendant: ObjectId,
+ ancestor: ObjectId,
+) -> Result<bool> {
+ let mut stack = vec![descendant];
+ let mut seen = HashSet::new();
+ while let Some(oid) = stack.pop() {
+ if oid == ancestor {
+ return Ok(true);
+ }
+ if !seen.insert(oid) {
+ continue;
+ }
+ stack.extend(parents(objects, oid)?);
+ }
+ Ok(false)
+}
+
+/// Copy every object reachable from `root` — the commit, its whole parent
+/// chain, and every tree and blob those commits record — from `src` into
+/// `dst`. Commit objects are copied verbatim, so their `gpgsig` signatures
+/// travel with them: fetching a ref moves the complete audit history and
+/// the signatures needed to verify it (`sync.forge-transfer`).
+pub(crate) fn copy_closure(src: &impl Find, dst: &impl Write, root: ObjectId) -> Result<()> {
+ let mut stack = vec![root];
+ let mut seen = HashSet::new();
+ while let Some(oid) = stack.pop() {
+ if !seen.insert(oid) {
+ continue;
+ }
+ let mut buf = Vec::new();
+ let data = src
+ .try_find(&oid, &mut buf)
+ .map_err(|source| Error::Object { oid, source })?
+ .ok_or(Error::Missing { oid })?;
+ let kind = data.kind;
+ let bytes = data.data.to_vec();
+ dst.write_buf(kind, &bytes)?;
+ match kind {
+ Kind::Commit => {
+ let commit =
+ CommitRef::from_bytes(&bytes, oid.kind()).map_err(|e| Error::Decode {
+ oid,
+ detail: e.to_string(),
+ })?;
+ stack.push(commit.tree());
+ stack.extend(commit.parents());
+ }
+ Kind::Tree => {
+ let tree = TreeRef::from_bytes(&bytes, oid.kind()).map_err(|e| Error::Decode {
+ oid,
+ detail: e.to_string(),
+ })?;
+ stack.extend(tree.entries.iter().map(|e| e.oid.to_owned()));
+ }
+ Kind::Blob | Kind::Tag => {}
+ }
+ }
+ Ok(())
+}
crates/ents-sync/src/preflight.rs
@@ -1,0 +1,162 @@
+//! Push pre-flight and inbox routing — turning the gate's verdict into a
+//! decision the user acts on before pushing.
+//!
+//! Two requirements live here. [`preflight`] runs the *identical* gate
+//! function every other call site runs ([`ents_gate::verify`],
+//! `gate.call-sites`), so a pre-flight verdict is a prediction that can
+//! only go stale between pre-flight and the hosted CAS, never one that is
+//! wrong about the rules (`sync.pre-flight`). And [`inbox_route`] computes
+//! the alternative a negative verdict offers: the same commit re-homed
+//! under the author's own `refs/meta/inbox/<member>/*` segment, awaiting
+//! adoption (`sync.inbox-routing`).
+//!
+//! The offer is a function of the verdict alone, so it is available the
+//! moment the verdict goes negative — at all three advisory sites the spec
+//! names (the local UI verdict at commit time, push pre-flight, and the
+//! canonical store's actual rejection, `sync.inbox-routing`) — not only
+//! after a push is attempted and refused. Sync never blocks a *local*
+//! write on a failing verdict (`sync.local-advisory`); the consequence it
+//! owns is exactly this inbox offer.
+
+use ents_gate::{Update, Verdict, verify};
+use ents_model::{MemberId, namespace};
+use gix::refs::{FullName, FullNameRef};
+use gix_object::Find;
+use gix_ref_store::RefStoreRead;
+
+use crate::error::Result;
+
+/// A pre-flight prediction: the gate's verdict on a proposed update, plus
+/// the inbox alternative a negative verdict offers (`sync.pre-flight`,
+/// `sync.inbox-routing`).
+///
+/// `verdict` is produced by the same [`ents_gate::verify`] the hosted store
+/// runs at CAS time, so it predicts that outcome exactly up to staleness of
+/// the last fetch — it cannot disagree about the rules (`gate.call-sites`).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PreFlight {
+ /// The gate's verdict on the proposed update.
+ pub verdict: Verdict,
+ /// Where the same commit could instead be routed — the author's own
+ /// inbox ref — when the verdict is a negative one the inbox can absorb
+ /// (`sync.inbox-routing`). `None` on a pass, and on refusals the inbox
+ /// cannot help (a divergence, whose answer is a merge, or a refname
+ /// mismatch).
+ pub inbox: Option<FullName>,
+}
+
+impl PreFlight {
+ /// Whether the gate admits the update. A caller pushing to a hosted
+ /// store treats a false here as a prediction the push will be refused
+ /// — never as authority to block a *local* write (`sync.local-advisory`):
+ /// this type carries no write access at all, so a failing verdict is
+ /// structurally incapable of vetoing one; the rejection consequence
+ /// sync owns instead is [`PreFlight::inbox`] (`sync.inbox-routing`).
+ // @relation(sync.local-advisory, scope=function)
+ #[must_use]
+ pub fn is_pass(&self) -> bool {
+ self.verdict.is_pass()
+ }
+}
+
+/// Evaluate push pre-flight for one proposed update (`sync.pre-flight`).
+///
+/// Runs the identical gate function the hosted store runs
+/// (`gate.call-sites`) against the local (last-fetched) snapshot, and, when
+/// the verdict is a refusal the inbox can absorb, attaches the route the
+/// author would use instead (`sync.inbox-routing`). `author` is the member
+/// whose inbox segment such a routed commit would land under — its own, and
+/// only its own (`meta-ref.inbox`).
+///
+/// # Errors
+///
+/// Propagates [`ents_gate::Error`] when the gate cannot evaluate (a store
+/// or object read failed) and a refname error if the inbox route cannot be
+/// built.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::{MemberId, Provenance, namespace};
+/// use ents_sync::preflight::preflight;
+/// use ents_gate::Update;
+/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member, write_meta_entity};
+///
+/// let refs = MemRefStore::default();
+/// let objects = ObjectStore::default();
+/// let admin = Keypair::from_seed(1);
+/// enroll_member(&refs, &objects, "admin", &admin, Provenance::AdminRegistered, 100);
+/// let config: gix::refs::FullName = namespace::CONFIG_REF.try_into().expect("valid");
+/// write_meta_entity(&refs, &objects, config, &ents_gate::Config { epoch: Some(200) }, Some(&admin), 200);
+///
+/// // A self-attested contributor's canonical issue push fails pre-flight,
+/// // and the inbox route is offered at once.
+/// let bob = Keypair::from_seed(2);
+/// enroll_member(&refs, &objects, "bob", &bob, Provenance::SelfAttested, 250);
+/// let name: gix::refs::FullName = "refs/meta/issues/9".try_into().expect("valid");
+/// let issue = ents_model::Issue { title: "t".into(), body: "b".into(), state: "open".into(), assignees: vec![], labels: vec![] };
+/// let tip = write_meta_entity(&refs, &objects, name.clone(), &issue, Some(&bob), 300);
+///
+/// let before = refs.fetched_copy();
+/// before.remove(name.as_ref());
+/// let pf = preflight(&before, &objects, &Update { name, new: Some(tip) }, &MemberId::new("bob")).expect("evaluates");
+/// assert!(!pf.is_pass());
+/// assert_eq!(pf.inbox.expect("offered").as_bstr(), "refs/meta/inbox/bob/issues/9");
+/// ```
+// @relation(sync.pre-flight, sync.inbox-routing, scope=function)
+pub fn preflight(
+ refs: &dyn RefStoreRead,
+ objects: &dyn Find,
+ update: &Update,
+ author: &MemberId,
+) -> Result<PreFlight> {
+ let verdict = verify(refs, objects, update)?;
+ let inbox = match &verdict {
+ // The gate already decided whether the inbox is the alternative:
+ // `inbox_alternative` is set exactly on authorization refusals
+ // against a canonical ref, and cleared for divergences (answer: a
+ // merge) and refname mismatches (`gate.verdict-reason`,
+ // `gate.advisory-local`).
+ Verdict::Fail(refusal) if refusal.inbox_alternative => {
+ Some(inbox_route(update.name.as_ref(), author)?)
+ }
+ _ => None,
+ };
+ Ok(PreFlight { verdict, inbox })
+}
+
+/// The inbox ref a rejected commit against `canonical` would be routed to,
+/// under `author`'s own segment (`sync.inbox-routing`, `meta-ref.inbox`).
+///
+/// The canonical ref's suffix below `refs/meta/` becomes the inbox id, so
+/// `refs/meta/issues/42` routes to `refs/meta/inbox/<author>/issues/42`:
+/// the destination records both who is submitting and what they submit,
+/// and stays under the author's own segment, the only place a member may
+/// write (`meta-ref.inbox`). A refname already outside `refs/meta/`, or an
+/// already-inbox ref, is returned unchanged — there is nothing to re-route.
+///
+/// # Errors
+///
+/// Propagates a refname error if the composed inbox refname is invalid.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::MemberId;
+/// use ents_sync::preflight::inbox_route;
+///
+/// let canonical: gix::refs::FullName = "refs/meta/issues/42".try_into().expect("valid");
+/// let routed = inbox_route(canonical.as_ref(), &MemberId::new("jdc")).expect("valid");
+/// assert_eq!(routed.as_bstr(), "refs/meta/inbox/jdc/issues/42");
+/// ```
+// @relation(sync.inbox-routing, scope=function)
+pub fn inbox_route(canonical: &FullNameRef, author: &MemberId) -> Result<FullName> {
+ if namespace::is_inbox(canonical) {
+ return Ok(canonical.to_owned());
+ }
+ let path = canonical.as_bstr().to_string();
+ let Some(suffix) = path.strip_prefix("refs/meta/") else {
+ return Ok(canonical.to_owned());
+ };
+ Ok(namespace::inbox_ref(author, suffix)?)
+}
crates/ents-sync/src/resolve.rs
@@ -1,0 +1,248 @@
+//! One merge machinery for every same-tip reconciliation sync performs:
+//! same-actor divergence (`sync.divergence-merge`,
+//! `gate.same-actor-divergence`) and adoption — a maintainer folding an
+//! inbox entity onto its canonical ref, or a member's self-run results onto
+//! the canonical results ref (`sync.adoption-machinery`,
+//! `gate.adoption-merge`).
+//!
+//! All three are the same operation: take the authorized side's current tip
+//! (`ours`) and the head being folded in (`theirs`), merge their typed trees
+//! three-way against the merge base ([`crate::merge::three_way`]), and record
+//! the result as a two-parent merge commit signed by the placing member.
+//! There is deliberately no separate adoption code path
+//! (`sync.adoption-machinery`), and deliberately no cherry-pick: `theirs`
+//! stays a parent, so the contributor's original signed commit — and its
+//! attribution — remains in ancestry (`sync.adoption-no-cherry-pick`). A
+//! cherry-pick would instead create a fresh commit by the placer and destroy
+//! the author's signature, which no gate could detect after the fact, so this
+//! property is the machinery's to keep, not the gate's.
+
+use std::collections::HashSet;
+
+use gix::bstr::BString;
+use gix::refs::FullName;
+use gix_hash::ObjectId;
+use gix_object::{Commit, Find, Kind, Write, WriteTo as _};
+
+use ents_model::trailer::Trailers;
+
+use crate::error::{Error, Result};
+use crate::merge::{Merge, three_way};
+use crate::objects::{commit_tree, parents};
+
+/// The two heads a merge reconciles onto one ref.
+///
+/// `ours` is the tip of the authorized side — the canonical ref the merge
+/// tip will advance, or `None` when that ref does not exist yet (adopting a
+/// contributor's brand-new entity onto a canonical ref that has no prior
+/// tip). `theirs` is the head being folded in: the other machine's tip in a
+/// divergence, or the contributor's inbox / self-run tip in an adoption.
+#[derive(Debug, Clone)]
+pub struct Heads {
+ /// The ref the resulting merge tip advances (its `Advance-ref` trailer).
+ pub refname: FullName,
+ /// The authorized side's current tip, or `None` if the ref is new.
+ pub ours: Option<ObjectId>,
+ /// The head being folded in — always kept as a parent, never
+ /// cherry-picked (`sync.adoption-no-cherry-pick`).
+ pub theirs: ObjectId,
+}
+
+/// The result of [`merge_heads`].
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Merged {
+ /// A signed merge tip that advances [`Heads::refname`]. It descends from
+ /// both parents, so an authorized signature makes it satisfy the tip
+ /// invariant (`sync.divergence-merge`); `theirs` is in its ancestry with
+ /// attribution intact (`sync.adoption-no-cherry-pick`).
+ Tip(ObjectId),
+ /// The two heads changed the same leaf of the typed tree differently.
+ /// Each path is a field (and, for a collection, an index) into the
+ /// entity — a human resolves it before the merge can complete.
+ Conflict(Vec<BString>),
+}
+
+/// Resolve two divergent heads into one signed merge tip, or report the
+/// conflicting paths — the single machinery divergence and adoption share
+/// (`sync.divergence-merge`, `sync.adoption-machinery`).
+///
+/// The typed trees of `ours` and `theirs` are merged three-way against
+/// their merge base; a clean merge is recorded as a merge commit whose
+/// parents are `[ours, theirs]` (just `[theirs]` when the canonical ref is
+/// new), authored and committed by `author`, bound to [`Heads::refname`] by
+/// the `Advance-ref` trailer, and signed by `sign`. `sign` returns the
+/// armored SSHSIG PEM for the commit's payload — exactly what git stores in
+/// the `gpgsig` header — so the composition root injects the placing
+/// member's key without this crate ever holding one.
+///
+/// Because `theirs` is always a parent, the contributor's original signed
+/// commit stays in ancestry: this is a merge, never a cherry-pick
+/// (`sync.adoption-no-cherry-pick`). The placer signs the *tip*, which is
+/// what makes an authorized member's merge the legitimate adoption mechanism
+/// (`gate.adoption-merge`) rather than a direct fast-forward to an
+/// unauthorized signature (`gate.adoption-no-fast-forward`).
+///
+/// # Errors
+///
+/// Propagates object read/decode/write failures from the merge and from
+/// building the commit.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::{Provenance, namespace};
+/// use ents_sync::resolve::{Heads, Merged, merge_heads};
+/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member, write_meta_entity};
+///
+/// let refs = MemRefStore::default();
+/// let objects = ObjectStore::default();
+/// let key = Keypair::from_seed(1);
+/// enroll_member(&refs, &objects, "jdc", &key, Provenance::AdminRegistered, 100);
+///
+/// // Two of jdc's machines diverged on the same single-writer ref.
+/// let name: gix::refs::FullName = "refs/meta/issues/1".try_into().expect("valid");
+/// let issue = ents_model::Issue {
+/// title: "t".into(), body: "b".into(), state: "open".into(),
+/// assignees: vec![], labels: vec![],
+/// };
+/// let ours = write_meta_entity(&refs, &objects, name.clone(), &issue, Some(&key), 200);
+/// let mut other = issue.clone();
+/// other.state = "closed".into();
+/// let theirs = write_meta_entity(&refs, &objects, name.clone(), &other, Some(&key), 300);
+///
+/// let author = gix::actor::Signature {
+/// name: "jdc".into(), email: "jdc@ents.test".into(),
+/// time: gix::date::Time { seconds: 400, offset: 0 },
+/// };
+/// let heads = Heads { refname: name, ours: Some(ours), theirs };
+/// let merged = merge_heads(&objects, &heads, &author, "Merge divergent heads",
+/// |payload| key.sign(payload)).expect("merges");
+/// assert!(matches!(merged, Merged::Tip(_)));
+/// ```
+// @relation(sync.divergence-merge, sync.adoption-machinery, sync.adoption-no-cherry-pick, scope=function)
+pub fn merge_heads(
+ objects: &(impl Find + Write),
+ heads: &Heads,
+ author: &gix::actor::Signature,
+ summary: &str,
+ sign: impl FnOnce(&[u8]) -> String,
+) -> Result<Merged> {
+ let theirs_tree = commit_tree(objects, heads.theirs)?;
+
+ let (tree, parents) = match heads.ours {
+ // Adopting onto a ref with no prior tip: nothing to merge, but
+ // `theirs` still becomes the sole parent so attribution survives —
+ // a degenerate merge, never a cherry-pick.
+ None => (theirs_tree, vec![heads.theirs]),
+ Some(ours) => {
+ let base = merge_base(objects, ours, heads.theirs)?;
+ let base_tree = base.map(|b| commit_tree(objects, b)).transpose()?;
+ let ours_tree = commit_tree(objects, ours)?;
+ match three_way(objects, base_tree, ours_tree, theirs_tree)? {
+ Merge::Clean(tree) => (tree, vec![ours, heads.theirs]),
+ Merge::Conflict(paths) => return Ok(Merged::Conflict(paths)),
+ }
+ }
+ };
+
+ let tip = seal(
+ objects,
+ tree,
+ parents,
+ &heads.refname,
+ author,
+ summary,
+ sign,
+ )?;
+ Ok(Merged::Tip(tip))
+}
+
+/// Build and sign the merge commit — the tip whose signature, not any tree
+/// content, is what satisfies the tip invariant.
+fn seal(
+ objects: &impl Write,
+ tree: ObjectId,
+ parents: Vec<ObjectId>,
+ refname: &FullName,
+ author: &gix::actor::Signature,
+ summary: &str,
+ sign: impl FnOnce(&[u8]) -> String,
+) -> Result<ObjectId> {
+ let trailers = Trailers {
+ ents_ref: Some(refname.clone()),
+ schema_version: None,
+ };
+ let message = format!("{summary}\n\n{}", trailers.render());
+ let mut commit = Commit {
+ tree,
+ parents: parents.into(),
+ author: author.clone(),
+ committer: author.clone(),
+ encoding: None,
+ message: message.into(),
+ extra_headers: Vec::new(),
+ };
+
+ // Sign exactly as `git commit -S` does: SSHSIG over the commit
+ // serialized *without* its gpgsig header, stored back as that header —
+ // so the signature is repository data that verifies offline
+ // (`gate.signature-artifact`).
+ let mut payload = Vec::new();
+ commit.write_to(&mut payload).map_err(|e| Error::Decode {
+ oid: tree,
+ detail: format!("serializing merge commit failed: {e}"),
+ })?;
+ let pem = sign(&payload);
+ commit
+ .extra_headers
+ .push(("gpgsig".into(), pem.trim_end().into()));
+
+ let mut raw = Vec::new();
+ commit.write_to(&mut raw).map_err(|e| Error::Decode {
+ oid: tree,
+ detail: format!("serializing signed merge commit failed: {e}"),
+ })?;
+ Ok(objects.write_buf(Kind::Commit, &raw)?)
+}
+
+/// The nearest common ancestor of `a` and `b` by parent edges, or `None`
+/// when they share no ancestor (each is then merged against an empty base).
+///
+/// This is a breadth-first nearest-ancestor search: it collects every
+/// ancestor of `a`, then walks `b`'s ancestry breadth-first and returns the
+/// first commit already seen from `a`. For the divergence and adoption
+/// shapes sync produces — two lines splitting from one common tip — that is
+/// the true merge base. It does not resolve the multiple-merge-base
+/// criss-cross case optimally; a stricter base would only ever *reduce*
+/// spurious conflicts, never admit a wrong clean merge, since the three-way
+/// rule keeps a field only when at least one side matches the base.
+fn merge_base(objects: &impl Find, a: ObjectId, b: ObjectId) -> Result<Option<ObjectId>> {
+ let ancestors_of_a = ancestors(objects, a)?;
+ let mut queue = std::collections::VecDeque::from([b]);
+ let mut seen = HashSet::new();
+ while let Some(oid) = queue.pop_front() {
+ if !seen.insert(oid) {
+ continue;
+ }
+ if ancestors_of_a.contains(&oid) {
+ return Ok(Some(oid));
+ }
+ for parent in parents(objects, oid)? {
+ queue.push_back(parent);
+ }
+ }
+ Ok(None)
+}
+
+/// Every commit reachable from `oid` by parent edges, inclusive.
+fn ancestors(objects: &impl Find, oid: ObjectId) -> Result<HashSet<ObjectId>> {
+ let mut seen = HashSet::new();
+ let mut stack = vec![oid];
+ while let Some(oid) = stack.pop() {
+ if !seen.insert(oid) {
+ continue;
+ }
+ stack.extend(parents(objects, oid)?);
+ }
+ Ok(seen)
+}
crates/ents-sync/src/transfer.rs
@@ -1,0 +1,208 @@
+//! Fetch and push over `refs/meta/*` — the routine plumbing that moves the
+//! forge itself, not merely code (`sync.forge-transfer`).
+//!
+//! Both directions copy the *complete* object closure of every meta-ref —
+//! each ref's whole commit chain and all its trees and blobs, commit
+//! objects verbatim so their signatures travel too — so a clone plus
+//! `refs/meta/*` carries the entire audit history and the signatures needed
+//! to verify it, with nothing left server-side (`sync.forge-transfer`).
+//!
+//! A remote and a local repository are each just a ([`RefStoreRead`]/
+//! [`RefStore`], `Find`/`Write`) pair; transfer is expressed directly over
+//! those seams, with no bespoke transport type. [`fetch`] advances every
+//! local meta-ref that the remote fast-forwards and reports the ones that
+//! diverged, feeding them to the merge machinery ([`crate::resolve`]).
+//! [`push`] runs pre-flight against the remote's own policy before moving a
+//! ref, so a rejected canonical push surfaces the inbox alternative instead
+//! (`sync.pre-flight`, `sync.inbox-routing`).
+
+use ents_gate::Update;
+use ents_model::MemberId;
+use gix::refs::FullName;
+use gix_hash::ObjectId;
+use gix_object::{Find, Write};
+use gix_ref_store::{Expected, RefEdit, RefStore, RefStoreRead, TxOutcome};
+
+use crate::error::Result;
+use crate::objects::{copy_closure, descends_from};
+use crate::preflight::{PreFlight, preflight};
+
+/// The meta-ref prefix that scopes every forge transfer.
+const META_PREFIX: &str = "refs/meta/";
+
+/// A remote meta-ref that has moved out from under the local tip: neither
+/// side descends from the other, so a fast-forward is impossible and the
+/// answer is a merge ([`crate::resolve::merge_heads`], `sync.divergence-merge`).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct Diverged {
+ /// The ref that diverged.
+ pub name: FullName,
+ /// The local tip.
+ pub local: ObjectId,
+ /// The remote tip.
+ pub remote: ObjectId,
+}
+
+/// What [`fetch`] did to the local `refs/meta/*` set.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct FetchReport {
+ /// Refs advanced to the remote tip (created, or fast-forwarded).
+ pub updated: Vec<FullName>,
+ /// Refs already at the remote tip; nothing to do.
+ pub unchanged: Vec<FullName>,
+ /// Refs whose local and remote tips diverged — resolve by merging.
+ pub diverged: Vec<Diverged>,
+}
+
+/// Fetch every `refs/meta/*` ref from `remote` into `local`, moving the
+/// whole forge (`sync.forge-transfer`).
+///
+/// For each remote meta-ref the full object closure is copied into
+/// `local_objects` first — so the ref never points at an object the local
+/// store lacks — then the local ref is advanced if the remote is a
+/// fast-forward, left alone if already current, and reported as
+/// [`Diverged`] otherwise. Object copy is unconditional even for a
+/// divergence, since the subsequent merge needs both heads present locally.
+///
+/// # Errors
+///
+/// Propagates ref-store and object failures; a stale CAS during the ref
+/// update surfaces as an [`crate::Error`]-free skip is *not* done here —
+/// fetch runs single-writer against the local store.
+///
+/// # Examples
+///
+/// ```
+/// use ents_model::Provenance;
+/// use ents_sync::transfer::fetch;
+/// use ents_testutil::{Keypair, MemRefStore, ObjectStore, enroll_member};
+/// use gix_ref_store::RefStoreRead;
+///
+/// // The "remote" is just another ref-store / object-store pair.
+/// let remote_refs = MemRefStore::default();
+/// let remote_objects = ObjectStore::default();
+/// let key = Keypair::from_seed(1);
+/// enroll_member(&remote_refs, &remote_objects, "jdc", &key, Provenance::AdminRegistered, 100);
+///
+/// let local_refs = MemRefStore::default();
+/// let local_objects = ObjectStore::default();
+/// let report = fetch(&remote_refs, &remote_objects, &local_refs, &local_objects).expect("fetches");
+/// assert_eq!(report.updated.len(), 1);
+///
+/// let name: gix::refs::FullName = "refs/meta/member/jdc".try_into().expect("valid");
+/// assert!(local_refs.get(name.as_ref()).expect("readable").is_some());
+/// ```
+// @relation(sync.forge-transfer, scope=function)
+pub fn fetch(
+ remote_refs: &dyn RefStoreRead,
+ remote_objects: &impl Find,
+ local_refs: &dyn RefStore,
+ local_objects: &(impl Find + Write),
+) -> Result<FetchReport> {
+ let mut report = FetchReport::default();
+ for entry in remote_refs.iter_prefix(META_PREFIX)? {
+ let (name, remote_tip) = entry?;
+ copy_closure(remote_objects, local_objects, remote_tip)?;
+
+ let local_tip = local_refs.get(name.as_ref())?;
+ match local_tip {
+ Some(local) if local == remote_tip => report.unchanged.push(name),
+ Some(local) if descends_from(local_objects, remote_tip, local)? => {
+ advance(
+ local_refs,
+ &name,
+ Expected::MustExistAndMatch(local),
+ remote_tip,
+ )?;
+ report.updated.push(name);
+ }
+ Some(local) => report.diverged.push(Diverged {
+ name,
+ local,
+ remote: remote_tip,
+ }),
+ None => {
+ advance(local_refs, &name, Expected::MustNotExist, remote_tip)?;
+ report.updated.push(name);
+ }
+ }
+ }
+ Ok(report)
+}
+
+/// The outcome of pushing one local meta-ref to a remote ([`push`]).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum Pushed {
+ /// Pre-flight passed and the ref was transferred and advanced on the
+ /// remote.
+ Advanced(FullName),
+ /// Pre-flight predicted a rejection the inbox can absorb; the ref was
+ /// *not* pushed, and this is the inbox route offered instead
+ /// (`sync.inbox-routing`).
+ Inbox(FullName),
+ /// Pre-flight predicted a rejection the inbox cannot absorb (a
+ /// divergence — merge first — or a refname mismatch). The ref was not
+ /// pushed; the prediction is carried for the caller to render.
+ Refused(Box<PreFlight>),
+}
+
+/// Push one local meta-ref `name` to `remote`, pre-flighting against the
+/// remote's own policy first (`sync.pre-flight`).
+///
+/// The local tip's object closure is copied to the remote only once
+/// pre-flight passes; a predicted rejection routes to the inbox
+/// (`sync.inbox-routing`) or is reported, and nothing is transferred. This
+/// runs the identical gate the remote will run at CAS time
+/// (`gate.call-sites`), so the result is a prediction that can only be
+/// stale, never wrong about the rules. Local writes are never blocked by
+/// this — that is [`mod@crate::preflight`]'s and the local store's concern
+/// (`sync.local-advisory`); push is the one place a verdict gates an
+/// actual (remote) write.
+///
+/// # Errors
+///
+/// Propagates pre-flight, ref-store, and object failures.
+// @relation(sync.pre-flight, sync.inbox-routing, sync.forge-transfer, scope=function)
+pub fn push(
+ remote_refs: &dyn RefStore,
+ remote_objects: &(impl Find + Write),
+ local_objects: &impl Find,
+ name: &FullName,
+ local_tip: ObjectId,
+ author: &MemberId,
+) -> Result<Pushed> {
+ let update = Update {
+ name: name.clone(),
+ new: Some(local_tip),
+ };
+ // Pre-flight needs the proposed objects visible in the store it reads,
+ // exactly as the hosted CAS would after ingest; copy first, then judge.
+ copy_closure(local_objects, remote_objects, local_tip)?;
+ let pf = preflight(remote_refs, remote_objects, &update, author)?;
+ if !pf.is_pass() {
+ return Ok(match pf.inbox {
+ Some(inbox) => Pushed::Inbox(inbox),
+ None => Pushed::Refused(Box::new(pf)),
+ });
+ }
+
+ let expected = remote_refs
+ .get(name.as_ref())?
+ .map_or(Expected::MustNotExist, Expected::MustExistAndMatch);
+ advance(remote_refs, name, expected, local_tip)?;
+ Ok(Pushed::Advanced(name.clone()))
+}
+
+/// Apply one ref advance as a single-edit CAS transaction.
+fn advance(
+ refs: &dyn RefStore,
+ name: &FullName,
+ expected: Expected,
+ new: ObjectId,
+) -> Result<TxOutcome> {
+ Ok(refs.transaction(&[RefEdit {
+ name: name.clone(),
+ expected,
+ new: Some(new),
+ }])?)
+}
crates/ents-sync/tests/merge.rs
@@ -1,0 +1,155 @@
+//! Property tests for the schema-aware three-way merge (`sync.divergence-merge`).
+//!
+//! Strategy: **proptest** — the spec states an algebraic invariant (a merge
+//! that respects the typed tree's schema, field by field) over an
+//! unenumerable input space, so example rows cannot stand in for it. The
+//! properties pinned here are the ones a schema-aware merge must uphold:
+//! disjoint field edits combine (never conflict), the same field changed
+//! two ways conflicts, and the merge is commutative in its two sides.
+
+#![expect(clippy::unwrap_used, clippy::expect_used, reason = "tests")]
+
+use ents_model::{Issue, MemberId};
+use ents_sync::merge::{Merge, three_way};
+use ents_testutil::ObjectStore;
+use proptest::prelude::*;
+
+/// A base issue with room to edit every field independently.
+fn issue_strategy() -> impl Strategy<Value = Issue> {
+ (
+ "[a-z]{1,8}",
+ "[a-z]{1,8}",
+ prop::sample::select(vec!["open", "closed"]),
+ prop::collection::vec("[a-z]{1,5}", 0..3),
+ prop::collection::vec("[a-z]{1,5}", 0..3),
+ )
+ .prop_map(|(title, body, state, assignees, labels)| Issue {
+ title,
+ body,
+ state: state.to_string(),
+ assignees: assignees.into_iter().map(MemberId::new).collect(),
+ labels,
+ })
+}
+
+/// Apply a deterministic, value-changing edit to field `i` (0..5).
+fn edit_field(issue: &mut Issue, i: usize) {
+ match i {
+ 0 => issue.title.push_str("-edit"),
+ 1 => issue.body.push_str("-edit"),
+ 2 => {
+ issue.state = if issue.state == "open" {
+ "closed"
+ } else {
+ "open"
+ }
+ .to_string()
+ }
+ 3 => issue.assignees.push(MemberId::new("added")),
+ _ => issue.labels.push("added".to_string()),
+ }
+}
+
+fn ser(objects: &ObjectStore, issue: &Issue) -> gix_hash::ObjectId {
+ facet_git_tree::serialize_into(issue, objects).unwrap()
+}
+
+fn de(objects: &ObjectStore, tree: gix_hash::ObjectId) -> Issue {
+ facet_git_tree::deserialize(&tree, objects).unwrap()
+}
+
+proptest! {
+ /// Each side changes a *disjoint* set of fields, so the merge must fold
+ /// both sets in with no conflict — the concrete meaning of "schema-aware
+ /// three-way merge over the typed tree" (`sync.divergence-merge`): the
+ /// merged entity carries every field's winning value, resolved per field.
+ // @relation(sync.divergence-merge, scope=function, role=Verifies)
+ #[test]
+ fn disjoint_field_edits_merge_field_by_field(
+ base in issue_strategy(),
+ owners in prop::collection::vec(0u8..3, 5),
+ ) {
+ let objects = ObjectStore::default();
+ let mut ours = base.clone();
+ let mut theirs = base.clone();
+ let mut expected = base.clone();
+ for (i, &owner) in owners.iter().enumerate() {
+ match owner {
+ 1 => { edit_field(&mut ours, i); edit_field(&mut expected, i); }
+ 2 => { edit_field(&mut theirs, i); edit_field(&mut expected, i); }
+ _ => {}
+ }
+ }
+
+ let b = ser(&objects, &base);
+ let o = ser(&objects, &ours);
+ let t = ser(&objects, &theirs);
+
+ let merged = three_way(&objects, Some(b), o, t).unwrap();
+ let tree = merged.tree().expect("disjoint edits never conflict");
+ prop_assert_eq!(de(&objects, tree), expected);
+ }
+
+ /// Both sides change the *same* scalar field to different values: no
+ /// content-addressed pick is possible, so the merge must report that
+ /// field as a conflict rather than silently choose one.
+ // @relation(sync.divergence-merge, scope=function, role=Verifies)
+ #[test]
+ fn same_field_divergent_edits_conflict(base in issue_strategy()) {
+ let objects = ObjectStore::default();
+ let mut ours = base.clone();
+ ours.title.push_str("-ours");
+ let mut theirs = base.clone();
+ theirs.title.push_str("-theirs");
+
+ let b = ser(&objects, &base);
+ let o = ser(&objects, &ours);
+ let t = ser(&objects, &theirs);
+
+ let merged = three_way(&objects, Some(b), o, t).unwrap();
+ prop_assert_eq!(merged, Merge::Conflict(vec!["title".into()]));
+ }
+
+ /// The merge is commutative in its two sides: swapping `ours` and
+ /// `theirs` yields the identical clean tree, or the identical conflict
+ /// set. A resolution that depended on argument order would silently
+ /// disagree with itself across two machines.
+ // @relation(sync.divergence-merge, scope=function, role=Verifies)
+ #[test]
+ fn merge_is_commutative(
+ base in issue_strategy(),
+ ours in issue_strategy(),
+ theirs in issue_strategy(),
+ ) {
+ let objects = ObjectStore::default();
+ let b = ser(&objects, &base);
+ let o = ser(&objects, &ours);
+ let t = ser(&objects, &theirs);
+
+ let forward = three_way(&objects, Some(b), o, t).unwrap();
+ let backward = three_way(&objects, Some(b), t, o).unwrap();
+
+ match (forward, backward) {
+ (Merge::Clean(x), Merge::Clean(y)) => prop_assert_eq!(x, y),
+ (Merge::Conflict(a), Merge::Conflict(bb)) => prop_assert_eq!(a, bb),
+ (f, bk) => prop_assert!(false, "clean-ness must match: {:?} vs {:?}", f, bk),
+ }
+ }
+
+ /// A side that did not move is a no-op: `three_way(base, base, theirs)`
+ /// adopts `theirs` wholesale — the field-level analogue of a
+ /// fast-forward, and the trivial-merge case adoption relies on.
+ // @relation(sync.divergence-merge, scope=function, role=Verifies)
+ #[test]
+ fn one_sided_change_adopts_the_other(
+ base in issue_strategy(),
+ theirs in issue_strategy(),
+ ) {
+ let objects = ObjectStore::default();
+ let b = ser(&objects, &base);
+ let t = ser(&objects, &theirs);
+
+ prop_assert_eq!(three_way(&objects, Some(b), b, t).unwrap(), Merge::Clean(t));
+ prop_assert_eq!(three_way(&objects, Some(b), b, b).unwrap(), Merge::Clean(b));
+ }
+}
crates/ents-sync/tests/preflight.rs
@@ -1,0 +1,230 @@
+//! Pre-flight, inbox routing, and the local-advisory boundary
+//! (`sync.pre-flight`, `sync.inbox-routing`, `sync.local-advisory`).
+//!
+//! Strategy: **rstest table-driven** for the enumerable cases (pass vs the
+//! two kinds of refusal, and the refname-mapping table for [`inbox_route`]),
+//! plus targeted integration tests for the two invariants that are about
+//! *identity* rather than a case: a pre-flight verdict equals the gate's own
+//! verdict on the same inputs (`sync.pre-flight`), and a failing pre-flight
+//! never blocks a local write (`sync.local-advisory`).
+
+#![expect(clippy::unwrap_used, reason = "tests")]
+
+use ents_gate::{Config, Update, verify};
+use ents_model::trailer::Trailers;
+use ents_model::{Issue, MemberId, Provenance, namespace};
+use ents_sync::{inbox_route, preflight};
+use ents_testutil::{
+ CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity,
+};
+use gix::refs::FullName;
+use gix_ref_store::{Expected, RefEdit, RefStore, RefStoreRead};
+use rstest::rstest;
+
+fn issue() -> Issue {
+ Issue {
+ title: "t".into(),
+ body: "b".into(),
+ state: "open".into(),
+ assignees: vec![],
+ labels: vec![],
+ }
+}
+
+/// A booted forge (admin enrolled, epoch set) plus a self-attested bob.
+fn forge() -> (MemRefStore, ObjectStore, Keypair, Keypair) {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let admin = Keypair::from_seed(1);
+ let bob = Keypair::from_seed(2);
+ enroll_member(
+ &refs,
+ &objects,
+ "admin",
+ &admin,
+ Provenance::AdminRegistered,
+ 100,
+ );
+ let config: FullName = namespace::CONFIG_REF.try_into().unwrap();
+ write_meta_entity(
+ &refs,
+ &objects,
+ config,
+ &Config { epoch: Some(200) },
+ Some(&admin),
+ 200,
+ );
+ enroll_member(&refs, &objects, "bob", &bob, Provenance::SelfAttested, 250);
+ (refs, objects, admin, bob)
+}
+
+/// A pre-flight against a canonical push that the pusher is not authorized
+/// for offers the author's own inbox route the instant the verdict goes
+/// negative (`sync.inbox-routing`), while an authorized push offers none.
+#[rstest]
+#[case::authorized_pass(1, true, false)]
+#[case::unauthorized_offers_inbox(2, false, true)]
+// @relation(sync.pre-flight, sync.inbox-routing, scope=function, role=Verifies)
+fn preflight_offers_inbox_only_on_an_authorization_refusal(
+ #[case] seed: u8,
+ #[case] expect_pass: bool,
+ #[case] expect_inbox: bool,
+) {
+ let (refs, objects, admin, bob) = forge();
+ let signer = if seed == 1 { &admin } else { &bob };
+ let author = if seed == 1 { "admin" } else { "bob" };
+
+ let name: FullName = "refs/meta/issues/9".try_into().unwrap();
+ let tip = write_meta_entity(&refs, &objects, name.clone(), &issue(), Some(signer), 300);
+
+ let before = refs.fetched_copy();
+ before.remove(name.as_ref());
+ let pf = preflight(
+ &before,
+ &objects,
+ &Update {
+ name,
+ new: Some(tip),
+ },
+ &MemberId::new(author),
+ )
+ .unwrap();
+
+ assert_eq!(pf.is_pass(), expect_pass);
+ assert_eq!(pf.inbox.is_some(), expect_inbox);
+ if let Some(inbox) = pf.inbox {
+ assert_eq!(inbox.as_bstr(), "refs/meta/inbox/bob/issues/9");
+ }
+}
+
+/// A fast-forward refusal is a *divergence* — the answer is a merge, not the
+/// inbox — so pre-flight offers no inbox route for it, matching the gate's
+/// own `inbox_alternative` signal (`sync.inbox-routing`).
+// @relation(sync.inbox-routing, scope=function, role=Verifies)
+#[test]
+fn a_divergence_refusal_offers_no_inbox() {
+ let (refs, objects, admin, _bob) = forge();
+ let name: FullName = "refs/meta/issues/3".try_into().unwrap();
+ write_meta_entity(&refs, &objects, name.clone(), &issue(), Some(&admin), 300);
+
+ // An independent root correctly bound to the same ref: signed by an
+ // authorized member with a matching trailer, but with no parents, so it
+ // cannot descend from the current tip — a genuine fast-forward refusal.
+ let mut other = issue();
+ other.state = "closed".into();
+ let sibling = {
+ let tree = facet_git_tree::serialize_into(&other, &objects).unwrap();
+ let trailers = Trailers {
+ ents_ref: Some(name.clone()),
+ schema_version: None,
+ };
+ let message = format!("Mutate {}\n\n{}", name.as_bstr(), trailers.render());
+ write_commit(
+ &objects,
+ &CommitSpec {
+ tree,
+ parents: vec![],
+ message,
+ seconds: 350,
+ },
+ Some(&admin),
+ )
+ };
+
+ let pf = preflight(
+ &refs,
+ &objects,
+ &Update {
+ name: name.clone(),
+ new: Some(sibling),
+ },
+ &MemberId::new("admin"),
+ )
+ .unwrap();
+ assert!(!pf.is_pass());
+ assert!(
+ pf.inbox.is_none(),
+ "a divergence routes to a merge, not the inbox"
+ );
+}
+
+/// Pre-flight runs the *identical* gate function every call site runs, so
+/// its verdict is always exactly the gate's verdict on the same inputs — a
+/// prediction that can only be stale, never wrong about the rules
+/// (`sync.pre-flight`, `gate.call-sites`).
+// @relation(sync.pre-flight, scope=function, role=Verifies)
+#[test]
+fn preflight_verdict_equals_the_gate_verdict() {
+ let (refs, objects, admin, bob) = forge();
+
+ for (author, signer) in [("admin", &admin), ("bob", &bob)] {
+ let name: FullName = format!("refs/meta/issues/{author}").try_into().unwrap();
+ let tip = write_meta_entity(&refs, &objects, name.clone(), &issue(), Some(signer), 300);
+ let before = refs.fetched_copy();
+ before.remove(name.as_ref());
+ let update = Update {
+ name,
+ new: Some(tip),
+ };
+
+ let pf = preflight(&before, &objects, &update, &MemberId::new(author)).unwrap();
+ let gate = verify(&before, &objects, &update).unwrap();
+ assert_eq!(
+ pf.verdict, gate,
+ "pre-flight must be the gate, not a copy of it"
+ );
+ }
+}
+
+/// Sync honors the gate's advisory role locally: a failing pre-flight is a
+/// prediction, not a veto — the same commit still writes to the local store
+/// (`sync.local-advisory`). The consequence sync owns is the inbox offer,
+/// which the failing pre-flight already surfaced.
+// @relation(sync.local-advisory, scope=function, role=Verifies)
+#[test]
+fn a_failing_preflight_never_blocks_the_local_write() {
+ let (refs, objects, _admin, bob) = forge();
+ let name: FullName = "refs/meta/issues/9".try_into().unwrap();
+ let tip = write_meta_entity(&refs, &objects, name.clone(), &issue(), Some(&bob), 300);
+
+ let before = refs.fetched_copy();
+ before.remove(name.as_ref());
+ let pf = preflight(
+ &before,
+ &objects,
+ &Update {
+ name: name.clone(),
+ new: Some(tip),
+ },
+ &MemberId::new("bob"),
+ )
+ .unwrap();
+ assert!(!pf.is_pass());
+ assert!(
+ pf.inbox.is_some(),
+ "the rejection consequence is an inbox offer"
+ );
+
+ // Nothing sync did prevents the local write from applying.
+ let outcome = before
+ .transaction(&[RefEdit {
+ name: name.clone(),
+ expected: Expected::MustNotExist,
+ new: Some(tip),
+ }])
+ .unwrap();
+ assert_eq!(before.get(name.as_ref()).unwrap(), Some(tip));
+ let _ = outcome;
+}
+
+#[rstest]
+#[case::canonical("refs/meta/issues/42", "refs/meta/inbox/jdc/issues/42")]
+#[case::nested("refs/meta/results/unit/abc", "refs/meta/inbox/jdc/results/unit/abc")]
+#[case::already_inbox("refs/meta/inbox/jdc/issues/1", "refs/meta/inbox/jdc/issues/1")]
+#[case::non_meta("refs/heads/main", "refs/heads/main")]
+// @relation(sync.inbox-routing, scope=function, role=Verifies)
+fn inbox_route_maps_canonical_to_the_authors_segment(#[case] input: &str, #[case] expected: &str) {
+ let canonical: FullName = input.try_into().unwrap();
+ let routed = inbox_route(canonical.as_ref(), &MemberId::new("jdc")).unwrap();
+ assert_eq!(routed.as_bstr(), expected);
+}
crates/ents-sync/tests/resolve.rs
@@ -1,0 +1,267 @@
+//! Divergence and adoption over the one merge machinery
+//! (`sync.divergence-merge`, `sync.adoption-machinery`,
+//! `sync.adoption-no-cherry-pick`).
+//!
+//! Strategy: **integration harness** — these are enumerable end-to-end
+//! scenarios whose point is that a real signed merge tip, built by
+//! [`ents_sync::merge_heads`], is accepted by the *real* gate
+//! ([`ents_gate::verify`]) and keeps the folded-in commit in ancestry. A
+//! property test would not add coverage over the specific shapes the spec
+//! names; the value is in exercising the same function the production path
+//! runs against genuine git objects and signatures.
+
+#![expect(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ clippy::panic,
+ reason = "tests"
+)]
+
+use ents_gate::{Config, Update, Verdict, verify};
+use ents_model::trailer::Trailers;
+use ents_model::{Issue, Provenance, namespace};
+use ents_sync::{Heads, Merged, merge_heads};
+use ents_testutil::{
+ CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity,
+};
+use gix::refs::FullName;
+use gix_hash::ObjectId;
+
+fn issue(state: &str) -> Issue {
+ Issue {
+ title: "t".into(),
+ body: "b".into(),
+ state: state.into(),
+ assignees: vec![],
+ labels: vec![],
+ }
+}
+
+/// Build a signed commit recording `entity`, bound to `refname`, with the
+/// given parents — the general shape [`write_meta_entity`] specializes.
+fn signed_commit(
+ objects: &ObjectStore,
+ refname: &FullName,
+ entity: &Issue,
+ parents: Vec<ObjectId>,
+ key: &Keypair,
+ seconds: i64,
+) -> ObjectId {
+ let tree = facet_git_tree::serialize_into(entity, objects).unwrap();
+ let trailers = Trailers {
+ ents_ref: Some(refname.clone()),
+ schema_version: None,
+ };
+ let message = format!("Mutate {}\n\n{}", refname.as_bstr(), trailers.render());
+ write_commit(
+ objects,
+ &CommitSpec {
+ tree,
+ parents,
+ message,
+ seconds,
+ },
+ Some(key),
+ )
+}
+
+fn author(seconds: i64) -> gix::actor::Signature {
+ gix::actor::Signature {
+ name: "placer".into(),
+ email: "placer@ents.test".into(),
+ time: gix::date::Time { seconds, offset: 0 },
+ }
+}
+
+/// Enroll `admin` and turn verification on by recording the epoch.
+fn boot(refs: &MemRefStore, objects: &ObjectStore, admin: &Keypair) {
+ enroll_member(
+ refs,
+ objects,
+ "admin",
+ admin,
+ Provenance::AdminRegistered,
+ 100,
+ );
+ let config: FullName = namespace::CONFIG_REF.try_into().unwrap();
+ write_meta_entity(
+ refs,
+ objects,
+ config,
+ &Config { epoch: Some(200) },
+ Some(admin),
+ 200,
+ );
+}
+
+fn parents_of(objects: &ObjectStore, tip: ObjectId) -> Vec<ObjectId> {
+ match objects.get(&tip).expect("tip present") {
+ gix::objs::Object::Commit(c) => c.parents.into_vec(),
+ _ => panic!("merge tip is a commit"),
+ }
+}
+
+/// Same-actor divergence: two of one member's machines edit disjoint fields
+/// of the same single-writer ref. The merge folds both, and the merge tip
+/// satisfies the tip invariant — the gate accepts it advancing the ref from
+/// the canonical tip (`sync.divergence-merge`, `gate.same-actor-divergence`).
+// @relation(sync.divergence-merge, scope=function, role=Verifies)
+#[test]
+fn same_actor_divergence_merges_to_a_gate_valid_tip() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let jdc = Keypair::from_seed(1);
+ boot(&refs, &objects, &jdc);
+
+ let name: FullName = "refs/meta/issues/1".try_into().unwrap();
+ let base = signed_commit(&objects, &name, &issue("open"), vec![], &jdc, 300);
+
+ // Two divergent children of the same base, editing different fields.
+ let mut ours_issue = issue("open");
+ ours_issue.title = "renamed".into();
+ let ours = signed_commit(&objects, &name, &ours_issue, vec![base], &jdc, 400);
+ let theirs = signed_commit(&objects, &name, &issue("closed"), vec![base], &jdc, 400);
+
+ let heads = Heads {
+ refname: name.clone(),
+ ours: Some(ours),
+ theirs,
+ };
+ let Merged::Tip(tip) = merge_heads(
+ &objects,
+ &heads,
+ &author(500),
+ "Merge divergent heads",
+ |p| jdc.sign(p),
+ )
+ .unwrap() else {
+ panic!("a same-actor divergence merges cleanly");
+ };
+
+ // Both disjoint edits survive the merge.
+ let merged_tree = match objects.get(&tip).unwrap() {
+ gix::objs::Object::Commit(c) => c.tree,
+ _ => panic!("commit"),
+ };
+ let got: Issue = facet_git_tree::deserialize(&merged_tree, &objects).unwrap();
+ assert_eq!(got.title, "renamed");
+ assert_eq!(got.state, "closed");
+
+ // The merge tip satisfies the tip invariant.
+ let snapshot = refs.fetched_copy();
+ snapshot.set(name.as_ref(), ours);
+ let verdict = verify(
+ &snapshot,
+ &objects,
+ &Update {
+ name,
+ new: Some(tip),
+ },
+ )
+ .unwrap();
+ assert!(matches!(verdict, Verdict::Pass(_)), "{verdict:?}");
+}
+
+/// Adoption of a contributor's brand-new entity onto a canonical ref that
+/// has no prior tip: the maintainer merges the contributor's signed commit
+/// (`sync.adoption-machinery`, `gate.adoption-merge`), which stays a parent
+/// so its signature and attribution survive — a merge, never a cherry-pick
+/// (`sync.adoption-no-cherry-pick`). The maintainer's signature on the tip
+/// makes it satisfy the tip invariant.
+// @relation(sync.adoption-machinery, sync.adoption-no-cherry-pick, scope=function, role=Verifies)
+#[test]
+fn adoption_merges_the_contributors_commit_without_cherry_picking() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let admin = Keypair::from_seed(1);
+ let bob = Keypair::from_seed(2);
+ boot(&refs, &objects, &admin);
+ enroll_member(&refs, &objects, "bob", &bob, Provenance::SelfAttested, 250);
+
+ // Bob submits an issue under his own inbox segment (all he may write).
+ let inbox: FullName = "refs/meta/inbox/bob/issues/5".try_into().unwrap();
+ let contribution = signed_commit(&objects, &inbox, &issue("open"), vec![], &bob, 300);
+
+ // The maintainer adopts it onto the canonical ref via the *same*
+ // machinery divergence uses — only the heads differ.
+ let canonical: FullName = "refs/meta/issues/5".try_into().unwrap();
+ let heads = Heads {
+ refname: canonical.clone(),
+ ours: None,
+ theirs: contribution,
+ };
+ let Merged::Tip(tip) = merge_heads(&objects, &heads, &author(400), "Adopt bob's issue", |p| {
+ admin.sign(p)
+ })
+ .unwrap() else {
+ panic!("a trivial adoption merges cleanly");
+ };
+
+ // Not a cherry-pick: bob's original signed commit is in ancestry.
+ assert!(
+ parents_of(&objects, tip).contains(&contribution),
+ "the contributor's commit must remain a parent, its signature intact"
+ );
+
+ // The maintainer's signature makes the tip satisfy the tip invariant on
+ // the previously-absent canonical ref.
+ let verdict = verify(
+ &refs,
+ &objects,
+ &Update {
+ name: canonical,
+ new: Some(tip),
+ },
+ )
+ .unwrap();
+ assert!(matches!(verdict, Verdict::Pass(_)), "{verdict:?}");
+}
+
+/// Adoption folding an inbox entity onto an *existing* canonical ref rides
+/// the identical [`merge_heads`] path, with a real three-way merge over the
+/// two typed trees (`sync.adoption-machinery`).
+// @relation(sync.adoption-machinery, scope=function, role=Verifies)
+#[test]
+fn adoption_onto_existing_canonical_ref_uses_the_merge() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let admin = Keypair::from_seed(1);
+ let bob = Keypair::from_seed(2);
+ boot(&refs, &objects, &admin);
+ enroll_member(&refs, &objects, "bob", &bob, Provenance::SelfAttested, 250);
+
+ let canonical: FullName = "refs/meta/issues/7".try_into().unwrap();
+ let base = signed_commit(&objects, &canonical, &issue("open"), vec![], &admin, 300);
+
+ // Bob branches from the canonical base and edits a field in his inbox.
+ let inbox: FullName = "refs/meta/inbox/bob/issues/7".try_into().unwrap();
+ let mut contributed = issue("open");
+ contributed.title = "bob's title".into();
+ let contribution = signed_commit(&objects, &inbox, &contributed, vec![base], &bob, 350);
+
+ let heads = Heads {
+ refname: canonical.clone(),
+ ours: Some(base),
+ theirs: contribution,
+ };
+ let Merged::Tip(tip) = merge_heads(&objects, &heads, &author(400), "Adopt bob's edit", |p| {
+ admin.sign(p)
+ })
+ .unwrap() else {
+ panic!("clean adoption");
+ };
+
+ assert!(parents_of(&objects, tip).contains(&contribution));
+ let snapshot = refs.fetched_copy();
+ snapshot.set(canonical.as_ref(), base);
+ let verdict = verify(
+ &snapshot,
+ &objects,
+ &Update {
+ name: canonical,
+ new: Some(tip),
+ },
+ )
+ .unwrap();
+ assert!(matches!(verdict, Verdict::Pass(_)), "{verdict:?}");
+}
crates/ents-sync/tests/transfer.rs
@@ -1,0 +1,266 @@
+//! Forge transfer: fetch and push over `refs/meta/*` (`sync.forge-transfer`,
+//! and the push side of `sync.pre-flight` / `sync.inbox-routing`).
+//!
+//! Strategy: **integration harness** — a "remote" and a "local" are each a
+//! ref-store / object-store pair, and the properties are end-to-end: after a
+//! fetch the destination can verify a signed tip entirely on its own (so
+//! history and signatures came across), a divergence is reported rather than
+//! silently overwritten, and a push gates the *remote* write on the same
+//! gate while routing an unauthorized canonical push to the inbox.
+
+#![expect(
+ clippy::unwrap_used,
+ clippy::expect_used,
+ clippy::panic,
+ reason = "tests"
+)]
+
+use ents_gate::{Config, Update, Verdict, verify};
+use ents_model::{Issue, MemberId, Provenance, namespace};
+use ents_sync::transfer::{Pushed, fetch, push};
+use ents_testutil::{
+ CommitSpec, Keypair, MemRefStore, ObjectStore, enroll_member, write_commit, write_meta_entity,
+};
+use gix::refs::FullName;
+use gix_ref_store::RefStoreRead;
+
+fn issue(state: &str) -> Issue {
+ Issue {
+ title: "t".into(),
+ body: "b".into(),
+ state: state.into(),
+ assignees: vec![],
+ labels: vec![],
+ }
+}
+
+/// Enroll `admin` (and optionally `bob`) and record the epoch.
+fn boot(refs: &MemRefStore, objects: &ObjectStore, admin: &Keypair, bob: Option<&Keypair>) {
+ enroll_member(
+ refs,
+ objects,
+ "admin",
+ admin,
+ Provenance::AdminRegistered,
+ 100,
+ );
+ let config: FullName = namespace::CONFIG_REF.try_into().unwrap();
+ write_meta_entity(
+ refs,
+ objects,
+ config,
+ &Config { epoch: Some(200) },
+ Some(admin),
+ 200,
+ );
+ if let Some(bob) = bob {
+ enroll_member(refs, objects, "bob", bob, Provenance::SelfAttested, 250);
+ }
+}
+
+/// Fetch moves the whole forge: every meta-ref, its full history, and the
+/// signatures needed to verify it, so the destination verifies a tip on its
+/// own with nothing left behind (`sync.forge-transfer`).
+// @relation(sync.forge-transfer, scope=function, role=Verifies)
+#[test]
+fn fetch_moves_the_whole_forge_with_verifiable_signatures() {
+ let remote_refs = MemRefStore::default();
+ let remote_objects = ObjectStore::default();
+ let admin = Keypair::from_seed(1);
+ boot(&remote_refs, &remote_objects, &admin, None);
+
+ // An issue with two commits of history.
+ let name: FullName = "refs/meta/issues/1".try_into().unwrap();
+ let parent = write_meta_entity(
+ &remote_refs,
+ &remote_objects,
+ name.clone(),
+ &issue("open"),
+ Some(&admin),
+ 300,
+ );
+ let tip = write_meta_entity(
+ &remote_refs,
+ &remote_objects,
+ name.clone(),
+ &issue("closed"),
+ Some(&admin),
+ 400,
+ );
+
+ let local_refs = MemRefStore::default();
+ let local_objects = ObjectStore::default();
+ let report = fetch(&remote_refs, &remote_objects, &local_refs, &local_objects).unwrap();
+
+ // member, config, and the issue all arrived.
+ assert!(
+ report
+ .updated
+ .iter()
+ .any(|n| n.as_bstr() == "refs/meta/member/admin")
+ );
+ assert!(
+ report
+ .updated
+ .iter()
+ .any(|n| n.as_bstr() == "refs/meta/config")
+ );
+ assert_eq!(local_refs.get(name.as_ref()).unwrap(), Some(tip));
+
+ // The full history came with it, not just the tip.
+ assert!(
+ local_objects.get(&parent).is_some(),
+ "the parent commit must transfer too"
+ );
+
+ // The signatures and policy transferred: the destination verifies the
+ // tip against its *own* fetched state, offline.
+ let snapshot = local_refs.fetched_copy();
+ snapshot.remove(name.as_ref());
+ let verdict = verify(
+ &snapshot,
+ &local_objects,
+ &Update {
+ name,
+ new: Some(tip),
+ },
+ )
+ .unwrap();
+ assert!(matches!(verdict, Verdict::Pass(_)), "{verdict:?}");
+}
+
+/// When a local meta-ref has moved out from under the remote — neither tip
+/// descends from the other — fetch reports the divergence for the merge
+/// machinery to resolve, and does not clobber the local ref.
+// @relation(sync.forge-transfer, scope=function, role=Verifies)
+#[test]
+fn fetch_reports_divergence_instead_of_overwriting() {
+ let name: FullName = "refs/meta/issues/1".try_into().unwrap();
+
+ let remote_refs = MemRefStore::default();
+ let remote_objects = ObjectStore::default();
+ let key = Keypair::from_seed(1);
+ let remote_tip = write_meta_entity(
+ &remote_refs,
+ &remote_objects,
+ name.clone(),
+ &issue("open"),
+ Some(&key),
+ 300,
+ );
+
+ // The local ref points at an independent-root commit: no descent either
+ // way.
+ let local_refs = MemRefStore::default();
+ let local_objects = ObjectStore::default();
+ let local_tip = {
+ let tree = facet_git_tree::serialize_into(&issue("closed"), &local_objects).unwrap();
+ write_commit(
+ &local_objects,
+ &CommitSpec {
+ tree,
+ parents: vec![],
+ message: "local".into(),
+ seconds: 300,
+ },
+ Some(&key),
+ )
+ };
+ local_refs.set(name.as_ref(), local_tip);
+
+ let report = fetch(&remote_refs, &remote_objects, &local_refs, &local_objects).unwrap();
+ assert!(report.updated.is_empty());
+ let diverged = report
+ .diverged
+ .iter()
+ .find(|d| d.name == name)
+ .expect("divergence reported");
+ assert_eq!(diverged.local, local_tip);
+ assert_eq!(diverged.remote, remote_tip);
+ // The local ref is untouched.
+ assert_eq!(local_refs.get(name.as_ref()).unwrap(), Some(local_tip));
+}
+
+/// Push pre-flights against the remote's own policy: an authorized push is
+/// transferred and advances the remote ref (`sync.pre-flight`,
+/// `sync.forge-transfer`).
+// @relation(sync.pre-flight, sync.forge-transfer, scope=function, role=Verifies)
+#[test]
+fn push_advances_the_remote_on_an_authorized_ref() {
+ let admin = Keypair::from_seed(1);
+ let remote_refs = MemRefStore::default();
+ let remote_objects = ObjectStore::default();
+ boot(&remote_refs, &remote_objects, &admin, None);
+
+ let local_refs = MemRefStore::default();
+ let local_objects = ObjectStore::default();
+ boot(&local_refs, &local_objects, &admin, None);
+
+ let name: FullName = "refs/meta/issues/1".try_into().unwrap();
+ let tip = write_meta_entity(
+ &local_refs,
+ &local_objects,
+ name.clone(),
+ &issue("open"),
+ Some(&admin),
+ 300,
+ );
+
+ let pushed = push(
+ &remote_refs,
+ &remote_objects,
+ &local_objects,
+ &name,
+ tip,
+ &MemberId::new("admin"),
+ )
+ .unwrap();
+ assert_eq!(pushed, Pushed::Advanced(name.clone()));
+ assert_eq!(remote_refs.get(name.as_ref()).unwrap(), Some(tip));
+}
+
+/// A self-attested contributor's canonical push is predicted to fail, so
+/// push routes it to the contributor's own inbox segment and leaves the
+/// remote canonical ref untouched (`sync.inbox-routing`).
+// @relation(sync.inbox-routing, sync.pre-flight, scope=function, role=Verifies)
+#[test]
+fn push_routes_an_unauthorized_canonical_push_to_the_inbox() {
+ let admin = Keypair::from_seed(1);
+ let bob = Keypair::from_seed(2);
+ let remote_refs = MemRefStore::default();
+ let remote_objects = ObjectStore::default();
+ boot(&remote_refs, &remote_objects, &admin, Some(&bob));
+
+ let local_refs = MemRefStore::default();
+ let local_objects = ObjectStore::default();
+ boot(&local_refs, &local_objects, &admin, Some(&bob));
+
+ let name: FullName = "refs/meta/issues/1".try_into().unwrap();
+ let tip = write_meta_entity(
+ &local_refs,
+ &local_objects,
+ name.clone(),
+ &issue("open"),
+ Some(&bob),
+ 300,
+ );
+
+ let pushed = push(
+ &remote_refs,
+ &remote_objects,
+ &local_objects,
+ &name,
+ tip,
+ &MemberId::new("bob"),
+ )
+ .unwrap();
+ match pushed {
+ Pushed::Inbox(route) => assert_eq!(route.as_bstr(), "refs/meta/inbox/bob/issues/1"),
+ other => panic!("expected inbox routing, got {other:?}"),
+ }
+ assert_eq!(
+ remote_refs.get(name.as_ref()).unwrap(),
+ None,
+ "canonical ref must be untouched"
+ );
+}