CLI machine-interface event stream
This document specifies the opt-in machine-readable event stream the alcove CLI emits under --event-stream. It covers the file descriptor the stream is written to, the NDJSON framing and per-line schema version, every event type and its fields, the registry of warning sources, the four terminal-error categories and the rules that classify them, the security marker and the internal-fault code that sits opposite it, the single-terminal-event guarantees, the sanitization applied to every field, and, outside the stream, the disclosure digest line the human log records. It is the spec-tier complement to the operator-facing --event-stream description in CLI.md, which says what the stream is for and how to consume it; this document says how each line is constructed. It does not include the exit-code table (see CLI.md) -- only the two codes a supervisor must read against the category beside them -- nor the exchange protocol that produces the stages (see PROTOCOL.md), nor the display-sanitization escape format the fields reuse (see DISPLAY_ESCAPE.md and packages/core/src/utils/sanitizeForDisplay.ts). Intended readers are implementors writing a supervising process and security auditors.
The stream is a machine interface for a supervising process (an orchestrator, a job runner, a test harness) that spawns alcove and reads structured progress and outcome events without parsing the human log. It is off by default; passing --event-stream turns it on for every exchange-running command (the zero-setup exchange, alcove exchange, and the online alcove invite/accept). It has no effect on an offline invite/accept, which runs no exchange, except an acceptance that runs the exchange itself: given an INPUT_FILE, that offline-form accept emits the stream exactly as the online form does. An accept that does not run the exchange, on either form -- declined at the prompt, stopped by --no-run or by a requested column its configuration does not send, or given no INPUT_FILE -- opens no stream and writes nothing to fd 3.
Where the contract is declared
The stream's schema is declared in code once, in the @alcove/cli-contract workspace package (packages/cli-contract/src/): the event types and their fields, the warning sources, the CLI's named exit codes, and the cause field of the error event. The CLI builds every line from those declarations, and the console validates every line it relays against them. The package imports nothing from an application.
File descriptor
The event stream is written to file descriptor 3, a fixed constant, never configurable. stdout (fd 1) and stderr (fd 2) are untouched by --event-stream: the CSV result still goes to stdout (byte-stable), and every human log line still goes to stderr. A supervisor wires fd 3 to a pipe it reads (for example, in Node, spawn(cmd, args, { stdio: ["inherit", "pipe", "pipe", "pipe"] }) exposes it as child.stdio[3]), so structured events arrive on a third channel that cannot corrupt the result or interleave with the log.
If --event-stream is given but fd 3 is not open for writing (the process was spawned without wiring it, or with it open only for reading), the CLI fails closed and loud. It raises a usage error (exit 64) before the run reads its configuration, opens a connection, or moves any data, rather than silently dropping every event or crashing mid-run on the first write. Every failure after the check passes ends with the run's terminal error event (see Terminal-event guarantees).
Where each exchange-running command runs the check:
alcove exchange, the quick exchange, and an onlineinviterun it first, once the command line has parsed and the--log-fileis open. The configuration load, the--invitationdecode, the input read, and every other refusal come after it.- An
accept, on either form, is known to run an exchange only once the invitation decodes, the input is read, and the operator consents. It runs the check at that decision, ahead of every later step; the invitation decode, the configuration load, the credential reads, the input read, and the consent prompt come before it.
Every exchange-running command raises the undeclaredColumns warning before SSH host-key trust, which opens an SSH connection of its own to read the server's key: alcove exchange with the lifecycle's other local prepare checks, before the wake call a connection.server.provision block states; the online invite/accept immediately before the host-key step. Zero-setup raises none, since its direct-mode inference declares every input column (DEFAULT_STANDARDIZATION.md).
The check is an fstat on fd 3 followed by a zero-length write, which writes nothing; a failure of either is refused with a message saying fd 3 is not open for writing, giving the shell redirect 3>events.ndjson as one way to wire it. The write is required because fstat passes two descriptors the process cannot write: one open only for reading (3</dev/null, or an inherited read end), and one the supervisor left closed (3>&-), which the Node runtime takes for its own event loop before the check runs. The write's error code varies with the case and the platform (measured: EBADF for a read-only descriptor, ENXIO on macOS and EINVAL on Linux for a closed one), so the check treats any error as fail-closed and reads no code.
NDJSON framing
The stream is newline-delimited JSON: one JSON object per line, each terminated by a single \n. Each event is serialized and written synchronously before the next is built, and the writer drains a short write in a loop, so every line is written whole and no two events interleave. Line ordering is emission order.
A single read can still end part-way through a line: a warning line runs to about 4 KB, above the size a pipe writes atomically. A supervisor buffers what it reads up to each \n and parses whole lines. apps/cli/test/integration/backendAgnostic/eventStreamBackPressure.test.ts drives this under back pressure, with a reader that falls behind a writer emitting 2,000 such warnings, on both descriptors a supervisor ordinarily wires: the socket pair Node's stdio: "pipe" creates, and an OS pipe from a shell redirection. On both, a full buffer blocks the write rather than shortening it, and the reader reassembles every event as one whole line, in order.
Every line has a schema-version field so the version is observable from any single line on its own, without tracking stream position:
| Field | Type | Value | Meaning |
|---|---|---|---|
v | integer | 1 | Event-stream schema version. Starts at 1. Bumped on any breaking change to an event's field layout or to the classification rules below; an additive field need not bump it. |
type | string | one of stages, stage, stageEnd, warning, metrics, result, error | The event discriminant. This party owns every value; none is partner-derived, so a consumer can switch on it safely. |
A field added to an existing event is additive and does not bump v. Every field a consumer already reads keeps its name, its type and its meaning, and a consumer written against the version before it ignores a key it does not know, as it ignores any other. warning source is the worked case: a supervisor written before that field existed goes on reading message exactly as it did. A bump is for what an older consumer would read WRONG -- a field removed or renamed, a type or unit changed, or a change to the rules deciding which value an existing field takes. A value added to the warning source registry is additive on the same reading, so a consumer switching on source treats a value it does not recognize as a notice it does not know rather than as a malformed line.
A write failure after the supervisor has closed its read end (an EPIPE) marks the stream broken and is swallowed: it never crashes the exchange. Once broken, no later event retries the write. A supervisor reads "the stream stopped before a terminal event, and the process exited" as its own signal (see Terminal-event guarantees).
Event types
The event types, and the fields each has in addition to v and type:
stages
Emitted once, before the first stage transition, mirroring the web front end's onStages. It holds the full ordered list of protocol stages the run will pass through, so a supervisor can render a progress skeleton up front. On a single-pass run the list also names the three encrypt and match stages (encrypting my data, doubly-encrypting partner's data, identifying shared elements); which of them a party passes through follows the role the handshake resolves, so each party emits two of the three, in the list's order.
| Field | Type | Meaning |
|---|---|---|
stages | array of { id, label } | The ordered stage list from core's describeExchangeStages. id is a stable stage identifier (for example confirming protocol, stage 1 / 2); label is its display text. Both are sanitized (see Sanitization). |
{"v":1,"type":"stages","stages":[{"id":"confirming protocol","label":"Confirming protocol"},{"id":"stage 1 / 2","label":"Linking key 1 / 2"}]}stage
Emitted at the start of each protocol stage, mirroring onStage. It marks a transition into the stage named by id.
| Field | Type | Meaning |
|---|---|---|
id | string | The stage identifier, matching an id from the preceding stages event. Sanitized. |
label | string | The stage's display text. Sanitized. |
{"v":1,"type":"stage","id":"stage 1 / 2","label":"Linking key 1 / 2"}stageEnd
Emitted when a protocol stage completes, stating how long it ran so a supervisor can attribute wall-clock to the stage. A stage completes when the next stage begins (its stage event closes the previous one) or when the exchange finishes (the last stage). Only a completed stage is reported: a run that aborts mid-stage emits no stageEnd for the in-flight stage, so a reported duration is always a whole stage's time. The local output stage (result-CSV and audit-record generation, after runExchange returns) is not a named protocol stage and is not timed.
| Field | Type | Meaning |
|---|---|---|
id | string | The completed stage's identifier, matching an id from the preceding stages event. Sanitized. |
durationMs | integer | The stage's wall-clock in whole milliseconds; never negative. |
{"v":1,"type":"stageEnd","id":"stage 1 / 2","durationMs":1234}warning
Emitted for each non-fatal warning. Every one of them states which notice raised it in source, the machine-readable discriminant of this event: a supervisor decides whether to alert on the line without parsing message, so the cross-party host-key divergence security signal and the notices an ordinary run raises are told apart by a field rather than by prose. The sources, each with its source value, are enumerated in Warning sources below: the terms-exchange warnings mirroring onWarning, the cross-party host-key divergence notice, the first-contact partner-certificate pin notice, the resolved-cardinality and pair-table notices of the post-terms, pre-round boundary (PROTOCOL.md, The advisory bound), the pre-exchange signing-without-a-record notice below, the failed-record notice of a terminated run below, the payload columns an unattended run took without asking (Payload columns taken without asking), and the count of lines a --log-file could not take (Log file loss). The divergence notice is a security signal a supervisor that discards stderr would otherwise never see. One warning is also emitted per persistence loss, the class defined below: an audit artifact the run was asked for and could not produce, a configuration or consent record an online invite/accept could not write, or the configuration and key a zero-setup --save could not write. A warning does not end the run, and the terminated-run notice is the one exception to that reading: it accompanies a run that is already failing, and it is the failure's own error event that ends it.
A run that configures a signing identity while record writing is off (--no-record) emits its warning before any credential, terms, or data are sent, so the operator can still change either choice: the receipt that run writes has no record to pair against and can never verify above INCOMPLETE, and the record cannot be reconstructed afterwards (see EXCHANGE_RECORD.md). The run itself is unaffected -- which artifacts to keep is the operator's call.
An audit-artifact warning takes one of two shapes: an artifact that was built but could not be written names the destination it could not be written to, while an exchange record that could not be built at all names no destination and states that none was written. The second shape closes with what the run's own state makes of that: a completed run's says the run need not be re-run, a terminated run's that the disclosure it had already made has no local record.
A transport that does not finish closing within the ceiling for its channel raises no event at all. That close runs after the terminal event (Terminal-event guarantees), so a warning for it would land where a consumer is no longer obliged to read; the run states it on the operator log at error level instead -- the level an unattended --log-level error run keeps -- and leaves the exit code alone, the exchange's own outcome being decided before the close began. --log-level silent suppresses error too, so a run set to it learns of an abandoned close on no channel, this stream included. The notice, what it claims about the operator's disk, and the ceiling for each channel are in CLI.md, WEBRTC_TRANSPORT.md and FILE_SYNC.md.
A run that disclosed and then terminated in the signed-receipt swap still writes the self-attested record of that disclosure (PROTOCOL.md). A failure to write it emits a warning naming the destination, exactly as the completed-run write does, and a record that could not be built at all emits the other shape, so a disclosure that occurred always leaves either its record or a notice of that record's absence. The same holds for a run that terminates in the refusal of a payload sent to a party that receives none, or of one whose columns differ from the agreed ones. Two exits from that rule are by design. --no-record gates the write and the notice alike, the operator having chosen to keep neither. And a failure thrown as a non-object brings neither mark back to the CLI, so core reports the loss on the operator log instead -- where the record built, at the point it could not be attached; where it did not, at the build. Both notices are not a persistence loss and leave the exit code alone. That class means "the exchange succeeded, do not re-run it", and its code says so; this run failed and exits on its own fault, which is the opposite instruction and the one the operator needs. The terminal event stays error, and the warning precedes it like every other.
| Field | Type | Meaning |
|---|---|---|
source | string | Which of this party's notices raised the warning, one of the closed set in Warning sources. This party owns every value; none is partner-derived, so a consumer can switch on it safely. |
message | string | The warning text. Sanitized and redacted -- it can embed partner-authored column names (see Sanitization). |
unescapedMessage | string | The same warning text before the escape message takes: redacted and fitted so that one escape of it stays within the warning budget, but NOT escaped. A consumer that escapes what it shows reads this field instead of message, so the text takes that one pass and no more. Present on every warning except payloadReceiveTaken, whose partner text is in columns (Payload columns taken without asking). Additive under the same schema version. |
{"v":1,"type":"warning","source":"termsExchange","message":"date mismatch: yours is 2026-01-01, your partner's is 2026-02-01; one of you may have a stale copy of the linkage terms","unescapedMessage":"date mismatch: yours is 2026-01-01, your partner's is 2026-02-01; one of you may have a stale copy of the linkage terms"}A partner payload whose columns differ from the partner's agreed payload.send raises no warning: the receiving run ends on an error event with exit code 76 (The partner-refusal code), and its message names none of the columns.
Warning sources
The closed set of source values. WARNING_SOURCES in packages/cli-contract/src/warningSources.ts is the same set in code. A new warning source claims its value in this table.
source | The notice it names |
|---|---|
termsExchange | A warning raised during the terms exchange (core's onWarning), such as the two parties' terms stating different dates. Its text can hold partner-authored values. |
hostKeyDivergence | The cross-party host-key divergence notice: the one control that catches a one-sided SFTP interception, and the signal a supervisor that discards stderr has nothing else to read. |
partnerCertificatePinned | A first authenticated contact adopted the partner's signing certificate: the message names the fingerprint recorded, the configuration file it went into, and the out-of-band comparison that is the only thing which strengthens it. Raised once, at the terms exchange, before anything is disclosed, and never on a run that already pins a partner fingerprint. |
unnamedPartnerRecord | The partner named no identity on a run that files an exchange record, so that record holds no partner name and an accounting of disclosures drawn from it takes the recipient from the operator's own notes. |
resolvedCardinality | What the two parties' agreed deduplicate values resolved to, stated at the post-terms, pre-round boundary. |
pairTableAdvisory | The projected pair-table size at that same boundary, past the advisory bound (PROTOCOL.md, The advisory bound). |
signingWithoutRecord | A signing identity configured while record writing is off, raised before any credential, terms, or data are sent. |
undeclaredColumns | The input file holds columns the metadata does not declare, which the run does not send; the message names them, up to a fixed number and counting the rest, and ends with how to declare one. Raised before any credential, terms, or data are sent, and only when there is at least one, which a zero-setup run never has. |
payloadSendBeyondConfiguration | The run states to its partner, from its metadata, payload columns that the configuration's payload.send does not list; the message names them and how to record them. Raised before any credential, terms, or data are sent, and only when the configuration has a payload.send and the metadata sends a column it does not list. |
payloadReceiveTaken | An unattended run -- no terminal on stdin -- took the payload columns its partner declares it sends, because its terms list none it receives (linkage_terms.payload.receive), and asked no question. The message states the notice the run writes on stderr at every log level, naming each column and the configuration they were written to, or that they were written to none, with the configuration path escaped for display where stderr shows it as the operator typed it. Raised once, at the terms exchange before any linkage key or data moves; a zero-setup --save run raises it once its save outcome is known, before the terminal event. The event also holds columns and columnCount (Payload columns taken without asking). |
terminatedRunRecord | A terminated run's record of what it had already disclosed could not be written, or could not be built at all. |
persistenceLoss | A completed run's local write that did not reach disk (Persistence loss). The only value that stands beside a process exit code. |
memoryShortfall | The run went ahead under --allow-memory-shortfall although its PSI round needs more memory than the process has; the message names both figures and which limit sets the second (FILE_SYNC.md). Raised before any network contact, and only under that flag. |
logFileLoss | The --log-file stopped taking lines during the run. Raised at most once, immediately before the metrics event, and only when a line was lost; the event also holds lostLines, the count of lines the file is missing (Log file loss). |
writeWithoutRenaming | The run writes its exchange files directly under their final names (write_without_renaming); the message names the assumption the operator vouches for, that the shared folder never passes a file to the partner while it is still being written (FILE_SYNC.md). Raised once, before any network contact, and only on an sftp or filedrop run with the option on. |
invitationRelayEnrollment | The registrar the invitation's relay names did not enroll this exchange: it refused, could not be reached, or did not answer within the run's wait (PROTOCOL.md). The message names the registrar's origin and what it answered, and states that the run goes on with its other relays. Raised at most once, before the first connection attempt, and only on a webrtc run whose connection.invitation_relay names a registrar and whose first TURN url is one the invitation names and takes the run's derived credential. Its text holds the registrar's address and answer, which the partner's invitation chose. |
The console relay passes every value above through unchanged on its own job event stream, where the notices it composes itself take a disjoint set of values (SERVER_JOB_API.md).
Persistence loss
A persistence loss is a local write a completed run was asked to make and could not: the exchange itself succeeded, so it must not be re-run, and what is missing is on this party's own disk. Every one of them is reported on fd 3 as a warning under source: "persistenceLoss" -- a supervisor never has to parse stderr prose for this class -- and every one of them exits EX_CANTCREAT (73) and leaves the terminal event result, except the skipped post-exchange persistence, whose run failed on its result and takes that failure's exit code and terminal error (see Exit codes). The source value stands beside that exit code rather than replacing it: the code is what a bare supervisor reading exit status alone has, the field what one reading fd 3 alone has, and a single function in apps/cli/src/eventStream.ts sets both, so a loss cannot reach one channel and miss the other. The full set:
- An audit artifact the run was asked for and could not produce: the self-attested exchange record, or the signed receipt.
- The post-authentication configuration write of an online
invite/accept, whose run completes and writes its result while the configuration a later recurringalcove exchangeneeds does not reach disk. - The zero-setup
--savebootstrap: the configuration, and the key file that accompanies it when both parties saved, which do not reach disk while the exchange itself completes and writes its result. The warning names the outcome of each file it was asked to write, so the both-saved case where the key file failed and the configuration written before it could not be rolled back names that configuration as still on disk, to be moved or removed before thealcove invitethe same notice advises. A configuration file that appeared at the target path after the run's up-front conflict check is the same loss -- the run refuses to overwrite it, and what is missing is again the recurring-exchange setup rather than anything about the invocation. - The machine-managed consent record an online
acceptrefreshes in place on a configuration it reuses: the partner'sdeduplicatedeclaration. The reused configuration stands as it is; what is lost is this acceptance's record of what the operator consented to, so the next recurring exchange holds the partner to the previous one's. - The registration of the rotated relay key at the relay registrar a webrtc run registers at (PROTOCOL.md). It is a remote write rather than a local one, reported here because it is acted on the same way: the exchange completed and must not be re-run, and the human log names the registrar and the next step. A run whose result failed registers after that failure and reports no loss here; the human log names the outcome.
- The catch-all for a post-exchange persistence step that itself failed before it could name a specific loss: the run reports that a post-exchange persistence step did not complete, with the cause on the human log.
- The post-exchange persistence a run never got to, because the result before it failed: it was not delivered -- a stdout reader that stopped taking it, or a result file that did not reach disk -- or its table could not be built because the partner's payload did not fit the shape the association table requires. Nothing this party would have saved after the result is on disk, while the partner may hold the recurring exchange this side did not save, so the warning names the skipped step. It is the one entry here that stands beside a terminal
errorrather than aresult, the result being what the run failed on, and its exit code is that failure's: 73 for an undelivered result, 76 for a table that could not be built (Error categories).
A persistence-loss warning has no rendered error text: the cause of the failed write goes to the human log, where it is escaped once at its own sink, so a supervisor reading this field is not handed a double-escaped copy of it.
Every other warning leaves the exit code alone, and no other source value accompanies that exit code. A persistence-loss warning is an ordinary warning event under the same schema version: v marks a change to an event's field layout or to the classification rules, and neither the occasions a warning is emitted for nor the process exit code is either of those, so a consumer written against v: 1 reads every warning above.
The terminal counterpart of the same loss is a result file that could not be written: there the run has no result to report, so it fails with the terminal error event and its output category (see Error categories) and exits 73 alongside the losses here.
Log file loss
A --log-file write that fails is not a persistence loss: the log is not an artifact the run owes, so it leaves the exit code alone, and a completed run exits 0. The loss is still reported where an unattended operator keeps it. The first failed write is stated on the operator's stderr as it happens and later ones are counted; before the terminal event, the run states the count on stderr once and emits one warning with source: "logFileLoss" and an extra field:
| Field | Type | Meaning |
|---|---|---|
lostLines | integer | How many diagnostic lines could not be written to the log file up to this event. Present only on a logFileLoss warning. |
{"v":1,"type":"warning","source":"logFileLoss","message":"3 diagnostic lines could not be written to log file run.log, ...","unescapedMessage":"3 diagnostic lines could not be written to log file run.log, ...","lostLines":3}A line lost after the terminal event, while the command finishes, is not on this stream (Terminal-event guarantees); the command states that remainder on stderr when it closes the file, which the exit of a failed run can skip. The field is additive under the same schema version: a consumer written against v: 1 that does not know it reads the warning by its source and message.
Payload columns taken without asking
A payloadReceiveTaken warning holds two extra fields, so a supervisor names the columns without parsing message:
| Field | Type | Meaning |
|---|---|---|
columns | array of strings | The partner's column names the message names, in the order the partner declared them, each redacted and fitted to the per-value budget but NOT escaped: a consumer escapes a name where it shows it. |
columnCount | integer | How many columns the run took. It exceeds the length of columns where the message was cut to the warning budget. |
{"v":1,"type":"warning","source":"payloadReceiveTaken","message":"this unattended run took the payload columns your partner declares it sends you, without asking: \"program\". They were written to alcove.yaml as linkage_terms.payload.receive, and later exchanges refuse a partner that sends a different list.","columns":["program"],"columnCount":1}The message is composed from escaped parts rather than escaped whole: each column name is escaped on its own, with a double quote inside a name shown as \", and the configuration path is escaped like any other event field, so the message takes no second pass at construction. That keeps one partner name from consuming the next through a dangling private-key marker, and keeps a quote from faking the end of a name. The list is cut at the last whole name that keeps the message within the warning budget, and columns holds the names before that cut. The stderr line is cut against its own budget, so where escaping lengthens the path the message may name fewer columns than that line. columns holds partner text unescaped, as a warning's unescapedMessage does, so a consumer composing its own notice from the names escapes them once, where it shows them, rather than escaping an escape; JSON encoding keeps each name inside its string on the one line. This warning has no unescapedMessage: its message is composed from escaped parts, and columns is where its partner text is. Both fields are additive under the same schema version.
metrics
The per-run operational-counter summary. Emitted exactly once, immediately before the terminal result or error event (so the terminal event stays last on the stream), on any run whose terminal event the protocol lifecycle emits. A failure the command's exit boundary reports instead (see Terminal-event guarantees) has no metrics event: its counters belong to the exchange's own transport, which such a failure never opened. It reports this party's dataset size and how often the transport had to retry a data operation or re-establish the connection over the run. Every field is this party's own non-negative integer -- none is partner-derived. On a signal exit no terminal event fires, so no metrics event fires either. The counters are read at the moment this event is emitted, which precedes the transport close that follows the terminal event, so retries or re-establishments during that close are not in them; the operator log states its own connection counts after the close.
| Field | Type | Meaning |
|---|---|---|
recordsProcessed | integer | This party's input record count fed into the exchange. |
transportRetries | integer | Data-operation retries over the run: the count of transport-operation re-issues past the first attempt (the SFTP put/rename retry loops). The re-issue that follows a lost session is not one of them: that loss is counted in reconnects instead, which apps/cli/test/integration/inflightDropRecovery.test.ts holds against the harness server. 0 when none occurred, and always 0 on the filedrop channel, whose per-operation resilience is the poll-read loop rather than an operation re-issue. |
reconnects | integer | Connections this run had to establish beyond its first: connect-time dialing retries past the first attempt, plus every session the exchange lost to the partner (SFTP only). One increment per session lost, not per operation the loss interrupted and not per re-dial that recovered it, so a drop tearing a fan of concurrent operations moves this by one and so does a drop whose recovery re-dial fails. 0 when none occurred. Which triggers are counted here, which are exempt, and which are charged against max_reconnect_attempts are tabulated in SFTP_SESSION_RECOVERY.md, which also names the rows a real server drives and those held by unit cases alone; a healthy connection_per_poll run reports zero only against a partner whose server does not cap session lifetime. |
{"v":1,"type":"metrics","recordsProcessed":1000,"transportRetries":0,"reconnects":1}result
The success terminal event. Emitted exactly once, after the exchange completed and the local output stage (result CSV plus the non-fatal audit record) finished. It is emitted for a run that took a persistence loss too -- the exchange itself succeeded -- so a result beside exit 73 is read with the preceding warning, which names what is missing.
| Field | Type | Meaning |
|---|---|---|
resultWritten | boolean | true when a matched result CSV was produced; false for a helper whose agreed terms give it no output table (it contributed to the match but receives no result file), and false for a count-only (psi-c) exchange, which produces no matched pairing for either party. |
matchedRows | integer | The number of matched rows in the result table this party received. Present exactly when resultWritten is true; 0 is a run in which nothing matched. |
resultPath | string | The absolute path the result table was written to, escaped for display like the stream's other free-text fields (a cap of 4096 output characters). Present when resultWritten is true and the result went to a file in the OUTPUT_FOLDER, absent when it went to stdout. |
intersectionCount | integer | The size of the intersection a count-only exchange reported: the run's whole result. Present exactly when this party's agreed terms entitled it to the count, absent on every other run (including a count-only helper's, whose terms give it no output). It is what separates the two resultWritten: false outcomes -- present means this party received what its terms promised, absent means the terms withheld the result table -- so a consumer keys on the field's presence rather than on a zero value. |
countReportedByPartner | boolean | Whether intersectionCount arrived as the partner's report rather than as a figure this party computed: true on the PSI sender seat of a both-entitled count-only run, whose number travels over the count-report leg, and false on the receiver that computed it. Emitted with intersectionCount and omitted with it, so a consumer reads the pair or neither. A consumer that presents the count states the trust posture from this field -- a sender's number is checked against no run of its own (PROTOCOL.md) -- and reads a missing or non-boolean value as false rather than caveating a locally computed count. |
matching | object | What the two parties' agreed deduplicate values resolved to for this party: localDeduplicate and partnerDeduplicate (booleans, the pair as presented at the terms exchange) and cardinality (one of one-to-one, one-to-many, many-to-one, many-to-many, read from this party's own side). Present on every result. The human log states the same three at info level, which a supervisor reading fd 3 alone -- or a run at a quieter level -- does not see, so this field is the machine channel for them. A consumer that does not recognize the cardinality value treats the object as absent rather than rendering the label. |
entityClusters | object | How the entity closure grouped this party's result, the diagnostic an operator judges its own linkage keys by (PROTOCOL.md): clusterCount, the clusters the table's pairs fall into; localRows and partnerRows, the records of each party standing in one; and shapes, one entry per distinct cluster shape ordered largest first, each holding that shape's localRows, partnerRows, distinctValues (the matched values one such cluster formed on) and clusters (how many clusters share all three figures). Present on a many-to-many run this party holds the table of, absent under every other cardinality -- whose clusters follow from the table's own shape -- and absent where shapes holds more than 256 entries: it is the one variable-length field of this stream, and a distribution that wide would push the terminal event past a consumer's per-line bound, so the field drops rather than being truncated into a list that misstates what it leaves out. Every figure is one of this party's own non-negative integers. The human log states the same summary as a sentence, at the info level a supervisor reading fd 3 alone does not see. |
{"v":1,"type":"result","resultWritten":true,"matchedRows":1204,"resultPath":"/data/matched.csv","matching":{"localDeduplicate":false,"partnerDeduplicate":false,"cardinality":"one-to-one"}}
{"v":1,"type":"result","resultWritten":false,"intersectionCount":42,"countReportedByPartner":false,"matching":{"localDeduplicate":false,"partnerDeduplicate":true,"cardinality":"one-to-many"}}
{"v":1,"type":"result","resultWritten":true,"matchedRows":5,"matching":{"localDeduplicate":true,"partnerDeduplicate":true,"cardinality":"many-to-many"},"entityClusters":{"clusterCount":2,"localRows":3,"partnerRows":3,"shapes":[{"localRows":2,"partnerRows":2,"distinctValues":2,"clusters":1},{"localRows":1,"partnerRows":1,"distinctValues":1,"clusters":1}]}}error
The failure terminal event. Emitted exactly once, for every failure that ends the process through a command handler after the fd-3 check passes; a signal exit and a crash outside every handler emit none (see Terminal-event guarantees). It states the classified category, display-safe error text, and the code the process exits with.
| Field | Type | Meaning |
|---|---|---|
category | string | One of exchange, output, security, config (see Error categories). |
message | string | Display-safe error text, the same rendering stderr receives (see Sanitization): byte for byte the text of stderr's error line after its log prefix (timestamp, level, logger name), which apps/cli/test/integration/backendAgnostic/terminalErrorEvent.test.ts asserts on each failure it drives -- an unpinned SFTP host, a missing shared folder, and a partner that never arrives. |
recoveryHint | optional boolean | Present as true only, absent otherwise (see The self-explaining marker). |
internalFault | optional boolean | Present as true only, on exactly the failures the CLI exits 70; absent otherwise (see The internal-fault code). |
termsChange | optional object | Present only on a run that ended on a partner terms change it did not take on; absent otherwise (see A partner terms change the run did not take on). |
exitCode | optional integer | The code the process exits with after this event, one of the codes in Exit codes. Every error event this version of the CLI emits holds it; it is optional because an emitter older than the field omits it. |
cause | optional object | Present only on a failure whose cause core's failure-cause catalog names, as the cause's kind and its facts; absent otherwise (see The failure cause). |
{"v":1,"type":"error","category":"security","message":"key exchange authentication failed","exitCode":77}
{"v":1,"type":"error","category":"exchange","message":"Your partner did not arrive in the shared folder within 90 seconds.\nCheck that you and your partner use the same folder and that it is syncing, then run again; --peer-timeout sets how long to wait.","recoveryHint":true,"exitCode":69,"cause":{"kind":"partner-never-arrived","channel":"filedrop","waitedMs":90000}}exitCode is additive under v: 1. A consumer that reads it takes the next step the exit code's documented meaning names, the same step it would take from the process status, without waiting for the process to exit. A consumer reads an error event with no exitCode as one from an emitter older than the field and waits for the process status instead; it never reads the absence as success, since the event is a failure whatever it holds. The field does not replace the process status: a supervisor that has both reads the two as the same value.
The self-explaining marker
recoveryHint: true states that message names the cause and the next step, so a consumer showing fixed copy for the category beside it MUST show the message instead and MUST NOT add an advisory of its own. The field is absent where the CLI makes no such claim; a consumer reads its absence as no claim, never as a denial.
A consumer MAY show copy of its own in place of a message it recognizes, or of the message of an event whose cause it reads, where the step that message names is not one its own operator can take -- the next step these messages name is an edit of the configuration file the run was given, or a command-line flag, which a consumer composing that file and command line per run does not put in front of anybody. Copy substituted that way states the same cause and a step its own operator holds; a message the consumer does not recognize is shown as it arrived.
The CLI also sets the marker on a failure holding a cause, whose message it ends with its own remedy for that cause (apps/cli/src/failureRemedy.ts).
The value is read by core's statesItsOwnNextStep, which answers for the one mark markStatesItsOwnNextStep sets, under the two-state convention that an error holds it exactly when it holds its own next step (packages/core/src/errors.ts). The mark is held beside the error object, never as a property on it, so a property of any name on an error makes no claim. The read walks the cause chain, so a wrap of a marked failure keeps the claim its message still states. It is the same read the CLI's own stderr path makes to suppress its generic advisory, so the two sinks never disagree about which failures explain themselves. The CLI also sets the marker on an unmarked internal fault or partner refusal, whose message it ends with a fixed next step of its own (see The internal-fault code and The partner-refusal code), so there too the message holds exactly one next step.
This is what lets a consumer tell the refusals of the terms-time certificate pin apart from an ordinary security failure (a wrong secret, a tampered handshake) without matching on message text: the five pin refusals carry the marker and each names what to do, while the handshake failures, whose messages are non-oracular by design, do not.
The failure cause
cause states, for a failure core's failure-cause catalog names (packages/core/src/failureCause.ts), what happened as data rather than text: a supervisor states the cause in its own words, or in core's sentence for it (failureCauseSentence), and names a remedy its own operator can take in place of the command-line flag the message names. The catalog and the per-application remedies behind it: docs/notes/failure-cause-catalog.md.
The object holds a kind, one of the allowlist below (FAILURE_CAUSE_KINDS), and the facts that kind holds, each optional fact omitted where the CLI did not know it:
kind | Facts | Meaning |
|---|---|---|
partner-never-arrived | channel (optional; one of filedrop, sftp, webrtc), waitedMs (optional non-negative integer) | The partner did not arrive before this party stopped waiting, over the channel named, after the wait named. |
folder-missing | path (string), code (one of ENOENT, ENOTDIR) | The shared folder a file-drop exchange names is not usable: nothing is at path (ENOENT), or something other than a folder is (ENOTDIR). |
relay-registrar-unreachable | host (non-empty string), port (integer 1-65535), failure (one of no-connection, name-not-resolved, no-answer), and the facts of that class: code (one of ECONNREFUSED, EHOSTUNREACH, ENETUNREACH, ETIMEDOUT, UND_ERR_CONNECT_TIMEOUT) for no-connection; code (one of ENOTFOUND, EAI_AGAIN) for name-not-resolved; for no-answer, either code (ECONNRESET) or timedOutMs (non-negative integer), never both | The relay registrar at host and port did not answer: no connection was made, its host name did not resolve, or the connection was reset or the request timed out before it answered. |
The field is read off the tag core's markFailureCause sets, walked over the cause chain as the remedy the message ends with is, and the event's message states the same cause and the CLI's remedy for it, with the self-explaining marker. The CLI builds the field from one row per kind (FAILURE_CAUSE_STREAM_FIELDS, packages/cli-contract/src/failureCauses.ts), whose keys are the kinds the stream states; a tagged cause whose kind has no row is left off the event, and the message then holds no remedy for it. The validator refuses a no-answer cause holding both code and timedOutMs, but drops a fact belonging to another class rather than refusing the cause, as it rebuilds every kind from its own facts. A kind added to the allowlist is additive under v: 1: a consumer drops a cause whose kind it does not recognize, or whose facts are not of the types above, and reads the message as it would on an event with no cause. The console relay does exactly that (SERVER_JOB_API.md).
Two limits of the causes the CLI tags: a read-only file at the folder path is not a folder-missing cause -- its access failure is retried as a transport failure until the connection times out, and exits 69 rather than 64 -- and the remedy for a partner-never-arrived cause on an error object that cannot take a property (a frozen one) names --peer-timeout even on an online invitation, whose wait --accept-timeout sets.
Error categories
The four categories are the web front end's ExchangeErrorCategory vocabulary (apps/web/src/psi/exchangeLifecycle.ts), so a consumer reads one set of names across both. The CLI's error taxonomy -- the core UsageError/OperatorConfigError hierarchy, the InternalConsistencyError and ProtocolRefusalError classes beside it, the ConnectionError kinds, and the command boundaries' 64/69/70/73/76/77 exit split -- maps onto them as follows:
| Category | Meaning | Classification rule |
|---|---|---|
config | A fault composed solely of this party's own configuration -- actionable, safe to report, and reproduced identically by every retry. | The terminal error is an OperatorConfigError (a UsageError subclass), in any phase before output. Scoped to that exact base type, not any UsageError: a sibling UsageError can embed partner-influenced text, so it stays exchange (message not reported as config). Every construction site of OperatorConfigError and its subclasses is enumerated by apps/cli/test/unit/operatorConfigErrorSites.test.ts, which records what each message interpolates and why it is local, and fails on a site or an interpolation it does not account for. That enumeration is syntactic and bounded to what it scans: packages/core/src, apps/cli/src, and apps/web/src, for classes written as class X extends <member> and constructions written as new <Identifier>(...), so a member or a construction reached through a factory, an alias, or a variable is outside it. |
security | A trust-boundary failure: the authenticated key exchange reported a wrong secret, tamper, or replay; the SFTP host-key verification failed against the pinned fingerprint; or the post-handshake AEAD layer reported tampering. An unpinned host is not in this category: the host-key trust step every exchange-running command takes before the protocol lifecycle refuses it on a non-interactive run as a usage error, so that refusal emits category: "exchange" with exitCode: 64 (see Terminal-event guarantees). | The terminal error is a ConnectionError with kind === "security", bare or behind transport-kind wraps (classifyFailure in packages/core/src/failureClass.ts, the same reading the exit code takes), in any phase before output. |
output | The privacy-sensitive exchange already succeeded; only local result generation failed. The operator must not re-run the exchange. | The failure landed in the output phase (after runExchange returned, during result-CSV or audit-record generation), regardless of the error's type. |
exchange | Every other failure -- a retryable transport or usage fault, an internal fault, or a refusal by the partner or the agreed terms (see The partner-refusal code). | The default: any terminal error not matched by a rule above. |
The phase advances as the run progresses: everything up to and including the handshake is prepare, the PSI exchange is run, and the local result/record generation after runExchange returns is output. The rules are checked in the order output-phase, then OperatorConfigError, then a security-kind ConnectionError bare or behind transport-kind wraps, else exchange -- both discriminants (the error's type/kind and the phase) are structural, not a claim about which check happened to fire.
The config rule keys on the type alone, where the web's alert classification additionally requires its prepare phase. What the category has to agree with here is the exit code, which no front-end alert has. Every OperatorConfigError exits EX_USAGE (64), the code that tells an operator to change their own input, while exchange is the retryable bucket. So a member raised mid-run (the receipt bindings a signing run holds at the terms exchange and again at the swap, whose two disagreeing values are both this party's own) would otherwise have the stream inviting the retry the exit code refuses, on a fault every attempt reproduces identically. Widening costs the category nothing it claims: a member's message is composed solely of this party's own content whatever phase raises it, and this stream emits that message under either category.
A partner terms change the run did not take on
When the partner's linkage terms differ from this party's at the terms exchange and alcove exchange does not take them on -- the operator declined at the prompt, the run had no terminal on stdin, or the change reaches terms the run's input was prepared under -- the run ends with category: "config" and exits EX_USAGE (64), before any linkage key or data moves (CLI.md). The terminal error is an OperatorConfigError whose message holds fixed text and this party's own paths: the configuration, its key file, and the proposal file the run wrote beside the configuration, inside the alcove apply command that applies it. It states its own next step, so the event carries the self-explaining marker and the CLI prints no generic advice to retry beneath it. The change itself -- the partner's column names and the other terms that differ -- is shown on the operator log before the event, escaped, and never in the event's message. The partner's run ends with a partner abort at the terms exchange, category: "exchange" and exit 76.
An operator who declines the payload columns a partner declares on a run that lists none it receives (CLI.md) ends the run the same way, with no proposal written; termsChange.received.added names those columns.
The event's termsChange object states the same change for a supervisor that reads fd 3 alone. It is present on this refusal and on a terms-exchange refusal core raised before the run could offer the change at all (the partner's terms would not make a valid document, or would still differ once taken on), which is category: "exchange" and exit 76:
| Field | Type | Meaning |
|---|---|---|
proposalWritten | boolean | true where the run wrote the partner's terms beside the configuration for alcove apply, false where it wrote nothing. |
received | optional { "added": [string], "removed": [string] } | The columns the partner now sends, and no longer sends, against the columns this party receives; absent where the two agree. |
sent | optional { "added": [string], "removed": [string] } | The columns this party sends against the columns the partner receives, the partner's to decide: added names the columns this party sends that the partner does not receive, which it would no longer send under the partner's terms, and removed the columns the partner receives that this party does not send, which it would now send; absent where the two agree. Absent too where the partner's terms state no receive list: adopting them keeps this party's payload.send. |
partnerDeduplicate | optional { "expected": boolean, "presented": boolean } | The partner's deduplicate against the value this party holds it to; absent where they agree. |
otherTerms | array of string | The diagnostic for each other term that differs. |
These are the parts of core's TermsDelta (PROTOCOL.md). Each column name and diagnostic is partner-chosen and escaped at construction as the operator log's display of the same change escapes it: a column name under the per-value cap, a diagnostic under the warning cap. The object names no path.
The output category covers the whole stage, so it is broader than the exit code that usually accompanies it. The run exits EX_CANTCREAT (73) when the loss is the result file failing to reach disk -- the local write the code names, and the terminal counterpart of a persistence loss. A result streamed to stdout takes the same code on the same ground: the run gives the reader as long as it keeps taking the result and gives up 60 s after the last of it that left the process, so a reader that stopped -- or that closed the pipe, which fails the write outright -- leaves the result undelivered, and that run fails with this category rather than reporting a result a supervisor would read as delivery. The bound is on the stall and not on the transfer: a reader consuming a multi-megabyte result in small reads holds the run open for as long as it needs. The exchange record and the receipt are written before that failure is raised, the disclosure having occurred whatever became of the result. It exits EX_PROTOCOL (76) for the other faults of that stage, which are not local write failures: a partner payload that does not fit the shape the association table requires (duplicate or missing partner row indices) is refused while building the table, and 73 would tell an operator to go looking on their own disk for something that is not there.
The security marker
The error event's category: "security" marks every trust-boundary failure. The exit code sorts them by what a supervisor does next, the same rule as for every other failure (Exit codes): an authentication failure is never retried, a partner receipt or certificate that does not verify goes to the partner, and a frame that fails its integrity check is retried within the cap.
- Authentication failures exit 77 (
EX_NOPERM). Core raises them as anAuthenticationError, asecurity-kindConnectionErrorthe CLI's error-to-exit boundary maps to 77. This covers the handshake cases (a failed key-exchange authentication: wrong secret, tampered or malformed handshake frames) and the host-identity cases (an SFTP host-key mismatch against the pinned fingerprint, and a WebRTC coordination-server certificate that does not verify on this host) alike: a retry reaches the same refusal, so a wrong secret must not be retried as a transport blip, and a host presenting an unexpected key must not be silently reconnected to. - A partner receipt or certificate that does not verify exits 76 (
EX_PROTOCOL), the partner-refusal code (see The partner-refusal code). Core raises it as aReceiptVerificationError, asecurity-kindConnectionErrorthe CLI's error-to-exit boundary maps by its class. - The other
securityfailures exit 69 (EX_UNAVAILABLE), the code a plain transport drop yields: a frame on the authenticated channel that fails its integrity or ordering check. The storage or network the frame crossed can corrupt it, and a retry clears that, so 69's action -- retry up to the cap, then alert -- is the right one; a failure that repeats on every attempt reaches the cap like any other 69. The category is what tells it apart from a transport drop, for a supervisor that also alerts on the first one.
A rotated secret this party could not save also exits 77, under category: "exchange": the failure is a local write rather than a trust-boundary check, but it leaves the next key exchange to fail the same way.
The partner-refusal code
A refusal by the partner or the agreed terms exits EX_PROTOCOL (76): the terms are incompatible, the partner runs an incompatible protocol version, the partner aborted, the partner sent a frame or payload that is malformed or outside what was consented, or its certificate or receipt did not verify. Core raises these as a ProtocolRefusalError, a PeerAbortError, a ConnectionError of kind protocol, or a ReceiptVerificationError, and the CLI's error-to-exit boundary maps each to 76, bare or behind the message bridge's transport-kind wraps. A retry meets the same partner and the same terms, so it reaches the same refusal; after a partner abort, whose cause may be a fault on the partner's own side, this side's message does not state why the partner aborted -- the abort marker holds no cause, and the partner's own error states it -- so a retry alone does not help until the partner runs again.
The category follows the rules above and does not single this code out: a ReceiptVerificationError is security, a partner payload refused while building the result table is output, and every other partner refusal is exchange. A supervisor separates it by the exit code, or by the event's exitCode field.
The human line agrees with the code, in the same way as for an internal fault. Every partner refusal that reaches an exit-76 boundary presents the operator one next step, contact the partner and do not retry unchanged. A refusal whose message states only what was refused gets a fixed line beneath the error from the CLI's command boundary, Contact your partner before running again: the partner or the agreed terms refused this exchange, and retrying unchanged will fail the same way. (PARTNER_REFUSED_NEXT_STEP, apps/cli/src/util/exit.ts), and the terminal event's message ends with the same line and carries the self-explaining marker. A refusal whose message already states its step -- a PeerAbortError, which tells the operator to contact the partner, or an invitation-term divergence -- is marked as stating its own next step instead, and the CLI adds nothing. Either way the CLI's "retry the exchange without re-inviting" advisory, which follows a failure after this side stored the next secret, is suppressed: it would prescribe the retry the exit code exists to stop. A refusal in the key exchange or at the terms exchange comes before either side stores (PROTOCOL.md), so it rotates nobody and the CLI prints no rotation line beneath it. A responder that sent terms message 2 and then meets a refusal other than the initiator's abort, before its own store, is the exception: the initiator may already hold the next secret, so the CLI prints a rotation-state line on stderr beneath the fixed step, prescribing no retry of its own, that the next run may need a new invitation from the partner.
A refusal over a difference in the linkage terms gets a step of its own in place of that fixed line, for the party that refused or for the party whose run its abort ended, and for the kind of run: alcove update and alcove apply on a run from a configuration and key file, a fresh invitation on an online alcove invite or alcove accept, which writes neither before the terms are agreed on, and the input columns and --linkage-strategy on a quick exchange (termsRefusalNextStep, apps/cli/src/termsRefusalRemedy.ts; PROTOCOL.md). The terminal event's message ends with it and carries the self-explaining marker, as for the fixed line.
The internal-fault code
The mirror of the security marker: here the exit code states the distinction and the category cannot. An internal fault is a failed check on one party's own state -- two derivations of the same quantity disagreeing, an exhaustiveness branch reached, a precondition the party's own callers guarantee broken: a defect in Alcove itself, not in anything the operator, the partner, or the transport supplied. Core and the CLI raise it as an InternalConsistencyError, and the CLI's error-to-exit boundary maps that class to EX_SOFTWARE (70), bare or behind the message bridge's transport-kind wraps. scripts/check-internal-fault-throws.mjs refuses a plain Error thrown in an exhaustiveness branch or with "internal error" in its text.
The reasoning below is stated for the single-pass sender's send-time reply-cap safety check, where the reply this party built exceeds the byte cap both parties derive from their declared sizes, on an exchange whose declared sizes the single-pass ceiling gate has already cleared.
The terminal error event states category: "exchange". The four categories are the web's vocabulary and have no internal-fault member, and the classification rules key on the phase and on OperatorConfigError / ConnectionError membership, neither of which this class joins -- so it falls to the default bucket, alongside the retryable transport faults it is not one of. A supervisor separates it by the exit code, or, reading fd 3 before the process exits, by the event's internalFault or exitCode field.
internalFault: true is set from the same classification the exit boundary applies (exitCodeForError, apps/cli/src/util/exit.ts), so it is present exactly when the process exits 70 and absent on every other code, 69 and 76 included. A consumer that offers a retry for the exchange category MUST NOT offer it on an event holding the field. Its absence makes no claim that a retry will help; the exit code remains the full account.
Why 70 rather than a code already in the vocabulary:
- Not 64 (
EX_USAGE). 64 tells the operator that their own input or configuration is what to change. This condition is reached only after the ceiling gate has found both parties' declared widths and record counts within budget, so there is no dataset either operator controls that moves it, and the error's own message says so. - Not 69 (
EX_UNAVAILABLE). 69 is the transport-availability code a supervisor is expected to retry once the transport recovers. This fault is deterministic in the run's own inputs, so every retry rebuilds the same reply and reaches the same refusal -- while conducting another full exchange, re-sending this party's records, which is exactly the loop the retry cap exists to bound (see Exit codes). - 70 (
EX_SOFTWARE) is thesysexitscode for an internal software error, and matches the remedy the message states: report it, with the two byte counts it names.
The human line agrees with the code. Every internal fault that reaches an exit-70 boundary presents the operator one next step, report it and do not retry, from one of two places; a fault raised inside zero-setup's save hook is reported as the persistence loss it causes (exit 73, whose copy already says not to re-run) and takes neither. Most raise sites state only the condition that failed; for those the CLI's command boundary adds a fixed line beneath the error, This is a fault in Alcove itself: report it with this message; retrying will not help. (INTERNAL_FAULT_NEXT_STEP, apps/cli/src/util/exit.ts), and the terminal event's message ends with the same line and carries the self-explaining marker. A raise site whose message already states that step -- the reply-cap check above ends "report it with this message" -- marks its instance with markStatesItsOwnNextStep instead, and the CLI adds nothing. Either way the CLI's generic post-handshake "retry the exchange without re-inviting" advisory is suppressed. The suppression is critical rather than cosmetic: the reply-cap check fires mid-data-exchange, after the terms exchange stored the next secret, which is exactly the window that advisory prints in, and it would prescribe the retry the exit code exists to stop.
Terminal-event guarantees
Exactly one terminal event -- a result when the exchange and the local output stage completed, or one classified error on an organic failure -- is emitted per run. It is the last event on the stream. The stages, stage, stageEnd, warning, and metrics events that precede it are progress and summary, not outcome. The one metrics event is emitted immediately before the terminal event, so a supervisor reads the run's operational counters on the line just above the outcome.
The terminal event means the run's outcome is decided and nothing it owed to disk is outstanding: every local artifact is written before it. The transport's close is what follows it. That close is bounded by the ceiling for its channel and can outlive a consumer that stopped reading, which is why the outcome goes out first, and why no event is written after the terminal event on any path: a write attempted after it -- by the close, or by anything raised beside the close -- is refused by the writer and noted on the operator log, and what the refused notice had to say reaches the operator through the log call at its own site. A supervisor that reads the terminal event and then kills the process group takes only that close with it, which the notice of an overrun already describes as abandoned. On filedrop and sftp in delete mode that close is also what removes this party's own protocol files from the exchange directory (FILE_SYNC.md, responsibleFiles), so a kill leaves them for --sweep-exchange-files on the next run to clear, as an overrun's own notice asks.
A result says the exchange completed, which is not the same as a zero exit. A run that took a persistence loss emits a warning for each one, then its result, and exits 73 (see Exit codes). Either channel alone identifies it: the exit code separates it from both a clean run (0) and a transport failure (69), and the warning names what is missing where the code cannot. A supervisor keying success off the terminal event reads the exit code with it, because the terminal event alone cannot separate such a run from a fully persisted success.
The classified terminal error category is the machine-readable abort reason: it names a security, output, config, or exchange failure independently of the free-text message (the same text stderr logs) and of the exit code. A supervisor keys the abort decision off that category, not off the human log line.
The guarantee applies from the moment the fd-3 preflight passes: every failure that ends the process after it through a command handler -- other than a signal exit or a crash outside every handler, both below -- emits exactly one classified error event, whose exitCode is the code the process then exits with. A failure inside the protocol lifecycle -- including the local prepare checks (an expired or malformed shared secret, a bad key-file path) -- emits it there, before the transport close, classified against the phase the run reached; on alcove exchange, which runs those local prepare checks ahead of its wake call and host-key step, a failure of one of them emits the same event from there. The exit boundary emits nothing for a failure the lifecycle already reported, so no run has two.
Every other failure emits it at the command's exit boundary, in the prepare phase and with no metrics event before it:
- The refusals ahead of the lifecycle: the retain-sweep flag guard, the read of the coordination server a web app address publishes (an online
invite), the configuration file failing to read, parse or validate, the key file failing to load, the@pathcredential reads, the decode of an--invitation, the online bootstrap's mutual-exclusion guard, signing-identity resolution, and an unreadable input CSV. A configuration file that does not exist, cannot be read, is not YAML, or fails the exchange schema is aconfigfailure (Error categories); the rest keep the category their error classifies to. - The write of the key file an
--invitationprovisions, the wake call aconnection.server.provisionblock states, the unpinned-host refusal, the SSH host-key trust probe, and the relay key registration an earlier run left unconfirmed. - An error no command handler caught, which exits 1.
A failure before the preflight passes emits no terminal event and exits with the code its own error maps to, never 130 or 143. Those failures are the argument parse -- a bad, unrecognized, or repeated flag or positional, and a --log-file that cannot be opened -- and the fd-3 preflight itself; on an accept, also every check that command makes up to its consent prompt, the prompt included (see File descriptor). A supervisor distinguishes that from an interrupt by the exit code.
A run interrupted by SIGINT or SIGTERM exits through the signal handler's process.exit (exit 130 for SIGINT, 143 for SIGTERM), which bypasses the emission site, so a signal exit emits no terminal event of its own. A supervisor reads the absence of a terminal event together with exit 130 or 143 as the interrupt signal. This applies to both interrupt sub-cases (a clean interrupt and an interrupt that coincides with an in-flight error), so the "no terminal event plus 130/143" reading is unambiguous rather than fired inconsistently. A broken pipe (the supervisor closed its read end) likewise leaves the stream without a terminal event; the exit code remains authoritative there too.
Two exits bypass every emission site and emit no terminal event: a crash outside the command handlers' promise chain (an uncaught exception or unhandled rejection, which Node ends on its own default), and the PSI heap-restart child dying under a signal or out of memory, which the parent ends by re-raising the same signal, so a shell reports 128 plus the signal number. A supervisor reads a non-zero exit with no terminal event as a crash or an interrupt, by the exit code, never as success.
One window puts a terminal event beside an interrupt status: a signal that arrives once the terminal event has been emitted and the transport is still closing. The outcome stands -- everything the run owed was finished before it was written -- and the exit status is the signal's, since the process did not end on its own. A supervisor reading both channels takes the terminal event as what the run did and 130 or 143 as how the process ended; one reading the exit code alone learns only that the run was interrupted, which is the safe reading, the interrupt having cost it nothing but the close.
Sanitization
No unsanitized partner- or server-controlled string reaches an event, save two fields a consumer escapes itself: a warning's unescapedMessage and the column names of a payloadReceiveTaken warning, both below. Every other free-text field is escaped at construction, using the same display-boundary sanitizers stderr uses, so a hostile value cannot inject a control sequence, a bidi override, a spoofed NDJSON line break, or a confusable character into a supervisor's parser or terminal:
- Stage
labelandid(on both thestageandstageEndevents) and thewarningmessagederive from linkage-key names and terms text the partner may have authored, so they are passed throughredactAndSanitizeForDisplay-- the private-key strip and thensanitizeForDisplay(packages/core/src/utils/sanitizeForDisplay.ts), exactly asprotocol.tstreats the same strings before they reach stderr, so neither route is the weaker one. Every code point outside printable ASCII is rewritten to a visible\xHH/\uHHHH/\u{HHHHH}escape, so a raw ESC (\x1b, the ANSI-sequence driver), aU+202Eright-to-left override, a newline, a zero-width character, and a homoglyph are all neutralized. Every free-text field of this stream is stripped of PEM/OpenSSH private-key blocks alike, because a persisted event stream is the same class of sink as--log-file. Thewarningmessageescapes under a display cap sized for a whole composed warning rather than for a single value (4096 output characters). That is because the cross-party host-key divergence notice is the warning a supervisor that discards stderr has nothing else to read, and the per-value cap cuts it before its recovery instruction. The values interpolated into a warning have that per-value cap where they were composed.protocol.tsreads the same cap where it puts a composed terms-exchange warning on stderr, so the two sinks show one text at one budget: the operator watching the terminal is never shown less of that warning than the supervisor reading this stream. That is a property of the terms-exchange route, not of this stream'swarningevents at large -- a warning emitted only to this stream has no stderr twin to compare. - The
payloadReceiveTakenwarning is the one whosemessageis not escaped whole: it is composed from parts already escaped, each partner column name on its own. Itscolumnsfield is one of the two exceptions to this section's rule: each entry is redacted and fitted to the per-value budget but left unescaped, for the consumer to escape once where it shows it (Payload columns taken without asking). - The other exception is a warning's
unescapedMessage: the textmessageescapes, redacted and fitted so one escape of it renders within the same 4096-character warning budget, with the truncation marker paid for out of that budget, but left unescaped. A composition that names a partner-chosen value keeps that value raw in it, redacted and fitted to the per-value budget where it is interpolated -- the host-key divergence notice's two key types and two fingerprints included -- so a consumer escaping this field makes the only pass that value takes. The one composition that still escapes a value where it is composed is the date-mismatch terms warning, over a value the terms schema holds to a shape the escape does not rewrite (DISPLAY_ESCAPE.md). - Every line is written as printable ASCII: the writer encodes each code unit outside printable ASCII in a JSON string as its
\uXXXXescape (asciiSafeJsonTextinapps/cli/src/util/jsonLine.ts), so the two unescaped fields put no raw control character, DEL, C1 character, line separator or bidi override on the descriptor, and parse back to exactly the text they hold. - The
errormessageis rendered bysanitizeErrorForDisplay, which walks the error'scausechain, escapes each link throughsanitizeForDisplay, and strips PEM/OpenSSH private-key blocks -- the same rendering stderr and--log-filereceive. A first-party composition whose structure is the line break -- the acceptor's configuration-reconciliation refusal, a conflict list under its recovery step -- marks its error throughkeepFirstPartyLineBreaks, and the field then holds those breaks as line breaks. The mark states which lines the composition wrote: each is escaped on its own and joined afterwards, so a control character INSIDE a line, whoever chose it, is neutralized where the value was composed or escaped here, and opens no line of its own. - The numeric fields contain no free text: the
stageEnddurationMsand themetricscounters (recordsProcessed,transportRetries,reconnects) are this party's own integers, floored to a non-negative whole number at construction so a malformed value cannot produce an out-of-contract field. No partner-controlled string rides any metric event. TheresultintersectionCountis the one numeric field a partner can influence -- a count-only sender is told the tally by the receiver (see PROTOCOL.md, PSI-C) -- and it takes the same floor at construction, on top of the protocol bound core applies before it gets here: the reported figure may not exceed the smaller of the two exchanged record counts. - The
resultentityClustersobject holds no free text either: every member of it and of itsshapesentries is a count this party derived from its own table and its own rounds' blocks, floored to a non-negative whole number at construction like the metric counters. It names no record, no row index and no linkage-key value, so the summary states nothing beyond what this party's own run already held, with no partner-derived figure and nothing sent on the wire (PROTOCOL.md). - The
resultresultPathis the result file in this party's ownOUTPUT_FOLDER, made absolute, never partner text, and is passed throughredactAndSanitizeForDisplaylike the stage fields, under a cap of 4096 output characters so a deep path is not cut at the per-value default.matchedRowsis the row count of this party's own result table, floored at construction like the metric counters. - The
errorcauseobject holds two free-text facts, afolder-missingcause'spathand arelay-registrar-unreachablecause'shost: this party's own configured folder path and registrar host, each passed throughredactAndSanitizeForDisplaylikeresultPath, under the same cap of 4096 output characters. Itskind,channel,failureandcodeare first-party literals from closed sets this build declares,portis the registrar port this party configured, andwaitedMsandtimedOutMsare this party's own durations, floored to a non-negative whole number at construction like the metric counters. - The
resultmatchingobject holds no free text: bothdeduplicatevalues are booleans the terms schema parsed before the run began, andcardinalityis one of the four closed labels this party derives from the pair, so nothing a partner authored rides the field. - The enum-like fields (
type,source,category, and the stageidvalues the CLI itself defines) are this party's own closed vocabulary, not partner-derived, so a consumer can trust them as discriminants.type,sourceandcategoryare emitted as first-party literals and take no escape: each is chosen at its emission site from a closed set this build declares, never composed from a value a partner sent. The stageidvalues are routed through the escape uniformly, whether the CLI defined them or they hold partner-authored text, since one field holds both and they are echoed on the wire in the same string form.
Because sanitization runs before serialization, a partner-supplied newline is already an escaped \x0a by the time the object is serialized, so it cannot forge a second line. The \n that frames NDJSON lines is the writer's own line terminator: a field value holds no raw newline at all, except the error message, where the renderer's cause-chain framing and a first-party composition's own line breaks are raw, and the two unescaped fields above, which hold whatever line breaks their text does -- and the line's JSON encoding writes each as an escape inside its JSON string, so none ends a line either. A supervisor reads them back as line breaks after parsing the line, which is where they are meant for a human.
A supervisor that escapes what it shows reads unescapedMessage and makes the one pass the text takes; that is what the console relay does (SERVER_JOB_API.md). A supervisor that re-escapes the warning message instead -- defense in depth at its own trust boundary, which this stream is designed to tolerate -- composes under that same shared budget: the field is a whole warning, not a value, so re-capping it at the per-value default would cut the recovery instruction the stream exists to deliver. Re-escaping is otherwise free of cost beyond fidelity: every pass doubles a literal backslash already doubled by the last, so a partner filename's single backslash widens with each boundary it crosses, and a message composed near the budget at one boundary is what the next one cuts.
The error message is not one value either, and a supervisor that re-escapes it splits it first. It is a whole rendered chain: at most MAX_ERROR_CAUSE_DEPTH links joined by the renderer's own \ncaused by: framing, each already escaped under the composed-message cap of 1024 output characters. The cap bounds what the escape emits and the markers are appended on top of it. So a link the cap cut renders to as many as 1038 characters (the cap plus the 14-character truncation marker), and the last link of a chain the depth bound cut to as many as 1065 (that plus a space and the 26-character elision marker). An 8-link chain at its ceiling is 8331 characters of link text, 8415 with the seven framing separators. Escaping each link at that cap and holding the link count to that same depth bound admits exactly the volume the renderer emits. Charging the whole chain to one cap instead cuts it wherever that cap falls -- on a refusal that partitions by chooser, inside the first link or two, which is where a partner-chosen fragment sits and ahead of where this party's recovery step does. The split is exact: every link is escaped before the join, so no byte of a message can stand as the newline a boundary opens with, and a link whose own text reads caused by: cannot forge one. A link that kept its own line breaks holds raw newlines the re-escape then writes as \x0a, since the mark saying whose they are lives on the error object and a boundary reading rendered text has only the text. An elision marker that arrives on the last link is lifted off before that link is re-escaped and appended again afterwards, since the renderer appends it past the cap and re-escaping the link whole would spend the budget on the marker itself: what that preserves is the marker's absence, the half an operator can rely on (DISPLAY_REDACTION.md). sanitizeErrorChainLinks in packages/core/src/utils/sanitizeErrorForDisplay.ts is that pass; the console's own relay is a consumer of it (SERVER_JOB_API.md).
Disclosure digest
This section is about the human log, not the event stream. It fixes the line a --log-file keeps after the display an exchange shows before it runs (CLI.md), which a later unattended run reads back to decide whether that display changed.
- The line. The log copy of the display ends with
Disclosure digest: sha256:<hex>, logged atwarn, so the file holds it behind the log prefix as[<ISO timestamp>] [WARN] [<logger>] Disclosure digest: sha256:<hex>.<hex>is 64 lowercase hex digits: the SHA-256, over UTF-8, of the display's lines as rendered followed by two lines the display does not show, joined with\n:party identity: <identity from the linkage terms>andconnection destination: <destination>. The destination holds no credential: for sftp the scheme, host, port and path (the split-directory paths follow, space separated), for a filedrop the path or paths, for webrtc the signaling endpoint (wsorwss, host, path, and the port only when it is not the scheme's default). So an unattended run whose party identity or destination changed is not silenced, though the display reads the same. - When it is written. Only when the log records the display: a
--log-fileat a--log-levelofwarnor lower. - A log file that is standard error. A
--log-filethat is the same open file as standard error (the same device and inode, as/dev/stderris) gets no second copy of the consent lines, since they already print there; the digest line is still written. Limit: a--log-filethat names the terminal through another device node, such as/dev/ttywhile standard error is the terminal's/dev/ptsnode, is not recognized as standard error, so each consent line, and the host-key fingerprint line, prints twice on that terminal. - How it is read back. The run reads the last 8 MiB of the log file, from the first whole line inside that window, and takes the digest on the last whole line of the form above. A digest further back counts as absent, which prints the display. A path that is not a regular file is not read.
- What it decides. An unattended run (no terminal on stdin, or an
INPUT_FILEof-) whose display digest equals the one read back writes the display to the log file alone; every other run prints it as well. - Why a display value cannot pose as the line. Every name the display shows is escaped by
sanitizeForDisplaybefore it is logged, so no value puts a raw line break into the file, and the reader's timestamp and logger-name fields stop at], so a value cannot supply a prefix of its own mid-line. The reader's pattern is derived from the formatter that writes the file (logFileLinePatterninapps/cli/src/util/logging.ts).