Exchange record format
This document specifies the self-attested exchange record: the local audit artifact each party produces once an exchange has disclosed. It covers the record, verification-keys, and agreed-terms file shapes, the format version and reader-rejects-unknown rule, and the point at which a record becomes owed and what it states about the run that produced it. It also covers the commitment scheme and the agreed-terms hash, the readable governance metadata, the result-size and per-direction disclosure framing, the count-only (psi-c) rows, the value that pairs a signed receipt to one run of an exchange, and the record's privacy properties. It does not cover the PSI protocol that produces the exchange (see PROTOCOL.md), the exchange-agreement format that supplies the record's governance fields (see EXCHANGE_REFERENCE.md), or the canonical byte encoding the commitments and the agreed-terms hash are computed over (see CANONICAL_ENCODING.md). The separate certificate-backed signed receipt reuses this record's commitment scheme and on-disk format, so the commitment-scheme and file-shape sections below are written to stand on their own. Intended readers are security auditors, external implementors, compliance reviewers, and developers.
Each party produces a self-attested record of what it disclosed, on every exchange path and on every run that disclosed, whether or not that run finished (see When a record is owed). That runExchange is the one place the record is built was confirmed in the code; that every deployed surface reaches it is unverified as of 2026-09-29. The record is designed to stand on its own as a disclosure-log entry: an operator can use it to populate a HIPAA accounting of disclosures or a FERPA disclosure record without separately retaining and re-matching the original linkage-terms config. It is not evidence against the partner, and -- being built from data both sides already hold -- it needs no private key and adds no protocol round-trip. It is unsigned: a session-derived MAC is not non-repudiation, so an unsigned record is an accurate, local audit artifact rather than one that overstates what it proves. Non-repudiation is the separate signed receipt's job -- an artifact both parties sign, its own file, produced alongside the record on the CLI's authenticated exchanges when a signing identity is configured.
When a record is owed
A record is owed from the moment this party's payload crosses -- the moment the transport takes the first part of its payload, which is sent in parts (PROTOCOL.md, A list of matched records is sent in parts). That is the point from which the disclosure the record attests has occurred: the linkage keys have been exchanged and matched, this party has handed its payload columns to the transport for the partner, and nothing after it can call them back. The record is therefore produced on every path out of the exchange from that point on -- the run returning normally, and the run terminating anywhere after the send: in the rest of the payload exchange (the partner's reply never arriving, or arriving as a frame the wire schema refuses), in the check that refuses any column sent to a party receiving no payload (assertNoPayloadReceived), in the check that refuses a received column set differing from the partner's agreed payload.send (assertPayloadMatchesAgreedSend), or in the signed-receipt step after it.
What a record attests is this party's own act of disclosure -- the payload frame handed to the transport -- and not the partner's confirmed receipt of it. That holds on both legs: a send resolves on the local hand-off rather than on delivery, and a frame still buffered when the channel closes on an error may never leave this machine (see COMMUNICATION.md). It is the conservative direction for an accounting: a record may over-report a disclosure whose frame never arrived, and it never omits one that did.
A failure before that point produces no record, and owes none: nothing earlier in the exchange discloses what a record commits to, and what such a run leaves the operator is its own error and its entry on the machine-readable event stream. The record's own build is the one exception in the other direction -- it is a secondary artifact, so a build that fails leaves the run's result untouched and reports that no record could be produced.
A payload send the transport cannot settle is owed a record as a resolved one is. The file-sync transport behind the SFTP and synced-folder channels reports a write it can neither confirm nor retract (TransportWriteIndeterminateError, raised when the rename into the partner's directory fails indeterminately, or, for a party writing without renaming, when the write of a message fails), and that payload file may already be in the partner's directory. The send rejects and the run fails -- this is not a resolution -- but the region opens on it, so the run produces its record and hands it back on the failure. What that record attests is the hand-off, exactly as a resolved send's does: this party gave its payload to the transport and the transport could neither confirm nor retract what became of it. It is never a proven disclosure, and a reader must not take it for one. A send rejected on a later part is owed a record whatever the rejection, since the parts before it were taken. Every other rejection of the first part's send produces no record and owes none, the transport having settled that the write did not happen. On the leg that sends first, such a record commits to an empty received payload under the rule below; on the responder leg the partner's payload has already arrived, and the record commits it.
A run cut between its own send and the partner's payload received nothing, and its record commits to an empty received payload for that reason -- the same present-and-empty commitment a count-only run writes, since what this party received is nothing either way. The record does not distinguish the two: a reader of the artifact alone cannot tell a run cut before the reply from a partner that transmitted no payload, and neither reading overstates what this party disclosed. That distinction is a live-run one, not a fact the frozen document holds: a caller of this implementation reads it off the terminated run's error (exchangeDisclosedWithoutPartnerPayload). No CLI event and no browser view reports it.
The asymmetry runs the other way on the responder leg. Its payload send is the last frame of the payload exchange (packages/core/src/payloadExchange.ts) -- and of the whole exchange on an unsigned run, since only a signing run's receipt swap follows it -- so the region a responder's record is owed from opens at the end of its payload exchange rather than before the partner's reply.
Every record states which of the two it is, in a mandatory outcome:
| Value | What it states |
|---|---|
completed | The run finished. A run that signed exchanged its receipt; a run with no signing identity had none to exchange, and says so by holding no receiptBinder. |
receipt-swap-terminated | This party handed its payload to the transport and the run then terminated without this party holding a receipt for it. The value covers every termination after that hand-off, the swap's own among them -- including a hand-off the transport could neither confirm nor retract, which is not a proven disclosure (the indeterminate-write rule above). Its name is narrower than its meaning, and a reader who takes the literal at face value infers a step the run may not have reached. |
Five properties fix how the row is read.
It is stated, never inferred from silence. A completed run's record asserts that it completed rather than leaving a reader to conclude it from an absent marker, on the same rule the count-only payload commitments follow. A critical fact is recorded explicitly so a reader never has to guess whether a field is absent because nothing happened or because the writer left it out. It is also why the format version moves with the field -- an earlier record could only have been written for a run that finished, so reading its silence either way states something its writer never did.
A terminated record attests the disclosure in full. It is not a partial or provisional artifact: it holds the same commitments, the same governance metadata, the same exposed-record count, and the same result size under the same entitlement gate as a completed run's. The commitments to this party's own payload and to the association table are fixed by the time this party's payload crosses; the commitment to the received payload is fixed by what had arrived when the run terminated -- empty where nothing had, the partner's frame where it crossed before the cut, and empty on a count-only run whatever a non-conforming partner sent, since those terms receive no payload -- so the commitments are the run's real ones however early after the send the run ended. What it does not hold is a receipt, because none was exchanged.
What it holds and what can later be opened are separate questions, and the second has a limit. A terminated run returns no result, so no result file is written -- and all three of the record's commitments re-supply from one (No data snapshot in the keys): the association table from the result's pairing columns, and each payload from the result's rows on its own side of that pairing. So a terminated record's commitments cannot be reproduced and opened, and the keys file beside it has nothing to open. They still bind what this party disclosed, and the record stays readable, citeable, and pairable to a receipt the partner holds; what does not follow is that a holder can demonstrate that binding by opening one. What a terminated record is for is the accounting entry, not the opening.
A terminated record keeps the binder its run derived. The binder is derived from the session key before the swap runs, so it is a true value for the run whatever the swap then does, and by the swap's wire ordering the partner may hold a completed receipt bearing it. A record that dropped the binder would leave that genuine receipt reading as unpaired against it. The binder's presence rule is therefore "the run derived one", not "this party holds a receipt": a run that terminated before it derived one has no receiptBinder, exactly as an unsigned run's record does, and what says this party holds no receipt is the outcome beside it.
It is this party's own statement, like the rest of the record. The two parties of one run can legitimately record different outcomes -- the swap's wire ordering lets one side complete while the other terminates -- and nothing compares them. A reader must not take one party's outcome for the other's.
It states that the run terminated after disclosing, not why. One value covers every way the post-disclosure region can end. Those ways are the partner's reply never arriving or arriving malformed, a received payload refused because this party receives none or because its columns differ from the agreed ones, a signature refusal in the swap, and an ordinary transport drop. A compliance reader cannot tell them apart from the outcome alone, and the field does not claim to: the cause is in the operator's own run log and machine-readable event stream, which are where an incident is reconstructed. What the field is for is the accounting-of-disclosures question -- did a disclosure occur, and did this party come away with a receipt for it -- which it answers on its own. The one way the record does distinguish is the certificate mismatch below, which is not a reading of the outcome but a field beside it.
An observed certificate mismatch is stated on its own, in a mandatory certificateMismatchObserved. On every termination above, the partner is merely unverified, which the record already documents by holding partnerIdentity as the self-asserted string it is. One arm is different: the run held the certificate the partner presented and found it was not the pinned identity, so who received the disclosure is positively in doubt rather than simply unconfirmed. A reader of the record has to be able to tell that arm from the rest, and no reading of the outcome gives it.
The field is a mechanical two-way predicate over what the run observed about that certificate, not a cause or a step. It is true on exactly three findings, each of them the run holding the presented certificate body and refusing it:
- its fingerprint is not the pinned one;
- it does not authorize the identity under which its holder agreed on the terms;
- its self-signature does not verify.
It is false everywhere else, which is four kinds of run besides the one that compared a certificate against a pin and found it matched:
- A run that had no fingerprint pinned at all failed on this party's own configuration and observed nothing about the partner's certificate.
- A run whose receipt signature did not verify had already matched the certificate against the pin -- what failed there is the signature over the content, not the identity behind it.
- A run where no check applied: a browser-conducted run and an unsigned CLI run pin nothing and present nothing, a first-contact run has no prior pin and adopts the fingerprint the partner presented, and a run that terminated before the receipt swap never reached the check at all. None of them observed a certificate to refuse.
- A run where the partner presented no certificate, or one this format cannot read. The run observed an absence or a malformed value, which is not the finding that a presented certificate is the wrong one.
None of these is inferred into a mismatch, on the rule the whole field rests on: it is set from positive evidence the run observed, never from a termination the run cannot attribute to the certificate. The consequence for a reader is that false states that this run observed no mismatch, and never that it confirmed the partner. The four cases above are indistinguishable from each other, and from a clean comparison, in the record: the field is one bit and does not claim to separate them, and no other field of the record recovers the distinction. A reader that needs to know whether a run attempted the check reads the operator's own run log and event stream, as it does for the outcome's cause.
Three properties fix how it is read, beyond the five the outcome takes.
It is written on every record, false included, on the stated-never-inferred-from-silence rule above: a reader takes the value rather than concluding a clean authentication from an absent marker, and the rule for reading it is the same sentence on a completed record and a terminated one. A completed record always states false, a mismatch having terminated the run it was observed in -- the two fields are independent in the format and not in the runs it describes.
It narrows who received the disclosure, and nothing else about it. The disclosure occurred: this party's payload had crossed, which is why a record is owed at all, and every commitment and governance field beside the marker is what it is on any other terminated record. What the marker qualifies is the partnerIdentity field -- a self-asserted name the run did not confirm, and on this arm one the run has positive grounds to doubt. It says nothing about the data, and nothing about which party the disclosure actually reached, which this party cannot know.
It is this party's own observation, like the rest of the record. The two parties of one run can record different values: a certificate this party refuses is one the partner presented and believes in, and its own record is written from its own pin and its own view of this party's certificate.
Where a terminated record is written is not distinguished: it takes the same destination, filename, and owner-only permissions a completed run's takes (Record and verification-keys files). The distinction belongs in the document, which travels with it wherever it is copied, rather than in a filename, which does not.
Record fields
The record captures a fixed set of fields. The format version (alcove-exchange-record/v11) is a single recognized literal, which a reader rejects rather than migrates if unrecognized. It moves with the field set, so a record written before the receiptBinder below, before either identity became omittable, before the outcome below or the certificateMismatchObserved beside it, or before the governance block's resolved matching, is refused on the discriminant rather than read as one whose absent field means something else (pre-release, no migration is offered). A removed field moves it too: a record written while the governance block could hold a rule-set citation and the writer's verdict on it is refused on the discriminant rather than read with those fields dropped. The version also moves when the bytes a field is computed over change: a record whose agreed-terms hash was taken over the terms as exchanged, before it covered the resolved terms, is refused on the discriminant rather than reported as a terms mismatch. The run's outcome is recorded next, and beside it certificateMismatchObserved, which states whether the run observed that the certificate the partner presented is not the pinned identity. The local timestamp is a UTC datetime ending in Z, in the form fixed under The timestamp's form below; a timestamp with a numeric offset is rejected. A hash, over the canonical encoding, of the agreed exchange terms covers both parties' linkage terms in a fixed, canonical-sorted order, so both parties and an independent third party derive the same hash. Both parties' self-asserted identity strings are each present only where that party supplied one (see The parties' identities below). The number of records this party contributed to the exchange (recordsExposed) is its own input row count, recorded on every record as a per-direction statement of what it put in. The result size is recorded, but only in the both-output case. An optional self-facing retention/disposition pointer records where this party filed its copy of the result and under what retention schedule (see below). A per-exchange binding nonce (CSPRNG-generated, at least 128 bits) keeps two runs with identical terms producing distinct records. And on an exchange that derived a signed receipt's binder, that receiptBinder is repeated verbatim, so the two artifacts pair to one run (see Pairing a receipt to one run). The binding nonce is the unsigned record's own per-exchange value and is distinct from both the per-commitment salts below and the receipt binder: it is generated locally, so the two parties' records for one run hold different nonces, and no other artifact is derived from it and the two parties never compare it. The web application does compare it across its own stored records, as a local key for recognizing a record it already holds; that comparison is never sent to the partner. The receiptBinder is the opposite on both counts -- both parties derive the identical value from the run's session key, and comparing it is the whole point. Do not conflate the three.
Those fields are the record document's own top-level members, and this is all of them. A reader indexes a record by these names, and a writer emits no others:
| Key | Presence | Value |
|---|---|---|
version | always | alcove-exchange-record/v11 |
createdAt | always | UTC datetime ending in Z (The timestamp's form below) |
outcome | always | completed or receipt-swap-terminated |
certificateMismatchObserved | always | boolean, and required: a record that omits it is rejected rather than read as false |
termsHash | always | unpadded base64url SHA-256 (The agreed-terms hash) |
localIdentity | omitted where this party supplied no name | non-empty string |
partnerIdentity | omitted where the partner supplied none | non-empty string |
governance | always | the governance metadata object |
recordsExposed | always | non-negative integer, this party's own input row count |
resultSize | omitted outside the both-output case | non-negative integer |
retentionDisposition | omitted where this party filed no note | non-empty string; never "" |
bindingNonce | always | unpadded base64url over 32 CSPRNG bytes |
receiptBinder | omitted where the run derived no binder | unpadded base64url over 32 bytes |
commitments | always | localPayloadSent, partnerPayloadReceived, and associationTable where this party holds one, each an unpadded base64url MAC (Commitment scheme) |
An omitted field is an absent member, never null and never an empty string: the two are distinct in the canonical encoding, and every presence rule in this format is read off the member rather than off its value. The keys file beside the record holds version and salts and nothing else (Record and verification-keys files).
The parties' identities. localIdentity and partnerIdentity each state that party's self-asserted linkage_terms.identity, which is optional. A party that supplied no name sends terms with none, and this record omits its field rather than writing an empty string or a stand-in label: Alcove names no party the operator did not name. The distinction is structural in the record document -- the member is present or it is not -- so a reader of the file tells "nobody gave a name" from "a name was given" by the member's presence, and never by its value. It does not survive rendering, and no surface makes it: identity is unauthenticated free text a party chooses for itself, so a party that names itself the absence marker's own text displays exactly as a party that named nobody. What a surface shows is a self-asserted label in either case. The two fields are independent -- one party may be named and the other not -- and the format version moves with the omission for the reason every other omittable field moves it: an earlier record could not have been written without both, so its silence would have meant a writer that dropped the field. A surface rendering the record shows an explicit absence marker where a field is absent rather than a blank ((no name given) in this implementation's own surfaces). The agreed-terms hash is taken over the terms as sent, so an absent identity is absent from the hashed document too -- not hashed as an empty string.
The form a stated name takes. A name an operator types reaches the terms NFC-normalized with surrounding whitespace removed -- at a browser seat and on the CLI's --identity alike, on the inviting path and the accepting path alike -- so one typed name is one string in the terms the agreed-terms hash covers and in the identity a signing certificate is authorized against, whichever seat typed it. This format applies no rule of its own: a record states its writer's terms value as those terms state it.
The timestamp's form. A written createdAt is what the Date Time String Format of ECMA-262 fixes -- YYYY-MM-DDTHH:mm:ss.sssZ, the output of Date.prototype.toISOString. A read createdAt is looser: the validator requires the trailing Z and rejects every numeric offset, +00:00 included, but accepts any sub-second precision, so 2026-08-10T12:00:00Z and 2026-08-10T12:00:00.000Z are both valid records for one instant. What the UTC-only rule buys is therefore one reading of an instant rather than one string for it, and nothing cross-party rests on the string: the timestamp is each party's own, and it enters neither the agreed-terms hash, nor a commitment, nor the receipt content both parties sign. This document states the form rather than resting on the name ISO 8601, a publication the project does not hold and so cannot cite a clause of.
The free-text fields a reader accepts. The two identity strings, the agreement purpose, each payload column description, and retentionDisposition are bounded in length by the reader but are not held to the terms document's control-character rule. The record is a frozen log, so its reader stays permissive toward what a possibly-different-version writer recorded. How such a value renders is governed by display escaping at the surface that shows it, not by this format. retentionDisposition is the one of them written from this party's own local note rather than from either side's terms, and its authoring rule is drawn differently for that: it admits tab, LF, and CR, the whitespace a multi-line note holds (see the exchange intent).
The name fields a reader accepts. The linkage field names and types, the payload column names, and the agreement reference are bounded in length by the reader and are not held to the terms document's name shape either, for the reason the free-text fields are not. A name enters a record verbatim from terms the schema has already held to that shape, so what the reader's permissiveness admits is a record another writer produced, and how such a value renders is governed by display escaping at the surface that shows it.
The bounds a reader applies. Beyond the version literal, a reader refuses a record on four structural bounds, so a hostile or corrupt document is rejected before anything is recomputed:
- Every base64url field --
termsHash,bindingNonce,receiptBinder, and each commitment and each salt -- is held to^[A-Za-z0-9_-]+$and to 256 code units. The alphabet admits no=, so a padded value is refused, and the empty string is refused with it. The length is a cap, not a lock: every legitimate value is a 32-byte value (43 characters), and the bound exists to refuse a megabyte-scale string rather than to pin a width, which the recomputation decides. governance.matchingBasisis capped at 256 entries, the linkage-entry bound the terms document itself takes.governance.payloadSentandgovernance.payloadReceivedare capped at 4,096 entries each, the payload-entry bound the terms document takes.- Each free-text and name field is capped in code units: 1,024 for the two
identitystrings, the agreementpurpose, each payloaddescription, andretentionDisposition; 256 for the linkage field names and types, the payload column names, and the agreementreference.
A count cap is applied before the entries themselves are validated, so an over-long list is one refusal on the count rather than one per entry.
Result size and per-direction disclosure
The two disclosure figures -- recordsExposed and the result size -- are gated differently, and the difference is deliberate. recordsExposed is the count of records this party contributed to the exchange -- its input row count, counting every contributed record rather than only those that resolve to a usable linkage key, so it is an accurate upper bound on what the party exposed rather than a derived match figure. It is known from the party's own input alone, so it does not depend on the result or on the partner, and it is recorded for every party regardless of role. The result size (the intersection cardinality) is recorded only when both parties' agreed terms have them both receive output, so it is stored only when both sides are entitled to the result, and omitted when only one party does. The gate is the terms agreement (entitlement), not what a party happens to learn during the exchange: in a single-output exchange the helper that does not receive output can still observe its match count during the clean cascade, but the record does not report it. Privacy here is enforced by what the tool writes down, not by what is theoretically discoverable; the helper's own outbound disclosure is stated by recordsExposed instead. Framing the helper's figure as "records I exposed" rather than "the result size" is also what keeps the record meaningful under a future matching algorithm that discloses neither the result size nor the partner's set size -- the figure comes only from the party's own input, which any such algorithm still leaves it knowing.
Reading the disclosure direction off a record: a PSI exchange discloses the membership of matched records mutually -- each party reveals its matched membership to the other to make the match -- so direction is not a flag but a reading of the fields together. recordsExposed is the magnitude of what this party put in (its outbound exposure); the algorithm says what kind of disclosure occurred (psi revealed matched identifiers, psi-c only a count); payloadSent and payloadReceived give the payload categories per direction; and the presence of the association-table commitment (below) marks whether this party itself received the matched pairing. So "what this party disclosed" reads off algorithm + recordsExposed + payloadSent, and "what it received" off payloadReceived and the association-table commitment -- without consulting the output configuration, which the record does not hold.
The algorithm field is bound to what the run actually did, not to an unvalidated terms value. Core admits an algorithm only where a run path honors it (assertAlgorithmImplemented in packages/core/src/exchange.ts, at both the local prepare step and the run boundary), so an algorithm the exchange cannot conduct is refused before any identifier is revealed rather than written into a record. A psi-c (count-only) record therefore attests a run that disclosed a count and no identifier, and the same two points refuse a count-only document outside the shape a count-only run admits, so the record can never attest a run the exchange did not conduct. This is the run-side half of the guarantee; a pure record-side clamp is rejected because it would keep the record accurate while silently ignoring an operator's stated intent to disclose only a count. The rows a count-only record holds are fixed under Count-only (psi-c) records below.
Result size under a deduplicating cardinality
Under one-to-one the three figures a result could be counted in -- the association table's pairs, this party's matched records, and the partner's matched records -- are one number, so the field's meaning needs no statement. A deduplicating cardinality separates them: several of the declaring party's records link to one of its partner's, so that party has more matched records than its partner does, and both figures sit under a single pair count. Where both parties declare it a fourth figure appears, the count of entity clusters the table resolves into, and the reading is unmoved: the pair count is the sum over the clusters of the pairs each holds -- m * n for a cluster of m and n records that is one block of one matched value -- which is at least either party's matched-record count, which is in turn at least the cluster count.
The recorded result size is the pair count -- the number of entries in the association table, a repeated row index on one side of a pair still being a pair. Two properties decide it. It is the figure both parties derive identically from the single table the exchange produced, so the two records of one exchange state the same size, as they do under one-to-one; a per-party matched-record count would put two different result sizes on one exchange and leave a reader of either unable to say which was meant. And it is the count of disclosed linkages, which is what the field is for: each entry sets one record of this party against one record of the partner's, and a disclosure accounting is over those linkages rather than over the entities behind them.
No second count is recorded beside it. Each holder can derive its own matched-record count from its retained result, so a field holding it would disclose nothing new to that holder while adding a figure to the artifact it hands an auditor; a reader that needs the entity-level figure reads the result file. The entitlement gate above is unchanged: the pair count is recorded only when both parties' agreed terms have them both receive output. The reasoning behind the choice is in deduplicate-matching-semantics.md.
What verification binds about the result size
No commitment covers the result size. The figure sits in the record in cleartext under the gate above, so a commitment beside it would bind the same integer without hiding anything, and the record's privacy properties already state that a reader learns the size when it is present. What stands behind the figure instead is the association-table commitment, because the field is a count of that table: its entries are the pairs the size counts, so a table re-supplied and opened against its commitment holds the figure's correct value.
Verification therefore recounts the size rather than opening anything for it. Where the record holds a result size and its association table opened, the opened table's pair count is compared against the cleartext field; a disagreement is a definite failure, reported against the result size and distinct from a commitment mismatch. The two differ in what they mean, not only in where they are reported. A commitment mismatch is ambiguous: the record was altered, or the re-supplied file does not belong to this exchange, and nothing in the artifacts tells the two apart. A result-size mismatch is not: a file that did not belong to this exchange would have failed the table's own commitment first, so an opened table with a different number of pairs means the record's cleartext figure does not state the size of the pairing the record itself commits to.
Where no opened pairing stands behind the figure it is reported as unchecked, never verified, on the fail-safe rule the commitment statuses follow. Five states reach that. The result was not re-supplied; the keys hold no salt for the table; the re-supplied table did not reproduce its commitment; the re-supplied table opened but is not shaped as a pairing; or the record has no association-table commitment at all. Anything other than two equal-length halves of non-negative integer row indices has no pair count, so there is nothing to recount -- a state a record built by an exchange never reaches, since the table it commits to is that shape. The last state is the one every count-only record is in, since a count-only exchange commits to no such table. A record with no result size is neither checked nor at fault: the field's absence is what the entitlement gate produces whenever only one party receives output.
Count-only (psi-c) records
Not yet built: the matching-basis row and the
single-passparagraph below describe count-only over every agreed linkage key. The shipped build runs one key and refusessingle-pass(PROTOCOL.md, PSI-C).
A count-only exchange (PROTOCOL.md) writes a record of the same shape as a psi exchange. Five of its rows are fixed by what a count-only run does and does not produce.
algorithm is psi-c. The record then attests that this exchange disclosed a count and no identifier -- the disclosure an auditor reads off the field. As under psi, the value is bound to what the run did rather than to the terms value alone.
The result size states the count, under the unchanged entitlement gate. The count is the run's whole result: the size of the one-to-one table over every agreed key in the permuted space (PROTOCOL.md, Computing the count). It is recorded in the result-size field under exactly the gate Result size and per-direction disclosure sets: present when both parties' agreed terms have them both receive output, omitted when only one does. For psi-c that gate coincides with the protocol's count-report leg, the both-entitled case being precisely the case in which both parties hold the count. In a one-sided run the receiver therefore holds a count its own record does not state: the conservative rule psi applies to the result size holds unchanged, not relaxed because psi-c's whole result happens to be one integer.
The cleartext field is the count's whole attestation in the record. No commitment is added for it: the figure is already recorded in cleartext under that gate, so a commitment beside it would bind the same integer without hiding anything, and the record's privacy properties already state that a reader of the record learns the result size when it is present. Under psi the figure is still checkable -- verification recounts it from the association table the record commits to -- but a count-only run produces no such table, so that recount has nothing to run against here and the count reports as unchecked. That is the accurate reading of a figure no retained artifact reproduces, not a gap in the format.
The matching basis lists every agreed key's fields. A count-only run matches on every agreed linkage key, so its matching basis is the one any record lists: every linkage field the agreed keys reference, by the same rule and in the same order, never narrowed to one key or to the keys a match was found on. Neither operator is told which key any match was found on, so the basis states what the count was matched on, not what matched.
No association-table commitment, and its absence is normative. A count-only run produces no pairing, so neither party holds one to commit to, whatever its entitlement -- a psi-c record has no association-table commitment on either side. The absence is critical rather than incidental: the presence of that commitment is the record's marker that this party received the matched pairing (see Result size and per-direction disclosure), so a psi-c record with one would assert a disclosure the run did not make. This is the one row where psi-c departs from the psi rule, which commits the table whenever this party received output.
Payload commitments are present and empty. psi-c refuses payload in either direction, so both directions commit the empty committed payload { "columns": [], "rows": [] } -- present, salted, and domain-separated exactly as a populated one would be, never omitted. The readable payloadSent and payloadReceived lists are likewise the explicit empty list. Recording the no-payload case explicitly rather than by omission is the rule the psi no-payload case already follows, and it is what keeps a reader from having to guess whether a field is absent because nothing flowed or because the writer left it out.
The linkage strategy is not among those rows. Count-only terms may declare single-pass, which runs as the cascade realization (PROTOCOL.md, A declared single-pass runs the same rounds). The record has no strategy field, and the agreed-terms hash, the agreed-terms file, and the receipt's terms envelope each state the strategy as the terms declare it, never which realization ran.
What the separate signed receipt does and does not attest for such a run is under A count-only run's receipt below.
Governance metadata
Alongside those fields the record holds readable governance metadata so it is self-describing as a disclosure record. This is the governing data-sharing agreement, the algorithm, the categories of data exchanged, and the matching basis. The agreement is recorded by its reference (for example MOU-2025-0042), the purpose of the disclosure under it, and its expiration date, identifying the authority for, and the purpose of, the disclosure. The purpose is a brief readable statement of why the disclosure was made, as a HIPAA 164.528 accounting and a FERPA 99.32 record both require; a single agreement can authorize several purposes, and this names the one this exchange was for. The algorithm is psi, which revealed matched identifiers, or psi-c, which revealed only a count, so the record shows whether identifiers or only a count were disclosed. The categories of data exchanged are the payload columns this party committed as sent and received, each recorded as its name plus any data-dictionary description that dictionary supplies for it. The name is the column actually transmitted by the metadata disclosure gate, not a name from the optional declared data dictionary. The no-payload case (no columns disclosed, or no matched records) is recorded explicitly as an empty list rather than by ambiguous omission. The matching basis is the linkage fields the match keyed on, each recorded as its standardized field name and semantic type (for example last_name, date_of_birth, ssn4), documenting the PII on which shared membership was determined. The standardized name (not the raw source column) is the identifier the linkage keys and the standardization config both reference, so it is the anchor for tracing the basis back through standardization to the data; the basis covers only the fields the linkage keys actually reference, so a declared-but-unused field is not listed (it was not matched on). The legal agreement reference, purpose, and expiration, the algorithm, and the linkage-field set come from terms both parties validated at exchange time, so both parties' records hold consistent values for them. The payload column-name sets are not read from the (cross-checked) declared dictionary at all but from the committed payloads -- the columns the isDisclosedToPartner metadata gate actually transmitted -- so the readable list cannot drift from the committed bytes (a record can never read "sent nothing" while real columns were committed, the failure mode when no dictionary is authored). They are still consistent across the two parties, by a stronger route than cross-checking: the two directions' payload commitments cover byte-identical data, so one party's payloadSent equals the other's payloadReceived by construction (see Commitment scheme). The data-dictionary description, looked up by committed column name, is the one governance field not cross-party-validated -- the two parties may annotate the same column differently, and a committed column the dictionary does not describe has a bare name. The metadata is names, categories, descriptions, and references only -- never a payload value, a linkage-field value, or a matched identifier -- so the record stays free of protected data at rest, with the data exchanged bound by the commitments below rather than embedded.
The governance block's keys
The block's own members, and the presence rule for each:
| Key | Presence | Value |
|---|---|---|
algorithm | always | psi or psi-c |
legalAgreement | omitted where the agreed terms name no agreement | reference, purpose, expirationDate |
matchingBasis | always, [] where the keys reference no field | entries of name and type |
payloadSent, payloadReceived | always, [] where nothing was disclosed | entries of name and an optional description |
matching | always | localDeduplicate, partnerDeduplicate, cardinality |
expirationDate is a date with no time of day and no zone: YYYY-MM-DD, held to the calendar, so 2026-02-30 is refused and so is a datetime such as 2026-02-01T00:00:00Z -- unlike the record's own createdAt, which is a datetime and nothing else. The agreement's three members are copied out of the terms one by one, so the record holds its own values and states the agreement as the run agreed on it.
The two absence conventions sit side by side here. legalAgreement states "no agreement was named" by an absent member, while the payload lists and matchingBasis state "nothing was disclosed" and "no field was matched on" as an empty array. A reader must not read one convention as the other: an absent payloadSent is not an empty one, and it is not a record this format admits.
The matching basis against what the run contributed
The basis is a terms fact -- the fields the agreed linkage keys reference -- so both parties' records hold the same list, while which of those fields each party's input binds a column to is its own and the two parties' bindings differ. The writer holds the one to the other: a record whose matching basis names a linkage field a key element reads and this party's input bound no column to MUST NOT be written, and the build fails instead. That failure is the ordinary record-build failure of When a record is owed -- the exchange's own result stands, and the run reports that no record could be produced. The basis is never narrowed to this party's contribution in place of the refusal, which would leave the two parties' records disagreeing over a term both agreed to. A field only a key's missing-field condition reads is outside that rule: an input with no column for it ran the key with every record lacking the field, which is all the condition reads, so the record is written with the field in its basis.
A key condition's field is referenced. The field a key's missing-field condition names is in the basis as an element's field is: whether a record had a valid value for it decided whether the record took part in the key's rounds.
One entry per field, in a fixed order. A field several key elements reference is recorded once: entries are deduplicated by the field name the elements reference, and the list is then sorted by name as a sequence of UTF-16 code units -- the same ordering the canonical encoding and the terms compatibility check use, never a locale collation. So both parties and both implementations write the same list in the same order for one set of agreed keys, and a reader comparing two records compares two sequences rather than two sets. type never participates in the order, names being unique after the dedupe.
The check reads bindings, not values: a bound column whose every row standardizes to empty satisfies it, and whether the values behind the bindings cover the agreed keys is the prepare-time satisfiability check's, which refuses such an input before anything is sent. A run therefore reaches the build with the basis and the bindings already in step, under the same terms it matched under, and the check at the build is what holds a record produced by any other path, including one whose inputs a caller assembled itself.
The resolved matching
The governance block records what the two parties' agreed deduplicate values resolved to for this party (matching): its own declared value (localDeduplicate), the value its partner presented at the terms exchange (partnerDeduplicate), and the cardinality the pair gives this party (cardinality, one of the four closed labels). All three are always present; the one-to-one pair is recorded as stated rather than by omission, on the same rule the outcome and the no-payload commitments follow.
Both values, not the label alone. The label is read from the recording party's own side, so the two parties of one deduplicating exchange hold mirror labels (many-to-one against one-to-many) for the single procedure they ran. A reader of one record could not tell from the label which side declared what, and a record naming only the resolved multiplicity would state the run's shape without stating the partner's term that decided it -- which is the term the one-sided binding does not hold an inviting party's partner to. The values are booleans and the label is one of the four; no partner free text enters the field.
It is the run's own resolution, not a re-derivation. The record derives the triple from the same function the run boundary resolves the cardinality with (resolvedMatchingFromTerms, packages/core/src/linkageTermsPolicy.ts), over the two terms documents that boundary already admitted, so a record cannot name a cardinality its run did not match under. The values are inside the agreed-terms hash already -- each party's deduplicate is part of the terms document that hash covers -- so recording them readably adds a reading rather than a new claim.
Where it is read. The CLI and a browser seat state the same sentence, composed in core (describeResolvedMatching, packages/core/src/pairTableProjection.ts): the CLI on its run log at info, its default level, once the terms are agreed on and beside the resolved role; and a browser seat conducting the exchange on its running screen at that same point, until the run completes. No completion panel states it, and a console seat, which watches a CLI run, states no sentence for the pair; the CLI's result event includes the three. The record holds the three on every run whatever the log level, including the one-to-one pair no cardinality notice is raised for, because a party's own document states only its own value and nothing before the terms exchange holds the pair.
Retention and disposition pointer
Distinct from that governance metadata, the record can also hold an optional retention/disposition pointer. It is a free-text note recording where this party filed its copy of the result (the association table and any received payload) and under what retention schedule it is held or disposed of, so the record answers an auditor's first operational question -- where did the result go -- without a separate lookup. Unlike the governance fields, it is not drawn from the agreed terms: it is sourced from this party's own local exchange config, so it is per-party (the two parties' pointers are independent and need not match), is never exchanged with the partner, and is not folded into the agreed-terms hash. It is metadata only -- never a payload value, a linkage-field value, or a matched identifier -- and is recorded only when configured; its absence is explicit (the field is omitted, never an empty string). Configured via retention_disposition in the exchange spec.
Commitment scheme
The record commits to the data exchanged rather than embedding it: the payload columns this party sent, the payload columns it received, and -- when this party holds it (it received output) -- the association table. A bare SHA-256 of a low-entropy result (for example an association table over identifiers; cf. the SSN brute-force warning in SECURITY_DESIGN.md) would be brute-forceable by anyone holding the record, leaking the intersection. Instead each commitment is
commitment = HMAC-SHA-256(key = salt, message = canonical({ domain, data }))with a fresh per-commitment salt (CSPRNG-generated, at least 128 bits) and a per-kind domain label that separates the three commitment kinds. The three labels are fixed literals, one per record member:
| Commitment | domain | data |
|---|---|---|
localPayloadSent | alcove-commit-payload-sent/v2 | the committed-payload shape below, for the payload this party sent |
partnerPayloadReceived | alcove-commit-payload-received/v2 | the same shape, for the payload it received |
associationTable | alcove-commit-association-table/v2 | the [our row indices, partner row indices] pair below |
The label is folded into the committed message and never into the salt, so the three kinds stay distinct even under an identical salt and data pair, and a verifier that computes the wrong label computes a different message: no opening it holds will match. The stored commitment is the whole 32-byte MAC in unpadded base64url, and each salt is stored the same way in the keys file. Keying HMAC with the secret salt gives computational hiding (the commitment reveals nothing about the data, and a low-entropy data set cannot be brute-forced from the commitment without the salt); binding follows from the collision resistance SHA-256 lends HMAC, so the committer cannot open one commitment to two different data sets. The committed message is the canonical encoding (RFC 8785) of { domain, data }, so both parties commit over byte-identical serializations of the same logical data (row order, encoding, and null handling all fixed) and an independent implementation reproduces the bytes; that serialization is specified in CANONICAL_ENCODING.md. The two payloads are committed in a canonical representation owned by the record format rather than the transport encoding: { columns, rows }, with the wire hasData discriminant dropped and a no-data payload the empty value { "columns": [], "rows": [] }. A committed payload binds the disclosed column names and the row values only -- not any party's internal row indices. The received-payload commitment would otherwise bind the partner's row numbers, which the holder does not retain in a reproducible form (its result file records the received values, not the partner's row numbers), leaving the holder unable to reopen its own commitment. Which records matched is bound separately, by the association-table commitment -- an ordered [our row indices, partner row indices] pair, one entry per matched pair -- whose partner side the result file retains (see the partner row-index column in PROTOCOL.md) so the holder can reproduce it. Under a deduplicating cardinality a row index repeats on one side of that pair, once per link, and the commitment binds the repeated entries as it binds any others. The payload commitments are unaffected, holding one row per matched record (a payload row is addressed by the sender's own row index, so a record that stands in several pairs contributes one row). Because both payloads normalize to this one shape, a sender's payload-sent commitment and the receiver's payload-received commitment cover byte-identical data for the same logical payload. That byte-identical-data property lets a verifier re-supply the shared payload when it opens a commitment. It does not, on its own, let one party recompute the other party's commitment: the sent and received kinds use different domain labels and independent per-commitment salts, so cross-verifying a partner's record additionally requires that partner's per-commitment salt (its verification keys, which are otherwise kept private) or a purpose-built cross-commitment step -- neither of which this format delivers by itself. The record is local and self-attested, so its commitments are never compared between parties; the mutually-verifiable artifact is the separate signed receipt, whose content excludes these salted commitments.
The agreed-terms hash
termsHash is not one of those commitments and hides nothing: it is a bare hash over both parties' linkage terms, so any holder of the terms can recompute it. It is
termsHash = base64url(SHA-256(canonical({ domain: "alcove-agreed-terms/v3", terms: [first, second] })))with terms holding the two parties' resolved linkage-terms documents, and nothing else in the hashed object. The resolved terms are the ones both parties agree to at the terms exchange, and each party derives both of them from the two documents that crossed the wire:
- An unset
payload.sendis the columns that party's metadata discloses, which it states in the terms it sends (An unsetpayload.receiveis filled on the first run). A presentsend, an explicit empty list included, is kept as written. - An unset
payload.receive, under terms where the partner can send payload at all (neitheralgorithm: psi-cnoroutput.expects_output: false), is the partner's statedpayload.send, each column as{ "name": ... }alone, or the empty list when the partner states none (termsResolvingPayloadReceive,packages/core/src/config/recurringTerms.ts). This is the list the first-run fill records, so the hash is the same over the terms as they were exchanged and over a configuration the fill has since written. A presentreceiveis kept as written.
The domain label moved to v3 when the hashed terms became the resolved ones, and the record version moved with it. The first rule is applied by the sender before the terms leave it, the second inside computeTermsHash, so a verifier recomputing the hash from a configuration states an unset send from that configuration's metadata, or from the input header its run inferred metadata from, and supplies the rest as written. The pair is ordered by the two documents' own canonical strings, compared as sequences of UTF-16 code units: the smaller string's document is first. Ordering by those bytes rather than by role or by local-and-partner is what makes the two parties, and a third party holding both documents, derive one value -- there is no "my terms first" rule to disagree about. The digest is the whole 32 bytes in unpadded base64url, as every base64url field of this format is.
A term absent from a document is absent from the hashed bytes: an identity no party supplied is a missing member, not an empty string, so the hash states what the terms stated. The same value is the receipt content's termsHash (Receipt content), computed by this one rule so the record and the receipt agree without either deriving the other.
Result columns outside the commitments
A result file may hold columns no commitment covers. The three commitments bind what was EXCHANGED -- the payload this party sent, the payload it received, and the pairing between the two parties' rows -- so a column of this party's own input written into the result beside them is outside all three. That is what the local include_own_columns config key writes, and the only case: the exchange itself puts nothing else in the file. Such a column is the holder's own retained data, re-derivable from the input the record is verified against, so committing to it would bind a second copy of data this party already holds rather than record a disclosure.
The re-supply path is bounded accordingly. It reads the received payload as exactly as many result columns as the record's own governance names received, starting at the first value column, rather than to the end of the row -- so a result written with own columns opens every commitment, and one written without them opens them unchanged. That read is a fixed-width window, not a scan to the row's end: its width is the record's own received-column count, so a column past that width -- a carried own column among them -- is never read during re-supply, and an edit confined to those columns is not detected by verification. The values inside the window stay checked, cell for cell, by position.
Signed receipt
The certificate-backed signed receipt reuses this document's canonical encoding and committed-payload shape but is a separate artifact from the self-attested record: it is a single signed receipt holding facts both parties derive byte-identically, over which each party makes an ECDSA P-256 signature that a third party can verify against either party's view. Where the self-attested record is a per-party, salted, hiding artifact, the signed receipt is a mutually-verifiable attestation of what flowed, whose directional payload values are session-keyed MACs (see below) rather than salted commitments. The signing/exchange step that produces it, its trust model, and the fingerprint-pin-before-signature check are specified in PROTOCOL.md, and the checks a holder runs against a stored copy in Verifying a stored signed receipt; this section fixes the byte layout so an independent implementation reproduces it.
Receipt content
The receipt content is the object both parties sign. Its canonical bytes are folded into the per-signer signed bytes below; the content object has exactly these keys (the canonical encoder sorts them):
termsHash-- the agreed-terms hash (unpadded base64urlSHA-256), computed exactly as the self-attested record'stermsHashabove, so both parties produce the same value.initiatorToResponderPayloadandresponderToInitiatorPayload-- the directional payload MACs, each an unpadded base64urlHMAC-SHA-256over the canonical encoding of the<committedPayload>that flowed in that direction, under a per-direction key derived from the session key. The MAC key isHKDF-SHA-256of the session key with a zero salt and the info stringalcove-signed-receipt-payload-v2:<direction>, where<direction>isinitiator-to-responderorresponder-to-initiator; the label is prefix-free against every other session-key label.<committedPayload>is the record's committed-payload shape. Both parties hold both directions (each sends one payload and receives the other, and a sender's committed payload is byte-identical to the receiver's) and both hold the session key, so both compute the same two MACs; keying them by the fixed handshake direction (not by local/partner) makes the two content objects byte-identical. The MAC key is derived at sign/verify time and never stored in the receipt file -- the file holds only the resulting MAC value. Keying off the session key (not a bare hash) is what stops a third-party receipt holder, who has neither the session key nor the flowing data, from recomputing a directional MAC or brute-forcing a low-entropy payload from it; the no-data direction is likewise a session-keyed MAC, not a public constant, so it does not leak payload presence. The salted record commitments remain the record's hiding artifact.binder-- the per-exchange replay binder (unpadded base64url over 32 bytes):HKDF-SHA-256of the session key with a zero salt and the info stringalcove-signed-receipt-binder-v2:initiator. Both parties fold in the initiator-role label, so both derive the same binder (see PROTOCOL.md). The exchange record of the same run repeats this value in itsreceiptBinderfield, which is what pairs the two artifacts offline: Pairing a receipt to one run.
The salted record commitments and the association-table pairing are not part of the receipt content; the rationale (they are not byte-identical across parties, or not held by both) is in PROTOCOL.md. The terms-exchange envelope fields carried beside linkageTerms -- the record count, the save and disclosesPayload advertisements, the observed host key, and the protocol version -- are outside the content and outside the agreed-terms hash on the same rule: each is asserted by its own party rather than derived byte-identically by both. A receipt therefore attests the agreed terms and nothing about the run's envelope, including the payload-intent flag the association-table withhold reads beside the agreed terms and the host key each party independently observed.
Receipt signature
Each party signs its own signer-bound bytes: the RFC 8785 canonical encoding of { "domain": "alcove-signed-receipt-content/v3", "content": <content>, "signer": { "fingerprint": <signer fingerprint>, "role": <signer role> } }, where <content> is the shared receipt content above, <signer fingerprint> is the signer's own certificate fingerprint (the same value a partner pins), and <signer role> is initiator or responder. The signature is an ECDSA signature over those bytes under that party's signing key -- P-256 with SHA-256, encoded as the fixed-length 64-byte raw r || s (IEEE P1363) in unpadded base64url, exactly as a certificate self-signature is (PROTOCOL.md). A signature field that decodes to any other length is refused rather than decoded, so a DER SEQUENCE of the same two field elements does not verify. Signing is randomized, so the same content signed twice under one key yields different signature bytes and both verify; what reproduces across implementations is the signed bytes above, not the signature. Binding the signer's fingerprint and role into the signed bytes makes the two { certificate, signature } blocks non-interchangeable: a holder cannot swap them to re-attribute a direction's payload to the wrong party, since each signature binds its own signer (the shared content alone would otherwise verify under either key). A verifier reconstructs the partner's signed bytes from the shared content, the partner's pinned fingerprint, and the partner's role (the opposite of the local role). The content domain (alcove-signed-receipt-content/v3) is domain-separated from the certificate signature and fingerprint domains, so a receipt signature can never be confused with a certificate self-signature.
Signed receipt file
The signed receipt has a format version (alcove-signed-receipt/v4, a single recognized literal a reader rejects rather than migrates -- it moves with the certificate format the record embeds and with the envelope below), the shared content above, one { certificate, signature } block per party keyed by the fixed handshake role (initiator, responder), and the unsigned envelope field beside them.
The unsigned envelope: partnerTerms. The record holds the linkage terms of the party its holder exchanged with, as an optional partnerTerms field beside content -- the same linkage_terms document shape the terms exchange carries, in the camelCase spelling the record format writes. A reader parses it under the bounded linkage-terms schema every other partner-terms path uses (TRANSFORM_PARAMETER_BOUNDS.md), and a file whose carried terms that schema refuses is refused whole.
Neither signature covers the field. It is retained so a holder re-derives the agreed-terms hash from the receipt and its own terms rather than from a second file it had to keep. It stands on the hash it reproduces: a verifier recomputes content.termsHash from it and reports a mismatch when the two disagree. Where no exchange record is supplied, it also seeds the two expected identities, beside the holder's own terms (signedRecordExpectations): a rewritten identity there flips the asserted-identity line to a mismatch, and fails the hash check on the same edit, so every line the carried copy seeds stands only where that hash matches. A record with the field absent -- a holder stripped it, or supplies the partner's terms from a document of its own -- verifies exactly as one holding it does, and leaves the agreed-terms hash to whatever terms the verifier supplies.
Agreement between the two parties' copies. Both parties write the same signed content and the same two signature blocks: roles are fixed by the handshake rather than by local/partner, and each party stores the signature the partner sent rather than re-deriving it, so those fields agree byte for byte even though ECDSA signing is randomized. The envelope is where the two copies differ -- each holds the OTHER party's terms. That agreement is a property of how the two parties assemble the file, not a property of the format: ECDSA signatures are malleable in s, so a third party holding a record can replace either signature with its negation and produce a differing copy that still verifies, attesting the same facts. Two records are therefore compared by verifying them against the pinned fingerprints, never by hashing the file or comparing bytes.
Contents and custody. The record holds no payload contents and no private keys: only public certificates, signatures, the agreed-terms hash, the two session-keyed payload MACs (from which a holder without the session key can recover neither the payload nor its presence), the per-exchange binder, and the partner's linkage terms in the envelope above. Both parties' identities and the terms are bound into it -- that is its purpose, a mutually non-repudiable attestation -- so it is not anonymous; see Receipt privacy properties for exactly what a holder learns. The CLI writes it owner-only by default (an operator shares it by copying the file, matching the record's conservative default) and atomically (temp file then rename), to a timestamped alcove-receipt-<time>.json path in the run's folder (Where a run's files go), sharing the exchange's createdAt stamp with the record. Serialized as ordinary pretty JSON with a trailing newline (the human-readable persisted form, not the canonical encoding, which is only the bytes that are signed), like the self-attested record.
Receipt privacy properties
A third-party holder of the signed receipt (without the session key and without either party's retained input or result) learns:
- the two parties'
identitystrings (each held in a certificate the record embeds); - that an exchange occurred between them under a given agreed-terms hash;
- one party's whole linkage-terms document, from the
partnerTermsenvelope: itsidentitylabel; thealgorithmandlinkageStrategyit runs under; thelinkageFieldsandlinkageKeysit linked on and the cleaning steps declared on those fields; itspayloaddeclarations andoutputflags; thelegalAgreementit declares -- that agreement'sreferenceand expiry date, and its free-textpurpose(the most identifying of all eleven: prose one party wrote stating why the two of them link data, which can name a program, a population, or an authority no other field does); and itsdate,version, anddeduplicateflag. All of it is the declarations of that document, never a value from either party's data. A holder that was handed the file by the other party is handed that party's own copy, whose envelope holds the terms of the party it exchanged with; - the mutual, non-repudiable attestation itself -- each party's ECDSA P-256 signature binding it to the terms and this exchange;
- the per-exchange binder, from which it can tell whether an exchange record it also holds is the record of this same run (Pairing a receipt to one run) -- and nothing further: the binder is a one-way value over a session key it does not have.
It does not learn:
- payload contents -- the directional payload values are session-keyed MACs, not the data;
- payload presence, from the MAC itself -- the no-data direction is a session-keyed MAC, not a public constant, so a holder cannot tell an empty direction from a non-empty one there, nor recover a low-entropy payload by brute force (both require the session key, which the record never holds). The carried partner terms are the exception the bullet above states: a holder reads that party's
payload.sendandpayload.receivedeclarations andoutputflags in full, so it can tell whether that party declared any payload; - any private key -- only public certificates travel in the record.
The session key, the per-direction MAC keys, and the flowing data all stay off the receipt, so recovering a payload from it is not possible for anyone but the two data holders. Retention and access control of the receipt remain the holder's responsibility.
Pairing a receipt to one run
A verified verdict on a signed receipt establishes who signed it and that the signed content -- the agreed-terms hash, the two directional payload MACs, and the per-exchange binder -- was signed by both named parties. On its own that does not say which run of a partnership the receipt attests. Recurring runs under the same agreed terms produce a byte-identical agreed-terms hash and hold the same two certificates, and the two content fields that do vary per run -- the binder and the payload MACs -- are reported, not recomputed, an offline verifier having neither the session key they derive from nor the data the MACs cover.
What pairs a receipt to one run is the receiptBinder the exchange record repeats: the same 32-byte value the receipt content's binder holds, written verbatim into the record of the exchange that produced that receipt. Both parties derive it from the run's session key with the initiator-role label, so both records hold the same value; a different run has a different session key and so a different value. A verifier handed a receipt and a record -- alcove verify-receipt with both artifacts, the web app's verify view with both loaded, or a third party given the two files -- compares the two and reports one of four outcomes:
verified-- the record and the receipt hold the same value: they are one run.mismatch-- they hold different values: the two artifacts are from different runs, and the whole verdict isfailed. Every other check may pass; the pairing is what separates two runs of one partnership.unpaired-- the record has noreceiptBinderat all. A record omits the field exactly when its exchange derived no binder (no signing identity, a path with no session key, or a signing run that terminated before deriving one), so no receipt can belong to it: alsofailed.not-checked-- no record was supplied. Nothing is contradicted and nothing fails, but which run the receipt attests stays open, so the verdict is held atincompleteand never reachesverified. This is the case for a holder legitimately in possession of only the receipt; the remedy is the run's record, not a weaker verdict.
A record whose outcome is receipt-swap-terminated takes the same three record-bearing outcomes as any other: where its run derived a binder it holds it, so a receipt held beside it pairs or mismatches on the value. What the outcome adds is that the writing party held no receipt of its own -- a genuine receipt pairing to such a record is the partner's copy, whose own signatures and anchors the verdict reports as it does for any receipt. The record's outcome is not itself a verification input and changes no verdict; it is a fact about the run, read from the record.
The check is an equality comparison of two public values, not a derivation: the verifier still cannot compute the binder (that needs the session key, which only the two parties ever held and neither retains), so a binder substituted into both artifacts by a party that holds them both is out of its reach. That residual is detectable only during the live exchange, where each party derives the value independently and a partner that signed a different one fails the signed-receipt step. What the pairing does close is the offline case a holder of two genuine artifacts can otherwise pass off: one run's receipt read beside another run's record.
The record's own per-exchange binding nonce is not this value and could not be. It is generated locally, so the two parties' records for one run hold different nonces, while the receipt content admits only facts both parties derive byte-identically -- so folding a nonce into the signed content would need both parties' nonces exchanged during the receipt step, and each would be a value its party asserts rather than derives. Repeating the receipt's already-shared, already-derived binder in the record instead leaves the signed format untouched; the reasoning is recorded in receipt-run-binding.md.
Privacy of the repeated value. Repeating the binder in the record widens neither artifact's disclosure of the linked data. It is a one-way HKDF-SHA-256 output over the session key that the signed receipt already publishes in cleartext, so a holder of the receipt learns nothing new from the record's copy, and a holder of the record learns a value that discloses nothing about the data: the session key is not recoverable from it and it covers no payload. Its presence or absence adds one fact to that record -- whether the exchange produced a signed receipt -- which is a statement about the two parties' own configuration, not about the data or the people in it. The one capability it confers is the intended one: a holder of both parties' records for one run can tell that the two are that same run, rather than only that they name the same parties and terms. Both privacy lists state that difference: the receipt privacy properties list the binder among what a third-party holder of the receipt learns, and the record's own privacy properties list what a present receiptBinder adds to the record. Those enumerated pairing facts are the whole of what a holder of either artifact learns from the repeated value; neither list gains anything about the linked data or the people in it.
A count-only run's receipt
The receipt is a separate artifact from the record and binds neither the record, nor its commitments, nor its result-size field -- for a count-only run or any other. Its content is the agreed-terms hash, the two directional payload MACs, and the per-exchange binder, and nothing else. For a count-only run those three resolve as follows.
- The agreed-terms hash binds
algorithm: psi-calong with the rest of the agreed terms, the algorithm being a mandatory-consistency term both parties fold into the same hash. The receipt is therefore third-party-verifiable evidence that this exchange was agreed on and conducted as count-only. - Both directional payload MACs are taken over the empty committed payload, computed and recorded exactly as a populated direction's would be. A holder without the session key cannot distinguish an empty direction from a populated one, so the receipt does not disclose that no payload flowed.
- The count itself is not part of the receipt content and is not added to it. The content admits only facts both parties derive byte-identically, and the count is not one: in a one-sided run only the receiver holds it, and in a both-entitled run the sender holds it as the receiver's report rather than as its own derivation. The same rule already excludes the association-table pairing.
A count-only receipt therefore attests what was agreed on and that an exchange occurred under it -- not what the count was. A party that needs the count itself third-party-attested has no artifact here that supplies it.
Record and verification-keys files
The artifact is split across two files, on the CLI and the web app alike. The record holds the commitments and the non-secret summary above; it does not contain the matched data or the salts, so it does not reveal -- or allow brute-force recovery of -- the intersection, and it is the part safe to retain or hand to an auditor. The verification keys hold, per commitment, only the salt (base64url, at least 128 bits) needed to open that commitment later; the file has its own format version (alcove-exchange-keys/v2), rejected-if-unrecognized like the record's. The keys hold no snapshot of the committed data -- they are not a second at-rest copy of the matched data (see No data snapshot in the keys below). They are still sensitive and must be kept private: a salt together with the record's commitment can open -- and brute-force a low-entropy -- committed value, such as the intersection, so anyone holding both the keys and the record could open the commitments.
Both files are written owner-only. The CLI writes them atomically (temp file then rename; see CREDENTIAL_STORAGE.md) to timestamped paths in the run's folder (Where a run's files go), or skips them under --no-record: the record as alcove-record-<time>.json and the keys as alcove-record-<time>.keys.json beside it. The web app offers the two as two separate downloads -- the shareable record and the private verification keys -- mirroring the CLI split, so the record can be shared with an auditor without the keys. A browser run that disclosed and then failed, or whose result file could not be built after the exchange completed, offers the same two downloads beside its failure. The record is written owner-only even though it is shareable: "shareable" means it has no secret material (no salts, no matched data), not that it is world-readable on disk. It still names both parties, the governing agreement, the data categories, this party's exposed-record count, and (when present) the result size in cleartext (see below), so the conservative default keeps it private to the owner; share it by copying the file, not by loosening its permissions. The verification keys are not shareable at all -- keep them private and separate from the record.
The CLI also writes the agreed-terms file beside the record, under the same control.
--no-record suppresses the record and its keys, not the signed receipt: a signing-configured run writes its signed receipt either way. That receipt's pairing check then has no record to pair against, so it is permanently in the not-checked case of Pairing a receipt to one run and verifies at most incomplete on every verifying surface -- and the record that would resolve it cannot be reconstructed after the exchange. An operator retaining receipts as evidence retains the record too. Because nothing after the run can repair the combination, the CLI warns about it before the exchange runs, on both the operator log and the warning event of the machine-readable event stream; the run then proceeds, since which artifacts to keep is the operator's choice.
Agreed-terms file
The agreed-terms hash needs both parties' terms to re-derive, and the record holds neither. The CLI therefore writes the two documents the hash is computed over beside the record, so the party that ran the exchange re-derives it with no file from its partner. Neither document is new to that party: it states its own terms and receives its partner's at the terms exchange.
- Shape. A JSON object with exactly three members:
version(alcove-agreed-terms-file/v1, a single recognized literal a reader rejects rather than migrates),localTerms, andpartnerTerms. Each terms member is the linkage-terms document exactly as the run passed it tocomputeTermsHash-- this party's terms as it stated them, and the partner's as received -- in the camelCase spelling the record format writes, as the receipt'spartnerTermsis. A reader parses each under the same bounded linkage-terms schema, and a file either member of which that schema refuses is refused whole. A reader also refuses, rather than drops, any member beyond these three and any key inside either terms member that the schema does not read, so the terms it re-hashes are every byte the file states; and it refuses a path that is not a regular file once opened, and reads at most 1 MiB of a regular file, supplying no terms past that bound. The open is the ordinary blocking open, so a FIFO at that path with no writer attached stalls the command until a writer appears or the process is stopped; refusing such a path before opening it is not done. - Name and custody. The record path with a
.terms.jsonsuffix in place of a trailing.json(alcove-record-<time>.terms.json), the rule the keys file's.keys.jsonfollows. Written after the record, owner-only and atomically like it, and serialized the same way (pretty JSON with a trailing newline). - Wherever the record is written. The file follows the record to any directory, a
filedropconnection's shared folder included. It holds only what both parties already hold from the terms exchange, and it is not a secret: the record beside it is the file the operator chose to place there, so the same choice places the terms file. A third party who reads it learns the contents the record's Privacy properties section lists for this file -- each party's linkage fields and keys, payload columns, governing agreement, and identity -- so placing the record in a shared folder gives every reader of that folder those terms. - Non-fatal. A failed write leaves the record and keys as written and is reported as a warning, not as a missing artifact: the file holds nothing the verifier cannot be given another way.
- Not covered by anything. No signature or commitment covers the file. It stands on the hash it reproduces: a verifier recomputes the record's
termsHashfrom the two documents and reports a mismatch when they disagree, so a file that does not belong to the record cannot verify it.
A verifier reads the file beside the exchange record it is given, when one is there. Each half comes from the first source that supplies it: a terms document the operator names (--config-file for localTerms, --partner-terms for partnerTerms), then, for partnerTerms, the copy a signed receipt named with --signed-record carries, then the file. A file that cannot be read or parsed, or is over the size bound, is reported and supplies neither half. The web app writes no such file.
Where a run's files go
The CLI's OUTPUT_FOLDER positional names the folder a run writes its files in: the result, the record with its keys and agreed-terms file, and the signed receipt, each under the stamped name its section gives. The same rule holds on alcove exchange, the zero-setup form, and an online alcove invite or alcove accept that runs its own exchange.
- A folder, never a file. A path naming an existing file, or anything else that is not a folder, is refused before the partner is contacted, with exit 64: nothing is sent and the file is left as it is.
- Created when missing. A folder that does not exist is created, parents included, before the partner is contacted, and is left in place if the run fails afterwards. A folder that cannot be created, or in which the run cannot create a file, is refused before contact with exit 64.
- Without one. With no
OUTPUT_FOLDERthe result goes to stdout, and the other files go to the working directory under the same names. - No receipt override. No setting names the signed receipt's location; it is written in the folder under its stamped name.
Result file name
The run writes its result in its folder as alcove-results-<time>.csv, a new file each run.
- The stamp's source.
<time>is the record's owncreatedAtwith colons and the fractional-second dot replaced by hyphens -- the stamp inalcove-record-<time>.jsonand inalcove-receipt-<time>.json. A run that built no record takes the stamp from the time its output stage began, as the receipt does. A result thus pairs with its record, keys, terms file and receipt by name, andverify-receipt RECORD INPUT_FILE RESULT_FILEtakes the result whose stamp the record's name has. - No shell feature. The folder is an ordinary argument: the per-run name comes from the CLI, not from command substitution or any other shell feature.
The record's format is unchanged: it does not name the result file.
Re-supplied files from another run
A failed verification is attributed to re-supplied files from another run of the exchange -- most often a result file a later run overwrote -- when all of the following hold:
- the agreed-terms hash re-derived (
verified), the one check that reads no re-supplied file; - every commitment opened, and each either matches or does not match: none is unopened and no salt is missing;
- at least one non-matching commitment is the received-payload commitment (
partnerPayloadReceived) or the pairing commitment (associationTable), the two the result file reproduces; - the recorded result size is not contradicted by the opened pairing.
The CLI then leads its verdict with VERIFICATION FAILED: the result file is not from this exchange record's run and asks for the files from that run; the exit code is that of any failed verification. The attribution is not proof: an altered commitment fails the same way, so the verdict names record alteration as the remaining explanation. A record whose received-payload or pairing commitment was altered, checked against the correct files, cannot be told apart from a later run's result and receives this headline. Any other failure keeps the general headline, as does every failure in a run whose signed receipt (--signed-record) failed a signature check.
No data snapshot in the keys (data minimization)
The verification keys hold only salts. Storing, per commitment, a snapshot of the exact committed data alongside the salt would make verification self-contained at the price of a second copy of the matched data at rest -- a copy an operator would not expect from a tool built for a transient exchange, and outside any accounting of where data is stored. Salts-only keys leave the matched data persisted only in the result file the operator chose to write. The cost is that verification is not self-contained: it must re-supply the committed data (from the holder's own retained input and result) and re-canonicalize it. Because a commitment is HMAC-SHA-256(key = salt, message = canonical({ domain, data })), the re-supply path MUST reproduce the exact RFC 8785 canonical bytes (see CANONICAL_ENCODING.md) the original commit used -- the committed-payload shape (column names and row values) for a payload, or the [our row indices, partner row indices] pair for the association table -- or every opening fails. The committed payload holds no row indices, so it re-supplies from the retained input, config, and result; the association table's partner side re-supplies from the partner row-index column the result file holds for every matched pair. Because the table is committed per pair while the payloads are committed per record, the re-supply is not uniform across the three data sets under a deduplicating cardinality. The table takes one entry per result row, while each payload takes the result's distinct rows on its own side of the pairing: the distinct local rows for the sent payload, the distinct partner row indices for the received one, each in the order that side sent them. Reproducing from those files requires them unmodified: the commitments bind the matched-row order the result was written in, so a re-sorted or re-exported result does not reproduce them (the "hash a file, check it later" constraint any file verifier has). Three reproduction edges are reported as warnings rather than silently mis-reconstructed. An identifier column with duplicate values makes the our-side row index ambiguous when a result row is mapped back to an input row: the first occurrence is used, and every later duplicate then reproduces the first row's values, so the commitments report a mismatch. An input whose identifier names the individual rather than the row has such duplicates by construction, which is the input a deduplicating exchange sets out to group. A result referencing an identifier the supplied input does not hold signals that the input does not match this exchange. And repeated result rows for one partner record whose received values disagree mean the result no longer holds the single row those values were committed as. That last one is a requirement on the re-supply, not merely a diagnostic: taking the distinct partner rows MUST be conditional on every copy of a repeated row agreeing cell for cell, and a disagreeing copy is reproduced beside the first rather than collapsed onto it, so the record fails verification. Which commitment reports the divergence depends on what else the edit touched: with the pairing (partner row-index) columns intact, a disagreeing copy mismatches the received-payload commitment; an edit that also moves a pairing column can reproduce the committed received payload exactly, and there it is the association-table commitment that mismatches instead. Either way the record as a whole does not verify. A collapse that kept the first copy unconditionally would leave every later copy's value cells reproduced by nothing and so bound by no commitment in the record, while the result file writes one such row per pair. A fourth edge cannot be seen during reconstruction, because nothing in the re-supplied files distinguishes it: a result value cell cannot tell a committed empty string from a committed null -- the result writes both as an empty cell -- so a genuinely-null received cell reproduces as an empty string and its commitment reports a mismatch. It is named after the verdict instead: when the received-payload commitment mismatches and the re-supplied received payload has an empty cell, the empty-versus-null difference is reported as a possible cause of that mismatch. Both conditions are required -- with no empty cell that cause is impossible, and with no mismatch there is nothing to explain -- and the verdict itself is untouched, since the record cannot say which of the two values was committed. The sent-payload commitment is not subject to the edge: it re-supplies from the retained input file, which the send side read through the same reader, so an empty cell there was an empty string at commit time and is treated as one again. The commitments' order-dependence could be lifted by committing the pairing as a canonically ordered set -- deferred, since it changes the committed bytes and warrants its own review. That re-supply path is owned by the verification consumer, verify-receipt; this document fixes only the artifact shape it consumes.
A corollary of re-supply: opening a commitment is a data-holder capability. A third party who lacks the holders' retained input and result can still check the signatures and identity bindings on the signed receipt but cannot open the record's commitments itself -- only a party that already holds the data can. The salts-only keys hand no one the data.
Keys written by default (with the record)
Writing the verification keys is default-on: whenever recording is enabled the keys are written alongside the record under the single --no-record control (the web app offers both downloads together the same way), with no separate suppressor for the keys alone. This is a deliberate default, worth recording because the keys are private, non-shareable material whose only consumer is verify-receipt.
The default turns on an asymmetry in recoverability. Each salt is CSPRNG-generated at commit time and stored nowhere but the keys file, so if the keys are not written at the exchange the salts cannot be reconstructed and the commitments can never be opened -- and the exchange generally cannot be re-run to mint fresh ones. An operator cannot know at exchange time whether they will later want to open a commitment; writing the keys at the exchange is the only way to keep that capability open, while not writing them forecloses it irrecoverably. Default-on costs one extra owner-only file the operator can delete at any time; default-off costs a capability that cannot be recovered after the fact.
The keys being cheap and low-risk to retain is what makes that asymmetry decisive. They hold only salts and no matched data (above), so default-on does not persist a second copy of protected data; the record is itself written owner-only and default-on, so a second owner-only, salts-only file is a marginal at-rest increase, not a new category of exposure. The keys' one real risk -- that with the record they open the commitments (the brute-force concern above) -- materializes only if both files leak, and both are 0600; an operator who wants neither uses --no-record, which suppresses the pair. Keeping them under one control also avoids a record whose commitments can never be opened -- a partial state with no use and a trap for the verifier. This default would only need revisiting if the keys came to hold more than salts, or a concrete need emerged to retain the shareable record while withholding its opening material.
One case reaches that unopenable state whatever is written, and the default does not pretend otherwise. A terminated run writes no result file, so its record's commitments have nothing to re-supply from and the keys beside them open nothing. They are written there all the same, on the same single control: the write stays one rule rather than acquiring a condition, and what it costs is an owner-only file of salts the operator can delete. The terminated case supplies no second reason for the default -- the recoverability asymmetry above is about the runs that produce a result -- and it is not a reason against it either.
Privacy properties
Privacy of the record itself: it stores the terms hash, each identity string that was supplied, this party's exposed-record count, the result size (when present), any retention/disposition pointer, the run's outcome and certificateMismatchObserved, and the governance metadata above (the agreement reference, purpose, and expiration, the algorithm, and the data categories) in cleartext. Anyone who reads it therefore learns that an exchange with that partner occurred, under which agreement, over what categories of data, how many records this party exposed, where it filed the result (when a pointer is configured), whether the run ran to completion, whether it observed that the partner's certificate was not the pinned identity, and -- when both sides were entitled to it -- its size. It holds no protected values. The outcome adds one fact about the run's own conduct and none about the data: both of its values describe a disclosure that occurred, and they differ only in whether the run got through the steps after it and left this party a receipt. certificateMismatchObserved adds one more fact of the same kind, about what the run found when it checked the partner's certificate against a fingerprint this party had already pinned: it names no certificate, no fingerprint, and no party the record does not already name, and it discloses nothing about the data or the people in it. The receiptBinder, when present, adds only that the exchange produced a signed receipt and the means to tell whether a given receipt is that run's; it is a one-way value over the session key and discloses nothing about the data (Pairing a receipt to one run). As with the receipt, retention and access control of the record (and the strict protection of the verification keys) are the holder's responsibility.
The agreed-terms file beside the record holds both parties' linkage terms in cleartext: the linkage fields and keys, the payload columns each side stated, the governing agreement, and each supplied identity. It holds no data values. Its custody is the record's: owner-only, in the directory the operator chose for the record.
See also
- PROTOCOL.md - where the record sits in the exchange protocol, and the non-repudiation receipt produced alongside it
- EXCHANGE_REFERENCE.md - the exchange-agreement format that supplies the record's governance fields and the
retention_dispositionpointer - CANONICAL_ENCODING.md - the RFC 8785 byte encoding the commitments and the agreed-terms hash are computed over
- COMPLIANCE.md - how the record supports a HIPAA accounting of disclosures and a FERPA disclosure record