Repository Semantics - barocss/barocss-hub GitHub Wiki

Repository-Semantics

Order: 1 repository model, 2 operation model, 3 version model, 4 sync model. (The concurrency matrix is covered by the §2.7 scenario table, not a separate section.) Labels: [decision-proposal] = proposed, refinable with evidence; [open question] = unresolved.

1 Repository model

1.1 NodeId

  • [decision-proposal] NodeId = (Counter, ReplicaId) (same tuple order as OpId, 2.1; changed by #7). ReplicaId is a 128-bit random id generated once per replica (device/process lineage); Counter is a u64 that the replica increments monotonically for every id it mints (Lamport-style, never reused, persisted before use).
  • [decision-proposal] Uniqueness without coordination: two replicas never share a ReplicaId (collision probability ~2^-64 at 2^32 replicas, accepted); within a replica the counter is strictly increasing. Hence ids are globally unique without a server.
  • [decision-proposal] NodeIds are totally ordered by (Counter, ReplicaId) lexicographically; this order is used only for deterministic tie-breaking, never as meaning.
  • [decision-proposal] The root has a reserved well-known id ROOT = (0, ReplicaId::ZERO), identical on all replicas. TRASH = (1, ReplicaId::ZERO) is reserved likewise (see 1.4). Tuples are written (Counter, ReplicaId) everywhere (#7). ReplicaId::ZERO is reserved and never generated, so reserved ids cannot collide with minted ones.
  • [decision-proposal] (settled in 2.1) Counter is shared with operation ids: one Lamport clock per replica mints both OpIds and NodeIds; a create's NodeId equals its OpId.

1.2 Node and kinds

A node is {id, kind, parent, name, content}. kind is immutable for the lifetime of a NodeId.

Kind parent name content
Root none none (empty) none
Directory a node id UTF-8 name, no /, not ./.., non-empty none; children are derived from other nodes' parent
File a node id as above see below
  • [decision-proposal] File content is one of: Bytes (opaque blob, whole-value replace, last-writer by causal order + id tie-break) or Text (a sequence CRDT). The variant is fixed at creation.
  • [open question] Which text sequence CRDT (Automerge text, Loro text, Fugue, custom) and whether line- or char-granular. Deferred to research. Proposed answer: §7 Text-CRDT (#48): in-house FugueMax, line-level first; text-ness per edit (epochs), not fixed at creation (contradiction with the bullet above noted in 7.4.1).
  • [open question] Symlinks, executable bit, and other metadata. Not in v0.
  • [decision-proposal] A node may never have kind = Root other than ROOT.

1.3 Tree invariants

Invariants hold on every converged state (after applying any set of operations in any causal order). Each is phrased to become a property test.

  1. Single root: exactly one node has kind Root, its id is ROOT, and it has no parent. TRASH is the only other parentless node (kind Directory, never live, never projected).
  2. Parent exists: every non-root live node has exactly one parent, and that parent is a live Root or Directory node (never a File).
  3. Acyclic / reachability: following parent from any node other than ROOT/TRASH reaches ROOT or TRASH; from any live node it reaches ROOT in a finite number of steps (at most the number of nodes); no node is its own ancestor.
  4. Identity stability: for every node n present in states S and S' where S' = apply(S, op) and op is a move/delete/set-content, kind_S'(n) == kind_S(n), and the node set of S' equals that of S (only a valid create adds a node; no op removes one). The node set of any state equals {ROOT, TRASH} ∪ {op.id | op ∈ Ops, op is a valid Create}; a Create rejected as invalid (2.2) is a no-op and adds no node (#7). Test: random op sequences, check after each step.
  5. Convergence: for two replicas, Ops(A) == Ops(B) (as sets) ⇒ State(A) == State(B), independent of delivery order.
  6. Name validity: every live non-root node's name is a valid name as in 1.2.
  • [decision-proposal] (parent, name) uniqueness is not a state invariant. Concurrent creates/renames may legitimately produce two live siblings with the same name; forbidding it would require coordination or silently dropping intent. Uniqueness is a projection-time concern (1.5) plus a reported structural conflict.
  • [decision-proposal] Cycle-creating concurrent moves are resolved in the state (not at projection); exact rule in 2.4. (The former invariant "unique ids" is about operations, not state, and now lives in 2.6.)

1.4 Deletion / TRASH

  • [decision-proposal] (changed by #4, supersedes the earlier tombstone-flag proposal) Delete is move(node, parent = TRASH). The node record is kept with its last name and content; only its parent changes. Liveness = the node's ancestor chain reaches ROOT (not TRASH). Descendants of a trashed directory are therefore not live either, without touching them.
  • Why TRASH over a deleted flag: (a) one operation type (move) covers create/rename/reparent/delete, so one conflict rule (2.4) covers every structural race instead of two interacting rules (flag vs parent); (b) it is the representation proven convergent by Kleppmann et al. 2021 and used by Loro (Discussion #6); (c) restore is just a move out of TRASH and needs no extra state; (d) acyclicity (invariant 3) holds for trashed records automatically. Cost: move-vs-delete becomes a move-vs-move race whose outcome is a product decision (2.5).
  • [decided] (D-014, #20) Edit-vs-delete: an edit revives the deleted file, consistent with D-001; see §2.3 Edit vs delete.
  • [open question] Garbage collection of TRASH subtrees and the op log (requires causal stability). Section 4 (sync model).

1.5 Filesystem projection

  • [decision-proposal] Projection is a pure, deterministic function project(State) -> Map<Path, Entry>: walk live nodes from ROOT; path of a node = path of parent + / + name. Root maps to the working-tree root.
  • [decision-proposal] Name collision (two live siblings with the same name): the sibling with the smallest NodeId (order in 1.1) gets the plain name; others get a deterministic disambiguated name name~<short-id> (short-id format: see the #9 suffix-format entry below), and a structural conflict is recorded for the user. No intent is dropped; the projection is identical on all replicas.
  • [decision-proposal] A path is never identity: renaming a node changes its projected path but not its NodeId; importing filesystem changes back must map paths to NodeIds via the last projection, not by path equality.
  • [decided] (user decision 2026-09-26; D-003; recorded by #10) Name comparison is byte-exact. Collision groups in projection are formed by exact byte equality of names: no case folding, no Unicode normalization. Names that differ only in case or normalization are distinct siblings with distinct paths. Clashes on case-insensitive or normalizing filesystems (macOS, Windows) are handled at checkout time, per OS, at checkout time, not by project. (Code: projection::project groups by name as bytes.)
  • [decided] (D-019, #20; replaces the #19 "refuse the whole checkout" rule) Case clash at checkout: skip only the clashing files. When the working directory's filesystem folds case (probed at checkout), head paths are grouped by their lowercase form; every member of a group with two or more paths, and everything under such a member, is not materialized. The rest is materialized normally. Skipped paths stay in the checkout index as unmaterialized entries (u <path> <node id>, index v0), are listed by barocss-hub status under "unmaterialized (case clash ...)", are reported as warnings by checkout, and are never treated as deleted by status/record. A previously materialized clean file that becomes part of a clash is removed from disk (a dirty one is refused unless --force, as for any removal).
    • [decision-proposal] (#20) No member of a clash group is privileged: all of them are skipped (not "first by byte order wins"), so the result does not depend on which name sorts first. The user resolves it by renaming in a case-sensitive checkout.
    • Case folding is Unicode simple lowercase; Unicode normalization is not modeled (open question from #19). (Code: hub-cli wd::clashing, wd::plan, wd::Entry::Unmaterialized; tests crates/hub-cli/tests/workdir.rs case_clash_*, on the real filesystem and with a forced probe via HUB_TEST_FORCE_CASE_INSENSITIVE.)
    • [decision-proposal] (#20, #23) Blocked state. A working-directory file or directory at, case-folding onto, or under an unmaterialized path is blocked: barocss-hub status prints blocked: <path> collides with unmaterialized <paths>, record refuses until it is renamed or removed, checkout never overwrites it, and mkdir/touch/mv/rm refuse (refusing to <cmd>) rather than clobber it. (Code: wd::blocked; tests blocked_file_on_unmaterialized_path_*.)
  • [decision-proposal] (#9) Suffix format: short id = <counter>-<low 32 bits of replica, 8 hex>, suffixed name name~<short-id>. Plain names of every collision-group winner are reserved first; each loser (ascending NodeId) tries name~<short-id>, then name~<counter>-<full 32-hex replica>, then that with .1, .2, ... until unused among its siblings; every assigned name is reserved before the next loser. Two losers sharing a short id therefore get distinct paths (test issue10_losers_sharing_short_id_get_distinct_paths).
  • [decided] (user decision 2026-09-26; D-002; recorded by #10) User-chosen names always win; suffix instability is accepted. If a sibling (e.g. created later) takes a name equal to an existing name~<short-id>, that sibling gets the plain name and the suffixed node falls back to the long form, so its projected path changes. Names matching the suffix pattern are not rejected. Projection stays a pure function of the state, so this is deterministic on all replicas; it is a path change, not an identity change (paths are never identity, above). (Tests suffix_never_collides_with_user_name, issue10_later_sibling_takes_suffixed_name.)
  • [decision-proposal] (#26) Working tree follows the projection after every local mutation. Invariant: after any successful record or mkdir/touch/mv/rm (and after checkout), the working directory equals project(head) for every tracked node, modulo unmaterialized (D-019) paths, and the checkout index has an entry for every live projected path. A local op can change the projected path of nodes the user did not touch (a collision disappears, so f1~<id> becomes f1; a new node joins a collision group and takes a suffix; D-002 suffix changes). record therefore builds its ops first, indexes the scanned tree under the new state's nodes at their on-disk paths, and plans the new head projection against that with the checkout planner (wd::plan), appending the ops only if the plan succeeds. The same safety rules apply as for checkout: an unrecorded, blocked (D-019) or ignored (#24) path is never overwritten or deleted; the command refuses and appends nothing. (Chosen over "keep old paths in the index until checkout", which would leave the tree off-projection between commands.) mkdir/touch/mv/rm already planned against the full projection. (Code: hub-cli record; tests crates/hub-cli/tests/sync.rs record_materializes_rename_when_collision_disappears, record_suffixes_new_node_when_collision_appears, record_refuses_rename_that_would_delete_ignored_file; sync_prop.rs asserts the invariant after every step.)

1.6 Ignore rules: .hubignore (#24)

Ignore rules act only on the working-directory scan (record, status, checkout's safety checks). They never change ops, projection, versions or sync.

  • Known technique: a subset of gitignore semantics (git gitignore(5)): last matching pattern wins; a parent directory that is excluded cannot have children re-included; patterns with a leading or middle / are relative to the file's directory, others match at any level.
  • Supported subset (one file, .hubignore at the working-directory root): blank lines and # comments; trailing spaces trimmed; leading \# / \! escape; ! negation; trailing / = directories only; leading or middle / = anchored (leading / dropped); * / ? never match /; ** as a whole segment = zero or more segments, trailing /** = everything inside but not the directory itself; ** inside a segment = *. Not supported: [...] classes, other backslash escapes, escaped trailing spaces, nested .hubignore files, global/per-repo exclude files, case-insensitive matching. .hub/ is always excluded before rules apply.
  • [decision-proposal] (#24) .hubignore is an ordinary tracked file: recorded and synced like any file, so all replicas ignore the same paths once they check out the same version.
  • [decision-proposal] (#24) Once tracked, stays tracked. A path in the checkout index is never classed as ignored: its edits and deletion are recorded; barocss-hub status lists it under "tracked but ignored". barocss-hub rm stops tracking it.
  • record never creates nodes for untracked ignored paths. status hides them; status --ignored lists the top-most ignored path of each ignored subtree. Ignored files are not hashed and untracked ignored directories are not descended (unless tracked paths lie under them).
  • checkout writes tracked files even if they match a rule. It never deletes or overwrites an untracked ignored path, even with --force: a target path occupied by one, or a forced removal that would delete one, is refused with the path named. A tracked directory removed by checkout keeps its ignored contents (non-recursive removal, warning "kept directory").
  • Sync: unaffected (§4 exchanges ops/blobs/checkpoints; rules are not consulted).
  • Code: hub-cli ignore.rs, wd::scan_all, wd::plan (PlanOpts::ignored); tests crates/hub-cli/tests/ignore.rs, ignore_prop.rs, unit tests in ignore.rs.

1.6.1 Built-in default ignores and editor saves (#37)

  • [decision-proposal] (#37, repaired) Built-in defaults, applied before every .hubignore line (so a !pattern in the file re-includes one; last match wins), even with no .hubignore. Unconditional: *.swp *.swo *.swn *.swx (vim swap), *___jb_tmp___ *___jb_old___ (JetBrains safe write), .~lock.*# (LibreOffice lock), .goutputstream-* (GNOME GIO atomic replace), .DS_Store Thumbs.db desktop.ini (OS folder metadata). Conditional (names an editor uses that are also plausible real file names; review found data/4913 and notes~ with real content silently not synced): 4913 only if it is an empty regular file (vim probe); x~ only while x exists beside it (backup); #x# only while x exists beside it (emacs auto-save); .#x only if it is a symlink or a regular file of at most 256 bytes, one line, containing @ (emacs lock [email protected]:boot). Directories never match the conditional ones. Otherwise they are recorded like any file. The conditions are evaluated by the one matcher (ignore::Ignore, rooted), so record, status, watch (fingerprint and record) and "tracked but ignored" agree. Plain status prints N file(s) hidden by default ignore rules (see ...); status --json has hidden_by_defaults and default per ignored entry. The #24 rule holds: a tracked path stays tracked. Code: ignore::DEFAULTS, ignore::CONDITIONAL, ignore::Cond.
  • Consequence: a conditional file changes class when its sibling appears or goes: after rm x with a lingering x~, x~ is recorded (it is no longer a backup). During vim's own save (x renamed to x~) the watch deferral below prevents recording that intermediate state.
  • Observed behavior (tests crates/hub-cli/tests/editor.rs, simulated with the real binary): vim backupcopy=no (4913 probe, .x.swp, rename x→x~, write x, delete x~), a VS Code-style atomic write (temp + rename over x) and JetBrains safe write (x___jb_tmp___, x→x___jb_old___, tmp→x, delete old) are each recorded, by record and by watch, as one modified change of the same node; no temp file is recorded. The scan is path-based, so a new inode at the same path is a content edit; the temps never reach rename detection because they are ignored.
  • [decision-proposal] (#37) Watch defers during a save. If a tracked file is missing while its own x~, x___jb_old___ or x___jb_tmp___ exists, a watch step records nothing (event deferred) and retries, for at most 5 s (HUB_WATCH_SAVE_WAIT_MS); after that the delete is recorded (a real delete with a lingering backup is not blocked forever). Without it, a save whose "file absent" window is longer than --debounce is recorded as delete + create (a new NodeId) — shown by slow_save_without_deferral_is_delete_plus_create.
  • Hypothesis (unverified): the exact temp names VS Code uses for atomic saves are not in the list; the rename is atomic, so its temp is only visible mid-save and the debounce covers it. A real editor trace would confirm.

2 Operation model

Inputs: §1, Discussion #6 (research #2). Labels as above.

2.1 Operation envelope

  • [decision-proposal] Op = {id: OpId, deps: Set<OpId>, payload}. OpId = (Counter, ReplicaId) from the replica's single Lamport clock (1.1): before minting, Counter = 1 + max(own last counter, max counter of all ops in deps). The author replica is id.ReplicaId.
  • [decision-proposal] deps = the current causal frontier (heads) of the author's op DAG at creation time. A version vector Map<ReplicaId, Counter> is a derived summary for sync (section 4), not part of the op.
  • [decision-proposal] Total order < on ops: by (Counter, ReplicaId) lexicographically. It extends causal order (if a ∈ deps*(b) then a < b) because of the Lamport rule. This order is the only tie-breaker; it carries no meaning.
  • [decision-proposal] An op is applied only after all its deps are present (causal delivery); ops received early are buffered.

2.2 Op types

Payload Fields Meaning
Create `kind ∈ {Directory, File(Bytes Text)}, parent, name`
Move node, new_parent, new_name covers rename (same parent), reparent, and restore from TRASH
Delete node exactly Move{node, TRASH, current name}; kept as a distinct payload so intent is visible in history (apply treats it as a Move; 2.5)
SetContent node, content_op Bytes: whole-value replace; Text: an op of the chosen sequence CRDT (granularity [open question], see 1.2)
  • [decision-proposal] Ops that target ROOT or TRASH as the node (moving, deleting, or creating a node whose id is reserved), Creates of kind Root, moves with new_parent a File, invalid names, and ops referencing unknown nodes are rejected at authoring time. Using TRASH (or a trashed directory) as the parent of a Create or Move is not in this list; it is allowed (see below). If one still arrives (buggy/malicious peer), it is a no-op recorded as an invalid-op conflict, deterministically on every replica.
  • [decision-proposal] (#7, one label per case) invalid-op covers ops that are invalid against the state at the op's position in the total order < (reserved ids, a parent that does not exist or is a File, bad name, unknown node, a Create whose id already exists). Validity is judged against that state, not against the author's view, so every replica reaches the same verdict (#10). A move that would form a cycle is never labelled invalid-op, even if a buggy peer logged it against its own authoring check: whether it forms a cycle depends on the state at its position in <, so it goes through the 2.4 ancestor check and, if skipped, is recorded with label cycle.
  • [decision-proposal] (#7) Plain Move{node, TRASH, name} is allowed and means exactly the same as Delete{node} (with the given name kept on the record); implementations may normalize it to Delete. Move{node, d, name} where d is a trashed directory is allowed: the node's parent becomes d, so the node (and its subtree) is not live, exactly as if it had been in d when d was deleted. It is not a conflict by itself. User-facing tools do not offer it (trashed nodes are not projected), but it arises through the API and concurrently (row 7, row 11). Only ROOT and TRASH themselves may not be moved.
  • [decision-proposal] (#8) Delete{node} of a node that does not exist (at its position in <) is statically invalid (invalid-op), like any op referencing an unknown node. An invalid op is a no-op and is never a loser in a cycle or same-node-move conflict, and is not counted as an applied Move/Delete when choosing a winner (2.4). Its only record is its invalid-op conflict. (Code: Work::apply, Delete branch returns Outcome::Invalid for an unknown node; evaluate only considers Outcome::Applied ops for winners/losers; test invalid_op_is_noop_and_recorded, and no_silent_drop skips invalid ops.)
  • [decision-proposal] (#8) Create with parent = TRASH or parent = a trashed directory is allowed (TRASH is a Directory, 1.3 #1). The node is created (it counts in the node set, 1.3 #4) but is not live and not projected, exactly as if it had been created live and then moved into the trash; restoring it is an ordinary move out. It is not a conflict. This arises concurrently (a create under d concurrent with a delete of d) and through the API; user-facing tools do not offer it. (Code: Work::apply Create branch validates only parent_ok, i.e. parent kind is Root/Directory, not liveness.)

2.3 Apply semantics

  • [decision-proposal] State is a pure function of the op set: State(Ops) = fold(apply, S0, sort(Ops by <)) where S0 = {ROOT, TRASH}. Implementations may apply incrementally using Kleppmann undo-do-redo: on receiving op o with later ops L already applied, undo L (in reverse), apply o, redo L. Result must equal the sorted fold (this is the testable spec; undo-redo is an optimization).
  • Structural ops (Create/Move/Delete) are applied by the tree rule in 2.4. SetContent never changes structure; Bytes content = value of the greatest (by <) SetContent on that node; Text content = CRDT merge of all its ops (order-independent by the CRDT's own guarantee).
  • Every rule below is deterministic given the op set, so every replica computes the same state and the same conflict set.
  • [decision-proposal] (#18, file content v0, Bytes only) SetContent{node, blob} carries a BlobId = SHA-256 of the whole file bytes; blobs live in a content-addressed store (v0: in memory). A new File has the empty blob; Root/Directory have no content.
    • Validity: SetContent whose node does not exist at its position in <, is ROOT/TRASH, or is not a File is an invalid-op (2.2), a no-op, never a loser.
    • Concurrent edits: the greatest Applied SetContent of n in < is the winner (its blob is n's content). Every other Applied SetContent e of n with no causally later Applied SetContent of n is a loser and gets exactly one content{node: n, winner, loser: e, winner_blob, loser_blob} conflict (same shape as the 3+ move rule in 2.4: k concurrent edits give k-1 conflicts naming one winner). Nothing is silently dropped; the losing blob stays addressable. A SetContent causally after all heads clears them (normal editing).
    • Edit vs move: independent fields; the content follows the node; no conflict (row 3).
    • Edit vs delete: [decided] (D-014, #20; replaces the #18 "edit never revives / edit-deleted" rule) an edit revives the deleted file, consistent with D-001, and a conflict keeps the delete's intent visible. Precisely, after the sorted fold (a post-fold step, so still a pure function of the op set): for each Applied SetContent E of x (ascending by <) with x not live, let t be the node on x's ancestor-or-self chain whose parent is TRASH. If t's winning parent-setting op (greatest Applied Move/Delete of t, 2.4) is a Delete D and E ∉ deps*(D) (the delete did not see the edit), then t is restored to its pre-delete parent (its parent in the fold state just before D; Delete never changes the name) and edit-revived{node: x, edit: E, delete: D} is recorded. Repeat until nothing changes (the restored parent may itself have been deleted, so nested deletes along x's path are all undone). A restore is skipped if the pre-delete parent is TRASH itself (the node was created or moved directly into TRASH) or if it would form a cycle. The result is the same for every delivery order and every replica.
      • Concurrent edit and delete (E ∥ D), in either < order: x is live at its pre-delete path with E's content, one edit-revived conflict (row 14). If the delete was of an ancestor d, it is d that returns (with its whole subtree), so x keeps its path.
      • [decision-proposal] (D-014 leaves this case to the spec) An edit causally after the delete (D ∈ deps*(E)) also revives, with the same edit-revived conflict: editing a trashed file is treated as intent to keep it.
      • A delete causally after the edit (E ∈ deps*(D)) wins as usual: x stays trashed with E's content, no conflict (a re-delete after seeing a revive therefore sticks).
      • If x is live anyway (e.g. a concurrent move revives it, D-001, row 15), there is nothing to revive and no edit-revived.
      • edit-revived is informational: D is not a loser (2.6 #5 unaffected).
      • [escalation] E-12 (#20) Reviving a deleted ancestor d also brings back d's other children, which the deleter meant to delete. Alternative: move only x out to d's pre-delete parent (like the D-001 descendant case: d stays deleted, but x's path changes). The implementation follows the text above (restore d); a product decision is requested.
    • Text files (#48): concurrent text edits merge instead of producing content conflicts; epoch rule and TextEdit in §7 Text-CRDT 7.4.3/7.4.4 [decision-proposal].
    • (Code: tree::{BlobId, BlobStore, Payload::SetContent, Conflict::{Content, EditRevived}}, evaluate, revive_by_edits (shared with Replica); projection::Projection.blobs; tests crates/hub-core/tests/content.rs.)

2.4 Concurrent move rule (cycles, same-node races)

  • [decision-proposal] Kleppmann rule: when applying a Move of n under p in < order, if n == p or n is an ancestor of p in the current state, the move is skipped (state unchanged). Since ops are applied in the total order, the move that is later in < and would close the cycle is the one skipped.
  • [decision-proposal] Concurrent moves of the same node (two or more): all are applied in < order, so the greatest non-skipped one determines the final parent/name (LWW by op order). This is the winner.
  • [decision-proposal] Record losing move as conflict (extension over Kleppmann 2021, which drops silently): a skipped cycle-forming move, and a move overwritten by a concurrent (not causally later) move of the same node, each produce a StructuralConflict{kind, winner: OpId, loser: OpId, node} in the derived conflict set.
  • [decision-proposal] (#7, 3+ moves) For a node n, let W be the winner (the greatest non-skipped Move/Delete of n in <). Every other non-skipped Move/Delete m of n that has no causally later Move/Delete of n (no m' of n with m ∈ deps*(m')) is a loser and gets exactly one conflict {kind: same-node-move, winner: W, loser: m, node: n}. So k mutually concurrent moves yield k-1 conflicts, all naming the same winner (not a chain A→B→C). A skipped cycle-forming move gets only its cycle conflict and is not a same-node loser (its winner: see the cycle-winner rule below). Hence "exactly one conflict per loser" (2.6 #5) holds for any number of concurrent moves. The losing op stays in the log; the user can re-issue it. Conflicts are derived state (a function of the op set), so all replicas report the same ones. A move overwritten by a causally later move is normal editing, not a conflict.
  • [decision-proposal] (#8) Cycle conflict winner: when the Move of n under p is skipped, the existing path p → … → n (following parents) is what blocks it. winner = the greatest (by <) op among those that last set a parent edge on that path (each node on the path from p up to, excluding, n; a Create counts as setting its node's edge). For a self-move (n == p) there is no edge, so winner = the skipped op itself. (Code: Work::apply, Outcome::Skipped { winner } computed from setter along the path; test row1_concurrent_cycle.)
  • [decision-proposal] (#8, revived trigger, order-independent) A revived{node: x, delete: D, move: M} conflict is recorded iff all hold: (a) D = Delete{d} and M = Move{x, …} with x ≠ d; (b) D and M are both Applied (valid and not cycle-skipped); (c) D ∥ M (concurrent: neither in the other's deps*); (d) D is the winner for d, i.e. the greatest Applied Move/Delete of d; (e) in the state just before min(D, M) in the sorted fold, x exists and d is a strict ancestor of x; (f) x is live in the final state. It depends only on the op set, not on delivery. (Code: evaluate, revived loop; tests row5_delete_vs_move_descendant_revived_both_orders, revived_not_for_causal_restore (fails c), revived_not_when_delete_is_overridden (fails d), revived_not_when_x_stays_in_trash (fails f).)
  • [decision-proposal] (#8) In v0, conflicts are derived by re-evaluating the full op log with the sorted fold (evaluate), not maintained incrementally; the incremental undo-do-redo replica (Replica::integrate) maintains only the state and must agree with evaluate. Incremental conflict maintenance is a later optimization with the same spec.
  • Name collisions produced by concurrent create/rename are not resolved here; see 1.5.

2.5 Move vs delete (decided: Option A)

Scenario: A deletes directory d (Move to TRASH); concurrently B moves d (or a descendant x of d) elsewhere.

  • [decided] (user decision 2026-09-26; source: Decisions entry D-001 (wiki a3c8d31), and the decision comment on #4; applied by #7) Option A — move revives, conflict recorded. Delete is treated exactly as a Move (1.4, 2.2). The greatest op in < wins (2.4): if B's move is later, d is live at B's target; if A's delete is later, d is in TRASH. The overwritten op is recorded as a same-node-move conflict (2.4), so the deleter sees that the delete did not stick. Moving a descendant x out of a concurrently deleted d keeps x live (its own parent is not trash) and A's delete of d still takes effect; so the delete's intent stays visible, a revived{node: x, delete: A, move: B} conflict is recorded. It is informational: A is not a loser, so 2.6 #5 is unaffected.
    • Why: single rule, formally proven (Kleppmann et al. 2021), no special cases, implementable on Loro/Kleppmann engines as-is; no reorganization work is lost.
    • Accepted cost: the outcome depends on Lamport order, which users do not see, and a deleted node can reappear. The recorded conflict mitigates the surprise.
  • Rejected: Option B — delete wins. A Move concurrent with a Delete of n (or of an ancestor) would be skipped if it brought n out of the trashed subtree; only a causally later move (explicit restore) revives.
    • Rejected because: B's reorganization is undone from B's perspective; it needs a concurrency check (deps*) on top of the Kleppmann rule, so the convergence proof no longer applies directly; the descendant case needs an extra ancestor-at-delete-time rule. Its advantage (clock-independent, predictable deletes, reportedly Loro's behaviour, unverified #6) did not outweigh this.
  • The node is never erased, so the losing intent is recoverable from the log. Delete stays a distinct payload for history and UI intent only; apply treats it as a Move.

2.6 Invariants on operations (testable)

  1. Unique ids (moved from §1.3): no two ops share an OpId; hence no two Creates produce the same NodeId. Property: over random multi-replica histories, OpIds are pairwise distinct.
  2. Lamport consistency: for every op b and a ∈ deps(b): a < b.
  3. Permutation convergence: for any op set Ops and any two causally-consistent delivery orders π1, π2 (with or without incremental undo-redo), State(π1) == State(π2) and Conflicts(π1) == Conflicts(π2). Hence Ops(A) == Ops(B) ⇒ State(A) == State(B).
  4. Tree invariants preserved: after applying every prefix of the sorted op list, §1.3 invariants 1–6 hold.
  5. No silent drop (reworded by #8): for every structural op that is not invalid: a skipped cycle move loses exactly one conflict (its cycle conflict), even when a causally later move of the node exists; otherwise the op either takes effect in the final state or is overwritten by a causally later Applied op (zero losses), or appears as loser in exactly one same-node-move conflict. Invalid ops are never losers (only invalid-op, 2.2). Holds for any number of concurrent moves of one node (2.4). revived conflicts (2.5) have no loser and do not count toward per-loser conflicts. (Test: no_silent_drop inside property shuffled_duplicated_delivery_converges.)
  6. Idempotence: applying an already-present op is a no-op.
  7. Own-chain deps (#11): an author's deps always include its own previous op (directly or transitively), so each replica's ops form a causal chain. Follows from 2.1 (deps = the author's current frontier). §3.1 (version vector determines the version) and §3.5 #7 rely on it. Property: for every op b by replica r with a previous op a by r, a ∈ deps*(b). [decision-proposal] (#12) An op that violates this is not rejected and is not an invalid-op: the version vector is only a hint (a sync summary), while the frontier / op set stays the authoritative version identity. With such an op present, VV may fail to determine the version (§3.1, §3.5 #7). (Code: version::own_chain_ok detects violations; Version::from_vv and compare_vv are meaningful only when it holds; test own_chain_violation_is_detected; generator check p2_6_7_generator_own_chain_deps.)

2.7 Worked scenarios (replicas A, B, C; initial tree /a/, /b/, /a/x unless stated; ops concurrent; assume A's op < B's op < C's op in <)

# A B Converged state Conflict recorded
1 move a under b move b under a /b/a/x; B's move skipped (would form cycle) cycle: winner A, loser B
2 move x to /b/x move x to /x /x (B later wins) same-node move: winner B, loser A
3 rename x→y set-content x /a/y with B's content none (independent fields)
4 create /a/n create /a/n two nodes; projected /a/n (smaller id, A's) and /a/n~<B-short-id> name collision (1.5)
5 delete a move x to /b/x /b/x live, a in TRASH (Option A) revived: node x, delete A, move B (informational, no loser)
6 delete a move a under b /b/a/x live (B later; Option A) same-node-move: winner B, loser A
7 move a under b delete b a,x trashed with b (not live); no conflict under either option since nothing moves out of trash none
8 move a under b, then (causally after) try to move b under a — second move rejected at authoring as a cycle (2.2); never enters the log; /b/a/x none
9 delete a (later, causally after seeing it) move a to / /a/x live: explicit restore none
10 move x to /b/x; B: move x to /x; C: move x to /b/z (all three concurrent) (see A) /b/z (C greatest wins) same-node-move: winner C, loser A; winner C, loser B (2 conflicts, one per loser)
11 delete b (causally after seeing it) move x into trashed b b, x in TRASH subtree, not live; /a/ live none (move into trashed dir is allowed, 2.2)
12 set-content x = "A" set-content x = "B" /a/x with "B" (greater op wins) content: winner B, loser A (both blobs)
13 set-content x; B, C also set-content x (all concurrent) (see A) C's content content: winner C, loser A; winner C, loser B
14 set-content x (or delete x) delete x (or set-content x) /a/x live with the edited content (either order; the edit revives, D-014) edit-revived: node x, edit, delete (informational)
15 delete x set-content x; C: move x to /b/x /b/x live with B's content (move revives, D-001; the edit has nothing left to revive) same-node-move: winner C, loser A; no edit-revived
16 set-content x move x to /b/y /b/y with A's content (either order) none
Rows 12-16 (#18; rows 14, 15 updated by #20 for D-014) cover file content: concurrent edits (12, 13), edit vs delete (14; with a delete of ancestor a instead, a is restored and x stays at /a/x), edit vs move-revive (15), edit vs move (16, 3). An edit causally after the delete also revives ([decision-proposal], §2.3); a delete causally after the edit stays. (Tests: crates/hub-core/tests/content.rs edit_vs_delete_both_orders_revives, edit_vs_delete_of_parent_folder_revives_folder, edit_vs_nested_deletes_revives_whole_chain, edit_causally_after_delete_revives, delete_causally_after_edit_stays_deleted, redelete_after_seeing_revive_sticks; property edit_revive_holds inside content_converges_and_no_silent_drop.)
Row 8 shows cycle skips in 2.4 only arise from concurrency. Rows 5, 6, 9 cover move-vs-delete; row 1 covers cycles; rows 1, 2, 6, 10 show losing moves recorded as conflicts; row 10 covers 3+ concurrent moves; rows 7, 11 cover moves into a trashed directory.

3 Version model

Inputs: §1, §2, Architecture, Discussion #6, Issue #11. Labels as above. Notation: deps*(o) = transitive deps of o; a ≺ b iff a ∈ deps*(b) (causal order); < = the total order of 2.1 (extends ≺).

3.1 Version identity

  • [decision-proposal] A version is a causally closed op set V: o ∈ V ⇒ deps(o) ⊆ V. The empty set is the genesis version (state S0, 2.3). Only causally closed sets are versions; a replica's applied op set is always one, because ops are applied only after their deps (2.1).
  • [decision-proposal] A version is named by its frontier F(V) = {o ∈ V | no o' ∈ V with o ≺ o'} (its maximal ops), written canonically as the list of OpIds sorted by <. F(∅) = {}. The frontier uniquely determines the version: V = F(V) ∪ ⋃_{f ∈ F(V)} deps*(f) (down-closure), since every op in a finite causally closed set is below some maximal op.
  • Why frontier, not content hash, as the identity:
    • Deterministic across replicas: it depends only on the op set and OpIds, not on any byte encoding, so no canonical serialization (wire format, out of scope) is needed yet.
    • Cheap: size = number of concurrent heads (usually 1 to a few), updated in O(|deps|) per op; comparison needs only membership tests (3.2). A hash would need to be recomputed per version and cannot answer ancestor/concurrent questions by itself.
    • Same shape as deps: an op's deps is exactly the frontier of the author's version at authoring time (2.1), so "the version an op was made against" is already named this way.
    • Cost / limitation: a frontier names a version only relative to an op store that can resolve its OpIds; it does not detect a peer that reuses an OpId for different payloads (violating 2.6 #1).
  • [decision-proposal] (#46) Integrity digest: a derived, per-author-replica additive digest of a version's op set (LtHash16 over the ops' content hashes, #46 Review R2), used by sync to detect ids with two payloads; see 4.12. It is not the version identity (the frontier stays the identity) and it is not stored.
  • [decision-proposal] Derived version vector VV(V): ReplicaId → Counter, VV(V)[r] = max{c | (c, r) ∈ V} (absent = 0). Because each op's deps include the author's current frontier, a replica's own ops form a causal chain; so a causally closed V contains, for each replica r, exactly the ops of r with Counter ≤ VV(V)[r]. Hence VV also determines V (given the store). It is a summary for sync, not the identity: its size grows with the number of replicas ever seen, while the frontier stays small. (Counters of one replica are not contiguous because of the Lamport rule, so VV[r] = c does not mean c ops.)

3.2 Comparison and merge

  • [decision-proposal] Comparison is op-set inclusion. For versions V1, V2:
    • equal iff V1 = V2 iff F(V1) = F(V2);
    • V1 is an ancestor of V2 iff V1 ⊊ V2 iff every f ∈ F(V1) is in V2 and F(V1) ≠ F(V2); descendant is the converse;
    • concurrent otherwise (neither contains the other). Equivalent test on vectors (under §2.6 #7): V1 ⊆ V2 iff VV(V1) ≤ VV(V2) pointwise.
  • [decision-proposal] Merge V1 ⊔ V2 = V1 ∪ V2. It is causally closed: if o ∈ V1 ∪ V2, then o is in some Vi, which is closed, so deps(o) ⊆ Vi ⊆ V1 ∪ V2. Its frontier is F(V1 ⊔ V2) = max≺(F(V1) ∪ F(V2)) (drop any listed op that is below another listed op). Merging never creates an op: a merge is not recorded in the log. The next op authored on the merged version has deps = F(V1 ⊔ V2); this is the only git "merge commit" analogue in the op model. Per D-004, a user-facing commit is a checkpoint (3.4), not an op or a merge record.
  • [decision-proposal] Common ancestor V1 ⊓ V2 = V1 ∩ V2 is also causally closed (both are closed), and is the greatest common ancestor. It is the natural "what both sides already have" point for sync (section 4) and for showing a diff since the fork. Versions form a distributive lattice under ⊆, with ⊔ as join and ⊓ as meet.
  • Merging versions needs no conflict resolution step at this level: all conflict handling is already in the deterministic fold (2.3–2.5), so the merged state is simply State(V1 ∪ V2).

3.3 State, conflicts and projection as functions of a version

  • [decision-proposal] State(V) = fold(apply, S0, sort(V by <)) (2.3), Conflicts(V) = the derived conflict set of 2.4/2.5 evaluated on V, and Projection(V) = project(State(V)) (1.5). They are defined only for causally closed V, so every rule that inspects "the state just before op o" sees all of o's deps.
  • [decision-proposal] Version-level convergence: F(VA) = F(VB) ⇒ VA = VB ⇒ State(VA) = State(VB) ∧ Conflicts(VA) = Conflicts(VB) ∧ Projection(VA) = Projection(VB). This is the Architecture invariant Ops(A)==Ops(B) ⇒ State(A)==State(B) restated: two replicas with the same frontier show the same tree, the same conflicts and the same working tree, regardless of delivery order (2.6 #3).
  • The converse does not hold: different versions may have equal states (e.g. a move followed by a move back). Version equality is history equality, not content equality.
  • [decision-proposal] Any past version can be reconstructed from the log by down-closure of its frontier and re-folding. Checkout of an old version is therefore a pure computation; it does not need stored snapshots (a stored state cache is an optimization with the same spec).

3.4 Checkpoints and commits

  • [decided] (user decisions 2026-09-26; D-004, D-005) A commit is a checkpoint with a message, not an atomic transaction. The user commits by creating a checkpoint on the current frontier; ops keep replicating live between commits, and a commit does not make a group of ops atomic. Frontier-based short ids are shown to users (like git short SHAs) alongside checkpoint messages, using the v0 encoding and resolution below. D-004's field "name/message" is the field message (code: crates/hub-core/src/checkpoint.rs, #13).
  • [decision-proposal] A checkpoint is an immutable label {message, frontier, author, note?} that refers to a version. It is metadata beside the op log, not an op: it never changes State, and adding or removing it never changes any version. Two checkpoints with the same frontier refer to the same version. (Analogue: a git tag pointing at a commit; here every version is a "commit" by construction.)
  • [decision-proposal] A checkpoint is valid only if every OpId in its frontier is resolvable; a replica that lacks some of those ops treats it as pending until sync delivers them.
  • [decision-proposal] As implemented (#13, crates/hub-core/src/checkpoint.rs). Checkpoint { message, frontier, author: ReplicaId, note: Option }; frontier is always stored canonical (F(V), sorted by <), built from a version or from any resolvable id set (absorbed by down-closure; an unresolvable id is Missing, i.e. pending). The local list is insertion-ordered and allows duplicate messages and several labels per version. Property-tested: adding checkpoints leaves the op set, State, Conflicts and Projection unchanged, and downclose(frontier) returns the labelled version.
  • [decision-proposal] Version id encoding v0 (#13). id(V) = SHA-256("barocss-hub/version/v0\n" ‖ u64_be(|F|) ‖ for f in F(V) in < order: u64_be(f.counter) ‖ u128_be(f.replica)). Defined for any frontier size, including genesis (|F| = 0). Shown as lowercase hex; the default short id is the first 8 digits. Replica- and delivery-order-independent because F(V) is.
  • [decision-proposal] Short-id resolution (#13). A prefix resolves against a version index, not all versions (the version set of a store is exponential): genesis, the principal version downclose({o}) of every op, plus explicitly added versions (checkpoints, heads, merge results). Prefix: 4 to 64 hex digits, case-insensitive; otherwise invalid. Result: unique match, not found, or ambiguous with the sorted list of matching full ids. Display lengthens the short id past 8 digits until it is unique in the index (git-style). An unindexed version must be named by its full id or a checkpoint.
  • [decided] (D-006) Checkpoints are private by default. A new checkpoint is visible only on the replica that created it until the user explicitly publishes it.
  • [decided] (D-007) Checkpoints are immutable: never removed, renamed or re-targeted. Anything movable is a ref (§3.8), a separate concept.
  • [decision-proposal] Publish state (#15). Each checkpoint has a local visibility private | published. publish(c) moves it private → published; there is no inverse. Publishing does not change the record {message, frontier, author, note} (immutable, D-007); it only adds the record to the replicated published set (§4.6). Rationale for monotonic: once a record has been sent, a peer may have relayed it, so "unpublish" could not be enforced without tombstones, and D-007 already rules out removal. Publishing an already-published checkpoint is a no-op (idempotent). A checkpoint received from a peer is published by definition. Only a checkpoint whose frontier is resolvable locally (not pending) can be published.
  • Code gap (not a contradiction): Checkpoint at develop (crates/hub-core/src/checkpoint.rs, field message) has no visibility field; the visibility is local state beside the record and is future work.
  • [resolved] How checkpoint labels replicate: only published checkpoints, as a grow-only set (§4.6); same message on different frontiers is allowed. Movable names are refs (§3.8).
  • [resolved] Multi-op short-id encoding (a frontier can hold several OpIds): resolved by the version id encoding v0 above (#13).

3.5 Testable properties

  1. Closure: every replica's applied op set is causally closed after every step of any delivery order (with buffering, 2.1).
  2. Frontier determines version: for any version V, downclose(F(V)) = V; and F(V1) = F(V2) ⇔ V1 = V2. Property: random histories, compare sets.
  3. Frontier is an antichain: no two ops in F(V) are causally related; the canonical list is sorted by < and has no duplicates.
  4. Merge laws: ⊔ is commutative, associative and idempotent on versions, V ⊔ ∅ = V, and V1 ⊔ V2 is causally closed. Same for ⊓, plus absorption V1 ⊔ (V1 ⊓ V2) = V1.
  5. Merge frontier: F(V1 ⊔ V2) = max≺(F(V1) ∪ F(V2)) (computed from the frontiers alone equals the frontier of the union).
  6. Comparison consistent with inclusion: compare(V1, V2) returns equal/ancestor/descendant/concurrent exactly as V1 = V2, V1 ⊊ V2, V1 ⊋ V2, neither; the frontier test, the vector test (VV pointwise ≤) and the set test agree. V1 is an ancestor of V1 ⊔ V2 or equal to it.
  7. Version vector determines version: VV(V1) = VV(V2) ⇔ V1 = V2 for causally closed sets (depends on each replica's ops forming a causal chain, 2.6 #7).
  8. Version-level convergence: equal frontiers ⇒ equal State, Conflicts and Projection, for any two delivery orders (restates 2.6 #3).
  9. Checkpoints are inert: adding any checkpoint, publishing it, or moving any ref (§3.8) leaves every State(V) and every version unchanged.

3.6 Worked example (replicas A, B, C; ids written (Counter, Replica))

Ops:

  • a1 = (1,A) Create dir /p, deps {}.
  • A sends a1 to B and C. Then, concurrently:
    • A: a2 = (2,A) Move p → rename /q, deps {a1}.
    • B: b1 = (2,B) Create file /p/f, deps {a1}.
  • C receives a2 (not b1): c1 = (3,C) Create file /q/g, deps {a2}.
Replica Op set Frontier VV (A,B,C) Projection
A {a1, a2} [(2,A)] (2,0,0) /q/
B {a1, b1} [(2,B)] (1,2,0) /p/f
C {a1, a2, c1} [(3,C)] (2,0,3) /q/g

Comparisons: A's version is an ancestor of C's ((2,A) ∈ V_C). A vs B and B vs C are concurrent. Common ancestor of B and C: {a1}, frontier [(1,A)].

Merge B ⊔ C: op set {a1, a2, b1, c1} (closed: each op's deps are inside). Frontier = max≺([(2,B)] ∪ [(3,C)]) = [(2,B), (3,C)] (neither is below the other). VV (2,2,3). State = fold in < order a1 < a2 < b1 < c1: /q/f and /q/g (B's file follows its parent's rename, since f's parent is the NodeId (1,A), not the path). No conflicts. Then merging A in changes nothing (V_A ⊆ V_B ⊔ V_C), so all three replicas reach frontier [(2,B), (3,C)] and projection /q/f, /q/g. If B now authors b2 = (4,B) (e.g. edits /q/f), its deps are [(2,B), (3,C)], and the frontier collapses to [(4,B)].

3.7 Out of scope for v0 (open questions for the sync model, section 4; addressed in §4 as noted)

  • [open question] Wire format and canonical op encoding (needed for the digest in 3.1).
  • [addressed in §4.2–4.4] Sync protocol: exchanging frontiers / version vectors, computing V_remote \ V_local from ⊓, and buffering of ops with missing deps.
  • [open question] Garbage collection and log compaction (needs causal stability: ops below every known replica's frontier); how old versions stay reconstructible (3.3) after compaction, e.g. via stored snapshots at checkpoints.
  • [addressed in §4.6, §3.8, §4.11; E-1/E-2 and E-4..E-8 decided, D-006/D-007, D-009..D-013] Replication and conflict rules for checkpoint labels (3.4) and refs.
  • [open question] Shallow/partial clones: a version whose down-closure is not fully present locally.

3.8 Refs (movable named pointers; D-007) (#15)

  • [decided] (D-007) Refs are a separate concept from checkpoints. Checkpoints stay immutable; a ref is the thing that moves (like a git branch).
  • [decision-proposal] Identity: a ref is identified by (scope, name). scope ∈ {local, published}. A local ref exists only on one replica and never replicates; a published ref replicates to peers (§4.11). name is a non-empty UTF-8 string; no two refs of the same scope share a name on a replica. The local and published namespaces are separate.
  • [decided] (D-013) Name resolution (#17): on a name clash the local ref wins the bare name; the published ref is addressed as published/<name> (like git's origin/main). Concretely: <name> resolves to (local, name) if it exists, else to (published, name); published/<name> always resolves to (published, name). Clash handling at publish time is decided by D-016 (see publish below). [decided] (D-017, #20) Local ref names starting with published/ are invalid (refused at creation/rename), so published/<x> is always unambiguous.
  • [decision-proposal] Target: a version, stored as a canonical frontier F(V) (3.1), or ⊥ (deleted, see below). Pointing a ref "at a checkpoint" means copying that checkpoint's frontier; the ref does not hold the checkpoint record, so it stays valid whatever happens to checkpoints. Reasons: a frontier is already the version name (3.1), ids need no extra indirection, and D-005 short ids work unchanged. A target must be causally closed after down-closure; a received move whose target is unresolvable is pending (like checkpoints, 3.4) and is shown at its last resolvable value or not at all.
  • [decision-proposal] Moves: a ref changes only through a move record Move { scope, name, target: [OpId] | ⊥, stamp: (Lamport, ReplicaId), supersedes: [stamp] }. stamp.Lamport = 1 + max(counter of every op in the local store, Lamport of every move record known locally) and ReplicaId = the author, so stamps are unique and a move is ordered after every move and op its author has seen. supersedes = the stamps of the current heads of that ref known to the author (see §4.11). Creating a ref is its first move (supersedes = [], or the current heads if the name exists only as a deleted ref, see below). A ref's move records form a grow-only set; the ref's value is a pure function of that set (§4.11). A move never creates an op, never changes any version, State, Conflicts or Projection (3.5 #9), and never changes a checkpoint.
  • [decision-proposal] Stamps are persisted (#15 review, #17): move records are written to the store together with their stamp before they are shown or sent, and the Lamport high-water mark survives restarts (as op counters do, §1.1), so a restarted replica can never reissue a stamp. A reissued stamp would break uniqueness and therefore the LWW pick of §4.11.
  • [decided] (D-009) Deletion (#17): a ref can be deleted. Deletion is a replicated move record with target = ⊥ (a tombstone move), authored like any move (fresh stamp, supersedes = current heads). It converges exactly like moves, by the §4.11 rule. A ref whose value is ⊥ is absent: it is not listed, a bare name does not resolve to it, and (D-013) a deleted local ref no longer shadows the published one. Its move records stay in the grow-only set (retention/compaction is an open question with GC, 4.10). Recreating the name later is an ordinary move superseding the ⊥ head(s). A ⊥ target is always resolvable, so a delete is never pending. Deleting a local ref only affects the local replica.
  • [decision-proposal] Delete vs concurrent move (#17), from the §4.11 rule with no special case: if a delete d and a move m are concurrent (neither supersedes the other), both are heads. The one with the greater stamp decides the value: d wins → the ref is absent; m wins → the ref exists at target(m). In both cases |heads| > 1, so a ref-move conflict listing both heads (a ⊥ head is shown as "deleted") is derived on every replica and surfaced (D-011); the losing intent is never silently dropped. A later move or delete that supersedes both heads resolves it. A delete that was authored after seeing m supersedes it and is a plain sequential delete (no conflict), and vice versa.
  • [decided] (D-012) Fast-forward only for published refs (#17). A move of a published ref with target T ≠ ⊥ is allowed only if T is a descendant-or-equal of the ref's current value C: downclose(T) ⊇ downclose(C) (i.e. C ⊑ T in 3.2). No constraint applies to deletes (target ⊥) or to the very first create of a name. [decided] (D-015, #20) A name whose heads are all ⊥ (deleted) can only be recreated fast-forward from its last value before the delete (lastval, §4.11), so delete-then-recreate cannot bypass D-012. [decided] (D-018, #20) While a published ref has several heads (a ref-move conflict), a move must be FF against all heads: downclose(T) ⊇ ⋃ downclose(lastval(h)) over every head h (a ⊥ head contributes its lastval), so no published target is silently dropped; in practice the move goes to a merge of the heads. The authoritative check runs at authoring, locally, against the author's current heads (= supersedes, §4.11); a non-FF move is refused and never enters the move set. Receivers re-check the same condition as a context-free validation (§4.11) so a buggy peer's non-FF move cannot cause divergence. Local refs may move anywhere (forward, backward, concurrent).
  • [decided] (D-010) Default scope: a newly created ref is local; publishing is explicit, consistent with D-006.
  • [decided] (D-016, #20; the fresh-first-move part stays [decision-proposal] from #15/#17) publish(n): publishing local ref n with current value T ≠ ⊥: (i) if no published record of n exists, it authors one fresh first move {published, n, T, fresh stamp, supersedes []}; the local move records are not copied, so earlier private targets and their stamps are never exposed. (ii) If published/n exists (any heads, including ⊥ heads), publishing is an ordinary FF move of published/n to T superseding its current heads, checked by D-012/D-015/D-018 against all heads; a non-FF publish is rejected (nothing is authored). (iii) The local ref is kept after publishing (not retired); by D-013 the bare name keeps resolving to it, and the published one is published/n. Publishing is monotonic (a published ref never returns to local scope; it can only be deleted, D-009) and idempotent in effect (publishing again at the same value is an FF no-op move or may be skipped).
  • [decided] Privacy covers labels only (#15 review, #17). Private checkpoints (D-006) and local refs (D-010) keep only the label (name, move records, message) off other replicas. The ops and content they point at replicate regardless through the op log (§4.2–4.4); a private label is not an access-control mechanism (access control is out of scope, 4.10).

4 Sync model

Remote transport and wire protocol v1 (#42) are §5, on the page Remote-Sync. Inputs: §1–§3, D-004, D-005, Issue #14, code on develop ca593fb (tree::Replica, version.rs, checkpoint.rs). Labels as above. Notation: V_A = the applied (causally closed) op set of replica A; F_A = F(V_A); K ⊆ V means "known"; pending (buffered) ops are not part of V_A.

4.1 Goal and invariant

  • [decision-proposal] A sync session between A and B is complete when both hold V_A ⊔ V_B = V_A ∪ V_B (3.2) as their applied op set, i.e. both report the frontier max≺(F_A ∪ F_B). By 3.3 they then show the same State, Conflicts, Projection and version id (3.4).
  • [decision-proposal] Sync only ever adds ops: V_A before ⊆ V_A after. No op is lost; duplicates are no-ops (2.6 #6), so an op received twice has effect once. Sync never authors ops and never creates merge records (3.2).
  • [decision-proposal] Sync is transport-agnostic and one-directional per half: "pull from B" makes V_A := V_A ∪ V_B. A full sync is pull in both directions; the two halves are independent and may run concurrently or in either order.

4.2 Messages (abstract; fields only, no wire encoding)

  • [decision-proposal]
    • Have { frontier: [OpId], vv_hint: Option<VersionVector> } — the requester's F_A (authoritative) and optionally VV(V_A) (hint only).
    • Ops { ops: [Op], sender_frontier: [OpId], done: bool } — a batch of ops sorted by <; may be split over several messages; sender_frontier = F_B at the start of the answer.
    • Need { ids: [OpId] } — ids the requester lacks (e.g. deps of buffered ops, or entries of sender_frontier still missing after a batch).
    • Checkpoints { records: [Checkpoint] } — published checkpoints only, see 4.6.
    • RefMoves { moves: [Move] } — move records of published refs, see 4.11.
  • Pull from B: A → Have; B → Ops…(done); A applies; if some id of sender_frontier is not in V_A (possible only if B's log grew or the answer was truncated), A sends Need or a fresh Have (4.4). Then Checkpoints and RefMoves in either direction.

4.3 Delta computation (frontier authoritative, VV a hint)

  • [decision-proposal] B computes the known-to-A set K = downclose(F_A ∩ V_B) — only frontier ids B can resolve. Because V_A is causally closed, K ⊆ V_A. B sends Δ = V_B \ K in < order. Then V_A ∪ Δ ⊇ V_B, so one answer always suffices for completeness.
  • Δ may contain ops A already has (when some of A's frontier ops are unknown to B, their down-closure cannot be subtracted). This is only redundancy, removed by idempotence. [open question] Reducing it: a multi-round ancestor search (A lists older ids, like git's have negotiation), or a digest per version (3.1), once the wire format exists.
  • [decision-proposal] Δ is causally closed relative to V_A: every dep of an op in Δ is in K ⊆ V_A or earlier in Δ (since < extends ≺, 2.1). So A never has to buffer ops from a single well-behaved sender.
  • [decision-proposal] Stale or wrong VV hint (2.6 #7). The vv_hint must never be used to exclude ops from Δ beyond K. It may only be used for estimates (expected delta size, progress bars, a fast "possibly up to date" pre-check that is then confirmed by the frontier). Reason: a hint that is lower than the truth (stale, from an earlier session) only causes redundant sends, but a hint that is higher or does not determine the version (a peer whose ops violate the own-chain rule, 2.6 #7, where VV[r] = c does not imply all of r's ops ≤ c are present, or a buggy peer) would make B skip ops A lacks, i.e. op loss. version::from_vv / compare_vv are meaningful only when own_chain_ok holds (3.1), which a peer cannot prove.
    • [open question] An optimization that uses the VV to exclude ops when both peers attest own-chain validity (e.g. after a digest check). Not in v0.

4.4 Partial and interrupted sync

  • [decision-proposal] The receiver applies ops one at a time in arrival order through the causal buffer (2.1; code: Replica::receive, which dedups by id and holds ops with missing deps in pending). Its applied set is causally closed after every step (3.5 #1), so an interruption at any point leaves A at a valid version: V_A_old ∪ P where P is a <-prefix of Δ (closed by 4.3) plus whatever buffered ops became ready.

  • [decision-proposal] A advertises only its applied frontier, never pending ops. Buffered ops may be kept or dropped after an interruption; either way correctness is unaffected because they are not part of V_A.

  • [decision-proposal] Resume = start a new session with the current Have. No session state is needed: K is recomputed from the new frontier, so already-delivered ops are skipped (or resent redundantly) and nothing is missed. Persisting an op to the store before acknowledging it is the durability boundary; the exact unit (per op / per batch) is an implementation choice.

  • [decision-proposal] Ops arriving from several peers or relayed out of order are buffered until their deps arrive; A sends Need { ids } for missing deps of buffered ops. [open question] Buffer bound and eviction policy for ops whose deps never arrive.

  • [decision-proposal] (#22, #23) Sync is log-only; the working directory is updated by checkout. A sync only adds ops, blobs, pending/warning records and published checkpoints to .hub/. It never writes the working directory or .hub/index, so it is safe with unrecorded changes. Afterwards barocss-hub status reports the working directory behind head and barocss-hub checkout materializes head, with the usual refusals (dirty files, D-019 clashes and blocked paths). (Code: hub-cli sync::sync_cmd; tests sync_leaves_unrecorded_changes_alone, sync_prop.)

4.5 Validation hooks: reject vs buffer vs record

Check Where Result Why
Malformed / undecodable message or op on receive reject (drop, report peer) no op identity to reason about; wire format is out of scope
Unknown deps on receive buffer in pending (2.1), request with Need causal delivery
Duplicate id, same payload on receive no-op (2.6 #6) idempotence
Duplicate id, different payload (violates 2.6 #1) on receive local measure only: keep the first, reject the newcomer; flag peer [open question] equivocation; not convergent in v0, see note below
Lamport violation d ∈ deps(o) but not d < o (2.6 #2) when o leaves the buffer (deps present) reject o [decision-proposal] < would not extend ≺; every replica computes the same verdict from o and its deps alone
Own-chain violation (2.6 #7) — accept 2.6 #7 decision: VV is only a hint
Semantically invalid op (2.2: reserved ids, unknown node, bad name, File parent, …) at evaluation accept into the log, no-op + invalid-op conflict must be deterministic on every replica; rejecting would make replicas diverge depending on which ones saw it
Cycle move at evaluation accept; cycle conflict (2.4) depends on position in <
  • [decision-proposal] Rejection is allowed only for checks that depend on the op and its deps alone (context-free), so all honest replicas reject the same ops. Descendants of a rejected op stay permanently pending (their deps can never be satisfied) and are never applied.
  • [decided] (D-008) Rejected ops and their stuck descendants produce a visible warning naming the peer.
  • [decision-proposal] Warning contents (#15). One warning per (peer, rejected op): RejectWarning { peer: peer identity the op was received from (transport-level; ReplicaId of the op author shown too if decodable), rejected: OpId (absent for a malformed message with no decodable id), reason: malformed | lamport-violation | equivocation, detail: short text (e.g. the dep dwithd ≮ o, or both payload digests), stuck: [OpId] = pending ops with the rejected op in their deps* (grows as more such ops arrive), first_seen: local time }. Reason equivocation uses the keep-first local measure above; the warning names the peer that sent the second payload.
  • [decision-proposal] The warning is local: it is not an op, not a checkpoint or ref, not part of any version, and not replicated. Each replica derives its own warnings from what it received (another replica may never see the bad op). It does not block sync with that peer (option c of E-3 was not chosen); other ops from that peer keep applying. Stuck descendants are kept out of V_A and are not advertised (4.4). Dismissing a warning is a local UI action and does not re-admit the op.
  • [open question] Retention of stuck ops and warnings (bounded with the buffer, 4.4).
  • [open question] Equivocation (the same OpId sent with different payloads) cannot be resolved convergently in v0. Keep-first is order-dependent: replicas that received different payloads first end with the same frontier and version id but different State, which breaks the context-free rule above. Keep-first is therefore only a local measure, not a convergence guarantee. The offending peer is flagged and surfaced as a visible warning naming the peer (D-008). Detection requires a content digest per op/version (3.1, 4.10); #46 adds it (4.12): a sync between two replicas holding different payloads under one id now always detects it, even when the id-based delta would not send the op. A convergent resolution is future work.
  • Note (code gap, not a contradiction): Replica::receive at ca593fb dedups by id without comparing payloads and does not check Lamport consistency; implementing the two rejects above is future work.
  • [open question] Authentication and authorization (who may send which ops, signed ops, per-path permissions) are out of scope for v0.

4.6 Checkpoint replication

  • [decided] (D-006) Only published checkpoints (3.4) replicate; a private checkpoint is never sent, listed in Checkpoints, or relayed.
  • [decision-proposal] Published checkpoints replicate separately from the op log, as a grow-only set of immutable records, exchanged after ops in the same session (Checkpoints message). The set union is commutative, associative and idempotent, so it converges like ops do, and it never touches any version (3.5 #9). A record's identity is the whole record {message, frontier, author, note} (a content hash once a wire format exists); duplicates collapse.
  • [decision-proposal] Messages are not unique keys: two checkpoints with the same message on different frontiers both survive (this matches the local list at ca593fb, which allows duplicate messages and several labels per version). No last-writer-wins on names in v0; LWW would need a clock for labels and silently drops one user's label, conflicting with "never silently drop concurrent intent" (Architecture).
  • [decision-proposal] A sender sends only checkpoints whose frontier is in its own V (so after the op half, the receiver can resolve them); a received checkpoint whose frontier is not resolvable is kept pending (3.4) until sync delivers the ops, and is not shown as a valid version label.
  • [decision-proposal] A replica that receives a record it also holds as private marks its own copy published (identical record = same identity). The published set on a replica is own published ∪ received; publishing is monotonic, so the set only grows.
  • Alternatives considered: (a) checkpoints as ops in the log — gives causal order and history for labels but makes metadata part of versions, contradicting "adding a checkpoint never changes any version" (3.4, D-004); (b) LWW map name → frontier — now covered by refs (3.8, 4.11), not checkpoints (D-007).
  • E-1, E-2 are decided (D-006, D-007); see 4.9.

4.7 Testable properties

Properties 1-10 (and the ref properties' targets) assume no equivocation (4.5); with equivocating peers, v0 guarantees only same op-id set and frontier, not same State.

  1. Sync reaches the join: for random histories, after sync(A,B) both applied sets equal V_A ∪ V_B and both frontiers equal max≺(F_A ∪ F_B).
  2. Monotone, no loss: every op in V_A before sync is in V_A after; every op in V_B is in V_A after a complete pull.
  3. Idempotent: sync(A,B); sync(A,B) = sync(A,B); the second session applies zero new ops.
  4. Commutative / order-independent: sync(A,B) = sync(B,A); for three replicas, any sequence of pairwise syncs that connects all of them (and is then closed with enough rounds) ends at V_A ∪ V_B ∪ V_C on all, with the same frontier and version id.
  5. Delta closure: every <-prefix of Δ united with V_A is causally closed (so the receiver never buffers from one honest sender).
  6. Interruption safety: cutting the op stream after any k ops leaves A at a causally closed version ⊆ V_A ∪ V_B; a subsequent full sync reaches property 1.
  7. Stale/wrong VV hint never loses ops: for any vv_hint (random, stale, too high, or taken from a store violating 2.6 #7), the resulting V_A equals property 1's result. (Generator: histories with deliberate own-chain violations, as in test own_chain_violation_is_detected.)
  8. Duplicates and reordering: delivering Δ duplicated and shuffled through the buffer yields the same V_A and pending_len() = 0 at the end.
  9. Semantically invalid ops replicate: an op that is invalid-op (2.2) arrives on every replica and produces the same invalid-op conflict everywhere.
  10. Checkpoint convergence: after checkpoint exchange, both replicas hold the same published checkpoint set (union); pending status depends only on the local op set; checkpoint exchange changes no version.
  11. Private never leaks: for any history and any sync schedule (including relays through third replicas), a checkpoint or ref that its author never published never appears on any other replica.
  12. Publish is idempotent and monotonic: publish(c); publish(c) = publish(c); no operation returns a published checkpoint or ref to private, and a replica's published set never shrinks.
  13. Ref convergence: for random concurrent moves and deletes (⊥) of one published ref on n replicas and any sync order, after all move sets are exchanged every replica computes the same value (target) and the same head set / ref-move conflict.
  14. Refs are inert: creating, moving or publishing a ref leaves the op set, every version, State, Conflicts and Projection unchanged (extends 3.5 #9).
  15. Ref targets are versions: every non-pending ref value down-closes to a causally closed op set present locally; a pending ref never shows an unresolvable target.
  16. No silent drop of ref moves: every move record is in the move set on every replica after exchange; a move that loses the LWW pick is either superseded by a later move whose author saw it, or appears in the ref's ref-move conflict heads.
  17. Stamp order extends causality: if move m2's author knew move m1 (or m1 ∈ supersedes(m2)), then stamp(m1) < stamp(m2).
  18. Warnings are local: a RejectWarning (or a rejected-move warning, §4.11) never appears in any message; syncing with a replica that has warnings changes neither side's warnings.
  19. Delete vs move converges (#17): for a delete and a concurrent move of the same published ref, authored on different replicas, and any exchange order, every replica ends with the same value (⊥ iff the delete has the greater stamp) and the same ref-move conflict listing both heads; a later move or delete superseding both heads leaves one head and no conflict everywhere.
  20. Non-FF receipt never diverges (#17): inject moves of a published ref that violate the FF rule (§4.11 validity) from a "buggy" replica, delivered in random orders, relayed or not, to n honest replicas: all honest replicas reject exactly the same moves, compute the same value, heads and conflicts, and the result equals the run without the bad moves (plus the permanently pending moves that supersede them).
  21. FF at authoring: authoring a move of a published ref to T with downclose(T) ⊉ downclose(C) is refused and leaves the move set unchanged; for local refs any target is accepted.
  22. Publish hides private history (#17): after publish(n), the only record of n that any other replica ever receives from the publisher's local history is the single fresh first move with supersedes = []; no earlier local target or stamp appears remotely.
  23. Stamps survive restart: persist, restart, author a move: its stamp is greater than every stamp and op counter known before the restart (no reissue).
  24. Permanent pending is uniform (#17): a move whose target contains an op every honest replica rejects (4.5), or whose supersedes names a stamp that never arrives or was rejected, stays pending on every honest replica and never affects heads, value or conflicts.
  25. Name resolution (D-013): with local n and published n both present, n resolves to the local ref and published/n to the published one; after deleting the local ref, n resolves to the published one.
  26. Recreate is FF from the last value (D-015, #20): for random histories that delete a published ref and later recreate it (on the same or another replica), a recreate whose target does not descend from lastval of the ⊥ heads is refused at authoring and rejected identically on every honest receiver in any delivery order; an FF recreate is accepted everywhere.
  27. FF against all heads (D-018, #20): with a ref-move conflict of k heads, a move FF w.r.t. the winning head but not w.r.t. some other head (including a ⊥ head's lastval) is refused at authoring and, if injected by a buggy peer, rejected by every honest replica regardless of order; a move to a merge of all heads is accepted and clears the conflict. The receipt verdict is a pure function of the move and the records it transitively names.
  28. Publish onto existing (D-016, #20): publish(n) with published/n present authors a move superseding exactly the current heads, is refused when non-FF, and leaves the local ref n in place.
  29. Reserved prefix (D-017, #20): creating or renaming a local ref to a name starting with published/ fails; no such local ref ever exists.

4.8 Worked example (continues 3.6; replica ids A=1, B=2, C=3)

Start: V_A = {a1,a2} F [(2,A)]; V_B = {a1,b1} F [(2,B)]; V_C = {a1,a2,c1} F [(3,C)].

Order 1: A↔B, then B↔C, then C↔A.

  • A↔B (the two halves run concurrently), pull A←B: A sends Have{[(2,A)], vv_hint=(2,0,0)}. (2,A) ∉ V_B, so K = ∅ and Δ = [a1, b1]; a1 is a duplicate no-op at A. Pull B←A: K = ∅, Δ = [a1, a2]. Now V_A = V_B = {a1,a2,b1}, F [(2,A),(2,B)] (short id aadfb58d).
  • B↔C, pull C←B: C's frontier [(3,C)] unknown to B, K = ∅, Δ = [a1,a2,b1] (a1, a2 redundant). Pull B←C (halves run sequentially): B sends Have{[(2,A),(2,B)]}; C now holds both, K = {a1,a2,b1}, Δ = [c1]. Both: {a1,a2,b1,c1}, F [(2,B),(3,C)].
  • C↔A: pull A←C: K = downclose({(2,A),(2,B)}) = {a1,a2,b1}, Δ = [c1]. Pull C←A: K = V_A, Δ = ∅.

Order 2: B↔C, then A↔C (A↔B not needed).

  • B↔C: pull C←B: K = ∅, Δ = [a1,b1] → C {a1,a2,b1,c1}; pull B←C: B sends Have{[(2,B)]}; C now holds b1, K = {a1,b1}, Δ = [a2, c1]. (Had the halves run concurrently, C might not yet resolve (2,B): K = ∅, Δ = [a1,a2,c1], same result with one redundant op.) Both: {a1,a2,b1,c1}.
  • A↔C: pull A←C: K = downclose({(2,A)}) = {a1,a2}, Δ = [b1, c1]; pull C←A: Δ = ∅.

Result (both orders): every replica holds {a1,a2,b1,c1}, frontier [(2,B),(3,C)], VV (2,2,3), projection /q/f, /q/g, no conflicts (3.6), version id ac652fa3bae659478269f983eb34f25b5ba1b4f3d6e169d773147e4c4fea5003, short id ac652fa3 (encoding v0, 3.4; computed with a Python encoder of the formula; that encoder reproduces the GOLDEN_ONE vector from tests/checkpoint.rs).

Stale hint variant: in Order 2, A sends vv_hint = (1,0,0) from an old session instead of (2,0,0). B/C ignore it for exclusion; K comes from the frontier [(2,A)], so Δ is unchanged. A deliberately too high hint (2,2,3) would, if trusted, make C send nothing and A would miss b1, c1 — which is why 4.3 forbids hint-based exclusion.

Interruption variant: in Order 2, pull A←C is cut after b1. V_A = {a1,a2,b1} (closed; F [(2,A),(2,B)]). Resume: new Have{[(2,A),(2,B)]}, K = {a1,a2,b1}, Δ = [c1]. Same result.

Checkpoints: if B labelled [(2,B)] "draft" and C labelled [(3,C)] "draft" and both published them, after sync all replicas hold both records (same message, two versions); neither is dropped (4.6). Had C not published its label, only B's would appear on A and B.

Concurrent ref move (two replicas, #15). Replicas A=1, B=2 only, ops as in 3.6 without C: a1=(1,A), then concurrently a2=(2,A) at A and b1=(2,B) at B.

  1. After a1 synced, A creates ref main → [(1,A)]: move m0 = {published, main, [(1,A)], stamp (2,A), supersedes []} (Lamport = 1 + max(1) = 2), and publishes it; B receives m0. Heads {m0}, value [(1,A)] on both.
  2. Concurrently (no sync): A authors a2, then moves main → [(2,A)]: mA, stamp (3,A) (1 + max(op counter 2, move 2)), supersedes [(2,A)]. B authors b1, then moves main → [(2,B)]: mB, stamp (3,B), supersedes [(2,A)].
  3. Sync A↔B (ops, then RefMoves). Both hold ops {a1,a2,b1} and moves {m0, mA, mB}. Superseded: m0. Heads: {mA, mB}. Value: the head with the greatest stamp; (3,A) < (3,B) (equal Lamport, replica 1 < 2), so main = [(2,B)] on both replicas regardless of which side received first. Both derive the conflict ref-move(main, heads [mA → [(2,A)], mB → [(2,B)]]); A's move is not lost, it is visible as the losing head. State is unchanged by all of this: each replica still shows State({a1,a2,b1}).
  4. Resolution: A moves main → the merge [(2,A),(2,B)]: mR, stamp (4,A), supersedes [(3,A),(3,B)]. After sync, heads {mR}, no conflict, main = [(2,A),(2,B)] (short id aadfb58d, 4.8) on both.
  5. Deletion vs concurrent move (#17). From step 4 (both hold heads {mR}, main = [(2,A),(2,B)]), concurrently: A deletes main: d = {published, main, ⊥, stamp (5,A), supersedes [(4,A)]} (1 + max(op counter 2, move 4) = 5). B authors b2 = (3,B) with deps [(2,A),(2,B)], then moves main → [(3,B)]: mB2, stamp (5,B) (1 + max(3, 4)), supersedes [(4,A)]. FF check at B (published ref): downclose([(3,B)]) = {a1,a2,b1,b2} ⊇ downclose(C = [(2,A),(2,B)]) = {a1,a2,b1} ✓. After sync both hold {m0,mA,mB,mR,d,mB2}; heads {d, mB2}; (5,A) < (5,B), so main = [(3,B)] on both (the ref exists) with conflict ref-move(main, heads [d → deleted, mB2 → [(3,B)]]), surfaced in barocss-hub status (D-011). Had A's stamp been greater (e.g. A had authored more ops first), main would be absent on both, with the same conflict showing B's losing move. Resolution: either side supersedes both heads, e.g. A deletes again: d2, stamp (6,A), supersedes [(5,A),(5,B)] → one head, main absent, no conflict. Non-FF receipt: if a buggy B had instead sent mX = {published, main, [(1,A)], (5,B), supersedes [(4,A)]}, every replica checks it against target((4,A)) = [(2,A),(2,B)]: {a1} ⊉ {a1,a2,b1}, so mX is rejected everywhere (warning naming B), heads stay {d} and main is absent on both, independent of arrival order.
  6. Move during a conflict must be FF against all heads (D-018, #20). Back at step 3 (heads {mA → [(2,A)], mB → [(2,B)]}, value [(2,B)]), B authors b2 = (3,B) with deps [(2,B)] and tries to move main → [(3,B)]. FF against the winner mB: {a1,b1,b2} ⊇ {a1,b1} ✓, but against mA: {a1,b1,b2} ⊉ {a1,a2} ✗, so the move is refused at B (under the old D-012 text it would have been accepted and silently dropped a2 from main). A buggy B sending it anyway (supersedes [(3,A),(3,B)]) is rejected by every receiver, since the check uses only the records it names. Only a target containing both, such as step 4's merge [(2,A),(2,B)] or a descendant of it, is accepted.
  7. Recreate after delete (D-015, #20). In step 5's variant where d wins (main absent, heads {d, mB2} with d greatest, or after a later delete superseding both), lastval(d) = [(2,A),(2,B)]. Recreating main → [(1,A)] is refused ({a1} ⊉ {a1,a2,b1}); recreating it at [(3,B)] or any descendant of [(2,A),(2,B)] is accepted. Variant: if B had synced mA before moving, mB would supersede (3,A), get stamp (4,B), and there would be a single head (no conflict): a sequential move, not a concurrent one.

4.9 Escalations

Decided (user, 2026-09-26; no longer open):

  • [decided] E-1 → D-006: option (b), checkpoints are private by default with an explicit publish (3.4, 4.6).

  • [decided] E-2 → D-007: option (c), checkpoints are immutable; movable named refs are a separate concept (3.8, 4.11).

  • [decided] E-3 → D-008: option (b), visible warning naming the peer (4.5).

  • [decided] E-4 → D-009: refs can be deleted; deletion is a replicated ⊥ move converging like moves (3.8, 4.11). Close to former option (b).

  • [decided] E-5 → D-010: new refs are local by default; publishing is explicit (3.8).

  • [decided] E-6 → D-011: a ref-move conflict is shown as a warning in barocss-hub status (4.11).

  • [decided] E-7 → D-012: published refs are fast-forward only; non-FF moves are refused locally (3.8, 4.11).

  • [decided] E-8 → D-013: the local ref wins the bare name; the published ref is published/<name> (3.8).

Decided (user, 2026-09-27; #20):

  • [decided] E-9 → D-015: a recreate after delete must be FF from the last pre-delete value (former option (c)).
  • [decided] E-10 → D-016: publishing onto an existing published/n is an FF move of it (former option (b)); non-FF is rejected; the local ref is kept.
  • [decided] E-11 → D-017: local names starting with published/ are invalid (former option (a)).
  • [decided] (former implementer note on conflicts) → D-018: during a ref-move conflict a move must be FF against all heads.

Open:

  • [escalation] E-12 (#20): reviving a deleted ancestor under D-014 also revives its other children (§2.3).

Ignore rules (§1.6, #24) do not affect sync: they apply only to the working-directory scan.

4.10 Out of scope for v0 (open questions for later sections)

  • [open question] Wire format and canonical op encoding (wire v1 exists, 5.2); per-version integrity digest: done as a sync-time digest by #46 (4.12).
  • [open question] Transport (HTTP, git remote helper, p2p), discovery, concurrency of sessions.
  • [open question] GC / log compaction and causal stability (3.7).
  • [open question] Partial / shallow clones (sync of a subtree or of history after a checkpoint).
  • [open question] Access control, authentication, signed ops (4.5).
  • [open question] Delta redundancy reduction (4.3) and buffer bounds (4.4).

4.11 Ref replication (#15)

  • [decided] Only published refs replicate; new refs are local (D-010, consistent with D-006). Privacy covers the ref label only; the ops its targets name replicate regardless (3.8). For each published ref name, replicas exchange the grow-only set of move records (RefMoves, 4.2), after ops, in the same session as checkpoints. Union is commutative, associative, idempotent, so every replica with the same move set has the same input. A sender sends only moves whose target is in its own V; a received move with an unresolvable target is kept and treated as pending for value selection (below) until the ops arrive.
  • [decision-proposal] Concurrent-move rule: LWW by stamp over the heads, with a derived conflict record. For ref n with resolvable move set M:
    • heads(M) = { m ∈ M | no m' ∈ M with stamp(m) ∈ supersedes(m') };
    • value(n) = target(m*) where m* = the head with the greatest stamp (Lamport first, then ReplicaId; unique because stamps are unique);
    • if |heads| > 1, the replica derives a ref-move conflict { ref: n, heads: [(stamp, author, target)] } sorted by stamp. It is derived state (like 2.4 conflicts), so it is identical on every replica with the same M, and it disappears when a later move supersedes all heads. A head with target ⊥ (a delete, D-009) is treated like any other head: if it has the greatest stamp the ref's value is ⊥ (absent); in the conflict it is listed as "deleted". So delete vs concurrent move needs no special rule (3.8, 4.7 #19). All of this is a pure function of M, hence deterministic and convergent (4.7 #13).
  • [decided] (D-011) A derived ref-move conflict is shown as a warning in barocss-hub status (like D-008's surfaced warnings), listing the ref name and every head (stamp, author, target or "deleted"), until a move or delete supersedes all heads. It is derived and local, never replicated.
  • [decision-proposal] Receipt-side validity (#17; context-free, like the rejects of §4.5). A received move m of a published ref is evaluated once every stamp in supersedes(m) is present in the local move set (otherwise m is pending, waiting for those records; senders send the whole move set of the ref, so honest histories resolve). m is valid iff:
    1. stamp(s) < stamp(m) for every s ∈ supersedes(m) (4.7 #17), and
    2. FF (D-012, D-015, D-018; updated by #20): define lastval(r) = target(r) if ≠ ⊥, else lastval of the greatest-stamp record in supersedes(r), or ⊥ if supersedes(r) = [] (the last non-deleted value, reached through the delete's own supersedes). If target(m) ≠ ⊥, then downclose(target(m)) ⊇ ⋃ { downclose(lastval(s)) | s ∈ supersedes(m), lastval(s) ≠ ⊥ }: FF against all heads the author saw (D-018), with ⊥ heads contributing the value before the delete (D-015). Deletes (target ⊥) and first creates (supersedes = []) have no FF constraint. Resolvability (#17 review, #20): the FF verdict is computed only once target(m) and every lastval(s) it needs are resolvable (in the local V); until then m is pending, not rejected, so the verdict never depends on op arrival order. The verdict depends only on m and the records it names, never on arrival order or on the receiver's other moves, so all honest replicas reach the same verdict. An honest author's move always passes, because its authoring check uses exactly its heads = supersedes(m) and the same lastval. An invalid move is excluded from M entirely (it neither supersedes nor can win) on every replica and produces a local warning naming the peer (as D-008), shown in barocss-hub status; it is kept only for that warning. A move whose supersedes names a rejected or never-arriving record stays permanently pending, identically everywhere (mirrors 4.5's stuck descendants). Honest replicas never author such a move because rejected moves are never among their heads. Why the check is on supersedes and not the receiver's current value: the receiver's value depends on which other moves it has seen, so checking against it would make the verdict order-dependent and replicas would diverge; checking against the records the move names is a pure function of the received set.
  • [decision-proposal] A pending move (target unresolvable, or superseded records missing) is excluded entirely from M, both for superseding and for the value, so the value is a function of resolvable state only. Once all ops and moves arrive (4.7 #1) every replica has the same M. A superseding move that is still pending temporarily re-exposes the older head; this self-heals when its ops arrive. Permanent pending is uniform (#15 review): a move whose target contains an op that every honest replica rejects (4.5) can never resolve, and is excluded identically on every replica, so it does not break convergence (4.7 #24).
  • Why this rule (justification): it gives one usable value per ref at all times (a checkout target always exists), it is convergent without coordination, and it satisfies Architecture's "never silently drop concurrent intent" because the losing move stays in the set and is surfaced through the conflict heads (as D-007's escalation text anticipated: "concurrent moves pick one winner and must surface a conflict"). The Lamport part makes a move authored after seeing another one win over it (4.7 #17), so the common sequential case behaves like "last move wins".
  • Alternatives considered:
    • (a) Plain LWW by (Lamport, replica), no conflict record: simplest, but silently discards a concurrent move; rejected per Architecture.
    • (b) Multi-value register (all heads, no winner): never hides intent, but a ref has no single target until a user resolves it, so "checkout main" becomes ambiguous; the chosen rule keeps these heads anyway, so (b) is (chosen rule minus the pick).
    • (c) Auto-merge the heads' targets (⊔ of versions, 3.2): always a single, lossless value, but it points the ref at a version nobody chose, and backward moves (resets) would be undone by merging; surprising.
    • (d) Moves as ops in the op log: gets causal order for free, but makes ref moves part of versions, contradicting 3.5 #9 and D-004's "metadata beside the log".
    • (e) Wall-clock LWW: not deterministic under clock skew; rejected.
  • Consistency: stamps use the same (Counter, ReplicaId) tie-break as the op order < (2.1); ref values are frontiers, so D-005 short ids and version ids (3.4) apply unchanged; equivocation caveats of 4.5 apply to targets (a target frontier names the same ids everywhere, but with an equivocating peer not necessarily the same State).

4.12 Divergence under equal ids: content digest (Issue #46) [decision-proposal]

  • Problem (observed behavior, #45 Review repro): two copies of one .hub/ share replica id r. Each records an edit of f under the same OpId (c, r) with different content and syncs only with a server. The server ends with f=A, the copy with f=BB, both at head 98736884, no warning. Cause: the delta (4.3) is id-based. The copy's Have names (c, r), the server holds an op with that id, so (c, r) ∈ K and is never sent; the equivocation check of 4.5 (duplicate id, different payload) needs both payloads at one replica and never fires. Same for the D-022 DuplicateReplica check, which only sees ids it never authored.
  • Known technique: set reconciliation by digest (e.g. hash-of-set comparison before the delta, Merkle/range summaries as in Dynamo-style anti-entropy, git's object ids). Content hashes turn "same id" into "same id and same bytes".
  • Options compared:
    • (a) Frontier content digest. Per-op hash h(o) = SHA-256(canonical(o)) (id, deps, payload); a set digest (repaired by Review R2: a SHA-256 sequence digest on the full-replica wire, and the additive LtHash16 D(S) = Σ_{o∈S} E(h(o)) for partial replicas, below), kept per author replica (D_r(S) over the ops of S authored by r) plus a count. Additive, so it is maintained incrementally (D(S ∪ {o}) = D(S) + h(o)) and D(K) = D(V_B) − D(Δ) costs only |Δ|. The answerer sends D_r(K) for every r; the requester compares with D_r(downclose(F_A ∩ V_B)) of its own store.
    • (b) Per-replica hash chain. Each r's ops, in counter order, chain H_r(c) = SHA-256(H_r(prev) ‖ h(o)); Have sends (VV[r], H_r(VV[r])); a mismatch at a common counter means divergence.
    • Correctness (false positives). (b) assumes that every honest replica holding (c, r) holds exactly the same r-ops below c (own chain). 2.6 #7 says own-chain violations are accepted (4.5 table), so an honest replica can hold (6, r) without (5, r); comparing chains at counter 6 then reports divergence where there is none, unless a refinement step follows. (a) compares the digests of the same down-closure on both sides: if no OpId has two payloads, downclose(R) names the same op set with the same bytes on both replicas, so the digests are equal for any history (own-chain violations, out-of-order delivery, pending ops, interrupted syncs: only the applied, causally closed set is summed). A mismatch therefore always means some id in downclose(R) has two payloads. No false positives by construction.
    • Coverage (false negatives). Every id held by both A and B with different payloads is either in K (caught by the digest) or in Δ (sent, caught by the existing 4.5 equivocation check on receive). So one pull half detects every divergence between the two replicas. A replica that never holds an op with the id cannot detect it (nothing to compare), which is correct: it has no diverged state. Hash collisions: SHA-256 per op; the sum is a multiset hash, collision-resistant enough for accidental copies (it is not a defense against a malicious peer, which can already send arbitrary ops, 5.9.4).
    • Partial replicas / thin clients (#47, D-048). Under (b) a thin client keeps only its own chain head H_self and compares it with the server's H_self at its counter: cheap and needs no other ops, but only for its own replica id and only with the own-chain assumption. Under (a) the digest is additive, so a thin client that holds a window W above a server-provided base B (a snapshot/checkpoint) can use D_r(B ∪ W) = D_r(B) + D_r(W) with D_r(B) supplied by the server with the base (trusted like the base snapshot itself); it then detects divergence on every op it holds, including all ops it authored since B, which are exactly the ops a copied thin client can diverge on. A thin client that holds nothing of r compares nothing for r. The per-replica split of (a) gives the same "my replica only" check as (b) (D_self) without assuming own chains. So (a) works for partial replicas with one extra field on the base (D_r(B)), and needs no op-format change.
    • Cost. (a): one SHA-256 per op hashed once per session per side (computed at load, not stored), |Δ| additions for D(K), and one Sums frame of #replicas lines (100 replicas, D-026: ≈ 9 KB). (b): the same hashing, plus per-op chain state that must be indexed by counter to answer "chain at c", plus a refinement protocol for own-chain violations.
    • Decision-proposal: (a), per-author-replica additive set digest of the down-closure, exchanged after Have. (b) is rejected because it has false positives under accepted own-chain violations (4.5) and needs a refinement step anyway.
  • Format: the digest is derived (computed from the op log when a sync session loads its replica). Nothing is stored: no on-disk format change, no migration, old binaries open the repository unchanged. (If a later persisted cache is added, it is a format change under the #33 bar.)
  • Digest function (Review R2, [decision-proposal]). A plain Σ h(o) mod 2^256 is not collision resistant against a chosen-op adversary: Wagner's generalized birthday attack (D. Wagner, "A Generalized Birthday Problem", CRYPTO 2002) finds a set of about 2^16 ops whose hashes sum to a target in about 2^31 work. A malicious author could use that to keep two honest replicas silently diverged. The additive digest is therefore LtHash16 (used for partial replicas; full replicas use the cheaper sequence digest, see the measured cost below) (M. Bellare, D. Micciancio, "A New Paradigm for Collision-free Hashing: Incrementality at Reduced Cost", EUROCRYPT 1997; K. Lewi, W. Kim, I. Maykov, S. Weis, "Securing Update Propagation with Homomorphic Hashing", IACR ePrint 2019/227).
    • Parameters: n = 1024 lanes, each a u16, combined lane-wise mod 2^16 (2 KiB). Lewi et al. estimate ≥ 200 bits of security for these parameters, reducing collisions to a lattice (SIS-type) problem.
    • Element E(h): 2048 bytes of BLAKE3 XOF output in derive-key mode (context barocss-hub 2026-10 lthash16 op v1) over h(o) = SHA-256 of the canonical op encoding.
    • Additive and subtractive, so incremental use and the partial-replica property below are unchanged.
    • Measured cost and the split (observed behavior, then decision-proposal). LtHash16 costs about 2.1 µs per op on one core (BLAKE3 XOF, 2 KiB per op; release, M-series, digest::tests::timing_probe). A full-replica session hashes the whole store on both sides, so a no-op file:// sync at 1M ops measured +11.8 % and +12.3 % (two runs, medians of 5), over D-026's 10 %. Two changes followed:
      • Full replicas compare a sequence digest on the wire. For each author r, S_r(K) = SHA-256 over the chunk digests of K in < order, using fixed position chunks of 16 384 ops; each chunk digest is SHA-256 over that author's op hashes h(o) in the chunk. It is collision resistant (SHA-256 over an ordered sequence), so no chosen set can collide with another and Wagner's attack does not apply. It needs one SHA-256 per op and parallelizes over chunks. It is not additive, so K is summed directly; the whole-store value is cached per frontier inside a session.
      • LtHash16 is the additive digest for partial replicas (#47, digest::LtSums, implemented and tested now). A server provides D_r(B) with a base snapshot, and a thin client checks D_r(B) + D_r(W). When thin clients exist, the wire will carry LtHash checksums for them (a new feature flag), not for full replicas.
      • Escalated to Review/Planner: this replaces the instruction "LtHash on the wire" with "a secure sequence digest on the full-replica wire + LtHash for partial replicas" because of D-026.
    • Dependencies: blake3 = 1.8.2 (with arrayref, arrayvec, cfg-if, constant_time_eq; cc at build time) plus the existing sha2. No LtHash crate is used: the candidates found (lthash-rs, nym-lthash) are small and unvetted, and the construction is about 60 lines.
  • Where detection happens (wire, 5.2.8): in each half, right after Have, the answerer sends Sums { resolved, D_r(K), n_r(K) } (feature digest). The requester computes its own sums over downclose(resolved) and compares per replica. This is the first point at which the divergence is observable.
  • Localization (op-level evidence, R5): (Review (b): the localization Ops never trigger NeedBlobs; ids and payloads are the evidence, so no blob crosses.) a digest mismatch is a claim by the peer, not evidence (a lying peer could fake it), so it is never persisted by itself. For each mismatching replica r the requester sends Need for its own r-ops in downclose(resolved), newest first, in groups (≤ 1024 ids), and stops after the first group that produces an equivocation. The answerer returns its ops for those ids; the existing 4.5 receive check sees the same id with a different payload and records an equivocation warning (D-008, op-level, naming the peer). If the op's author is our own replica id, the same evidence also records a DuplicateReplica warning (D-021/D-022: our id is in use elsewhere), which stops later syncs with that peer until reid (D-025). Honest peers always produce evidence: a mismatch implies an op in downclose(resolved) with two payloads, held by both. A mismatch without evidence (a buggy or lying peer) ends the session with protocol and persists nothing.
  • Stop: when the localization produced a new equivocation warning, the requester persists the warnings only (no ops of that half, as D-022) and ends the session with duplicate-replica, pointing to reid in the copy. An already-known equivocation (warning already saved, e.g. after reid, D-024: ops recorded under the shared id cannot be re-authored) does not stop later sessions again: the divergence stays visible in warnings/status, nothing is silent, and sync continues (D-008: equivocation is a local warning that does not block the peer). Own-id divergence keeps blocking through the DuplicateReplica warning until reid.
  • Old peers: gated by feature digest in both Hellos. A pre-#46 peer never receives Sums (it would be an unknown frame type) and never sends it; it ignores the unknown feature name. Between a new and an old peer the behavior is exactly pre-#46 (no detection, no breakage).
  • Implementation (#46, observed behavior): hub-cli digest.rs (op hash, Sums, parallel hashing on up to 8 threads), session.rs (answer sends Sums; compare_sums, localize; persist stops on a new equivocation); hub-core receive adds the DuplicateReplica warning when the equivocating op is authored under our own id. The answerer's K is V_B \ Δ by a merge walk (Δ is a <-ordered subsequence of the store), so no extra set is built. Because both halves compare, the server also detects divergence in the push half when the pull half did not stop first (e.g. after the copy ran reid): it records its own op-level equivocation warning and stops that one session. After that, the copy's diverged op is rejected by the server on every push (it is localized and sent again), so sync exits 3 (D-045) each time: visible, never silent. [open question] Whether to suppress resending a localized op whose equivocation the peer already reported (needs per-peer memory of reported rejections).
  • Testable properties: (4.7 #27) for random same-id copies and random sync schedules (any order, interrupted syncs, out-of-order relays, own-chain violations), every pair of replicas holding one id with two payloads gets a persisted equivocation warning at the first sync between them or between either and a replica holding the other payload, and no sync ever reports divergence when no OpId has two payloads; (#28) the Review repro over file:// and ssh: the second sync of the copy fails with duplicate-replica, warnings lists the equivocation and the duplicate replica, and the copy's log is unchanged; (#29) mutation: with the digest comparison disabled, (#28) fails.

4.13 Rescue of a copied repository: reid --rescue (Issue #49, D-049) [decision-proposal]

Source: user decision D-049, with D-050. Refines D-024 ("reid only before recording"): ops already recorded under a shared id are no longer lost; their effect is re-authored under a fresh id.

  • Problem (observed behavior, #46 repro). Two copies of one .hub/ record different content of f under the same OpId X=(c, r). The server keeps the original's X'. The copy keeps its own X (keep-first, 4.5), its sync stops with duplicate-replica (4.12), and after a plain reid every push of X is rejected (5.2.7). The copy's edit never reaches anyone.
  • Known technique. Rebase by replaying commits (git rebase) re-applies each change on a new parent; it fails or conflicts when parents differ. Recording the difference of trees (git commit of a working tree, diff/apply of a snapshot) is parent-independent.
  • Decision-proposal: re-author by effect, not by op replay. Replaying X's payload under a new id would keep X's deps, which may name ops whose meaning differs on the server. Rescue instead:
    1. Rescue set R (from persisted data, works offline): the up-closure in the local store (the ops plus every local descendant, any author) of
      • each op named by a saved equivocation warning (warnings.log) whose author is this repository's current replica id (we kept our own op; the peer holds another payload), and
      • each op in .hub/reported-rejections (5.2.7.1) that the store holds (ops we pushed and a remote rejected). R is causally up-closed, so the store minus R is causally closed. A pushed-but-not-yet-rejected op under the shared id is not in R offline (nothing persisted says it diverged). Online confirmation: after the local stages, rescue runs one sync with the default remote (skipped by --offline, or when there is no default remote, or when the remote is unreachable: rescue still succeeds and says to sync later). If that sync reports new rejections, they are remembered as usual (5.2.7.1) and rescue says to run reid --rescue again; the rerun's R is exactly those ops.
    2. Intended tree = the copy's current working directory, including unrecorded edits (what the user sees). Rescue requires the working directory at head (index base = head), else it refuses with behind ("run barocss-hub checkout first"), so it never mistakes pulled-but-not-materialized ops for deletions. Ignored paths (.hubignore) are not touched or recorded.
    3. Remove R from the local store (ops, checkpoints whose frontier names an op of R, published records likewise), take a fresh replica id, rebase the index onto the new head (the state of store minus R, files indexed with size/mtime 0 so every file is rehashed), and record the working tree as new ops under the new id. A same-file difference becomes a SetContent on the existing node; a file created in R becomes a new Create; a delete or move done in R is reproduced by its effect (delete, or move/rename as record detects it).
    4. Same-file ordering. The recorded ops are concurrent with the server's X' (the copy never held X' when it recorded offline). Their Lamport counters are floored above the largest counter of the store before removal (so above X', which has X's counter): content's last-writer rule (2.3) picks the rescued content deterministically, and X' stays in history (log --path, a content conflict listing the overwritten version). With a text CRDT (#48) this becomes a merge.
  • Stages and crash safety (the #33 bar, Appendix A.1). Under the exclusive lock; refused while watch runs. A rescue directory .hub/rescue/<ts>/ holds manifest (header barocss-hub/rescue/v0: old and new replica id, counter floor, R, the backed-up files) and files/ (the backup). .hub/rescue/current (one line, <ts>) marks a rescue in progress; every mutating command (record, checkout, mkdir, touch, mv, rm, commit, publish, sync, watch, plain reid) refuses with blocked while it exists. Stages, each idempotent, each with a crash point: 0. rescue-backup: copy every tracked or recorded-to-be working-tree file whose bytes differ from its version in store minus R (and every removed op's encoding into the manifest) into files/, flushed; then manifest (temp + fsync + rename + dir sync); then current (atomic). A crash before current leaves an orphan directory that the next rescue removes; nothing else changed.
    1. rescue-ckpts: checkpoints.log and published.log rewritten without the checkpoints that name R (atomic). Valid with either ops.log.
    2. rescue-snapshot: .hub/snapshot deleted (a cache, A.2.3).
    3. rescue-ops: ops.log rewritten as one batch of the kept ops in their original order (temp + fsync + rename + dir sync). The rename is the commit point of the removal.
    4. rescue-reid: replica = the manifest's new id (atomic).
    5. rescue-index + rescue-record: index rebased onto head, then a normal record (blobs first, then ops, then index). Rerun after a crash rebases onto the head again, so a record that already landed records nothing.
    6. rescue-done: duplicate-replica warnings and the equivocation warnings naming R dropped from warnings.log, reported::forget of the entries for R, done written in the rescue directory, then current removed. Only now is the DuplicateReplica block (D-022) cleared. A rerun with current present resumes from the manifest (stages 1-6 recomputed from disk); a rerun after success finds R empty and does nothing (nothing to rescue), and, if a DuplicateReplica block is saved, it is a plain reid that clears it (an untouched copy).
  • --dry-run prints the plan (old id, R, the files that differ, the backup path) under a shared lock and writes nothing: .hub/ and the working tree stay byte-identical.
  • Trust. The trust key (#45) is the canonical .hub/ path plus the remote; the replica id is not part of it, so trust survives the rescue.
  • Old binaries. .hub/rescue/ is a new directory that pre-#49 binaries never read, list or remove (verified with develop 714faf0). A pre-#49 binary does not honor current, so running it mid-rescue is as unsafe as any concurrent tool: documented.
  • Repair after Review (#49 R1-R3, Open #2/#5):
    • Checkpoints (R1). Checkpoint records (checkpoints.log, published.log) whose frontier names an op of R are written into the manifest (ckpt private|published <K body>) before stage 1 removes them, listed in the dry-run plan, --json (checkpoints) and the VS Code confirmation, and re-created after the record (stage rescue-ckpts-restored) as private checkpoints on the rescued head with the same message and author and a note rescued from replica <old>. Published ones are re-created privately too and listed for the user to re-publish (no automatic publish: the remote may already hold the old one). Idempotent: an identical checkpoint is never added twice, so a crash between removal and re-creation loses nothing on rerun.
    • Marker against old binaries (R3, the #33 technique). Before current is written (and again on every resume), replica gets the header barocss-hub/replica/v1-rescue (same id). A #49+ binary reads it as v1 (and refuses mutating commands through current); pre-#49 binaries fail with "unsupported repository format 'v1-rescue'" before reading or writing anything else. Stage 6 restores barocss-hub/replica/v1 with the new id before removing current (crash in between: the rerun resumes). A marker without current (crash between the two writes, nothing else changed) is cleared by the next reid --rescue. Observed with develop 714faf0: every repository command (status, ls, log, show, warnings, record, checkout [--force], commit, publish, mkdir, touch, mv, rm, cat, sync, reid, init, serve) is refused with .hub/ byte-identical; watch keeps running but refuses every step and writes only its watch.pid. Not stoppable by a marker: 714faf0's config-only commands (remote list|add|rm|set-url|trust, warnings --clear-reported) never open the repository; they touch only .hub/remotes, the reported-rejections memory and the user trust store, none of which a rescue in progress reads (the rescue set is already in the manifest).
    • Watch (R2). --dry-run is allowed while watch runs (read-only, shared lock); the real rescue still refuses. VS Code's "Rescue…" stops that folder's watcher, shows the plan, confirms, rescues, and restarts the watcher afterwards, also on cancel or error.
    • Checkout after an online rescue (Open #2, decided by the user). After the confirming sync, rescue runs checkout only if the working tree is unchanged since the rescue recorded it; otherwise it leaves it and says so (checkout: {done: false, reason} in --json).
    • Known limit (Open #5, follow-up Issue). Ops of other replicas that depend on R are dropped locally (their effect is kept through the working tree), but they still exist at that replica C. When C's op returns through the server it can create a spurious name collision (g / g~<id>), and C itself stays diverged on the shared id with no remedy (its rescue set only seeds ops under its own id). Also (Review note): a malicious peer can manufacture seed (a) by sending an op under our id with another payload; rescue then re-authors our honest op by effect, so content survives.
  • Observed (#49 implementation): after an online rescue the confirming sync pulls the server's X', so the working directory is one version behind head (same bytes; checkout writes nothing) and status shows the content conflict X' overwritten by the rescued op. An old binary (develop 714faf0) runs status/record/sync/warnings/checkout on a rescued repository and leaves .hub/rescue/ byte-identical.
  • Testable properties: the #46 repro → rescue → next sync exits 0, the server has the copy's content at head and the original's in history, the backup holds the copy's bytes; an untouched copy (just a reid); deletes and moves; a crash (kill -9 via crash points) at every stage, then rerun converges to the same result; dry-run byte-identical; rescue offline, then sync; rerun after success is a no-op; no repeated exit 3.

6 Online-first, thin clients (Issue #47, D-048)

Specified on its own page: Online-First. It refines §4.5 for partial replicas (the blob-remote rule, 6.2.3) and changes no other convergence rule.

7 Text content CRDT (Issue #48)

On its own page: Text-CRDT.

Appendix A On-disk format v0 (#23)

.hubignore (#24) lives in the working directory, not .hub/: it is a tracked file, not part of the on-disk format; see §1.6.

[decision-proposal] throughout (format v0 is not yet a user decision). This describes .hub/ as hub-cli writes it on develop after #23. Code references: store.rs, blobs.rs, wd.rs, sync.rs, lib.rs (Repo::init, Repo::open_dir_with).

Common framing (logs). UTF-8 text: one header line, then one record per line, <body>\t<crc>\n, where crc = first 8 lowercase hex digits of SHA-256(body). A body never contains a tab or newline. Strings are tokens s<bytes> with %, bytes <= 0x20 and 0x7f percent-escaped (s alone = empty). Ids: <counter>.<replica, 32 lowercase hex>; id lists are comma-joined or -. Blob ids are 64 lowercase hex digits; uppercase is rejected as corrupt (canonical form, one spelling per blob, #23).

  • Appends write one whole line, then fsync.
  • On open, a final record without its newline or with a bad crc is a torn write: it is truncated away (set_len + fsync) and reported as a warning. A bad record that is not the last line is corruption and the open fails.
  • Whole-file rewrites use write_atomic: .<name>.tmp in the same directory, fsync, rename, fsync of the directory.
Path Header / format Written by Crash-safety rule
replica barocss-hub/replica/v0\n<32 hex>\n init (last), reid write_atomic. Written last by init, so its presence marks a complete init.
ops.log barocss-hub/oplog/v0; O <id> <deps> <payload>, payload C d|f <parent> <str>, M <node> <parent> <str>, D <node>, S <node> <blob> record, mkdir/touch/mv/rm, sync Append + fsync. Every prefix replays: sync appends in < order, so deps come first. An exact repeat of an id is skipped; the same id with a different op is corruption. Ops with missing deps in the log are corruption.
checkpoints.log barocss-hub/checkpoints/v0; K <author 32 hex> <frontier> <str message> <str note|-> commit, record -m Append + fsync. The frontier must resolve in ops.log.
published.log barocss-hub/published/v0; K ... records (as above) publish, sync Created with write_atomic (header only), then append. Grow-only; duplicates are ignored on read. Only records whose frontier is in the op log are written.
pending.log barocss-hub/pending/v0; P <peer 32 hex> <op body> sync Rewritten with write_atomic after the ops are appended. Optional (absent = empty).
warnings.log barocss-hub/warnings/v0; W <peer> <author|-> <opid|-> <reason> <first_seen> <str detail>, reason in equivocation, lamport-violation, corrupt-blob, duplicate-replica sync; reid (drops duplicate-replica, provisional #23 default) write_atomic. Optional.
blobs/<64 hex> raw bytes, no header record, sync Temp blobs/tmp-<pid>-<nanos>, fsync, rename, fsync of blobs/. A leftover tmp-* is never a blob name and is swept by writers under the exclusive lock. Every read verifies SHA-256. The empty blob is implicit (no file).
index barocss-hub/index/v0, base <frontier|->, then d <path> <node>, f <path> <node> <blob> <size> <mtime ns>, u <path> <node> checkout, record, mkdir/touch/mv/rm write_atomic. u = unmaterialized path (D-019). 0 0 size/mtime = unknown, rehash. Racy-git guard: a cached blob is trusted only if size and mtime match and the mtime is older than the index's own. Racy smudging: on save, a file entry whose mtime is within 2 s (RACY_NS) of the save time is written as 0 0, so it is rehashed next time (covers coarse-mtime filesystems). Missing index = nothing materialized (base = genesis).
tmp/ working-file temps w-<pid>-<nanos>-<seq> checkout apply Created create_new, fsync, rename over the target (same filesystem is checked). Leftovers are swept by record and by checkout (both under the exclusive lock). Never scanned.
checkout-in-progress barocss-hub/checkout/v0\ntarget <frontier|->\n checkout apply write_atomic before any working-dir change; removed only after the new index is saved (then .hub/ is fsynced). While present, record and mkdir/touch/mv/rm refuse; barocss-hub checkout with no id resumes the target.
pinned barocss-hub/pinned/v0\n<frontier ids comma-joined|->\n checkout (Issue #25 repair) write_atomic after the index is saved; present iff the last checkout materialized a version other than head (barocss-hub checkout <id> naming head does not pin); removed by a checkout that lands on head. Local only, never synced. While present, barocss-hub watch pauses auto-record and auto-checkout (keeps syncing) and barocss-hub status shows "pinned at ". A crash before it is written leaves checkout-in-progress, whose resume re-derives it. [decision-proposal]
peers barocss-hub/peers/v0, then one canonical absolute peer root per line barocss-hub peer add/rm (Issue #25) write_atomic under the repo lock. Local config, never synced.
watch.lock, watch.pid empty; <pid>\n barocss-hub watch (Issue #25) The watcher holds a non-blocking flock on watch.lock for its lifetime; watch.pid is removed on clean exit. A watch is active only while the lock is held, so a pid file left by SIGKILL is ignored.
lock empty file every command Advisory flock: exclusive for mutating commands (held from replay to the last append), shared for readers; sync takes both repos' locks exclusively in canonical-path order. Released on close, so a crash leaves no stale lock. Non-unix targets: no lock; every command prints a warning (#23; std File::lock needs Rust 1.89, toolchain is 1.87).

Sync persist order (per side, after both in-memory pulls finish): 1. blobs of new applied and pending SetContent ops; 2. new ops appended in < order; 3. pending.log and warnings.log rewritten; 4. new published checkpoints appended. Any prefix of this sequence is a valid repository; rerunning the sync sends the rest (tests interrupted_sync_is_valid_and_rerun_completes, sync_prop with randomized in-process interruptions, #23).

  • Note (#50): the reported-rejections memory (Remote-Sync 5.2.7.1) identifies a rejected payload by SHA-256 of the op's text encoding, not by this section's binary hash. It's a local, single-encoding cache and never compared across repositories.

A.1 Durability contract and barrier order (#29) [decision-proposal]

Contract (unchanged by #29, now explicit). (1) When a hub command exits 0, everything it wrote is durable: it survives a process crash and a power loss. (2) After a crash or power loss at any point, .hub/ opens as a valid, causally closed version (every op's deps and every SetContent blob present). (3) Rerunning the interrupted command (or barocss-hub checkout with no id, for a checkout) converges. Nothing a command had not yet acknowledged is promised to survive; everything an earlier successful command acknowledged is.

Primitives (code: hub-cli/src/durable.rs).

  • flush(f): hand one file's data and metadata to the device. Linux: fsync(2) (which also flushes the device cache). macOS: plain fsync(2), which does not flush the drive's cache (Apple fsync(2)/fcntl(2) man pages).
  • barrier(f): make everything already flushed on that volume durable. macOS: one fcntl(F_FULLFSYNC); per fcntl(2) it does what fsync does and then asks the drive to flush all buffered data to permanent storage, so one call after N plain fsyncs covers all N files. Linux: fsync(f) (the per-file fsyncs were already durable; this is cheap).
  • dsync(dir): durable directory entries: fsync(dir) on Linux, F_FULLFSYNC(dir) on macOS (std sync_all).
  • log sync: std sync_data (Linux fdatasync, macOS F_FULLFSYNC).
  • Group commit = flush each of N files, then one barrier; before #29 every file got its own F_FULLFSYNC (~10 ms each on macOS).

Per-OS choice [decision-proposal]. macOS: per-file fsync + one F_FULLFSYNC. F_BARRIERFSYNC was rejected: it only orders I/O (writes before it reach media before writes after it) and does not guarantee the data is on media when it returns, so a command could exit 0 with data still lost on power loss, which breaks clause (1). It would be acceptable only as an intermediate ordering point followed by a final F_FULLFSYNC, which saves nothing here. Linux: per-file fsync (each includes a cache flush; typically tens of µs to ~1 ms on SSD). syncfs(2) was considered and not taken: it flushes the whole filesystem (unrelated data too) and before Linux 5.8 did not reliably report writeback errors. Other unix: as Linux. Tradeoff: on Linux a 10k-file record still issues 10k cache flushes (cheap on SSD, slow on rotating disks); revisit with syncfs if it matters.

Torn-tail rule, extended (#29 repair). A power loss during an un-synced append may leave the log's tail missing, cut mid-record, or zero-filled (the file size can reach media before the data). On open: a final record without its newline or with a bad crc is torn (as before). A record containing a NUL byte is treated as an un-synced tail only if no complete crc-valid record follows it anywhere in the file: then the log is truncated at the start of that record (set_len + fsync) with a warning. If any valid record follows, it is corruption: the open fails and the file is left byte-identical (as before #29). NUL never appears in a valid record. Consequence: a power loss that leaves a zero hole followed by surviving records of the same un-acknowledged batch is reported as corruption, not silently repaired; nothing is lost, but the user must intervene. That is the price of never truncating acknowledged records. Every batch is appended in < order, so any truncation point leaves a causally closed prefix.

Per-command order. Stages in brackets are the crash hooks (HUB_TEST_CRASH_STAGE=<stage>[:<k>], abort at the k-th occurrence, default 1).

  • init: write_atomic ops.log, checkpoints.log, then replica last. Crash before replica: not a repo; barocss-hub init again.
  • record (record -m, watch auto-record): sweep temps → for each new blob: blobs/tmp-* write + flush → [blobs-written] → one barrier → [blobs-barriered] → rename every temp to its blob name → [blobs-renamed] → flush(blobs/) + one barrier (#34 repair; was dsync(blobs/), same effect, now logged by HUB_TEST_FLUSH_LOG); existing hash-valid blob files are flushed into the batch, never trusted as durable (A.2.8) → append all op lines to ops.log (plain writes) → [ops-appended] → one log sync → [ops-fsynced] → working-dir apply (checkout order below; usually no files) → record -m: append checkpoint + sync.
    • Crash at blobs-written / blobs-barriered: only tmp-* files (possibly truncated by a power loss); swept by the rerun. No op exists, so the repo is the previous version.
    • Crash at blobs-renamed or before the dsync: some blobs visible, possibly lost again by a power loss; no op names them yet. Orphan blobs are harmless and reused by the rerun.
    • Crash at ops-appended: process crash: the lines are in the page cache and survive; power loss: any prefix, a cut record or a zero hole may remain; open truncates per the rule above. The blobs of every surviving op are durable (the dsync came first). The rerun records whatever is still unrecorded.
    • Crash at ops-fsynced (before apply), or a power loss that leaves a strict prefix of the batch: ops durable, index still at the old base, so barocss-hub record refuses (not at head). Recovery is barocss-hub checkout. If the whole batch survived, the files on disk already match head and checkout succeeds. If only a prefix survived, checkout may refuse (a file created by the prefix whose content op was lost has "unrecorded changes"), or it may restore a file whose unrecorded deletion was lost. No acknowledged data is lost and the working files are untouched, but convergence needs the user (keep the edits, checkout --force, put them back, record). This window existed before #29 (a crash between per-op appends leaves the same prefix); #29 only widens it to a power loss. Open concern on #29, not fixed here.
  • mkdir/touch/mv/rm: one op append + log sync → apply. Unchanged.
  • sync (and watch auto-sync), per side, after both in-memory pulls: blob batch with direct writes (#34, A.2.8: each received blob written to its final name + flush → [blobs-written] → flush(blobs/) → one barrier → [blobs-barriered]; blobs-renamed is hit right after with nothing to rename; no batch at all if no blob is new) → all new ops appended in < order, one log sync ([ops-appended], [ops-fsynced]; the old per-op HUB_TEST_CRASH_IN_SYNC=<n> still aborts before op n) → pending.log, warnings.log write_atomic → published records appended, one sync. Any prefix is a valid repo; the rerun resends the rest.
  • checkout (and record's/watch's apply): marker write_atomic (durable) → removals → mkdirs → each file: tmp/w-* create_new + write + flush → [files-written] → one barrier → [files-barriered] → rename every temp over its target (the old HUB_TEST_CRASH_IN_CHECKOUT=1 aborts after the first rename) → [files-renamed] → flush every directory whose entries changed (deduplicated, deepest first): the parent of every removed path, of every level of each created directory chain, and of every written file. A listed directory that no longer exists as a directory (removed, or an ancestor replaced by a file in the same apply: NotFound/ENOTDIR) is skipped and its nearest existing ancestor directory is flushed instead; on Linux each flush is a real fsync of that directory, on macOS a plain fsync made durable by the following barrier → one barrier → index write_atomic (old after-index hook) → pinned write/clear (old after-pin hook) → marker removed + dsync(.hub/). Barrier-before-rename means a rename never exposes un-durable content, so a power loss leaves each target either old or new, never truncated. Any crash before the marker removal leaves the marker; barocss-hub checkout resumes (idempotent plan); leftover temps are swept. (Before #29, directories were never fsynced; #29 adds that. The same apply is used by mkdir/touch/mv/rm and by record's materialization, so they get the same rule. Test hook HUB_TEST_FLUSH_LOG=<file> lists the flushed directories.) A file the user removes while an apply is running (e.g. under barocss-hub watch) no longer fails the index refresh: its entry is saved with unknown stat and the next scan records the deletion.
  • commit / publish: one checkpoint append + sync. Unchanged.
  • reid: write_atomic of replica (+ warnings.log). Unchanged.
  • watch: each step is one of the commands above under the repo lock, with the same guarantees; a step's output line is printed only after its last sync.

Recovery on open/resume (unchanged apart from the NUL rule): torn or NUL tails truncated with a warning; blobs/tmp-* and tmp/ swept by writers under the exclusive lock; checkout-in-progress → barocss-hub checkout resumes.

A.2 On-disk format v1 (#33) [decision-proposal; implements D-029]

Format v1 adds per-batch commit records to ops.log and a state snapshot (.hub/snapshot). v0 repositories are migrated automatically on first open by a v1 binary (A.2.6). Everything in Appendix A and A.1 that this section does not change still holds. The sync wire (§4) is unchanged, apart from the #34 NeedBlobs step (A.2.8).

A.2.1 Files changed from v0

Path v1 content Notes
replica barocss-hub/replica/v1\n<32 hex>\n The version marker and the migration's single commit point (A.2.6). A v0 binary checks this exact header before it reads any other file and fails with corrupt .hub/replica (A.2.5).
format barocss-hub/format/v1\n Informative copy of the version, for tools and future binaries. Written after the switch. If present, it must agree with replica; a v1 binary refuses any other value.
ops.log header barocss-hub/oplog/v1; O ... records as in v0, plus B commit records A.2.2.
snapshot binary, A.2.3 A cache: deleting it never changes observable state.
peers header barocss-hub/peers/v1 Always present in v1 (header only when there are no peers), so v0 hub peer/hub watch refuse (A.2.5). A v1 binary also reads the v0 header.
watch.lock empty Always present in v1 (created at init/migration), so a v0 hub watch that fails leaves .hub/ byte-identical (A.2.5).

rescue/ (#49, 4.13): rescue directories (<ts>/manifest, <ts>/files/, <ts>/done) and current; local, never replicated, ignored by older binaries. While a rescue is in progress replica carries the header barocss-hub/replica/v1-rescue (same id; #49+ binaries read it as v1, pre-#49 binaries refuse the repository), restored to barocss-hub/replica/v1 when the rescue completes.

Unchanged: checkpoints.log, published.log, pending.log, warnings.log, blobs/, index, tmp/, checkout-in-progress, pinned, watch.pid, lock.

A.2.2 Batches and commit records (ops.log)

  • A batch is the run of O records written by one append (one record, one mkdir/touch/mv/rm, one side of one sync, the ops of one watch step). It ends with one commit record, framed like every record (<body>\t<crc>\n): B <seq> <count> <chain>
    • seq: 1 for the first batch, then +1 per batch (decimal).
    • count: number of O records in the batch (>= 1; a batch is never empty).
    • chain: 16 lowercase hex = the first 8 bytes of SHA-256( chain of batch seq-1 (16 ASCII hex; 0000000000000000 for seq 1) ‖ the 8-hex crc of each O record of the batch, in file order ). The chain commits to the whole log prefix, so the commit record of batch k identifies the log up to and including batch k.
  • Append order. Write the batch's O lines, then its B line (plain writes), then one log sync. A command acknowledges (exits 0 / prints) only after that sync. If an append stops early (error or interrupt hook), the O lines already written get a B record for exactly those lines, then the sync; so "persisted" still means durable and complete.
  • Open rule. Read the log from the start (or from the snapshot's covered offset, A.2.3):
    1. The committed prefix is the longest run of complete batches from the start: every record crc-valid and newline-terminated, each batch = one or more O records followed by a B whose seq, count and chain check out. Only committed batches are applied.
    2. The rest of the file after the committed prefix is the uncommitted tail. If the tail contains a self-consistent batch (a crc-valid B preceded by exactly count crc-valid O lines whose chain, computed from the preceding committed chain, matches), acknowledged data follows damage: that is corruption; the open fails and the file is left byte-identical. A B record inside the tail whose seq, count or chain does not match is not self-consistent.
    3. Otherwise the tail is un-acknowledged (a crash or power loss during the last append) and is truncated (set_len + fsync) to the end of the committed prefix, with a warning. This covers every torn form: a cut last line, a bad crc, O lines with no B, a B whose batch lost a line, and zero-filled holes (NUL bytes) anywhere in the tail.
  • NUL/hole rule, redefined (replaces the A.1 rule for ops.log). A NUL byte, a bad crc or a missing newline before the end of the last committed batch is corruption (hard error, file untouched). The same damage after it is part of the uncommitted tail and is truncated, whether or not other records of that batch survived. Rationale: each batch is synced before the next one is written, so no batch can be committed after a batch that was not durable; a power loss can only damage the last, un-acknowledged batch. This fixes the #29 limitation (a zero hole followed by surviving records of the same un-acknowledged batch used to be reported as corruption): it now recovers automatically.
  • An op's id repeated with an identical op is skipped (as v0); with a different op it is corruption. Ops with missing deps are corruption. Every batch is appended in < order, so every committed prefix is causally closed.
  • checkpoints.log, published.log: single-record appends, v0 torn-tail and NUL rules unchanged (A.1).

A.2.3 Snapshot (.hub/snapshot)

  • Contents. Everything open needs to skip replay: the replica's derived state (tree state, per-node parent setter, the applied-op log in < order with each op's undo record and fold outcome, the D-014 view and its revive triples) and the position it covers. The op store (id -> op) and the replica's known set are rebuilt from the applied-op log; the pending set is always empty in .hub/ (ops with missing deps are corruption, sync buffers them in pending.log, which is not cached). Also cached: the head frontier (ids no op depends on, < order) and the greatest op counter; on open they are carried over the tail as F' = (F ∪ ids(tail)) − deps(tail) (the store is causally closed). Checkpoints are re-read from checkpoints.log (small). Not stored, derived on demand: Causality/version-vector tables, the conflict set and the short-id index (measured at 1M ops, Performance #33: derivation fits the 1 s budget, and caching them would need incremental maintenance for every tail).
  • Encoding. Little-endian binary: barocss-hub/snapshot/v1\n ‖ covered log offset (u64) ‖ covered batch seq (u64) ‖ covered batch count (u64) ‖ covered chain (16 ASCII hex) ‖ covered op count (u64) ‖ payload length (u64) ‖ payload (replica: interned replica-id table, LEB128 counters/lengths) ‖ head frontier (count u64, then (counter u64, replica u128) each) ‖ max counter (u64) ‖ checksum (u64 over everything before it). The checksum is a fast non-cryptographic 64-bit hash (a cache only needs to catch accidental damage). The covered offset is the byte offset just after the covered batch's B line.
  • Validation (it is never trusted blindly). On open, the snapshot is used only if all hold: the magic line matches; the checksum matches; the payload decodes completely with no trailing bytes; the covered offset is <= the ops.log length and the log's bytes just before that offset are exactly the framed B <seq> <count> <chain> line of the covered position (this binds the snapshot to the log position and, through the chain, to the whole prefix); the decoded op count equals the covered op count. Then only the batches after the covered offset are read, checked (A.2.2 open rule, with the snapshot's seq and chain as the starting point) and replayed. Consequence: damage inside the covered prefix is not seen while a valid snapshot covers it (the open uses the snapshot's state, which equals the replay of that prefix as it was when the snapshot was written, and writes nothing to the log); it is reported as corruption by the next full replay (snapshot removed or invalid, or HUB_NO_SNAPSHOT=1). If damage is found after the covered offset, the open re-reads the whole log so the error text is the same as without a snapshot.
  • Fallback. If any check fails (corrupt, truncated, wrong version, bound to a log that no longer exists or to another repository), open prints warning: ignoring .hub/snapshot (<reason>); replaying ops.log and replays the whole log as without a snapshot. A writer (exclusive lock) also removes the bad snapshot, so the warning is not repeated; a new one is taken by the policy below. Read-only commands never write.
  • Crash-safe write. .hub/.snapshot.tmp write + flush → [snapshot-written] → barrier → rename to snapshot → [snapshot-renamed] → dsync(.hub/). A crash leaves the old snapshot or the new one, both valid (an old snapshot is simply followed by a longer replayed tail). A leftover .snapshot.tmp is ignored and overwritten.
  • When snapshots are taken. Only by a command that holds the exclusive lock (record, mkdir/touch/mv/rm, commit, publish, checkout, reid, sync on both sides, each watch step), after all its acknowledged work is durable, and only when the ops replayed on top of the current snapshot (the tail) reach max(SNAP_MIN, covered / SNAP_DIV) ops, with SNAP_MIN = 4096, SNAP_DIV = 16 [decision-proposal]; always right after a migration (step 5, still under the exclusive lock) if the log has at least SNAP_MIN ops. Taking or not taking a snapshot never changes any output; a failure to write one is a warning, never an error (the command's own work is already durable). HUB_SNAPSHOT_MIN=<n> overrides SNAP_MIN (0 = after every write, used by tests) and HUB_NO_SNAPSHOT=1 disables reading and writing snapshots (debugging and benchmarks).

A.2.4 Crash stages (test hooks, HUB_TEST_CRASH_STAGE) Appends: ops-appended (O lines written) → commit-written (B line written, not synced) → log sync → ops-fsynced. Snapshot: snapshot-written, snapshot-renamed. Migration: mig-log-written, mig-log-synced, mig-replica-tmp, mig-switched, mig-log-renamed, mig-peers, mig-format (A.2.6). A process crash after commit-written keeps the batch (the page cache survives); a power loss before ops-fsynced may drop it (never partly: the open rule drops the whole batch).

A.2.5 Old (v0) binaries A v0 binary must refuse a v1 repository before writing anything, and must never truncate a v1 log. The v0 code paths, as shipped up to develop 0aff2d9:

  • Every command that opens the repository (record, checkout, mkdir, touch, mv, rm, ls, status, commit, log, show, publish, warnings, reid, sync, each watch step) goes through Repo::open_dir_with: take .hub/lock (an existing empty file, opened without truncation), read replica, compare its first line with barocss-hub/replica/v0. A v1 replica fails there with corrupt .hub/replica, before ops.log is read, so the v0 torn-tail truncation code is never reached. As a second guard, the v0 log reader checks the header line first and returns missing or wrong header before any truncation.
  • hub peer add|rm|list do not open the repository: they read peers, whose v1 header fails the v0 check (corrupt .hub/peers) before anything is written.
  • hub watch takes watch.lock (already present in v1, not modified), writes watch.pid, then fails reading peers and removes watch.pid on exit. Net effect: .hub/ byte-identical.
  • hub init in a v1 repository refuses (already a hub repository); help touches nothing. The v0 message is not specific ("corrupt"), because the v0 binaries cannot be changed; v1 binaries refuse newer versions with unsupported repository format ... (this hub understands v0 and v1).

A.2.6 Migration v0 -> v1 Runs on the first open by a v1 binary, under the exclusive lock (a shared-lock command drops its lock, takes the exclusive lock, migrates, and reopens under its own mode). Every step before the switch leaves the v0 files untouched; the switch is one atomic rename.

  1. Read replica (v0) and ops.log with the v0 rules, in memory only: a torn or NUL v0 tail is left out of the new log, the v0 file is not truncated. Decode and validate exactly as a v0 open (duplicates, closure). Any error: stop, nothing written.
  2. Write .hub/ops.log.v1 = v1 header + one batch holding every v0 op in v0 file order (v0 has no batch boundaries; all of it was acknowledged, so a single batch is exact; an empty log gives a header-only file) → [mig-log-written] → flush + barrier → [mig-log-synced].
  3. Switch: write .hub/.replica.tmp = v1 replica file (same id), flush → [mig-replica-tmp] → rename over replica → dsync(.hub/) → [mig-switched]. From here on the repository is v1 and v0 binaries refuse it.
  4. Roll forward: rename ops.log.v1 over ops.log → dsync(.hub/) → [mig-log-renamed]; write peers with the v1 header (write_atomic; keeps the peer list) → [mig-peers]; create watch.lock if missing, write format (write_atomic) → [mig-format].
  5. Take a snapshot (A.2.3) if the log has at least SNAP_MIN ops, still under the exclusive lock (also when the migrating command is a reader). Crash safety. Crash before step 3's rename: replica is still v0; v0 binaries work normally (they may even append to the v0 log); a v1 binary restarts the migration from step 1 and overwrites ops.log.v1/.replica.tmp. Crash after the rename: replica is v1; on every open a v1 binary checks for roll-forward work (ops.log.v1 present; peers missing or v0; watch.lock or format missing) and finishes step 4 under the exclusive lock before continuing. Step 4's renames only happen after the switch is durable (dsync), so a power loss cannot leave a v1 ops.log under a v0 replica. Idempotent: rerunning any step gives the same files. Migrating never changes the op set, checkpoints or any output (tested byte-for-byte).

A.2.9 Format v2: text ops (#54, Text-CRDT 7.4.1/7.9/7.11, D-053 E-T5) [implemented] v2 = v1 + T records + snapshot v2. Every other file and rule of A.2 is unchanged.

  • T record: O <id> <deps> T <node> <epoch> s=<seen> d=<del> i=<ins> (grammar, canonical form and h(o) tag 4 in Text-CRDT 7.4.1). Every writer uses it: ops.log, pending.log (P <peer> <record>), the rescue manifest (op <record>), the wire Ops body and the #50 reported-rejection hash. A v2 binary reads v1 logs unchanged.
  • Header: replica = barocss-hub/replica/v2\n<32 hex>\n (barocss-hub/replica/v2-rescue while reid --rescue runs; replaces v1-rescue in a v2 repository) and format = barocss-hub/format/v2\n. format may still say v1 under a v2 replica (roll-forward pending); v2 under a v1 replica is corrupt. reid and the rescue marker keep the repository's format.
  • Lazy upgrade: the repository stays v1, byte for byte, until the first TextEdit is about to reach any .hub/ file (appended by Repo::append_ops*, or received by sync into ops.log or pending.log). Then, under the exclusive lock, before any T record is written: .replica.tmp (v2 header, same id) → flush → [v2-replica-tmp] → barrier → rename over replica → dsync(.hub/) → [v2-switched] (commit point) → format v2 (write_atomic) → [v2-format]. Header only, no log rewrite. A crash before the rename leaves a working v1 repository (old binaries keep working; the next text append starts over); after it, every open (also a shared-lock one, which takes the exclusive lock briefly) rolls format forward. Idempotent.
  • Old binaries: pre-#54 binaries (tested: develop 05a1be0) read replica first and refuse a v2 repository with unsupported repository format 'v2' before reading or writing anything else; every command that opens the repository leaves .hub/ and the working tree byte-identical (test old_binary_refuses_a_v2_repository_before_writing, also mid roll-forward). Config-only commands that never open the repository behave as in the #49 marker test. A v1 repository without text ops is written identically by both binaries (v1_repo_is_byte_identical_under_old_and_new_binaries).
  • Snapshot v2: file header barocss-hub/snapshot/v2\n, same framing and validation as A.2.3; payload RSNAP2 = the v1 payload (applied-op log records gain payload tag 4) followed by the text part: per (node, epoch) the doc (the seed's blob id and length, never its bytes; op list; per-op first fragment and split index; fragments with op index, offset, length, parent, chunk, first left/right child, next sibling, right origin, deleter set, side; interned deleter sets; chunk lists, chunk order and rank) or a deferred marker; the valid and invalid TextEdit sets; per text node the winning epoch and head blob id, its content conflicts and concurrent-edit notices, and the fold content the head overrides. An op's inserted bytes are not stored twice: decode reads them back from its record in the payload's op log. Referenced seeds: open uses the snapshot only if every referenced seed blob exists in blobs/ with its length (one stat each; 10 000 seeds in 0.04 s), else warning: ignoring .hub/snapshot (referenced seed blob <id> is missing); replaying … and the A.2.3 fallback. A doc whose seed is referenced is loaded lazily: its seed is read and hash-checked on first use (a new edit on that file, or a derivation of that node). A damaged seed (bit rot that keeps the length) is reported then as corrupt (same as A.2.8: committed blobs are verified when read); the tail replay of the next open then fails over to the full replay. Blob retention rule: a blob named by any committed op is never removed (there is no blob GC). Any future GC must keep the seeds a snapshot references, or remove the snapshot first. A replica without text ops still writes v1 (header and payload). The header must match the payload, else the snapshot is ignored. Sizes and open times: Performance (#54 B2/B5).
  • Record size: an op authored by this replica whose encoded record exceeds TEXT_OP_MAX (192 KiB = FRAME_FLOOR − 64 KiB of Ops header room) is refused with limit and nothing is written; a save larger than that becomes a chain of TextEdits appended in one batch (Text-CRDT 7.4.1 "chained saves").
  • Crash stages added to A.2.4: v2-replica-tmp, v2-switched, v2-format. Tests: crates/hub-cli/tests/text_t2.rs (crash at every stage, then rerun; appends after the switch; byte identity; the v2-rescue marker).

Error line numbers. Corruption errors name the physical line of ops.log (commit records count as lines).

A.2.7 init (v1): write_atomic ops.log (v1 header), checkpoints.log, peers (v1 header), format; create watch.lock; then replica (v1) last.

A.2.8 Sync blob transfer and direct blob writes (#34) [decision-proposal] (v1 minor revision; the on-disk layout is unchanged, so no version marker changes)

  • Only lacking blobs travel. pull sends each Ops batch without bytes (answer_ops); the receiver answers with the blob ids it lacks among the batch's SetContent ops (lacking_blobs, a NeedBlobs step); only those bytes are fetched and received before the batch's ops. The same holds for the Need round. The Issue #21 answer (all bytes attached) remains in the core for tests. §4 semantics are unchanged: a blob is still verified by SHA-256 on receipt and a SetContent is applied only when its blob is present.
  • Held rule (what the receiver may claim to hold without reading it). A blob counts as held only if (a) a committed (applied) op names it, checked by existence in one readdir of blobs/ (A.1 contract (2): a blob is durable before any op naming it is appended), or (b) its bytes were read and hash-verified in this process (blobs of pending ops). A file under a blob name that no committed op names is never trusted without verification, so a torn direct-written file is re-fetched. A committed op whose blob file is missing makes sync fail ("cannot sync ...: missing blob"); a committed blob whose bytes were damaged after the fact (bit rot) is no longer caught by the receiver at sync time (it was before #34, when load read every blob), only when it is read (checkout, or when it is sent: the sender reads and verifies every blob it sends).
  • Existing files: verified and made durable before use (#34 repair). BlobBatch::add (record and sync) never trusts a file under a blob name by existence. It reads and verifies it: a missing or corrupt file is (re)written; a hash-valid file is not rewritten but is reopened, flushed and joins the batch, so the batch's flush(blobs/) and barrier always happen before any op naming it is appended. Reason: a crash after a direct write (or after a rename) but before the barrier leaves hash-valid bytes that a later power loss can still lose; before this repair a rerun skipped them, issued no barrier, and could append ops naming non-durable blobs (A.1 (1)/(2)). Held, for the NeedBlobs step, still means only: named by a committed op (trusted by the A.1 invariant, checked by one readdir) or hash-verified in this process; an unreferenced file is requested again and then goes through add as above. Tests rerun_after_blob_crash_barriers_blobs_before_ops (record and sync, every blob stage; the rerun is aborted at ops-appended and must already have logged a blob barrier and a flush of .hub/blobs) and intact_orphan_blobs_are_made_durable_before_use (non-truncated orphans, sync and record).
  • Direct writes (sync only). Received blobs are written straight to blobs/<hex> (create/truncate, write, flush), then one flush(blobs/) and one barrier for the batch; no temps, no renames. A crash or power loss before the barrier may leave final-named files with torn bytes. No op names them (ops are appended after the barrier), so the repository is valid; readers verify the hash (a torn file reads as corrupt), and the held rule and add above ensure it is re-fetched/rewritten, never trusted. Orphan torn files are harmless garbage until rewritten (no GC yet). record, checkout and snapshot keep temp + rename.
  • Barriers. Exactly one blob barrier per side whose batch has at least one blob (new, or existing but re-flushed), whatever the count, and none otherwise (test hook HUB_TEST_BLOB_BARRIER_LOG, tests many_new_blobs_one_barrier_per_sync, partial_blob_overlap_sends_only_missing_bytes, already_held_blobs_transfer_zero_bytes). The op-log sync, pending.log/warnings.log write_atomic and the published append keep their own syncs (A.1, unchanged).
  • Why no fan-out. Measured on the bench Mac (APFS): rename(2) ~130-190 µs per file and file create ~137 µs, flat 20k-entry dir vs ab/-fan-out equal within noise, so a fan-out layout would not pay for a layout change. Direct writes remove the rename (the dominant cost, 62% of samples before #34); the remaining cost is per-file creation. A pack format (many blobs per file) would remove it; deferred by user decision (#34).
  • Old v1 binaries (develop 2c7dd10) open repositories written after #34 unchanged (same layout); a torn orphan file reads as corrupt there too, and their put rewrites corrupt files. Tested with a 2c7dd10 release binary (#34 evidence).
  • Instrumentation: HUB_TEST_SYNC_STATS=<file> appends blobs-in this|peer <bytes> <count> per sync.
⚠️ **GitHub.com Fallback** ⚠️