Repository Semantics - barocss/barocss-hub GitHub Wiki
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.
-
[decision-proposal]
NodeId = (Counter, ReplicaId)(same tuple order as OpId, 2.1; changed by #7).ReplicaIdis a 128-bit random id generated once per replica (device/process lineage);Counteris 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::ZEROis 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.
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) orText(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 = Rootother thanROOT.
Invariants hold on every converged state (after applying any set of operations in any causal order). Each is phrased to become a property test.
-
Single root: exactly one node has kind
Root, its id isROOT, and it has no parent.TRASHis the only other parentless node (kindDirectory, never live, never projected). -
Parent exists: every non-root live node has exactly one parent, and that parent is a live
RootorDirectorynode (never aFile). -
Acyclic / reachability: following
parentfrom any node other thanROOT/TRASHreachesROOTorTRASH; from any live node it reachesROOTin a finite number of steps (at most the number of nodes); no node is its own ancestor. -
Identity stability: for every node
npresent 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. -
Convergence: for two replicas,
Ops(A) == Ops(B)(as sets) ⇒State(A) == State(B), independent of delivery order. - 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.)
-
[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 reachesROOT(notTRASH). Descendants of a trashed directory are therefore not live either, without touching them. - Why TRASH over a
deletedflag: (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).
-
[decision-proposal] Projection is a pure, deterministic function
project(State) -> Map<Path, Entry>: walk live nodes fromROOT; path of a node = path of parent +/+ name.Rootmaps 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::projectgroups bynameas 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 bybarocss-hub statusunder "unmaterialized (case clash ...)", are reported as warnings bycheckout, and are never treated as deleted bystatus/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-cliwd::clashing,wd::plan,wd::Entry::Unmaterialized; testscrates/hub-cli/tests/workdir.rscase_clash_*, on the real filesystem and with a forced probe viaHUB_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 statusprintsblocked: <path> collides with unmaterialized <paths>,recordrefuses 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; testsblocked_file_on_unmaterialized_path_*.)
-
[decision-proposal] (#9) Suffix format: short id =
<counter>-<low 32 bits of replica, 8 hex>, suffixed namename~<short-id>. Plain names of every collision-group winner are reserved first; each loser (ascending NodeId) triesname~<short-id>, thenname~<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 (testissue10_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). (Testssuffix_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
recordor mkdir/touch/mv/rm (and aftercheckout), the working directory equalsproject(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, sof1~<id>becomesf1; a new node joins a collision group and takes a suffix; D-002 suffix changes).recordtherefore 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-clirecord; testscrates/hub-cli/tests/sync.rsrecord_materializes_rename_when_collision_disappears,record_suffixes_new_node_when_collision_appears,record_refuses_rename_that_would_delete_ignored_file;sync_prop.rsasserts the invariant after every step.)
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,
.hubignoreat 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.hubignorefiles, global/per-repo exclude files, case-insensitive matching..hub/is always excluded before rules apply. -
[decision-proposal] (#24)
.hubignoreis 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 statuslists it under "tracked but ignored".barocss-hub rmstops tracking it. -
record never creates nodes for untracked ignored paths. status hides them;
status --ignoredlists 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-cliignore.rs,wd::scan_all,wd::plan(PlanOpts::ignored); testscrates/hub-cli/tests/ignore.rs,ignore_prop.rs, unit tests inignore.rs.
-
[decision-proposal] (#37, repaired) Built-in defaults, applied before every
.hubignoreline (so a!patternin 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_StoreThumbs.dbdesktop.ini(OS folder metadata). Conditional (names an editor uses that are also plausible real file names; review founddata/4913andnotes~with real content silently not synced):4913only if it is an empty regular file (vim probe);x~only whilexexists beside it (backup);#x#only whilexexists beside it (emacs auto-save);.#xonly 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), sorecord,status,watch(fingerprint and record) and "tracked but ignored" agree. PlainstatusprintsN file(s) hidden by default ignore rules (see ...);status --jsonhashidden_by_defaultsanddefaultperignoredentry. 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 xwith a lingeringx~,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): vimbackupcopy=no(4913probe,.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, byrecordand bywatch, as onemodifiedchange 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___orx___jb_tmp___exists, a watch step records nothing (eventdeferred) 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--debounceis recorded as delete + create (a new NodeId) — shown byslow_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.
Inputs: §1, Discussion #6 (research #2). Labels as above.
-
[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 isid.ReplicaId. -
[decision-proposal]
deps= the current causal frontier (heads) of the author's op DAG at creation time. A version vectorMap<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.
| 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
ROOTorTRASHas the node (moving, deleting, or creating a node whose id is reserved), Creates of kindRoot, moves withnew_parentaFile, invalid names, and ops referencing unknown nodes are rejected at authoring time. UsingTRASH(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-opcovers 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 aFile, 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 labelcycle. -
[decision-proposal] (#7) Plain
Move{node, TRASH, name}is allowed and means exactly the same asDelete{node}(with the given name kept on the record); implementations may normalize it to Delete.Move{node, d, name}wheredis a trashed directory is allowed: the node's parent becomesd, so the node (and its subtree) is not live, exactly as if it had been indwhendwas 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). OnlyROOTandTRASHthemselves 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 aloserin acycleorsame-node-moveconflict, and is not counted as an applied Move/Delete when choosing a winner (2.4). Its only record is itsinvalid-opconflict. (Code:Work::apply, Delete branch returnsOutcome::Invalidfor an unknown node;evaluateonly considersOutcome::Appliedops for winners/losers; testinvalid_op_is_noop_and_recorded, andno_silent_dropskips invalid ops.) -
[decision-proposal] (#8)
Createwithparent = TRASHorparent= a trashed directory is allowed (TRASH is aDirectory, 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 underdconcurrent with a delete ofd) and through the API; user-facing tools do not offer it. (Code:Work::applyCreate branch validates onlyparent_ok, i.e. parent kind isRoot/Directory, not liveness.)
-
[decision-proposal] State is a pure function of the op set:
State(Ops) = fold(apply, S0, sort(Ops by <))whereS0 = {ROOT, TRASH}. Implementations may apply incrementally using Kleppmann undo-do-redo: on receiving opowith later opsLalready applied, undoL(in reverse), applyo, redoL. 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.
SetContentnever changes structure;Bytescontent = value of the greatest (by<) SetContent on that node;Textcontent = 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,
Bytesonly)SetContent{node, blob}carries aBlobId= SHA-256 of the whole file bytes; blobs live in a content-addressed store (v0: in memory). A newFilehas the empty blob;Root/Directoryhave no content.-
Validity: SetContent whose
nodedoes not exist at its position in<, isROOT/TRASH, or is not aFileis aninvalid-op(2.2), a no-op, never a loser. -
Concurrent edits: the greatest Applied SetContent of
nin<is the winner (its blob isn's content). Every other Applied SetContenteofnwith no causally later Applied SetContent ofnis a loser and gets exactly onecontent{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 SetContentEofx(ascending by<) withxnot live, lettbe the node onx's ancestor-or-self chain whose parent isTRASH. Ift's winning parent-setting op (greatest Applied Move/Delete oft, 2.4) is aDeleteDandE ∉ deps*(D)(the delete did not see the edit), thentis restored to its pre-delete parent (its parent in the fold state just beforeD; Delete never changes the name) andedit-revived{node: x, edit: E, delete: D}is recorded. Repeat until nothing changes (the restored parent may itself have been deleted, so nested deletes alongx's path are all undone). A restore is skipped if the pre-delete parent isTRASHitself (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:xis live at its pre-delete path with E's content, oneedit-revivedconflict (row 14). If the delete was of an ancestord, it isdthat returns (with its whole subtree), soxkeeps 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-revivedconflict: editing a trashed file is treated as intent to keep it. - A delete causally after the edit (E ∈ deps*(D)) wins as usual:
xstays trashed with E's content, no conflict (a re-delete after seeing a revive therefore sticks). - If
xis live anyway (e.g. a concurrent move revives it, D-001, row 15), there is nothing to revive and noedit-revived. -
edit-revivedis informational: D is not a loser (2.6 #5 unaffected). -
[escalation] E-12 (#20) Reviving a deleted ancestor
dalso brings backd's other children, which the deleter meant to delete. Alternative: move onlyxout tod's pre-delete parent (like the D-001 descendant case:dstays deleted, butx's path changes). The implementation follows the text above (restored); a product decision is requested.
-
Concurrent edit and delete (E ∥ D), in either
- Text files (#48): concurrent text edits merge instead of producing
contentconflicts; epoch rule andTextEditin §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 withReplica);projection::Projection.blobs; testscrates/hub-core/tests/content.rs.)
-
Validity: SetContent whose
-
[decision-proposal] Kleppmann rule: when applying a Move of
nunderpin<order, ifn == pornis an ancestor ofpin 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, letWbe the winner (the greatest non-skipped Move/Delete ofnin<). Every other non-skipped Move/Deletemofnthat has no causally later Move/Delete ofn(nom'ofnwithm ∈ 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 itscycleconflict 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
nunderpis skipped, the existing pathp → … → 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 frompup to, excluding,n; a Create counts as setting its node's edge). For a self-move (n == p) there is no edge, sowinner= the skipped op itself. (Code:Work::apply,Outcome::Skipped { winner }computed fromsetteralong the path; testrow1_concurrent_cycle.) -
[decision-proposal] (#8,
revivedtrigger, order-independent) Arevived{node: x, delete: D, move: M}conflict is recorded iff all hold: (a)D = Delete{d}andM = Move{x, …}withx ≠ d; (b) D and M are both Applied (valid and not cycle-skipped); (c) D ∥ M (concurrent: neither in the other'sdeps*); (d) D is the winner ford, i.e. the greatest Applied Move/Delete ofd; (e) in the state just beforemin(D, M)in the sorted fold,xexists anddis a strict ancestor ofx; (f)xis live in the final state. It depends only on the op set, not on delivery. (Code:evaluate, revived loop; testsrow5_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 withevaluate. 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.
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,dis live at B's target; if A's delete is later,dis in TRASH. The overwritten op is recorded as asame-node-moveconflict (2.4), so the deleter sees that the delete did not stick. Moving a descendantxout of a concurrently deleteddkeepsxlive (its own parent is not trash) and A's delete ofdstill takes effect; so the delete's intent stays visible, arevived{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 broughtnout 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.
- Rejected because: B's reorganization is undone from B's perspective; it needs a concurrency check (
- The node is never erased, so the losing intent is recoverable from the log.
Deletestays a distinct payload for history and UI intent only; apply treats it as a Move.
- 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.
-
Lamport consistency: for every op
banda ∈ deps(b):a < b. -
Permutation convergence: for any op set
Opsand any two causally-consistent delivery orders π1, π2 (with or without incremental undo-redo),State(π1) == State(π2)andConflicts(π1) == Conflicts(π2). HenceOps(A) == Ops(B) ⇒ State(A) == State(B). - Tree invariants preserved: after applying every prefix of the sorted op list, §1.3 invariants 1–6 hold.
-
No silent drop (reworded by #8): for every structural op that is not invalid: a skipped cycle move loses exactly one conflict (its
cycleconflict), 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 asloserin exactly onesame-node-moveconflict. Invalid ops are never losers (onlyinvalid-op, 2.2). Holds for any number of concurrent moves of one node (2.4).revivedconflicts (2.5) have no loser and do not count toward per-loser conflicts. (Test:no_silent_dropinside propertyshuffled_duplicated_delivery_converges.) - Idempotence: applying an already-present op is a no-op.
-
Own-chain deps (#11): an author's
depsalways 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 opbby replicarwith a previous opabyr,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,VVmay fail to determine the version (§3.1, §3.5 #7). (Code:version::own_chain_okdetects violations;Version::from_vvandcompare_vvare meaningful only when it holds; testown_chain_violation_is_detected; generator checkp2_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. |
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 ≺).
-
[decision-proposal] A version is a causally closed op set
V:o ∈ V ⇒ deps(o) ⊆ V. The empty set is the genesis version (stateS0, 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'sdepsis 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 closedVcontains, for each replicar, exactly the ops ofrwithCounter ≤ VV(V)[r]. HenceVValso determinesV(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, soVV[r] = cdoes not meancops.)
-
[decision-proposal] Comparison is op-set inclusion. For versions
V1,V2:-
equaliffV1 = V2iffF(V1) = F(V2); -
V1is an ancestor ofV2iffV1 ⊊ V2iff everyf ∈ F(V1)is inV2andF(V1) ≠ F(V2);descendantis the converse; -
concurrent otherwise (neither contains the other).
Equivalent test on vectors (under §2.6 #7):
V1 ⊆ V2iffVV(V1) ≤ VV(V2)pointwise.
-
-
[decision-proposal] Merge
V1 ⊔ V2 = V1 ∪ V2. It is causally closed: ifo ∈ V1 ∪ V2, thenois in someVi, which is closed, sodeps(o) ⊆ Vi ⊆ V1 ∪ V2. Its frontier isF(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 hasdeps = 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 ∩ V2is 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).
-
[decision-proposal]
State(V) = fold(apply, S0, sort(V by <))(2.3),Conflicts(V)= the derived conflict set of 2.4/2.5 evaluated onV, andProjection(V) = project(State(V))(1.5). They are defined only for causally closedV, so every rule that inspects "the state just before opo" sees all ofo'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 invariantOps(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).
-
[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 changesState, 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 };frontieris always stored canonical (F(V), sorted by<), built from a version or from any resolvable id set (absorbed by down-closure; an unresolvable id isMissing, 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,ConflictsandProjectionunchanged, anddownclose(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 becauseF(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 itprivate → 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):
Checkpointat develop (crates/hub-core/src/checkpoint.rs, fieldmessage) 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).
- Closure: every replica's applied op set is causally closed after every step of any delivery order (with buffering, 2.1).
-
Frontier determines version: for any version
V,downclose(F(V)) = V; andF(V1) = F(V2) ⇔ V1 = V2. Property: random histories, compare sets. -
Frontier is an antichain: no two ops in
F(V)are causally related; the canonical list is sorted by<and has no duplicates. -
Merge laws:
⊔is commutative, associative and idempotent on versions,V ⊔ ∅ = V, andV1 ⊔ V2is causally closed. Same for⊓, plus absorptionV1 ⊔ (V1 ⊓ V2) = V1. -
Merge frontier:
F(V1 ⊔ V2) = max≺(F(V1) ∪ F(V2))(computed from the frontiers alone equals the frontier of the union). -
Comparison consistent with inclusion:
compare(V1, V2)returns equal/ancestor/descendant/concurrent exactly asV1 = V2,V1 ⊊ V2,V1 ⊋ V2, neither; the frontier test, the vector test (VVpointwise ≤) and the set test agree.V1is an ancestor ofV1 ⊔ V2or equal to it. -
Version vector determines version:
VV(V1) = VV(V2) ⇔ V1 = V2for causally closed sets (depends on each replica's ops forming a causal chain, 2.6 #7). -
Version-level convergence: equal frontiers ⇒ equal
State,ConflictsandProjection, for any two delivery orders (restates 2.6 #3). -
Checkpoints are inert: adding any checkpoint, publishing it, or moving any ref (§3.8) leaves every
State(V)and every version unchanged.
Ops:
-
a1 = (1,A)Create dir/p, deps{}. - A sends
a1to B and C. Then, concurrently:- A:
a2 = (2,A)Movep→ rename/q, deps{a1}. - B:
b1 = (2,B)Create file/p/f, deps{a1}.
- A:
- C receives
a2(notb1):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)].
- [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_localfrom⊓, 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).nameis 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'sorigin/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 withpublished/are invalid (refused at creation/rename), sopublished/<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)andReplicaId= 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,ConflictsorProjection(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
dand a movemare concurrent (neither supersedes the other), both are heads. The one with the greater stamp decides the value:dwins → the ref is absent;mwins → the ref exists attarget(m). In both cases|heads| > 1, so aref-moveconflict 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 seeingmsupersedes 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 ifTis a descendant-or-equal of the ref's current valueC:downclose(T) ⊇ downclose(C)(i.e.C ⊑ Tin 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 (aref-moveconflict), a move must be FF against all heads:downclose(T) ⊇ ⋃ downclose(lastval(h))over every headh(a ⊥ head contributes itslastval), 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 refnwith current valueT ≠ ⊥: (i) if no published record ofnexists, 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) Ifpublished/nexists (any heads, including ⊥ heads), publishing is an ordinary FF move ofpublished/ntoTsuperseding 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 ispublished/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).
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.
-
[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 frontiermax≺(F_A ∪ F_B). By 3.3 they then show the sameState,Conflicts,Projectionand version id (3.4). -
[decision-proposal] Sync only ever adds ops:
V_Abefore ⊆V_Aafter. 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.
-
[decision-proposal]
-
Have { frontier: [OpId], vv_hint: Option<VersionVector> }— the requester'sF_A(authoritative) and optionallyVV(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_Bat the start of the answer. -
Need { ids: [OpId] }— ids the requester lacks (e.g. deps of buffered ops, or entries ofsender_frontierstill 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 ofsender_frontieris not inV_A(possible only if B's log grew or the answer was truncated), A sendsNeedor a freshHave(4.4). ThenCheckpointsandRefMovesin either direction.
-
[decision-proposal] B computes the known-to-A set
K = downclose(F_A ∩ V_B)— only frontier ids B can resolve. BecauseV_Ais causally closed,K ⊆ V_A. B sendsΔ = V_B \ Kin<order. ThenV_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'shavenegotiation), or a digest per version (3.1), once the wire format exists. -
[decision-proposal]
Δis causally closed relative toV_A: every dep of an op inΔis inK ⊆ V_Aor 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_hintmust never be used to exclude ops fromΔbeyondK. 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, whereVV[r] = cdoes not imply all ofr's ops≤ care present, or a buggy peer) would make B skip ops A lacks, i.e. op loss.version::from_vv/compare_vvare meaningful only whenown_chain_okholds (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.
-
[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 inpending). 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 ∪ PwherePis 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:Kis 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. Afterwardsbarocss-hub statusreports the working directory behind head andbarocss-hub checkoutmaterializes head, with the usual refusals (dirty files, D-019 clashes and blocked paths). (Code:hub-clisync::sync_cmd; testssync_leaves_unrecorded_changes_alone,sync_prop.)
| 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 depdwithd ≮ 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 }. Reasonequivocationuses 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_Aand 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::receiveatca593fbdedups 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.
-
[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 (
Checkpointsmessage). 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.
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.
-
Sync reaches the join: for random histories, after
sync(A,B)both applied sets equalV_A ∪ V_Band both frontiers equalmax≺(F_A ∪ F_B). -
Monotone, no loss: every op in
V_Abefore sync is inV_Aafter; every op inV_Bis inV_Aafter a complete pull. -
Idempotent:
sync(A,B); sync(A,B)=sync(A,B); the second session applies zero new ops. -
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 atV_A ∪ V_B ∪ V_Con all, with the same frontier and version id. -
Delta closure: every
<-prefix ofΔunited withV_Ais causally closed (so the receiver never buffers from one honest sender). -
Interruption safety: cutting the op stream after any
kops leaves A at a causally closed version⊆ V_A ∪ V_B; a subsequent full sync reaches property 1. -
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 resultingV_Aequals property 1's result. (Generator: histories with deliberate own-chain violations, as in testown_chain_violation_is_detected.) -
Duplicates and reordering: delivering
Δduplicated and shuffled through the buffer yields the sameV_Aandpending_len() = 0at the end. -
Semantically invalid ops replicate: an op that is
invalid-op(2.2) arrives on every replica and produces the sameinvalid-opconflict everywhere. - 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.
- 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.
-
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. -
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-moveconflict. -
Refs are inert: creating, moving or publishing a ref leaves the op set, every version,
State,ConflictsandProjectionunchanged (extends 3.5 #9). - 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.
-
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-moveconflict heads. -
Stamp order extends causality: if move
m2's author knew movem1(orm1 ∈ supersedes(m2)), thenstamp(m1) < stamp(m2). -
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. -
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-moveconflict listing both heads; a later move or delete superseding both heads leaves one head and no conflict everywhere. - 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).
-
FF at authoring: authoring a move of a published ref to
Twithdownclose(T) ⊉ downclose(C)is refused and leaves the move set unchanged; for local refs any target is accepted. -
Publish hides private history (#17): after
publish(n), the only record ofnthat any other replica ever receives from the publisher's local history is the single fresh first move withsupersedes = []; no earlier local target or stamp appears remotely. - Stamps survive restart: persist, restart, author a move: its stamp is greater than every stamp and op counter known before the restart (no reissue).
-
Permanent pending is uniform (#17): a move whose target contains an op every honest replica rejects (4.5), or whose
supersedesnames a stamp that never arrives or was rejected, stays pending on every honest replica and never affects heads, value or conflicts. -
Name resolution (D-013): with local
nand publishednboth present,nresolves to the local ref andpublished/nto the published one; after deleting the local ref,nresolves to the published one. -
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
lastvalof the ⊥ heads is refused at authoring and rejected identically on every honest receiver in any delivery order; an FF recreate is accepted everywhere. -
FF against all heads (D-018, #20): with a
ref-moveconflict of k heads, a move FF w.r.t. the winning head but not w.r.t. some other head (including a ⊥ head'slastval) 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. -
Publish onto existing (D-016, #20):
publish(n)withpublished/npresent authors a move superseding exactly the current heads, is refused when non-FF, and leaves the local refnin place. -
Reserved prefix (D-017, #20): creating or renaming a local ref to a name starting with
published/fails; no such local ref ever exists.
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, soK = ∅andΔ = [a1, b1];a1is a duplicate no-op at A. Pull B←A:K = ∅,Δ = [a1, a2]. NowV_A = V_B = {a1,a2,b1}, F[(2,A),(2,B)](short idaadfb58d). - 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 sendsHave{[(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 sendsHave{[(2,B)]}; C now holdsb1,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.
- After
a1synced, A creates refmain→[(1,A)]: movem0 = {published, main, [(1,A)], stamp (2,A), supersedes []}(Lamport = 1 + max(1) = 2), and publishes it; B receivesm0. Heads{m0}, value[(1,A)]on both. - Concurrently (no sync): A authors
a2, then movesmain→[(2,A)]:mA, stamp(3,A)(1 + max(op counter 2, move 2)), supersedes[(2,A)]. B authorsb1, then movesmain→[(2,B)]:mB, stamp(3,B), supersedes[(2,A)]. - 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), somain = [(2,B)]on both replicas regardless of which side received first. Both derive the conflictref-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 showsState({a1,a2,b1}). - 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 idaadfb58d, 4.8) on both. -
Deletion vs concurrent move (#17). From step 4 (both hold heads
{mR},main = [(2,A),(2,B)]), concurrently: A deletesmain:d = {published, main, ⊥, stamp (5,A), supersedes [(4,A)]}(1 + max(op counter 2, move 4) = 5). B authorsb2 = (3,B)with deps[(2,A),(2,B)], then movesmain→[(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), somain = [(3,B)]on both (the ref exists) with conflictref-move(main, heads [d → deleted, mB2 → [(3,B)]]), surfaced inbarocss-hub status(D-011). Had A's stamp been greater (e.g. A had authored more ops first),mainwould 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,mainabsent, no conflict. Non-FF receipt: if a buggy B had instead sentmX = {published, main, [(1,A)], (5,B), supersedes [(4,A)]}, every replica checks it againsttarget((4,A)) = [(2,A),(2,B)]:{a1} ⊉ {a1,a2,b1}, somXis rejected everywhere (warning naming B), heads stay{d}andmainis absent on both, independent of arrival order. -
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 authorsb2 = (3,B)with deps[(2,B)]and tries to movemain→[(3,B)]. FF against the winnermB:{a1,b1,b2} ⊇ {a1,b1}✓, but againstmA:{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 droppeda2frommain). 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. -
Recreate after delete (D-015, #20). In step 5's variant where
dwins (mainabsent, heads{d, mB2}withdgreatest, or after a later delete superseding both),lastval(d) = [(2,A),(2,B)]. Recreatingmain→[(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 syncedmAbefore moving,mBwould supersede(3,A), get stamp(4,B), and there would be a single head (no conflict): a sequential move, not a concurrent one.
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-moveconflict is shown as a warning inbarocss-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/nis 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-moveconflict 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.
- [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).
-
[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 ownV; 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
nwith resolvable move setM:-
heads(M) = { m ∈ M | no m' ∈ M with stamp(m) ∈ supersedes(m') }; -
value(n) = target(m*)wherem*= the head with the greateststamp(Lamport first, then ReplicaId; unique because stamps are unique); - if
|heads| > 1, the replica derives aref-moveconflict{ 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 sameM, 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 ofM, hence deterministic and convergent (4.7 #13).
-
-
[decided] (D-011) A derived
ref-moveconflict is shown as a warning inbarocss-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
mof a published ref is evaluated once every stamp insupersedes(m)is present in the local move set (otherwisemis pending, waiting for those records; senders send the whole move set of the ref, so honest histories resolve).mis valid iff:-
stamp(s) < stamp(m)for everys ∈ supersedes(m)(4.7 #17), and -
FF (D-012, D-015, D-018; updated by #20): define
lastval(r)=target(r)if≠ ⊥, elselastvalof the greatest-stamp record insupersedes(r), or ⊥ ifsupersedes(r) = [](the last non-deleted value, reached through the delete's ownsupersedes). Iftarget(m) ≠ ⊥, thendownclose(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 oncetarget(m)and everylastval(s)it needs are resolvable (in the localV); until thenmis pending, not rejected, so the verdict never depends on op arrival order. The verdict depends only onmand 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 samelastval. An invalid move is excluded fromMentirely (it neither supersedes nor can win) on every replica and produces a local warning naming the peer (as D-008), shown inbarocss-hub status; it is kept only for that warning. A move whosesupersedesnames 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 onsupersedesand 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 sameM. 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).
-
Problem (observed behavior, #45 Review repro): two copies of one
.hub/share replica idr. Each records an edit offunder the same OpId(c, r)with different content and syncs only with a server. The server ends withf=A, the copy withf=BB, both at head98736884, no warning. Cause: the delta (4.3) is id-based. The copy'sHavenames(c, r), the server holds an op with that id, so(c, r) ∈ Kand 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 LtHash16D(S) = Σ_{o∈S} E(h(o))for partial replicas, below), kept per author replica (D_r(S)over the ops ofSauthored byr) plus a count. Additive, so it is maintained incrementally (D(S ∪ {o}) = D(S) + h(o)) andD(K) = D(V_B) − D(Δ)costs only|Δ|. The answerer sendsD_r(K)for everyr; the requester compares withD_r(downclose(F_A ∩ V_B))of its own store. -
(b) Per-replica hash chain. Each
r's ops, in counter order, chainH_r(c) = SHA-256(H_r(prev) ‖ h(o));Havesends(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 samer-ops belowc(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 indownclose(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_selfand compares it with the server'sH_selfat 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 windowWabove a server-provided baseB(a snapshot/checkpoint) can useD_r(B ∪ W) = D_r(B) + D_r(W)withD_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 sinceB, which are exactly the ops a copied thin client can diverge on. A thin client that holds nothing ofrcompares nothing forr. 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 forD(K), and oneSumsframe of#replicaslines (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.
-
(a) Frontier content digest. Per-op hash
- 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^256is 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 (contextbarocss-hub 2026-10 lthash16 op v1) overh(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-opfile://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 ofKin<order, using fixed position chunks of 16 384 ops; each chunk digest is SHA-256 over that author's op hashesh(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, soKis 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 providesD_r(B)with a base snapshot, and a thin client checksD_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.
-
Full replicas compare a sequence digest on the wire. For each author
- Dependencies:
blake3 = 1.8.2(with arrayref, arrayvec, cfg-if, constant_time_eq; cc at build time) plus the existingsha2. 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 sendsSums { resolved, D_r(K), n_r(K) }(featuredigest). The requester computes its own sums overdownclose(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
Opsnever triggerNeedBlobs; 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 replicarthe requester sendsNeedfor its ownr-ops indownclose(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 untilreid(D-025). Honest peers always produce evidence: a mismatch implies an op indownclose(resolved)with two payloads, held by both. A mismatch without evidence (a buggy or lying peer) ends the session withprotocoland 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 toreidin the copy. An already-known equivocation (warning already saved, e.g. afterreid, D-024: ops recorded under the shared id cannot be re-authored) does not stop later sessions again: the divergence stays visible inwarnings/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 untilreid. -
Old peers: gated by feature
digestin bothHellos. A pre-#46 peer never receivesSums(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-clidigest.rs(op hash,Sums, parallel hashing on up to 8 threads),session.rs(answersendsSums;compare_sums,localize;persiststops on a new equivocation); hub-corereceiveadds the DuplicateReplica warning when the equivocating op is authored under our own id. The answerer'sKisV_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 ranreid): 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), sosyncexits 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 withduplicate-replica,warningslists the equivocation and the duplicate replica, and the copy's log is unchanged; (#29) mutation: with the digest comparison disabled, (#28) fails.
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 offunder the same OpIdX=(c, r). The server keeps the original'sX'. The copy keeps its ownX(keep-first, 4.5), its sync stops withduplicate-replica(4.12), and after a plainreidevery push ofXis 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 (gitcommitof a working tree,diff/applyof a snapshot) is parent-independent. -
Decision-proposal: re-author by effect, not by op replay. Replaying
X's payload under a new id would keepX's deps, which may name ops whose meaning differs on the server. Rescue instead:-
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
equivocationwarning (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).Ris causally up-closed, so the store minusRis causally closed. A pushed-but-not-yet-rejected op under the shared id is not inRoffline (nothing persisted says it diverged). Online confirmation: after the local stages, rescue runs onesyncwith 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 runreid --rescueagain; the rerun'sRis exactly those ops.
- each op named by a saved
-
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("runbarocss-hub checkoutfirst"), so it never mistakes pulled-but-not-materialized ops for deletions. Ignored paths (.hubignore) are not touched or recorded. -
Remove
Rfrom the local store (ops, checkpoints whose frontier names an op ofR, published records likewise), take a fresh replica id, rebase the index onto the new head (the state of store minusR, 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 aSetContenton the existing node; a file created inRbecomes a newCreate; a delete or move done inRis reproduced by its effect (delete, or move/rename as record detects it). -
Same-file ordering. The recorded ops are concurrent with the server's
X'(the copy never heldX'when it recorded offline). Their Lamport counters are floored above the largest counter of the store before removal (so aboveX', which hasX's counter): content's last-writer rule (2.3) picks the rescued content deterministically, andX'stays in history (log --path, a content conflict listing the overwritten version). With a text CRDT (#48) this becomes a merge.
-
Rescue set
-
Stages and crash safety (the #33 bar, Appendix A.1). Under the exclusive lock; refused while
watchruns. A rescue directory.hub/rescue/<ts>/holdsmanifest(headerbarocss-hub/rescue/v0: old and new replica id, counter floor,R, the backed-up files) andfiles/(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, plainreid) refuses withblockedwhile 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 minusR(and every removed op's encoding into the manifest) intofiles/, flushed; thenmanifest(temp + fsync + rename + dir sync); thencurrent(atomic). A crash beforecurrentleaves an orphan directory that the next rescue removes; nothing else changed.-
rescue-ckpts:checkpoints.logandpublished.logrewritten without the checkpoints that nameR(atomic). Valid with eitherops.log. -
rescue-snapshot:.hub/snapshotdeleted (a cache, A.2.3). -
rescue-ops:ops.logrewritten 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. -
rescue-reid:replica= the manifest's new id (atomic). -
rescue-index+rescue-record: index rebased onto head, then a normalrecord(blobs first, then ops, then index). Rerun after a crash rebases onto the head again, so a record that already landed records nothing. -
rescue-done:duplicate-replicawarnings and theequivocationwarnings namingRdropped fromwarnings.log,reported::forgetof the entries forR,donewritten in the rescue directory, thencurrentremoved. Only now is the DuplicateReplica block (D-022) cleared. A rerun withcurrentpresent resumes from the manifest (stages 1-6 recomputed from disk); a rerun after success findsRempty and does nothing (nothing to rescue), and, if a DuplicateReplica block is saved, it is a plainreidthat clears it (an untouched copy).
-
-
--dry-runprints 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 develop714faf0). A pre-#49 binary does not honorcurrent, 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 ofRare 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 (stagerescue-ckpts-restored) as private checkpoints on the rescued head with the same message and author and a noterescued 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
currentis written (and again on every resume),replicagets the headerbarocss-hub/replica/v1-rescue(same id). A #49+ binary reads it as v1 (and refuses mutating commands throughcurrent); pre-#49 binaries fail with "unsupported repository format 'v1-rescue'" before reading or writing anything else. Stage 6 restoresbarocss-hub/replica/v1with the new id before removingcurrent(crash in between: the rerun resumes). A marker withoutcurrent(crash between the two writes, nothing else changed) is cleared by the nextreid --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;watchkeeps running but refuses every step and writes only itswatch.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-runis allowed whilewatchruns (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
checkoutonly 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
Rare 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.
-
Checkpoints (R1). Checkpoint records (
-
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;checkoutwrites nothing) andstatusshows the content conflictX'overwritten by the rescued op. An old binary (develop714faf0) 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.
On its own page: Text-CRDT.
.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>.tmpin 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.
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: plainfsync(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: onefcntl(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 (stdsync_all). -
log sync: stdsync_data(Linuxfdatasync, macOSF_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, thenreplicalast. Crash beforereplica: not a repo;barocss-hub initagain. -
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; wasdsync(blobs/), same effect, now logged byHUB_TEST_FLUSH_LOG); existing hash-valid blob files are flushed into the batch, never trusted as durable (A.2.8) → append all op lines toops.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: onlytmp-*files (possibly truncated by a power loss); swept by the rerun. No op exists, so the repo is the previous version. - Crash at
blobs-renamedor 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, sobarocss-hub recordrefuses (not at head). Recovery isbarocss-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.
- Crash at
- 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-renamedis 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-opHUB_TEST_CRASH_IN_SYNC=<n>still aborts before op n) →pending.log,warnings.logwrite_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 oldHUB_TEST_CRASH_IN_CHECKOUT=1aborts 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 realfsyncof that directory, on macOS a plain fsync made durable by the following barrier → one barrier → index write_atomic (oldafter-indexhook) → pinned write/clear (oldafter-pinhook) → 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 checkoutresumes (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 hookHUB_TEST_FLUSH_LOG=<file>lists the flushed directories.) A file the user removes while an apply is running (e.g. underbarocss-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.
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
Orecords written by one append (onerecord, one mkdir/touch/mv/rm, one side of onesync, 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 ofOrecords in the batch (>= 1; a batch is never empty). -
chain: 16 lowercase hex = the first 8 bytes of SHA-256(chainof batchseq-1(16 ASCII hex;0000000000000000for seq 1) ‖ the 8-hexcrcof eachOrecord 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
Olines, then itsBline (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), theOlines already written get aBrecord 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):
- 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
Orecords followed by aBwhoseseq,countandchaincheck out. Only committed batches are applied. - The rest of the file after the committed prefix is the uncommitted tail. If the tail contains a self-consistent batch (a crc-valid
Bpreceded by exactlycountcrc-validOlines 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. ABrecord inside the tail whose seq, count or chain does not match is not self-consistent. - 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,Olines with noB, aBwhose batch lost a line, and zero-filled holes (NUL bytes) anywhere in the tail.
- 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
-
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
idrepeated 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'sknownset are rebuilt from the applied-op log; the pending set is always empty in.hub/(ops with missing deps are corruption, sync buffers them inpending.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 fromcheckpoints.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'sBline. -
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.loglength and the log's bytes just before that offset are exactly the framedB <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, orHUB_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.logand 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.tmpwrite + flush → [snapshot-written] → barrier → rename tosnapshot→ [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.tmpis 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, withSNAP_MIN = 4096,SNAP_DIV = 16[decision-proposal]; always right after a migration (step 5, still under the exclusive lock) if the log has at leastSNAP_MINops. 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>overridesSNAP_MIN(0= after every write, used by tests) andHUB_NO_SNAPSHOT=1disables 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), readreplica, compare its first line withbarocss-hub/replica/v0. A v1replicafails there withcorrupt .hub/replica, beforeops.logis 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 returnsmissing or wrong headerbefore any truncation. -
hub peer add|rm|listdo not open the repository: they readpeers, whose v1 header fails the v0 check (corrupt .hub/peers) before anything is written. -
hub watchtakeswatch.lock(already present in v1, not modified), writeswatch.pid, then fails readingpeersand removeswatch.pidon exit. Net effect:.hub/byte-identical. -
hub initin a v1 repository refuses (already a hub repository);helptouches nothing. The v0 message is not specific ("corrupt"), because the v0 binaries cannot be changed; v1 binaries refuse newer versions withunsupported 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.
- Read
replica(v0) andops.logwith 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. - 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]. -
Switch: write
.hub/.replica.tmp= v1 replica file (same id), flush → [mig-replica-tmp] → rename overreplica→dsync(.hub/)→ [mig-switched]. From here on the repository is v1 and v0 binaries refuse it. - Roll forward: rename
ops.log.v1overops.log→dsync(.hub/)→ [mig-log-renamed]; writepeerswith the v1 header (write_atomic; keeps the peer list) → [mig-peers]; createwatch.lockif missing, writeformat(write_atomic) → [mig-format]. - Take a snapshot (A.2.3) if the log has at least
SNAP_MINops, still under the exclusive lock (also when the migrating command is a reader). Crash safety. Crash before step 3's rename:replicais still v0; v0 binaries work normally (they may even append to the v0 log); a v1 binary restarts the migration from step 1 and overwritesops.log.v1/.replica.tmp. Crash after the rename:replicais v1; on every open a v1 binary checks for roll-forward work (ops.log.v1present;peersmissing or v0;watch.lockorformatmissing) 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 v1ops.logunder a v0replica. 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.
-
Trecord:O <id> <deps> T <node> <epoch> s=<seen> d=<del> i=<ins>(grammar, canonical form andh(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 wireOpsbody 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-rescuewhilereid --rescueruns; replacesv1-rescuein a v2 repository) andformat=barocss-hub/format/v2\n.formatmay still say v1 under a v2replica(roll-forward pending); v2 under a v1replicais corrupt.reidand the rescue marker keep the repository's format. -
Lazy upgrade: the repository stays v1, byte for byte, until the first
TextEditis about to reach any.hub/file (appended byRepo::append_ops*, or received bysyncintoops.logorpending.log). Then, under the exclusive lock, before anyTrecord is written:.replica.tmp(v2 header, same id) → flush → [v2-replica-tmp] → barrier → rename overreplica→dsync(.hub/)→ [v2-switched] (commit point) →formatv2 (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) rollsformatforward. Idempotent. -
Old binaries: pre-#54 binaries (tested: develop 05a1be0) read
replicafirst and refuse a v2 repository withunsupported repository format 'v2'before reading or writing anything else; every command that opens the repository leaves.hub/and the working tree byte-identical (testold_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; payloadRSNAP2= 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 invalidTextEditsets; per text node the winning epoch and head blob id, itscontentconflicts andconcurrent-editnotices, 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 inblobs/with its length (onestateach; 10 000 seeds in 0.04 s), elsewarning: 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 ascorrupt(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 ofOpsheader room) is refused withlimitand nothing is written; a save larger than that becomes a chain ofTextEdits 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; thev2-rescuemarker).
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.
pullsends eachOpsbatch without bytes (answer_ops); the receiver answers with the blob ids it lacks among the batch'sSetContentops (lacking_blobs, aNeedBlobsstep); only those bytes are fetched and received before the batch's ops. The same holds for theNeedround. The Issue #21answer(all bytes attached) remains in the core for tests. §4 semantics are unchanged: a blob is still verified by SHA-256 on receipt and aSetContentis 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
readdirofblobs/(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'sflush(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 theNeedBlobsstep, still means only: named by a committed op (trusted by the A.1 invariant, checked by onereaddir) or hash-verified in this process; an unreferenced file is requested again and then goes throughaddas above. Testsrerun_after_blob_crash_barriers_blobs_before_ops(record and sync, every blob stage; the rerun is aborted atops-appendedand must already have logged a blob barrier and a flush of.hub/blobs) andintact_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 oneflush(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 andaddabove 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, testsmany_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.logwrite_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 vsab/-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
putrewrites corrupt files. Tested with a 2c7dd10 release binary (#34 evidence). - Instrumentation:
HUB_TEST_SYNC_STATS=<file>appendsblobs-in this|peer <bytes> <count>per sync.