Skip to content

Remote Sync

easylogic edited this page Oct 15, 2026 · 13 revisions

Remote-Sync

Remote transport for the §4 sync model, and wire protocol v1. Issue #42. Inputs: D-037, D-038, D-039, D-040, D-041, D-042, D-043, D-044, Repository-Semantics §4 (messages §4.2, delta §4.3, resume §4.4, validation §4.5, checkpoints §4.6, refs §4.11), Appendix A (A.1 barrier order, A.2 format v1), CLI-JSON, and the code on develop a28c722 (hub-core/src/sync.rs: Have, Ops, Need, CheckpointsMsg, answer_ops + lacking_blobs = the #34 NeedBlobs step, receive_blob; hub-cli/src/sync.rs: sync_with, persist, dup_warning, the canonical-path lock order).

This page is Repository-Semantics §5 Remote transport (it extends §4 and does not change it); Repository-Semantics §4 links here. Labels: [decided] (with a D-link), [decision-proposal] (this spec's proposal; Review/Mentor may accept), [open question], [escalation] (a new product choice for the human; not decided here). Implementation is #43; the background service is #44. Repaired after Review (#42 REPAIR R1–R6).

5.1 Scope

  • [decided] (D-037) The message exchange is transport-independent; SSH is implemented first (barocss-hub remote add origin ssh://host/path, barocss-hub sync origin, remote end barocss-hub serve --stdio); HTTP is designed here but implemented later.
  • [decided] (D-038) Topology is a central, always-on replica on a server; every client syncs with it. P2P is deferred.
  • [decision-proposal] The protocol is still symmetric: both ends are ordinary replicas running the same §4.5 validation; "client" only means "the side that opened the connection and sends the first Have". Nothing in v1 depends on the server being central, so P2P later needs no new messages.
  • Refined by Online-First (D-048, #47): the protocol stays symmetric between full replicas, but a partial replica only talks to a full replica that serves it (6.1.1). "Partial clone" below is superseded: thin clients are specified in 6.2.
  • Out of scope (explicit): the HTTP implementation (only its design, §5.3.2); P2P discovery/relay topology; ACL enforcement (§5.9.6 is an open question); blob packs (D-035); partial clone; in-protocol repo selection (forbidden, §5.9.1).

5.2 Wire protocol v1

5.2.1 Framing [decision-proposal]

  • A connection is a pair of ordered, reliable byte streams (stdin/stdout of serve --stdio, an in-memory pipe in tests, later an HTTP body). No other assumption is made about the transport.
  • Preamble: each side first writes the 8 bytes BHUBWIRE (magic), then frames. A peer that reads anything else closes with no reply (catches a login banner or a shell motd leaking into stdout, and a non-barocss-hub program answering).
  • Frame: len: u32 big-endian (length of type + payload, 1 ≤ len ≤ max_frame) · type: u8 · payload: len-1 bytes. No padding, no compression in v1.
  • Payload encoding: UTF-8 text, fields separated by one space, list items by ,, using exactly the Appendix A token forms (s<escaped> strings, <counter>@<replica32hex> op ids, 64-hex blob ids, frontier lists), and op bodies are the ops.log record bodies (O <id> <deps> <payload>, without the tab-crc; the frame length replaces the newline). Reason: one parser for disk and wire, already fuzzed by the log tests, and a byte-exact deterministic encoding (a frame sequence is a pure function of the two replicas' states and the batch size), which is what makes the in-memory transcript tests of §5.10 possible. Blob chunk payloads (Chunk) are the only binary payloads.
  • Determinism: senders emit ops in < order, blob ids sorted ascending, checkpoints in published.log order; nothing depends on timing, hash-map order or wall clock except the optional Progress frame, which tests ignore.

5.2.2 Frame types [decision-proposal]

type name direction payload maps to
0x01 Hello both, first frame see 5.2.3 —
0x02 Have requester frontier vv_hint|- §4.2 Have
0x03 Ops answerer sender_frontier done(0|1) then \n-separated op bodies (≤ max_ops_per_frame) §4.2 Ops (without blob bytes, as answer_ops)
0x04 Need requester op id list §4.2 Need
0x05 NeedBlobs requester sorted, deduplicated blob id list #34 step (lacking_blobs)
0x06 BlobBegin answerer blob_id size offset streamed blob, 5.2.4
0x07 Chunk answerer raw bytes (≤ max_chunk) followed by 32 bytes SHA-256 of those bytes 5.2.4
0x08 BlobEnd answerer blob_id 5.2.4
0x09 NoBlob answerer blob_id answerer lacks it (it is pending there too)
0x0A Checkpoints both \n-separated K … records (published only) §4.2/§4.6
0x0B RefMoves both \n-separated move records §4.2/§4.11; only if feature refs negotiated
0x0C Done both phase name (ops, blobs, meta) end of a phase
0x0D Committed receiver→sender (the side that requested and persisted) frontier that the receiver made durable; with feature rejected-ops (#45) followed by \nR <op-id> <reason> lines for the ops of this half it rejected (equivocation, lamport-violation) durability ack, 5.4; rejected-op feedback, 5.2.7
0x0E Error both code s<message> fatal(0|1) 5.2.6
0x0F Progress both phase done total|- UI only, optional
0x10 Bye both — clean close
0x11 Sums answerer * or resolved ids, then <replica> <count> <checksum> lines #46 divergence digest (5.2.8); only if feature digest negotiated
  • Unknown frame types: a fatal Error protocol unless the type is in a feature the peer announced (5.2.3). So adding types needs a feature flag or a protocol bump.

5.2.3 Handshake and capability negotiation [decision-proposal]

  • Hello { proto_min, proto_max, format, format_minor_max, replica, features, limits }, text: proto 1 1 format 1 0 replica <32hex> features blobs-chunked,checkpoints,resume limits frame=<n> chunk=<n> ops=<n>.
  • Both sides send Hello immediately (the client does not wait), then read the peer's. The session protocol is min(proto_max_a, proto_max_b) if ≥ both mins, else Error version-mismatch (fatal) naming both ranges, and close. v1 defines only protocol 1.
  • format is the on-disk major format (D-029, A.2). The wire carries logical records, not files, so different on-disk formats can sync as long as each side can encode/decode the v1 record bodies; a server whose repo has a newer major format than its binary understands refuses to open it (corrupt, as locally today) before Hello.
  • features: the intersection is in force. v1 features: blobs-chunked (mandatory in v1), checkpoints (mandatory), resume (blob offset resume, 5.2.4), refs (RefMoves; not implementable yet: refs are specified in §3.8/§4.11 but not in the code on develop, so #43 omits it), progress, rejected-ops (#45, 5.2.7), digest (#46, 5.2.8). text (#54, Text-CRDT 7.9, D-053 E-T6): the side can encode and hold TextEdit (T records, Text-CRDT 7.4.1). In force only when both Hellos carry it. The answerer never sends a TextEdit to a peer without it: if an Ops batch it is about to send (the delta or a Need answer) contains one, it ends the session with Error upgrade-required (fatal). For a pull from a text server the old client has persisted nothing of that half; for a push to an old server (mixed remotes: a client with a text remote and an old one), the push ends the session the same way. file:// uses the same session code. A TextEdit received without text negotiated is unsupported (fatal). Old peers ignore the unknown feature name. Record size (#54): a replica never authors an op record larger than TEXT_OP_MAX = 192 KiB (FRAME_FLOOR minus 64 KiB of Ops header room), so its records reach every conforming peer; larger saves are chained (Text-CRDT 7.4.1). Tests: crates/hub-cli/tests/text_t2.rs, also with the pre-#54 05a1be0 binary as client and as server.
  • limits: each side announces what it accepts; a sender must stay within the peer's limits (5.2.5). Floors (R4): frame ≥ 256 KiB + 64, chunk ≥ 16 KiB and chunk ≤ frame - 33, ops ≥ 1; a Hello below a floor or above the hard caps (frame 4 MiB, chunk 1 MiB, ops 65 536) is fatal protocol. With frame ≥ 256 KiB every legal op record fits in one frame (5.2.5 name limit).
  • Replica identity check (D-022), repaired R5: if the peer's replica equals our own, send Error duplicate-replica and end this session only. Nothing is persisted: the Hello replica is an unauthenticated claim, so a lying client (e.g. announcing the server's own id) must not be able to write warnings into the central server. A DuplicateReplica warning is persisted only from op-level detection (§4.5: an op authored under our id that we never authored), which is evidence, not a claim. The client side, which chose the URL, may show the refusal but also persists nothing from Hello. (Differs from local dup_warning, which is safe locally because both repos are on the user's disk.)
  • No repo id in Hello: the repo is fixed by the SSH command (§5.9.1). [open question] A repo identity (e.g. the root op id) to refuse syncing two unrelated repos; today nothing prevents it locally either.

5.2.7 Rejected-op feedback (Issue #45) [decision-proposal]

  • Problem: a pushed op that the receiver rejects (equivocation: the id is already held with another payload; Lamport violation: a dep not < the op) was only a D-008 warning on the receiver. The sender exited 0 and never learned it.

  • Rule: after the persist step of the half it requested, the receiver computes the rejected ops of that half from the replay's verdict (R1b): every received op that is neither in the applied set nor in the pending buffer; equivocation if the id is held with another payload, else lamport-violation. Ops still pending (missing deps) are not rejections. If both Hellos carry rejected-ops, it appends them to its Committed frame as lines R <op-id> <reason> (reason [a-z-]{1,64}, at most max_list lines; anything else is fatal protocol).

  • Compatibility: the feature gates the extra lines. A pre-#45 peer's decoder would refuse them, so they are never sent to it; a pre-#45 server never sends them, and the client reports "rejected_reported": false (it cannot know). No protocol bump.

  • Client: each rejection is a warning: line naming the op, the reason and what to do (reid for equivocation, upgrade/redo for Lamport); sync --json has "rejected": [{op, reason, message}] and "rejected_reported"; watch emits a rejected event; the VS Code extension shows a warning notification. [decided] (D-045) sync then exits 3 (EXIT_REJECTED, distinct from 1 = error and 2 = no cwd) with its normal report: everything else of the session was committed, so stdout is the full human report or --json document (rejected non-empty) and stderr has the warnings, never an error document. watch, the D-044 service and VS Code are not affected: watch emits the rejected event, keeps running and does not back off; VS Code shows a warning and treats a sync exit 3 with a valid document as a result (no background service exists in the repo yet; when it is added it must run watch or treat 3 as a warning). Against a pre-#45 server (rejected_reported: false) the exit is 0.

  • Hint (#49, D-049): the duplicate-replica error and the equivocation/rejection hints now say "run barocss-hub reid --rescue" (re-authors the copy's edits under a fresh id, Repository-Semantics 4.13) instead of "run barocss-hub reid, then redo the change". reid --rescue forgets the reported-rejection entries (5.2.7.1) of the ops it re-authors.

5.2.7.1 Each rejection is reported once per remote (Issue #50) [decision-proposal]

Source: user decision D-050 (2026-10-14; first given in the Hub Compute session, reconciled in the Planner session), split from #49 into #50. Local only: no wire change, no feature, no protocol bump.

  • Memory: .hub/reported-rejections (header barocss-hub/reported-rejections/v0, CRC-framed records like the other .hub logs, never replicated): R <local> <url> <exec> <op> <payload-sha256> <reason>. (local, url, exec) is the remote's #45 trust key (canonical local .hub/, normalized URL, exec word; for file:// the canonical target repository root). It's keyed by the trust key, not by the peer replica id from Hello: the replica id changes on the server's reid and can be shared by a copy (D-022), while the trust key names what the user configured and confirmed. Since the local .hub/ is part of the key, a copied or moved repository has no memory and reports again.
  • Match: a rejection is already reported iff a record has the same key, op id, SHA-256 of this repository's text encoding of that op (store::encode_op, not the binary 4.12 hash), and reason. Anything else is new: another op, another payload under the same id (a new equivocation), another reason, another remote. duplicate-replica is never remembered or suppressed. An op not held locally isn't remembered.
  • Behavior: sync warns and exits 3 (D-045) only for new rejections. If every rejection is already reported, it exits 0 and prints nothing about them (human mode); --json still lists all of them in rejected, each with already_reported. watch emits rejected events (and VS Code notifications) only for new ones. The memory only dedups the rejected-op report: the D-008 equivocation warnings, the D-022 DuplicateReplica refusal and the #46 localization (which stops the session on a new equivocation) are unchanged.
  • Durability: the memory is written only after the report was printed or emitted, under the exclusive .hub/lock: temp, flush, barrier, rename, directory sync (crash stages reported-tmp, reported-renamed). A crash before the rename loses only the memory, so the report repeats; it never suppresses a report that wasn't shown. A corrupt or torn file is ignored, which also just means repeats. A write failure is a warning, not a sync failure.
  • Listing and clearing: barocss-hub warnings lists them (reported rejection (suppressed): …; --json: reported_rejections). barocss-hub warnings --clear-reported [<remote>] forgets all of them, or one remote's, so they're reported again. reported::forget(dir, locked, pred) is the hook #49's reid --rescue uses to drop the entries it resolves.
  • Old binaries and peers: a pre-#50 binary never reads or writes the file (verified: c8be24d ran status, warnings, log, record, sync and reid on a repository with the file, and the file's hash was unchanged). A pre-#45 peer reports no rejections, so nothing changes.

5.2.8 Divergence digest (Issue #46) [decision-proposal]

  • Feature digest. Only when both Hellos carry it.
  • Frame 0x11 Sums, answerer → requester, sent right after receiving Have and before the first Ops. Payload: first line * (every id of the requester's frontier resolves here) or the resolved id list (F_A ∩ V_B, - if empty); then one line per author replica with ops in K: <replica 32hex> <count> <checksum 64hex> (sorted by replica, ≤ max_list lines). checksum = the per-author sequence digest S_r(K) of K = downclose(resolved) (SHA-256 over 16 384-op position chunks in < order; Repository-Semantics 4.12). Review R2 replaced the earlier plain 256-bit sum (vulnerable to Wagner's attack; never released). The additive LtHash16 is reserved for partial replicas behind a future feature. The canonical op hash input is length-prefixed binary (id, sorted deps, payload tag and fields), independent of the text encoding.
  • Requester: compares with its own sums over downclose(resolved). Mismatch → localization with ordinary Need frames (newest first, ≤ 1024 ids per round, stop at the first new equivocation), then Done ops; at persist, a new equivocation stops the session with duplicate-replica after persisting only the warnings. No new frame is needed for the evidence: the peer's own Ops answer is the evidence (R5).
  • Review R1: when one side stops with a fatal Error and closes while the other is still writing, the writer reads the buffered Error frame and reports the peer's code (for example duplicate-replica with the reid hint), not io; file:// also prefers the far end's error.
  • Both halves use it, so the server also detects divergence in the push half when the pull half did not stop first.
  • Old peers: no Sums, no detection (as pre-#46). An old peer ignores the unknown feature name in Hello; it never sees frame 0x11.
  • Cost: hashing every op once per side per session (derived at load, nothing stored), one Sums frame per half.

5.2.4 Session flow and chunked blobs [decision-proposal]

A sync is pull then push over one connection, each half exactly the #34 in-memory pull:

  1. Hello ↔ Hello.
  2. Pull half (client requests): client Have → server Ops…(done=1), frames in < order. After each Ops frame the client computes lacking_blobs and sends NeedBlobs; the server streams each requested blob; the client sends Need for missing deps (4.4) if any. Client sends Done ops.
  3. The client persists (5.4) and sends Committed <frontier>.
  4. Push half (server requests): the same with roles swapped — server Have → client Ops… → server NeedBlobs/Need → server persists → Committed.
  5. Meta: both send Checkpoints (and RefMoves if negotiated), then Done meta; each side persists its received records and replies Committed.
  6. Bye ↔ Bye.
  • Blob streaming (tightened, R4): BlobBegin id size offset is accepted only if id is in the receiver's outstanding NeedBlobs set (unsolicited id → fatal protocol), offset ≤ size and offset equals the requested offset (else protocol), size ≤ max_blob_size (else limit); a Chunk that would carry the total past size is fatal protocol, and BlobEnd before size bytes is fatal protocol. One blob in flight at a time; BlobBegin id size offset · Chunk* · BlobEnd id. The receiver writes chunks straight to blobs/tmp-* (never buffers a whole blob; this is the #34 RSS note), checks every chunk's SHA-256 trailer (a bad chunk = transport damage: fatal Error corrupt, temp discarded), and at BlobEnd checks the whole-file SHA-256 against the id; a mismatch is a CorruptBlob warning for that peer (D-021), the file is dropped, and the ops that need it stay pending, exactly as receive_blob today.
  • Resume inside a blob (feature resume): a receiver keeps a partially received temp as blobs/part-<blobid> (flushed, not durable-promised). In the next session's NeedBlobs it may write id@offset; the sender starts at that offset and the receiver re-hashes the kept prefix before appending. A kept part may have come from a different peer, so when the whole-file hash fails after a resumed transfer (offset > 0), the receiver deletes the part and re-requests the blob once from offset 0 in the same session, with no warning (R6); only a mismatch of a transfer that started at 0 yields CorruptBlob. Parts are swept after 7 days or when the blob exists ([open question] the age).
  • Chunk size default 256 KiB; the per-chunk hash costs ~0.1% of bandwidth and lets a resume trust a prefix.

5.2.5 Limits and timeouts [decision-proposal]

limit default on violation
max_frame (len field) 1 MiB (4 MiB hard cap in v1) fatal Error limit, close, before reading the payload
max_chunk 256 KiB fatal limit
max_ops_per_frame 1024 fatal limit
max_list (ids in Need/NeedBlobs/frontier) 65 536 fatal limit
max_blob_size (server config) 2 GiB Error limit non-fatal for that blob; ops needing it stay pending on the receiver
step timeout (no complete frame) 60 s; Hello 15 s fatal timeout
session wall time (server) 1 h fatal timeout
concurrent sessions per repo (server) 8; the repo lock already serializes persist steps Error locked (non-fatal, retry later)
rate (server) none in v1; [open question] per-key rate limits are left to sshd/MaxStartups —
  • Record size limit (R4) [decision-proposal]: a name is at most 4096 bytes (after the §1.3 rules; checked on the raw bytes), and a checkpoint message/note at most 64 KiB each. So an escaped op record (≤ 3 bytes per name byte + ~300 bytes of ids) and a K record stay far below the 256 KiB frame floor, and one record always fits in one frame. Enforced at authoring: record/watch auto-record (a working-directory name over the limit is skipped with a warning, like an unreadable file; record reports it), mkdir/touch/mv and commit -m/publish refuse with invalid-path (names) or usage (messages). On receipt, an op with an over-long name is accepted into the log as a §2.2 invalid op (conflict, never projected), as for other bad names. Already existing oversized records (possible only from pre-#43 binaries): the sender never splits a record across frames; if a single record exceeds the peer's max_frame, it sends fatal Error limit naming the op id, and the session ends with a clear message ("op X is larger than the remote accepts; run with a larger frame limit"). Nothing is silently skipped, because skipping an op would make every later op pending forever.
  • Limits are enforced before allocation (the length prefix is checked before reading the payload), so a hostile length cannot make the server allocate.

5.2.6 Errors [decision-proposal]

  • Error { code, message, fatal }. Codes reuse the CLI-JSON error codes where the meaning is the same: not-a-repository, corrupt, io, locked, duplicate-replica, bad-blob-id, invalid-path, error. upgrade-required (#54; also a CLI-JSON code): the peer lacks feature text and the ops it would receive include text edits (5.2.3). New wire-only codes: protocol (malformed frame, unknown type, frame out of order), version-mismatch, limit, timeout, unsupported (feature used but not negotiated), forbidden (path confinement, §5.9).
  • A fatal error closes the connection after the frame; the peer keeps everything it has already made durable (5.4). The CLI maps a remote Error to the same code in its own --json error ({"error":{"code":…,"remote":true}}) and exit code 1; transport failure (ssh exits non-zero, EOF) is io with the ssh stderr tail in the message.
  • Op-level problems are not errors: equivocation, Lamport violation, corrupt blob and invalid op go through §4.5 (5.8) and become warnings/conflicts; the session continues.

5.3 Roles and transports

5.3.1 serve --stdio (SSH remote end) [decided: D-037; details decision-proposal]

  • barocss-hub serve --stdio <path>: open the repo at <path> (after confinement, §5.9.2), speak the protocol on stdin/stdout, log to stderr, exit 0 after Bye, 1 after a fatal error. It never writes a working directory and never reads user input.
  • It serves exactly one repo per process (§5.9.1) and holds the repo lock only during persist steps (A.1), so a local record on a non-bare server repo is not blocked for a whole session.
  • --root <dir> (else env BAROCSS_HUB_SERVE_ROOT, else serve.root in a server config; env ignored under a forced command, 5.9.6) sets the confinement root.

5.3.2 serve --http (designed, not implemented) [decided: D-037 later; design decision-proposal]

  • Extended by Online-First 6.5: serve --http becomes the single-writer daemon with live sessions and the JSON API; auth is required (E-B1 supersedes E-R3).
  • One POST /<repo>/sync per session, request body and response body each a stream of v1 frames (HTTP/1.1 chunked or HTTP/2), after the same preamble; frame semantics identical. Because v1 is request/response per half, a non-streaming fallback (one POST per phase carrying a session token) is possible later. Auth (TLS plus a bearer token, reverse-proxy auth or mTLS) is an [open question] (E-R3). Not implemented in #43.

5.3.3 Local file:// [decision-proposal]

  • file:///path runs the same protocol over an in-process pipe with a server thread on the other repo. Per D-041 it is the successor of peer; the existing direct sync_with path may remain the implementation for file:// until the protocol version lands, and the two must produce identical results (property P1).

5.4 Durability across disconnects [decision-proposal]

  • Rule: each side persists received data with the A.1 order before acknowledging it; Committed <frontier> is sent only after the persist step's final barrier/log sync. The sender treats nothing as delivered until Committed, and nothing depends on it anyway: resume is stateless (§4.4), K is recomputed from the next Have.
  • What each side persists, per half, as the local sync does today (A.1 "sync" and the "Sync persist order"): 1. blobs (direct writes of verified files, flush, flush(blobs/), one barrier; [blobs-written], [blobs-barriered]); 2. new applied ops appended in < order, one log sync ([ops-appended], [ops-fsynced]); 3. pending.log and warnings.log (write_atomic); 4. published checkpoints appended (and ref moves later). Under format v1 (A.2) step 2 is one commit-marked batch, so no partial batch is visible.
  • When: the receiver persists at the end of its half (after Done ops and all requested blobs), not per frame. [decision-proposal] Additionally, for large deltas it persists every persist_every = 64 MiB of received blob bytes or 100k ops, emitting Committed each time, so a drop late in a big transfer loses at most one window.
  • Drop mid-ops: nothing of the unpersisted window is on disk (it was only in memory), so the repo is the previous version; ops already Committed are durable. The rerun's Have carries the new frontier; the answer is V_B \ K again (4.3), so persisted ops are skipped and nothing is missed.
  • Drop mid-blob: complete verified blobs of the window were not yet flushed-and-barriered, so they may or may not survive; either way no op names them yet (orphan blobs are harmless, reused by the rerun, A.1). The partial blob is kept as part-<id> for resume (5.2.4) or discarded.
  • Drop between persist and Committed: the data is durable but the peer did not see the ack; harmless, the next Have shows it.
  • Superseded for a daemon-served repo by Online-First 6.1.2 (single-writer daemon, #47); the R1 model below still applies to serve --stdio without a daemon.
  • Concurrency model (R1) [decision-proposal]: persist-only lock plus reload-and-merge. develop's sync_with holds both repos' exclusive locks for the whole session; a remote session cannot, because it would block the server (and every other client) for as long as the slowest network. So serve takes the exclusive repo lock only for each persist step, and each persist step, under the lock: (1) reopens the on-disk state (ops, blobs, pending.log, warnings.log, published.log); (2) replays every op and blob the session received (in arrival order) through receive_ops/receive_blob against that reloaded state (R1b), so equivocation, Lamport, duplicate-replica, corrupt blob and pending promotion are evaluated against the merged state, not the snapshot the session loaded at start; a same-id op with a different payload that another session already persisted is therefore an equivocation with a D-008 warning, never a silent id-dedup drop. Then pending = reloaded pending ∪ replay's pending, minus ops now applied; warnings = reloaded ∪ replay's warnings (dedup by key, earliest first_seen); published = reloaded ∪ received (checkpoints re-filtered as §4.6). The session's earlier in-memory validation is only advisory (for early Need/NeedBlobs); only the replay's verdict is written; (3) writes with the A.1 order; pending.log/warnings.log are rewritten from the merged set, never from the session's stale snapshot. Consequences: concurrent sessions (up to 8) never lose each other's pending ops or warnings (P16); a session's Have may be stale by the time it persists, which is harmless (redundant ops dedup; a later Have fetches the rest); the Committed frontier is the merged frontier. The client keeps the develop model (session-long exclusive lock on its own repo), since only the local user is affected.
  • Lock model as built (#43, recorded by #45) [decision-proposal]:
    • file:// sync runs the v1 protocol in-process (client and server Side over an in-memory pipe) and holds session-long exclusive locks on both repositories, taken in canonical-path order (the smaller canonical .hub/ path first). Every file:// sync, from either end, takes the pair in the same order, so two concurrent a→b and b→a syncs cannot deadlock; one waits. Both repos are on the user's disk, with no network latency, so holding both for the whole session is cheap and keeps the develop semantics (no stale state, nothing to replay).
    • serve --stdio (the served end of ssh) uses persist-only locks with reload and replay (R1, R1b above): a shared lock to load, the exclusive lock only for each persist step. A session can be held open by a slow or hostile network client for up to the session wall clock; holding the server's lock that long would block every other client and a local record on a non-bare server.
    • The ssh client holds its own repo's exclusive lock for the session (as file://'s client end).
  • Server side is symmetric: in the push half the server is the receiver and follows the same order; a drop never leaves the central replica with an op whose deps or blob is missing (A.1 clause 2). Pending ops and warnings from a client are persisted server-side exactly as on a client.
  • No acknowledged data lost: the only acknowledgements are Committed frames and the command's exit 0; both happen after the barrier. barocss-hub sync origin exits 0 only after both halves and meta are Committed on both sides.

5.5 Remotes configuration and URL forms

  • [decided] (D-037) git-like commands: barocss-hub remote add <name> <url>, barocss-hub sync <name>.
  • [decision-proposal] Also remote list [--json], remote rm <name>, remote set-url <name> <url>. barocss-hub sync with no argument syncs the default remote.
  • [decision-proposal] Stored in .hub/remotes, local config, never synced, write_atomic under the repo lock, like peers: header barocss-hub/remotes/v0, one line per remote R s<name> s<url> <key=value>* (keys: exec=<remote binary path>, default=1, later http.token-file=). Remote names follow the §1.3 name rules plus no /.
  • [decided] (D-041) peers merges into remotes: the peer command goes away; a same-machine folder is remote add <name> file:///abs/path. Migration [decision-proposal]: the first command that takes the exclusive repo lock and finds .hub/peers (and no peers entries already in remotes) converts each peer root to a remote named local-<n> (n = 1.. in file order; if the basename is a valid unused name, that name instead), URL file://<canonical root>, merged with any existing remotes; writes remotes with write_atomic (fsync, rename, dsync); only then removes peers and dsyncs .hub/. Crash before the rename: peers intact, remotes unchanged, rerun repeats. Crash after the rename, before removing peers: the rerun sees peers again and merges idempotently (dedup by URL), then removes it. A read-only command (shared lock) reads both files without migrating. peer add/rm print a usage error pointing to remote. The watch started event's peers count becomes remotes (CLI-JSON change for #43).
  • [decision-proposal] Default name origin for the first remote add when no name is given, and the first remote added becomes the default; remote add --default changes it.
  • URL forms [decision-proposal]:
    • ssh://[user@]host[:port]/abs/path — path absolute on the server; ssh://host/~/repo is expanded by the server relative to the login home (then confined).
    • scp-like [user@]host:path — relative paths are relative to the login home (as git); recognized only if there is no :// and the part before : contains no /.
    • file:///abs/path and a bare local path.
    • https://host/path — reserved, refused with unsupported until serve --http exists.
    • Anything else: usage error. Userinfo passwords (user:pass@) are refused (§5.6).

5.6 SSH via the system ssh binary [decided: D-037; details decision-proposal]

  • Command line: ssh [-p <port>] [-l <user>] -o BatchMode=<yes if non-interactive> -- <host> '<exec> serve --stdio <quoted path>', with <exec> = exec= of the remote (default barocss-hub). ssh is found on PATH; BAROCSS_HUB_SSH (like GIT_SSH_COMMAND) overrides the program and fixed args. The remote path is single-quoted with ' escaped as '\''; a path containing a newline or NUL is refused locally.
  • Injection hardening (Issue #45) [decided by the #45 security fix; details decision-proposal]. The remote command is interpreted by the remote login shell, so every component is restricted, and re-checked when the ssh argv is built (defense in depth):
    • exec: one word of [A-Za-z0-9._/~+-], non-empty, ≤ 1024 bytes, not starting with -. No space, quote, ;, &, |, $, backquote, (), <>, \, glob or brace character can appear, so it is safe unquoted as the first word (~ is allowed for ~/bin/barocss-hub and is inert elsewhere in a word). The #43 check (printable, no quote) allowed barocss-hub;id, which ran id on the remote.
    • user: [A-Za-z0-9._-], not starting with -. host: a DNS name/IPv4 of [A-Za-z0-9._-] not starting with - or ., or a bracketed IPv6 literal [hex:…[%zone]] (brackets stripped for ssh). Nothing in them can be syntax for a ProxyCommand shell after ssh's %h/%r expansion (cf. CVE-2023-51385). port: a u16. Host and user are separate argv entries after --/-l; no local shell is ever used.
    • path: absolute or ~/… (so it can never be read as an option of serve; a relative scp path is sent as ~/<path>, ~user is refused), printable ASCII (the forced-command pchar), and none of \ or ! (special inside single quotes in fish, resp. csh history expansion). It is always single-quoted.
    • All of this is validated at remote add, at remote set-url, and when .hub/remotes is loaded: a hand-edited or planted file with an unsafe name, url or exec makes every command that reads remotes (sync, watch, remote list) fail with corrupt ("refusing .hub/remotes: remote X is unsafe: …; nothing was run"), before ssh is started; remote rm <name> still works to repair it.
  • Binary discovery: the remote login shell's PATH, unless exec= (per remote) or --exec gives an absolute path (for installs in ~/.local/bin or ~/.cargo/bin not on a non-interactive PATH). A missing binary shows up as ssh exit 127 before the preamble; the CLI reports io with a hint to set exec.
  • Host keys, keys, agents, ProxyJump, ControlMaster: entirely delegated to ssh and the user's ~/.ssh/config. barocss-hub never parses known_hosts, never accepts a host key, never reads or asks for a password or passphrase. stdin of ssh is the protocol stream, so interactive prompts go to the tty (ssh uses /dev/tty); the background service (§5.11) runs with BatchMode=yes, so a missing key fails instead of hanging.
  • stderr of ssh is passed through (human mode) or captured into the error message (--json).

5.7 Bare repositories (central replica) [decided: D-038; details decision-proposal]

  • [decided] (D-042) barocss-hub init --bare <dir> creates <dir>/.hub/, laid out exactly as a normal repo's, plus an empty marker file .hub/bare; <dir> itself holds nothing else and is never used as a working directory. serve accepts the path of <dir>.
  • No working directory, no index, no pinned, no checkout-in-progress, no .hubignore evaluation, no watch.
  • Allowed on a bare repo: serve, status (head, counts, warnings; no working-directory section), log, show, cat, cat-blob, ls, warnings, commit/publish of an existing version id (metadata only), reid, remote/sync (a server may also pull from another server). Refused with a new code bare ([decision-proposal], added to CLI-JSON): record, checkout, mkdir/touch/mv/rm, watch.
  • A non-bare repo can also be served (P2P-ready); sync is log-only (§4.4), so serving never touches its working directory.

5.8 Published checkpoints, refs, warnings over the wire

  • [decided] (D-006) Only published checkpoints are sent: the sender reads published.log only; checkpoints.log (private) is never opened by the serve/sync send path (property P9). A received record identical to a private one marks it published (§4.6).
  • [decided] (D-007, D-009, D-010, D-011, D-012, D-013, D-015–D-018) Refs: only published ref move records travel in RefMoves, evaluated by the receipt-side validity of §4.11 (FF against all heads, published/ prefix reserved, local wins on clash). Local refs never leave the replica. Because refs are not implemented on develop, RefMoves is gated behind feature refs and absent from #43.
  • [decision-proposal] Sender filter: only checkpoints/moves whose frontier is in the sender's applied V (§4.6); the receiver keeps unresolvable ones pending.
  • [decided] (D-008, D-021, D-022) Equivocation, Lamport violation, CorruptBlob, DuplicateReplica are recorded as local warnings naming the peer = the remote's replica id from Hello (for display, also the remote name). A persisted DuplicateReplica (op-level only, 5.2.3) stops later syncs with that peer (fatal duplicate-replica) until reid (D-025).
  • [decision-proposal] Warnings are local and not sent over the wire in v1. On a central server, the server's warnings are visible to the server admin via status/warnings; [open question] whether clients should be able to fetch the server's warnings (a Warnings frame behind a feature) so a user learns that the server rejected their op.
  • [decision-proposal] Relay: the server relays everyone's ops (they are in its log), and relays published checkpoints it holds; an op it rejected (equivocation/Lamport) is not in its log and so is never relayed; a pending op is not relayed (only the applied frontier is advertised, §4.4).

5.9 Security

5.9.1 One repo per process, no in-protocol selection [decision-proposal; Mentor addendum]

  • serve --stdio <path> serves only the repo named on its command line. No frame names a repo or a path; there is no frame that could switch repos. (Property P11.)

5.9.2 Path confinement [decision-proposal]

  • The server canonicalizes <path> (realpath, resolving every symlink) and requires it to be inside the canonical --root when a root is configured (default in forced-command setups; without a root, the OS account's permissions are the boundary, as with git). Rejected with forbidden: a path with a .. component (even before canonicalization), a path whose canonical form leaves the root, a repo whose .hub/ or any store file is a symlink pointing outside the repo, a path that is not a repository (not-a-repository). Checks run before any file is opened for writing. (P12.)
  • Blob files are addressed only by strict 64-hex ids (bad-blob-id otherwise), so no received id can form a path; part-<id> names likewise. Op names obey the §1.3 name rules (no /, no NUL, not ./.., non-empty) and are validated by the existing decoder; an op breaking them is a §2.2 invalid op (recorded as an invalid-op conflict, never projected), exactly as locally. Checkpoint messages and ref names are data, never paths. Remote names in .hub/remotes are validated the same way. (P13.)

5.9.7 Trusted remotes: first contact (Issue #45, repaired; D-046, D-047 decided)

  • Threat: .hub/remotes travels with a folder. A repository someone hands you (archive, shared or synced drive, copied disk) may name an attacker's host, user or exec, or, with file://, another of your own repositories (Review reproduced a planted file:///…/victim default=1 pulling the victim's content into the planted folder and pushing planted ops into the victim and on to its team server). With D-044 auto-sync this would happen without any act of yours.
  • Rule: every remote read from .hub/remotes (ssh and file://) is contacted only if this user trusts it from this repository. Trust lives in a per-user store outside every repository ([decided] D-046): on macOS ~/Library/Application Support/barocss-hub/trusted-remotes (XDG_CONFIG_HOME is not consulted on macOS, like other macOS applications; one rule per platform keeps the location predictable); on Linux and other Unixes $XDG_CONFIG_HOME/barocss-hub/trusted-remotes when XDG_CONFIG_HOME is absolute, else $HOME/.config/barocss-hub/trusted-remotes. BAROCSS_HUB_CONFIG_DIR (absolute; the directory holding trusted-remotes) overrides it on every platform; the tests always set it (with a temporary dir), so they never touch the real ~/Library or ~/.config. macOS migration: a store written by the first #45 rounds at $XDG_CONFIG_HOME or ~/.config/barocss-hub/trusted-remotes is still read (its entries count) and is moved on the next write: the write merges its entries into the new store (temp + fsync + rename, under the store lock), then deletes the old file. Not done when BAROCSS_HUB_CONFIG_DIR is set. Header barocss-hub/trusted-remotes/v1, lines T <local> <url> <exec> (escaped as .hub/remotes). The key is:
    • local: the canonical .hub/ of the repository syncing (R2), so a planted folder carrying the exact URL of an already-trusted team server is a new, untrusted remote;
    • url: ssh://[user@]host[:port]/path normalized, or file://<canonical target> (realpath, symlinks resolved; a missing target keeps the literal path, and is refused as untrusted without being created);
    • exec: the exec word (default barocss-hub), - for file://. Changing host, user, port, path, target or exec by hand, or copying the folder elsewhere, makes it untrusted again.
  • Old entries: the first #45 round wrote …/v0 entries without local. They no longer match: each such remote needs one re-confirmation (remote trust, --yes or the prompt), and the next write replaces the file with v1.
  • Becoming trusted: remote add and remote set-url (the user's own act; a local remote add ../b stays painless), remote trust <name>, or a first contact confirmed on a terminal or with sync --yes. The peers → remotes migration grants no trust [decision-proposal]: a .hub/peers file can be planted just like remotes, and the migration cannot tell who wrote it; each migrated remote needs one confirmation. (Argument for carrying trust: the user created those peers with peer add on a pre-#43 binary. It is rejected because the file is indistinguishable from a planted one, and the cost is one confirmation per remote per repository.)
  • CLI: first contact asks Trust it and connect now? [y/N] when stdin and stderr are terminals and --json is not given; otherwise it refuses with untrusted-remote (exit 1; the message names the remote, URL, exec, the local repository and the two ways to trust it) unless --yes. Without a usable location (no absolute HOME, nor the override or, off macOS, XDG_CONFIG_HOME) it refuses unless --yes (which syncs without recording). A URL or path typed on the sync command line is the user's own act and is not gated.
  • watch / service (D-044): an untrusted remote is skipped with one untrusted-remote event per remote per run; not an error, no backoff, re-checked every cycle (a remote trust takes effect without a restart). watch --no-sync contacts no remote.
  • VS Code: declares capabilities.untrustedWorkspaces: limited. In Restricted Mode it starts watch --no-sync and says so once ("Manage Workspace Trust"); granting trust restarts running watchers with sync. untrusted-remote and rejected watch events become warning notifications.

5.9.3 No command execution [decision-proposal]

  • The server executes nothing a client sends; the only process started is ssh on the client, with a command built from local config. The server never runs hooks in v1. [open question] server-side hooks (notify on push) later, admin-configured only.

5.9.4 Malicious peer = §4.5 path [decision-proposal; Mentor addendum]

  • Ops and blobs from the wire go through exactly SyncReplica::receive_ops/receive_blob and the §4.5 hooks: equivocation and Lamport violation rejected with a D-008 warning, corrupt blob dropped with a CorruptBlob warning, invalid ops recorded as conflicts, unknown deps buffered. There is no wire-specific fast path that skips validation. (P14.)

5.9.5 DoS

  • Frame/chunk/list/blob limits, timeouts and session caps of 5.2.5; limits checked before allocation. [open question] Bound on the pending buffer a peer can grow with ops whose deps never arrive (already open in §4.4); v1 proposes a cap of 100k pending ops per peer, after which further such ops are dropped with a warning.

5.9.6 Authentication and authorization

  • [decided] (D-037) Authentication is SSH's.
  • [decided] (D-043) serve --read-only is in the alpha. The server announces feature read-only in its Hello (R7); a client seeing it runs the pull half only, skips the push half and the meta phase except receiving the server's Checkpoints (it sends an empty Done meta and no Checkpoints), and exits 0 with the notice remote is read-only; pulled only (--json: "read_only": true in the sync document; watch synced event gets read_only: true), so the D-044 service does not error every cycle. Otherwise (a client ignoring the flag): the server answers the pull half and sends its Checkpoints, but never sends a Have for the push half, and any Ops, BlobBegin/Chunk or Checkpoints/RefMoves frame from the client is refused with fatal forbidden before anything is written (it never takes the exclusive lock). Use it in a forced command for read-only keys.
  • [open question] Finer ACLs (per-repo, per-user). Without --read-only, anyone who can run serve on the repo can read and write it.
  • Forced-command example (server ~/.ssh/authorized_keys), confining one key to serving under one root:
restrict,command="barocss-hub serve --stdio --root /srv/hub --from-ssh-original-command" ssh-ed25519 AAAA... alice@laptop
restrict,command="barocss-hub serve --stdio --read-only --root /srv/hub --from-ssh-original-command" ssh-ed25519 AAAA... ci@build

restrict (OpenSSH ≥ 7.2) disables port/agent/X11 forwarding and pty, and future forwarding features; recommended over listing no-* options.

  • --from-ssh-original-command [decision-proposal] (R3) parses SSH_ORIGINAL_COMMAND with this exact grammar and nothing else (no shell is ever invoked):
cmd    = word SP "serve" SP "--stdio" [SP "--read-only"] SP path
word   = 1*( %x21-7E except "'" )           ; the client's binary name, ignored
path   = "'" 1*( pchar / "'\''" ) "'"       ; exactly what §5.6 emits
pchar  = %x20-7E except "'"                 ; printable ASCII, no quote
SP     = %x20                               ; exactly one space

Anything else (extra or missing spaces, other options, unquoted path, " quoting, backslashes other than inside '\'', non-ASCII, control bytes, trailing text) exits with forbidden. A path produced this way is then confined: it must be absolute or start with ~/ (expanded to the login home); ~user is refused. A --read-only in the authorized_keys command cannot be removed by the client; one in SSH_ORIGINAL_COMMAND can only add it. (Non-ASCII repo paths are therefore not servable through a forced command in v1; [open question] follow-up: allow valid non-control UTF-8 in pchar.) The scp form host:path and ssh://host/path map onto this grammar only as an absolute path or ~/…: the client sends a relative scp path as ~/<path>. exec= values are restricted to [A-Za-z0-9._/~+-] (§5.6, #45; a subset of word), at remote add, set-url and load.

  • Root precedence: --root on the command line wins over BAROCSS_HUB_SERVE_ROOT and the server config. Under --from-ssh-original-command the environment is ignored entirely for root and exec (a client could inject variables where sshd's AcceptEnv/PermitUserEnvironment allow it).

5.10 Version compatibility [decision-proposal]

  • Old client / new server and vice versa: protocol ranges intersect → the older protocol is used; otherwise both sides print version-mismatch with both ranges and exit 1 without writing anything.
  • A pre-#43 binary on the server (no serve command): ssh returns the binary's usage error before the preamble; the client reports io: "remote barocss-hub has no serve (too old?)". A non-barocss-hub program: no magic → protocol.
  • Format-v1 minor revisions (A.2): a minor revision may add record kinds only behind a wire feature; a peer never sends a record kind the other did not announce (format_minor_max). A received unknown record kind despite that is protocol (fatal), not silently dropped, so no data is lost unnoticed.
  • Protocol 2 would be required for: changing frame layout, changing an existing frame's meaning, or making a feature mandatory.

5.11 Background service and VS Code [decided: D-039, D-040; details for #44]

  • D-039: the service runs watch plus remote sync for registered repos. [decided] (D-044) remote sync is automatic whenever a repo has a remote; adding a remote is the opt-in. [decision-proposal] watch syncs with every remote (local file:// ones included, which replace peers) after each auto-record and on a timer (e.g. 60 s, backoff on failure); each result is a synced/error event in CLI-JSON §3 with peer = the remote name. ssh runs with BatchMode=yes. Details (registration, scheduling, backoff, logs) belong to #44.
  • D-040 + D-044 ([decided]): with autoStartWatch true, opening a folder starts watch, which syncs with the repo's remotes by default.

5.12 Testable properties [decision-proposal]

  • P1 Equivalence: for random replica pairs, sync over an in-memory pipe transport yields the same ops, blobs, pending, warnings and published sets on both sides as the #22/#34 local pull both ways (compare sync_prop).

  • P2 Determinism: the frame transcript of a session is a pure function of the two states and limits (byte-identical across runs; Progress excluded).

  • P3 Disconnect at every frame: for each k, cutting the pipe after frame k (in either direction) leaves both repos opening as valid, causally closed versions (A.1 clause 2), and a rerun converges to the P1 result.

  • P4 No acknowledged loss: with the A.1 crash hooks, a crash after a Committed for frontier F never loses any op ≤ F on that side.

  • P5 Blob resume: a drop inside a blob and a rerun with resume transfers only the remaining bytes (+ one chunk), and the result equals P1; a tampered kept prefix is detected and re-fetched.

  • P6 Per-chunk integrity: flipping one byte of a Chunk gives fatal corrupt and no blob file; a correctly chunked blob whose content does not hash to its id gives a CorruptBlob warning and pending ops (as receive_blob).

  • P7 Limits: a frame with len > max_frame, a chunk > max_chunk, a list > max_list each give Error limit without allocating the payload (measured by a counting allocator or a fixed cap on read buffer).

  • P8 Version refusal: disjoint protocol ranges → both sides exit 1 with version-mismatch, no file written; overlapping ranges → the lower max is used.

  • P9 Privacy: a private checkpoint (and, later, a local ref) never appears in any frame, for any history (property test over transcripts).

  • P10 Duplicate replica (reworded by #45 to match R5): two repos sharing a replica id → the Hello check gives fatal duplicate-replica for that session only, and nothing is persisted from Hello on either side (an unauthenticated claim, R5). A DuplicateReplica warning is persisted only from op-level detection (an op authored under our id that we never authored), and a persisted one stops syncs with that replica until reid; after reid sync works.

  • P11 One repo: no byte sequence sent by a client causes the server to open any file outside the repo it was started on (fuzz the frame decoder with a filesystem-access log).

  • P12 Confinement: serve --root R refuses .., absolute paths outside R, symlinks (repo dir, .hub/, or a store file) escaping R, with forbidden, before opening anything for write.

  • P13 Names and ids: a blob id that is not 64 lowercase hex, an op with a name violating §1.3, and checkpoint messages containing /, .. or NUL never create or touch a path outside blobs/ (ids refused bad-blob-id; bad names become invalid-op conflicts, as locally).

  • P14 Same validation: a scripted malicious peer (equivocation, Lamport violation, corrupt blob, invalid op, op authored under the server's own id) produces the same warnings/conflicts over the wire as with the local sync.

  • P15 Forced command: with --from-ssh-original-command, any SSH_ORIGINAL_COMMAND other than the §5.6 form exits forbidden, and shell metacharacters in it are never interpreted.

  • P16 Concurrent sessions (R1): N ≥ 2 concurrent serve sessions on one repo, each delivering ops with missing deps and ops that raise warnings, persist in any interleaving; afterwards pending.log ∪ log contains every pending op of every session and warnings.log every warning (no lost update), and the result equals running the sessions sequentially. Equivocation across sessions: two concurrent sessions sending different payloads under the same OpId → exactly one payload is in the log, a D-008 equivocation warning naming the later-persisting session's peer is persisted, and nothing is dropped silently (R1b).

  • P17 Forced-command grammar (R3): a fuzzed SSH_ORIGINAL_COMMAND is accepted iff it matches the 5.9.6 grammar; ~user/…, env-provided roots under a forced command, and --root overridden by env are all refused/ignored.

  • P18 Limit floors and blob framing (R4): Hello with chunk=0, frame=1 or below the floors is fatal protocol; BlobBegin with an unsolicited id, offset > size, or chunks past size is fatal protocol and leaves no blob file.

  • P19 Record size (R4): mkdir/touch/mv of a 4097-byte name refuses with invalid-path; every op any command can author encodes to ≤ 256 KiB; an existing record larger than the peer's max_frame gives Error limit naming the op, never a hang or a skipped op.

  • P20 Hello id cannot poison (R5): a client announcing the server's replica id gets duplicate-replica and the server's warnings.log is byte-identical before and after; an op authored under the server's id still gives a persisted DuplicateReplica warning.

  • P21 Part from another peer (R6): a part-<id> whose prefix came from peer X, resumed from peer Y, is retried from 0 once and gives no CorruptBlob warning when Y's full blob is correct; if the from-0 transfer also mismatches, exactly one CorruptBlob naming Y.

  • P22 Read-only (D-043): under serve --read-only, any Ops, BlobBegin, Chunk, Checkpoints or RefMoves from the client gives forbidden, and the repo (all files, mtimes) is unchanged; pulls still equal P1; a normal client syncing with a read-only server exits 0 with read_only: true, sends no push or Checkpoints frame, and its pull result equals P1 (R7).

  • P24 No injection (#45): with a fake ssh that evaluates the remote command with sh -c, no shell metacharacter (; & | \ $ ( ) < > \ " ' space tab newline CR * ? [ ] { } ! # ~ % ^ = ,) in any position of exec, user, host, port or path, given at remote add, remote set-url, in a planted .hub/remotes, or on the synccommand line, runs a command; unsafe values are refused before ssh starts, and every command that reaches the shell is exactly serve --stdio '' (tests/injection45.rs`, with a positive control proving the fake executes unquoted commands).

  • P25 First contact (#45, repaired): a planted ssh or file:// remote (also via a symlink), or a planted copy of a trusted URL in another folder, is never contacted (both repos byte-identical) and a migrated peer starts untrusted; a planted ssh remote is never contacted by sync without a terminal or --yes (untrusted-remote), is asked about on a terminal, is skipped by watch with exactly one event and no error; --yes/remote trust/remote add/set-url record it outside the repo; changing url or exec by hand makes it untrusted again (tests/trust45.rs).

  • P26 Rejected-op feedback (#45): a push containing an equivocating op and a Lamport-violating op gets both listed in Committed iff rejected-ops was negotiated; a client without the feature gets a plain Committed; against a server without it the client reports rejected_reported: false (tests/wire.rs).

  • P27 Divergence (#46): two copies sharing a replica id that record different content under one OpId and meet only through a server: the copy's next sync (ssh and file://) fails with duplicate-replica, persists an equivocation and a DuplicateReplica warning naming the server, and appends no op; with an old peer (no digest) the session behaves as pre-#46; random same-id copies and sync schedules never diverge silently and honest histories never report divergence (tests/remote.rs diverged_copy_detected_over_{ssh,file}, session::diverge46_tests, mutation-checked).

  • P23 Peers migration (D-041): crashing at each step of the peers → remotes migration and rerunning yields exactly one file:// remote per former peer and no peers file.

5.13 Worked example: client ↔ central server, with a disconnect

Server srv (bare, /srv/hub/proj, replica S); laptop L (replica L) with remote add origin ssh://alice@srv/srv/hub/proj. Server holds ops s1..s3 and published checkpoint v1 at {s3}; L holds {s1} plus new l1 (touch a.png, 5 MiB blob b1) and l2, and a private checkpoint wip.

  1. barocss-hub sync origin → ssh -l alice -- srv 'barocss-hub serve --stdio '\''/srv/hub/proj'\'''. Forced command confines to /srv/hub.
  2. Both: BHUBWIRE, Hello proto 1 1 … replica L|S features blobs-chunked,checkpoints,resume. Protocol 1, features intersected.
  3. Pull: L Have {l2}. l2 ∉ V_S, so S computes K = downclose({l2} ∩ V_S) = ∅ and Δ = {s1,s2,s3} (s1 redundant, §4.3). S Ops {s3} done=1 [s1 s2 s3]. L: s3 names no missing blob → empty NeedBlobs, Done ops. L persists (append s2, s3; log sync) → Committed {l2,s3}.
  4. Push: S Have {s3}. L Ops {l2,s3} done=1 [l1 l2]. S NeedBlobs b1. L BlobBegin b1 5242880 0, Chunk×20 (256 KiB each)… the Wi-Fi drops after chunk 12. ssh exits 255; L reports io and exits 1.
    • S: l1/l2 were only in memory; its log is still {s1..s3}. The 3 MiB prefix is in part-b1. L: {s1,l1,l2,s2,s3} durable since step 3's Committed. Nothing acknowledged is lost; both open as valid versions.
  5. Rerun: handshake; pull: L Have {l2,s3}, S answers only what L lacks (nothing new, possibly redundant s1..s3), Committed. Push: S Have {s3}, L Ops [l1 l2], S NeedBlobs b1@3145728, L streams chunks 13..20, S verifies prefix + chunks, whole SHA-256 = b1, persists b1 (flush + barrier), appends l1, l2 (log sync), Committed {l2,s3}.
  6. Meta: S Checkpoints [K … {s3} v1]; L Checkpoints [] (wip is private, never sent). Both persist, Committed, Bye. L exits 0. L's status then reports the working directory behind head (log-only sync, §4.4) until checkout.

5.14 Escalations

  • Resolved by user decisions: E-R1 → D-041, E-R2 → D-042, E-R4 → D-043, E-R5 → D-044.
  • E-R3 HTTP auth mechanism: superseded by Online-First E-B1 (D-048 makes auth required; options and proposal in 6.5.4).

5.15 Open questions

  • Allow valid non-control UTF-8 in the forced-command grammar (follow-up; ASCII-only accepted for v1, 5.9.6). HTTP auth (E-R3). Repo identity in Hello to refuse unrelated repos (5.2.3). Server warnings visible to clients (5.8). Rate limits beyond sshd (5.2.5). Pending-buffer cap (5.9.5, §4.4). part-* sweep age (5.2.4). ACLs (5.9.6). Server hooks (5.9.3). Reducing redundant Δ via ancestor negotiation (§4.3) now that a wire exists.

Clone this wiki locally