File-sync transport state model
This is the single source of truth for the intended state mechanics of FileSyncConnection (the sftp and filedrop channels). It exists because the rendezvous and message-loop behavior is an implicit state machine spread across several enforcement sites in one file, and because each change that adds a file type or a transition would otherwise re-derive the mechanics from prose and let the enforcement sites drift apart. This document is the shared target every such change is reconciled against.
This is a developer/design document. For the user-facing configuration reference and the canonical filename grammar see EXCHANGE_REFERENCE.md; for the high-level channel and synchronization overview see COMMUNICATION.md; for the cryptographic protocol see PROTOCOL.md. Where this document and EXCHANGE_REFERENCE overlap, EXCHANGE_REFERENCE owns the filename grammar and this document owns the state machine. This is a deliberate tier inversion: the filename grammar -- this document's single critical invariant -- stays in the overview-tier EXCHANGE_REFERENCE.md because that is the document an operator authoring an alcove.yaml opens, so this spec-tier document references up into the overview tier for it. Do not "tidy" the grammar down into docs/spec/; doing so would silently break the grammar references throughout this document. (The boundary is recorded in docs/spec/README.md.)
Core principle: the directory is the state machine
Two parties share one directory (an SFTP path or a local filedrop path) and never hold a lock on the directory itself. Each party mutates the shared state only by put, delete, and rename, and observes the peer only by list. The protocol state is therefore exactly the set of filenames present in the directory. Everything else -- roles, modes, who has consumed what -- is inferred from that set.
Two consequences follow, and they are the reason this document exists:
- Correctness reduces to agreement on two things: which filenames are legal at each phase, and how each file is routed (treated as a message, a control signal, or ignored). A bug is almost always a disagreement about one of those two between the two parties, or between two of this code's own enforcement sites.
- Every new file type is a new symbol in the state machine's alphabet, and must be accounted for at every site that reads the directory. Adding one without updating all of them is the standard failure mode.
The single critical invariant: the filename grammar
Routing safety rests entirely on one rule, defined in full under EXCHANGE_REFERENCE.md "Filename grammar" and restated here as the discriminant the state machine depends on:
A protocol file is named
<id>-...-<token>.json. If<token>is all digits it is the file's declared byte count, and the file is a message unless the segment before it is the type wordhello,joiningorabort, which makes it a control file with contents. Otherwise<token>is a type word (ack,lock) and the file is a zero-length control file. No control file is routed as a message.
The byte-count invariant. Every file a party puts under a final name either has a name ending in its byte count, -<bytes>.json, or is a zero-length control file named with a type word (ack, lock), in both publish modes. So an observer outside the protocol can tell a complete file from one still being written by name and size alone: a name ending in a byte count is complete only when the file's size equals it (a shorter or empty file under such a name is still being written), and a type-word name is always zero-length. A reader gates every read on the count its name states, and refuses a stated count of zero before any read, since every file a party names with a byte count has contents. A temp-*.tmp name is not a final name and states no byte count: without write_without_renaming every final name appears only at the rename, complete, and the in-flight temp is never complete. The operator-facing form of this rule is EXCHANGE_REFERENCE.md "Moving files out of the folder".
In code this discriminant is isMessageName(name): parseMessageByteCount(name) !== undefined and parseSizedControlName(name) === undefined (applied by the poll() scan in fileSyncMessageLoop.ts). It partitions the size-terminal names: a name the writer builds for a message never has a type word before its byte count, because an untimestamped message name is built only from a UUID id and a timestamped one has the digit counter there. Only the protocol's own in-flight temp write temp-<uuidv4()>.tmp is excluded earlier, matched by isProtocolTempName (its temp- prefix and a v4-UUID stem, the exact shape send()/writeAck() produce) -- a foreign temp-*.tmp whose stem is not a UUID is not excluded here; it fails the grammar and is treated as a foreign file. Every .json control file is excluded by the discriminant instead: the ack marker and the lock tiebreaker because their terminal token is non-numeric, and the hello, joining and abort files because of the type word before their byte count. The grammar is what lets the message scan ignore a renamed hello, an ack, or a lock file without an explicit per-type exclusion, provided the site that reads the directory uses the discriminant rather than a bare prefix glob. A new zero-length control file gets a non-numeric terminal token; a new control file with contents gets a type word before its byte count, added to the type list parseSizedControlName accepts. Two sites name control types explicitly anyway, and neither narrows the discriminant. The poll() peer-message scan tests !name.endsWith("-ack.json") ahead of isMessageName; it narrows nothing, an ack's terminal token being non-numeric, so the discriminant behind it rejects the same names. The entry guard's grammar classifier (isProtocolGrammarName in fileSyncNames.ts) accepts every size-terminal name and lists the lock and ack suffixes, because it answers the opposite question: not "is this a message" but "is this a protocol artifact at all". So a leftover control file is recognized (rejected at the no-flag entry guard, swept under --sweep-exchange-files) rather than failing the directory-clean check as a foreign file. A new zero-length control file has to be added to that classifier to be recognized at entry; a new control file with contents is recognized there by its byte count.
File taxonomy
Every row here must agree with every column for the state machine to be consistent.
| File | Pattern | Written by | Token | Has payload | Byte-count gated | In responsibleFiles | Swept by cleanup() | Routed by poll() | Seen by hasOutstandingMessage |
|---|---|---|---|---|---|---|---|---|---|
| hello | <id>-hello-<bytes>.json | each party (written temp-then-rename from temp-hello-<uuidv4()>.tmp) | numeric, after the type word hello | yes (envelope: bilateral flags locklessRendezvous, retainFiles, camelCase on disk) | yes, through the I5a read gate | yes (delete mode only), tracked after the rename | yes (not in retain mode) | no (type word before the byte count) | no (not lastSentFile) |
| lock | <peer1>-<peer2>-lock.json | whichever party wins the exclusive-create race (lock path); both reconstruct the same name and both attempt it | lock | no | n/a | yes | yes | no (non-numeric) | no (not lastSentFile) |
| ack | <writerId>-<originalName>-ack.json (named after the acked file: rendezvous <myId>-<peerId>-hello-<bytes>-ack.json; message <myId>-<peerId>-<ts>-<NNN>-<bytes>-ack.json) | the party acking a file -- rendezvous: each lockless party (acks the peer hello); message loop: the retain receiver (acks each consumed message) | ack | no (zero-length marker; matched by name existence) | no (zero-length: name-appearance is completion) | rendezvous ack: yes (delete-mode lockless); message ack: no | rendezvous ack: yes (delete mode); message ack: no (transcript is retained) | no (non-numeric) | no (not lastSentFile) |
| message | <id>-<bytes>.json or <id>-<ts>-<NNN>-<bytes>.json | each party | numeric | yes | yes | yes (delete mode only) | yes (not in retain mode) | yes | this party's own lastSentFile only (delete mode; a peer message is not) |
| temp | temp-<uuidv4()>.tmp | each party (in-flight message or ack write) | n/a (.tmp) | partial write | n/a | no | inline in send()/writeAck(); an orphan is recognized at the entry scan and swept once entry is accepted (site 1) -- only this exact temp-<uuidv4()>.tmp shape, a foreign temp-*.tmp is tolerated as foreign; a session-holding transport that could not perform the inline sweep re-issues it, best-effort and within the same run, its per-file v4 UUID being what makes that later re-issue unable to reach another file (see SFTP_SESSION_RECOVERY.md); never cleanup() | no (temp-uuid shape) | no (not lastSentFile) |
| hello temp | temp-hello-<uuidv4()>.tmp | each party (in-flight hello or joining-sentinel write) | n/a (.tmp) | partial write | n/a | no | inline in the write's own failure path; not swept at the entry guard, with or without --sweep-exchange-files (site 1) -- see Hello temp disposition below; never cleanup() | no (temp-uuid shape) | no (not lastSentFile) |
| joining | <id>-joining-<bytes>.json | joiner (lock path) | numeric, after the type word joining | yes (hello envelope, pre-staged for the rename to the hello, so it states the same byte count as the hello; the peer matches by name and never reads it) | n/a (never read) | yes (released once the peer hello is deleted) | yes (only before the peer hello is deleted) | no (type word before the byte count) | no (not lastSentFile) |
| abort | <writerId>-abort-<bytes>.json | the failing party, best-effort on teardown, only when its directory is still writable | numeric, after the type word abort | yes (envelope: version, authenticated token, camelCase on disk; token construction and verification in CHANNEL_SECURITY.md) | yes -- the reader looks only for the name its peer's token length fixes, and a listed size or body other than that count is unverified; plus its own small hard cap ABORT_MARKER_MAX_BYTES (~1 KiB, not the 512 MB frame cap), enforced by a pre-get() listed-size refusal, with the get itself capped at its name's byte count | no (it must persist for the peer to read; a later exchange's entry guard recognize-and-sweeps it instead -- any well-formed marker, under whichever id wrote it, delete mode only, retain mode through --force-retain-sweep; see Abort-marker lifetime below) | no (control file; persists) | no (type word before the byte count: isMessageName is false, so it is never consumed as a message -- the read side detects it separately and short-circuits the poll loop with a PeerAbortError, but only in a cycle without exactly one fully-synced peer message waiting: a single waiting message is read first, so an abort frame the partner sent before writing its marker reports the frame's own cause, while a still-syncing message or two or more waiting messages leave the marker to act at once) | no (not lastSentFile) |
Writing without renaming. The rows above describe the default write. A party with write_without_renaming on writes no temp-*.tmp and no temp-hello-*.tmp: every row it would write temp-then-rename it writes directly under the final name, and every such row tracked in responsibleFiles is tracked before the write rather than after it (Writing without renaming). The two temp rows still describe a partner without the option.
Message body format. A message file's body is a binary envelope version || type || seq || payload: a 1-byte version/format marker (MESSAGE_ENVELOPE_VERSION), a 1-byte type discriminator (0 = JSON object, 1 = raw binary frame), an 8-byte big-endian per-session seq, then the payload -- UTF-8 JSON parsed through the parseBoundedJson chokepoint for type 0, or the raw frame bytes delivered as-is for type 1. type is the outer, cleartext discriminator the reader keys on, because an encrypted frame's own JSON/binary tag is inside the AEAD ciphertext (see CHANNEL_SECURITY.md) and cannot drive the transport read. The payload is stored verbatim -- no base64url and no whole-frame string conversion -- so the on-disk size is the raw frame plus the 10-byte header (the byte count the filename declares and the sync-gate waits for), and a frame larger than Node's maximum string length can be read. The send-time timestamp is not in the body (a timestamped filename records it); the seq is cross-checked against the filename NNN in retain mode. Byte 0 is the whole compatibility check on this path, and it is checked before anything else is believed: a value that is not this build's MESSAGE_ENVELOPE_VERSION yields a terminal likely incompatible partner version hint naming the real cause -- both parties must run the same Alcove version -- rather than a silent misparse. Two foreign values reach it. A JSON-text body leads with { (0x7B), which is what a peer too old to speak the envelope writes and cannot be taught a handshake to avoid; a version above this build's is what a newer peer writes. Only a byte 0 that coincidentally equals this build's marker falls through to the generic malformed-envelope error. The forward-looking clean check is the terms exchange's protocolVersion reconcile (PROTOCOL.md), which is transport-agnostic; it rides these message frames rather than preceding them, so on the file-sync path this envelope-version-byte hint is what first catches a foreign envelope, while the reconcile covers skews whose envelope stays readable.
Send-path framing (peak-shaving, not a wire change). send() does not concatenate the header and payload into one buffer. It builds only the 10-byte header (serializeFileSyncMessageHeader, defined in fileSyncFraming.ts) and hands put the [header, payload] pair as an ordered two-chunk list (the Uint8Array[] arm of the FileTransportClient.put src type); the transport writes the parts back-to-back, so the on-disk bytes are byte-identical to the concatenated form above but the payload is never copied. This holds a binary frame's live footprint at ~1x its size on the send path rather than ~2x, which a header-prepending whole-buffer copy would cost. Each transport realizes the two-chunk write without a memory-side concat: the SFTP adapter streams the parts through the same createBoundedPutSource idle-window-bounded source it uses for a lone Buffer (so the stall window and retry cover the hottest, largest binary frame -- a chunk list is re-iterable, so the source is rebuilt per retry attempt), and LocalFSClient writes them with positional writev calls, repeated over what is left after a partial write. serializeFileSyncMessage (the whole-buffer form) exists only for the transport tests, which inject a complete message file's bytes; a receiver reads the whole file and decodes it with a subarray view regardless of how it was written. The send path is channel-hardening surface -- it is the put liveness/stall window and the crash-safe atomic write -- so a change to it is security-review scope under the CONTRIBUTING Dependency Policy.
Foreign and snapshotted files (a classification, not a new file type). A name that fails the grammar above -- a conflict copy, a partial download, an unrelated file -- is foreign: it has no <id>-...-<token> shape, sits outside the NNN alphabet, and is never a protocol file. Foreign names present at synchronize() entry are recorded in an in-memory snapshot and tolerated for the session; a foreign name that first appears after entry is a newcomer handled by unexpected_files. Neither is prefix-screened at poll(): a foreign name that coincidentally matches <peerId>-<digits>.json matches the message grammar, so it is a protocol file (a message), not foreign -- rejected at the entry guard with no flag, or swept under --sweep-exchange-files, and never snapshotted. Two of these rows are read at entry as well, by the pre-sweep retain-signal inspection (site 1): the hello row's payload supplies retain_files, and a retain-only message ack's mere presence is itself a retain signal (the shape that distinguishes one is in I0).
Hello temp disposition (why one temp shape is swept at entry and the other is not). Both temp shapes are isProtocolTempName, so both are grammar-recognized, recognized for the loop, and tolerated at entry rather than refused. They differ only in whether the entry guard deletes them, and the split is the reason the hello write has its own name shape at all.
- A
temp-<uuidv4()>.tmpis a message or ack write, and writing either requires having already seen the peer's hello -- which this party writes only after its entry scan. So one visible at entry is orphaned by construction, and the sweep deletes it without inspecting it, but only once entry is otherwise accepted: with no flag, after the strict-empty guard and the one-peer-hello check pass; with--sweep-exchange-files, after the retain inspection and the protocol-file deletes. An entry the guard refuses deletes no temp. The delete still runs before this party writes its hello, so no in-flight write can race it. - A
temp-hello-<uuidv4()>.tmpshares none of that: writing a hello requires nothing from the peer, so a party starting at the same instant can have one in flight in this very listing. Deleting it would break that party's rename and fail its exchange, so it is added toignoredand left alone.--sweep-exchange-filesdoes not reach it either: the flag is an assertion about the durable protocol files in the directory, not about a write in flight at this instant.
The cost is that this party's own crash residue -- a hello temp left by a kill between the put and the rename -- is indistinguishable from that live peer's write by name, and so is also left. It is inert: it matches the grammar (never counted as a foreign file), no reader opens it, and the operational sync glob already excludes temp-*.tmp. In split mode the outbound directory takes the opposite disposition -- both shapes are swept there -- because the routing rule ("no peer file lands in outbound") makes a hello temp there necessarily this party's own.
Limit -- a hello temp in a shared inbound directory has no in-protocol removal path. The disposition above is stated for crash residue, but it is keyed on the name shape alone, so it covers every file of that shape whatever produced it. In a shared (non-split) directory the shape is writable by the partner, or by anyone else with write access, and once present it is reachable by no removal mechanism the protocol owns. The entry sweep spares it by construction, --sweep-exchange-files does not reach it, it never enters responsibleFiles so cleanup() never sees it, and because isProtocolTempName accepts it isRecognizedLoopFile does too. So unexpected_files (default error on plain delete-mode transports) treats one appearing mid-loop as recognized rather than flagging it. The residual is accumulation of inert names, re-listed and re-logged on every subsequent run in that directory, not a read, a delete, or a routing decision: no reader opens a hello temp and no branch acts on one. The remedy is out-of-band directory hygiene. Narrowing isRecognizedLoopFile to exclude the shape would restore the mid-loop flag, but it belongs with the unexpected_files policy rather than here, because a hello temp legitimately tolerated at entry must not then be flagged mid-loop by the same run.
Abort-marker lifetime (one writer, one remover, and the reader is neither). The marker is the one protocol file whose removal belongs to a later exchange, so its lifetime is stated here rather than inferred from the sweeps that touch it.
- Written by the failing party, best-effort, on a terminal post-handshake fault with its directory still writable -- one
<writerId>-abort-<bytes>.jsonper failing party. - Not removed by the session that wrote it. It is absent from
responsibleFiles, socleanup()leaves it: a peer that has not yet polled must still find it after the writer has closed. - Not removed by the peer that reads it, either. Consuming a marker is a read; the peer that verifies one stops its poller and reports a
PeerAbortError, leaving the file exactly as it found it. So a fast-failed peer leaves no on-disk trace of having read it, and the writer cannot observe whether it was read. - Removed by the entry scan of the next delete-mode exchange to use the directory (site 1(d)), which sweeps any well-formed marker whichever id wrote it -- including one named by neither party of that exchange, the shape a retry after a failed exchange always presents, since both parties draw fresh ids. In retain mode nothing removes it automatically: it is refused by the strict-empty guard and cleared only by
--sweep-exchange-files --force-retain-sweep.
Sweeping a marker no id in the entering session names is safe because no marker visible at entry can belong to that session. A marker is written only post-handshake, and a party cannot reach post-handshake before its peer has passed this same entry scan, so a marker present here is residue of a prior session, whose token cannot authenticate under the entering session's key. What is swept and what is believed stay separate: the sweep changes only what is deleted at entry, never what is accepted as a peer abort, which is still the token check in CHANNEL_SECURITY.md.
Limit. In a directory a live peer is still using -- a violation of directory exclusivity, which the protocol has no entry-time liveness signal to detect -- the sweep can delete that peer's marker before it polls, costing it the fast-fail and dropping it back to the peer-inactivity timeout and its generic hedge. That is a degradation of an optimization, never a false success: an absent marker is strictly uninformative.
The five enforcement sites
The state machine is enforced -- and therefore must be kept consistent -- at exactly these five places, split across three modules. Site 2 (responsibleFiles/cleanup()) lives in fileSyncConnection.ts. Sites 3 and 4 (the poll() scan and hasOutstandingMessage in send()) live in its message loop fileSyncMessageLoop.ts, which the connection composes and delegates poll()/send() to. Sites 1 and 5 (the entry guard/sweep and the rendezvous role-commit logic) live in its rendezvous coordinator fileSyncRendezvous.ts, which the connection's synchronize() delegates to after validating entry. The connection remains the composition root that owns the shared responsibleFiles/foreignFileSnapshot state both coordinators mutate by reference. Adding or changing a file type is a change to all applicable rows of the taxonomy at all five sites; this is the anti-drift checklist.
Preexisting-file guard (
synchronize()start). Three distinct mechanisms, kept separate. (a) Foreign snapshot (tolerate, both modes, always). A name that fails the grammar is foreign; foreign names present at entry are recorded in an in-memory snapshot and tolerated, deleting nothing (safe in retain mode, where sync-mediated conflict copies are expected noise). The snapshot feeds the recognized-for-the-loop set at site 3 so a pre-existing foreign name is not re-flagged mid-loop.(b) Default strict-empty guard (no flag). Three mechanisms compose it:
isPeerHelloName/peerIdFromControlNamefor the one file kind that may legitimately predate entry, the grammar discriminant for protocol-versus-foreign, and (a)'s snapshot for what does not count as present. Anything else is a terminalUsageErrorwhose text points at--sweep-exchange-files. Full rule -- what is tolerated, the rejection list, and the abort marker's mode-dependent disposition: I0. This is what code-enforces retain mode's fresh-directory precondition (see Fresh directory in retain mode).(c) Opt-in sweep (
--sweep-exchange-files). Before any delete, a pre-sweep retain-signal inspection checks whether the directory belongs to a retain exchange. It reads three signals, whose shapes I0 defines: local retain mode, a retain-only message ack (a filename scan only -- no body is read), and a peer hello advertisingretain_files=true, read through the I5a gate under a near-future deadline so a non-resolving body cannot stall the sweep; an unresolved or unparseable body is retain-uncertain. A hello the gate refuses as oversized or malformed is a terminal refusal on the bare flag and retain-uncertain under--force-retain-sweep, which sweeps it with a warning naming the file, its listed size, why it could not be read, and that retain mode could not be ruled out (I5a). Two limits of that warning: a hello whose body outgrows the limit after a listing that showed it within the limit is reported as larger than the limit beside the smaller listed size, and, like the retain warning, it is logged before a close that races the inspection can still cancel the deletes, so a cancelled sweep can log a deletion that did not happen. On any signal the bare flag refuses and--force-retain-sweepis required; otherwise the sweep deletes every protocol-grammar file (this party's and the peer's) viaclient.deleterather thansafeDelete, which is what puts a transport that cannot delete in the transport-failure class (see Error taxonomy) instead of falsely reporting a clean slate, and entry proceeds against a clean slate. Foreign files are never swept. Finally, theignoredset remains the extension point for the in-flight temp writes: a crashed message or ack write is added toignoredrather than rejected andsafeDeleted once entry is accepted -- after (b)'s checks pass with no flag, after (c)'s deletes with the flag -- so a refused entry leaves it in place, and a hello write'stemp-hello-<uuidv4()>.tmpis added toignoredwithout being deleted (see Hello temp disposition above). The sweep therefore matchesisProtocolTempNameminusisHelloTempName; a foreigntemp-*.tmpwhose stem is not a v4 UUID fails the grammar and is handled by the foreign snapshot in (a), tolerated like any other foreign name rather than deleted. The flag's protocol-file deletion and the temp sweep are kept separate; the temp logic is not forked.(d) Leftover abort-marker recognize-and-sweep (delete mode, always). Because every authenticated terminal failure leaves a
<writerId>-abort-<bytes>.jsonthat must persist for the peer to read, a directory reused for a later exchange would otherwise reject "directory not clean". This guardsafeDeletes a leftover marker and adds it toignored-- like the orphaned temp -- matching any well-formed marker: the<id>-abort-<bytes>.jsongrammar (isAbortMarkerName) narrowed to a non-empty recovered id (peerIdFromControlName), under whichever id wrote it and whatever byte count its name states. Matching by id would recognize nothing on the case that matters: after a failed exchange both parties retry under fresh ids, so the leftover names neither of them and neither of their hellos. The names the widened match does not reach keep their existing dispositions: a bare-abort-<bytes>.jsonrecovers no id, is attributable to no party, and stays an unexpected protocol file (exit 64, the same fate as a bare-hello-<bytes>.json); a name that fails the grammar is foreign and is never touched. The sweep runs in delete mode only: in retain mode the marker sits beside a durable audit transcript, so it is left for the strict-empty guard to refuse (exit 64) and the existing--force-retain-sweepgate to clear -- reusing that machinery rather than a parallel retain check so the two cannot drift. In delete mode the markers are recognized at the start of the scan but deleted only once entry is otherwise accepted. With no flag, thesafeDeleteruns after the strict-empty guard and the one-peer-hello check pass. With--sweep-exchange-files, the markers join the protocol files that (c) deletes after its retain inspection. A directory the entry refuses, such as a retain transcript entered by a delete-mode party, therefore keeps its markers. This is a scoped, documented loosening of the entry guard: the match is on the name alone and the body is never read, so a malformed or garbage token -- or a file that merely has a marker's name and no marker envelope at all -- is swept/ignored rather than named and refused by the strict-empty guard. That is acceptable under the threat model, because such a name is inside the protocol namespace the directory is reserved for and is never a foreign name, which (a) still tolerates untouched; it is bounded by the safety argument and the live-peer limit in Abort-marker lifetime above.responsibleFiles+cleanup(). The set of files this party created and must remove onclose()/cleanup(). Pre-track before writing so a crash mid-write is still swept. Retain mode is the sole exception: exchange files are intentionally not swept. No interaction with the entry sweep (site 1): the files it deletes predate the session and were never tracked here, andsynchronize()prunesresponsibleFilesagainst the live listing before the guard runs.poll()scan. Reads the peer's messages:<peerId>-prefix,.json, a message name by the discriminant (isMessageName), and a listed size that has reached the declared byte count. The gate is exact: a smaller listed size waits for the next cycle, while a larger listed size, or a read body whose length is not the declared count, is a terminalUsageError. In retain mode the scan additionally selects only the message whose<NNN>equals the in-memoryrecvSeqcounter, so an already-consumed message still on disk (retain mode never removes it) is skipped rather than re-delivered. A<peerId>-...-<byteCount>.jsonwhose NNN segment is non-numeric is a malformed protocol file -- a correctly configured retain peer cannot produce it once both sides have agreed on retain/timestamp at rendezvous. It is a terminalUsageErrorraised before therecvSeqselection, reported regardless of theunexpected_filespolicy (and not aBilateralModeMismatchError, since by this point the bilateral agreement is settled). Beyond message selection, the scan classifies every file in the listing against a baseline of files legal during the loop -- the recognized-for-the-loop set. That set is both hellos, by the exact names rendezvous agreed on, both acks, the lock, both parties' messages and message-acks, our own writes, an in-flighttemp-<uuidv4()>.tmp(only the protocol's own temp shape; a foreigntemp-*.tmpwith a non-UUID stem is not recognized and falls to the unexpected-file policy unless snapshotted at entry), and both expected abort markers,<selfId>-abort-<bytes>.jsonand<peerId>-abort-<bytes>.json(isExpectedAbortName). The set also holds both parties'<id>-joining-<bytes>.json, by exact name, each stating the byte count of that party's agreed hello: a lock joiner writing without renaming deletes its sentinel only after writing its hello, so the peer's sentinel can still be listed once this party's loop has started, and this party's own outlives a delete that failed untilcleanup()removes it. The two markers are recognized by exact name, the byte count the 32-byte token fixes, and unconditionally, whether or not this party is yet armed to verify one, so an abort a peer writes mid-loop never reaches theunexpected_filespolicy; recognizing a name is not verifying its envelope, which I5a governs. A hello, joining or abort name under either id with any other byte count is not recognized. A file that is not recognized for the loop is an unexpected file, whose disposition isunexpected_files' to decide; the enum and its mode-coupled default are in EXCHANGE_REFERENCE.md. What belongs to this model is the scope: the policy decides disposition only, never routing (I2), and it is local and unilateral rather than bilateral, so the two parties may run different values. A fully-synced message that fails to parse or validate is terminal and stops the poller (see I5b); transient transport and ack-write failures reschedule (anemit-handler throw would too, but the production consumer cannot throw -- see I8). The entry foreign snapshot feeds this site: pre-existing foreign (grammar-failing) names are added to the recognized-for-the-loop set so they are tolerated rather than warned as newcomers. It does not suppress a grammar-matching<peerId>-<digits>.json: that name is a message by the discriminant, so it is selected and terminally rejected by the message scan (see I8), never snapshotted -- the foreign snapshot and the message scan are disjoint by construction.hasOutstandingMessage(insend()). The delete-mode sender waits here for the peer to consume (delete) its last message, and it matches that message by exact name:lastSentFile-- the name this party itself wrote -- present in the listing. Under the one-outstanding-per-direction rule (I9) that is the only legitimate unconsumed own-message, and it is undefined before the first send, when there is nothing to wait for. The exact match subsumes the grammar exclusion the site would otherwise need, since the party's own hello and ack markers are notlastSentFilewhatever their terminal token, and it holds where the discriminant alone would not. A foreign<thisId>-<digits>.json(this party's own id as prefix, numeric terminal) matches the message grammar but is not the name this party wrote, so it cannot hold the sender in the wait. This site therefore consults neither the grammar nor the entry foreign snapshot.close()'s terminal-frame drain matcheslastSentFilethe same way (see Phase 3), so the two waits cannot disagree about which file is outstanding.Rendezvous role and collision logic (
synchronize(): lock path, lockless path, and role commit). Assignsrole/handshakeRole/peerId, detects the two-hello collision, and recovers the peer id from the hello name by a right-anchored parse: everything before-hello-<bytes>.json(parseSizedControlName). The recovered id must be non-empty: a bare-hello-<bytes>.jsonparses to"", which is never a usable peer identity, so it is not a peer hello -- and the sibling bare-joining-<bytes>.jsonis not a joiner arrival. Both the entry guard and the in-flight lock/lockless peer-hello scans (and the lock-path joining-sentinel handling) route their parsing through one helper (peerIdFromControlName, viaisPeerHelloName/isPeerJoiningName) so the non-empty check holds at every site, not just at entry; the post-rendezvous prefix-collision guard additionally fails closed on an empty committedpeerIdas a last gate. This closes a gap where the entry guard rejected a bare hello name but the in-flight scans did not, so one injected after entry (mid-rendezvous, the directory being unauthenticated) could be adopted aspeerId="". That letspoll()key its peer scan on${peerId}-=-and the lockless ack barrier wait on an ack no honest peer writes, or lets a bare-joining-<bytes>.jsonforce the boundedjoinerRecoveryMsstarter stall. Unaffected by session-start directory hygiene: the foreign snapshot, the pre-sweep inspection, and the sweep all run before role logic, and the snapshot is read-only. After a sweep the directory is clean, so this site sees no pre-existing peer hello and the party proceeds as if it arrived first.Every rendezvous read of a peer hello also bounds the two ids' combined length, before any name derived from the peer id is written. The longest name either party writes that holds both ids is the retain-mode ack of a timestamped message. Sized at the highest message counter,
MAX_MESSAGE_SEQ(999999, six digits), and the frame cap's byte count, that name must fit inMAX_FILE_NAME_BYTES(255 bytes, the common NAME_MAX), orpeerIdLengthRefusalraises a terminalUsageErrornaming both ids' byte lengths. The same read refuses an id pair where one id extends the other at a-boundary (site/site-2,peerIdPrefixRefusal), since a name built from one would parse as the other's. The lockless barrier commits only the peer hello it acked (see I1), and the post-commit gate above repeats the prefix check on the committed id as a safety check behind these reads. Both checks apply in both modes. The length check's result depends only on the sum of the two ids, and the prefix check's only on the pair, so both parties reach the same verdict, provided each reads the other's hello. The counter bound is enforced, not assumed: each party sends at most 1,000,000 messages per session, andsend()refuses the next one with a terminalUsageError, so no name outgrows the size this check measures.The refusal follows the symmetric detection of a bilateral-mode mismatch. A refusing party leaves its hello in place, and the lock joiner, which has written nothing when it detects the refusal, first writes its hello beside the peer's, so in either arrival order the peer reads it at its next poll and refuses too. Like a mismatch, the refusal leaves both hellos for the operator to clear, and it degrades the same way when the joiner's hello write fails: the peer then waits out its peer timeout. One case always takes that path: a joiner whose own id is too long to name its hello (
<id>-hello-<bytes>.jsonover 255 bytes) cannot write one. The lockless barrier's changed-hello refusal (I1) instead rolls back this party's own hello and ack, as the more-than-one-peer-hello refusal does, and touches no file of the partner's.
Two orthogonal mode axes
The transport has two independent binary modes. They are bilateral: both parties must choose the same value, and there is no negotiation (see Bilateral configuration).
- Rendezvous mode -- how the two parties meet:
- Lock (default,
lockless_rendezvous: false): atomic exclusive-create plus a-lock.jsontiebreaker; the joiner deletes the peer hello and writes its own. Requires deletion visibility and exclusive-create. - Lockless (
lockless_rendezvous: true): an ack-handshake barrier that uses neither exclusive-create nor delete; both hellos and both acks coexist. For sync-mediated transports where both sides "win" a local create or deletions do not propagate.
- Lock (default,
- Message-loop mode -- how send/consume is signaled:
- Delete-as-signal (default): the receiver deletes the sender's message file; the sender waits for that deletion before sending the next.
- Retain (
retain_files: true): no exchange file is deleted; the receiver writes a zero-lengthackmarker the sender waits for instead. For transports that do not propagate deletions, and for audit transcripts.
Not all combinations are valid. Retain mode requires lockless rendezvous: lock rendezvous is delete-based (the joiner deletes the peer hello as a role-assignment signal) and cannot produce the whole-directory no-delete transcript retain mode guarantees. The three valid combinations are lock + delete-as-signal (default), lockless + delete-as-signal, and lockless + retain. Lock + retain is rejected by schema validation.
Split inbound/outbound directories
By default one directory is both read and written. A split configuration names two: this party reads the peer's files from the inbound directory and writes its own to the outbound directory, with no single shared path. It is for deployments where inbound and outbound folders are separate -- a managed share, or an SFTP server with distinct drop and pickup directories -- bridged so one party's outbound is the other's inbound. Configured as inbound_path/outbound_path (filedrop top-level, or under server for sftp), the pair mutually exclusive with the single path/server.path; see EXCHANGE_REFERENCE.md.
Routing rule. Every peer-file read -- the hello, message, ack, and abort-marker listings and reads -- comes from the inbound directory; every self-write -- this party's hello, messages, ack markers, abort marker, and in-flight temp-*.tmp files -- goes to the outbound directory. The lockless barrier lists only the inbound directory. It prunes responsibleFiles against that listing in delete mode only, and delete mode is always shared mode (split requires retain), so there the inbound listing is also this party's own directory. A message's temp write and its atomic rename both occur within the outbound directory, so no rename ever crosses the two directories (cross-filesystem rename is not atomic). In shared mode inbound and outbound are the same directory, so this reduces to the single-directory behavior exactly. this.path is the inbound directory throughout the code; the outbound directory is resolved via outboundPath (which falls back to this.path in shared mode).
Split mode requires retain mode -- a deliberate constraint, not a mechanical necessity in every topology. On a shared store a delete-mode sender could poll its outbound (which is the peer's inbound) and observe the peer's delete; but that only holds when deletions propagate back, which fails across the one-way or delete-suppressing bridge that motivates separate folders. Retain mode signals with ack/abort markers that are always retained, and keeping the files directly serves the separate-folders goal of an auditable record of what flowed each way. Lock rendezvous is excluded for the same reason it is excluded from retain mode generally: its lock/tiebreaker file must exist in one shared location for the delete-based handshake. Because retain mode already implies lockless rendezvous and timestamped filenames, only the lockless+retain code paths ever observe split directories -- the lock and delete-mode paths are unreachable with an outbound directory configured (rejected at config validation), so they stay on this.path unchanged.
Both directories must be fresh. Retain mode's fresh-directory precondition is enforced on the inbound and the outbound directory at synchronize() entry: peer files never land in outbound (the peer writes to its own outbound, which is this party's inbound), so any protocol-grammar file there is this party's own leftover from a crashed prior session and fails the clean-start guard exactly as on the inbound side. An orphaned temp-*.tmp is swept from both directories once entry is accepted, and left in both when it is refused; a foreign file in either is snapshotted and tolerated. The two directories must differ, checked by one shared textual-normalization rule (pathsResolveToSameDir) at both config validation and open(), so the schema and the live connection give the same verdict (the open() check additionally guards a caller that constructs a connection directly, bypassing the schema). On both channels it catches redundant separators, . segments, trailing slashes, and backslashes (the fold is unconditional, not filedrop-gated), so e.g. /x vs /x/, /x//y vs /x/y, and /x/./y vs /x/y are all caught. The normalization is textual only: .. segments (which may cross a symlink), case (Windows is case-insensitive), and a relative-vs-absolute SFTP path under the unknown login home cannot be resolved client-side and remain the operator's responsibility, not a guaranteed reject.
Invitation-borne role swap. Split directories are a per-party choice the deployment bridges (one party's outbound is the other's inbound), so an invitation cannot hold a single shared path -- but it can hold the role mapping. The invitation connection-endpoint wire format therefore holds the split pair: the sftp and filedrop endpoint sub-schemas (packages/core/src/config/invitation.ts) have inboundPath/outboundPath beside the single path, under the same directory-mode rules the connection config uses (both halves together, mutually exclusive with path, the two halves distinct, and filedrop must name a directory in one form). Like every field of the token, these are camelCase on the wire: the encoder JSON.stringifys the validated token verbatim with no snake_case pass, and the sub-schemas are z.strictObject, so a token minted with inbound_path/outbound_path is rejected by the decoder rather than read. The snake_case spellings belong to the connection config on disk, not to the endpoint. They remain credential-free locators. Distinctness is enforced on the endpoint itself -- it survives the mirror swap (equal inviter halves yield equal acceptor halves), unlike the absolute-path rule, which is a per-party property the acceptor remaps and so stays deferred to the connection config's own validation. The token records the inviter's own pair verbatim; the cross-party swap happens at exactly one consumer, connectionFromEndpoint in apps/cli, which sets the acceptor's inbound_path from the inviter's outbound_path and its outbound_path from the inviter's inbound_path, so the two parties start as mirror images. Centralizing the swap at that single seeding site keeps the token a faithful record of the inviter's config rather than a pre-swapped acceptor view, and forecloses a double-swap -- the offline invite path calls the same function with no endpoint, so it never reaches a seeded branch. The seeded block also holds the retain-mode trio a split exchange requires (so it is runnable once credentials are filled in); the inviter's literal paths are a starting point the operator reconciles, since the swap fixes the roles, not the literal names -- in an asymmetric topology the paths, host, and even channel differ between parties. An SFTP login-home (unset) path has no concrete value to mirror, so such an endpoint conveys roles with no suggested path rather than erroring. Two accept paths consume that swap, both through this one function so the single-swap-site invariant holds across them: the offline accept seeds its whole connection block from connectionFromEndpoint directly, and the online accept (applyEndpointSplitDirectories in apps/cli) calls the same function only to lift out the swapped pair, then grafts that pair onto the connection it built from the acceptor's own URL. The online merge keeps the channel, host, port, and credentials from the URL (the endpoint is credential-free, and a bridged acceptor may reach the rendezvous over a different host or even channel). It takes the split inboundPath/outboundPath from the endpoint -- fully replacing the URL's single path, which cannot express a role mapping and whose inbound leg, if it won, would break the mirror. It also layers the retain-mode trio over any URL-derived options. An explicit --outbound-path (which instead routes through applyConnectionOverrides) is the override and wins, so the merge is skipped whenever it is set. The grafted split connection is re-validated through the connection schema before any network activity, so a degenerate endpoint (e.g. relative filedrop paths the endpoint schema defers) fails with the schema's own message rather than at connect time. The accept-time reconcile of a pre-existing config diffs against this merged connection, not a URL-only target, so a location disagreement still aborts before any acceptance is sent.
No token-version bump. Adding inboundPath/outboundPath to the z.strictObject endpoint sub-schemas is in principle an incompatible wire-format change -- an older decoder rejects the unknown keys rather than ignoring them, unlike a non-strict top-level addition. The invitation-token version was nonetheless left at "1": the change landed while Alcove is pre-release with no decoder deployed in the field, so no reader can observe the incompatibility and a bump would only churn the version. This is a one-time pre-release allowance, recorded in the version policy comment in invitation.ts; a strict-endpoint addition made after a release ships must bump the version (or otherwise stage compat). What is newly disclosed on the wire is the inviter's own inbound/outbound directory paths -- public locators with no credential or server-identity material, exactly as the single path already did.
Retain-mode declaration on the token
The inviterRetainsFiles top-level field. The invitation token has an optional top-level inviterRetainsFiles: boolean (camelCase on the wire, beside connectionEndpoint): the inviting party's statement that its own retain_files is on, so the exchange keeps every file it writes and the rendezvous location becomes a permanent transcript rather than being emptied as each message is consumed. It is the one disclosure of this transport that outlives the run, and the invitation is the only artifact that always reaches a partner, so it is stated where the partner decides.
It is a declaration, and nothing applies it. No accept path reads it into a connection: the accepting party sets its own half exactly as before, and a disagreement fast-fails at the hello with BilateralModeMismatchError, identically to an invitation with no declaration at all. The enforcement model is untouched -- see Bilateral configuration, which records why disclosing a bilateral flag stays inside the detect-and-fail boundary.
Three states, and absence is not the negative. true is the declaration; false is a stated delete mode; an omitted field is no declaration. Alcove's mint paths write only true, and only from a configuration that has retain mode on -- the online alcove invite reads its post-override connection, the config-as-source invite reads connection.options.retain_files off the config the exchange will run, and the console reads the options block the run holds ORed with the endpoint's shape (below). That is a description of the three producers rather than a rule they are held to, with one exception the mint checks: where the endpoint holds the split pair, the declaration is not theirs to omit (below). Every other path omits it: an offline invite whose written connection block is still a placeholder scaffold has no settled mode to declare, a webrtc mint has no retain mode to declare, and a token minted before the field existed has nothing. Of the three, only true is stated: false and absent render nothing on the declaration's own account, because delete mode is not the mirror claim (a run killed outright, or one that fails after the handshake, leaves files behind in either mode) and a stated negative would promise a cleanup this transport does not make. The endpoint beside the field is the second ground on which the same line renders.
The display's second ground: the endpoint's shape. An acceptance surface states the retention where the token declares true, and also where its connectionEndpoint holds the split inbound_path/outbound_path pair -- a shape no connection can be built from without retain mode, since a separate outbound directory requires retain_files: true (Split inbound/outbound directories) and the accept paths seed exactly that trio. Both the seeding and the display read one predicate, endpointRequiresRetainedFiles in packages/core/src/config/invitation.ts, so the shape that puts a party into retain mode and the shape that is stated to it cannot drift apart. The case it covers is a token with a split endpoint and no declaration: Alcove's own mints cannot produce that pair, because the mint-only refusal below rejects it, but a foreign or older token can, and on the declaration alone the accepting party would be seeded into retain mode with the consent line silent. The rendered sentence is the same fixed first-party copy on either ground: a split rendezvous cannot be run in delete mode on either side, so the mode the copy states is the mode any run of it is in.
One flag only. lockless_rendezvous is equally bilateral and equally fast-failing and is not included: nothing about it changes what an acceptor consents to, and the token's declaration list is held to facts with consent weight. That limit is the recorded line against flag-advertisement drift; adding a second flag means revisiting it here rather than following the precedent.
Refused on a webrtc endpoint. A token pairing inviterRetainsFiles: true with a webrtc connectionEndpoint is rejected at both encode and decode by the token schema's own refine: retain mode is a file-sync setting the WebRTC transport does not have, so the pair would state a mode no run of that token could be in. This is the operative gate, not a mirror of one on the connection side. ConnectionConfigSchema has a corresponding defense-in-depth refine over a webrtc connection's options.retainFiles, but a webrtc connection's options parses through SharedOptionsSchema, which declares no retainFiles field and so strips it before that refine ever sees it. A hand-authored alcove.yaml pairing channel: webrtc with options: {retain_files: true} loads successfully and runs in delete mode, with the field silently dropped rather than refused. A token with no endpoint at all is unconstrained -- the offline file-sync invite holds the declaration with no locator beside it.
Refused as a false declaration on a split endpoint. The mirror refusal on the file-sync side, by a second refine on the same schema and likewise at both encode and decode: a token pairing inviterRetainsFiles: false with a connectionEndpoint holding the split inbound_path/outbound_path pair is rejected. That endpoint's shape seeds the acceptor into retain mode whatever the token says -- the refine reads endpointRequiresRetainedFiles, the same predicate the accept path's own seeding reads -- so the negative states a mode no run of that token could be in, exactly as a true on webrtc does. Scoped to the explicit negative: a token omitting the field on the same endpoint stays valid to decode, since absence is no declaration rather than a contradicted one, and it is the very pair the display's second ground above exists to cover. The refusal is keyed on the shape, not the channel: a single shared path runs in either mode, so a stated false beside one decodes. And it binds the field's declared spelling only: the token body is non-strict and folds case only for the keys inside linkageTerms, so an unrecognized spelling of the declaration -- inviter_retains_files among them -- is dropped in decode rather than read, landing the token in the no-declaration state the display's second ground covers.
Refused at mint when a split endpoint leaves it undeclared, and only at mint. The one rule that is not symmetric between encode and decode: encodeInvitation validates against a mint-only schema (MintedInvitationTokenSchema in packages/core/src/config/invitation.ts) that requires inviterRetainsFiles: true on any token with a split inbound_path/outbound_path endpoint. Emitting such an endpoint hands the partner a permanent transcript that only the locator's shape reveals, and any artifact composed from the declaration rather than from the shape -- an accept kit's file-handling disclosure among them -- would then state nothing about it. The rule lives at the single boundary every mint path reaches, the CLI's three and the web app's alike, rather than as a gate each producer restates.
Decoding is untouched: the same token still decodes, and the acceptance surfaces still state the retention on the endpoint's shape (the display's second ground above). Absence is no declaration on a decode path, every Alcove read path derives the retention from the shape rather than from the declaration, and the token is a public protocol surface -- so refusing it there would reject a foreign token Alcove already handles and displays correctly, buying nothing. The asymmetry is the point: Alcove holds itself to declaring what it hands over without demanding the same of an implementation it does not control.
The web mint (generateInvitation in apps/web/src/psi/invitation.ts) derives the declaration rather than leaving it to its caller: invitationDeclaresRetainedFiles ORs endpointRequiresRetainedFiles over the endpoint into the retain flag it was passed, and both partner-facing surfaces -- the token and the accept kit's file-handling disclosure -- read that one value, so a split endpoint minted with the flag omitted declares the retention instead of failing this check.
The CLI's mint paths need no such derivation, and a test holds each fact that keeps it unnecessary. The online invite's connection reaches encodeInvitation through applyConnectionOverrides, which refuses --outbound-path without retain mode by a flag-named message (outboundPath without retain mode is rejected naming --retain-files, apps/cli/test/unit/config.test.ts) and re-validates the merged connection through ConnectionConfigSchema, whose split-requires-retain refine is held by the two a separate outbound directory without retain_files is rejected cases in packages/core/test/config/connection.test.ts. The config-as-source invite emits no connectionEndpoint at all, so the shape it would be checked against is absent (validateInvite: a file-sync config-as-source invite emits no connection endpoint, apps/cli/test/unit/commands/invite.test.ts).
No token-version bump (top-level, like connectionEndpoint). An optional field at the top level is ignored by an older decoder's non-strict z.object, so it takes the backward-compatible path the version policy describes rather than the one-time strict-endpoint allowance the split pair needed. What the field puts on the wire is a setting the inviter advertises in its hello a few seconds later regardless; what it moves is the point at which the partner sees it, from after the acceptance to before it.
The classification the two acceptance surfaces render it under -- enforced on the mode agreement, the inviter's word on what becomes of the transcript -- is the retainedFiles entry of packages/core/src/consent/consentFacts.ts, with the reasoning in shared-consent-summary.md.
Phases and legal directory contents
Read each phase as "given this mode, the directory may legitimately contain exactly this set; anything else is an error or a not-yet-synced transient."
Phase 0 -- clean start
Legal contents at entry: nothing this protocol owns, except at most one peer hello (the other party may have arrived first), plus any foreign files, which are snapshotted and tolerated. The preexisting-file guard (site 1) aborts in both modes, with one UsageError, on any other protocol file (full list in I0). Tolerating the peer hello is what lets whichever party arrives second -- it always sees the first party's hello -- proceed instead of failing. With --sweep-exchange-files, the legal-contents rule is replaced by a clean slate: once the pre-sweep retain-signal inspection clears (or --force-retain-sweep is supplied), every protocol file is deleted and entry proceeds as if the directory held only the untouched foreign files.
Phase 1 -- rendezvous, lock path
sequential arrival -- no lock is created
A put A-hello-<n>.json
B list -> sees A-hello-<n>.json (B is the joiner)
B write B-joining-<n>.json (temp-then-rename) ; B delete A-hello-<n>.json ; B rename B-joining-<n>.json -> B-hello-<n>.json
A list -> its own hello gone, B-hello-<n>.json present ; A reads it, commits
starter/responder, deletes it
simultaneous arrival -- the two-hello collision, and the only path that writes a lock
A put A-hello-<n>.json ; B put B-hello-<n>.json (neither had seen the other's)
both list -> both hellos present
both assign roles by hello filename order and reconstruct
<first>-<second>-lock.json, then race createExclusive on itLegal transient contents: one self hello, optionally one peer hello, optionally one lock file, optionally one joiner sentinel. The joiner closes the one observable inconsistency window -- peer hello gone, joiner hello not yet written -- with a <id>-joining-<bytes>.json sentinel: it writes the sentinel (holding its hello body), deletes the peer hello, then renames the sentinel to its own hello. The rename is atomic at the SFTP layer, so on a delete-visible transport the sentinel spans exactly that window. A joiner writing without renaming writes its hello directly and then deletes the sentinel, so the peer can list both until that delete lands. A sync-mediated transport may briefly expose both the sentinel and the renamed hello, which the peer tolerates because the lingering sentinel's id matches the peer hello it is completing against (a sentinel whose id matches no peer hello is third-party contamination and is rejected as a usage error, like a second peer hello or lock file). The peer's wait loop treats a <peerId>-joining-<bytes>.json as "joiner mid-arrival" and waits a bounded recovery window (joinerRecoveryMs, default 30s) for the rename to land. If the sentinel persists past the window the joiner failed mid-arrival -- after writing the sentinel but before writing its hello, on either side of the delete -- and the peer aborts with a distinct transport error (CLI exit 69) instead of polling to the full peerTimeoutMs. The joiner releases the sentinel from responsibleFiles the moment it deletes the peer hello, so a failure after that point leaves the sentinel on disk as the peer's recovery signal (and, if the joiner process dies, for the next run's Phase 0 guard to reject); a failure before that point is swept by cleanup() with the peer hello still intact. The peer never re-creates its own deleted hello on recovery -- that would race the joiner's rename and trip the two-hello collision check (I1). The filename-order rule is scoped to the collision branch: there, and only there, role is derived from hello filename order (thisFile.name < otherFile.name), which is also the order both parties encode into the lock name so they reconstruct the same one. It is never a fresh id comparison, because for ids where one is a prefix of the other the two orders can diverge. The sequential branches compare nothing -- the joiner that consumed the peer hello commits initiator/joiner and the party whose hello it consumed commits responder/starter, each unconditionally.
Phase 1 -- rendezvous, lockless path
A put A-hello-<n>.json ; B put B-hello-<n>.json (neither deletes the other)
each, on seeing the peer hello, writes a zero-length ack named after it
(A writes A-B-hello-<n>-ack.json; B writes B-A-hello-<n>-ack.json)
each completes when the ack of its OWN hello exists in the listing
(A waits for B-A-hello-<n>-ack.json; B waits for A-B-hello-<n>-ack.json)Each party writes its own hello temp-then-rename (temp-hello-<uuidv4()>.tmp -> <id>-hello-<bytes>.json), on this path and the lock path alike, so the final name never appears with a partial body and a hard kill mid-write leaves only the temp.
The ack is a zero-length marker matched by name existence -- no body is read, and there is no read gate (a zero-byte file has no partial-sync window: its name appearing is completion). Legal contents: both hellos and both acks coexist and persist into the message loop (they are not deleted at rendezvous). This is why the poll() scan (site 3) must rely on the grammar discriminant: the party's own <id>-hello-<bytes>.json and ack share its message prefix and would be false positives under a bare <id>-* glob. Site 4 excludes the same files by waiting on lastSentFile by exact name (see I2).
Phase 1 -- terminal rendezvous failure
A rendezvous that fails terminally rolls back this party's own rendezvous artifacts and nothing else: its <id>-hello-<bytes>.json, the <myId>-<peerId>-hello-<bytes>-ack.json it wrote if it had already seen a peer hello, and its lock file on the lock path. The peer's hello and ack are left untouched -- they are the peer's to remove -- as are foreign files, with two exceptions on the lock path: the abandoned-lock failure deletes the peer's hello (the second carve-out below), and a bilateral-flag or id-pair refusal found at the lock deletes the peer-written lock, a transient rather than an advertisement, and leaves both hellos. This holds in both message-loop modes, and in retain mode it is the third of the three sites that can remove a file from the directory, alongside cleanup() (a no-op there) and the --force-retain-sweep-gated entry sweep (I4b).
Deleting them is correct in retain mode, where every other deletion is reserved for an explicit operator escalation, for three reasons:
- There is no transcript. A terminal rendezvous failure means the message loop was never entered, so what is on disk is the failed attempt's own scaffolding -- a hello with two booleans, and a zero-length ack -- not the durable record retain mode exists to preserve. Retain's no-delete contract governs an exchange; this attempt produced none.
- The artifacts are indistinguishable from a live peer's. The directory has no entry-time liveness signal (I1), so an abandoned hello reads to the next entrant exactly as a peer that arrived first, and the real partner's later arrival trips the at-most-one-peer-hello guard -- attributing the operator's own residue to a third session. What the entrant then does with it is mode-dependent (see Entry-present peer hello below); in neither mode does it produce an exchange.
- It is retain mode's only rollback.
responsibleFilesis never populated in retain mode (everyaddis guarded -- see I4a) andcleanup()returns before deleting, so nothing downstream removes these files. Without this site the mode with the strictest entry precondition (Fresh directory in retain mode) would have no unwind at all, and every failed attempt would block the next one.
There are two carve-outs:
- A
BilateralModeMismatchErroror an id-pair refusal (length or prefix) keeps its advertised hello as the directory's terminal state in both modes (see Bilateral configuration and the id-length bound at site 5). - An abandoned lock removes the peer's hello as well. On the lock path, when
createExclusivefails withEEXISTand the lock is then gone, the peer claimed the lock and abandoned the handshake. This party deletes the peer's hello with its own before the terminalUsageError, whose recovery step is a retry. Left in place, the abandoned hello would be an entry-present peer hello to that retry, and the lock joiner fast path would consume it and stall in the key exchange (see Entry-present peer hello below). The lock path runs in delete mode only, so this never touches a retain transcript.
The rollback is issued through safeDelete, which swallows a transport-level delete failure, and it runs in-process: a hard kill (SIGKILL, OOM, power loss) leaves the artifacts behind, exactly as it leaves a delete-mode session's responsibleFiles unswept. Crash residue is this rollback's stated limit, not a case it covers; what the next run makes of it is the entry-present peer hello case below.
Phase 1 -- entry-present peer hello
A peer hello already in the inbound directory when a run scans it is the one protocol file I0 tolerates, and the only one whose writer has demonstrated a propagation leg. It is also exactly what an interrupted run in this directory leaves behind, and the two are indistinguishable on disk. What the entrant does with it turns on the mode:
- Lock rendezvous (the default). The joiner fast path consumes it -- reads the body, deletes the hello, renames its own sentinel into place, and commits role/peerId -- against a party that may not exist. Nothing in the rendezvous waits, so the stall lands in the key exchange. The directory is left holding nothing of the leftover, which is why this failure is self-limiting after one run.
- Lockless rendezvous (and therefore retain). Both hellos coexist; the entrant writes its ack of the leftover and waits for an ack of its own that no one will write. Its own terminal rollback then removes exactly what it wrote, returning the directory to the same single leftover -- so without a bound this repeats on every invocation for as long as the directory is reused.
What each mode's failure looks like to the operator, and the recovery for it, are in EXCHANGE_REFERENCE.md "Directory exclusivity".
The bounded window. That repetition is why the lockless barrier bounds the wait when the peer hello was present at entry: if it has not acknowledged this party's hello within the window, the rendezvous fails terminally (a UsageError, naming the file and the recovery step) instead of polling to peer_timeout_ms. It is armed on the entry-present case alone -- a hello that appears after entry is an ordinary peer arriving and is never timed -- and the leftover is not deleted: a hello this party cannot prove is its own is not its to remove, and --sweep-exchange-files remains the operator's assertion that no concurrent session is using the path. The terminal rollback of this party's own artifacts is unchanged.
The window is derived from the operator's own remaining budget rather than fixed, so it is never longer than they asked for and scales with the transport they configured:
window = max(rendezvousBoundMs(ENTRY_HELLO_ACK_WINDOW_MIN_POLL_CYCLES),
remaining budget * ENTRY_HELLO_ACK_WINDOW_FRACTION)
deadline = now + window, armed only while window < remaining budgetwith ENTRY_HELLO_ACK_WINDOW_FRACTION = 1/8 and ENTRY_HELLO_ACK_WINDOW_MIN_POLL_CYCLES = 6 (fileSyncRendezvous.ts, the only place either is set). At the defaults that is 7 m 30 s, against the full hour it replaces on every invocation. The measurement is taken from the moment this party's own hello is on disk: before that there is nothing for a live peer to acknowledge.
The deadline is armed strictly inside the peer budget or not at all: arming requires the window to fit strictly inside the remaining budget, so now + window cannot reach the peer timeout. That guarantee comes from the strictness of the comparison, not from now and the remaining budget sharing one clock reading -- a second, later reading only shrinks the remaining budget the window is checked against, so a window that clears the stricter check still leaves the deadline strictly under the budget. now and the remaining budget are read once regardless, as the simpler shape to reason about: a budget that cannot hold the window arms nothing, and an exhausted budget is therefore reported by the peer timeout rather than as a residue reading.
The wall-clock floor under every rendezvous bound. rendezvousBoundMs(cycles) is max(cycles * poll interval, joinerRecoveryMs), and it holds both this window's floor and the I5a rendezvous read bound below. A poll interval is how often a party looks, which is independent of how long the transport takes to answer; a bound counted purely in cycles therefore expires inside a single round trip whenever the configured cadence sits well below the server's latency. Measured against two live connections over a latency-asymmetric transport, a purely cycle-derived window aborted a genuinely live partner mid-round-trip in about 650 ms and named its hello for removal. A party cannot calibrate this from observation either -- the slow side is the partner, whose operations it never sees -- so the floor is wall-clock. It reuses joinerRecoveryMs (default 30 s) rather than introducing a second constant, because that value already answers exactly this question for the lock path: how long a peer's write-and-rename may take to land on this transport. Neither use extends a wait past what the operator configured -- the I5a read bound is capped at the remaining peer budget, and the window above is armed only while it fits strictly inside that budget -- modulo the transport operation already in flight when the budget expires, because the budget is checked between transport operations and never inside one. On a high-latency transport that overshoot is measurable: a lockless rendezvous against an entry-present hello, at a 5 s peer_timeout_ms and 2 s per transport operation, runs a little over 8 s before the ordinary timeout is reported. It is a property of the loop's between-operations budget check, which every wait here shares, rather than of the floor. On a budget too small to hold the floor, the ordinary peer timeout fires instead, with its ordinary message rather than a residue attribution.
Limit -- the window remains a wall-clock heuristic. The floor makes a false abort improbable, not impossible: a partner whose round trip exceeds joinerRecoveryMs on a budget large enough for the window to fire is alive and mid-answer when it fires. Nothing local distinguishes that partner from residue, which is why the terminal text names residue as a reading rather than a finding, puts a re-run ahead of the removal, and conditions the removal on the file surviving that re-run.
Attribution. Because the lock path commits role/peerId with no observation attributable to a live peer, the connection states the fact it does hold: unconfirmedEntryPeerHello names the entry-present hello until the first such observation clears it (the peer's ack of this party's own hello, or a peer message delivered to the application). A consumer that would otherwise attribute a later silence to the peer -- the CLI's peer-silence guidance -- reads it first and names the leftover and the local recovery instead, rather than sending the operator to a partner that may never have been there.
A configured peer_id sidesteps all of this, with or without fields in it: the leftover then holds an id this party recognizes as its own, so it is a self-hello the entry guard refuses immediately (exit 64) naming the file, before any wait. Being filename-based, that refusal is unaffected by a torn body. When an operator should set it is in EXCHANGE_REFERENCE.md.
Phase 2 -- message loop, delete mode
The sender writes temp-<uuid>.tmp, renames it to the final numeric-terminal name (so a watcher never sees a partial file under its final name), and then blocks in hasOutstandingMessage until the peer deletes it. The receiver's poll() reads one peer message once its on-disk size reaches the declared byte count, parses it, deletes it, and only then emits data. Parsing first leaves a corrupt fully-synced message on disk for inspection instead of destroying it (I5b); deleting before the emit keeps the sender's go-ahead independent of whether the local hand-off succeeds. Legal contents: at most one outstanding message per direction.
A failed consume-delete is retried once after one poll interval. A retry that finds the file already gone counts as a successful delete, since the first attempt can take effect on the server with its reply lost; the message is emitted. If the retry fails for any other reason, the message is not emitted: the file is still on disk, so the next poll would read it again, and the sender never gets its go-ahead. poll() stops and raises a terminal UsageError. The error says that delete mode needs an account that can delete files in the exchange directory, and that the alternative is retain mode on both sides. The typical cause is an upload-only SFTP account. A UsageError from either delete attempt, such as a stall deadline, is terminal immediately.
Phase 2 -- message loop, retain mode
The write path is the same as delete mode, but the receiver never deletes the message file. Instead it writes a zero-length ack marker named after the consumed message (<myId>-<consumedMessageName>-ack.json) before emitting data. This is the same construct as the lockless rendezvous ack, applied to a message instead of a hello: only the receiver writes one (delete mode signals the same thing by deleting the file).
This ack is a durable-received acknowledgment, not an application-consumption one. By the time it is written the message is a fully-synced file that retain mode never deletes, so it is already durable. That is what licenses writing the ack before the data hand-off, and it keeps the sender's go-ahead independent of whether the local hand-off succeeds. The order must not be flipped to emit-before-ack: an ack-write failure after a successful emit would re-deliver an already-consumed message. Conversely, because the ack precedes emit while recvSeq advances only after a successful emit, the ack write is idempotent per <NNN> -- a message reprocessed after a transient receive failure is not acked twice. The marker name is also deterministic per message (a pure function of the consumed message's fixed name), so even if the per-<NNN> write guard were bypassed the re-write would reuse the identical name and create no duplicate file; the guard only saves a redundant put+rename.
The sender gates its next send on the existence of the ack of its just-sent message, located by constructing the expected name <peerId>-<lastSentStem>-ack.json from the stem it already holds in lastSentFile -- not by parsing a marker on disk. The ack is zero-length, so the gate is name existence rather than a byte-count comparison. No exchange file is deleted as a protocol step.
Because no message is ever deleted, every consumed message stays on disk on every transport -- not only those that fail to propagate deletions. The receiver therefore tracks the next unprocessed message with an in-memory per-session counter, recvSeq: it processes only the peer message whose <NNN> equals recvSeq, then increments it. That counter stays aligned with the peer's monotonic <NNN> sequence only because of the clean-directory precondition (I0), on which the I8 counter shadow rests.
Phase 3 -- cleanup and close
close() calls cleanup(), which removes every file in responsibleFiles. In retain mode it removes nothing at all: it returns before deleting, because the directory is the durable transcript. The in-flight temp-*.tmp is outside cleanup() in both modes -- it is never tracked in responsibleFiles, so cleanup() never sees it; it is swept inline in send()/writeAck() instead (see I4b). close() and cleanup() must not diverge on this.
Before cleanup(), in delete mode the side that sent the terminal frame drains it: it polls the directory until the peer consumes (deletes) lastSentFile, so a clean close never sweeps a frame the peer has not yet read. The drain is bounded by a short fixed budget TERMINAL_FRAME_DRAIN_TIMEOUT_MS (60s), applied as min(TERMINAL_FRAME_DRAIN_TIMEOUT_MS, inactivity_timeout_ms) measured from when the drain begins (after the abort-marker decision gate, not the first instruction of close()) -- not the full inactivity_timeout_ms (default one hour). At teardown the exchange result is already computed and persisted, and on timeout cleanup() deletes the frame as a fallback, so the drain protects nothing durable (durability is decoupled from deletion). A longer bound would only make a clean close against a crashed or departed peer block for up to the hour before exiting, with no gain, while a peer that legitimately needs the frame reads it from cleanup()'s fallback delete on its own next listing. The constant is sized to a sync tool's flush latency (the peer's poller listing, consuming, and the deletion propagating back), on the order of the per-operation liveness bounds, and is internal-only (not a config setting) like joinerRecoveryMs; it would only need revisiting if a transport's round-trip propagation routinely exceeded it, re-introducing the premature-sweep race the drain exists to avoid. Each drain list() is additionally raced against the time remaining to the deadline (not its own fresh inactivity_timeout_ms boundTransport budget, which may now be far larger than the drain window), so a list issued late in the drain cannot run past the deadline. Retain mode skips the drain entirely: it never deletes a *.json, so the final send persists as transcript and the peer reads it whenever it next lists. The min() cap means a inactivity_timeout_ms configured below the fixed budget still bounds the drain at the smaller value, never longer than the operator asked.
close() ends the transport last, after cleanup(). That wait is bounded on the same terms as the drain and for the same reason, by CONNECTION_CLOSE_TIMEOUT_MS (30s) applied as min(CONNECTION_CLOSE_TIMEOUT_MS, inactivity_timeout_ms), internal-only rather than a config setting. It bounds core's wait for a transport it does not own, not the connection itself -- abandoning the wait closes nothing -- so a transport holding a session is obliged to bound its own close and close from its own side (FileTransportClient.end); the SFTP channel's forced close is specified in SFTP_SESSION_RECOVERY.md. A rejection is logged at debug and teardown proceeds, so a transport that cannot finish closing never changes what the run produced or reported.
Above those two waits sits the run's own teardown ceiling, 3 min on both file-based channels: the point at which a finished CLI run stops waiting for its transport to close, whatever is still running beneath. It is above the drain (60 s) and the transport close (30 s) together, with the SFTP adapter's own client-close, forced-close and deferred-cleanup drains smaller again, so reaching it means a close overran every bound beneath it. Reaching it stops the wait and reports it on the operator log, and changes no exit code; where that close sits against the run's terminal event, and the budget within which the process then returns, are in CLI_EVENTS.md and WEBRTC_TRANSPORT.md.
Session lifetime across an idle boundary
The state machine above holds whether the transport keeps one session for the whole exchange or releases it at the loop's idle boundaries. What a releasing transport adds is stated here: when the loop's session exists, and how teardown obtains one. On the sftp channel the connection_per_poll option selects that behavior; when an operator should choose it is in EXCHANGE_REFERENCE.md, and what a run counts and warns about in SFTP_SESSION_RECOVERY.md.
The option is local, not bilateral, and so is outside the state machine's shared alphabet. How a party dials leaves no trace in the shared directory: it writes no file, and the hello envelope holds the two bilateral flags only (see the file taxonomy), so connection_per_poll is not advertised and a difference between the parties cannot be detected -- or fail -- at rendezvous. One party may cycle its session while the other holds one, with no effect on which filenames are legal at any phase. This is why the option appears nowhere in the phases, the invariants, or the preconditions, and why it is not subject to the bilateral stance in Bilateral configuration.
The per-cycle lifetime is the poll loop's property. A session is established at the start of each poll cycle and released before the loop goes idle again, so it need only survive that cycle's operations rather than the whole exchange. Two idle stretches are still spanned by one session: the rendezvous that precedes the loop, which holds one across its wait for the partner, and a boundary reached while an operation this side has issued is still unsettled, which keeps its session rather than cutting that operation off the wire. What a boundary is held for, and what ends a hold, are in SFTP_SESSION_RECOVERY.md.
Every boundary has exactly one outcome. One invocation of the transport's idle release is one boundary, and it concludes in exactly one of the outcomes below -- the first of them that applies, in the order given, where "the session cleared" in the three rows that turn on who ended the transport means it cleared under the release's own end(), so a session that clears only once this side destroys the transport is forced. apps/cli/test/unit/connection/ssh2SftpAdapterIdleBoundaryPrecedence.test.ts holds that order: it reaches each outcome on its own, then stages the conditions of an earlier row together with those of later ones and asserts the earlier row is the one recorded. What belongs here is which of them leave the loop a session and which do not; what each does to the operator-facing accounting, and what is counted, capped, exempt and bounded, is in SFTP_SESSION_RECOVERY.md. A new outcome appends a row. A transport that keeps one session for the whole exchange releases nothing, so it reaches no boundary at all.
| outcome | reached when | the loop's session after |
|---|---|---|
declined | its bounded wait for the session transition ahead of it expired | unchanged, and possibly still live -- whatever that transition leaves |
skipped | it reached the front of the queue with teardown already latched | teardown's |
held | an operation this side had issued was still unsettled | live, and spanning this gap |
alreadyEnded | no session to release, its generation already ended and charged to whatever took it | none |
noSession | no session to release, its generation still live -- a partner-side drop this boundary is the first thing to observe | none |
closedByPeer | the peer's FIN was already consumed, so the release's own end() closed nothing, and the session cleared | released |
releasedOverEndedTransport | this side's write half had ended without this release -- the withheld-close drop's shape -- and the session cleared | released |
released | neither half had ended, so the release's own end() ended the transport, and the session cleared | released |
didNotClose | the session did not clear and the transport is still writable, which that end() should have ended | live, and held across this gap |
destroyDidNotClear | this side destroyed the transport and the session still did not clear | live; the release raises |
forced | this side destroyed the transport past the release's bound and the session cleared | released |
The three that turn on who ended the transport are classified before the close is driven, because an operation already on the wire is torn by that close and reads the boundary while the release is still running; forced leaves that classification standing, since the forcing says how the boundary concluded rather than who ended the transport beneath it.
Teardown obtains a session ahead of the drain's deadline. The release runs at the reschedule and only while the poller is still active, so a cycle whose poller was stopped before it reached that boundary -- by close() or by a terminal error -- keeps its session, while a close() reached from the inter-poll gap finds the session the last boundary released. close() therefore drives the transport's re-establishment before the terminal-frame drain's clock starts: billed to that budget instead, a re-dial handshake could expire the drain and drop the terminal frame to cleanup()'s fallback delete. It establishes nothing where a session is already live, and nothing on a transport that keeps one for the whole exchange or holds none at all; it is best-effort and non-throwing, as close() must be -- a failed or refused re-dial leaves teardown exactly where a still-released session leaves it.
The abort-marker write obtains one the other way. It is not preceded by that re-establishment: it rides the transport's own within-cycle recovery instead, because a re-establishment ahead of it would race the write's own re-dial on the one shared session. The two teardown writes are asymmetric by construction -- the drain's session is established for it, the marker's is recovered under it -- and the marker write is the earlier of the two, running ahead of both the drain and that call.
Poll interval and step pace
A party learns of the partner's file only by listing the directory, so every message waits for the partner's next poll. This section records what that costs one linking-key step and the defaults set from it, defaultPollingFrequencyMs in packages/core/src/connection/fileSyncConnection.ts.
The measurement
The run. Two alcove quick-exchange processes built from this repository ran a filedrop exchange over one local directory in the dev container, on 10 records each from test_data/ (all 10 matching), under the default cascade strategy: 30 linking-key steps. A module preloaded into each process (node --import) recorded every timer armed at the poll interval and every fs/promises call, each with its start and end. A step is the window between the first party's stage and stageEnd events on --event-stream. There was one run per interval, on 2026-10-25, in delete mode unless the row says otherwise.
The attribution. Within a step, a message file on disk that its reader has not yet opened is a discovery wait. A sender's wait for its previous message to be consumed (delete mode) or acknowledged (retain mode), outside a discovery wait, is a send-gate wait. A file operation of either party outside both waits is file I/O, and the rest is computation. Each stretch of the step spent in a wait is one sequential poll wait.
Mean per step, in milliseconds; the last column is the whole run, rendezvous and output included:
| Interval | Step | Discovery waits | Send-gate waits | File I/O (of it, writes and renames) | Computation | Sequential poll waits | Whole run |
|---|---|---|---|---|---|---|---|
| 1 ms | 118 | 25 | 2 | 74 (16) | 17 | - | 5.0 s |
| 50 ms | 458 | 375 | 49 | 11 (4) | 23 | 8.9 | 14.5 s |
| 100 ms | 888 | 722 | 132 | 10 (4) | 23 | 9.6 | 27.4 s |
| 250 ms | 2083 | 1737 | 288 | 17 (8) | 41 | 8.1 | 64.1 s |
| 250 ms, retain mode | 2363 | 2032 | 261 | 45 (7) | 25 | 8.9 | 72.2 s |
250 ms, the filedrop default (no flag) | 2204 | 1789 | 381 | 10 (4) | 23 | 8.3 | 67.9 s |
| 500 ms | 4262 | 3484 | 741 | 13 (5) | 24 | 8.1 | 129.5 s |
| 1 s | 6924 | 5117 | 1745 | 23 (9) | 39 | 6.8 | 210.4 s |
5 s, the sftp default | 31209 | 21033 | 10113 | 20 (8) | 44 | 6.2 | 947.6 s |
The 1 ms row is the floor without poll waits: its polls do little but list the directory, so its file I/O is mostly listings, and a stretch of waiting there is too short to count.
What a step is made of. A step writes 9.2 message files on average across the two parties. At most one message per direction is outstanding (Phase 2), so a step's messages are sequential: each costs the reader's next poll, and a party sending two in a row first waits for the partner to consume the first. Poll waits are 92% of a step at 50 ms and 97% or more from 250 ms up. At 10 records, computation is 17 to 44 ms a step, and writes and renames are 4 to 16 ms.
Why a wait costs nearly a whole interval. Each party schedules its next poll one interval after its previous poll ends, so the offset between the two parties' polls drifts only slowly. In every run up to 500 ms the median offset was 1 to 4 ms, less than the time a party takes to write its reply after the poll that brought the partner's message. The reply therefore lands just after the partner's own poll of that instant, and the partner finds it one interval later rather than half an interval later on average. At 1 s and 5 s the median offset was 48 and 81 ms, long enough for a reply to land before the partner's next poll in one direction, and a step took 6.2 to 6.9 intervals rather than 8.3 to 9.5.
The Windows share. The 2026-10-24 lab run over an SMB share on the same LAN, at the 5 s interval in retain mode, took 30.7 s per step and 967 s in all; the local run at 5 s took 31.2 s per step and 948 s. At 5 s the interval accounts for the whole pace, and the share added nothing measurable.
The defaults
filedrop: 250 ms (DEFAULT_FILEDROP_POLLING_FREQUENCY_MS), a working value. The measurement fixes what an interval costs, six to ten of them per step and nearly all of a small exchange's time, but not the interval itself. At 250 ms a 30-step exchange of 10 records took 64 to 72 s over a local directory, against 948 s at 5 s, and each party lists the directory four times a second. A shared folder has no server whose flood protection the cadence could trip, so the CLI's sub-second warning and the console's matching advisory apply onsftponly.sftp: 5 s (DEFAULT_SFTP_POLLING_FREQUENCY_MS), unchanged. About ten listings a second tripped an SFTP server's flood protection and dropped the connection, and this measurement does not cover an SFTP server.- No other wait. The send-gate waits use the same interval, so no other per-step wait has a setting of its own.
Limits
- A networked share's own latency is not measured. Every run was on a local directory, where a listing shows a file as soon as its rename lands. A network share adds a round trip to each listing and file operation, and a client that caches directory listings or file attributes can hide a new file for the life of its cache. That would put a floor under every discovery wait at any interval. The container has no share to measure it on; the lab run shows no such floor at 5 s, and at 250 ms it is unmeasured.
- The floor that remains. At 250 ms a step is still 97% poll waits, about eight of them, against 33 to 70 ms of computation and file I/O. A shorter interval only scales the waits down. Fewer waits per step need a change to the message loop, such as polling soon after this party's own write or sending fewer messages per step, not a different default.
- A listing grows with the directory in retain mode. Retain mode keeps every message and acknowledgment, 562 files in the one directory by the end of the 30-step retain run, and each poll lists them all: the 250 ms retain run spent 45 ms a step on file I/O against 10 to 17 ms in delete mode. Over a share each entry can cost a round trip.
- Computation grows with the input. At 10 records it is under 50 ms a step; on a larger input the masking cost in PROTOCOL.md ("Masking compute is a wall-clock expectation") outweighs the interval.
- One run per interval, on one machine. Run-to-run variation was not measured; the three 250 ms runs, across both modes, ranged from 2.08 to 2.36 s a step.
- The sweep's hello read is not shortened by the interval. The read deadline of
--sweep-exchange-files' retain inspection is two poll intervals floored at the joiner recovery window and capped at the remaining peer budget, so a shorter interval does not shorten it.
Invariants
These are the testable properties the state machine must preserve. New work should be checkable against this list.
I0 -- clean entry (classification with an opt-in override). At
synchronize()start (enforcement site 1), with no sweep flag, the directory must be empty except for at most one peer hello (a-hello-<bytes>.jsonwhose recovered id is non-empty and not this party's own; a bare-hello-<bytes>.jsonparses to an empty id and is an unexpected protocol file, not a peer hello -- see site 5) and any foreign files. Foreign files are snapshotted (in-memory) and tolerated in both modes. Any other protocol file -- a second or self hello, a lock file, an ack marker, ajoiningsentinel, or a message (including a message-shaped<id>-<digits>.json, which matches the grammar and is therefore a protocol file, never foreign) -- is a terminalUsageError. The abort marker is the one kind whose disposition here is mode-dependent: in delete mode a well-formed<id>-abort-<bytes>.jsonunder any id is swept by site 1(d) before this guard runs and so never reaches the rejection, while in retain mode it is rejected exactly like the kinds above. A bare-abort-<bytes>.jsonrecovers no id, is swept in neither mode, and is rejected in both. With--sweep-exchange-files, the directory is first inspected for a retain signal (local retain mode, a peer hello advertisingretain_files=true, or a retain-only message ack -- an-ack.jsonwhose two trailing dash segments, the NNN and byte count, are both numeric); if none, every protocol-grammar file is deleted and entry proceeds against the resulting clean slate. Foreign files are never deleted by the sweep. When a retain signal is present the bare flag refuses; sweeping then requires--force-retain-sweep. This is the precondition the counter arguments in I8 rest on (a swept directory holds no message at entry, and a snapshotted foreign file is never counted -- see I8); it also code-enforces retain mode's fresh-directory requirement (see Fresh directory in retain mode), which holds unless the retain guard is passed. Theignoredset remains the entry point for the orphaned-temp sweep (a crashed in-flight write is added toignoredrather than rejected, andsafeDeleted only once entry is accepted -- after this guard and the one-peer-hello check with no flag, after the protocol-file deletes with the flag -- so a refused entry deletes none), which targets only the protocol's owntemp-<uuidv4()>.tmpshape (isProtocolTempName); a foreigntemp-*.tmpwith a non-UUID stem fails the grammar and is a grammar-failing name handled by the foreign snapshot, not deleted. The foreign snapshot is a sibling tolerance mechanism for grammar-failing names. What either refusal can name when it fires, and the budget that bounds the enumeration, are in Bounded error enumeration below.I1 -- one peer at rendezvous. More than one peer hello is a terminal usage error ("other sessions using this path?"), in both rendezvous paths. In the lockless barrier so is a peer hello other than the one this party read and acked: its flags and id were never checked, and its writer was never acked. Carve-out:
--sweep-exchange-filesoverrides this -- and a wider set than just "a second hello". Under lockless rendezvous a live peer the operator wrongly believed dead may have written its hello and its-hello-<bytes>-ack.jsonand a first message/ack; the sweep deletes every peer-prefixed protocol file, not only a second hello. The flag is the operator asserting the directory is theirs to clear and no concurrent session is live -- a responsibility the protocol cannot take on, since the directory has no entry-time liveness signal (a stale hello is byte-identical to a fresh one;modifyTimeis unreliable). The residual is real: a delete-mode sweep of a peer the operator misjudged as dead silently corrupts that peer's exchange. The pre-sweep retain-signal inspection narrows only the cross-mode case (it refuses to destroy a retain transcript without--force-retain-sweep); it does not make the sweep safe against a live delete-mode peer.I2 -- grammar routing. A file is routed as a message iff its terminal
.jsonsegment is all digits and the segment before it is nothello,joiningorabort. Control files (hello,ack,joining,lock,abort) are never routed. An ack marker is the case that makes this critical: a message ack holds the acked message's<NNN>and<byteCount>mid-name (both all-digit), but its terminal segment isack, so right-anchored parsing routes it as a control file. Routing is what this invariant governs, and nothing else touches it: neitherunexpected_files, which decides only the non-routing disposition of a non-message file during the loop, nor the entry foreign snapshot, which is a non-routing tolerate and belongs to I0.A party's own files fall out of the same rule -- its hello has a type word before its byte count and its ack a non-numeric terminal, so site 3 never routes them as self-messages, which matters because under lockless rendezvous they persist into Phase 2. Site 3 holds that with the discriminant itself (
isMessageName), never a bare<id>-*glob or a per-type suffix list. Site 4 (hasOutstandingMessage) holds it more strongly, and by a different mechanism: it waits on the single exact name inlastSentFile, so every other file this party owns -- control file or earlier message -- is excluded whatever its terminal token.I4a -- delete-mode crash safety. In delete mode, every committed file a party creates is tracked in
responsibleFilesso a mid-write fault still leaves it swept bycleanup()at the nextclose(). When theaddhappens depends on the write discipline, and the two are not interchangeable. A direct-write to the final name (lockviacreateExclusive) is pre-tracked before the call, because the final name can be left on disk by a call that then throws (e.g.createExclusivecreates the file on the server but the handle-close fails). A temp-then-rename write (helloin the rendezvous write, the lock joiner'sjoiningsentinel,messageinsend(),ackinwriteAck(), and the lock joiner's rename of its sentinel into<id>-hello-<bytes>.json) is tracked immediately after the rename, because the final name only appears at that atomic step and theaddfollows it with no throwable statement between. So there is no reachable orphan window short of a rename that throws-but-succeeded -- a pathological mode the message write has always accepted identically. (responsibleFilesis in-memory andcleanup()only runs in-process atclose(), so a hard process kill loses the set and sweeps nothing regardless of add timing; pre-tracking guards the caught-exception path, not a kill.) Everyaddis guarded by!retainFiles, because retain mode never sweeps (I4b) so tracking would serve no purpose. One file is direct-written to its final name: thelock, which has no payload at all. The joiner's<id>-joining-<bytes>.jsonsentinel, which holds the hello envelope, is written through the sametemp-hello-<uuidv4()>.tmpstage as the hello. Every file whose body another party reads -- thehelloon both rendezvous paths, themessage, and theabortmarker (tracked in neither mode, but written on the same discipline) -- is written temp-then-rename, so no reader can observe one under its final name before it is complete, which is what makes a torn body unrepresentable rather than merely unlikely (see I5a). No party reads the sentinel (the peer matches it by name existence -- see thejoiningtaxonomy row), and it is the staged body the joiner renames into<id>-hello-<bytes>.json, so that hello appears atomically like any other. A leftover sentinel is caught by the next run's entry guard (I0), which classifies by name. Underwrite_without_renamingevery temp-then-rename write above is a direct write instead and is pre-tracked like thelock; a body another party reads is then complete under its final name only by the operator's stated assumption, and what absorbs one read early is the reader's own byte-count gate (I5a for the hello, site 3 for a message).I4b -- retain mode never sweeps (except at two bounded sites). In retain mode
cleanup()is a global no-op: the directory is the durable transcript, so no*.jsonis removed bycleanup()andresponsibleFilesmembership is irrelevant.close()andcleanup()must not diverge on this. The literal statement (aboutcleanup()) survives, but its spirit ("retain never deletes a*.json") does not, at exactly two sites. (a) The gated entry sweep. A--sweep-exchange-files --force-retain-sweepentry sweep deletes*.jsontranscript files at entry, wiping a prior transcript before a fresh exchange -- an entry-time operation under explicit operator escalation, not acleanup()deletion. (b) A terminally failed rendezvous. The failing party removes its own hello, and its rendezvous ack if it wrote one, before the failure propagates (Phase 1 -- terminal rendezvous failure), theBilateralModeMismatchErrorcarve-out excepted; it is retain mode's only rollback, and it fires only where the rendezvous produced no exchange. Corollary the inspection relies on, stated to what those two sites leave standing: a directory holding a retain transcript advertises itself via theretain_files=truehello of the party that wrote it, which is what makes the pre-sweep retain-signal check reliable for a transcript already on disk at inspection time. (b) cannot remove such a hello, because a party whose rendezvous failed terminally wrote no transcript. What the corollary does not claim, and must not be widened back into, is that every retain directory advertises itself: a terminally failed retain rendezvous advertises nothing, and holds nothing the inspection needs to protect. In both modes the in-flighttemp-*.tmpis never tracked inresponsibleFiles; it is cleaned inline (best-effort) insend()/writeAck()and in the rendezvous hello write, independent ofcleanup(). That inline sweep is best-effort at the core layer only: a session-holding transport whose session is momentarily gone records the cleanup and re-issues it at its next re-establishment, so neither mode's run ordinarily ends holding a temp without it ever enteringresponsibleFiles. The re-issue is itself best-effort, under stated limits (the run, and the transport's cap on how many it records) -- see SFTP_SESSION_RECOVERY.md.I5a -- partial-sync read safety. A file whose body is read for content must not be read before the sync tool has finished writing it. Every payload-bearing file self-describes its full size in its filename, and a reader waits until the size reaches it. For the message that is site 3. The hello's reader is a read gate (
readControlFileWithGateinfileSyncRendezvous.ts) that retries a read shorter than its name's byte count until its deadline, then validatesHelloEnvelopeon a read of exactly that size. A hello listed or read larger than its name's count, or one of exactly that count that fails to parse, is a terminalMalformedHelloError(I5b). The gate first refuses a hello whose listed size or declared count exceedsHELLO_MAX_BYTES(1 KiB) with a terminalFrameSizeExceededError, and caps every read it makes at its name's byte count (see TRANSPORT_BOUNDS.md). Every caller passes a bounded deadline, never the peer timeout. At the rendezvous reads that isrendezvousBoundMs(RENDEZVOUS_HELLO_READ_POLL_CYCLES)-- six poll cycles under the wall-clock floor described above -- capped at the remaining peer budget so it is never longer than the operator asked for. Six cycles is three times the pre-sweep inspection's two, because this read races a peer that is actively writing rather than inspecting a stale directory. It is not larger still because the hello is written temp-then-rename: the final name appears only at the atomic rename, so the body is complete before the name is visible, and what a rendezvous read waits out is propagation, never a write in progress. A peer writing without renaming makes the name visible from the start of its write, so the read can also wait out a write in progress; the bound is the same, and a hello still torn when it expires fails the rendezvous as below. The floor is what keeps the bound meaningful when the configured cadence sits below the transport's round trip -- six cycles of a 10 ms poll is 60 ms, which abandons a hello a slow transport has merely not finished propagating. Exhausting the bound is terminal for that rendezvous. The message it throws states the hello's provenance (PeerHelloProvenance), which every caller supplies and which is the same distinction the bounded ack window is armed on. The hello the entry scan found is named as possible residue, while one that appeared after that scan gets no residue attribution at all -- the run watched it arrive, so a peer whose write is still landing explains it as well as a leftover does. Both prescribe a re-run first and condition any removal on the file surviving it; neither asserts which of the two indistinguishable causes it found, and only the entry-present text states the additional "no session shares this path" condition on the removal. Both texts are terse: the fixed text and the path share one rendered cause-chain link, so every character of text is one the path -- which is what the recovery step acts on -- does not get, and the budget each leaves is pinned by a test rather than asserted in a comment. A call site the entry-present hello cannot reach classifies by that comparison rather than by asserting its own unreachability. The throw is not aUsageError, because the pre-sweep inspection treats everything but aUsageErrorfrom this gate as retain-uncertain and refuses aUsageErroron the bare flag, so promoting it would turn that bounded read into a hard refusal. The ack marker needs neither: it is zero-length, so its name appearing is completion -- there is no body to gate or read. The hello is therefore the only control file whose body a peer reads through this gate, which is not the same as being the only one with a payload. Thejoiningsentinel holds a hello envelope but is matched by name and never read, and the abort marker is read under its ownABORT_MARKER_MAX_BYTEScap, a pre-get()listed-size refusal, and a listed size and body that must equal its name's byte count or leave it unverified, rather than through this gate (see the file taxonomy for both). Control-file payload fields are camelCase on disk (locklessRendezvous,retainFiles) -- a control file is a protocol message, not user-facing schema, so the snake_case-in-YAML config convention does not apply and there is nocamelizeKeysconversion on the serialize or parse path. A later envelope-field addition must stay camelCase to match. The gate is reached at entry as well: the pre-sweep retain-signal inspection reads the peer hello body throughreadControlFileWithGateto checkretain_files, validating against the hello schema (not a strip-unknowns base, which would read an absent flag as a false "no signal"). That read MUST be bounded -- a near-futuretimeToLiveof a small multiple of the polling frequency, never the fullpeer_timeout_ms-- so a non-resolving body cannot stall the sweep; an unresolved read is treated as retain-uncertain and refuses the bare flag. On the bare flag I5b still holds at this site: a fully-synced hello body that fails the schema is a terminalUsageError, the same classification as any malformed-hello-payload rejection, and a hello overHELLO_MAX_BYTESis the terminalFrameSizeExceededError. Under--force-retain-sweepboth are retain-uncertain instead: the operator has asked for the directory to be cleared, so the sweep deletes the hello with the rest, and its warning names the hello's path, its listed size, why it could not be read, and that retain mode could not be ruled out. A stalled read (TransportOperationStalledError) stays terminal under either flag, since it is a transport fault and not a property of the hello. (The message-ack half of the inspection is filename-only and reads no body.)I5b -- a completed body that fails validation is terminal (an instance of I6). Once a body has reached its declared size the partial-sync excuse is exhausted: a parse or schema failure is genuine corruption, not a retryable short read, and is a terminal
UsageError. An envelope version byte this build does not recognize is terminal the same way, as aProtocolRefusalError: the partner runs an incompatible build. For the hello this is theHelloEnvelopeschema check after the gate, except at the forced pre-sweep inspection (I5a). For messages inpoll()it is an envelope-deserialize failure (deserializeFileSyncMessage, including an unrecognized version byte), aparseBoundedJsonfailure on atype 0payload, a body-seq/filename-NNNmismatch, or a duplicateNNN; each stops the poller. Transient transport failures (and the unreachable emit-handler throw, see I8) are notUsageErrors and reschedule instead.I6 -- terminal failure, no caller retry.
synchronize()andsend()always present a terminal failure to the caller; the caller does not retry. Any bounded retry is internal. The I5b classification and the I8 counter discipline are instances of this contract at specific read/write sites. The "fail, then re-run with--sweep-exchange-files" recovery is an operator re-invocation of the CLI with different flags, not an in-process caller retry:synchronize()stays terminal per invocation, so I6 is preserved -- "on a re-run" means a new process, not a new in-process retry path.I7 -- filename order, never id order, wherever the lock path compares the two parties. There are exactly two such places: the two-hello collision's role assignment, and the lock name both parties reconstruct. Both compare hello filenames; neither compares ids, because for ids where one is a prefix of the other the two orders can diverge. The sequential branches assign roles unconditionally and compare nothing, so the invariant has no claim to make about them.
I8 -- in-memory counters are a shadow of on-disk NNN state, not an independent source of truth.
seq(next NNN to write),recvSeq(retain: next NNN to read),lastAckedNNN(retain: ack idempotency guard), andlastSentFile(the name of the last message this party sent -- read by the delete-mode drain and the retain-mode ack construction) are all in-memory projections of the shared directory's NNN sequence. Three rules keep the shadow aligned; breaking any one silently desynchronizes the two parties:seqadvances only after a message's durablerenameinsend(), so a failed write never leaves the counter past an NNN the peer cannot have. The counter's position is not a licence to reuse the slot: asend()can reject over a message the peer did receive. A transport whose write landed durably and was consumed before it could confirm that rejects withTransportWriteIndeterminateError(SFTP_SESSION_RECOVERY.md). The peer may already have emitted that message to its application, and nothing on the receive path would reject a second message arriving under the same NNN (delete mode does not read the bodyseqat all, and retain mode's NNN filter silently skips it). So the loop latches that write and the nextsend()fails with a terminalUsageErrornaming the spent NNN, rather than writing a second message the peer would read as a first. The latch is per-session and cleared byresetSessionState().Both messages the operator can reach here -- the rejection of the torn write itself and the refusal of the next
send()-- prescribe one recovery: re-run the exchange in a clean directory, both parties starting the new exchange fresh. They share a single sentence infileSyncMessageLoop.tsso they cannot diverge, and each is marked withmarkStatesItsOwnNextStep, which suppresses the CLI's generic post-handshake "retry the exchange without re-inviting" advisory. A plain retry is not sufficient for this condition, because what is unsettled is the write's fate rather than the directory's contents. The failed attempt's abort marker does not stand in the way -- site 1(d) sweeps it whatever ids the retry draws -- so a retry into the same directory runs a whole fresh exchange whenever the message never landed or the partner had already consumed it. A write that landed and was not consumed instead leaves that message file behind, which I0's clean-entry guard refuses on both sides -- the abort-marker sweep matches only the control grammar, so a leftover message is untouched by it. The writing party cannot tell the two apart -- that is exactly what its transport could not settle -- so it prescribes the recovery that holds either way. The mark makes each message's length critical -- with the generic advisory suppressed, the remedy is the only next step printed, and it has to clear the display boundary's per-error cap. The transport's own rejection is caller-neutral (see SFTP_SESSION_RECOVERY.md), sosend()restates it as a message-specific, marked rejection holding the transport's as itscause, which keeps the destination and the transport status on their own rendered lines under their own caps.In retain mode,
recvSeqadvances only after a successfulemitinpoll(). The ack marker (andlastAckedNNN) is written beforeemit. So a transient (non-UsageError) failure during receive -- e.g. an ack-write transport hiccup -- reprocesses the never-deleted message on the next poll and acks it exactly once: never skipped, never double-acked. (The ack name is a pure function of the message's fixed name, so even a bypassedlastAckedNNNguard re-derives the identical name and writes no duplicate file; the guard only saves a redundantput+rename.) AUsageErrorreachingpoll()'s catch is terminal and stops the poller instead (I5b). This reprocess path covers a failure during receive, not a throw from thedataconsumer. The sole production consumer (deliver()inmessageConnection.ts) latches an overflow as a non-throwing failure and never throws synchronously, so an emit-handler throw is unreachable defense-in-depth.All four counters -- and the spent-slot latch above -- reset together via
resetSessionState()at every session-boundary path: the rendezvous outer catch, the joiner prefix-at-dash error, andclose(). A partial reset that left any counter non-zero would corrupt the next session on the same instance.
The shadow is only safe because of I0 (clean entry): both counters begin at 0 against an empty directory and therefore stay aligned with the peer's monotonic NNN sequence, which also starts at 0. The I0 relaxation preserves this by rejection, not by exclusion from message selection. The grammar discriminant is digits-terminal, not prefix-gated, so a
<peerId>-<digits>.jsondoes passparseMessageByteCountand is selected atpoll()-- the foreign snapshot does not make it inert there (that is the mid-loop unexpected-file policy's concern). Alignment holds for two reasons: a swept directory holds no message at entry (counters start at 0 against a clean slate), and a message-shaped name is never snapshotted -- with no flag it is rejected at the entry guard, so no pre-existing message reaches the loop. Should such a name reachpoll()by another path (appearing after entry, or surviving a sweep), it does not advancerecvSeqin either mode. In delete mode it is selected and then terminally rejected as a non-conformingMessagebody (I5b). In retain mode the outcome depends on its NNN segment:- An NNN other than
recvSeqis skipped without being read, and the name never reaches the unexpected-file policy: by name it cannot be told from an already-consumed or not-yet-current retained message. - An NNN equal to
recvSeqis selected and read like any other message, so a non-conforming body is terminally rejected (I5b). - A non-numeric NNN is a terminal
UsageError.
Alignment holds because such a file is never counted.
Enforcement sites:
resetSessionState()(session-boundary resets), the post-renamethis.seq = seq + 1insend()(I8 write rule), theindeterminateWritelatch set at that samerenameand read atsend()entry (the spent-slot rule), and the post-emitthis.recvSeq++guarded by the pre-emit ack write inpoll()(I8 receive rule).Production composition note. The reschedule-on-transient half of the second rule is a property of
poll()in isolation. In the CLI aFileSyncConnectionis never consumed directly:apps/cli/src/run/prepare.tsalways bridges it throughfromEventConnection(messageConnection.ts), whoseerrorlistener routes every emittedpoll()error intoQueuedMessageConnection.fail().fail()synchronously callshooks.close()->conn.close(), andconn.close()callsconn.stop()before anyawait(after only the synchronousbeginTeardownsignal, and ahead of the abort-marker decision gate), sopollerActiveis cleared within that synchronous prefix, beforepoll()'sfinallyruns (conn.close()is itselfasync, but only its leadingstop()must run synchronously for this to hold, and it does) -- so the reschedule thatfinallywould otherwise perform never happens. Under composition, therefore, the first emittedpoll()error is terminal: the parkedreceive()rejects exactly once (naming the underlying cause, whichasConnectionErrorcopies into the reported error's message and attaches ascause, rather than the bridge's generic peer-silence inactivity message), the connection stops, and the never-deleted retain message is not reprocessed. A hypothetical retry counter onpoll()could never reach 2 in production for this reason. So the rule above (and the isolation unit tests that drivepoll()without the bridge -- e.g. the transient-list()-recovery test inpackages/core/test/connection/fileSyncConnection.test.ts) describes the module standalone; the composed contract is pinned by the bridged test that lives alongside them.I9 -- one outstanding message per direction (delete mode). The delete-mode sender blocks in
hasOutstandingMessageuntil the peer deletes its last message, so at most one message per direction is ever in flight; this is the channel's flow control and the property that bounds legal directory contents in Phase 2 delete mode. Retain mode imposes no such bound -- messages accumulate as the transcript and the receiver selects the next byrecvSeq(I8).Untouched by session-start directory hygiene. I2 (routing discriminant -- the foreign snapshot is a non-routing tolerate; only the post-entry classification was extended, by the mid-loop unexpected-file policy), I3 (the snapshot adds no
<id>--numeric file), and I4a (swept files predate the session and never enteredresponsibleFiles, whichsynchronize()prunes against the live listing before the guard) are untouched. So are I7 (sweep and inspection run before role logic) and I9 (entry precedes Phase 2). I5a is not in this list: the pre-sweep inspection reads the hello body through its gate (see I5a above).
Preconditions for a correct exchange
These operator-level requirements are part of the model: violating any of them silently corrupts or stalls the exchange, and several are not otherwise enforced in code. They are collected here so they live in one place rather than scattered across feature docs.
- Dedicated directory. One active exchange, exactly two parties, per directory, for protocol use. The requirement is on the protocol-grammar namespace rather than the directory as a whole, because foreign names are tolerated; what that tolerance covers is in EXCHANGE_REFERENCE.md "Directory exclusivity", and where this model enforces it is I0 and enforcement site 3.
- Matching builds. The file-sync control-file (hello) schema has no
protocol_versionfield; both parties run compatible builds and the payload schema is a hard contract. A payload-schema change (e.g. adding hello flags) is a flag-day that both sides must deploy together. This precondition is about the hello control file; the transport-agnostic terms exchange separately includes a build-levelprotocolVersionthat fail-closes with an actionable message on a mismatch, and a foreign message-envelope version byte is named as a likely version mismatch (see the message body format above and PROTOCOL.md). - Identical bilateral flags.
lockless_rendezvousandretain_filesmust match on both sides. - Fresh directory in retain mode. Retain mode never deletes, so a reused directory leaves a prior exchange's files: a stale message would be mis-consumed against the
recvSeqcounter and a stale ack marker could prematurely release the sender's gate, corrupting or stalling the exchange with no error. The entry guard (site 1) rejects a non-clean start in both modes, which is what protects retain specifically -- so this is advisory-unless-the-retain-guard-is-passed:--sweep-exchange-files --force-retain-sweeplets a non-clean retain start proceed by wiping the prior transcript first. Without that explicit escalation the guard still rejects, so the precondition holds for any normal invocation. In a split configuration the check runs on both the inbound and the outbound directory. - Distinct, non-prefix peer ids. When
peer_idis configured, the two ids must be distinct, and neither may be the other extended by-(e.g.site/site-2), which would break message prefix-routing. UUIDs (the default) satisfy this automatically. Under apeer_idholding fields the rule applies to each run's full id, and a partner id that matches this party'speer_idwith its fields filled in is a further conflict (Per-run peer id).
Per-run peer id
A peer_id may hold fields filled in each time the connection opens, giving each run its own id for a transfer system that never passes a file name twice (operator reference: EXCHANGE_REFERENCE.md). The grammar and expansion are packages/core/src/config/peerIdTemplate.ts. A peer_id with no % is used as written, exactly as without this section.
The effective id. open() sets the party id to peer_id with its fields filled in, once per call; every name this party writes, the hello, joining sentinel, lock, messages, acks and abort marker, is built from that id, and the partner adopts it from the hello name as it does any id. The configured peer_id is kept beside it as written. Nothing else fills the fields in: a configuration read, validated, disclosed to the console's browser or written back holds the text as written. The text before the first field starts every name this party writes, the lock aside, which starts with whichever id sorts first.
The fields. Every field fills in a fixed number of ASCII characters, so a peer_id has one expanded length:
| Field | Fills in | Characters |
|---|---|---|
%Y | UTC year, zero-padded | 4 |
%m, %d, %H, %M, %S | UTC month, day, hour, minute, second, zero-padded | 2 each |
%K | lowercase hex of 8 bytes from the platform's cryptographic random source, fresh per field | 16 |
%% | a literal % | 1 |
The clock is read once per expansion. A clock outside the years 0000-9999 refuses the expansion rather than write a five-digit %Y past the length the configuration was checked at. %K is a letter neither POSIX strftime nor GNU date defines (coreutils 9.1 prints date +%K as %K), so no date format string gives it another meaning.
The random field's size. 64 bits keeps the ids of one configuration distinct across a long schedule. One run a minute for ten years is n = 60 x 24 x 365.25 x 10 = 5,259,600 runs. By the birthday bound, the chance that any two of n uniformly random 64-bit values are equal is at most n(n - 1) / 2^65, about 7.5 x 10^-7 for that n: near one in a million, from %K alone, before the date and time fields separate runs further. A peer_id with only date and time fields gives two runs started within the same second the same id.
The load-time refusals. FileSyncOptionsSchema refuses, under the peer_id path, each with a message naming the problem and what to change:
- a
%followed by anything but one of the fields above, and a single%at the end; - an expansion over
MAX_PEER_ID_BYTES(211) UTF-8 bytes, the cap that keeps the longest two-id name within the 255-byte limit beside a one-byte partner id (site 5); - an expansion that is the reserved id
temp, which only apeer_idwith no field can be.
Core admits every character in peer_id, and the fields fill in digits and hex, so no expansion holds a character the id does not admit and core has no character refusal for them. The console's job-intent boundary holds a peer_id with fields to its own party-name shape at every expansion (SERVER_JOB_API.md).
The leftover check. The rendezvous classifies a hello or joining sentinel by whether its id is one this party writes: its current id, or any expansion of its peer_id (each literal exactly, each date or time field as digits of its width, %K as lowercase hex of its width; a linear, anchored match on a partner-written name). The entry scan therefore refuses an earlier run's hello as this party's own (exit 64, naming the file), as it does an exact self-hello, and the in-flight lock and lockless scans skip such a name as they skip this party's own hello. A joining sentinel at entry is refused whatever its id, and in split mode every protocol file in the outbound directory is refused whatever its id, so neither depends on the match.
The partner-facing id checks. peerIdLengthRefusal and peerIdPrefixRefusal run on the two effective ids, each read from the other's hello name, so both parties reach the same verdict: an effective id that is the partner's id followed by - and more text (site-%Y%m%d against a partner site) is refused by both.
Limit -- a partner id in this party's own format. A partner whose id matches this party's peer_id with its fields filled in (both parties configured with the same peer_id, say) is taken for this party's own earlier run. Present at entry, its hello is refused as a leftover; arriving later, it is skipped, and the run reaches the ordinary peer timeout. --sweep-exchange-files deletes such a hello as this party's own, without first reading it for the partner's retain setting as it does a partner's hello. The partner, which cannot see this party's peer_id, sees an ordinary hello and waits likewise. The prefix refusal does not catch it, since neither id is the other followed by -. The two parties' ids must not match each other in this way, as they must not be equal without fields.
Limit -- an own leftover reads as a shared peer_id first. The start-up refusal for a hello that fits this party's own peer_id names a shared peer_id as the likely cause before it names a leftover from an earlier run of this party. A genuine leftover from a killed run in a shared directory gets the same refusal, with its remedy second; nothing local tells the two apart.
Limit -- the console shows the plain timeout. The shared-peer_id explanation is part of the CLI's own peer-timeout message only. The failure cause the CLI emits stays a plain partner-never-arrived, so the console, and both apps' remedy lines, show the generic timeout and remedy.
Round set size limits
A linkage key round sends its set of values as one PSI message, in as many message files as the set needs (PROTOCOL.md, A PSI set is sent in parts). One bound caps how many values the first round's set can hold on the file-sync channels. It is not configurable.
- One PSI set: 16,777,216 values. The protocol's per-set maximum,
MAX_PSI_DECODE_ELEMENTS= 2^24 (PROTOCOL.md, A PSI set is sent in parts). Every sender refuses a set of its own over it before sending any part, and every receiver refuses a set declaring more. A file-sync exchange at the maximum completes with one party on each of two hosts, in about 64 minutes on the hosts measured, the starter peaking at 29.9 GB and the joiner at 22.5 GB or more (Measured runs at 2^24).
A set is sent in as many message files as it needs, each part's file filled to MAX_FRAME_SIZE_BYTES (536,870,888 bytes; TRANSPORT_BOUNDS.md) as described below, so a set at the per-set maximum takes two (PROTOCOL.md, A PSI set is sent in parts). A set of n values takes at most n times PSI_ENCODED_ELEMENT_BYTES (35: a 33-byte compressed point plus its protobuf tag and length) plus PSI_SET_MAX_FRAMING_BYTES (6), the most a PSI message adds to its elements. The framing is measured on the vendored library: a server setup adds 5 bytes at 10^6 values and 6 from 7,669,585 values up, where its element list's length prefix reaches 5 bytes; a request adds 2 and a response none.
Deduplication holds any number of distinct values. A round keeps one map entry per distinct value, and a V8 Map holds at most 2^24 entries: on Node 26.10, inserting the 16,777,217th entry throws RangeError: Map maximum size exceeded (a Set stops at the same count). The round's deduplication and the first-round count below therefore index their values in shards of 2^24 (DistinctValues over ShardedMap, packages/core/src/utils/distinctValues.ts and packages/core/src/psi/shardedMap.ts; the index itself is described under The practical input bound), filled in turn so the round's values keep their row-major order (PROTOCOL.md, The per-round grouping the two frames hold). The record verifier's index of the input's identifier column is sharded the same way (reconstructCommittedData, packages/core/src/records/recordVerification.ts), so a record over an input of more than 2^24 rows verifies. No distinct-value count refuses a round; the per-set maximum is the binding limit on the first round's set.
Where the limits are checked. Before the transport is built, and so before any file is written for the partner, the command-line application counts the values this party's first round sends and refuses a count over the per-set maximum (assertFirstRoundWithinSetMaximum), in both PSI roles, the same count every channel takes (PROTOCOL.md). The count follows this party's own terms: every distinct value when they set deduplicate, and otherwise only the values exactly one of its records holds. The check runs with the other refusals decided from local input, before the SFTP host-key step contacts the server. Where the count was taken, the refusal names it -- for a party that keeps its duplicates, the count at which it passed the bound, as the least the round sends -- the per-set maximum, and the remedy: split the input into smaller files and run one exchange for each. A later round's set is known only once the earlier rounds have matched, so it is not checked there; the round refuses a set of its own over the maximum before sending any part. A first round over one message file and within the maximum is written as several files.
Every round sends a set in parts sized to the message file. Each part's file -- the part, the AEAD envelope, and the file header -- is filled to MAX_FRAME_SIZE_BYTES, the bound the receiver's read gate applies (psiSetPartPayloadBytes, through the transport's outboundFileSyncFrameBound), so a set larger than one file is written as several rather than refused. The receiver's read gate still refuses any one file over that bound, and the joined set is held to the bound the agreed record counts admit (PROTOCOL.md, A PSI set is sent in parts).
The lists of matched records go in parts the same way. Each round's association table, original-index list and groupings, and after the last round each party's mapped-element list, the list it returns with the partner's rows, and its payload rows go in parts whose message files are filled to the same bound, so no list is bounded by one message file or by the 2^24 entries one JSON array holds (PROTOCOL.md, A list of matched records is sent in parts).
Measured run past the Map limit. In the development container (10 cores, 23 GB, Node 26.10, default heap), with other workloads running beside it, deduplicating 2^24 + 1 distinct short values takes about 26 s dropping duplicates and 24 s grouping them, and counting them about 12 s dropping and 16 s keeping; the file's peak RSS, input included, is about 4.1 GB. packages/core/test/stress/roundDedupPastMapLimit.stress.test.ts holds these cases and the raw Map limit in the opt-in stress tier (npm run test:stress -w packages/core), about 90 s; the unit tier pins the shards at a lowered bound. psiSetFraming.stress.test.ts in the same tier builds a server setup of 2^24 values and holds its framing to PSI_SET_MAX_FRAMING_BYTES, about 10 s; the unit tier holds every PSI message of a few values within it.
Memory a PSI round needs
A command-line party runs its PSI set operations in a worker_threads worker (apps/cli/src/psiWorkerHost.ts). At Node's default heap limit that worker runs out of heap well below the file-sync message-file bound above, so the CLI runs it under a heap ceiling sized from measured per-element costs, and refuses, before any network contact, a run whose input needs more memory than the process has. The constants and the check are in apps/cli/src/psiMemoryBudget.ts.
The measured costs
Measured 2026-09-30 in the development container: aarch64 Linux, 10 CPUs, 23 GB of memory, Node v26.10.0, the native PSI engine in the CLI's own worker, one phase per process. Each figure is the least-squares slope of peak process RSS (the last row: peak worker V8 heap) against the set size n, above the process's own baseline. The runs past 5,242,880 elements ran with the heap raised.
| Operation | Bytes per element | Intercept | Runs |
|---|---|---|---|
Joiner, full round (setup of n, response of n) | 1,176 | 209 MB | 4, from 2^20 to 2^23 |
| Starter, request received and response built | 983 | 80 MB | 4, from 2^20 to 2^23 |
| Setup decoded and held | 378 | 45 MB | 6, from 2^20 to 2^24 |
| Worker V8 heap, joiner full round | 709 | 298 MB | 3 |
The process holds 62 MB before it reads any frame. These are the PSI set operations alone: an exchange also holds its dataset, the round's value maps and the result, so the figures are a lower bound on a whole run.
Node's default heap limit binds first. On this build v8.getHeapStatistics().heap_size_limit is 4,395,630,592 bytes, the same in a worker as on the main thread. At that limit the largest symmetric round that completed was 5,242,880 elements a side; at 6,291,456 both the starter's request and the joiner's match failed with ERR_WORKER_OUT_OF_MEMORY.
The budget
A round over n elements a side needs 271,000,000 + 1,176 * n bytes: the joiner's full-round cost, the costlier role, plus the 209 MB intercept and the 62 MB baseline (psiRoundMemoryNeedBytes).
The heap ceiling is that need at the protocol's per-set maximum, 2^24 = 16,777,216 elements a side (MAX_PSI_DECODE_ELEMENTS, Round set size limits): 20,001,006,016 bytes (PSI_HEAP_CEILING_BYTES), passed to V8 as --max-old-space-size=19075 (PSI_HEAP_CEILING_MIB, the byte figure in MiB rounded up). A unit test holds the ceiling at or above the need at that maximum, so the two cannot drift apart. A browser partner's first round is smaller, held to its receive ceiling of 8,388,608 (PROTOCOL.md, The receive ceiling). V8 reserves heap as it is used, so a ceiling above the host's memory costs nothing by itself; the check below is what guards a small host.
How the ceiling reaches the engine
- An installed CLI calls
v8.setFlagsFromString("--max-old-space-size=19075")immediately before it starts the PSI worker, unless the process already runs under a larger limit (raisePsiWorkerHeapLimit). A worker created after the call is created with that limit; the main thread keeps the one it started with. A unit test starts a worker through the production call and reads the worker's ownheap_size_limit. - An installed CLI's main thread, which reads and prepares the input, keeps the limit node started it with: the CLI never raises it and never starts a second
node. A heap option the operator gives, inNODE_OPTIONSor on node's own command line, is honored as given. Before the input is parsed, the CLI refuses an input that limit cannot hold (The main thread's heap), and the refusal names theNODE_OPTIONS=--max-old-space-size=19075setting the images use. - The container images export
NODE_OPTIONS=--max-old-space-size=19075indocker-entrypoint.shbefore starting the CLI, which the FIPS variant's entrypoint also runs. It raises the main thread and the worker alike, and an operator's ownNODE_OPTIONSfollows it in the variable, so a larger value given withdocker run --env NODE_OPTIONS=...takes precedence. A unit test runs the real entrypoint under the real shell with a stand-innodethat reports both threads' limits.
Two other routes do not reach the worker on Node v26.10.0, driven in real Node:
resourceLimits.maxOldGenerationSizeMbon theWorkerleaves the worker'sheap_size_limitat 4,395,630,592.execArgv: ["--max-old-space-size=..."]on theWorkeris refused withERR_WORKER_INVALID_EXEC_ARGV.
The main thread's heap
Before it parses an input file, every command that reads one (loadInputRows, apps/cli/src/onlineBootstrap.ts) estimates the main thread's need from the file's record count and refuses, with a usage error (exit 64), an input whose need is over the main thread's heap_size_limit (checkInputFitsMainThreadHeap, apps/cli/src/inputHeapCheck.ts). Without the check such a run reads for minutes and then ends in a V8 abort (exit 134) with no message. The refusal states the record count and the file's size, the need, the limit, and the NODE_OPTIONS=--max-old-space-size=19075 line to set, or a larger value when the need is over the ceiling, and names --allow-memory-shortfall, which turns the refusal into a warning on stderr and reads the input anyway.
- The need is 760 bytes a record (
MAIN_THREAD_HEAP_BYTES_PER_RECORD). At Node's default limit of 4,395,630,592 bytes it admits 5,783,724 records, below the 6,500,000 measured to complete. The limit is V8'sheap_size_limit, which counts the young generation's reserve beside--max-old-space-size; at a limit of several gigabytes the reserve is a few percent of it. - The record count is the file's line count less the header, read without parsing. The parser takes one line ending from the start of the file -- LF, CRLF, or CR -- and reads the others as text, so the line count is the larger of the lines ended by an LF or a lone CR and the lines ended by a CR. A file too small to hold that many records at 2 bytes a record is not read. A quoted field holding a line break counts as an extra record, so the count is never under the parser's; a unit test holds it there for each ending.
- Not checked: an input read from stdin, and a path the check cannot stat or read, which the read itself then reports.
The figure is a record count, not a file size. At 1,000,000 records, four shapes of input were measured through the same stages: the four-column input below; the same with a 120-character free-text column; with six short code columns; and with a last name of about 40 characters. The file's size varied 4.4-fold; the peak heap varied by 8%. A figure in bytes of file would refuse the free-text input at a quarter of the records it holds.
| Input shape | File | Heap after the constraint pass | Peak heap |
|---|---|---|---|
| Four columns | 35.7 MB | 474 MiB | 881 MiB |
| With a free-text column | 156.6 MB | 725 MiB | 950 MiB |
| With six code columns | 51.4 MB | 548 MiB | 875 MiB |
| With a long last name | 72.7 MB | 631 MiB | 895 MiB |
The wall it is calibrated to. Measured 2026-10-04 in the development container (aarch64 Linux, 10 CPUs, 23 GB shared with other workloads, Node v26.10.0) at Node's default heap, no heap option given: the stages of packages/core/test/stress/inputPreparation.stress.test.ts's probe, from reading the four-column input through the first-round count that walks every record (the count stands in for the first round's set, which the main thread builds next), one size a process. Heap figures are V8's used_heap_size, sampled every 250 ms.
| Records | File | Outcome | Heap after the constraint pass | Peak heap | Peak RSS |
|---|---|---|---|---|---|
| 6,000,000 | 219.8 MB | completed | not sampled | 3,861 MiB | 4,069 MiB |
| 6,500,000 | 238.2 MB | completed | 2,614 MiB | 4,030 MiB | 4,210 MiB |
| 6,750,000 | 247.4 MB | out of heap during the count, exit 134 | 2,905 MiB | 4,091 MiB | 4,243 MiB |
| 7,000,000 | 256.6 MB | out of heap during the count, exit 134 | 2,991 MiB | 4,093 MiB | 4,242 MiB |
apps/cli/test/stress/mainThreadHeap.stress.test.ts holds the figure in the CLI's opt-in stress tier: at Node's default heap, with NODE_OPTIONS cleared, an input of the records the check admits passes the check and completes the CLI's read, the preparation, the constraint pass and the count. On the host above, an input of 5,940,041 records, the count 740 bytes a record admits, completed in 758 s at a peak heap of 4,157,901,960 bytes of the 4,395,630,592 limit and a peak RSS of 4,379,045,888 bytes; the figure admits 156,317 fewer records than that run. A unit test holds the records the figure admits at the measured default limit at or under 6,500,000.
Limits of the figure. It is measured for the four-column shape only. A wider or longer record keeps more of the main thread's heap after the constraint pass (725 MiB at 1,000,000 records with the free-text column, against 474 MiB), so such an input can run out of heap below the count the check admits. It covers the main thread through the first-round count, not the rest of the run.
The pre-contact check
Every exchange-running command (alcove exchange, zero-setup, the online invite, and an accept in the URL or endpointRun mode) logs one line before any network contact stating the heap limit the PSI engine runs under, the memory a round over this party's input needs, and the memory the process has (checkRunMemoryBudget, apps/cli/src/run/prepare.ts). With the engine in a worker, the line states the worker's limit and the main thread's measured heap_size_limit separately. It runs with the other refusals decided from local input, on every command before the first-round size check: the check reads only the record count, while the count walks every record, so a host short of memory is refused without waiting for it.
- The need is
psiRoundMemoryNeedBytesof this party's own record count, known before contact. - What the process has is the least of three figures: the heap limit of the thread that runs the engine (the raised worker limit, or the process's own when no bundled worker is present), the host's total memory (
os.totalmem()), and the container's memory limit as Node reports it (process.constrainedMemory(), the cgroup limit), counted only when it is below the host's memory. - A need over it is refused as a usage error (exit 64) naming both figures, which of the three limits it, and the remedies: more memory, a smaller input, or
--allow-memory-shortfall. --allow-memory-shortfallturns the refusal into a warning, on stderr and on the event stream undermemoryShortfall(CLI_EVENTS.md). The statement line is logged either way.
The need for a small input is the 271 MB fixed part plus about 1.2 KB a record: a thousand records need 0.27 GB, which a unit test holds passing against a 512 MB container limit. A container memory limit below about 272 MB therefore refuses every run until --allow-memory-shortfall is given; the fixed part is not lowered for small inputs because it is the measured fit's intercept plus the process's baseline, not a margin.
The partner's round
Once the terms are exchanged and before any PSI set moves, a cascade or count-only exchange makes the same check on the partner's round (checkPartnerRoundMemory, apps/cli/src/psiMemoryBudget.ts):
- The need is
psiRoundMemoryNeedBytesof an upper bound on the values a conforming partner's set for one linkage key holds (partnerRoundValues,packages/core/src/exchange.ts): its declared record count, which includes its fan-out, times the widest key's declared width, held to the per-set maximum,MAX_PSI_DECODE_ELEMENTS, over which no sender sends a set. The declared width applies in either PSI role. A sender's key read holds a row to the declared width times its local fan-out factor, andsplit_onhas no per-element cap, so a sender partner whose agreed or local cleaning fans out can realize more than the width its own key read applies without a fan-out; its local cleaning is not in the agreed terms, so it is weighed at the declared width whatever its terms declare. A unit test realizes a sender's and a receiver's key read on swapped keys withsplit_onin the agreed terms and in local cleaning, and holds each count at or under the figure (packages/core/test/partnerRoundCapacity.test.ts). Every figure is authenticated session state. A partner whose records repeat values, or whose terms drop a value several records hold, sends fewer. Count-only terms declare no candidate set, so for them the figure is the partner's declared record count. - What the process has is the same least of three figures.
- A need over it is refused as a
RoundCapacityError(a usage error, exit 64) naming the count, both figures and the remedies: a host with more memory, or the partner splitting their input into smaller files. It is this party's own limit, not a fault in anything the partner sent, and the partner is sent a fixed abort reason. --allow-memory-shortfallturns it into a warning, on stderr and on the event stream undermemoryShortfall, as before contact.
The partner's figure is an authenticated upper bound for a conforming partner, not the size of the set it sends: duplicate values and empty cells shrink the real set below it, and a sender partner on a swapped key whose cleaning does not fan out sends at most half of it. A command-line party whose memory sits near the bound is therefore refused unless --allow-memory-shortfall is passed, though the set it would receive could fit. Under the PSI-C construction the figure is the size of the set itself: every round's set has one element for each of the partner's declared records, so one check covers every round.
A single-pass exchange is left to its dataset ceiling. A browser party refuses against a fixed ceiling instead, on the same upper bound, and has no override (PROTOCOL.md, What a browser tab can match).
Limits
- Records, not elements, before contact. The pre-contact need is taken from the records this party holds. A key whose transforms produce several candidates a record, or a
split_onfan-out, sends more elements than records, which the check does not count. - Total, not free, memory. The host figure is the host's total memory, so two parties on one host, or other workloads beside the run, share memory the check counts once.
- The container figure is Node's. The container limit the check counts is
process.constrainedMemory(), which enters the least of the three figures when it is below the host's memory; the check reads no cgroup file itself. - Measured on cgroup v2 only. On Docker Desktop 29.8.1 (linuxkit 7.0.14, cgroup v2),
process.constrainedMemory()equals thedocker run --memoryvalue, and with no limit it returns 2^64, which the check treats as no container limit. cgroup v1 is not measured. Under a small container limit V8 shrinks its young generation, so the heap figure in the memory line is 20.03 GB at 768m and 20.10 GB at 4g or with no limit. Under 768m the check refuses with exit 64 and writes nothing to the drop directory; with--allow-memory-shortfallit warns and the run proceeds. The FIPS image behaves the same.
Measured runs at 2^24
On 2026-09-30 two alcove zero-setup parties ran over a file-drop directory in the development container above, each with a 16,777,216-row input of four columns (an id, a synthetic SSN, a last name and a date of birth; 623 MB each), half of each input shared with the other:
- Default main-thread heap, as Node starts it. Both parties ran out of heap on the main thread while preparing the input, before the pre-contact check and before any network contact: exit 134 after 204 s, peak RSS 4.50 GB each.
- With
NODE_OPTIONS=--max-old-space-size=19075, the images' setting. Both parties were still preparing their input on the main thread after 30 minutes, each on one core at 100%, at 6.78 GB and 6.11 GB RSS, and the attempt was stopped there, before the pre-contact check and before any network contact.
At the measured costs a symmetric round at this size needs about 20 GB for the joiner and 17 GB for the starter, more than this host holds for two parties.
On 2026-09-30 and 2026-10-01, on an Apple M1 Max with 32 GB of memory (Docker Desktop VM of 23.7 GB, 10 vCPUs, cgroup v2, the Node 26.10.0 Alpine image):
- 16,777,216 records a side, file-sync pair, no container memory limit. The run did not complete. Both parties were stopped during the first-round value count after about 15,600 s of wall time, at a peak of about 7.4 and 7.6 GiB a party. No refusal and no memory line was reached: on the build measured, the zero-setup path counted before it logged the memory line.
- 15,339,166 records a side. Not run: the pair needs about 37 GB and the host held 23.7 GB. A single party's preparation, up to the memory line, took 100 s and 108 s in two runs.
The completion run at the per-set maximum:
- 16,777,216 records a side, file-sync pair, to completion, one party per host. On 2026-10-02 both parties exited 0 and each returned the expected result, 8,388,607 matched rows (below). Round 1 took about 25 minutes, rounds 2 and 3 about 11 each, and round 4 to the end of the run about 15.
| Party | Host | Transport | Wall time | Peak RSS |
|---|---|---|---|---|
| Starter | Proxmox LXC container: Intel Core i3-14100, 3 cores / 6 logical CPUs, 29 GiB container memory limit, no swap, Linux, Node v26.8.2 | file:// on a directory of its own host | 3,826,025 ms | 29,913,112,576 bytes |
| Joiner | Apple M1 Max, 10 CPUs, 32 GiB, macOS, Node v26.8.2 | sftp:// to the starter's host over the LAN | 3,760,626 ms | 22,453,616,640 bytes, a floor |
What the figures mean for a host:
- The starter's host had no margin. The container's cgroup memory peak was 30,859,788,288 bytes, 145 MB under its
memory.highthrottling threshold, so at that limit the starter placement had no margin. - The joiner's figure is a floor. macOS peak RSS does not count compressed pages, and the Mac had about 1 GB in swap before and after the run, so 22.45 GB is a lower bound on the joiner's true peak.
- The expected result leaves out nulled SSNs. One shared row of the synthetic population holds 111-11-1111, which the built-in standardization nulls, so it matches on no key and the result holds 8,388,607 of the 8,388,608 shared rows.
The modelled needs are each party's PSI round (The measured costs: 20.0 GB for the joiner, 16.6 GB for the starter) beside its main thread's peak of 11.78 GiB (Preparing the input at 2^24): about 32.7 GB for the joiner and 29.3 GB for the starter. The starter's measured peak is 0.6 GB over its model. Two parties on one host need their sum, about 64 GB.
apps/cli/test/stress/fileSyncCompletion.stress.test.ts drives the run in the CLI's opt-in stress tier (npm run test:stress -w apps/cli, after npm run build -w apps/cli). Each party is the built alcove under the images' heap setting, with the four-column input above, half of it shared with the other. The test holds each result to that shared half, row for row, less any row whose SSN the built-in standardization nulls, which is what a run with no size limit returns, and logs the wall time and the party's peak RSS, its PSI worker included. It has two modes:
- Both parties on one host, over a local directory: the default, for sizes that fit one host.
- One party on this host, against a partner on another (
ALCOVE_STRESS_COMPLETION_PARTYset tostarterorjoiner,ALCOVE_STRESS_COMPLETION_URLnaming the shared directory as afile://orsftp://URL). The joiner is the party that arrives second, so on afile://directory the joiner waits for the starter's hello before it starts; either party stops as soon as it logs a role other than its own. It writes the party's figures and its host to a summary file.
Each skips where the memory is under what it needs: the free memory, or on macOS, whose free figure leaves out the cache it can reclaim, the available memory memory_pressure reports: free, inactive and speculative pages. ALCOVE_STRESS_COMPLETION_ROWS lowers the row count; the test file's header lists the other settings.
Preparing the input at 2^24
Measured 2026-10-01 in the development container (dev container, 10 cores, 23 GB; aarch64 Linux, Node v26.10.0), with other workloads running beside it. One zero-setup party ran alone over a file-drop directory (alcove --peer-timeout 30s file://<dir> <input>) under NODE_OPTIONS=--max-old-space-size=19075, on the four-column input above, one distinct SSN a record. Times are from the process's start, read off its log lines; peak RSS is the process's VmHWM at the end of each stage.
16,777,216 records, load average 2.7 at the start:
| Stage | Time | Ends at | Peak RSS |
|---|---|---|---|
| Read and parse the CSV, infer the terms | 14.1 s | 14.1 s | 3.09 GiB |
| Constraint pass (standardizes every record's key fields, cached for the round) | 78.7 s | 92.8 s | 7.69 GiB |
Memory check (memory: line) | under 0.1 s | 92.8 s | 7.69 GiB |
| First-round count, as the sender | 133.4 s (125,816 records/s) | 226.1 s | 11.78 GiB |
| First-round count, as the receiver | 136.1 s (123,314 records/s) | 362.2 s | 11.78 GiB |
On the build measured, the first round was held to one message file, 15,339,166 values; the count found at least 16,777,215, so the run was refused there with exit 64 and made no network contact. The stress probe below, run the same day over the same input on the core code as it stood before the lookups described under The practical input bound, with each value its own Map key, read it in 24.9 s and was still in the constraint pass 25.5 minutes later, when it was stopped.
15,339,166 records, the first 15,339,166 rows of the same input, load average 10.4 at the start: the read took 25.2 s and the constraint pass 176.4 s. The first-round count was skipped, since one value a record cannot exceed the bound, and the first entry appeared in the drop directory at 201.6 s, within 25 ms of the memory line, at a peak RSS of 6.48 GiB.
The bound. On this host a 2^24-record preparation, from reading the input to the first-round refusal, completes within 27 s per million records, about 7.5 minutes, while the host's load stays within its core count. It is the slowest such run rounded up to a whole second per million: the stress probe below took 446.0 s (26.6 s per million) at a load average of about 12, and the profiled run above 362.2 s (21.6 s per million). An input within the message-file bound skips the count and reached first contact at 13.1 s per million. Past that load the bound does not hold: the stress test run beside a full repository check took 828.3 s (49.4 s per million), 627.6 s of it in the count: about 430 s in its first role and 180 s in its second.
packages/core/test/stress/inputPreparation.stress.test.ts reproduces the measurement in the opt-in stress tier (npm run test:stress -w packages/core): it writes the input, runs the same core stages in a process of its own under the images' heap limit, logs each stage's time and peak RSS, and holds the first-round check at 2^24: admitted at the per-set maximum, and refused, with the count walking every record at a steady pace, under a maximum one value short of the values the input's first round sends. That is one value a record, less the record holding SSN 111-11-1111, which the built-in standardization drops as a placeholder. About 13 minutes and 12 GB resident; ALCOVE_STRESS_PREPARATION_ROWS lowers the row count.
Stages left as they are. Each is linear in the records and is a limit of the bound above:
- The constraint pass: 78.7 s at 2^24, 176.4 s at 15,339,166 under load. It runs every record's field standardization.
- The count walks every record twice where this party drops its duplicates, the zero-setup default: once for each PSI role, since it refuses only when both are over the bound. 269.5 s of the 362.2 s at 2^24. A party that keeps its duplicates stops counting once over the bound.
- Main-thread memory: a peak of 11.78 GiB at 2^24, the dataset's cached values (7.69 GiB) and the count's index of every first-round value. The PSI round's own need (The budget) is in the worker, beside it.
The practical input bound
On Node 26, V8 adds every string a Map or Set is asked about to its string table, and that table's lookups slow about fiftyfold once it holds about 2^24 strings of 9 decimal digits, which a cleaned SSN is. Measured 2026-10-01 in the development container, keeping each fresh string alive and looking it up once in a one-entry Set:
- 9-digit values in order took 20-70 ms per 250,000 lookups up to 17.0M strings, then over 5 s per 250,000;
- the same values in scattered order collapsed at the same count, so real SSNs, which are not sequential, are no different;
- 10-digit values above 2^32 and 16-digit values did not collapse up to 20M strings.
The host above measured the same collapse in the constraint pass at 2^24 records and in the first-round count from about 13.4M records, from roughly 95,000 to 2,000-3,000 records/s, in Docker and natively alike (2026-09-30 and 2026-10-01).
The constraint pass, the first-round count and the round's deduplication look values up by hash. The null_if and exclude lists, a record's candidate values as its key is assembled, the first-round count, the round's deduplication and the count-only round's single-occurrence filter look values up through DistinctValues (packages/core/src/utils/distinctValues.ts) rather than asking a Map or Set about the value, within the limit under Where values still reach the table below:
- It keys a
Mapby a 30-bit FNV-1a hash of the value's UTF-16 code units, a small integer in every V8 build, and confirms a hit by comparing the two strings with===, so two values share an entry exactly where the string comparison equates them. - A value whose hash another value already holds goes to a string-keyed map of its own, about 131,000 values at 2^24.
- A collection that rarely holds more than one value, a record's candidates or a short
null_iflist, compares its first 16 values one by one before it builds the index.
The hash is the key because, of the keys measured at 2^24, it alone adds no per-record string to the table whatever the value's shape (docs/notes/hash-keyed-value-lookup.md).
Where values still reach the table. The lookups above stop short of two collections. A record holding several candidates, from a split_on fan-out or a fuzzy expansion, reaches the round as a Set of its candidates, and a split_on step's output inside a field's standardization is a Set; both add those candidates to the string table. A fan-out over 2^24 records of digit values is not measured.
Bilateral configuration: detect and fail, never negotiate
lockless_rendezvous and retain_files are bilateral agreements with no negotiation step. The deliberate stance is mismatch detection and clear failure, not capability negotiation: a party advertises its own flags in its hello (HelloEnvelope, the two required fields) and fails fast with a both-sides-named error when the peer's differ. It never adapts to the peer's mode. This boundary is intentional and critical -- advertising flags is one step toward negotiation, and the design explicitly stops there. Do not let mismatch detection grow into capability negotiation without revisiting this decision.
A declared flag on the invitation stays inside that boundary, and this is the decision revisited on the record. The invitation states the inviter's retain_files as inviterRetainsFiles, which is a third home for a bilateral flag beside the connection config and the hello -- so the stance above has to answer for it rather than be quietly eroded. The boundary the stance draws is adaptation: what it forbids is a party changing its own mode to match the peer's, because that turns a misconfiguration the operator must fix into a silent behavior change neither operator chose. A declaration changes no mode. Nothing on the accept path reads the field into a connection, the accepting party still sets its own half from its own config, and a disagreement still reaches the hello and still fast-fails there with both sides named. Every mismatch that failed before this field fails after it, in the same place, with the same error. Disclosure is not negotiation.
What it buys is a consent fact, not a capability. Retain mode is the disclosure of this transport that outlives the run -- the rendezvous location becomes a permanent transcript -- and without the declaration a partner learns of it from a failed run, from an accept kit the inviter may not send, or from word of mouth: never before the point at which they consent. The declaration is therefore scoped to that justification and no further. It is not read, not applied, and not answered; the token holds retain_files alone and not lockless_rendezvous, which is equally bilateral and equally fast-failing but has no disclosure content of its own.
That scope is the line to hold. The next flag proposed for the invitation is admitted only if it discloses something an acceptor consents to, and the mechanism stays declaration-only: an accept path that reads a declared flag into its own configuration -- even to pre-fill it, even to warn -- has crossed from disclosure into adaptation and needs this section rewritten, not extended.
One derivation sits inside that line, which is why the sentence above says declared flag: a split-directory connection_endpoint seeds the acceptor's connection with the retain trio -- retain_files, lockless_rendezvous, timestamp_in_filename -- at connectionFromEndpoint and applyEndpointSplitDirectories. The function returns the adopted pair as its endpointDirectories result, and its caller in apps/cli/src/commands/accept.ts names both directories on the acceptance consent display. That trio is derived from the endpoint's shape, not from any flag the inviter declared: a separate outbound directory is unusable without it, since ConnectionConfigSchema refuses the pair unless retain mode is on, so what the acceptor is seeded with is entailed by the locator it was handed rather than adopted from a mode the peer announced. It is written into the acceptor's own connection block, where the operator reviews it -- on the online path, where that block is written only after the handshake, the operator reviews the adopted directories on the consent display before the run -- and the acceptance surfaces state it as a consent fact (Retain-mode declaration on the token). A newly proposed flag is weighed against the sentence above, never against this derivation.
The same bilateral-vs-local split governs the CLI surface: lockless_rendezvous, retain_files, and timestamp_in_filename get command-line flags (--lockless-rendezvous, --retain-files, --timestamp-in-filename) because an operator must coordinate them with the peer at invocation, whereas a purely-local tuning preference such as unexpected_files -- which the two parties may set differently -- is configured in YAML only. CLI flags are otherwise reserved for bilateral settings that must match across parties; local tuning lives in the connection config.
--outbound-path is the deliberate exception to that rule: a local setting (each party chooses its own inbound and outbound directories, which the deployment bridges) that is nonetheless a CLI flag. The justification is that it is a locator, not a tuning preference -- the companion to the inbound directory, which is already a command-line argument (the server URL or positional path) -- so pairing it with that argument on the command line is the natural surface, where a per-party tuning flag would not be. The CLI maps the URL/positional path (or, for alcove exchange, the loaded config's directory) to inbound_path and --outbound-path to outbound_path, for both sftp and filedrop, in the single applyConnectionOverrides chokepoint every bootstrap command routes its connection through (so the four commands share one mapping). It then validates the assembled connection through the same ConnectionConfigSchema a hand-written alcove.yaml is parsed with, so the split-mode preconditions -- the pair set together, the two directories distinct (pathsResolveToSameDir), the filedrop-absolute rule, and the retain-mode requirement -- reject identically whether a split arrived from flags or from YAML. Only the retain-mode requirement is additionally fast-failed at the CLI, with a --retain-files-named message, before the schema runs; the rest surface with the schema's own messages. The invitation-borne role swap for a split exchange (one party's outbound seeding the other's inbound) is a separate mechanism from this local CLI surface: it rides the invitation connection-endpoint wire format and is applied once at accept-seed time through connectionFromEndpoint, not through applyConnectionOverrides. Both accept modes consume it -- offline seeds the whole block from it, online grafts only its swapped pair onto the URL-built connection -- and an explicit --outbound-path (which does route through applyConnectionOverrides) overrides it on the online path. See Invitation-borne role swap above. (The inviter-side complement -- emitting that endpoint from online alcove invite -- is endpointFromConnection, defined in packages/core/src/config/endpointProducer.ts and re-exported by the CLI, the producer inverse of connectionFromEndpoint: it derives the endpoint from the connection the invite is using and records the inviter's pair verbatim, with no pre-swap at emit, so the single accept-side swap runs end-to-end. The acceptor mirror-swap is in place regardless of which inviter produced the endpoint. The producer is typed to the file-sync channels only: a webrtc locator names a signaling server, authored from the browser location rather than derived from a connection config, so a webrtc connection cannot reach it.)
The entry-sweep flags (--sweep-exchange-files, --force-retain-sweep) are a third category that does not fit that split: they are CLI-only, unilateral, and non-persistable. CLI-only not because they are bilateral but because "always sweep" must never be a persisted state -- they thread to the connection's runtime options on a path separate from config construction, never as FileSyncOptions/alcove.yaml fields. Unilateral: one party sweeps its own view of the directory, no peer must pass them, and they have none of the bilateral-mismatch stall risk of retain_files/lockless_rendezvous. They do, however, interact destructively with the bilateral retain_files agreement: because the sweep runs before rendezvous, a blind sweep would delete the peer hello that the bilateral flag check reads to detect a retain_files mismatch -- destroying a retain peer's transcript and bypassing the fast-fail that would have caught the misconfiguration. The pre-sweep retain-signal inspection (site 1) is what closes this; it must not regress to a blind sweep.
Detection is symmetric (two-sided), via durable hellos. The flag check sits at every peer-hello read site, not just one, and a detecting party leaves its own advertised hello in the directory (writing it first if it had not yet done so) and does not delete the peer's hello. The two rendezvous protocols are not symmetric in who reads whom -- a lockless_rendezvous mismatch pairs a lockless ack-barrier against a delete-based lock rendezvous -- so a check at one site alone would fast-fail only the party that happens to read the peer hello, leaving the other to hit the peer timeout. Because the advertisement is durable on both sides, the peer (whatever protocol it runs) reaches a peer-hello read site, reads the flags, and fails the same way. This holds in both arrival orders and for both flags, including a retain_files mismatch where both parties run the lockless ack-barrier. Leaving an already-written hello so the peer can read it is not adaptation: the boundary the bilateral stance forbids is adaptation, not durability, so symmetric detection stays inside it.
A mismatch leaves both hellos as the directory's terminal state. The mismatch path does not sweep the hellos: a party that advertised a mode has its advertisement respected as the terminal state rather than silently swept, and the peer needs the file on disk to reach the same conclusion. In-memory session state is still reset (resetSessionState()) so the instance is not wedged; only the on-disk sweep is skipped. The cost is that a rerun against the same directory is rejected by the entry guard (I0) -- a terminal usage error, not a silent stall. Which of I0's two refusals fires turns on the ids: with the default fresh id per run, both leftover hellos are peer hellos to the rerunning party, so it is the at-most-one-peer-hello check ("are there other sessions using this path?") that rejects. Only with a configured peer_id does this party's own leftover read as a self hello and take the unexpected-protocol-file refusal instead. Either way the operator clears the directory and reruns with corrected flags. This is consistent with retain mode's fresh-directory precondition. The mismatch is reported as a distinct typed error (BilateralModeMismatchError, a UsageError subclass) so the two cleanup sites -- the rendezvous outer catch and the joiner fast-path, which has no enclosing catch -- branch on it deterministically rather than by string-matching the message.
The symmetric guarantee is best-effort, contingent on the advertising write landing. Leaving a durable hello presumes the put that writes it succeeds. The joiner fast-path -- the one mismatch site that must write a new hello at detection time -- retries that write up to a bounded budget (ADVERTISE_HELLO_RETRY_ATTEMPTS, at the polling cadence), so a transient failure has more chances to land. The peer polls concurrently, so the advertisement need not arrive on the first try, only before the peer would otherwise time out, and the budget stays far under peerTimeoutMs. If the write still fails every attempt (a transport error sustained across that whole window), no write ordering can recover -- there is no durable file for the peer to read, so the peer falls back to the ordinary peer-timeout on that one side. The detecting party still fails locally and correctly: it swallows the write failure and throws the typed BilateralModeMismatchError (CLI exit 64) rather than letting a transport rejection mask the actionable cause as a generic error (exit 69). This degradation is the floor, never the target -- it is no worse than the pre-advertisement behavior (a silent stall until the peer timeout), and it only occurs when shared storage is failing writes, in which case the exchange could not have proceeded regardless.
Error taxonomy
Errors out of synchronize() and send() fall into two classes, and the distinction is part of the contract with the CLI:
- Usage / configuration error -> CLI exit
64(EX_USAGE). Wrong directory state (preexisting files), multiple concurrent sessions, a stale lockless ack, a message-consumption timeout, a bilateral-mode mismatch (BilateralModeMismatchError), and a malformed control payload (a fully-synced hello missing a required flag or with an out-of-type value) belong here. So does a--sweep-exchange-filesrefusal on a retain signal (local retain mode, a peerretain_files=truehello, a retain-only message ack, or a retain-uncertain hello body that did not resolve within the inspection budget -- the error names the file(s) and the signal and points at--force-retain-sweep), and so does--force-retain-sweeppassed without--sweep-exchange-files. - Transport failure -> CLI exit
69(EX_UNAVAILABLE). The peer did not arrive withinpeer_timeout_msor went silent pastinactivity_timeout_ms,list/get/putrejected, the listing will not converge, or a--sweep-exchange-filesdelete rejected (the sweep usesclient.delete, which rejects -- notsafeDelete, which swallows -- so a transport that cannot delete is exposed here instead of falsely reporting a clean slate).
This distinction is expressed by a typed UsageError (exported from @alcove/core, defined in errors.ts): the usage class is anything instanceof UsageError, and everything else is a transport failure. The CLI's command boundaries map a caught error to its exit code in one helper, exitCodeForError (util/exit.ts), and CLI.md lists every code with what it calls for. It returns 64 for a UsageError or a ConnectionError of kind usage, 70 for an InternalConsistencyError, 76 for a refusal by the partner or the agreed terms (a ProtocolRefusalError, a PeerAbortError, a ReceiptVerificationError, or a ConnectionError of kind protocol), 77 for an AuthenticationError, 66 for an InputNotFoundError (an input file that does not exist), the exit code a CLI raise site annotated on the error or on a link of its cause chain (withExitCode), and 69 otherwise. After the exchange starts, send() and poll() failures reach that boundary through the message bridge (fromEventConnection), which wraps each as a transport-kind ConnectionError holding the transport's error as its cause; the helper looks through those transport-kind links for the usage class, an InternalConsistencyError, a partner refusal, an AuthenticationError, and an InputNotFoundError, so each keeps its own code after it is wrapped. For the errors this section covers, that is 64 for the usage class and 69 for a transport failure. New throw sites in synchronize() or send() should throw UsageError for the usage bucket rather than a plain Error, classifying into one of these two buckets from the start.
Bounded error enumeration
Both refusals the entry guard raises -- the unexpected-protocol-file one in I0 and the at-most-one-peer-hello one in I1 -- name the files they refuse over, and what they can name is bounded. They are composed under the display-budget convention I5a states for the hello-read messages, resolved the other way: the refusal and its recovery step are the error's own message, holding no variable text at all, while the directory scope and the offending filenames ride in a cause link of their own, so each gets a full per-link budget rather than sharing one. The enumeration is bounded by that budget rather than by a count -- a protocol filename runs from roughly 47 characters to 112, so no fixed count both fills the budget and fits inside it -- and is reported as the true total plus the names that fit, taken in listing order.
Two things make that arithmetic hold at the rendered boundary rather than only on the way in. First, every fragment is costed as rendered: the composed link is sanitized again when it is shown, and sanitizing is not idempotent (each pass doubles a literal backslash), so an arithmetic done on raw lengths under-counts any name with escapes. Second, the names hold a reserved floor -- 112 characters, the longest name the protocol's own constructors build from UUID identities (a retain-mode message ack over a timestamped message at the maximum frame size, <uuid>-<uuid>-<YYYYMMDDTHHMMSS>-<NNN>-<byteCount>-ack.json at a three-digit counter), plus the (and N more) suffix -- held back before the directory scope is fitted into what is left. An operator-chosen path (two of them in split mode) is therefore trimmed with the display truncation marker rather than crowding the enumeration out. That value is computed by calling those two constructors, so it follows a change to either name shape, and packages/core/test/connection/fileSyncRendezvous.test.ts pins it at 112 against a name those constructors build. Each name is then fit-checked on its own and in listing order: one that does not fit is skipped and the next is tested. A long name early in the listing therefore costs only itself, and a name that does not fit is counted rather than shown in part, since a partial name reads like a whole one the operator could act on.
A name goes unshown for either of two reasons, which are different outcomes for the operator. It may be too long for the budget even as the first name tested -- the floor does not make every name fit, since a configured peer_id may run to 211 bytes and a session past message 999 widens the counter -- and then no delivery at that scope and count shows it. Or it may be a name that would be shown whole on its own, omitted only because earlier names spent the budget: in the deliveries of packages/core/test/connection/fileSyncRendezvous.test.ts, 11 of 12 message acks and 2 of 3 split-scope lock files are omitted for that reason, and each of those names is shown whole when it is the only file present; that suite asserts both counts and each solo display. A refusal reports the count with no names at all only when the first reason holds for every name it contains.
A name's private-key shape is not a second way to lose it. What spends the budget is rendered cost -- length plus whatever escaping the name's own code points add, as costed above -- and redaction does not add to it. The entry guard redacts each name, and the directory scope, through redactPrivateKeyMaterial before it fits them to the budget (see DISPLAY_REDACTION.md). A protocol-grammar filename shaped like a PEM private-key header is therefore replaced inside its own name: it displays as [redacted private key] in full. The redaction is fail-closed to the end of the fragment, so a name that is key material is taken whole rather than leaving a tail, while the count, the directory scope, and every name behind it stand. The replacement is shorter than the shortest marker it can stand in for, so redacting before the fit never widens a fitted name and the budget still holds. The scope clip is not a second limit either: the guard budgets in rendered cost over the raw path and admits a code point only when its whole escape cost fits, so the operator sees the escaping of a whole code-point prefix of the real path rather than a cut inside one code point's escape. What each message measures at the rendered boundary is pinned by a test rather than asserted here.
SFTP rename retry
Every atomic write this model specifies ends in a rename to the final name: the hello write's temp-hello-<uuidv4()>.tmp -> <id>-hello-<bytes>.json, the joiner's temp-hello-<uuidv4()>.tmp -> <id>-joining-<bytes>.json, and its <id>-joining-<bytes>.json -> <id>-hello-<bytes>.json in Phase 1, a message's temp-<uuidv4()>.tmp -> its numeric-terminal name and an ack's temp -> <writerId>-<originalName>-ack.json in Phase 2. Every one of them therefore inherits the SFTP adapter's rename retry, bounded the same as put: one initial attempt plus up to five re-issues by default (the adapter's retries option), 100 ms apart. Unlike put it is not idempotent, so it fires only on the generic SSH_FX_FAILURE (status 4), the status reporting that the rename did not take effect; every other status is terminal and is reported at once. Why that numeric status is the safe one to re-issue on, and how a stack bump can silently retire the retry, are in DEPENDENCY_PINS.md. What matters to the state machine is only that the retry cannot produce a second final name: a re-issue fires only where the source still exists.
The server observed refusing renames answered each of these with SSH_FX_NO_SUCH_FILE (status 2), the same status a source that really vanished gets, so its refusal is terminal at the first attempt. Another server may refuse with SSH_FX_PERMISSION_DENIED (3) or SSH_FX_OP_UNSUPPORTED (8), and a synced folder with EPERM or EACCES. Where the source is a temp this party has just written, every terminal rename failure the server or folder answered with a status or error code states the failure's own status or message, says the server may not allow renames, and names write_without_renaming as the possible remedy rather than a finding. Every other failure is reported as itself: one with no such code, such as a dropped session or a refused host key on re-dial, and, whatever its code, an undetermined write or a failure the transport has already classified with its own next step, such as a stalled operation or an exhausted reconnection budget. A party with that option on issues none of these renames (Writing without renaming).
Writing without renaming
write_without_renaming (--write-without-renaming) replaces every temp-then-rename write in this model with a single put straight to the final name, for a party whose server refuses rename. It is one party's local setting: neither the hello envelope nor the invitation states it, the directory holds the same final names either way, and a partner running without it completes the same exchange. It applies to sftp and filedrop alike, since the switch is in the connection, not the transport.
The assumption. A direct write is visible under its final name while it is still being written. The operator who turns the option on vouches that the shared folder never delivers a file to the partner in that state; for a service-synced share, that the sync does not carry a file across mid-write. Alcove cannot check this and does not police it; the run states it in a warning before it connects.
What each reader relies on. Every reader already has a completeness signal that does not depend on the rename, so the mode adds none:
| File | What its reader relies on under a direct write |
|---|---|
| message | its on-disk size reaching the byte count its name declares (site 3) |
| ack, rendezvous and message | nothing: it is zero-length, so its name appearing is completion |
| hello | its size reaching the byte count its name declares, through the I5a read gate, which retries a short read until its bounded deadline, then validates HelloEnvelope |
| joining sentinel | nothing: the peer matches it by name and never reads it |
| abort marker | its size equalling the byte count its name declares, then its verification: a torn body fails either, reads as no marker, and the loop keeps polling, at worst falling back to the peer-silence hedge |
| lock | unchanged: it is already a direct write (createExclusive) |
The lock-path joiner. The joiner writes its <id>-joining-<bytes>.json sentinel, deletes the peer hello, writes its own hello, and only then deletes the sentinel. The sentinel still spans the window in which the peer hello is gone and the joiner's is not yet written, so the peer's bounded recovery wait is unchanged. The new window is the one after the hello is written and before the sentinel is deleted, in which the peer can list both. The peer tolerates that during rendezvous because the sentinel's id matches the hello it completes against (Phase 1, lock path), and in its message loop because the peer's sentinel is in the recognized-for-the-loop set (site 3). Both tolerances are the peer's own, whatever the peer's setting, which is what keeps the option per-party; the loop tolerance is this release's, so a peer on an earlier build whose loop lists the sentinel before the delete lands fails with an unexpected-file error, an availability failure rather than a disclosure, which the pre-publication lock-step upgrade rule covers. A sentinel delete that fails does not fail the rendezvous, since the hello is already visible and the peer may have read it: the joiner warns, tracks the sentinel again so cleanup() removes it at close in delete mode, and its own loop recognizes it by name meanwhile. Requiring lockless rendezvous for the mode would have avoided the sentinel, but lockless_rendezvous is bilateral and advertised in the hello, so it would set the partner's configuration.
Write tracking. In delete mode, a write the default mode tracks after its rename is tracked before its put instead, as the lock is (I4a), because a put that throws can still leave the final name on disk. That covers the hello, the joining sentinel (released once the peer hello is deleted, as before), the joiner's hello, the lockless rendezvous ack (whose path the terminal rendezvous rollback also holds before the write), and the message. The abort marker and the hello a refusing joiner advertises stay untracked, as they are by default, because both must persist for the peer to read. A message whose direct write throws is an unsettled write, as an undetermined rename is (I8): the file may be on disk in full and already read, so send() rejects with TransportWriteIndeterminateError and the clean-directory remedy, the NNN is spent, and the next send() refuses. Over SFTP, a put whose reply a dropped session lost is decided by what the re-dialed session finds under the final name: complete, the write landed and the put resolves; short, the put is re-issued once; absent, longer, or unreadable, the write is undetermined, as above (SFTP_SESSION_RECOVERY.md).
Limits.
- A hard kill mid-
putcan leave a torn<id>-hello-<bytes>.json, the case the temp-then-rename hello write exists to prevent. The next run's I5a gate fails it within the rendezvous read bound, and--sweep-exchange-filesclears it. - Whether a given sync service delays a direct-written file's size relative to its name more than a renamed file's is not measured; the byte-count gate and the I5a gate are what absorb it, and the I5a bound (six poll cycles under the wall-clock floor) has not been measured against such a write.
- Policing the sync, and detecting a partial file beyond those gates, are out of scope.
Sender-side peer-silence attribution
The peer-silence timeout is asymmetric, by necessity rather than by oversight. When the peer dies mid-exchange -- the canonical case being its exchange directory going read-only so it can never write the next message or its ack -- the receiver (the failing party) names its own cause locally. poll() emits the underlying transport error, the fromEventConnection bridge makes it terminal on first emit, and asConnectionError copies that cause into the reported error (see the I8 production-composition note). The sender (the waiting party) has no equivalent signal. It observes only that nothing arrived within the inactivity budget (inactivity_timeout_ms) and fails with the generic peer-silence transport error.
That budget is armed per send, not measured against one deadline fixed when the connection opened. It bounds silence from the peer, so each wait -- the delete-mode wait for the peer to consume lastSentFile, and retain mode's wait for the ack of the last message -- gets the whole of it, exactly as every transport await is raced against a fresh budget (the whole-exchange budget in TRANSPORT_LIVENESS.md). It is therefore not a cap on how long an exchange may run: an exchange that takes longer than inactivity_timeout_ms reaches its next send with a full budget rather than one its own progress has spent, which matters because the protocol sends twice in a row with no receive between the two, so this wait is reached on every exchange. What stays bounded is the failure it exists for: a peer that never consumes or never acks ends that send within one budget, plus whatever poll step was in flight when it expired.
A cross-party signal -- the failing party writing a marker the waiting party reads -- cannot cover the headline case: an exchange directory that has become unwritable is precisely what stops the peer from writing any file, so for that mode (and a hard kill) the channel is absent in exactly the scenario it would serve. For the modes where the directory is still writable (a transport-op fault, a terminal parse error, a graceful crash) the authenticated <writerId>-abort-<bytes>.json marker covers it, letting the waiting party fail fast with a definitive PeerAbortError (CLI exit 76) rather than waiting out the full timeout. The marker states only that the partner ended the exchange: the cause may be a fault on the partner's own side, a transport stall or drop included, and the waiting party's message does not state it -- the partner's own error does. Retrying alone does not help until the partner runs again, which is why it exits 76 and not 69. It is additive to the filename grammar: the abort type word before its byte count routes it as a control file (the isMessageName discriminant), never mis-consumed as a message (see the filename grammar). Its wire envelope, the session-key-derived token that makes it unforgeable, the no-cause decision, and the small read cap are specified in CHANNEL_SECURITY.md; the key point for attribution here is that its absence stays strictly uninformative -- a missing, unreadable, or non-matching marker falls back to the hedge below and is never treated as success.
The operator guidance is the baseline beneath the marker, not a claimed ceiling, and remains the floor whenever the marker is absent. The sender's timeout error includes a transport-supplied hint (peerSilenceGuidance in run/notices.ts, threaded through fromEventConnection's inactivityHint option) that names the likely receiver-side causes -- the peer exited, or its directory became unwritable -- directs the operator to the peer's own logs where the real cause was recorded, and notes the slow-large-dataset case so the timeout is not misread as a death. It hedges rather than asserting a specific cause. The hint is supplied by the caller: the WebRTC transport, which shares the same core inactivity message, passes one that only says to raise the limit, with --inactivity-timeout or inactivity_timeout_ms. The authenticated marker above upgrades this hedge to a definitive peer-abort error for the writable-directory modes only; it complements the guidance rather than replacing it, so the read-only-directory headline, a hard kill, and a lost best-effort write all still degrade to this floor.
See also
- EXCHANGE_REFERENCE.md - the user-facing configuration reference and the canonical filename grammar this state machine rests on
- COMMUNICATION.md - the high-level channel and synchronization overview for the
sftpandfiledropchannels - PROTOCOL.md - the PSI and key-exchange cryptographic protocol sent over this transport
- CHANNEL_SECURITY.md - the application-layer AEAD envelope
- TRANSPORT_BOUNDS.md and TRANSPORT_LIVENESS.md - the transport memory and liveness bounds whose terminal errors this state machine reports