Skip to content

Alcove protocol ​

This document describes the PSI and PSI-C algorithms, how they are composed to produce a privacy-preserving record linkage, and the wire-level P-256 authenticated key exchange. It does not cover the exchange agreement format that parameterizes the protocol (see EXCHANGE_REFERENCE.md), the threat model and authentication design (see SECURITY_DESIGN.md), or the network layer over which the protocol runs (see COMMUNICATION.md). Intended readers are security auditors, external implementors, and developers.

Privacy preserving record linkage ​

The PPRL protocol utilizes a base PSI function to repeatedly reveal the size of the sets of shared statistical linkage keys. This reveals to the parties an association map between their shared members and nothing about elements they do not have in common.

PSI base function ​

The PSI base function is a lightly modified version of OpenMined's PSI. That package implements private set intersection layering over the encryption in Google's Private Join and Compute, whose big-number and elliptic-curve arithmetic comes from BoringSSL. The C++ is built two ways and Alcove ships both: a WebAssembly module, which the browser always uses, and a native N-API addon, which the CLI prefers and falls back from to the WebAssembly build where no prebuild loads. Both link BoringSSL statically. The base function divides the two participants into "server" and a "client" roles. At a high level, the steps of the protocol are:

  1. Both client and server generate their own private keys which live only in memory for the duration of the exchange. Keys are random scalars in the P-256 elliptic curve group, drawn from BoringSSL's random number generator. Where that generator takes its entropy differs by backend: the native addon's BoringSSL reads the operating system directly (getrandom or /dev/urandom on Linux, getentropy on macOS, ProcessPrng on Windows), while in the WebAssembly builds it calls getentropy, which the module imports as WASI random_get and the loader backs with the host CSPRNG -- crypto.randomFillSync under Node, crypto.getRandomValues in the browser and its worker.
  2. The client initializes the exchange by encrypting their own data with their own private key using a commutative encryption algorithm and then sends it to the server.
  3. The server commutatively encrypts both their own data and the client's data with their own private key and then sends both datasets to the client. Single-pass sends both in one reply to the client's request; under cascade and PSI-C the server sends its own encrypted dataset first, before the client's request arrives, and the client's re-encrypted data after it.
  4. The client can then remove their own key from their own data, leaving them with client and server datasets encrypted only by the server.
  5. A straightforward string comparison allows the client to see which elements they have in common. They can then choose to share the association table back with the server.

The terminology of "server" and "client" derives from OpenMined's PSI implementation and is used here to be consistent with their documentation. However, "server" and "client" are disfavored throughout the rest of this project as there are many other instances of servers and clients, and OpenMined's PSI includes no networking coding. Most often we will want to execute the protocol so that both parties learn the outcome. When it is necessary to distinguish between the two roles, we will instead use receiver and sender respectively. Both parties state their declared record counts on the terms exchange that opens every exchange, and the role assignment is a deterministic, work-minimizing function computed locally from those counts -- no separate round-trip. The rule, its optimality for both linkage strategies, and how the counts ride the terms exchange are specified in Role resolution and work minimization below.

Linkage keys ​

Statistical linkage keys are data elements that combine several other inputs into a single value that uniquely represents an individual with an extremely high probability. In this application, the most common data types for linkage keys are social security number, first name, last name, and date of birth. An example linkage key is the last four digits of the social security number concatenated with last name and date-of-birth as a character string.

Linkage keys can be designed to produce links even in the place of data quality errors, for instance by exhaustively generating all transpositions of two digits in a Social Security Number (SSN) or comparing all single-character edits of strings of a fixed length. By repeatedly executing the PSI base function on such keys, two parties execute a fuzzy PPRL. The two examples are not one mechanism: which party expands, and how many candidates the expansion costs, follow from the shape of the expansion rather than from the field it reads. Which party expands specifies both cases.

In order to preserve the guarantee that no information is revealed about individuals not in the intersection, linkage keys must be designed to be precise enough (high positive-predictive value) that any match is definitive.

Matching Algorithms ​

PSI ​

The way in which links are decided depends on whether both parties will receive output. If both do, they can communicate directly and optimize the procedure. For one-to-one mappings, linkage keys are applied in sequence, forming a cascade of deterministic matches: keys are ordered from most to least precise, and at each round only records that match uniquely on that key are accepted as pairs and removed from the candidate set -- the pool of records not yet definitively matched. Inputs without a match, or without a unique linkage key, continue to the next round.

If only one party receives the output, a compromise is necessary. The deterministic cascade above can run with only one party resolving the final association between each party's elements. In that case the other party learns how many records match each linkage key and how many match overall, and learns which of its own records match -- but not the identifiers of the records they match to. Alternatively, the cascade can be enacted entirely by a single party with the other receiving no output at all; then all of both datasets must be processed and sent to the receiver every round, which discloses to that party the existence of matches on less precise keys that the cascade would otherwise have filtered out.

A linkage can also hold several links per record. Which multiplicity an exchange may produce is fixed before the rounds begin by the two parties' deduplicate terms: rather than being asked to describe the multiplicity of the exchange, operators are asked whether they want to deduplicate their data, since they effectively use their partner's data to group their own. The resolved cardinalities, their matching procedures, and the disclosure a deduplicating match adds are specified in Deduplicating cardinalities: many-to-X matching below.

Unlike traditional PPRL, blocking under PSI is neither necessary nor appropriate. The PSI base function's computational complexity is O(n log n) in the total number of elements rather than quadratic in their product, so there is no cross-product comparison to reduce. Blocking would also compromise the privacy guarantee, by revealing to each party how many of the other's records fall into each partition.

The memory ceiling, and the CSV intake cap ​

The practical upper limit on records for browser-based execution is set by available memory, not computation. An encrypted element serializes to 35 bytes on the wire -- a 33-byte compressed curve point plus two bytes of protobuf tag and length framing -- but its resident footprint during the comparison step is larger. For the non-streaming single-pass strategy that resident cost -- not the wire size -- sets the binding dataset ceiling (see The single-pass dataset ceiling below). The only part of the algorithm that requires WebAssembly is the commutative encryption, which can be streamed and parallelized over the data.

Ahead of that algorithmic ceiling, the web app applies a CSV intake cap of 200 MiB (MAX_CSV_FILE_BYTES, apps/web/src/components/csvIntake.ts) -- three to six million identifier rows, depending on row width. This is a per-tab intake memory budget, separate from the comparison-step cost above: the two coexist during an exchange and are not added to one another. Intake's dominant cost is the parsed row array, retained by reference for the whole exchange (it backs rawRows through preparation and the output table). The cap is a chosen budget against that cost, not a protocol or wire-format limit.

The measured intake profile. Driven through the app's own intake path (loadCSVFileOffMainThread, off-main-thread worker included, served by the app's development server) in headless Chromium on a 10-core aarch64 Linux host with 23 GB of memory, over three row shapes handed to a file input the way a dropzone hands one over: a six-column identifier CSV (record id, two names, date of birth, email, ZIP; about 66 bytes a row), a two-column id-and-email CSV (about 33.5 bytes a row, the narrowest shape measured), and a twelve-column CSV of one-to-two-character cells (about 35.6 bytes a row, the most cells per byte measured). Retained is the live main-thread heap the row array holds, read by dropping the reference and collecting; peak and parsed are the renderer process's resident set, sampled every 50 ms, at the parse-time peak and after the parse, above a 135 MB empty-tab baseline.

ShapeInputRowsRetained row arrayPeak residentResident once parsed
six-column100 MiB1.61 M314 MB1.10 GB0.70 GB
six-column200 MiB3.18 M626 MB2.02-2.12 GB1.24-1.38 GB
six-column240 MiB3.81 M746 MB2.62 GB1.68 GB
two-column100 MiB3.14 M287 MB0.95 GB0.61 GB
two-column200 MiB6.22 M560 MB1.95 GB1.21-1.22 GB
two-column240 MiB7.45 M679 MB2.26 GB1.42 GB
twelve short cells100 MiB2.95 M775 MB1.79 GB1.24 GB
twelve short cells200 MiB5.90 M1.54 GB2.96-3.29 GB1.63 GB
twelve short cells240 MiB7.08 M1.86 GB3.66 GB2.74 GB
one short identifier column200 MiB16.13 M928 MB3.12 GB (A) / 2.96 GB (B)2.19 GB (A) / 1.96 GB (B)

The cost is per cell rather than per byte, so row shape moves it by about 2.5x at a fixed file size: the retained rows are 2.7x the file's bytes for the two-column shape, 3.0x for the six-column shape and 7.4x for the short-cell shape, at every size measured, and the parse-time peak runs 9 to 10x the file's bytes for the first two and 14 to 17x for the third. An earlier measurement of the six-column shape on a 16 GB x86-64 host agrees with this one (1.10-1.18 GB peak at 100 MiB, 2.42 GB at 250 MiB). The peak exceeds the retained array because the worker holds its own complete row array while it streams the rows back, so both copies are live at the hand-off; resident stays above retained after the parse because the renderer does not return the freed pages to the operating system. Parse wall time is 3.5 to 12 s at 100 MiB and 6 to 44 s at 200 MiB, the short-cell shape slowest. The renderer's main-thread heap limit on the measured build is 4,395,630,592 bytes (performance.memory.jsHeapSizeLimit, read with --enable-precise-memory-info; without that flag Chromium reports the same limit rounded to 3,760,000,000), so the short-cell shape at the cap holds 35% of that heap in its row array before the exchange allocates anything; that limit, not the machine's memory, is the nearer wall for a wide file of short cells, and retaining only the columns the run reads is the lever that lowers it.

A narrower shape at the cap. A follow-up run measured one short identifier column, 12 characters wide (13 bytes a row), at the 200 MiB cap (16,131,937 rows), on an Apple M1 Max host with 32 GiB of memory and Chromium 151 headless shell -- not this section's host or build. Peak resident minus baseline is measured here against that host's own empty-tab baseline of about 369 MB, not this section's 135 MB baseline. Peak main-thread heap reached 3.0 GB, 68% of that build's 4.40 GB heap limit. On the build measured, the run never reached the comparison step: a pre-send check holding the first round to one frame refused it at preflight, the first round's set at least 540.3 MiB against the 256 MiB WebRTC frame bound, and nothing was sent.

What bounds the cap is the WebRTC frame envelope, for the measured shapes. The 256 MiB envelope (MAX_WEBRTC_FRAME_BYTES) caps a single set frame, and a set of one distinct key value per row reaches it near 245 MiB of the narrowest measured shape, at 35 bytes an element; at 200 MiB that set frame is about 210 MiB. A unit test (apps/web/test/unit/psi/boundedReassembly.test.ts) holds the cap inside that envelope for that shape. A file narrower than any measured -- one short identifier column, or a split_on fan-out of several elements per row -- produces a set over one frame below the cap. The sending party sends such a set in parts where the partner's receive ceiling admits it and refuses it otherwise (The receive ceiling).

A measured run of that shape at the cap reached the frame bound before the tab's heap limit -- peak heap was 68% of the limit at the refusal -- so against a browser partner, whose ceiling of 8,388,608 values is below that shape's set at the cap, that ceiling, not memory, is the wall for that shape. The ceiling was re-measured through the application wrap the web application applies, which allocates transient copies of a frame while it encrypts or decrypts it, in both roles on the x86-64 host the ceiling was measured on, and held: both rounds matched with no heap at its limit (What a browser tab can match, "Rounds at the ceiling through the wrap"). In the development container a partner's set at the ceiling crossed the wrap in both roles, and a same-size round at the ceiling with the browser as the starter completed through it with the bench's inactivity timeout raised above the web application's default; the wrap's copies were ArrayBuffers, which the page's heap limit did not bind in that build (What a browser tab can match, "Rounds at the ceiling through the wrap").

The cap also defines a chain of bounds, each of which moves with it: the WebRTC per-string declared-length cap (MAX_WEBRTC_STRING_BYTES, equal to this constant, held so by a test), the parked-result bound (MAX_PARKED_RESULT_BYTES), the job-intent inputCsv length (MAX_INPUT_CSV_LENGTH) and through it the job body cap (MAX_JOB_BODY_BYTES, twice that length plus 24 MiB).

The sending party sizes its set frames to the envelope. A WebRTC party -- the web app and the CLI alike -- weighs its own set frames against the partner's receive-side bound, so no frame it sends is one the receiver refuses mid-exchange. A cascade or count-only round sends each PSI set -- the setup, the request, or the response that returns the partner's set re-encrypted -- in parts no larger than that bound admits (A PSI set is sent in parts), so a set larger than one message goes in several rather than being refused. Only a transport that states a receive-side bound is sized to it (MessageConnection.outboundWebRtcFrameBound, set by both WebRTC data channels; file-sync states its message-file bound through outboundFileSyncFrameBound instead, below). The part size is derived through one function (webrtcFrameExceedsBound, packages/core/src/connection/webrtcOutboundBound.ts). A browser party's receive ceiling is not derived from the frame bound: it is the measured constant BROWSER_PSI_SET_MAX_ELEMENTS (What a browser tab can match).

The first round is counted at the start. Once the exchange is prepared and before the connection opens, a party on any channel whose terms run the cascade or the count-only algorithm counts the values its first round sends under its own within-round rule: every distinct value when its terms set deduplicate on a cascade, and otherwise only the values exactly one of its records holds (assertFirstRoundWithinSetMaximum, packages/core/src/exchange/firstRoundCapacity.ts); for count-only that is the shipped build's rule, which the PSI-C construction replaces with the record count, as the next paragraph states. It refuses a count over the protocol's per-set maximum, 16,777,216 values (A PSI set is sent in parts). The count is taken in both PSI roles, since which one the party plays is decided later; the party refuses only when a set of the smaller count is over. A party that keeps its duplicates stops counting in a role once its count is over the bound, since that count only grows, and the refusal names the count it stopped at as the least the round sends. The count reports its progress as it goes (a countFirstRoundValues report on the PSI progress callback), so the command-line application and the browser show it while it runs. It refuses on no estimate of a later round: those rounds are bounded only from above, and a worst-case bound would refuse sparse or fan-out keys that fit. A count that fails fails closed: a refusal the round would raise in a role is left to the round when the other role fits, and any other failure -- a resource limit reached while counting -- refuses, with the failure as the refusal's cause. When the other role's count is over the bound, the refusal the round would raise in this role is the one shown; both refuse before any network contact. Once the terms are exchanged the party counts again, in the role it resolved to, against the partner's stated receive ceiling (The receive ceiling).

Under the PSI-C construction every round is counted. In an exchange running the PSI-C construction every round's set has one element for each of the party's declared records, in either role and whatever a key's coverage. Both counts, at the start and against the partner's receive ceiling, therefore weigh the party's declared record count, without reading its records; the count at the start does so only where its caller selects this construction, which no application does while it is not shipped. That figure is the exact size of every round's set, so one check covers every round of the exchange. The refusal states that a count-only exchange sends that many values for every linkage key.

A set of n elements is weighed at 35 * n bytes, the length of the response the library builds for it; the setup and the request add a few bytes of message framing, so no set frame of that count is shorter. Each frame is then taken as the data channel carries it: the BinaryPack byte-array frame around those bytes, split into PeerJS chunks of 16,300 bytes past that length, each chunk charged its slice plus the widest chunk envelope (55 bytes) and at least MIN_CHUNK_RESIDENT_BYTES. That is an upper bound on what either receiver charges -- the web app counts chunk payloads, the CLI whole chunk datagrams -- so a frame the sender admits is one neither refuses. Under it the largest frame admitted is 267,532,686 bytes, the largest set one frame holds 7,643,790 elements, and the most set bytes one part holds 267,532,665 without the application envelope and 267,532,635 with it; unit tests pin each, and the sizing against the real serializer and each receive path.

A single-pass exchange is not checked at the start: its dataset ceiling keeps every frame it sends under the bound, the reply at the ceiling being the largest (see The single-pass dataset ceiling), and a unit test holds that reply under the charged bound too.

On SFTP or a synced folder, a first round over one message file and within the per-set maximum is sent in several files, each part's message file fitting the receiver's bound (FILE_SYNC.md, Round set size limits).

The refusal (RoundSetLimitError, a usage error, CLI exit 64) states the count, the bound, and the remedy: split the input into smaller files and run one exchange for each. Its reason is "over-set-maximum", or "uncounted" where the count failed.

One round's matched records, and the entity closure's records and pairs. A V8 Map or Set holds at most 2^24 entries: on Node 26.10, inserting the 16,777,217th throws RangeError: Map maximum size exceeded (or Set). A cascade round and the many-to-many entity closure therefore keep no Map or Set entry per record or per pair. Each per-record structure is a typed array indexed by a dense ordinal -- a record's rank in the round, a slot, a position -- or a list grouped by one (packages/core/src/psi/int32Groups.ts), so a round holds as many matched records as memory admits:

  • This party's records owning one round's matched positions: their rows, ranks and slots in describeLocalRoundGrouping (packages/core/src/psi/roundGrouping.ts), each record's rank read off the sorted distinct rows.
  • Either party's records matched in one round: the round's incidence, its accepted pairs and the positions each accepted record's pairs rest on, grouped by rank in linkViaPSI (packages/core/src/psi/link.ts), and the sweep's removal set and once-only rule, kept against the sorted distinct receivers in roundCandidatePairSweep (packages/core/src/psi/roundResolution.ts). A party that keeps its duplicates sends one value for a group of its records, so a round whose values fit one set can match more than 2^24 records.
  • The entity closure's records on either side, and its pairs: the disjoint-set forest of entityClusters and the pair coverage of assertRoundDiagonalClosure (packages/core/src/psi/entityClosure.ts), over each party's sorted distinct rows.

The round's deduplication stores its values in shards and holds any number of distinct values (FILE_SYNC.md, Round set size limits), and the lists naming the matched records go in parts, below.

Measured run past 2^24. On a 10-core aarch64 Linux container with 23 GB of memory, Node 26.10, heap raised to 16 GiB, each structure driven to 2^24 + 1024 entries and to a 50-million-record input completes. Elapsed time and the process's peak resident set, input construction included:

Structure driven2^24 + 102450 million
describeLocalRoundGrouping, called directly3 s, 0.6 GB11 s, 1.5 GB
one many-to-one round, this party's records30 s, 3.8 GBnot run
one one-to-many round, the partner's records34 s, 3.6 GBnot run
entityClusters, one pair per record17 s, 4.4 GBnot run
assertRoundDiagonalClosure, two records a side per cluster17 s, 1.6 GB57 s, 4.3 GB

The cases not run at 50 million need about 11 to 13 GB by their figures at 2^24, more than the measuring host had free; the stress test skips a case when the host's free memory is under its need. packages/core/test/stress/perRowMaps.stress.test.ts holds every case in the opt-in stress tier (npm run test:stress -w packages/core), each run in its own process.

The lists of matched records go in parts. The cascade sends each list naming matched records -- each round's association table and original-index list, each party's mapped-element list, the list it returns with the partner's rows, and the payload rows -- in as many parts as the list needs (A list of matched records is sent in parts). One JSON message holds at most MAX_JSON_ARRAY_ELEMENTS = 16,777,216 entries in any array (packages/core/src/utils/boundedJson.ts), and on SFTP or a synced folder one message file at most MAX_FRAME_SIZE_BYTES = 536,870,888 bytes (PARSED_INPUT_BOUNDS.md, Application-layer parsed-input bounds). On Node 26.10 that byte figure is V8's longest string (buffer.constants.MAX_STRING_LENGTH, and "a".repeat throws Invalid string length one past it), so no one message could hold a longer list. Each part is held to both bounds, so neither bounds a list.

Measured run of the lists past both bounds. On 2026-10-03, in a 10-core aarch64 Linux container with 23 GB of memory, Node 26.10, heap raised to 16 GiB, with another memory measurement running beside it, each list of 2^24 + 1024 entries crossed file-sync in parts between two parties in one process, each party on a FileSyncConnection over one in-memory directory under the channel's AEAD envelope. Every message file was within MAX_FRAME_SIZE_BYTES, the largest at 536,870,873 bytes. Elapsed time and the process's peak resident set, both parties and input construction included:

Lists exchangedParts sentTimePeak RSS
both mapped-element passes, one-position entries, each way8, two per list76 s8.3 GiB
one payload of one 24-character column, and the partner's empty one332 s6.2 GiB

packages/core/test/stress/matchedListParts.stress.test.ts holds both cases in the opt-in stress tier (npm run test:stress -w packages/core). The run measures the lists' crossing, not a PSI round: a cascade matching that many records also runs the rounds measured above.

Measured whole exchange past 2^24 matched records. On 2026-10-04 two command-line parties ran one file-sync exchange over a local synced folder on one host (Apple M1 Max, 10 CPUs, 32 GiB, macOS, Node v26.8.2), each as a plain node process with its heap raised. The shape is many-to-one, so one PSI round of 2,097,408 values a side (2^21 + 256) matches past 2^24 records: Party A, with --deduplicate, held 16,779,264 rows (2^24 + 2048), eight records for each of 2,097,408 values; Party B held 2,097,408 rows, one per value. Every A record matched on the first key, and both parties wrote the correct result of 16,779,264 pairs. Wall time and maximum resident set size, each party a whole process:

PartyInput rowsPSI roleWall timeMaximum resident set
A, deduplicating16,779,264sender395 s16.04 GB
B2,097,408receiver380 s7.53 GB

A second run of the same shape and commands, on 2026-10-05 UTC, used an x86 host: Intel Core i3-14100, 6 CPUs pinned, a 29,696 MiB container, Linux 7.0.14-14-pve, Node v26.8.2, both parties on one host over a local synced folder. Both parties exited 0, both wrote the correct result of 16,779,264 pairs, and both wrote their exchange record:

PartyInput rowsWall timeMaximum resident setverify-receipt on the record
A, deduplicating16,779,264494.8 s16.54 GBexit 69, Map maximum size exceeded, no verdict
B2,097,408477.6 s7.87 GBVERIFIED

The verifier indexes the input's identifier column in shards of 2^24 entries, as the round's deduplication does (FILE_SYNC.md, Round set size limits), and the core verification completes at this size with the identifier index in shards of 2^24: packages/core/test/stress/verifyReceiptLarge.stress.test.ts pins it, verifying a record over an input of 2^24 + 2048 rows of this shape, through an identifier column and through row indices. The command-line verify-receipt over a 16,779,264-row input of this shape verifies in 44.40 s at 11.68 GB resident (party A) with NODE_OPTIONS=--max-old-space-size=19075, and at Node's default heap ends in JavaScript heap out of memory (exit 134) with no verdict; the command refuses an input its heap cannot hold with exit 64 before it parses, naming the NODE_OPTIONS setting to raise.

The exchange record and receipt at that size, 2^24 + 2048 pairs of this shape, are built and verified by packages/core/test/stress/canonicalLargeArray.stress.test.ts in the opt-in stress tier.

The record and receipt past V8's longest string. The record's commitments and the receipt's payload MACs are HMAC-SHA-256 over the canonical encoding, written in chunks into one byte array and passed to one crypto.subtle call (CANONICAL_ENCODING.md, Implementation note), so the encoding is not bounded by V8's longest string, 536,870,888 characters on Node 26.10. Measured on 2026-10-08 in a 10-core aarch64 Linux container with 23 GB of memory, Node 26.10, heap raised to 14 GiB. Each row is one process building a record over a sent payload of one 30-character column, an empty received payload, and an association table of as many pairs as rows, then both parties' receipt content, then verifying the record's commitments:

Sent payload rowsCommitment encodingOne string?ResultTimePeak RSS
15,339,166536,870,887 bytesyes; one row more is refusedrecord verifies; commitments and MAC equal the one-shot values248 s8.2 GiB
16,779,264 (2^24 + 2048)587,274,317 bytesrefusedrecord verifies; both receipt contents equal189 s7.2 GiB

Measured alone over the second row's built payload, the sent-payload commitment raises peak RSS by about 1.9 GiB: the byte array, grown by doubling, the encoding's transient strings, and the copy crypto.subtle makes of its input. Each encoding runs without yielding to the event loop, so that commitment holds the thread it runs on, a browser page's included, for about 32 s.

packages/core/test/stress/recordPastStringCap.stress.test.ts holds both cases in the opt-in stress tier.

A browser file larger than CSV_WORKER_FILE_BYTE_THRESHOLD (4 MiB, apps/web/src/psi/workers/csvParseController.ts) is parsed off the main thread in a Web Worker the app owns (apps/web/src/psi/workers/csvParse.worker.ts, constructed via new Worker(new URL(..., import.meta.url), { type: "module" })). So the parse itself -- the dominant CPU: tokenizing, splitting, and building the row objects -- runs off the main thread and the tab stays interactive while a large intake parses. The worker does not post the whole parsed row array back in one message: it streams the rows back in batches, so the main thread deserializes the reply in many small, interruptible steps rather than one uninterruptible structured-clone of the whole array. This does not lower the total receive-time cost, only spreads it across many small stalls, so the tab stays responsive even receiving a near-cap intake and the intake stall (the parse-time freeze and the receive-time deserialization alike) is gone rather than merely shortened. The worker derives the batch size from a target source-byte budget per message, so each batch is a bounded slice of the file regardless of row width; the granularity has no protocol or wire-format consequence -- the reassembled data and meta.fields are identical to a single post however the rows are split. A smaller file -- and the CLI's Node stream, which is not worker-cloneable -- parses inline, where the worker's spawn and structured-clone hand-off would cost more than they save. The worker wraps core's loadCSVFile rather than reimplementing the parse, so the non-string-header guard and the data/meta.fields contract hold identically whichever thread runs it. The parse is accumulated across PapaParse chunks either way, so a file spanning more than one Papa.LocalChunkSize chunk yields its complete row set rather than the silently truncated final chunk (which, on a record-linkage input, would be a wrong intersection with no error). PapaParse's own worker: true is not used: it spawns its worker by reading the running script's URL, which a Vite production build breaks (the bundled worker mis-applies header: true, leaking the first data row into the header). A worker Vite bundles and serves as its own asset survives that build; one PapaParse constructs from the running script's URL does not -- so the off-main-thread parse is restored through the former, and the threshold is a tunable UX budget, not a protocol or wire-format limit. The resulting payload then rides the WebRTC data channel under PeerJS's own message chunking, which already fragments and reassembles payloads far larger than a single frame at any cap in this range -- so the cap has no transport or wire-format consequence.

The memory a command-line party's PSI round needs, and the heap limit it runs under, are in FILE_SYNC.md, Memory a PSI round needs.

Linkage strategies: cascade and single-pass ​

The cascade above can be realized on the wire in two ways, chosen by the mandatory-consistency linkage_terms.linkage_strategy term (both parties must agree, like algorithm; the schema default is cascade). Both produce the byte-identical association table; they differ only in how the per-key PSI exchanges are sequenced, and in what one party discloses to the other.

cascade runs one dependent PSI base-function exchange per linkage key, in sequence: each round's candidate set is the records not yet matched by an earlier key, so a round cannot begin until the previous round's matches are known. The number of network round trips therefore grows with the number of keys. This is the established default, and the strategy the disclosure description above assumes. The withholding below is single-pass only -- the cascade has no message 3 to suppress -- but the payload-disclosure refusal it specifies is resolved for every strategy, so a receiver declaring payload.receive: [] against a sender whose process discloses a column refuses a cascade run too, before its first round. See Withholding the sender's table from a blind helper below.

single-pass batches every key into one PSI exchange and has the receiver reconstruct the cascade locally, so the round-trip count does not grow with the number of keys -- which is what makes a multi-key linkage practical over a high-latency channel (file-sync), where a chain of dependent rounds is prohibitively slow. The receiver is the PSI base function client; the sender is the server. Each party first forms the union of its distinct linkage-key values across all keys and encrypts each distinct value once. Because the reconstruction only ever compares values within a single key's round, a value appearing under more than one key maps to the same encrypted form with no ill effect, and values are not tagged by which key they came from (no per-key domain separation). This would only need revisiting if the reconstruction were changed to compare a single encrypted value across keys.

Both strategies match a per-record candidate set -- a split_on fan-out, a generate_fuzzy_comparisons expansion, or a key declaring swap -- by different routes to the one table: the single-pass receiver resolves it alone from the index table it already holds, and a cascade round resolves it on both parties from the grouping its two frames hold (Per-round candidacy under cascade). Terms naming a strategy whose resolution is unwritten are refused at validation rather than narrowed to a single candidate or promoted to the other strategy (see Fan-out runs under both linkage strategies).

The exchange is at most three frames, one per message, regardless of key count -- message 3 is suppressed entirely in the one blindness case below, leaving two. Message 2 packs everything the sender owes into a single binary frame: on a high-latency channel each frame costs a round trip (file-sync gates each send on the peer consuming the previous one), so combining the sender's reply into one frame holds the whole exchange at three round trips rather than one per piece. The transport holds one binary blob or one JSON object per frame, never the two mixed, so the record count is packed into the binary frame rather than sent as its own JSON frame.

  1. receiver -> sender -- the receiver's PSI request over its distinct-value union (encrypted once under the receiver's key).
  2. sender -> receiver -- a single binary frame packing four parts in order (all integers little-endian):
    • (a) a uint32 setup length, then the setup message -- the sender's distinct-value union, each value encrypted once under the sender's key;
    • (b) a uint32 response length, then the response -- the receiver's request re-encrypted under the sender's key;
    • (c) a uint32 containing the sender's row count, the number of rows its table covers (below the record count it declared on the terms exchange when its own cleaning fans out; see Fan-out matching);
    • (d) the distinct-value index table, the remaining bytes: an Int32 array in key-major order (one row per key) of length keyCount * senderRecordCount. Each entry, for a (key, record), is the index of the value that record holds for that key -- an index into the sender's distinct values -- or -1 if the record has no value for that key. The indices are in the setup message's sorted order (see the remapping below). That is the layout for a sender whose records hold at most one value per key; a sender that declares a fan-out ships the same Int32 array in the ragged, count-prefixed layout specified in Fan-out matching, which is the only part of any single-pass frame fan-out changes.
  3. receiver -> sender -- the resolved association table for the sender's side. This frame is conditional: it is suppressed entirely when the sender is a non-receiving helper (output.expects_output: false) that also discloses no payload, so a blind helper never receives -- and so never learns -- its own membership. See Withholding the sender's table from a blind helper below.

The sender remaps its distinct-value index table into the setup message's sorted order before sending, so the sorting permutation the setup holds internally never crosses the wire and the receiver's match result indexes the table directly. From the setup and response the receiver works out which of its values equal which of the sender's. It then replays the cascade locally -- over the sender's index table and an equivalent table it builds from its own records, applying the same within-round uniqueness rule and key order the step-by-step rounds use -- to produce the same matched pairs, and returns the sender's half in message 3.

Withholding the sender's table from a blind helper. Message 3 exists so the sender learns its half of the association table -- which of its records matched. A non-receiving helper (output.expects_output: false) needs that half only to build the payload it discloses for the overlap (preparePayload reads its matched rows); a helper that discloses no payload needs nothing back at all, so returning it would leak the helper's own membership -- which of its records the receiver also holds -- for no purpose. In exactly that case, a non-receiving helper disclosing no payload, the receiver suppresses message 3 entirely and the sender skips awaiting it, so the helper's process never receives the table and stays blind. The frame is omitted, never sent empty: an empty-versus-populated table would leak the match count by the frame's presence and size, so only suppressing it closes the channel.

Both parties derive the decision from the same authenticated session state -- the resolved sender's output entitlement, and its payload disclosure as the agreed terms and the sender's own advertisement together give it. Payload disclosure is per-party-local and lazy, so the receiver cannot read it off the terms alone and takes the sender's payload-intent flag disclosesPayload, held on the terms-exchange envelope beside the record count. That flag is the sender's own assertion and rides no agreed-terms hash, so it can only ADD disclosure to what the terms declare, in three cases. A payload.send the agreed terms declare present and empty binds the sender to disclosing no column, and the receiver withholds the half whatever the envelope advertises -- so a partner running modified software that agrees on withheld-shaped terms and then advertises the flag true is not sent its half by a conforming client. A payload.receive the receiver declares present and empty, with no payload.send declaration on the sender's own document to hold it to, contradicts an advertised disclosure rather than adding to it: both parties refuse the exchange there, before the linkage round, rather than running it with the sender's payload withheld. A conforming sender states its payload.send from its metadata at the terms exchange (EXCHANGE_FILE.md), so against one the terms comparison refuses that pair first; this refusal binds a sender whose terms state no send set. Each party resolves both directions of the exchange from the two agreed documents and both parties' asserted disclosure, so that refusal binds whichever PSI seat the declared record counts give either of them; only which direction the withhold gate reads follows the seat. Where neither direction is declared present and empty, the flag decides. Ahead of all three, a direction whose receiving party declares output.expects_output: false resolves to no disclosure and is never refused: the payload send gate transmits only to a partner entitled to the result, so no column moves that way for a declaration to contradict.

Both parties read the same two agreed documents, and a conforming sender advertises exactly what its own metadata discloses, so against such a partner the receiver's suppression, the sender's skip and either party's refusal agree, and neither side blocks awaiting a frame the other will not send. The two roles do not read the same assertion, though: the sender resolves from its own metadata and the receiver from the advertised flag.

A partner whose advertisement disagrees with its own metadata. Two outcomes, and only the first is closed:

  • Where the receiving side declares payload.receive present and empty, one side refuses alone -- the side reading the assertion that contradicts it -- and sends an abort frame before its refusal propagates, which ends the other side's wait instead of leaving it to the peer-inactivity budget and ends that side's run naming the partner's termination (An abort arriving where a round awaits binary).
  • Where the receiving side declares no payload.receive at all, a sender advertising the flag false while its metadata discloses a column makes neither side refuse: the receiver suppresses message 3 and the sender waits for it until the peer-inactivity budget runs out. It takes a modified partner, and it is a stated limit rather than a closed case (withholdsSenderAssociationTable, packages/core/src/psi/link.ts).

A helper that does disclose payload still receives the full table (it reads its matched rows to enrich the overlap -- an intrinsic membership disclosure the honest-but-curious model accepts), and any party entitled to output receives it as before. The full table is delivered to any payload-disclosing helper rather than trimmed to the sender's own row numbers, since the receiver's opaque row numbers are the least-sensitive layer and a helper that already learns its membership gains little from withholding them. This realizes, for the one closeable case, the send-everything pole's ideal that a no-payload helper stays blind; the membership disclosure remains intrinsic and accepted for the cascade and for a payload-disclosing helper (see docs/notes/one-sided-disclosure.md).

Why the full index table, and the disclosure it implies. The index table is transmitted in full -- every record, not a pre-filtered set of within-key-unique values -- because whether a value is unique depends on which records earlier keys already matched. A value shared by two records over the whole dataset can become unique once an earlier, more precise key matches one of them, so the receiver needs the complete per-record structure to replay the key order. This is the key disclosure difference between the two strategies. Single-pass hands the receiver the sender's full per-key duplicate structure and complete (encrypted) value set, so the receiver's reconstruction observes matches on less precise keys that the lockstep cascade would have filtered out before they were ever exchanged -- the same disclosure as the single-party variant above ("all of both datasets must be processed and sent to the receiver"). Only the cascade-equivalent table is emitted as the result; the weaker-key matches are visible to the receiver's process but never reported. Single-pass therefore trades this additional disclosure -- plus the extra bandwidth of the full index table and the extra encryption work, since it cannot skip records an earlier round would have removed -- for the constant round-trip count. Because the term is mandatory-consistency (above), that trade is one both parties agree to before any data is exchanged.

The frame ceiling. Single-pass cannot stream, and its single frame imposes a much tighter dataset ceiling than the streaming cascade. The cascade processes one key per round, so its working set is a single key's column at a time; single-pass holds every key's values in memory at once and ships message 2 as one frame -- the encrypted setup and response plus the index table's keyCount * senderRecordCount * 4 bytes. The frame travels as raw bytes (the AEAD and file-sync envelopes do not base64-expand it), so the on-wire frame is essentially its raw size plus a small fixed envelope. That frame is not bounded by the static transport cap: the single-pass reply is bounded by a per-exchange cap derived from the exchanged record counts and the agreed key count, identical on both parties, up to a fixed maximum dataset size -- specified, with its two enforcement points, in The single-pass dataset ceiling below.

What dominates. The dominant size term is the encrypted sets, not the index table: with D the distinct key-values pooled across all keys, the reply is about 35 * (D_sender + D_receiver) + 4 * keyCount * senderRecordCount bytes, each distinct key-value costing the 35 bytes given above against 4 bytes per (key, record) cell. What actually binds a single-pass exchange, and the cap derived for it, are in The single-pass dataset ceiling below.

Exceeding the ceiling aborts cleanly. The abort mechanics are specified under The single-pass dataset ceiling below. The pre-flight gate (prepareForExchange) is a coarse one-party shadow of that check, run locally from this party's own row count alone, before any credential, terms, or data are sent; the authoritative two-party check runs in linkViaSinglePassPSI once both counts are exchanged. Choose single-pass when the round-trip saving outweighs the ceiling and the additional disclosure; otherwise cascade.

The reference implementation is linkViaSinglePassPSI (packages/core/src/psi/link.ts); the four PSI base-function operations it composes are on PSIParticipant (packages/core/src/psi/participant.ts).

Partner-supplied index tables are checked against local state ​

Both strategies exchange association tables, and the cascade additionally exchanges mapped-element lists. Every index one of those contains addresses a position the receiving party owns or counts: its own rows, the per-round candidate set it just encrypted, the partner's rows as counted on the terms exchange, or the partner's masked set for a round, whose element bound is derived from the agreed key count and those same record counts. Each such list is validated before it drives the match set, the payload columns this party discloses, or its self-attested record. The checks:

  • the two halves of an association table must contain the same number of entries;
  • every entry must be a whole number within the range its side authenticates -- this party's own row count, the size of the set it encrypted for that round, the element bound derived for the partner's masked set, or the partner's row count held on the terms exchange. No entry may repeat, which is what caps a list's length at the count of positions it can address.
  • the partner's mapped-element list -- the cascade's inbound list naming which of this party's records the partner matched -- must name exactly this party's own matched records, one entry each, on the round it matched them. The exchange is symmetric, so a round pairs the same number of records on both parties and pairs this party's record only if the partner's corresponding record names it in that same round.
  • the returned mapped-element list -- this party's own list handed back with each entry translated into the partner's row space -- must contain one entry per record this party matched, each a distinct row within the partner's counted rows. The round each entry names is not re-checked on the way back, since only the translated row index is read from it. Where a cascade round resolved a candidate set, this rule and the one above are read over that round's accepted pairs, which both parties compute from the groupings its frames held (Per-round candidacy under cascade), except where the partner deduplicates and omitted its own grouping, where an entry names each of this party's widened records by one fixed position (Deriving one table from the exchanged association maps);
  • at the single-pass boundary where the sender receives the resolved association table, the local half must additionally arrive in ascending order. The cascade produces that order structurally and the receiver sorts this table into it, and the result rows and the exchange record's reconstruction of them read the table in it, so it is enforced at this boundary (linkViaSinglePassPSI in packages/core/src/psi/link.ts, through the ascending rule in packages/core/src/utils/partnerIndices.ts) rather than assumed. Under a deduplicating cardinality one of the two halves repeats and the other anchors the length; the rule is in Deriving one table from the exchanged association maps.

Every bound is derived from this party's own data or from a count held on the authenticated terms exchange, never from the frame being checked. A list failing any check is a protocol violation: the exchange aborts with a classified protocol error and produces no result, rather than continuing on an index that addresses nothing or a record set the exchange did not produce. The reference implementation is packages/core/src/utils/partnerIndices.ts and its call sites in link.ts and participant.ts.

An abort arriving where a round awaits binary ​

The rounds read raw byte frames: the masked setup, the request, the doubly-masked response -- in a cascade or count-only round, each part of them -- and the single-pass reply. A refusal that fires past the terms exchange and sends an abort is one-sided, so that abort reaches a partner deriving no refusal of its own, already parked on one of those frames. Each of these boundaries therefore classifies what arrives before a byte reaches the PSI library (receivePsiBinaryFrame, packages/core/src/psi/psiBinaryFrame.ts):

  • A partner's abort ends that run as a peer termination, naming the partner as the party that ended the exchange and holds the reason. The abort's reasons are partner-written text and are not read, so the refusing party's frame reaches no display.
  • Any other frame that is not binary is a classified protocol error naming the frame the round awaited. A peer that sends the wrong frame has refused nothing and is not reported as having refused.
  • A byte frame the PSI library rejects, in its deserialization or in the operation over it, is labeled a decode failure naming the frame and keeps the library's own message as its cause. The engine tags each failure the library raises over a partner's frame (markPsiLibraryFailure, packages/core/src/errors.ts), and this label is applied to tagged failures alone.
  • Every other failure ends the run on its own message: a refusal the engine names past the deserialization (the setup that holds no Raw data structure, and a request whose reveal flag disagrees with this party's mode, PSI-C, above), an engine precondition the calling party broke, a fault in the PSI worker the engine runs behind (a disposed engine, a caller breaking the worker's lockstep rule, a worker that crashed or exited under the call, a browser pool worker that made no progress for the pool's timeout), and a step stopped because the connection to the partner ended, which ends the run on that connection's error. None of these came from the partner's frame, so "failed to decode" would send the operator to the wrong party.

So no arrival at these boundaries ends a run on the PSI library's own decode message, which names nothing either operator can act on, and none is labeled a decode failure that was not one.

The same abort can reach a partner parked on a receive that awaits a JSON frame rather than binary: the zero-setup bootstrap frame, the count report, a cascade round's completion acknowledgement, the resolved association table, and the receipt swap. Each tests the arriving frame for an abort before its strict parse (receiveAfterTerms, packages/core/src/partnerAbortFrame.ts), so an abort there too ends the run as the peer termination it is rather than as a frame that failed schema validation.

The abort discriminant is unauthenticated in its content. The classifier recognizes the abort by shape alone -- a frame whose decision is abort -- and nothing in the frame binds it to a refusal the sender actually derived, so a partner that has refused nothing still ends this side's run as a definitive peer termination, holding back the retry advisory and this side's own abort. That is a stated limit rather than a closed case: past the key exchange the frame can only come from the partner this party mutually authenticated, and a partner ending an exchange it has no reason to end is a counterparty acting against the terms it is contractually bound by, not an attacker this protocol undertakes to stop.

A PSI set is sent in parts ​

A cascade or count-only round sends each of its PSI sets -- the setup, the request, and the response -- as one or more parts, each its own binary frame (sendPsiSet and receivePsiSet, packages/core/src/psi/psiSetParts.ts). The receiver joins a request's or a response's parts before the element scan and the decode, which see the whole set as they would a set sent in one frame. It does not join a setup's parts: it checks each part's header, scans the part, and hands it to the engine's streamed match as it arrives (receivePsiSetInPieces), and the scan, carried from part to part, counts and refuses as it would over the joined set (The single-pass dataset ceiling, the streamed match). The single-pass messages are not split; the single-pass dataset ceiling below keeps each within one frame.

The part header. Every part, the only part of a set that fits one frame included, begins with a 16-byte header, big-endian:

BytesField
0-3the part's index, from 0 (uint32)
4-7the set's part count, at least 1 (uint32)
8-15the set's byte length (uint64)

The rest of the part is the next slice of the set's bytes, in order. An empty set is one part of header alone. Known-answer vectors: packages/core/test/vectors/psi-set-part-vectors.json, replayed by packages/core/test/psi/psiSetPartVectors.test.ts.

The part size. A sender fills each part to the partner's per-frame receive bound, less the header and the channel's envelope: on WebRTC the data-channel bound, weighed as above (267,532,635 set bytes at MAX_WEBRTC_FRAME_BYTES with the application envelope); on SFTP or a synced folder the message-file bound (536,870,832 set bytes at MAX_FRAME_SIZE_BYTES inside the AEAD envelope). One function chooses it (psiSetPartPayloadBytes). A receiver requires no part size: each part is held to that receiver's own per-frame bound at its transport, and the set to the combined bound below.

The combined bound. A setup or a request may declare at most min(n, MAX_PSI_DECODE_ELEMENTS) * 35 + 6 bytes (psiSetByteBound). n is the authenticated element bound for the set's kind (psiElementBounds: the sender's value slots for a setup, the receiver's for a request), 35 is PSI_ENCODED_ELEMENT_BYTES and 6 is PSI_SET_MAX_FRAMING_BYTES, so no legitimate set of at most n elements is longer. At MAX_PSI_DECODE_ELEMENTS the bound is 587,202,566 bytes: two message files, or three WebRTC messages. A response may declare at most the byte length of the request this party sent, which it re-encrypts element for element without the request's framing.

The per-set maximum. MAX_PSI_DECODE_ELEMENTS (packages/core/src/connection/frameSize.ts) is 2^24 = 16,777,216, the protocol's per-set element maximum. It is the per-side target the command-line application's heap ceiling is sized to (FILE_SYNC.md, The budget), and a unit test holds that ceiling at or above the memory a round at it needs. A set is joined from parts, so no frame size bounds it. At the measured 211 bytes the PSI library allocates per declared element while deserializing, a request at the maximum costs about 3.3 GiB to decode; the engine's streamed match decodes neither a setup nor a response, and keeps a fixed slot per setup element instead.

The sender's own bound. A round refuses a set of this party's own with more than MAX_PSI_DECODE_ELEMENTS values, or more than the partner's stated receive ceiling (The receive ceiling), before building it, as a RoundSetLimitError naming the count, the bound and the split-input remedy, with reason "over-set-maximum" or "over-partner-ceiling", and sends the partner the abort reason "a PSI set is too large to send" in its place (PSIParticipant, packages/core/src/psi/participant.ts), so a sender never sends a set the combined bound or the partner's ceiling refuses.

What the receiver refuses, before any decode. Each refusal is a ProtocolRefusalError naming the set and the condition, and no part it refuses, nor any after it, reaches the engine:

  • On the first part, a declared byte length over the combined bound, checked before the set's buffer is allocated; and a part count of 0, or above the byte length (above 1 for an empty set).
  • A part shorter than its header.
  • A part whose index is below the one expected (a repeated part) or above it (a missing part).
  • On a later part, a part count or byte length that differs from the first part's.
  • A part whose slice runs past the declared length, and a last part that leaves the set short of it.
  • A part with no set bytes, unless it is the only part of an empty set, so every part before the last holds payload and a partner cannot keep the receiver reading empty parts.

An abort frame in place of any part ends the round as a peer termination, and any other non-binary frame is a protocol error, as above. The part header is a wire-format delta within PROTOCOL_VERSION 1, as Wire-format deltas states for pre-publication deltas; its vectors are among those check:protocol-version-bump pins.

A list of matched records is sent in parts ​

A cascade sends each list naming matched records -- the two index lists of each round's association table, each round's original-index list, the grouping sent after each of those two frames (The per-round grouping the two frames hold), each party's mapped-element list, the list it returns with the partner's rows (Deriving one table from the exchanged association maps), and its payload rows (Output) -- as one or more parts, each its own binary frame whose body is a JSON message of its own (sendMatchedList and receiveMatchedListParts, packages/core/src/psi/matchedListParts.ts). The receiver parses each part as it arrives and joins the parts in order; a list of one part holds the message the list is.

The part header. Every part, the only part of a list included, begins with a 16-byte header, big-endian, laid out as a PSI set part's is (A PSI set is sent in parts) but counting entries rather than bytes:

BytesField
0-3the part's index, from 0 (uint32)
4-7the list's part count, at least 1 (uint32)
8-15the list's entry count (uint64)

The rest of the part is its body, UTF-8 JSON. Known-answer vectors: packages/core/test/vectors/matched-list-part-vectors.json, replayed by packages/core/test/psi/matchedListPartVectors.test.ts.

The part body. A mapped-element list's part is a JSON array of the part's entries, in list order; each array element is one entry. A payload's part is a payload message (hasData, columns, rowIndices, rows) holding the part's rows and their row indices, every part naming the same columns; each row is one entry. A payload with no data ({"hasData": false}) is one part of no entries, and an empty mapped-element list one part holding []. A part of a round's index list is a JSON array of the part's positions, and a part of a grouping a JSON array of the part's run lengths or owner lists. A grouping the sender omits is one part of no entries holding null, and an empty grouping one part holding [] (packages/core/src/psi/roundTableParts.ts).

The part size. A sender fills each part's body to the bytes one part of a PSI set holds on the same connection (psiSetPartPayloadBytes): on SFTP or a synced folder, a message file of MAX_FRAME_SIZE_BYTES inside the AEAD envelope; on WebRTC, the data-channel bound (The part size). It cuts the list before the entry that would take the body past that, or past MAX_JSON_ARRAY_ELEMENTS entries, measuring each entry as its UTF-8 JSON with its separator. A receiver requires no part size: each part's frame is held to that receiver's own per-frame bound at its transport, each body to the JSON structure bounds, and a part of a mapped-element list, of a round's index list or of its grouping to the byte bounds below.

The entries a receiver admits. The first part's declared entry count is held, before any part's body is parsed, to a bound read from the agreed terms and this party's own result:

  • the partner's mapped-element list: the record count the partner declared on the terms exchange, one entry per record it accepted;
  • the returned list: the count this party already holds for it (Deriving one table from the exchanged association maps), one entry per record it matched, or the rows its own list named where the partner keeps its duplicates;
  • the partner's payload: the pairs in this party's matched table, the partner sending one row per distinct record of its own the table pairs; 0 for a party holding no table, a count-only run's;
  • the first index list of a round's association table, the partner's own positions: the elements this party encrypted for the round, its own half naming each at most once; the second, this party's positions: the first list's entry count;
  • a round's original-index list: the entries of the association table this party sent for the round;
  • a round's grouping: the entries of the index list it follows.

The bytes a receiver admits. A part of either mapped-element list -- the partner's own, and the one it returns -- is held to a byte bound (matchedListPartByteBound, packages/core/src/psi/matchedListParts.ts): the 16-byte header, 2 bytes for the array's brackets, and one longest entry with its separator for each entry the list still has to send. That count is the declared entry count less the entries the earlier parts held, at most MAX_JSON_ARRAY_ELEMENTS. Limit: the declared count comes from the first part's own header, so a partner that declares more entries than it sends raises that part's bound up to the count this party agreed to accept, and such a part is parsed before it is refused as ending short; the bound for the whole list never exceeds what this party's own state allows. Limit: the bound applies once the transport has read the part, so an oversized part is read whole, up to the transport's message cap (MAX_FRAME_SIZE_BYTES, 536,870,888 bytes, on file sync and SFTP; MAX_WEBRTC_FRAME_BYTES, 268,435,456 bytes, on WebRTC), before it is refused (TRANSPORT_BOUNDS.md, Matched-record lists in parts). The longest entry is {"theirIndex":...,"iteration":...} as JSON.stringify writes it, with no whitespace between tokens and each number at the digits of the largest value an honest party sends there (mappedElementEntryBytes, packages/core/src/psi/link.ts):

  • the partner's mapped-element list: a list of as many positions as one of the partner's records may own in the round (the width the agreed terms declare for the key, times 20), each below the count of this party's own candidate positions in that round, taking the round that gives the longest entry;
  • the returned list: one row below the record count the partner declared on the terms exchange;
  • in both, a round below the number of linkage keys.

A part of a round's index list is held to the same bound, its longest entry the digits of the largest position the list may name, with its separator (roundIndexEntryBytes, packages/core/src/psi/roundTableParts.ts): below the elements this party encrypted for the round, for its own half of the association table; below the element bound of the partner's set for the round (psiElementBounds), for the partner's half and the original-index list.

A part of a round's grouping is held to a byte bound over the whole list instead, since one owner list may hold an ordinal for every record the partner declared (roundGroupingBound, packages/core/src/psi/roundTableParts.ts). A part may hold what the earlier parts, each counted whole with its 16-byte header, left of the list's total. The total counts each entry at the digits of the largest value it may hold, read from the round's matched positions (the entries of the index list the grouping follows), the record count the partner declared on the terms exchange, and the candidate count one record may contribute to the key (The checks stay local):

  • run lengths: one entry per record, at most the lesser of the matched positions and the record count, each at most the lesser of that candidate count and the matched positions, with its separator;
  • owner lists: one entry per matched position, with its brackets and separator, and its ordinals, each below the record count and with its separator, at most the record count times the lesser of that candidate count and the matched positions in all.

To the entries the total adds 18 bytes, a part's header and the array's brackets, for each part the list declares at its first part, a count the receiver holds to one per entry. The total is at least 20 bytes, the one part of an omitted grouping: its header and null.

A round's grouping is held to the transport's frame bound (statedFrameBound, packages/core/src/psi/psiSetParts.ts: MAX_WEBRTC_FRAME_BYTES on WebRTC, MAX_FRAME_SIZE_BYTES on SFTP or a synced folder) over every byte of every part, each part's header and brackets included, so a round whose grouping's parts would exceed that bound in all is refused: the total above is the lesser of the figures above and that bound. A receiver therefore reads at most that bound of a round's grouping, and the one part that would cross it, before refusing it. Limit: a grouping whose JSON alone is within the bound is still refused where the headers and brackets of its parts take their total past it; each part beyond the first adds 17 bytes over the grouping's JSON as one array, and the first 16.

A payload's part has no byte bound of this kind: its rows hold the partner's column values, which no agreed term bounds.

What the receiver refuses, from the headers. Each refusal is a protocol ConnectionError naming the list and the condition, raised before the refused part's body is parsed:

  • On the first part, a declared entry count over the bound above; and a part count of 0, or above the entry count (above 1 for an empty list).
  • A part shorter than its header, or with no body.
  • A part whose index is below the one expected (a repeated part) or above it (a missing part).
  • On a later part, a part count or entry count that differs from the first part's.
  • On a part of a mapped-element list, of a round's index list or of its grouping, a part longer than the byte bound above. The refusal names the bound, and the part's own length is on a cause link of its own.

What the receiver refuses, parsing the parts. Each body is parsed as its part arrives, through parseBoundedJson and the list's own schema, before the next part is read, and refused as a protocol ConnectionError: a body that is not JSON, the refusal naming no byte of it; a body the list's schema refuses; a part holding no entries in a list that is not empty; and a part running past the declared entry count. Once the last part has arrived, parts ending short of the declared entry count are refused, and, for a payload, a part naming other columns than the first part, or a row index repeated across parts. No part reaches the matching path before every part of the list has passed these checks.

An abort frame in place of any part ends the exchange as a peer termination, and any other non-binary frame is a protocol error, as above. The party that answers a mapped-element list -- the joiner -- receives the partner's list and then sends its own, and sends its own also when a part of the partner's list is refused, on its header or its body, before it reports the refusal, so a refusal on its side does not leave the partner waiting for a list. In the same way the joiner, receiving a round's original-index list and grouping, sends the round's completion acknowledgement before it reports a part of either refused. Over a synced folder or SFTP, a party still sending a round's later lists after its partner refused an earlier one is not told of the refusal: it waits for its sent message to be taken until the inactivity deadline (Sender-side peer-silence attribution) and reports a timeout, and the round never completes.

A payload part is a disclosure. A payload is disclosed from the first part the transport takes, so a payload send refused on a later part is owed the same record as one that completed (EXCHANGE_RECORD.md, When a record is owed).

The lists the parts do not cover. The single-pass messages are one message each (A PSI set is sent in parts).

The part header is a wire-format delta within PROTOCOL_VERSION 1, as Wire-format deltas states for pre-publication deltas; its vectors are among those check:protocol-version-bump pins. Sending a round's association table, original-index list and groupings in parts is a delta of the same kind.

What a browser tab can match ​

Measured 2026-09-30 in the development container (aarch64 Linux, 10 CPUs, 23 GB of memory) in Playwright's Chromium headless shell, build 1234, with the web app's own worker-backed WebAssembly PSI engine matching frames a Node party built. Each step ran as one page call; peak is the renderer's resident set, against an empty-tab baseline of 108 MB.

Partner's set (setup)This party's set (response)Result of the matchWall timePeak renderer RSS
1,048,576 (2^20)1,048,576completed444.5 s1,236 MB
4,194,304 (2^22)65,536completed125.3 s2,820 MB
8,388,608 (2^23)65,536completed228.5 s5,233 MB
10,485,76065,536the engine aborted (Aborted()); the tab survived5,962 MB
12,582,91265,536the engine aborted6,513 MB
16,777,216 (2^24)65,536the tab crashed (V8 javascript OOM)5,381 MB

The engine's linear memory grows by about 252 bytes per setup element and 56 per response element against a fixed maximum of 2,147,483,648 bytes, which puts the failure of a symmetric round matched in one call near 6.97 million elements a side. The table's rows ran the match as one call. The rounds below up to 7,500,000 a side ran the joiner's match through the library's whole-setup calls -- in one call, or in setup slices under a memory budget -- which is what the web app's worker ran when they were measured. The worker runs the streamed match (The single-pass dataset ceiling: receiver memory and masking compute), which the rounds at 2^23 and 10,000,000 a side measure, and runs its masking chunks under the memory budget, so each masking call is sized to stay inside the budget at the measured per-element figures. No symmetric round larger than 2^20 a side was run in this table: the engine matches about 0.4 ms an element, and a larger one exceeded the measurement's 15-minute limit per step.

A same-size round at 2^22 a side. apps/web/test/bench/browserSameSizeRound.ts runs the web app's own worker-backed engine in Chromium against a Node partner on the native addon, one PSI operation per page call with no step limit. For each step it records the wall time, the renderer's peak resident set, the engine's linear memory after the step, and the worker's V8 heap before the step, at its peak and after it, read from V8's collection trace. Measured 2026-10-04 in the development container in Playwright's Chrome for Testing 151.0.7922.34 run headless, identifier-revealing, 4,194,304 values a side with 1,000 shared. Both rounds matched the known overlap; the joiner's took 4,224.6 s end to end and the starter's 3,673.3 s, partner steps included:

Browser's roleOperationWall timeWorker V8 heap: before, peak, afterEngine linear memory afterPeak renderer RSS
JoinerCreate the request2,362.4 s6, 1,261, 134 MiB362 MiB2,613 MB
JoinerTake the partner's setup2.4 s134, 670, 647 MiB362 MiB1,870 MB
JoinerThe match (association table)1,699.7 s647, 1,904, 6 MiB1,125 MiB4,204 MB
StarterCreate the setup1,978.4 s6, 1,300, 134 MiB362 MiB2,713 MB
StarterAnswer the request1,572.2 s134, 1,767, 6 MiB435 MiB3,518 MB

The worker's heap limit is 4,395,630,592 bytes (4,192 MiB), the used plus available bytes V8's memory allocator reports for the worker's isolate, and equal to the page's own performance.memory.jsHeapSizeLimit. A peak here is the heap in use as a full collection starts, so it counts garbage that collection then frees. The engine's linear memory stayed under the 1,610,612,736-byte budget in every step, as the match slices and masking chunks of that build sized it to (The single-pass dataset ceiling, which records each masking operation's per-element growth).

The same round in Node. packages/core/test/stress/psiRoundWalls.stress.test.ts runs the round with both parties on the WebAssembly engine, each in its own worker thread at Node's default heap limit, also 4,192 MiB, and the starter's worker stopped before the match. Measured 2026-10-04 in the same container at 4,194,304 a side, identifier-revealing; the round matched the known overlap, and the process peaked at 5,386 MiB resident. Node reports the heap in use as every collection starts, minor ones included, so its peaks are higher than the browser's for the same step:

PartyOperationWall timeWorker V8 heap: before, peak, afterEngine linear memory after
StarterCreate the setup1,854 s10, 2,523, 10 MiB362 MiB
JoinerCreate the request1,847 s10, 2,320, 9 MiB362 MiB
StarterAnswer the request1,478 s9, 3,074, 10 MiB435 MiB
JoinerTake the partner's setup1 s9, 1,089, 1,067 MiB362 MiB
JoinerThe match (association table)1,625 s1,066, 3,095, 14 MiB1,125 MiB

A same-size round at 7,500,000 a side. The same bench, measured 2026-10-04 on a 32 GB Apple M1 Max (10 CPUs, macOS 26.6.2, Node v26.8.2) in Playwright's Chrome for Testing 151.0.7922.34 run headless, identifier-revealing, 7,500,000 values a side with 1,000 shared, the Node partner on the native addon under the command-line application's heap ceiling of 19,075 MiB (FILE_SYNC.md, The measured costs, "The heap ceiling"), since at Node's default limit the partner ran out of heap answering the request. Both rounds matched the known overlap; the joiner's took 8,606.8 s end to end and the starter's 5,951.7 s, partner steps included. A run at this size whose bench page sent each set to the bench server in one request of about 263 MB crashed the renderer's main thread at the end of the browser's first step in both roles; with each set uploaded in pieces both roles completed, which is consistent with that crash being the bench's own, though the cause was not measured.

Browser's roleOperationWall timeWorker V8 heap: before, peak, afterEngine linear memory afterPeak renderer RSS
JoinerCreate the request3,221.1 s6, 2,301, 235 MiB626 MiB4,653 MB
JoinerTake the partner's setup1.5 s235, 1,218, 1,159 MiB626 MiB3,455 MB
JoinerThe match (association table)5,256.3 s1,160, 2,990, 6 MiB1,110 MiB5,925 MB
StarterCreate the setup3,225.3 s6, 2,512, 235 MiB640 MiB4,855 MB
StarterAnswer the request2,588.8 s235, 3,147, 6 MiB746 MiB6,311 MB

The worker's heap limit was again 4,192 MiB, and the page's heap peaked at 518 MiB in both roles. This round's match ran in setup slices under the memory budget, each slice decrypting the whole response, and took 1.75 times its linear scaling from 2^22: at this size the setup was matched in 2 slices of at most 4,724,653 elements, where 2^22 fits in 1, so the response was decrypted twice; that is derived from the slice formula that match used, not instrumented. The worker's heap through the match cycled between about 2.4 and 3.0 GB with no growth. The engine's linear memory stayed under the 1,610,612,736-byte budget in every step.

Same-size rounds at 2^23 and 10,000,000 a side, streamed match. The same bench, measured 2026-10-08 on an x86-64 host (Intel Core i3-14100, 6 CPUs, 31.1 GB of memory, no swap, Ubuntu 24.04 LTS in a Linux container, Node v26.8.2) in Playwright 1.62.1's Chrome for Testing 151.0.7922.34 run headless, identifier-revealing, with 1,000 values shared, the Node partner on the native addon. Each round ran the streamed match, and each took the partner's setup in one piece. Both rounds at 8,388,608 (2^23) a side matched the known overlap: the joiner's took 5,692.4 s end to end and the starter's 5,712.3 s, partner steps included. At 10,000,000 a side the joiner's round matched the known overlap in 6,787.3 s, and the starter's failed.

Size a sideBrowser's roleOperationWall timeWorker V8 heap: before, peak, afterEngine linear memory afterPeak renderer RSS
8,388,608JoinerCreate the request2,978.0 s6, 2,554, 262 MiB731 MiB4,591 MB
8,388,608JoinerTake the partner's setup0.6 s262, 262, 6 MiB731 MiB2,106 MB
8,388,608JoinerThe match (association table)2,426.4 s6, 6, 6 MiB731 MiB2,401 MB
8,388,608StarterCreate the setup2,977.6 s6, 2,765, 262 MiB731 MiB4,705 MB
8,388,608StarterAnswer the request2,460.8 s262, 3,459, 6 MiB731 MiB6,359 MB
10,000,000JoinerCreate the request3,551.9 s6, 2,910, 311 MiB848 MiB5,369 MB
10,000,000JoinerTake the partner's setup0.7 s311, 311, 6 MiB848 MiB2,443 MB
10,000,000JoinerThe match (association table)2,889.8 s6, 6, 6 MiB848 MiB2,798 MB
10,000,000StarterCreate the setup3,550.4 s6, 3,001, 311 MiB848 MiB5,555 MB
10,000,000StarterAnswer the requestfailed about 2,911 s in311, 3,949 at the last collection848 MiB6,360 MB

The worker's heap limit was again 4,192 MiB, and the page's heap peaked at 579 MiB (joiner) and 561 MiB (starter) at 2^23 and 690 MiB at 10,000,000. The engine's linear memory peaked at 766,377,984 bytes at 2^23 and 889,585,664 bytes at 10,000,000, under the 1,610,612,736-byte budget in every step. The streamed match held the worker's heap near 6 MiB at both sizes and ran at about 3,460 response elements a second.

The measured limit: the starter's answer at 10,000,000 a side. The starter's worker ran out of JavaScript heap while answering the partner's request, the step that decodes the partner's whole request into the worker's heap (processClientRequest, packages/core/src/psi/psiEngine.ts). Its last collection freed nothing, at 3,949 MiB in use, about 243 MiB below the reported limit, and V8 stopped the renderer (V8 javascript OOM (MarkCompactCollector: young object promotion failed)), which crashed the page. The host did not run out of memory: the container recorded no out-of-memory event, and its memory in use peaked at 9.23 GB of 31.1 GB in samples taken every 2 minutes across the four rounds. The same step peaked at 3,459 MiB at 2^23, so the starter's answer is the wall a larger round meets first; the joiner completed at 10,000,000 a side, its highest peak 2,910 MiB in creating the request.

Rounds at the ceiling through the wrap. apps/web/test/bench/browserWrappedRound.ts runs one round of core's participant on each side over a PeerJS data channel in the application wrap, as the web application's exchange runs one: the part framing, the element scan, the receive ceiling and the wrap's copies of each frame all run. The browser's engine is the web app's worker, and the partner's participant runs in a second Chromium on a Node engine on the native addon. Each engine operation is recorded as the bench above records a step, and the page's heap is sampled through the whole round. The bench raises the transport's inactivity timeout to 24 hours, above the web application's default of 1 hour. In the development container the third round's setup step, which ran 4,069.0 s under the load stated below, outran that default, so that round shows the memory at the ceiling, not that the web application's run would finish within its inactivity default; on the x86-64 host no step outran it.

Measured 2026-10-18 in the development container (aarch64 Linux, 10 CPUs, 23 GB of memory) in Playwright 1.64.0's Chrome for Testing 156.0.8078.4 run headless, identifier-revealing, with 1,000 values shared. In two rounds the partner's set was 8,388,608 values and the browser's 65,536, one round in each role. In the third the browser was the starter with 8,388,608 values a side, the role whose answer failed at 10,000,000 a side above. In every round each set at the ceiling crossed the channel in two parts, and every round matched the known overlap. Other work ran in the container beside them: the one-minute load reached 7.3, 15.3 and 22.7 in the three rounds, in the table's order, and the container's available memory fell to 12.8 GB, 6.4 GB and 5.6 GB.

Browser's role and setsThe sets at the ceilingWall timePage's usedJSHeapSize peakPage's V8 heap peakWorker V8 heap peakPeak renderer RSS
Joiner, 65,536 against 8,388,608the partner's setup, received170.2 s1,285 MiB, receiving the first part11 MiB25 MiB, creating the request1,323 MB
Starter, 65,536 against 8,388,608the partner's request, received; the response, sent3,285.3 s1,231 MiB from the request's receive through the answer; 2,535 MiB sending the response16 MiB3,455 MiB, answering the request in 3,052.0 s6,383 MB answering; 7,590 MB sending the response
Starter, 8,388,608 a sidethe browser's setup, sent; the partner's request, received; the response, sent7,477.0 s735 MiB creating the setup; 2,747 MiB from the request's receive through the answer; 3,526 MiB sending the response580 MiB, creating the setup2,765 MiB creating the setup in 4,069.0 s; 3,455 MiB answering the request in 3,136.5 s5,373 MB creating the setup; 7,157 MB answering; 8,606 MB sending the response

The page's and the worker's heap limits were each 4,395,630,592 bytes (4,192 MiB). usedJSHeapSize is the page's performance.memory figure, sampled in the page every 100 ms; on this build it counts the bytes of every live ArrayBuffer beside the V8 heap. The V8 heap peaks are read from V8's collection trace, as above.

Measured 2026-10-19 on the x86-64 host above (Intel Core i3-14100, 6 CPUs, 31.1 GB of memory, no swap, Ubuntu 24.04 LTS in a Linux container, Node v26.8.2) in the same Playwright 1.64.0 and Chrome for Testing 156.0.8078.4 run headless, identifier-revealing, with 1,000 values shared, the partner on the native addon. Four rounds ran at 8,388,608 values a side: the browser as the joiner and as the starter, each once through the wrap with this bench and once unwrapped with the same-size bench above. Every round matched the known overlap, and the wrapped joiner's round received the partner's setup in two parts. Nothing else ran in the container: the highest one-minute load in each round was 5.1 to 5.8. The lowest available memory the wrapped bench sampled was 18.0 GB in the joiner's round and 20.0 GB in the starter's, and samples taken every 2 minutes across all four rounds stayed at 20.5 GB or more. The kernel logged no out-of-memory event.

Browser's role and setsThe sets at the ceilingWall timePage's usedJSHeapSize peakPage's V8 heap peakWorker V8 heap peakPeak renderer RSS
Joiner, 8,388,608 a side, through the wrapthe partner's setup, received; the browser's request, sent; the response, received5,715.4 s2,270 MiB receiving the partner's setup; 3,037 MiB after creating the request581 MiB2,561 MiB, creating the request in 2,966.2 s5,530 MB creating the request; 6,019 MB after it
Starter, 8,388,608 a side, through the wrapthe browser's setup, sent; the partner's request, received; the response, sent5,741.5 s735 MiB creating the setup; 2,997 MiB after creating it; 3,590 MiB after answering the request580 MiB2,620 MiB creating the setup in 2,965.9 s; 3,455 MiB answering the request in 2,452.2 s5,077 MB creating the setup; 7,133 MB answering; 7,379 MB after answering
Joiner, 8,388,608 a side, unwrappednone: the bench hands each set to the page5,683.4 snot recorded580 MiB2,552 MiB, creating the request in 2,973.1 s4,799 MB creating the request
Starter, 8,388,608 a side, unwrappednone: the bench hands each set to the page5,716.0 snot recorded580 MiB2,730 MiB creating the setup in 2,979.8 s; 3,459 MiB answering the request in 2,462.3 s4,943 MB creating the setup; 6,495 MB answering

The page's V8 heap peak is the collection trace's figure, as above; the page's own samples of its V8 heap peaked at 546 MiB in the wrapped joiner's round and 580 MiB in the wrapped starter's. The unwrapped bench does not sample usedJSHeapSize. Through the wrap each round's wall time was within 0.6% of the unwrapped round's in the same role.

  • The wrap's copies are ArrayBuffers. The frame reassembled from the channel, the wrap's copies of it and the joined set are each an ArrayBuffer. With the browser's set at 65,536 the page's V8 heap stayed at 16 MiB or under while usedJSHeapSize reached 2,535 MiB, 60% of the limit. In the container's round at 8,388,608 a side the page's V8 heap, which holds the browser's own values, peaked at 580 MiB, against 561 MiB in the unwrapped starter's round at 2^23 above, while usedJSHeapSize reached 3,526 MiB, 84% of the limit.
  • The heap limit does not bind ArrayBuffers. In a one-off probe in the same build, not kept in the repository, a page held ten 512 MiB ArrayBuffers, a usedJSHeapSize of 5,369,351,736 bytes against its 4,395,630,592-byte limit, without a failure. What the wrap moves is the renderer's resident set: in the container, sending the starter's response took it 1.2 GB past its peak in the answer at 65,536 and 1.4 GB past it at 8,388,608 a side.
  • The worker's peaks match the unwrapped round's. The starter's answer, the step the round at 10,000,000 a side failed in, peaked at 3,455 MiB in the worker in all three starter rounds through the wrap, 82% of the limit and within 4 MiB of the 3,459 MiB the unwrapped rounds at 2^23 a side reached on the x86-64 host on both builds. Creating the setup at 8,388,608 peaked at 2,765 MiB in the container, as it did in the host's unwrapped round on the earlier build; on the later build it peaked at 2,620 MiB through the wrap and 2,730 MiB unwrapped. The engine's linear memory reached the same 766,377,984 bytes in every starter round at 8,388,608 a side. The wrap runs on the page, not in the worker.
  • The joiner's worker on the host. Creating the request peaked at 2,561 MiB through the wrap and 2,552 MiB unwrapped, 61% of the limit. In the wrapped round the partner's setup reached the engine before the request was created, and the engine's linear memory after that step was 955,908,096 bytes, against 766,312,448 in the unwrapped round, where the setup came after it; both are under the 1,610,612,736-byte budget.
  • The page's usedJSHeapSize on the host. It peaked at 3,037 MiB, 72% of the limit, after the joiner created its request, and at 3,590 MiB, 86%, after the starter answered the request, while the page's V8 heap stayed at 581 MiB or under.
  • What the wrap adds to the renderer's resident set on the host. The wrapped joiner's renderer peaked at 6,019 MB against 4,799 MB unwrapped, 1.2 GB higher, and the wrapped starter's at 7,379 MB against 6,495 MB, 0.9 GB higher. Part of the joiner's gap is the order of its steps, not the wrap, since the wrapped round had the partner's setup in the engine before it created the request. At the same step the joiner's renderer peaked at 5,530 MB against 4,799 MB creating the request, 0.73 GB higher, and the starter's at 7,133 MB against 6,495 MB answering, 0.64 GB higher.
  • No heap reached its limit, and no step outran the inactivity default. The highest worker peak in these four rounds was 3,459 MiB, 82.5% of the limit, in the unwrapped starter's answer. Every step of the four rounds ran under the default's 3,600 s; the longest through the wrap was creating the joiner's request, 2,966.2 s.

Not run.

  • 2^23 a side in Node. The Node case runs a size only when the host has twice the 1,400 bytes an element plus 1 GiB it estimates the round needs; at 8,388,608 that is 23.9 GiB, and the development host had 21.8 GiB free.
  • Any size between 8,388,608 and 10,000,000 a side, in either role.
  • Count-only rounds. Every round above is identifier-revealing.

The browser's ceiling. BROWSER_PSI_SET_MAX_ELEMENTS (packages/core/src/connection/frameSize.ts) is 8,388,608 (2^23), the largest same-size round measured to complete in a browser tab in both PSI roles. A browser party can play either role, so the ceiling is the smaller of the two roles' measured sizes: 10,000,000 completed as the joiner only. A browser party states it as its receive ceiling (The receive ceiling). It is past the 7,643,790 elements one WebRTC message holds (The memory ceiling, and the CSV intake cap), so a partner sends a set at the ceiling in two parts, each held to the per-frame bound, and the browser joins a request's parts, or streams a setup's into the engine, as it does any set sent in parts (A PSI set is sent in parts). A unit test pins the figure, the bytes a set at it holds and its part count.

What the measurement does not cover:

  • Another host or browser. The ceiling was measured on one host and one build of Chromium. A host with less memory, or a tab granted less heap than that build gave its worker, can fall short of what the ceiling admits.
  • The data channel on the ceiling's build. The two-part receive of a set at the ceiling is the path two command-line parties run on WebRTC (below); in a browser, through the application wrap, it was measured on the host the ceiling was measured on, in a later Chromium build, 156.0.8078.4, not the 151.0.7922.34 build the ceiling was measured in (above, "Rounds at the ceiling through the wrap").

The receive ceiling ​

Each party states on the terms exchange the most values one PSI set it receives may hold, and the partner holds every set it sends that party to that figure.

The field. receiveCeiling is an integer on the terms-exchange envelope beside recordCount (Role resolution and work minimization). It is required on message 1, and on a message 2 that proceeds; that slot doubles as the responder's abort frame, which omits it as it omits the record count. It lies between 1 and MAX_PSI_DECODE_ELEMENTS inclusive. A value outside that range, a value that is not an integer, and an absent value on a frame that must hold one are each refused at the terms exchange, before any linkage key moves. It rides the terms frame under the same authentication as the agreed terms, and like the record count it is per-party envelope metadata that enters no terms hash. The field is a wire-format delta within PROTOCOL_VERSION 1, as Wire-format deltas states for pre-publication deltas; the terms-envelope vectors (packages/core/test/vectors/terms-envelope-vectors.json), among those check:protocol-version-bump pins, hold it.

What each party states. A party states its connection's ceiling (inboundPsiSetElementCeiling), held to MAX_PSI_DECODE_ELEMENTS, or that maximum where its connection states none:

  • A browser party states BROWSER_PSI_SET_MAX_ELEMENTS, 8,388,608 (above).
  • The command-line application states MAX_PSI_DECODE_ELEMENTS, 16,777,216, on every channel. Its capacity below that is held by its own memory check (FILE_SYNC.md, The partner's round), not by the figure it states.

A command-line round on WebRTC past one frame. Two command-line parties hold each other to 16,777,216, so a WebRTC set past the 7,643,790 elements one frame holds goes in parts. On 2026-10-02 two alcove exchange parties on one host (Apple M1 Max, 10 CPUs, 32 GiB, macOS, Node v26.8.2) ran a cascade over channel: webrtc through the repository's broker on loopback, with host candidates only, at 7,700,000 records a side. Each first-round set went in two parts at the part size. The run took 1,178,964 ms, and both parties exited 0 with the expected result:

PartyPeak RSSLongest main-thread hold on the open channel
Inviter, PSI sender12,180,897,792 bytes10,970 ms
Acceptor, PSI receiver13,268,779,008 bytes9,929 ms

Peak RSS includes the party's PSI worker. apps/cli/test/stress/webrtcCompletion.stress.test.ts drives the run in the CLI's opt-in stress tier; what holds the main thread on an open channel is in WEBRTC_TRANSPORT.md. The same round with one party on each of two hosts is in WEBRTC_TRANSPORT.md.

What the sender does. A cascade or count-only exchange holds this party's sets to the partner's ceiling at two points:

  • After the terms exchange, before this party builds a set. Where the partner's ceiling is below MAX_PSI_DECODE_ELEMENTS, the party counts its first round as the start-of-exchange count does (The first round is counted at the start), in the PSI role it resolved to, and refuses a count over the ceiling. The refusal is a RoundSetLimitError with reason "over-partner-ceiling", naming its count and the ceiling, with the remedies: split the input, or have the partner run the exchange with the command-line application. A count that fails refuses the same way, with reason "uncounted" and the failure as its cause. A refusal over the ceiling sends the partner the abort reason "a PSI set is too large to send". A first round this party could not count sends "a PSI set was refused before sending", like any other refusal. Any other refusal the count raises, one the round itself would raise, propagates unchanged and sends the partner the abort reason "a PSI set was refused before sending", which states only that this party refused to send its set. A partner that resolved to the PSI sender does not wait on this check, so it may have sent its setup by then. A first round within the ceiling proceeds, however many records the partner's terms declare. Where the partner states MAX_PSI_DECODE_ELEMENTS, the start-of-exchange check and each round's own refusal hold the set to it already, so the party does not count again.
  • At each round, before building its set. A setup or a request of this party's own over the ceiling is refused as the sender's own bound states. A later round of a cascade is held to the ceiling here alone: its set is known only once the earlier rounds have matched. Under the PSI-C construction every round's set is the declared record count the checks before the first round already weighed (Under the PSI-C construction every round is counted).

A party reading one of these fixed reasons, or the receiver's below, in place of a set ends with a PeerAbortError whose partnerReason holds that reason as its own constant; any other abort leaves partnerReason unset. A browser's managed exchange records "a PSI set is too large to send" as the non-retryable partner-set-too-large kind and "a PSI set was refused before sending" as the non-retryable partner-refused-set kind, each with refusedInRound, since its own setup may have been sent. A browser's one-shot exchange states either cause and withholds the retry.

A single-pass exchange is held to its dataset ceiling instead (below), which is under every ceiling a party states.

What the receiver does. A setup or a request, either of which holds the partner's set, is held to this party's own ceiling twice before it is decoded. A response, which re-encrypts this party's own request, is held to that request instead: a response declaring more bytes than the request this party sent is refused at its first part, and one declaring more elements at the element scan (TRANSPORT_BOUNDS.md).

  • At the set's first part. The declared length is held to min(n, MAX_PSI_DECODE_ELEMENTS, ceiling) * 35 + 6 bytes, at most 293,601,286 for a browser, before the buffer a request is joined into is allocated or any of a setup reaches the engine. A declared length over the combined bound is a ProtocolRefusalError, as above. One within it but over the ceiling is this party's own limit: a RoundCapacityError sent to the partner as the fixed abort reason "the partner cannot process a set as large as the one you send for a linkage key", so a browser's managed exchange records it as the non-retryable partner-set-too-large kind wherever the round stopped.
  • At the element scan. The set's declared element count is held to min(n, MAX_PSI_DECODE_ELEMENTS, ceiling): a request's before deserialization, a setup's part by part, each part refused before it reaches the engine if it takes the count past the bound (TRANSPORT_BOUNDS.md), and a count over it is a ProtocolRefusalError.

A sender that holds its sets to the ceiling the receiver stated reaches neither refusal.

The single-pass dataset ceiling: receiver memory and masking compute ​

Single-pass exists to collapse a many-round-trip exchange over a minute-scale-latency channel (a 15-minute directory poll, times one dependent round per key, is hours of waiting) into one sweep, at the cost of more disclosure (the receiver sees the sender's full per-key duplicate structure and the weaker-key matches the cascade filters out, above). It is therefore an operator opt-in, agreed on up front in the linkage_strategy term -- never a default, never selected automatically.

What was measured. The harness at scripts/single-pass-bench.mjs measures what bounds single-pass. Its sweep mode runs a real linkViaSinglePassPSI over a relayed pipe with each side in its own process, so peak resident set is isolated per side; its rates mode times each masking operation directly. Both also report the WebAssembly linear heap directly -- the harness wraps WebAssembly.instantiate before the module instantiates and reads the exported WebAssembly.Memory byte length, since process.memoryUsage().arrayBuffers does not capture it. sweep also reports each side's sampled peak of process.memoryUsage().arrayBuffers. And sweep --gc forks the children under --expose-gc (the same flag the shipped CLI sets, turning on the phase-boundary relief above) and reports the post-GC live heap, so the WASM-heap, live-retained, and transient-peak terms are read off the same run. The measurements drive near-unique keys -- every cell a distinct value, so the pooled distinct count D = keyCount * rows, the worst case the ceiling must hold for. The per-value constants below were measured on one workstation-class core (aarch64, single-threaded). The row ceilings derived from them are linear projections of a fit whose measured range tops out near 16k rows (224k distinct values), not measured points, and the constants themselves move as the implementation's allocation churn is reduced (below). The cap therefore does not project receiver RSS at runtime from these constants; it sets a single fixed, conservative budget on the per-party (key, record) cell count derived from them (MAX_SINGLE_PASS_CELLS, below), which is non-configurable -- a configurable maximum reintroduces the memory DoS -- and revisited as a one-line change when the per-value peak moves.

Receiver memory binds single-pass, but as a transient peak, not live data. The receiver must hold both parties' full encrypted sets resident to run the match -- it decodes the reply, builds its own and the sender's distinct-value index tables, and replays the cascade -- so single-pass is O(total distinct values) resident and cannot stream. Two quantities differ several-fold here and must not be conflated:

QuantityScaleNotes
Live retained memorya few hundred bytes per distinct value~60 B of Alcove JS plus ArrayBuffer backing stores of about 180 B on the sender and about 78 B on the receiver (the arrayBuffers term, measured below), against a reply frame of 74 B a value. A separate grow-only WebAssembly linear heap (emmalloc, never returned to the OS) holds 16 MiB through D = 56,000 and 175 MiB at D ~= 2M, about 90 B/value, read from the WASM byte length as above. Even summed, ~1.4M distinct values (100k rows at the ~14-key default template) stays well within a 16 GB machine.
Lifetime peak RSS~1-2 KB per distinct valueTransient allocation churn, not retained state. This is the practical ceiling; it moves as the churn is reduced (next paragraph).

Measured 2026-10-08 in the development container (10 CPUs, 23 GiB, Linux arm64) through scripts/single-pass-bench.mjs sweep, 14 keys, three runs each with and without --gc at D = 14,000, 28,000 and 56,000 and one --gc run at D ~= 2M: the relieved peak grew about 1.0 KB a value on the receiver and 1.9 KB on the sender between 14,000 and 56,000, and stood at 0.79 KB and 1.37 KB a value at 2M. The post-GC live heap grew about 50 B a value between 14,000 and 56,000 and was 114 to 121 MiB at 2M, about 60 B a value.

The arrayBuffers term was measured 2026-10-08 in the development container through node scripts/single-pass-bench.mjs sweep --keys 14 --sizes 1000,2000,4000, with and without --gc, and larger sizes up to D = 224,000. The sampled peak grew linearly from a base of about 1.6 MiB, at 183 B a value on the receiver and 179 B on the sender with --gc (197 B and 176 B without); the sampler sees only the moments it runs, so these are lower bounds on the true peak, about 2.5 times one copy of the 74 B a value reply frame. What survives the phase-boundary collections is less on the receiver: the sender's reading at the end of its run grew 180 B a value, the receiver's reading as its match stage starts about 78 B a value, and the two runs at 224,000 differed by nearly a factor of two (35.8 and 19.1 MiB), because a reading just after a collection can include backing stores not yet released.

Because the bound is on distinct values, the same live memory reaches millions of rows at 1-2 keys. The practical single-pass ceiling is set by the transient peak, so it moves as the churn is reduced; the live-data wall is far higher.

Why the peak is kilobytes per value while live data is a few hundred bytes. The peak is the high-water mark of transient garbage churned during the per-element work, captured because neither V8 nor the WebAssembly allocator (emmalloc, grow-only under -s ALLOW_MEMORY_GROWTH=1) returns freed pages to the OS. The transient peak is GC-collectable V8 garbage from the per-element marshalling across the JS-to-native boundary, not native WASM memory. It is generated inside the @openmined/psi.js binding layer reached through createServerSetup/createClientRequest/computeValueMatches, plus the smaller per-key reconstruction maps linkViaSinglePassPSI builds itself, so it sits in V8's heap and answers to GC pressure (an explicit gc() at the phase boundaries, or --max-semi-space-size), not to --max-old-space-size. Direct measurement (forked sender and receiver, GC forced at each phase boundary, D of 14k and 56k) shows it: the post-GC V8 heap grows about 50 B/value and the WebAssembly linear heap not at all, while the lifetime peak grows about 1-2 KB/value (above). A component decomposition confirms it: at D = 14,000, a single process running the four masking ops with no reconstruction holds the WebAssembly linear heap flat at ~16 MB and the post-GC live JS heap at ~20 MB, while RSS climbs ~70 MB above its idle base in pages V8 freed but did not return to the OS.

linkViaSinglePassPSI calls relieveTransientMemory -- a no-op unless a global gc is exposed -- at the coarse phase boundaries (after each masking phase's transients become collectable and after the cascade replay). The CLI launches Node with --expose-gc (its Docker entrypoint and the apps/cli dev script) so the relief is active there. In the 2026-10-08 runs above it lowered the receiver's mean peak by 1 to 5% at D = 14,000 to 56,000 (D = 28,000: 136 -> 129 MiB) and the sender's by up to 8%, each inside the run-to-run spread at its size. At D ~= 2M, one run each way, the receiver's peak was 1,513 MiB relieved against 1,655 MiB unrelieved (about 9% lower) and the sender's 2,620 MiB relieved against 2,495 MiB unrelieved (about 5% higher); with a single run of each, neither difference is shown to exceed the run-to-run spread. The unrelieved run was measured 2026-10-08 in the same container by node scripts/single-pass-bench.mjs sweep --keys 14 --sizes 142857 (the relieved run's command without --gc), once, on the WebAssembly backend, in 43 minutes, with the same 175 MiB WebAssembly linear heap on each side. It targets the masking-phase transients, the dominant term; forcing a collection at every cascade key as well recovers a little more but is not done, since the cascade is light integer work beside the curve operations (a cascade exchange's collection between keys, measured, is below). A browser never exposes gc, so the web app does not get the relief and its single-pass ceiling rests on the same conservative cap. The complementary packages/core lever -- trimming Alcove's own per-key reconstruction churn (removeDuplicatesAndUndefineds keeps one map of first rows and roundValueFirstRows one map plus a recurring-value set, rather than three) -- is backend-agnostic and byte-identical, but a minor slice of the peak beside the boundary marshalling, so it does not move the measured RSS on its own.

A collection between a cascade exchange's keys is not made either. A collection at the start of each key after the first in linkViaPSI was measured on 2026-10-04 in the development container (10 CPUs, 23 GiB, Linux arm64, Node v26.10.0): two alcove exchange parties on one host over WebRTC on loopback, 1,000,000 records a side, the inviter the PSI sender, three runs each way, driven by apps/cli/test/stress/webrtcCompletion.stress.test.ts, one run at a time on a container shared with other work. The variant was one call to relieveTransientMemory at the top of the key loop for every key after the first, a local experiment not kept in the tree; rerunning it means adding that line. Peak RSS, PSI worker included, with the one-minute load average over each run (mean and highest):

Collection between keysRunSender peak RSSReceiver peak RSSLoad average
No12,192,068,6082,510,802,9449.9, 14.8
No22,191,880,1922,514,366,4648.4, 10.5
No32,246,066,1762,507,837,44010.5, 12.7
Yes12,204,246,0162,505,330,68812.1, 19.6
Yes22,170,642,4322,522,943,48817.4, 19.6
Yes32,114,883,5842,500,026,36819.0, 29.0

Every peak in both sets fell in the first key, before any collection between keys runs, so the collection cannot lower it. The sender's mean peak is 47 MB (2.1%) lower with it, less than the spread of either set (54 MB without, 89 MB with), and the receiver's is unchanged (2,511 MB without, 2,509 MB with). The main thread's heap at the highest sample was 0.65 to 0.67 GB in every run, against 1.73 and 1.81 GB in the second key of the 2026-10-03 run at 1,000,000 records (WEBRTC_TRANSPORT.md, A party's memory), the peak the collection was meant to lower. The collection runs carried a higher load average (12.1 to 29.0 against 8.4 to 14.8), which bears on the run-to-run spread but not on the conclusion, since every peak fell in the first key, before any collection between keys runs.

The live floor the peak cannot drop below is small and O(total distinct values) -- the receiver's match is a full sort-merge join over both sets -- but at a few hundred B/value that floor stays well under a GB even at millions of values, far below the memory wall.

What splitting a masking operation into chunks costs the peak. The split (below, "A long masking operation runs as a few chunks") reassembles the chunk results, which holds one list of element references beside the library's own for as long as the merge runs. Measured 2026-09-21 on the WebAssembly backend, aarch64 Linux container under Node 26, three samples per size, through scripts/single-pass-bench.mjs -- its sweep --gc mode for the peak and post-GC heap figures, its rates mode for the masking rates:

  • the receiver's relieved peak sits 20 to 40 MB above the unsplit path at D = 28,000 (three chunks) and D = 56,000 (five chunks), an offset that does not grow with D across those two sizes;
  • the per-value slope of that peak over D = 14,000 to 56,000 is about 1.0 KB (above), and the post-GC live heap moves ~50 B per distinct value;
  • the masking rates below are unchanged by the split at these sizes;
  • at D = 2,000,000 the setup merge itself holds ~0.1 GB over the element list it orders, measured in a child process running that merge alone rather than through an exchange. The near-ceiling D ~= 2M exchange the cell budget is derived from was re-measured through an exchange on 2026-10-08, and its peaks are under the cap below.

The association table is the cost these figures do not cover: the streamed match returns its pairs in response order, and the engine puts them in partner-index order with a counting sort that holds up to one 4-byte counter per partner element beside the two lists it returns. That cost was not measured at the ceiling through an exchange, so the receiver-peak headroom behind MAX_SINGLE_PASS_CELLS is re-established for the setup merge and for the 2.3 to 2.9 KB per value slope above, not for the association table at the ceiling.

Masking compute is a wall-clock expectation, not a cap. Masking is one elliptic-curve scalar multiplication per distinct value, exactly linear in D (confirmed D from 14k to 224k). The rates below are the single-threaded WebAssembly backend -- what the browser runs and what scripts/single-pass-bench.mjs measures:

OperationRate per distinct value
First-encrypt a set~0.46 ms
Re-encrypt~0.37 ms
Match~0.37 ms

Measured 2026-10-08 in the development container (Linux arm64), D = 32,000, two runs each pinned to one CPU with taskset at a one-minute load average of 1.7 to 3.1: first-encrypt 0.46 to 0.48 ms, re-encrypt 0.38 ms, match 0.37 ms, and the bench's own rates mode read the same at a load average of 3. The figures move with the host's load: the same pinned runs at a load average of 7 to 10 took 0.78 to 0.86, 0.60 to 0.66 and 0.62 ms.

On the WebAssembly backend the two-party critical path is therefore about 1.2 ms * keyCount * rows -- about 28 minutes for 100k rows at 14 keys, hours at a million. That is the figure the browser (WASM-only) has. The CLI defaults to the native N-API backend, which lowers the per-value cost about five-fold on one core and is additionally parallelized across cores, so a native CLI run's wall-clock sits well below the WASM figure. Measured in the same low-load runs as the table, both backends pinned to the same CPU, native first-encrypt took 85 to 86 us against 456 to 475 us for WebAssembly, and each of the four operations ran 5.3 to 5.5 times faster. Unpinned on the container's 10 CPUs, native first-encrypt took 15 to 16 us a value wall-clock, about 30 times the single-threaded WebAssembly rate; that is the parallel figure, not a single-core one. The CLI also runs this masking in a worker thread off the event-loop-owning thread, so a long round does not starve the connection's keepalives and timers; even so, a large round on weak hardware can outlast a strict server idle timeout, which the SFTP session heartbeat defeats (see TRANSPORT_LIVENESS.md). The web app runs the same masking in a Web Worker off the browser's UI thread, the browser sibling of the CLI offload: it wires a Vite new Worker(new URL("./psiCrypto.worker.ts", import.meta.url), { type: "module" }) into the same runtime-agnostic PSI worker boundary in packages/core (the host-side WorkerPsiEngine + the worker-side servePsiWorker dispatcher) that the CLI's worker_threads worker uses. This keeps the tab responsive and the WebRTC peer keepalives firing during a round, instead of the round freezing the interface. The worker loads its own WASM engine (@openmined/psi.js/psi_wasm_worker, the worker-context sibling of the main thread's psi_wasm_web) and the exchange's secret key is generated inside it (by createWithNewKey) and never posted to the page, which only connects the workers of the pool below; only bytes, value lists, index lists, the processed-element count a long operation reports part-way through, and a failure's message with whether the engine raised it cross the boundary, so the exchange result is byte-identical to the in-process path. WebRTC has its own liveness, so the browser needs no SFTP-style heartbeat, and a browser never exposes gc, so it runs the unrelieved memory peak (above). For a batch or scheduled exchange the wall-clock is tolerable, so it should not refuse a dataset -- it sets the operator's expectation and sizes the run window. With receiver memory not the binding wall at target scale (above), this masking compute is the practical limiter past ~100k rows. The cost is shared with the cascade (same primitive; the cascade does less curve work when match rates are high, since it drops matched records each round, and about the same at low overlap), so it is not a single-pass-versus-cascade discriminator. It remains the target of a possible curve/library replacement.

A long masking operation runs as a few chunks, so it can report progress. Each operation above is one library call that returns nothing until the whole set is through, which leaves a display with no figure to move for minutes. Above a floor the engine splits the set into a small number of chunks, reports the running processed count between them -- that integer and nothing else reaches a display -- and reassembles the chunk results into the message the single call produces. The floor and the chunk count answer to backend costs rather than to the protocol -- the split is invisible on the wire -- and each is stated with the constraint behind it in packages/core/src/psi/psiChunks.ts. The identity the merge rules answer to:

  • The reassembled server setup, client request and server response are byte-identical to the single call's, and the association table is identical to its.

  • A set at or below the floor takes one chunk, which is the single call unchanged.

  • An identifier-revealing response answers the request position by position, so its chunk results concatenate; a COUNT-ONLY response of the shipped build is sorted by element bytes -- the shuffle that keeps the receiver from pairing a response element with the request position it answers -- so its chunk results merge by that order instead.

  • A response of the PSI-C construction concatenates its chunk results in request order and is then reordered under the sender's pi as a whole, so every chunk answers under the one permutation.

  • The joiner's match is streamed. The engine's streaming match (createMatch in @openmined/psi.js from 2.0.6-seclink.5; packages/core/src/psi/psiEngine.ts) takes the partner's setup as pieces cut at any byte, in order, as its parts arrive (A PSI set is sent in parts). As the pieces arrive it checks the setup's shape -- exactly one Raw data structure, holding only element entries of at most 33 bytes, and nothing after it -- and its order, and keeps each element in a fixed 34-byte slot of its own memory; the setup is never decoded into JavaScript objects. The match then takes the response in pieces, decrypts each response element once, and finds it among the setup's elements by binary search. An identifier-revealing match records each pair of response index and setup index, and the joiner puts the pairs in the order the library's whole-setup call returns them: partner index ascending, ties by local index. A count-only match marks each setup element a decrypted element equals and counts the marks once the whole response is fed, which is the library's own count for the same setup and response: a value the partner repeated is counted once wherever the pieces are cut, and no per-piece count leaves the engine. The response is fed in the chunk policy's element ranges, each cut at the byte offset its range takes in proportion, which is an element boundary in a conforming response and anywhere the engine accepts otherwise; the running count of response elements fed, never of matches, is reported between pieces in every mode, the PSI-C construction's included, and a stop requested during the match, such as on a lost partner, takes effect at the next piece (WEBRTC_TRANSPORT.md). A match whose engine reports decrypting other than the response's element count is refused as an internal fault. Both backends match this way, in a worker or in process; the piece boundaries are local, so nothing on the wire changes. Equality with the library's whole-setup calls, the single decryption at every cut, and the refusals below are held on both backends by packages/core/test/psi/psiEngineStreamedMatch.test.ts. At 2^24 elements a side the identifier-revealing streamed match ran in 145 minutes on a hosted runner, with the WebAssembly engine's memory peaking at 860 MiB and the JavaScript heap at 274 MiB (packages/core/test/stress/streamedMatchLarge.stress.test.ts). On the native addon the same match ran in 8 minutes, with the process's resident set peaking at 1,949 MiB and the JavaScript heap at 273 MiB (packages/core/test/stress/streamedMatchLargeNative.stress.test.ts).

  • The match refuses a setup that is not strictly ascending. The streamed match finds each setup element by binary search, so the joiner refuses, as a protocol error, a partner setup whose elements are not strictly ascending by bytes. It checks the order as the setup's pieces arrive and refuses at the piece that shows it, before the joiner sends its request, so a malformed setup is refused the same way at every size, with the same message, and a refused setup is discarded before any response is matched against it. A setup that is not one Raw data structure is refused by name the same way; any other fault the engine finds in it is the setup failing to decode. A conforming setup always passes: each party masks distinct values, and the library sorts a Raw setup by element bytes. The resolver's handling of a value the partner contributed twice (Deriving one table from the exchanged association maps) stays in place as a safety check behind the order check.

  • The match decrypts the response once. The streamed match decrypts each response element once, whatever the setup's size and however the setup's pieces are cut, so its decryption cost follows the response's element count, which the request this party sent bounds.

  • A masking chunk on the WebAssembly engine is held to a memory budget. Creating a setup, creating a request, and answering a request each take their whole chunk into the engine's linear memory, against a fixed maximum of 2,147,483,648 bytes. A PSI worker on the WebAssembly engine therefore runs each at the chunk policy's sizes unless a chunk would hold more than floor(1,610,612,736 / b) elements, where b is the operation's figure below, and then in near-equal chunks of at most that many. 1,610,612,736 bytes (1.5 GiB) is the budget, which leaves 512 MiB of the maximum for the engine's own baseline and allocator slack. A set at or below the chunk floor stays one call. Masking cost does not grow with the chunk count: each element is masked once. packages/core/src/psi/psiWasmBudget.ts holds the figures, each the measured growth rounded up:

    Operationb, bytes an elementMeasured growth at 262,144Measured growth at 1,048,576Measured growth at the largest chunk run
    Create a setup432272 to 339348 to 421349 to 390 at 3,355,444, to 1,263 MiB
    Create a request400272 to 339297 to 359354 to 390 at 3,355,444, to 1,263 MiB
    Answer a request544339 to 420438 to 529448 to 499 at 2,960,685, to 1,424 MiB

    Measured 2026-10-03 in Node in the development container, one call per process (packages/core/test/stress/wasmMaskingGrowth.stress.test.ts). Each figure is a range because the engine grows its memory past what a call asks for, by up to 96 MiB: the low end is the memory before the call's last growth, which the call outgrew, and the high end is the memory after it. At these figures a chunk at the policy's sizes fits the budget for every set up to the browser's ceiling of 8,388,608 elements and for creating a setup or a request at 2^24. Answering a 2^24-element request takes 6 chunks of at most 2,960,685 elements in place of the policy's 5 of 3,355,444. The last column is one call at each operation's largest chunk at 2^24, run once each by the same probe, and each stayed inside the budget.

The browser runs its masking across a pool of PSI workers. The web app creates its request across several PSI workers at once: each masks a contiguous part of the set under the round's one key, and the primary merges their outputs by the chunk rules above (ShardedPsiEngine, packages/core/src/psi/shardedPsiEngine.ts, run in the primary by apps/web/src/psi/workers/psiCryptoWorkerProgram.ts; the pool is apps/web/src/psi/workers/psiWorkerPool.ts). Nothing on the wire changes, and the pool needs no cross-origin isolation headers and no SharedArrayBuffer: each worker has its own WASM instance and memory, and the workers exchange only structured-clone messages.

  • Which operations. Creating the request is split. Creating the setup and the match run on one worker, the primary, so a count-only match counts the whole response once: counts summed over parts would count a value the partner repeated once per part. A set under 2,048 elements, two shards of PSI_SHARD_MIN_ELEMENTS (1,024), runs on the primary alone.
  • The PSI-C construction keeps its state on the primary. The primary holds pi, sigma and the receiver's state, and no helper is given them. Only the request is split, each helper masking its part in the identifier-revealing mode (shardHelperMode), which sets the same reveal flag. The primary checks and records the whole set, then masks the first part after every helper has its own, so a set it refuses fails only after the helpers' parts are sent. It answers every partner request whole, refusing one whose reveal flag differs from its own, which only a request in the library's cardinality mode has, or one of another size than the exchange's first. shardedPsiEngine.test.ts and psiWorkerPool.test.ts check that no helper answers, and psiEngineCountOnlyPermuted.test.ts the refusals.
  • The party answering the request runs one worker. The starter, which creates the setup and answers the joiner's request, runs the single-threaded path, one worker, on every platform. A pooled answer at BROWSER_PSI_SET_MAX_ELEMENTS ran the renderer out of memory where one worker completed the same round (measured below); the cause is not established. ShardedPsiEngine and the pool can split the answer, and the identity tests below cover that output, but the web app's engine factory starts no pool for the starter. psiWorkerPool.test.ts checks that the factory starts one worker for the starter and the shard count for the joiner.
  • The output is one engine's. The merged request and response are byte-identical to one engine's under the same key, and in the PSI-C construction under the same pi. packages/core/test/psi/shardedPsiEngine.test.ts holds this at 1 to 5 engines in both modes on both backends, apps/web/test/unit/psi/psiWorkerPool.test.ts over the worker program against an engine built from the pool's key, and apps/web/test/browser/psiCryptoWorker.test.ts over real workers in Chromium, running a test-only worker entry whose keys are fixed, against an engine on the page built from the same key. For the PSI-C construction, shardedPsiEngine.test.ts holds both on both backends, and psiWorkerPool.test.ts the request.
  • How many workers. browserPsiShardCount (apps/web/src/psi/workers/psiShardPolicy.ts): the logical core count navigator.hardwareConcurrency reports, at most BROWSER_PSI_SHARD_CAP, 4, the count the measurements recommend (wasm-parallel-evaluation.md), and one where the count is missing or unusable.
  • macOS runs one worker. A browser any of whose platform reports names a Mac runs the single-threaded path, one worker, because Chrome's renderer crashed in 3 of 123 sharded runs there (wasm-parallel-evaluation.md). apps/web/test/unit/psi/psiShardPolicy.test.ts tests the platform check, and psiWorkerPool.test.ts that the engine factory then starts one worker.
  • The key goes from worker to worker. The primary generates the key. For each helper the page creates a MessageChannel and transfers one end to the primary and the other to the helper, keeping neither; the primary posts each helper its own transferred copy of the key over that channel. A primary takes only the first key-ports message it is given, before any request and with a port for each helper its seed names, and sends a refusal down the ports of any other, so the one-worker path's primary, seeded with no helpers, sends its key to no port. A helper builds its engine only from the key that arrives on its one port, generates none, and takes its parts from the primary over that port alone, refusing every request the page sends it. psiWorkerPool.test.ts checks over real channels that no message between the page and a worker contains the key.
  • A helper that fails its setup leaves the run on the primary. A helper reports to the page once its engine is built from the key. The first request the pool creates waits for every helper's report, up to the progress timeout below. If any helper failed or has not reported, every helper is terminated and the run goes on the primary alone, the single-threaded path. A primary that fails to start sends each helper a refusal in place of the key, so the wait ends at once.
  • The primary merges the parts. The page sends the primary the whole operation and receives only the merged message, as on the one-worker path. Once every helper has set up, the page tells the primary so, and the primary splits the set, sends each helper its part over the channel its key took, masks its own part last, so that every helper has its part before the primary's thread is taken, and takes each helper's output back on the same channel. psiWorkerPool.test.ts checks that the only result a split posts to the page is the merged message.
  • A worker that stops fails the run. A fault in any worker during a split operation fails that operation, and so the run, by the path a fault in one worker takes. A helper's error event reaches the page and fails it at once. A helper's failed reply, or its report of a part it could not read, reaches the primary, which masks its own part without yielding and starts it last, so it fails the operation once that part ends, after at most the time the primary's part takes. No worker is retried or started again.
  • The progress timeout. A worker of a split operation that reports neither a processed count nor its result for BROWSER_PSI_SHARD_PROGRESS_TIMEOUT_MS, 33 minutes, is treated as stopped, and the run fails with Encrypting the data made no progress for 33 minutes. Run the exchange again. This catches a worker that ends with no error event. The primary times each helper, and the page times the primary, whose count rises with every part's. The primary's own part holds its thread until it ends, so a part that ends restarts the timers of the parts still running, whose counts may have waited behind it. The longest measured gap between one worker's consecutive progress events, below, was 0.58 ms per element of the chunk it was masking. A split operation at BROWSER_PSI_SET_MAX_ELEMENTS, 8,388,608, gives each of at least two workers at most 4,194,304 elements in five chunks of at most 838,861, about 487 s at that rate. The timeout is four times that, rounded up to whole minutes; masking is linear in the element count (above).

Measured 2026-10-20 in the development container (aarch64 Linux, 10 CPUs) in Playwright's Chrome for Testing 156.0.8078.4 run headless: the pool over the web app's own worker bundle, 100,000 values, identifier-revealing, the two parties in one page. The harness was a local script, not kept in the tree. Other work ran beside it, at a one-minute load average of 2.6 to 13:

WorkersCreate the requestAnswer the requestLongest gap between progress events
1, the single-threaded path49.9 s, 47.7 s37.4 s, 37.5 s10.4 s over a chunk of 20,000
224.9 s19.8 s5.3 s over 10,000
412.3 s, 13.7 s9.8 s, 10.1 s4.8 s over 8,333

The heap each round grew, in the same harness with --enable-precise-memory-info, measured the same day at a one-minute load average of 1.1 to 3.3: the page's usedJSHeapSize, and each worker's used heap as the DevTools protocol reports it, sampled through the operation, as its peak less its start. The table is the pool as built, the primary merging. Where the page merged instead, decoding the partner's whole request and each worker's output itself, its heap grew by 37 to 38 MB creating the request and 57 MB answering it on four workers, in the same times.

WorkersPage, creating the requestPage, answering itPrimary, creatingPrimary, answeringEach helper, creating and answering
1, the single-threaded path4 MB4 MB33 MB56 MBnone
47 MB4 MB23 MB57 MB6 MB and 10 to 11 MB

In both tables the pooled rows' answer figures measure the pool answering, which the web app does not run: it answers on one worker, the first row. At 100,000 values a pooled round grows the page's heap about as the single-threaded path does, and the primary's about as that path's one worker's.

At BROWSER_PSI_SET_MAX_ELEMENTS the pool was measured 2026-10-21 on the x86-64 host above, at the same memory and with nothing else running, in the same Chrome for Testing 156.0.8078.4 run headless: one round in each role through the wrap with browserWrappedRound.ts, its page building the engine from the web app's pooled factory (a local change, not kept in the tree), 8,388,608 values a side, identifier-revealing, 1,000 shared, the partner on the native addon. The page read 6 logical cores, so the pool ran 4 workers. The one-worker figures are the host's wrapped rounds above.

Joiner's workersWall timeCreating the requestThe matchWorker V8 heap peakPeak renderer RSS
1, the single-threaded path5,715 s2,966 s2,429 s2,561 MiB6,019 MB
43,865 s1,122 s2,422 s2,656 MiB in the primary; 618 to 625 MiB in each helper6,510 MB
  • The joiner's round completed. It matched the known overlap, and creating the request took 38% of its one-worker time. The match is not split, and took about the same time.
  • The starter's pooled answer failed. Creating the setup ran on the primary alone in 2,960.4 s, as on one worker. Within about 197 s of the pooled answer starting, the renderer stopped with V8 javascript OOM (MarkCompactCollector: young object promotion failed), raised in an isolate whose size and collections mark it as a helper, at 167 MB in use, far under its 4,192 MiB limit. The renderer's resident set peaked at 7,890 MB, against 7,379 MB for the whole one-worker round, and the host's available memory stayed at 17.1 GB or more. The starter therefore answers on one worker (above).

The cap: a (key, record) cell-count budget plus a derived byte cap. The single-pass ceiling is gated on the receiver's projected memory, which the exchanged record counts and the agreed key count bound -- never on a static frame size. (A "cell" is one (key, record) pair; a party's cell count keyCount * recordCount is the worst-case upper bound on its count of distinct (deduplicated) linkage-key values D, which is itself never computed or exchanged.) Two quantities, both in packages/core/src/connection/frameSize.ts, computed identically on both parties:

  • MAX_SINGLE_PASS_CELLS = 3,000,000 -- the per-party budget on the cell count keyCount * recordCount. It is the real ceiling. An exchange exceeds it (and aborts, below) when either party's keyCount * recordCount exceeds the budget. It is a cell-count budget, not a bare row count, because a rows-only cap is off by ~14x between a 1-key and a 14-key linkage at the same memory; it admits ~214k rows at the ~14-key default template and ~3M rows at a single key. Methodology and value. The budget is derived from a near-ceiling measurement -- a forked-process run of the real linkage with the phase-boundary churn relief (above) active, at D ~= 2M distinct values (the worst-case keyCount * rows), not extrapolated from a low-D fit. That run put the binding peak -- the heavier party, which at scale is the sender (it holds its own encrypted setup plus the re-encrypted request resident at once) -- at 2,620 MiB relieved, the receiver at 1,513 MiB, over a WebAssembly linear heap of 175 MiB on each side and a post-GC live heap of 114 MiB on the sender and 121 MiB on the receiver. It was measured 2026-10-08 in the development container (10 CPUs, 23 GiB, Linux arm64) by node scripts/single-pass-bench.mjs sweep --gc --keys 14 --sizes 142857 (D = 1,999,998 a side), once, on the WebAssembly backend, in 59 minutes. Projected linearly to the 3M ceiling the sender peak is about 3.8 GiB -- comfortable headroom on a 16 GB target. With the relief active the binding constraint is not receiver memory but the WebRTC per-frame envelope: the cap is shared and transport-agnostic. The derived reply byte cap at the ceiling (~251 MiB, below) must stay below MAX_WEBRTC_FRAME_BYTES (256 MiB) so a legitimate single-pass reply is never rejected mid-exchange on the WebRTC path. 3M is the largest round value that keeps that envelope from binding. A browser, which never exposes gc and so runs the unrelieved peak, is expected to hit its own memory wall near the same scale (estimated from the relief factor, not separately measured), so raising further would benefit only file-sync and would require decoupling the cap per transport (a separate change). The true D is lower for sparse or repeating keys and is never exchanged (it would leak each key's duplication and sparsity structure -- see Role resolution and work minimization below), so gating on the cell-count upper bound is conservative by construction. It is a fixed constant, not operator-configurable -- a configurable maximum reintroduces the memory-exhaustion denial of service -- and may be raised only after a further measured reduction of the transient peak, re-derived from fresh measurement (and, past the point where the derived reply cap reaches the WebRTC envelope, the WebRTC reassembly path reworked to fail closed rather than mid-frame).

  • singlePassReplyByteCap(keyCount, sender, receiver), over each party's authenticated { effectiveKeyCount, recordCount } -- the accepted byte size of the reply frame, a defense-in-depth tightening of the read gate derived from the same authenticated counts so a hostile transport admin cannot pad a small exchange's frame up to the static ceiling. Operation order and rounding are fixed so two independent implementations produce the bit-identical integer. For a width-free exchange, where each party's effectiveKeyCount is the agreed keyCount, and with sCells = keyCount * senderRecordCount and rCells = keyCount * receiverRecordCount, the cap is:

    (40 + 4 + 4) * sCells + 40 * rCells + 256

    in exact integer arithmetic. Coefficients and their meaning:

    TermCoefficientRationale
    Sender masked value40 bytesDeliberate upper bound on the 35-byte serialized encrypted element (above)
    Sender index-table cell4 bytesOne Int32 per (key, record)
    Sender candidate-count prefix4 bytesOne Int32 per (key, record), charged on every exchange because a sender's own standardization can put it in the ragged layout with nothing in the agreed terms to show it (The width bound)
    Receiver masked value40 bytesSame as the sender's masked value
    Overhead (+ 256)256 bytesThree uint32 length/count prefixes plus the AEAD and file-sync message envelopes the read gate measures around it

    Each coefficient is an upper bound, so the cap never rejects a legitimate frame. The receiver enforces it at its transport read gate (the maxBytes on FileSyncMessageLoop's get(), threaded via setInboundFrameCap), and the sender checks its built reply against the same value before sending -- one computation. It is transport-aware via per-transport clamps: file-sync uses the derived cap up to MAX_FRAME_SIZE_BYTES; the WebRTC data channel keeps its fixed browser-tab envelope MAX_WEBRTC_FRAME_BYTES (256 MiB) at its reassembly read gate (see TRANSPORT_BOUNDS.md). At the cell-count ceiling the derived reply frame is about 251 MiB -- below both envelopes, though the 256 MiB WebRTC envelope is the nearer one -- so the per-transport clamp does not bind there, and the cell-count budget is what gates first.

Under a declared width a record can hold several values for one key, which falsifies the keyCount * recordCount assumption both quantities rest on. The generalization -- the (key, record) cell becomes a value slot, keyCount becomes the effectiveKeyCount both parties derive from the agreed terms, and recordCount becomes the count each party declared -- is specified with the width bound in Fan-out matching, with the cell-count budget unchanged in value. It leaves every number in this section unchanged for a width-free exchange.

The receiver additionally validates every count it decodes from the reply against authenticated state before it drives any allocation: the sender's row count packed into the reply must be no greater than the record count the sender held on the terms exchange, and must divide it into a whole fan-out factor. And the index table the frame actually holds must have exactly keyCount * senderTableRows entries -- or, from a sender shipping the ragged layout, the cell structure that layout's own checks require (Fan-out matching).

Declared PSI element counts are bounded by a wire-format scan before deserialization, on both the single-pass and the cascade decode paths. @openmined/psi.js deserializeBinary allocates one JS byte-slice object -- measured ~211 bytes -- per declared repeated bytes entry, so a frame within the byte cap yet packed with minimal (~2-byte) entries (up to ~frameBytes/2 of them) would exhaust memory inside deserializeBinary itself, before any post-deserialize count could run. The figure is 210.4 to 210.5 bytes of retained heap per entry, measured 2026-10-08 in the development container (Linux arm64) as heap retained after deserializeBinary of a request, a response and a server setup of 1,000,000 empty entries each, three runs. The participant (packages/core/src/psi/participant.ts) therefore scans the protobuf wire format and counts the declared elements without materializing them (countDeclaredPsiElements, connection/psiElementScan.ts), rejecting an over-declared frame before deserializeBinary is called; the scan stops once the count exceeds the ceiling, so it costs O(ceiling), not O(frame). The ceiling is the smaller of the authenticated keyCount * recordCount bound for the relevant party -- the sender's set for a setup, the receiver's for a request, via psiElementBounds in frameSize.ts; for a response, which re-encrypts this party's own request, that request's element count -- and the absolute MAX_PSI_DECODE_ELEMENTS = 16,777,216, the protocol's per-set maximum (A PSI set is sent in parts). It reads only authenticated session state, so both parties compute it identically and no legitimate frame is rejected. The streamed match does not deserialize a setup, but its engine keeps a fixed slot per element it reads, so the same scan bounds the setup; a cascade or count-only setup is scanned part by part as it arrives, carried from part to part (PsiElementScan), and a part that takes the count past the ceiling is refused before it reaches the engine. This binds a cascade frame whose partner over-declares its record count and caps the worst-case deserialize allocation at ~3.3 GiB. The scan reads only the stable protobuf wire format but assumes the element list's nesting depth per message kind, pinned by a scan/library equivalence test to re-verify on a library upgrade. See TRANSPORT_BOUNDS.md.

What a both-sided table costs, measured at quadratic scale. The budget above counts value slots, which a deduplicating cardinality does not add to (The per-side rules). What a both-sided run adds is PAIRS, and those grow as one party's rows times the other's, so they are measured on their own. The both-sided mode of scripts/single-pass-bench.mjs runs the real linkViaSinglePassPSI at many-to-many with each side in its own process, exactly as the sweep mode does, every row of both parties holding one shared value on the first key -- so the resolved table is the whole rows x rows product, which is the pair-count bound reached exactly rather than approached. The receiver runs the closure check over that table and the round's blocks inside the measured path, exactly as it does in an exchange. Measured on aarch64 under Node 26 in a Linux container, one key, each party a single-threaded forked process, over the 500/1000/2000/3000-row ladder the mode defaults to -- 0.25M to 9M pairs:

  • every per-pair figure below is the slope of a least-squares regression over the four sizes, not a peak divided by a pair count. One size reproduces none of them: at 0.25M pairs a side's whole peak sits at its intercept, which is also why the mode's own bytes/pair column -- the peak over the pair count -- reads several hundred at the small rungs and falls toward the slope at the large ones;
  • each side's peak resident set is linear in the pair count above an intercept of ~0.07-0.14 GB on the sender and ~0.16-0.22 GB on the receiver, at about 206 bytes per pair on the resolving receiver -- which builds the table, holds the round's blocks, and runs the closure check over both -- and 60 to 100 bytes per pair on the sender, which receives the table and checks it;
  • at 9M pairs that is 2.0 GB on the receiver and either 0.64 GB or 0.97 GB on the sender, with the receiver's whole exchange at 17 to 23 s;
  • what reproduces across ladders is the slopes and the largest rung -- the receiver's 9M-pair peak at 2.0 GB, the sender's on one of its two values -- while a smaller rung does not: the receiver's 1M-pair peak ranged from 0.41 to 0.52 GB across ladders. A re-measurement compares the fitted slopes and the 9M rung, never one smaller rung;
  • two limits bind a large run before the pair-count bound does. The advisory above (10,000,000 projected pairs) names one; the channel's per-frame bound on the frame holding the table is the other, at ~10 bytes of wire per pair (TRANSPORT_BOUNDS.md), which admits about 27M pairs within MAX_WEBRTC_FRAME_BYTES and about 54M within MAX_FRAME_SIZE_BYTES -- 5.6 GB and 11.1 GB of receiver peak at the slope above.

So the pair-count bound states the honest maximum rather than a memory budget: what it refuses is a table no run of the two datasets could have produced, and what keeps a legitimate run inside a machine is the frame bound beneath it and the advisory above it. Tightening it below the product would refuse an honest run instead. Re-measure it if the closure check's per-pair retention changes; the figures move with the implementation exactly as the per-value constants above do.

The exact-integer precision of the cell-count gate is a check, not an implicit dependency. The gate above decides keyCount * recordCount > MAX_SINGLE_PASS_CELLS, sound only while that product is exact -- below Number.MAX_SAFE_INTEGER (2^53). Every decoded record count has an explicit MAX_RECORD_COUNT (1,000,000,000,000) upper bound at its wire schema (recordCountField on the terms-exchange envelope, packages/core/src/protocolSetup.ts) that guarantees it: with the key count bounded at MAX_LINKAGE_ENTRIES (256), the product at both maxima is 2.56 x 10^14, about 35x below 2^53, so it is always exact. Under a declared width the gate multiplies the effective key count, bounded at MAX_EFFECTIVE_KEY_COUNT (5,120), and the product stays exact with about 1.8x headroom (The width bound). The bound is astronomically above any legitimate dataset (the frame cap admits ~15M curve points; the cell budget is 3M), so it never rejects a real count; it makes the gate's exact-product dependency an executable check that survives a future migration to BigInt or a wider numeric type, rather than resting silently on the schema's .int() safe-integer ceiling.

Exceeding the ceiling aborts; it does not route to cascade. linkage_strategy is mandatory-consistency (above), so it cannot change unilaterally mid-exchange. An over-ceiling exchange therefore fails closed with actionable guidance that does not recommend cascade (reduce the key or record count, or split the dataset); re-agreeing on cascade is a separate out-of-band step. Because the ceiling is computed from authenticated counts, both parties abort in lockstep before any single-pass frame moves, so neither hangs. The budget is per party, so the guidance is attributed: it states each over-budget party's own declared width, record count, and their product -- the quantities the gate multiplied -- names whether this party's own declaration, the partner's, or both reached the budget, and offers the dataset remedies only to a side that can apply them. A party within the budget is told so and is not sent to its own configuration for a limit the partner's declared width reached. The attribution is a label on a verdict both parties compute identically, so the two orientations mirror each other and the abort itself stays symmetric. (Cascade also discloses less, so a silent downgrade would not be a privacy hazard in itself -- the blocker is the consent invariant, that both parties agreed to single-pass specifically, plus the added round trips.) An in-protocol consented fallback remains possible future work, not the default.

This ceiling refuses where the many-to-many pair table only warns above its advisory bound, and the difference is what each binding quantity is bounded by rather than a difference in tolerance: nothing in the specified checks bounds the receiver memory this ceiling defends, while the returned-list checks already bound that table at one party's own product. The comparison is stated in full there.

Matching multiplicity: the (record, value) incidence ​

A PSI base function round matches values; an association table pairs records. What joins them is the round's incidence: the set of (record, value) pairs a party contributes to one linkage key's round, where the record is a row of that party's dataset and the value is a realized linkage-key string that row holds for that key. Both matching multiplicities this document specifies are properties of that incidence, and naming them apart is what lets each be specified once:

  • Values per record -- how many distinct values one record contributes to one key's round. One in the plain case, zero for a NULL/absent realization (Key input data), and more than one where a producer fans the record out: split_on today (Fan-out matching), and generate_fuzzy_comparisons when its matching lands.
  • Records per value -- how many of one party's records contribute the same value to one key's round. More than one is what deduplicate asks for (Deduplicating cardinalities). A value several of a party's own records hold is exactly what a one-to-one round cannot attribute to a single record, which is why one-to-one drops it.

The attribution rule. A round yields value-level matches: pairs of equal values, one contributed by each party. Each is lifted through the incidence on both sides into the record-level candidate pairs it implies -- (s, r) is a candidate pair when sender record s and receiver record r each contributed the matched value to that round. Several equal value pairs between the same two records collapse to one candidate pair. Every cardinality and every fan-out rule in this document is a rule about which of a round's candidate pairs are accepted into the association table and which records leave candidacy; none of them changes this lift.

The two axes are bounded separately, and only one reaches a frame's shape. A widening on the values-per-record axis raises a party's distinct-value count above the keyCount * recordCount assumption every derived single-pass bound rests on, so a producer on that axis owes a per-(record, key) width bound and the slot arithmetic derived from it (The width bound). It also changes what a frame must be able to express: one cell holding several values. A widening on the records-per-value axis cannot raise a distinct-value count at all -- it only stops values being dropped, and a record still holds its one value per key -- so it moves no derived bound and needs no new cell shape. What it owes instead is a statement of which side holds the multiplicity and how each side's within-round uniqueness rule differs.

The axes are independent and compose: a party may both fan out and deduplicate. Each producer fixes its own width, resolution, and cap; this section fixes only the incidence and the attribution rule they share.

Fan-out matching: multi-value key candidates ​

A fan-out is a standardization step or linkage-key element transform that realizes one record's value for one linkage key as several candidate values rather than one. split_on (EXCHANGE_REFERENCE.md) is the only fan-out. Both of its authoring surfaces fan out: a fan-out inside a key element's transform contributes its candidates to that element's position in the key's cross-product, exactly as a fan-out on the field feeding the element does, so one semantics and one bound cover both surfaces. generate_fuzzy_comparisons and a key declaring swap also produce per-record candidate sets, and those sets run through the same candidacy and resolution rules below. generate_fuzzy_comparisons contributes its own factor to the width its key declares and is held to that width, on the exceedance fate of its own (The width bound). The rules below are parameterized by the resolved cardinality (deduplicate resolves it, resolveLinkageCardinality in packages/core/src/exchange.ts) and specify the one-to-one case only; a deduplicating cardinality relaxes the resolution's acceptance clause through that same boundary (Deduplicating cardinalities). The design rationale, the alternatives set aside, and what stays open are in fan-out-matching-resolution.md.

Fan-out runs under both linkage strategies ​

Fan-out matching runs under linkage_strategy: cascade and under single-pass, on the one resolution both realize. The single-pass receiver holds the sender's whole per-key candidate structure and replays the cascade locally, so it is the only resolver in the exchange and divergence between the two parties' association tables is structurally impossible rather than mitigated. The cascade reaches the same table by a different route -- each party resolving its own round from the two groupings the round's frames hold -- specified in Per-round candidacy under cascade, with the disclosure and the trust statement that route takes on. Which strategies resolve a candidate set is an allowlist rather than a comparison against a named one, so a linkage_strategy added later refuses one until its own resolution is written (The combinations that stay unsupported).

The refusals a combination outside that list is held to. They are the parse and the three further points the psi-c refusals use: at each parse of the terms document, the invitation-token decode included; when the terms are authored or an invitation over them is minted; at the local prepare step, before any credential, terms, or data are sent; and at the agreed-terms run boundary, the first point at which both parties' values are known. linkage_strategy is a mandatory-consistency agreed term, so the run-boundary check is a symmetric function of the agreed pair and both parties refuse in lockstep. Each refusal is fail-closed: a candidate set is never narrowed to matching on one candidate, never silently promoted to another strategy (the strategy is what both parties consented to), and never run.

One half of that refusal is asymmetric, and where it fires decides what the partner sees. A field-level fan-out is authored in a party's own data standardization, which is per-party and local -- no invitation includes one -- so the partner cannot derive the refusal from the agreed terms, while an element-transform fan-out rides those terms and both parties refuse it in lockstep. The local half MUST therefore be refused at the prepare step, before any connection is opened, so the ordinary case is a configuration failure in front of the operator rather than a partner left waiting. A party that reaches the run boundary still holding it aborts the exchange with the reason, before any linkage frame moves, rather than proceeding into rounds the partner would run alone.

psi-c refuses a candidate set under either strategy, as an interim limit on its own terms: a count over candidate sets could depend on the order its resolution meets them in, which the count-only permutations hide, and no order-independent resolution or padded layout is specified for it (Refusals under count-only). That refusal is reached by the whole producer list rather than by step name -- a split_on fan-out, a generate_fuzzy_comparisons expansion, and a swap key's second order alike -- at the parse and each of the three further points above (The combinations that stay unsupported).

Value-level round participation ​

Fan-out widens the values-per-record axis of the round's (record, value) incidence, and the attribution rule that lifts a value-level match back to records is fixed there. Each candidate value is one PSI set entry, and every entry keeps the original row index it came from, so a match resolves back to its source record.

  • A record's candidate set for a key is a set: two candidates that realize to the same string collapse to one entry, so a record contributes each distinct value once.
  • The within-dataset uniqueness rule applies per value, not per record: a value held by two or more of this party's candidate records in that round is ambiguous and is dropped from the round, while those records' other candidate values remain in it. A record therefore participates with the subset of its candidates that are unique within the round.
  • A record whose candidate set for a key is empty -- the NULL/absent realization -- sits out that round and remains eligible for later keys, unchanged.
  • A record whose every candidate is a within-dataset duplicate participates with nothing, which is the single-valued rule's behavior reached value by value.
  • The empty string "" remains a fully supported candidate value, subject to the same uniqueness rule (see Key input data).
  • The single-pass distinct-value union pools candidates across keys exactly as it pools single values today, so a candidate value appearing under more than one key is encrypted once and has no per-key tag.

Because uniqueness is evaluated over the records still in candidacy, a value that is ambiguous in one round can become unique in a later one once the record holding the other copy has left candidacy -- the property the cascade already relies on, restated for candidacy rather than for matches.

Removal on a potential match ​

Round j produces a set of value-level matches, which the attribution rule lifts into the round's record-level candidate pairs -- here, (s, r) is a candidate pair when some participating candidate value of sender record s for key j equals some participating candidate value of receiver record r.

Every record appearing in any of round j's candidate pairs leaves candidacy for every round after j, whether or not the resolution below pairs it. A record that fans out therefore gets one chance to match, on the most precise key any of its candidates matches under, and cannot double-match on a less precise key. A record whose candidates matched but which resolution left unpaired ends the exchange unmatched: the evidence was contradictory, and the cascade's rule is that contradicted evidence does not continue.

Record-level resolution: canonical order and tiebreak ​

The resolution is a deterministic greedy sweep. For each linkage key j in the agreed key order:

  1. Form round j's record-level candidate pairs as above.
  2. Order them lexicographically ascending by (sender row index, receiver row index).
  3. Sweep in that order, accepting a pair when neither of its two records has already been accepted in this round, and discarding it otherwise.
  4. Accepted pairs enter the association table, attributed to round j.
  5. Every record in any of the round's candidate pairs -- accepted or discarded -- leaves candidacy for rounds after j.

The order is fixed normatively because it decides which of two ambiguous matches wins. Both of its components are role-derived rather than local: sender and receiver are fixed for the exchange by role resolution, so the order is the same one whichever party evaluates it, and it is the order message 3's local half already holds (that half is the sender's own rows in ascending order, enforced at the single-pass boundary). Nothing in the rule reads a hash-map iteration order, a value's bytes, or an encrypted element's position, so an independent implementation reproduces the same table from the same frames.

The exchange record is unaffected by design: its commitments cover the association table's row-index pairs and the payload values, never key strings (EXCHANGE_RECORD.md). The record-level table stays the only committed matching artifact, which is why the determinism of this rule -- not the candidate structure underneath it -- is the critical property.

Equivalence with the single-valued cascade ​

On inputs where every (record, key) candidate set holds exactly one value, the rules above reduce to the existing semantics: each round's candidate pairs form a partial one-to-one correspondence, so the sweep accepts every pair and removal on a potential match coincides with removal on a match. A fan-out-free exchange therefore produces the byte-identical table it produces today, and the two strategies' equivalence claim in Linkage strategies is unchanged.

For fan-out inputs the cascade's realization is specified below, and it MUST produce the identical table to single-pass on the same inputs, over every shape a candidate set reaches under a cardinality both strategies match -- one crossing distinct partner groups included (The final mapped-element entry names the positions its record's pairs rest on) -- which is why the resolution rule is fixed here, once, rather than left to whichever strategy realizes a candidate set second. What holds both strategies to it is one shared sweep and the differential vectors (What the cascade realization owes).

The width bound: a per-key candidate cap the terms declare ​

One record contributes at most w[j] candidate values to linkage key j's round, where w[j] is the width the agreed terms declare for that key. Both parties derive the same vector from terms they have both agreed on, so no width is exchanged, no party reads the other's declaration, and each holds its own rows to the number the other will hold its frame to. The bound is on the assembled candidate count for one (record, key) -- the cross-product across the key's elements, after the deduplicating set -- and it is enforced by each party on its own rows.

How a key's width is derived. w[j] is the product over key j's elements of each element's own candidate factor:

  • 20 for an element whose transform declares a fan-out function (split_on). The value is fixed by the protocol and not operator-configurable, for the same reason the single-pass budget is not: it is what a partner-supplied frame's bounds are derived from. The 20 itself is an arbitrary working value, neither derived from a measurement nor a privacy or consent bound. It can be raised on request; a raise is a protocol change for both parties, bounded by MAX_KEY_CANDIDATE_WIDTH and MAX_EFFECTIVE_KEY_COUNT below.
  • The kind's fuzzy ceiling for an element declaring generate_fuzzy_comparisons: the most candidates that kind can realize from one value, counting the value itself. Every kind's count is a function of the width w of the value it is handed: adjacent_years = 3 (the value and the year either side, whatever its width), adjacent_days = 3 (the value and the day either side, whatever its width), day_month_swaps = 2 (the value and the one date its day and month exchange to, whatever its width), edit_distances = w + 1 (one deletion per code point, plus the value), and transpositions = w(w - 1)/2 + 1 (one swap per pair of positions, plus the value). Each is taken at the width the element's own transforms bound its value to (below). An element whose transforms bound no width takes MAX_FUZZY_EXPANSION_INPUT_LENGTH = 128, the longest value the expansion accepts at all, and a bound above that is clamped to it, since a wider value is refused rather than expanded.
  • An element declaring both contributes their product; an element declaring neither contributes 1.
  • 2 for a key declaring swap, whatever its elements declare: the receiver assembles the key in the authored order as well as the exchanged one (Swapped keys), so a record contributes at most twice the product above.

How an element's width bound is derived. The bound is read off the element's transform chain, walked in order, since each step is handed the previous one's output; the last step to fix a width governs. A substring with a positive length, a phonetic code, and a parse_date output layout each fix a width from their declared params alone, whatever value they are handed, so each replaces the width handed to it. A zip3 and a state_code fix a width of 3 and 2 characters whatever value they are handed, and replace the width handed to them the same way. A pad_left fills up to its length and truncates nothing, so it raises a width already handed to it and fixes none by itself. A null_if or filter_regex returns the value it was handed or drops it, so it passes a width through. Every other step clears the bound, because its output width is not a function of the terms: a replace_regex replacement re-inserts the matched context at every match position, a case fold can emit more code points than it read, and Unicode normalization is not length-preserving either. An element left with no derivable maximum takes the 128-character limit above. Both parties read the same agreed terms, so both derive the same bound and the same ceiling from it, with nothing exchanged.

The factors compound rather than taking the larger of the two. The key builder assembles a key from the cross-product of its elements' candidate lists, so a key whose elements both expand realizes their product; declaring the larger would under-declare against what the row builder assembles and refuse honest rows at the width bound.

A party declares the fuzzy factor for a key regardless of the PSI role it goes on to resolve, including for an expansion only one of the two parties executes. The width is fixed by the agreed terms, before either party holds the other's record count, so no party knows its role when the number is fixed and each derives the widest case. The cost is stated rather than closed: a party that does not widen over-declares its slots and can refuse at its own single-pass ceiling an exchange that would in fact have fit -- a permissiveness and frame-size cost, not a correctness one.

Two ceilings bound the derivation. A single key's width MUST NOT exceed MAX_KEY_CANDIDATE_WIDTH = 1,024, and the sum over the agreed keys MUST NOT exceed MAX_EFFECTIVE_KEY_COUNT = 5,120. Terms crossing either are refused where the width is derived -- before any row is read -- rather than dropping or refusing every row later. The per-key ceiling is the count limb of the key-string assembly cap (MAX_KEY_STRINGS_PER_ROW, which is MAX_KEY_CANDIDATE_WIDTH itself; see TRANSFORM_PARAMETER_BOUNDS.md), so a key the terms declare wider than one row can be assembled for is refused rather than left to drop every row. The sum ceiling is what keeps the slot arithmetic's exact-integer assumption where it has always been (below).

A party's own standardization declares no width at all. Cleaning that fans out is per-party and local: the partner cannot see it, and a party that pre-fanned its file outside Alcove would present the same wire behavior. Such a party therefore multiplies its declared record count by the fan-out factor and declares no extra width. The factor is 20 when a linkage field read by at least one element of an agreed linkage key is cleaned by a pipeline declaring a fan-out step, and 1 otherwise; a fan-out on a field no key reads contributes no candidates, so it declares nothing. Its per-(record, key) bound is w[j] times that same factor, so the two multiply back to exactly the value slots its declared record count buys it. The one consequence to state is that role resolution reads the fanned count, so a fanning party declares the larger of the two counts and trends toward sender -- proportionate to the work its fan-out actually costs, and the direction that keeps the declaration safe (below).

The two producers take different fates on exceedance. For a fan-out the cap is a drop, specified in the paragraph below. For generate_fuzzy_comparisons it is a refusal when no declared fan-out also widened the row: the exchange ends, and the row is neither narrowed to part of its candidate set nor dropped. The drop check runs first, so a row a declared fan-out also widened keeps that warned-drop fate instead of the refusal. What separates them is what the terms declare. A fan-out's candidates are one record's several legitimate values, and dropping the record from that key's round leaves it eligible for the rest. Every candidate a fuzzy element declares, by contrast, is one the consent surface states matches independently. So a row contributing part of its set, or none of it, would match on less than the terms describe, with nothing said. The refusal is also what keeps the declared width accurate: the width the terms declare for a fuzzy key is the cap, so a wider row has no slot to occupy.

A record whose candidate count for a key exceeds that key's width contributes no candidate for that key round, exactly as an absent (NULL) realization does, and remains eligible for later keys; the drop is warned to the operator. It is not a run refusal: the transforms are partner-authored while the values expanded are this party's own rows, so failing the run would let a partner end an exchange by authoring a delimiter that shatters one local value. The separate resource refusal on the key-string cross-product (MAX_KEY_STRINGS_PER_ROW = 1024) stays where it is: it bounds the allocation of one row's cross-product and binds the candidate producers the width-bound drop does not cover. That gap is multiplicity that reaches the refusal from a producer unlisted in FAN_OUT_FUNCTION_NAMES (latent until a registry edit adds one); such multiplicity reaches this refusal rather than the drop, so it stays fail-closed. Where the two meet -- a fan-out whose cross-product is too wide to assemble at all -- the drop wins over the refusal, and it is taken on the projected combination count rather than the assembled set, because the assembled count is not knowable without the allocation that cap exists to prevent.

How the per-row warnings are coalesced. A key round raises two kinds of per-row line, each taken on the shape of the terms rather than the size of the data, so terms that reach one of them reach every row of an arbitrarily large dataset. The first is the drop above. The second is the privacy advisory for a record whose assembled candidate set exceeds 20, the fan-out factor -- a record that still contributes every one of those candidates to the round, which is what distinguishes it from a drop. Both are coalesced identically, and each spends an allowance of its own: a round warns at most MAX_ROW_LINES_PER_KEY_ROUND = 5 rows of each kind individually -- a drop line naming the row index, the key, and the bound crossed, an advisory line naming the row index, the key, and the count assembled -- and suppresses the individual line for every row of that kind after the fifth. The round's consumer closes it once it has read as far as it ever will. The rows a round is asked for are the consumer's to know rather than the round's (the cascade reads only the rows still unmatched after the previous key). The close emits one line per kind, stating that kind's total for the key and how many of those rows the individual lines did not cover. A kind that reached no row, or few enough that every one was reported individually, closes silently, and a close repeated with no further rows of that kind emits nothing. A round that reaches further rows of a kind after a close reports them at its next close as a count of additional rows against the total the previous line stated, the individual-line allowance being already spent. No line names a value from either party's rows.

Why the bound is not optional hygiene. Every derived single-pass bound rests on the assumption that keyCount * recordCount upper-bounds a party's count of distinct linkage-key values (see The single-pass dataset ceiling). A width falsifies that assumption: a record can hold several values for one key. The cap restores it in a form still derivable from the agreed terms and the exchanged counts, by replacing the (key, record) cell with a value slot -- one candidate value one record contributes to one key round:

  • effectiveKeyCount is sum(w[j]) over the agreed keys, a key with a missing-field condition counted once for each of its two rounds. It equals keyCount, which counts rounds the same way, exactly when no key's elements declare an expansion, and it is the same number on both parties, since both derive it from the same agreed terms.
  • slots = effectiveKeyCount * recordCount is a party's authenticated upper bound on its distinct-value count D, over the record count that party declared. It replaces keyCount * recordCount in every derived bound: the per-party single-pass budget (MAX_SINGLE_PASS_CELLS, a budget on value slots, unchanged in value), the per-message PSI element bounds (psiElementBounds: the sender's slots bound a received setup, the receiver's bound a received request), and the reply byte cap below.

The budget therefore buys width with rows, and what it admits depends on the whole template rather than on the widened key alone. In the ~14-key default template, one key with a fan-out element gives effectiveKeyCount = 33 and admits ~90k rows where that same template fan-out-free admits ~214k, and a fan-out element on all 14 of its keys admits ~10k. In a one-key template whose single element declares a fan-out, effectiveKeyCount = 20 and it admits ~150k rows; whose single element declares edit_distances with no transform bounding its width, effectiveKeyCount = 129 and it admits ~23k. An element declaring both multiplies the two factors, so its width turns on how tightly its transforms bound the value. An element that splits its cell and then takes a substring of 10 characters declares 20 x 11 = 220 and admits ~14k, while the same element with no width bound would declare 20 x 129 = 2,580. That is above the per-key ceiling, so those terms are refused where the width is derived rather than admitting any rows at all. Every row figure here is MAX_SINGLE_PASS_CELLS divided by the effective key count beside it. A party whose own standardization fans out declares 20 times its rows, so it reaches the same ceiling at 20 times fewer of them. That is the real cost of a bound that must hold on the worst case, since the true per-record candidate count is data-dependent and is never exchanged (it would disclose the same duplication and sparsity structure the distinct-value count does, Role resolution and work minimization).

The reply byte cap becomes, with sSlots and rSlots the two parties' slot counts and sCells = keyCount * senderRecordCount:

(40 + 4) * sSlots + 40 * rSlots + 4 * sCells + 256

in exact integer arithmetic. The last term is the per-cell candidate-count prefix the ragged index table below holds, and it is charged on every exchange rather than only where the agreed terms declare a width. A party whose own standardization fans out ships the ragged layout while those terms show no width at all, so no function of them can tell in advance which layout a legitimate sender will ship. A read gate that undershot would reject a legitimate frame, where overshooting only loosens defense-in-depth. At the slot ceiling the cap reaches about 251 MiB, still below the 256 MiB MAX_WEBRTC_FRAME_BYTES envelope, so the per-transport clamp does not bind and a legitimate reply is never rejected mid-exchange.

The exact-integer assumption holds where it was. The cell-count gate's soundness depends on its product being exact (the ceiling section). The ceiling binds the sum of the per-key widths at MAX_EFFECTIVE_KEY_COUNT = 5,120, so the product with MAX_RECORD_COUNT (10^12) is 5.12 x 10^15, still below 2^53 (about 9.01 x 10^15) and so still exact, with about 1.8x headroom. Bounding the per-key width alone would not do: 256 keys at MAX_KEY_CANDIDATE_WIDTH would be 262,144, whose product with MAX_RECORD_COUNT is 2.6 x 10^17 and loses precision. The invariant that pins the headroom is therefore pinned against the effective key count, and raising MAX_EFFECTIVE_KEY_COUNT requires re-deriving it.

Where the expansion runs: after the element transform, into the cross-product ​

Both parties MUST derive byte-identical key strings from the agreed terms, so the order in which a key's steps compose is normative rather than an implementation choice. For one (record, key), the order is:

  1. The record's own value for each element's field, as this party's local standardization realized it -- one value, or several where a fan-out realized several (Key input data).
  2. That element's transform pipeline, applied to each realized value in turn, its outputs collected into the element's deduplicated candidate list.
  3. The element's generate_fuzzy_comparisons expansion, applied to each candidate in that list, where this party is the one that expands the declared kind (Which party expands). The expansion's output leads with the candidate it was handed, so the exact value matches whether or not the expansion widens it.
  4. The cross-product across the key's elements, one candidate from each, concatenated in the elements' declared order -- both orders on the receiver of a key declaring swap (Swapped keys).
  5. One NFC normalization of each assembled string, and the deduplicating set that yields the record's candidate values for the round.

The expansion runs on the transformed value, not the raw one. The transform is what puts a value in the canonical space the partner's own value occupies, and a candidate can match only there: adjacent_years, adjacent_days, and day_month_swaps locate a year, month, and day at fixed offsets, which only a parse_date output layout guarantees. The reverse order would also feed each candidate back through a pipeline free to collapse several of them onto one string or to filter one to null, silently narrowing the candidate set the terms declare and the consent surface states. It is the order the width derivation assumes as well: an element's fuzzy ceiling is taken at the width its own transforms bound its value to (The width bound), which is the width of the value step 3 is handed.

The final normalization is on the assembled string. Each part is already NFC, but NFC is not closed under concatenation -- joining two normalized parts can cross a base plus combining-mark boundary that itself composes -- so the pass in step 5 is what makes the key string canonical, and it covers every candidate an expansion produced.

Which party expands: full-variant and deletion-neighbourhood expansions ​

A generate_fuzzy_comparisons kind, and the swap directive beside it, are one of two mathematically distinct expansions, and which of the two a kind is decides how many parties execute it. The classification is total over the kinds, a function of the kind alone, and identical on both parties (expandsOnReceiverOnly, packages/core/src/fuzzyComparisons.ts). The model, the alternatives weighed, and the involution argument in full are in one-sided-fuzzy-expansion.md.

Full-variant enumeration: exactly one party expands. transpositions enumerates every two-position transposition of the value -- all pairs, not adjacent positions alone -- adjacent_years enumerates the year either side, adjacent_days enumerates the day either side, day_month_swaps enumerates the one date a value's day and month exchange to, and a key's swap enumerates the two orders of its named pair. Each is the whole set of values at that distance from the record's own, so a partner holding any one of them meets it on that partner's exact value, with no expansion of the partner's own. A second party enumerating adds no match the first does not already reach: a shared candidate needs one variant from one side, not one from each. What it does add is its own candidate count -- work, value slots, and additional strings that can collide with a third record -- so a full variant is executed on one party alone.

Deletion neighbourhood: both parties expand. edit_distances enumerates the single-character deletions of the value, which are the values within one edit distance that are shorter than it -- a neighbourhood, not the full variant set. A substitution or an insertion between two records is reached only where each side's deletions meet in the middle, so expanding one side alone would match on less than the terms declare. Enumerating the full variant set instead is not an option at any width: it is the alphabet times the length.

The expanding party is the resolved PSI receiver. Where an expansion is one-sided, the party that executes it is the receiver role resolution already determines from the exchanged record counts (Role resolution and work minimization) -- the same party the swap directive has always been keyed on. This is a local execution choice, not an agreed term: it moves no term, no terms hash, and no wire byte, and both parties compute the classification from the agreed kind. A party that does not expand contributes its exact value for that element.

The intersection does not depend on which party is the receiver. Each full-variant expansion is an involution on the pair it relates: b is a transposition of a exactly when a is a transposition of b, and the same holds for a one-year shift, for a one-day shift, for a day/month exchange, and for the two orders of a swap. So a's variants contain b exactly when b's variants contain a, and the two parties' intersection is the same set whichever of them expanded. A recurring exchange whose record counts move may therefore resolve the opposite role between runs and still match the same records. Nothing is built to hold the role steady across runs, and nothing needs to be.

Limit: only the receiver refuses a date value that is not eight digits. adjacent_years, adjacent_days and day_month_swaps refuse the exchange at the first row whose value is not eight digits, and only the expanding party reads the value by position, so only the receiver refuses. A sending party holding such a row sends its exact value and the exchange runs. Whether an exchange over such a file runs therefore depends on which party resolves as the receiver, which can change between runs of a recurring exchange as record counts move.

The width is declared for the receiver case by both parties, which is where the cost of the asymmetry lands rather than in the matching; that rule and its trade are stated in The width bound above and not restated here.

Two stated limits of the all-pairs enumeration. First, transpositions grows quadratically with the width its element's transforms bound its value to, so an element bounded to more than 45 characters -- an element whose transforms bound nothing included, which takes 128 -- declares a width above MAX_KEY_CANDIDATE_WIDTH and is refused where the width is derived, before any row is read. Declaring transpositions in practice means bounding the element's value with a substring, phonetic, or parse_date transform. Second, the factors of a key's elements compound, so two all-pairs elements in one key cross the same ceiling at 9 characters each (37 x 37 = 1,369 above 1,024) even though either alone is admissible. Both refusals are on the declared width and so are decided from the terms alone; a row that outgrows an admissible width at run time is refused rather than dropped, because the exceedance is a fuzzy expansion's and the drop is reserved for the declared fan-out producers (The width bound). Both declared-width refusals are enforced at the decode -- every parse of the terms document and the invitation-token decode over it (LinkageTermsSchema and InvitationTokenSchema, packages/core/src/config/) -- and the width derivation itself stays in force at exchange preparation (prepareForExchange) and again at key-read planning in runExchange, before any row's keys are built and before anything is written to the wire, as the boundary for a LinkageTerms value built or mutated without a parse. A row's overflow on a key that also declares a fan-out producer takes that producer's drop path instead of this refusal, the record contributing no candidate to the key's round; the refusal above is the fuzzy-only key's. No machinery is added for the stacked case: the arithmetic is the author's to satisfy with a narrower bound or a second key.

Wire-format deltas: existing frames only, and no version bump ​

Under single-pass, fan-out changes the content of one frame the exchange already sends. It adds no frame, no field, no round, and no message ordering. Under cascade, the per-round grouping adds a list after each of two frames (The per-round grouping the two frames hold).

  1. The terms-exchange envelope holds no width. Every per-key width, and the effectiveKeyCount derived from it, is a function of the agreed terms, which both parties hold once the terms exchange returns -- so each derives the other's without a further field or round-trip, and neither is held to a number the other chose. The envelope's per-party integer is recordCount alone (Role resolution and work minimization), and it is the party's declared count: its rows times the fan-out factor its own standardization declares. That discloses a configuration fact (this party's cleaning fans out, at some scale) rather than a data fact, exactly as a party that pre-fanned its file outside Alcove would. A peer that still puts an effectiveKeyCount on the frame has it stripped by the non-strict envelope schema and the exchange proceeds on the derived widths: the field held nothing the agreed terms do not, so there is nothing for a refusal to protect.

  2. Single-pass message 2, part (d) -- the distinct-value index table -- takes a ragged layout when a record may hold several candidates for a key, and is byte-identical to today's fixed-width table otherwise. Two things put a sender into that layout: an effectiveKeyCount above keyCount, which both parties read off the agreed terms; and the sender's own standardization fan-out, which the receiver recovers as the ratio between the record count the sender declared on the terms exchange and the number of rows the reply itself holds. The frame stays one little-endian Int32 array; only its interpretation changes. In key-major order, then row order, each (key, record) cell is a count word c (0 <= c <= w[j] * f, with f that recovered factor) followed by c value-index words, each an index into the sender's distinct values in the setup message's sorted order and strictly ascending within the cell. There is no -1 marker in this layout: a record with no value for the key is c = 0.

  3. The reply's own record count is an upper bound rather than an equality. Message 2 part (c) states the number of rows the sender's table covers. A sender whose cleaning does not fan out declares the same count it exchanged on the terms exchange; one that fans out declares the rows it holds, against the larger count it declared for them. The receiver therefore refuses a reply declaring more rows than the sender exchanged, rather than any inequality, and recovers the factor as the quotient of the two counts. Cleaning declares one whole step's factor or none at all, so the only quotients an honest sender produces are 1 and 20 and every other reading is refused: a fraction, a factor between them, one above 20, and a reply declaring no rows against a positive exchanged count, which no factor multiplies up to. A sender holding no rows exchanged no records either, and that pair takes the unfanned factor. Every allocation downstream stays sized from the exchanged count, which the over-ceiling gate has already bounded, so the relaxation widens no allocation.

The receiver validates the ragged table against authenticated state before it drives any allocation, in the same order as the fixed-width checks it replaces. The table MUST declare exactly keyCount * senderTableRows cells. Each count MUST lie in 0 .. w[j] * f. The running total of counts MUST NOT exceed the sender's slot bound, effectiveKeyCount times the record count it exchanged. Each cell's indices MUST be strictly ascending (which is also what rejects a repeated index) and below that same slot bound. The frame MUST end exactly at the last index word, with no trailing bytes. The index bound is the slot bound rather than the sender's actual distinct-value count because that count is readable only from the setup message -- the frame under check -- and every bound here is derived from authenticated session state alone (Partner-supplied index tables are checked against local state). It upper-bounds the distinct-value count, so it rejects no legitimate frame; an index between the two addresses no value and simply matches nothing.

Nothing else on the single-pass wire changes. Message 1, message 3 (the resolved association table, whose entries are record-level row-index pairs), and the withholding rule for a blind helper are untouched.

The ragged layout takes no version bump. PROTOCOL_VERSION holds at 1. A bump is reserved for a wire-incompatible change made once the software is published; pre-publication there are no deployed peers for the version reconcile to hold apart, so a wire-format delta -- the ragged table included -- ships within version 1, and the existing-frames-only-and-no-bump discipline this section states binds from the first published deployment onward.

That obligation falls due long after this paragraph was written, so it is enforced by a check rather than by the paragraph alone. npm run check:protocol-version-bump is inert while the canonical release version (apps/cli/package.json) names no release above the pre-publication 0.1.0. From the release that does, it fails a change to the pinned wire format -- the conformance vectors covering what the linkage rounds put on the wire -- that includes no PROTOCOL_VERSION bump, naming this paragraph so the change takes the version decision. Which vectors it pins, and what it cannot see, are in the script's own header; the pin recorded per published version is scripts/protocol-version-pins.json.

The cascade grouping takes a bump once published. The per-round grouping (The per-round grouping the two frames hold) adds a list after each of two frames rather than reinterpreting an array already on the wire, which is the shape this section's discipline reserves a PROTOCOL_VERSION bump for. The consequence is conditional on the release, as the ragged layout's is: while the canonical release version (apps/cli/package.json) names no release above the pre-publication 0.1.0 there are no deployed peers to hold apart and the grouping ships within version 1, and from the release that does it takes a bump and a pin, which npm run check:protocol-version-bump holds it to. What the bump buys is the diagnosis. With it a peer that cannot read a grouped frame meets the version reconcile at the terms exchange -- the actionable "run the same version", before any round -- rather than a mid-round parse failure on the frame itself, which is the failure a wire-format delta inside the round loop otherwise produces.

The widened mapped-element entry rides with the grouping. It turns the position an entry names into a list of positions on the two passes the cascade already runs (The final mapped-element entry names the positions its record's pairs rest on), and it is readable only against the grouping that makes the round's accepted pairs computable, so the two land together and take one version decision between them: PROTOCOL_VERSION holds at 1 while the canonical release version names no release above 0.1.0, and from the release that does, both take a bump and a pin.

What no version gate separates. A build predating the delta advertises this build's version, and a width-free exchange with it is wire-compatible at the frame level: it includes no field this build refuses, and any effectiveKeyCount it still sends is stripped by a schema that does not declare the key. A sender under no width and no local fan-out ships a message 2 byte-identical to the fixed-width frame. An element-transform fan-out rides the agreed terms, which a build predating the capability refuses through its own terms validation. A fan-out authored in a party's local standardization rides no agreed term that validation could refuse, so a peer that cannot read the ragged table meets it as a frame-parse failure mid-exchange rather than as the version diagnosis. Both builds advertising 1, the reconcile separates no such peer: what it separates is a peer advertising a version other than this build's, or none it can read at all (Protocol-version reconcile). Pre-publication there is no such peer in the field to meet, which is what makes holding at 1 free; a delta of this shape landing after publication takes a bump.

The disclosure delta fan-out pays ​

Single-pass already hands the receiver the sender's full per-key structure and complete encrypted value set (Why the full index table in Linkage strategies). Fan-out extends that disclosure and widens the intersection surface. The rows a consent surface points at:

  • Candidate structure. The ragged table names, for every (key, sender record), how many candidates that record contributes and which of the sender's distinct values co-belong to it -- for all records, not only matched ones, since the table is unfiltered by construction. The receiver therefore learns each of the sender's records' fan-out width per key. This is strictly more than the single-valued table's duplicate structure, and more than a matched-values-only disclosure would be; it is the price of the strategy that has one resolver.
  • Intersection surface per individual. Each candidate value can independently reveal co-possession, so an individual is revealed as shared when any of its up to 20 candidates matches. A match on a fragment of a value (one part of a hyphenated name) is weaker evidence of identity than a match on the whole value, and the removal rule then takes that individual out of every later, less precise round on the strength of it.
  • Strategy coupling. A fan-out runs under either strategy, so authoring one does not mean opting into single-pass. Under cascade, the schema default and the web application's ordinary path, it adds the per-round grouping (next item); under single-pass it comes with that strategy's larger disclosure (the full index table and the weaker-key matches the lockstep cascade filters out) and its tighter dataset ceiling, further tightened by the slot arithmetic above. Which of the two applies follows linkage_strategy, a mandatory-consistency agreed term, so both parties agree to it before any data moves.
  • Per-round grouping under cascade. Each grouping (The per-round grouping the two frames hold) adds one fact about its own party to what a cascade round already discloses: the partition of that round's matched positions into the records owning them, and those records' relative row order. It reaches only the rounds a record is still in candidacy for -- a record matched under an earlier key is never described again -- and it names no row index and no record that matched nothing. What it makes visible that a single-valued round does not is the contradicted record: a record whose candidates matched and which the sweep left unpaired, where under a single-valued key every matched position is an accepted pair. Against single-pass it is strictly less: single-pass hands the receiver the ragged table for every record and every key, matched or not (Candidate structure above), where a cascade round states counts over its matched positions alone.
  • Exchange record. No delta. Commitments cover the association table's row-index pairs and the payload values, never key strings, so fan-out adds no committed artifact and changes no record row.

Per-round candidacy under cascade ​

Reachable: this subsection and the four below fix the cascade's realization of a candidate set, and an exchange whose agreed terms declare one runs it end to end (linkViaPSI, packages/core/src/psi/link.ts). The combinations that stay refused, and the points each is refused at, are in The combinations that stay unsupported.

The cascade's rules are written for a candidate set from any producer -- a split_on fan-out, a generate_fuzzy_comparisons kind, or a key declaring swap -- rather than for split_on alone. Nothing in them reads which producer realized a candidate: the frames name positions and records, the resolution orders records, and how many positions one record may own is bounded once already, by the width its key declares (The width bound). A one-sided expansion is the ordinary case rather than a special one, and leaves the party that does not expand one position per record (Which party expands).

Acceptance and removal are the rules already fixed, unchanged. Round j's value-level matches lift through the round's (record, value) incidence into record-level candidate pairs. The deterministic greedy sweep in ascending (sender row, receiver row) accepts a pair when neither of its records has already been accepted in this round and discards it otherwise (Record-level resolution). Every record in any of the round's candidate pairs leaves candidacy for every round after j, accepted or discarded (Removal on a potential match). The cascade adds no clause to those and relaxes none. What it adds is the one delta that makes them computable while the round is still open.

Candidate sets do not diverge; the accepted pair set does. A record leaves candidacy for round j + 1 exactly when it owns a value that matched in round j, and both the round's matched positions and the record owning each of them are this party's own -- the receiver computes the round's matches and the association table names the sender its own, so each party holds its matched positions before the round closes. So each party computes round j + 1's candidate set alone, and the two cannot differ. What no party computes alone is the round's accepted pair set, because the sweep orders and accepts by RECORD while a party holds only its own partition of matched positions into records. The partner's partition, for the round being run, is the whole of what a cascade round must add.

Why it cannot ride the final exchange. The mapped-element passes run once, after the last round, and each entry names a record the party MATCHED -- the resolution the grouping would be delivered to compute. Extending them to hold the grouping is therefore circular, on top of arriving after every round's candidate set was formed. It also forfeits the per-round fail-fast a lockstep cascade otherwise has: a partner whose frames do not reconcile is caught at the round it fails in, rather than after every key's data has moved.

The per-round grouping the two frames hold ​

A cascade round's two position-naming frames are the receiver's association table for the round -- its own matched positions paired with the sender's, in the sender's setup-sorted order -- and the sender's original-index list, that same set of the sender's positions remapped into the sender's own input order. Each is followed by the sending party's own grouping, a list of its own sent after the frame's index lists (A list of matched records is sent in parts), so that after the round's last list both parties hold both groupings. No array already on the wire is reinterpreted, and nothing else about the round moves: no round trip is added, and no message ordering.

The form is run lengths. A grouping is an array of positive whole numbers, one per record of that party owning at least one of the round's matched positions, in ascending own-row order. Its entries are the run lengths partitioning that party's matched positions -- taken in ascending position order -- into their owning records. It holds no row index and no entry for a record that matched nothing, so neither crosses the wire.

The round's candidate list is row-major. A party sending run lengths MUST hold one record's candidates contiguously in its candidate list for the round, records in ascending row order. That is what makes ascending position order ascending own-row order, so a record's matched positions form one run and a run's rank among the runs is its record's rank among that party's matched records. The single-valued cascade satisfies it already, contributing one position per record in row order. A party whose side of the resolved cardinality deduplicates contributes each distinct value once, so one candidate slot may stand for several of its records and no ordering makes every record's candidates contiguous; that party states an owner list per position instead (below).

Rank order is row order, so the sweep is the sweep. Each party maps every matched position, its own and the partner's, to the rank of the run holding it, and sweeps the round's candidate pairs in ascending (sender rank, receiver rank). Both groupings being stated in ascending own-row order, that order is the canonical (sender row, receiver row) order the resolution fixes. So both parties accept exactly the pairs Record-level resolution accepts, and reach the same table without either learning a row of the other.

An absent grouping is all ones. A party omits the grouping for a round in which each of its matched positions is the only one its record owns, and a checking party reads an absent grouping as one run of 1 per matched position. A round no producer widened therefore puts on the wire the index lists the single-valued cascade puts there, each grouping list holding null, and a one-sided expansion leaves the party that does not expand omitting its own. A present grouping whose every entry is 1 states the same partition and is read the same way.

The checks stay local. A grouping is checked before it drives the sweep, against state the checking party already holds, on the footing every partner-supplied index list is checked on:

  • every entry MUST be a positive whole number, a record owning no matched position being absent rather than a zero-length run;
  • the entries MUST sum to exactly the number of matched positions the frame names for that party. Where the checking party resolved the round it holds that count independently, having computed the association table itself; where it does not, the count is the frame's own length, which the checks already bound against this party's own state -- an association table's local half indexes the set this party encrypted for the round and may not repeat an entry, and the original-index list is held to that table's length;
  • no entry may exceed the candidate count one record may contribute to the key: w[j] (The width bound), times 20, the factor a party's own standardization may declare. That factor is admitted unconditionally, the cascade holding no quotient to recover it with as single-pass does from its reply's row count. The per-entry bound is therefore the widest a conforming partner can be, and what binds the aggregate is the sum above -- over a matched-position count the round's slot-derived element bound (psiElementBounds, The width bound) has bounded already, that bound being derived per message and so covering a cascade round as it covers a single-pass frame.

A grouping failing any check is a protocol violation: the exchange aborts with a classified protocol error and produces no result, at the round rather than after the last one.

The round's symmetry check reads accepted records. A round's value-level matching is a partial injection between the two parties' round positions -- each party contributes each value once and a value two of its records hold is dropped (Value-level round participation) -- so both parties hold the same number of matched positions and the length equality the two frames are held to is unchanged. What may legitimately differ is the number of RECORDS behind those positions: three matched positions can be two records on one party and three on the other. The check that a round pairs the same number of records on both parties (Partner-supplied index tables) therefore reads the round's ACCEPTED records, one per accepted pair on each party and equal by construction under one-to-one, rather than the records its candidate pairs name.

Under a deduplicating cardinality a position stands for a group, and the grouping is ragged. The "many" side keeps the values several of its records hold, so one of its matched positions stands for a row group rather than for one row (The per-side rules). A candidate set on that side crosses the two axes: one record owns several of the round's matched positions while one position is owned by several records, which run lengths cannot state. The form the grouping field holds where the resolved cardinality deduplicates on the sending party's own side is therefore an owner list per position -- one entry per matched position the frame names for that party, in ascending position order, each entry the ordinals of the records owning that position in ascending own-row order. An ordinal is a record's rank, from zero, among that party's records owning at least one of the round's matched positions, taken in ascending own-row order: the rank a run's place in the run-length form already states, so the ragged form likewise holds no row index and names no record that matched nothing.

The run-length form is its degenerate case. Where every position has exactly one owner, each ordinal owns a contiguous run of positions and the ordinals ascend with the positions, the round's candidate list being row-major, so the run lengths are the ordinals' occurrence counts in order and the two forms state the same partition. A party whose side of the resolved cardinality does not deduplicate therefore sends run lengths, an absent grouping is read as one owner per position under either form, and a round no producer widened sends the index lists the single-valued cascade sends.

The same local checks, restated over positions. A ragged grouping is checked before it drives the sweep, against state the checking party already holds:

  • the outer list MUST hold exactly one entry per matched position the frame names for that party, the count that stands in for the run lengths' sum -- held independently where this party resolved the round, and read off the frame's own already-bounded length where it did not, exactly as above;
  • every entry MUST be a non-empty list of whole numbers in strictly ascending order, so no matched position is owned by nothing and no record owns one position twice;
  • the ordinals used MUST be exactly 0 through k - 1 for the k records the grouping names, each appearing at least once, since an ordinal no entry holds names no record and so no rank;
  • k MUST NOT exceed the sending party's record count declared on the terms exchange, the bound every list from the many side is already held to (Deriving one table from the exchanged association maps);
  • one ordinal MUST NOT appear in more positions than the candidate count one record may contribute to the key -- w[j] times 20, the per-entry bound the run-length form is held to, read here as an occurrence count.

How many ordinals one entry holds -- how many of that party's records stand behind one matched position -- is bounded by k and by nothing narrower, the records-per-value axis being the many side's own declaration throughout the deduplicating cascade (Deriving one table from the exchanged association maps). A ragged grouping failing any check is a protocol violation on the terms a run-length one is: the exchange aborts with a classified protocol error at the round rather than after the last one.

The sweep and the symmetry check read the ragged form unchanged. Each party maps every matched position, its own and the partner's, to the ordinals owning it, lifts the round's value-level matches into one candidate pair per pair of owners of the two matched positions, and sweeps in ascending (sender ordinal, receiver ordinal) -- the canonical (sender row, receiver row) order, the ordinals being stated in ascending own-row order -- under the many side's relaxed acceptance clause (The per-side rules). The round's symmetry check reads accepted records as above, equal by construction under one-to-one; a deduplicating cardinality makes the two accepted-record counts legitimately differ, and what holds the pair there is the returned-list count rules already fixed (Deriving one table from the exchanged association maps), read at the widened entry as the round's accepted pair count -- one figure, computed by both parties from the two groupings. What the final pass states is derived identically by both parties from either form (below).

The final mapped-element entry names the positions its record's pairs rest on. The pass that reconciles rows after the last round keeps its shape -- one entry per record this party had accepted, naming the round it accepted it in -- and what an entry names within that round is a SET of positions in the partner's candidate set: every position the record matched that a partner record accepted with it owns, stated in ascending position order. Both parties derive that set identically from the round's two groupings, having computed the same accepted pairs from them (Rank order is row order above). Where the accepted partner record owns one of the round's matched positions the set holds one position, so a round no producer widened puts on the wire the entry the single-valued cascade puts there.

An entry is attributed by its place in the round, not by the positions it names. A round's entries are stated in the naming party's ascending own-row order over the records it accepted in that round -- the order its own grouping already states -- and the reading party takes the i-th of a round's entries for the i-th of the partner's accepted records in that round. It holds that rank without a row index crossing: the accepted pairs are what both parties computed from the two groupings, and both groupings are stated in ascending own-row order. Two of a party's accepted records can name the same position, which is why the place rather than the position attributes an entry.

The pairing decides what an entry expands to, not the group behind a position. Under a deduplicating cardinality a position stands for a group of the partner's rows (above), and a candidate set reaches two shapes in which that group is not what the entry states:

  • One record, two of the partner's groups. A "one"-side record whose candidates match two of the many side's records holding different values is accepted with both, the relaxed acceptance clause stopping only the many side from repeating (The per-side rules). Its entry names both positions and comes back as one row per accepted pair.
  • One group, two records. Two of the many side's records owning one position are accepted against different "one"-side records, one of them having been accepted on a value of its own earlier in the sweep. The entry naming that position stands for the records accepted with the entry's own record alone, never for the whole group.

The owners of a named position therefore bound what an entry expands to rather than stating it; what states it is the round's accepted pairs, which both parties hold. Where the resolved cardinality deduplicates on neither side each position has one owner and the two coincide, which is the single-valued reading.

The reading pass's preconditions, at the widened entry. A round is read against the accepted pairs the reading party computed for it wherever that party holds the partner's exact partition of the round's matched positions into records: every round except one where the partner deduplicates and omitted its own grouping, whose entries are read under the position reading instead (Deriving one table from the exchanged association maps). Every bound stays derived from that party's own state (Partner-supplied index tables):

  • every position an entry names MUST be one this party matched in that round, and an entry's positions MUST be distinct and ascending, which is also what rejects a repeated position;
  • a round's entries MUST be exactly the entries that pairing states, entry for entry in the order above: the i-th entry names exactly the positions on which the i-th accepted partner record's pairs rest. The equality is what a divergence between the two parties' resolutions meets, at the pass rather than in the result, and it holds the two coverage rules a list is otherwise read under (Deriving one table from the exchanged association maps) at the shapes a candidate set adds: a position may be named by two entries, where the group behind it splits across two of the partner's accepted records, and a position no accepted pair rests on is named by none, which is the contradicted record the disclosure rows above state;
  • a returned list MUST contain exactly one row per accepted pair the entries it answers stand in -- a count the sending party computed from the two groupings -- as one run per entry in the order sent, each run as long as the number of partner records accepted with that entry's record;
  • injectivity is read over that pairing rather than over one canonical position: two entries whose records are accepted with the same partner record MUST come back with the same row, two whose accepted partner records are disjoint MUST come back with disjoint runs, and the rows within one run MUST be distinct. So a conforming return may neither merge two of the partner's accepted records onto one row nor split one across two, which is what the pass rests on.

Under one-to-one each accepted record stands in exactly one accepted pair, so every run is one row and the return is the entry-for-entry list held to the distinctness rule the single-valued cascade states. Removal on a potential match keeps a record accepted in round j out of every later round's candidate set, so no two rounds' entries name one record between them.

No shape the sweep resolves is refused. Every accepted pair set a round produces is statable in this form, the return being one row per accepted pair, so a cascade states a round's pairings rather than refusing a round it resolved. A candidate set crossing distinct partner groups under a one-sided deduplicating cardinality -- many-to-one or one-to-many -- resolves to the table single-pass computes on the same inputs, under the equivalence MUST that binds every other shape (Equivalence with the single-valued cascade).

The widened entry pays no disclosure. An entry states the pairing the reading party computed for itself from the two groupings before the pass ran, so it names nothing that party did not hold; what the pass discloses is the row index behind an accepted record, as it is for the single-valued cascade (The disclosure delta fan-out pays).

Under many-to-many both parties send the ragged form, and the round's acceptance is total. Each party's own side of the resolved cardinality deduplicates, so each sends the owner list per position and reads its partner's, and the sweep's acceptance clause is degenerate: every candidate pair the two groupings lift is accepted, neither side stopping the other from repeating (The per-side rules). A round's accepted set is therefore the pair set its two groupings determine and nothing else -- a symmetric function of the two frames, with no order for either party to reproduce and no tiebreak for the two to agree on -- and the entry that follows names, for each of a party's accepted records, the whole set of partner positions its pairs rest on, exactly as above. The closure over the table it produces, and the two rules a candidate set under this cardinality is read by, are in The many-to-many entity closure.

The normative double-match case ​

One linkage key, cardinality one-to-one, sender S and receiver R with two records each. S0 realizes the candidate set {ab, cd} and S1 realizes {ef}; R0 realizes {ab} and R1 realizes {cd, ef}. Every value occurs once within its own party's round, so none is dropped for ambiguity.

  • The round's value-level matches are ab, cd, and ef, which lift into the candidate pairs (S0, R0), (S0, R1), and (S1, R1).
  • Both parties MUST produce the association table {(S0, R0), (S1, R1)}: the sweep accepts (S0, R0), discards (S0, R1) because S0 is accepted in this round already, and accepts (S1, R1).
  • All four records leave candidacy for every later round, the discarded pair's two included.
  • On the wire, S's matched positions are 0, 1, 2 with grouping [2, 1], and R's are 0, 1, 2 with grouping [1, 2].
  • Without S's grouping, R reads S's three matched positions as three records and produces {(S0, R0), (S0, R1)} -- the double match on one record the resolution exists to prevent. The exchange does not deliver it: R's mapped-element list then names one of S's records twice and the other never, which the inbound check refuses.

The case is normative in both directions. A conformance test asserts BOTH parties' association tables, not one party's, and runs the case in both role assignments -- S as sender and S as receiver -- since the sweep's order is role-derived and the exchange must reach the same records either way.

Two cases a deduplicating cardinality adds. Both are many-to-one with S the many side and R the "one" side, one linkage key, and every value unique within R, so none is dropped for ambiguity. Each is a shape an entry naming a single position cannot state, and each is a table single-pass produces on the same inputs.

  • A record accepted against two of the partner's groups. S0 realizes {ab} and S1 realizes {cd}; R0 realizes {ab, cd}. The candidate pairs are (S0, R0) and (S1, R0), and the sweep accepts both, the relaxed clause stopping only the many side from repeating. Both parties MUST produce {(S0, R0), (S1, R0)}. R0's entry names both of S's positions and comes back as two rows, S0's and S1's.
  • A group split across two records. S0 realizes {ab, cd} and S1 realizes {ab}; R0 realizes {cd} and R1 realizes {ab}. S's position for ab is owned by S0 and S1 together. The sweep accepts (S0, R0) on cd, discards (S0, R1) because S0 is accepted in this round already, and accepts (S1, R1), so both parties MUST produce {(S0, R0), (S1, R1)}. R1's entry names the position for ab and comes back as S1's row alone: the group behind that position is not what the entry states.

Both are normative in both role assignments, as the case above is, and each is asserted against the table single-pass computes rather than against the cascade's alone.

What the cascade realization owes ​

The grouping is the partner's own word. A checking party bounds a grouping but cannot verify it: nothing it holds states how the partner's own rows realize candidates, so a partner may merge or split its own runs and steer the sweep from inside the round's innermost loop. In the vocabulary the count-only claim uses, the bounds above are enforced and the partition itself is trust-contingent. The two mapped-element passes bound what a steered partition reaches -- a partition that would pair two of this party's records with one of the partner's is refused by the distinctness rule a returned list is held to under one-to-one (Partner-supplied index tables) -- but within those bounds the partition is the partner's declaration. It is not new in kind: single-pass has the sender take the whole resolution as the receiver's word (Linkage strategies), and a deduplicating cascade takes each group's size as the many side's own (Deriving one table from the exchanged association maps). It is stated as the strategy's trust statement rather than closed, on the semi-honest model this project adopts (SECURITY_DESIGN.md) and between partners already authenticated to each other.

One sweep, called by both strategies. A second resolver in the cascade makes the byte-identical table a property tests hold rather than one the structure holds. So the realization MUST factor the record-level resolution into ONE strategy-independent function that both linkViaPSI and linkViaSinglePassPSI call over the same candidate-pair input, rather than writing the sweep a second time in the cascade's round loop, and MUST land differential conformance vectors: fixed inputs run through both strategies with the two association tables asserted equal.

Both grouping forms, and the ragged one is not optional. The realization owes the run-length form and the ragged owner list per position both, as the two forms of the one grouping field (The per-round grouping the two frames hold). A party sends the ragged form wherever the resolved cardinality deduplicates on its own side and the run-length form otherwise, and reads whichever form its partner's side of that cardinality entails, the resolved pair being symmetric and held by both parties. The differential vectors above MUST therefore cover a candidate set on the "many" side of many-to-one alongside the one-to-one case, in both role assignments.

Every round the sweep resolves is stated on the two passes. The widened mapped-element entry states every accepted pair set a round can produce (The final mapped-element entry names the positions its record's pairs rest on), so the realization owes the round's pairings for every shape a candidate set reaches under one-to-one, many-to-one, and one-to-many, and owes no round-time refusal for a shape the sweep resolved. Two of those shapes are what the widened entry exists for -- a "one"-side record accepted against two of the many side's groups, and one group split across two of the "one" side's records (The normative double-match case) -- and the differential vectors MUST cover both, in both role assignments. Beside them the realization owes a conformance corpus over ragged and fanned-out inputs together, asserting for every fixture the one verdict this specification admits: both parties resolving, each party's table equal to the one single-pass computes on the same inputs. many-to-many is not among those cardinalities and takes obligations of its own, which both strategies meet: the round-diagonal closure check over the table each resolves, and differential vectors of its own, a candidate set joining two of a round's blocks among them (The many-to-many entity closure).

The combinations that stay unsupported ​

Each MUST be refused fail-closed -- never narrowed to matching on one candidate, never promoted to another strategy, and never run -- at every point its inputs are knowable. For a combination one party's own terms hold, those are the points the psi-c refusals use: every parse of the terms document, the invitation-token decode included, since the document alone decides it; when the terms are authored or an invitation over them is minted; at the local prepare step; and at the agreed-terms run boundary. For one that takes both parties' terms, those points are where a party first holds both documents: the accept boundary, where the accepting party holds the inviting party's document and sets its own value (deriveAcceptedLinkageTerms, packages/core/src/linkageTermsNegotiation.ts), which is what keeps such a pair off the consent surfaces; and the agreed-terms run boundary, where the refusal is a symmetric function of the agreed pair so both parties refuse in lockstep. Neither authoring nor the mint nor the prepare step can decide such a combination: a terms document states its own value and not its partner's.

  • A candidate set under psi-c, from any producer, an interim limit. A count over candidate sets could depend on the order its resolution meets them in, which the count-only permutations hide, and no order-independent resolution or padded layout is specified for it (Refusals under count-only).
  • The agreed (true, true) pair under a strategy that pairs no both-sided cardinality, whatever the keys declare. Neither shipped strategy is in that position, so this refuses nothing an operator can configure; it is the boundary a strategy added later is stopped at, and the pair is refused before the candidate set is read at all (Deduplicating cardinalities). Under both shipped strategies the pair runs, a candidate set included: the closure over a table a candidate set widened, and the two rules that read one, are in The many-to-many entity closure. many-to-one and one-to-many run there too: a candidate set on either side of those, the "many" side included, is specified above in the ragged grouping (The per-round grouping the two frames hold), every shape those two cardinalities reach included -- a candidate set crossing distinct partner groups among them.
  • A candidate set under a strategy that specifies no resolution for one. The gate is an allowlist of the strategies that do, so a linkage_strategy added later refuses a candidate set until its own resolution is written, rather than inheriting either of the two specified here.

No entry on this list rests on a limit of the mapped-element frame. That frame states every accepted pair set a round produces (The final mapped-element entry names the positions its record's pairs rest on), so each combination above stands on the reason stated with it -- a count that could depend on an order the count-only permutations hide, a cardinality one strategy does not pair and whose resolution the other has not yet built, a strategy whose resolution is unwritten -- and a shape the sweep resolves is never refused for want of a way to state it. A combination added here later takes a reason of its own on the same footing.

The half of any of these a party authors in its own data standardization is per-party and local, so no invitation holds it and a partner cannot derive it. That half MUST be refused at the earliest point this party derives it: the prepare step where its own terms decide the combination, and the run boundary where the agreed pair does. Refusing at the prepare step is what makes the ordinary case a configuration failure in front of the operator rather than a partner left waiting, the reason Fan-out runs under both linkage strategies states.

Deduplicating cardinalities: many-to-X matching ​

Reachable, every row under both strategies: every row of the table below runs end to end from agreed terms, and the surfaces that table feeds hold the multiplicity with it -- the payload frame, the result file, and the exchange record with its re-supply path. All four rows run under both linkage strategies (linkViaPSI and linkViaSinglePassPSI, packages/core/src/psi/link.ts), and each strategy's verdict on the both-sided row is an entry of MANY_TO_MANY_IMPLEMENTED_BY_STRATEGY (packages/core/src/linkageTermsPolicy.ts). An agreed (true, true) pair naming a strategy whose entry says it pairs no both-sided cardinality is refused before matching begins, rather than silently matched to less than the terms declare: assertBothSidedDeduplicateImplemented, reached from resolveLinkageCardinality (packages/core/src/exchange.ts) after the terms exchange and before the first round. It reads both parties' documents, so the refusal is symmetric in the pair, and it names the strategy rather than the cardinality -- the pair itself is what both shipped strategies run. The per-party guard beside it, applied at the local prepare step (prepareForExchange) among other points, refuses a party's own deduplicate: true under a strategy that matches no deduplicating cardinality at all, which neither shipped strategy is. A strategy in either position would be refused earlier still, at the accept boundary as well (deriveAcceptedLinkageTerms, packages/core/src/linkageTermsNegotiation.ts), reading the inviting party's terms, since linkage_strategy is a mandatory-consistency term and the accepting party's derived deduplicate: false would otherwise hide the pair from every check until the terms exchange. No strategy this document specifies is in either position, so both guards refuse nothing an operator can configure; each stays the boundary a strategy added later is stopped at, and singlePassResolves (packages/core/src/psi/link.ts) is the same reading below the run boundary, for a direct caller of that module. Each strategy pairs the cardinality by the per-side rules: the cascade over its rounds' exchanged association maps and the run-grouped returned-list rule below, single-pass in the receiver's local replay of those same rounds, whose resolved table its sender holds to a pair-count bound rather than to a length a distinct half pins.

A deduplicating cardinality is a widening on the records-per-value axis of the round's (record, value) incidence, and every rule below is a consequence of that. The design rationale, the alternatives set aside, and what stays open are in deduplicate-matching-semantics.md.

The declaring party is the "many" side ​

deduplicate: true on a party's linkage terms declares that several of that party's records may link to the same partner record. The declaring party is the "many" side of the resolved cardinality. That direction is normative and is stated here once, because it is the direction the operator-facing term takes: a party is asked whether to deduplicate its own inputs, which it does by using the partner's data to group them, so several of its own rows that turn out to name one partner record are one individual recorded more than once.

The term is per-party and has no consistency requirement: each party declares its own side of the cardinality, and the two values need not agree.

The resolved cardinality. After the terms exchange and before the first round, each party resolves the cardinality from the agreed pair -- its own deduplicate setting and the partner's. The resolution is a function of that pair alone, computed from the same authenticated state on both parties, so the lockstep rounds cannot diverge on it:

this party's deduplicatethe partner'sresolved cardinalitywhich side holds the multiplicity
falsefalseone-to-oneneither
truefalsemany-to-onethis party
falsetrueone-to-manythe partner
truetruemany-to-manyboth

The label is read from the resolving party's own side, which is why the pair (true, false) gives the declaring party many-to-one and its partner one-to-many. The two labels name one cardinality and one matching procedure, mirrored; what both parties must agree on is which side holds the multiplicity, and they do, because each derives it from the same agreed pair.

An accepted invitation binds the value its author presents. The term has no cross-party consistency requirement, but where a party reached the run by accepting an invitation, the value that invitation declared for the inviting party's own side is the value that party consented to, and it is the one the consent surfaces stated. The accepting party retains the declaration and MUST refuse a partner presenting any other value at the terms exchange, before any key or payload moves (assertPresentedDeduplicateMatchesInvitation, packages/core/src/exchange/termsRefusals.ts), since a presented true against a declared false widens what the accepting party's own records disclose beyond the terms it agreed to. The binding is scoped to that path: an exchange authored from two parties' own configuration files has no declaration between them, and the differing pair that makes one of them the "many" side runs unaffected. The refusal is one-sided by construction -- only the accepting party holds the declaration -- so it aborts that party's run rather than both parties in lockstep, and the abort it sends ends the partner's run naming that termination (An abort arriving where a round awaits binary).

The binding is one-sided by decision. Nothing holds an accepting party's presented value to one the inviting party expected: an inviter consented to no document of its partner's, and no check refuses a partner's value on an inviter's behalf. That leaves an exposure on the inviting party's side. An acceptor presenting true against an inviting party's false resolves that inviting party to one-to-many and stops the acceptor dropping its own within-dataset duplicates, so a value two of the acceptor's records share -- dropped from the round as ambiguous under one-to-one -- matches instead. A membership fact about the inviting party's record, and the payload row behind it, therefore leave that party's machine on a run that under the narrower pair would have matched nothing, and the acceptor reads a grouping over its own records that pair does not produce. It is the mirror of the widening the accept-path refusal above blocks in the other direction, and it is accepted rather than refused: which cardinality two parties match under is theirs to decide between them.

Both seats are told what the pair resolved to. Once the terms are agreed on and before the first round, each party is told its own deduplicate value, the value its partner presented, and the cardinality its own side resolved to. The CLI states them on its run log at info level, beside the resolved role. A browser seat conducting the exchange itself states them on its running screen at that same point, until the run completes. A console seat, which watches a CLI run rather than conducting one, states no sentence for the pair; for a deduplicating pair it shows the cardinality notice the CLI raises on its warning channel. No seat states them on its completion panel, whatever the pair resolved to: a party's own value, and its partner's where that party accepted an invitation, are shown where it agrees to the exchange. The same three are recorded in every party's self-attested record, whatever the log level, so the run's own artifact names both parties' values and not only the resolved multiplicity, and the CLI's result event includes them. The sentence is composed in core (describeResolvedMatching, packages/core/src/pairTableProjection.ts) and rendered unchanged wherever it is shown, so no two seats state one fact two ways. Where it is shown, it is stated whatever the pair resolved to, the one-to-one case included: each party's value comes from its own document, so nothing earlier in the run holds the pair.

A deduplicating party must receive the output. Any party declaring deduplicate: true must also declare output.expects_output: true. An output with multiple links per input imparts meaning to the data holder through entity resolution: the grouping is what a deduplicating exchange produces, and it exists only in the output. A "many" party entitled to no output would have widened its own match, disclosed to its partner how its records group (below), and received nothing the widening produced. The requirement is enforced by a refinement of its own on the terms schema (LinkageTermsSchema, packages/core/src/config/linkageTermsSchema.ts).

The per-side rules ​

Stated for many-to-one; one-to-many is its mirror, and many-to-many applies the "many" side's rules to both parties.

  • The "one" side's within-round uniqueness rule is unchanged. A value two or more of its records hold in that round is ambiguous and is dropped from the round, exactly as under one-to-one, and those records stay eligible for later keys. This is what holds the -to-one half of the cardinality: with the one side's contributed values unique within its dataset, each of the many side's records can match at most one of the one side's records.

  • The "many" side does not drop its within-dataset duplicates. It contributes each distinct value once, and a match on that value is attributed to every one of its candidate records holding it. That is the whole of the widening: the round's incidence holds several records per value on the many side, and the attribution rule lifts one value-level match into one candidate pair per record in that group. Because the widening is on the records-per-value axis, it adds no PSI set entry and no value slot, so no derived frame or dataset bound moves.

  • Multiplicity is within-round. Every record on either side that appears in any of round j's candidate pairs leaves candidacy for every round after j, accepted or not -- the same removal on a potential match fan-out states. A matched record on the "one" side is therefore not a candidate in a later round, and a later, weaker key cannot add a link onto a group an earlier key formed. A group is the set of the many side's records that shared one value with one partner record, on the most precise key any of them matched under.

    The one carve-out is the single-resolver drop: where the resolving "many" party drops a value two or more of the partner's records hold, the group standing behind that value is attributed nothing and its rows stay eligible for later keys, exactly as a value that party dropped before the round. The pairs are discarded as a set rather than resolved, so the round attributes the group no match to be removed on, and removing it anyway would cost those rows a later key over a partner fault they had no part in. The carve-out is reachable only against a partner that does not apply its own within-round uniqueness rule; under conforming partners no value reaches the resolver twice.

  • Resolution. Round j's candidate pairs are resolved by the deterministic greedy sweep in the canonical (sender row, receiver row) order fixed for fan-out, with one clause relaxed: a pair is accepted when the record on the many side has not already been accepted in this round, whether or not the record on the "one" side has. Under many-to-many every candidate pair is accepted. Where neither side fans out the sweep accepts every pair whatever the order, because each many-side record then holds one value for the key and the one side's contributed values are unique within its dataset, so each many-side record has exactly one candidate.

  • Both linkage strategies support it. A deduplicating cardinality needs no strategy restriction, and the axis distinction is the reason. Grouping a party's own records by value is computable from that party's own data, so each party computes its own removals locally in each round and the two cannot diverge. Resolving a fan-out needs the partner's grouping of matched values into records instead, which the cascade's per-round frames hold as a field of their own (The per-round grouping the two frames hold). Under single-pass the receiver is the sole resolver and applies these rules in its local replay, over the fixed-width index table unchanged: one value per (key, record) is all a deduplicating party's records ever hold, and the table already names which of a party's records hold the same value, which is the whole of what the widening reads. Nothing single-pass derives from authenticated state moves with it either. A party's value slot count and every bound taken from it (The width bound) count (key, record) cells, which duplication does not add to. So the ceiling, the per-message element bounds, and the reply byte cap are the same figures a one-to-one exchange of the same two datasets derives.

Deriving one table from the exchanged association maps ​

Both parties end the exchange with the same association table, and under the cascade that table is exchanged rather than derived from the intersection alone: a round's PSI output names positions in that round's two candidate sets, and only each party knows how its own positions map back to its own rows. The reconciliation is the pair of mapped-element passes the one-to-one cascade already performs (exchangeMappedElements, packages/core/src/psi/link.ts):

  1. Each party sends one entry per record it matched, naming the round and the position that record matched within the partner's candidate set for that round -- the set of positions its pairs rest on where a candidate set widened the round (below).
  2. Each party translates the list it received into its own row space and returns it. The originating party reads its partner's row index off each returned entry.

A deduplicating cardinality extends that in exactly one place: on the many side, a candidate-set position stands for a group of its rows rather than for one row. Three consequences follow. No entry gains a field, no frame is added, and a one-to-one exchange's passes are unchanged.

  • The many side's outbound list still holds one entry per matched record, so several of its entries may name the same position in the one side's candidate set -- one per record in the group.
  • The two outbound lists need not be the same length. A round pairs the same values on both parties but not the same number of records, so the count equality the one-to-one checks rest on is replaced by the rules below.
  • Translating an entry whose position stands for a group expands it into one entry per record in that group -- emitted in ascending record order, though no receiving check reads that order (the within-run order paragraph below states what the freedom reaches) -- with the groups left in the order of the list being translated. The returned list is therefore longer than the list sent -- by exactly a multiplicity the originating party can count for itself, since the partner's inbound list of pass 1 names each of this party's own matched positions once per record in the group behind it.

The index checks generalize with it, and every bound stays derived from this party's own data or from a count declared on the authenticated terms exchange:

  • every entry MUST name a round this exchange ran and a position within that round's candidate set that this party matched in that round -- unchanged;
  • a list arriving from the many side MUST name every position this party matched in that round at least once and no other position; entries naming one position may repeat, which is the widening itself, and the list's length is bounded by the partner's row count declared on the terms exchange;
  • a list arriving from the one side MUST name each such position exactly once, as under one-to-one;
  • a returned list MUST contain exactly the entry count the receiving party computed for it above, each entry a row within the partner's counted rows. Where the receiving party is the many side and its partner the "one" side, several of its records name the same partner row. So the flat no-repeat rule that caps a one-to-one list's length gives way to that computed count and to injectivity modulo the grouping that party itself sent: two entries whose outbound entries named the same (round, position) MUST name the same row, and two that named different positions MUST name different rows. Both halves are checkable against state that party holds: it kept the position every one of its own entries named, distinct positions in a round are distinct values held by distinct rows of the "one" side, and a row matched in round j has left candidacy for every later round. So the returning party can neither merge two of its partner's groups onto one row nor split one across two;
  • under many-to-many a returned list arrives as runs rather than entry for entry, both parties keeping their within-dataset duplicates, so each entry the checking party sent comes back as the whole partner group behind the position it named. The list is the concatenation of one run per entry sent, in the order sent. The run for an entry has the length that party accumulated from the partner's pass-1 list: the number of that list's entries naming the position the entry's record matched, which is the size of the partner group behind it. So the run lengths sum to exactly the entry count the whole list is held to, and every quantity the runs are read against is one the checking party computed rather than one read off the frame under check. Injectivity modulo the grouping reads over runs. Two entries that named the same (round, position) MUST come back with identical runs, element for element and in order. Two that named different positions MUST come back with disjoint runs. And the rows within one run MUST be distinct, a row named twice for one record being a repeated pair no consumer can read. That leaves the partner able neither to merge two of the checking party's groups nor to split one, exactly as the entry-for-entry form does. The two forms are alternatives -- a list is held one way or the other, never both -- and which applies follows the resolved cardinality both parties hold. The order of the rows within a run is the partner's own and is unpinned; run identity across a group makes that choice one consistent arrangement, so it is membership-preserving -- which partner rows a record pairs with is fixed however a run is arranged (the within-run order paragraph below states what the freedom reaches);
  • at the single-pass boundary where the sender receives the resolved association table, the half naming the "one" side's rows admits a repeat, several of the many side's records linking to each of them. Which half that is follows the resolved cardinality, which both parties hold: where the sender is the "one" side its own half is non-decreasing rather than strictly ascending -- the distinctness the ascending rule enforces is what is relaxed -- and where the receiver is, the partner half repeats while the sender's own stays strictly ascending. The remaining half keeps its distinctness, so it is the one whose range check caps the table's length, and the other is then held to that count: the bound is the many side's row count, which is the sender's own where it deduplicates and the count declared on the terms exchange where its partner does;
  • under many-to-many at that same boundary both halves repeat, so neither caps the other's length and the table is held to a bound of its own instead. Its own half stays non-decreasing, the order the result rows and the re-supply path read it in, and no pair may be named twice, a pair named twice being one link every consumer counts and writes twice.

A list failing any check is a protocol violation: the exchange aborts with a classified protocol error and produces no result.

The widened entry generalizes these rules over a round's accepted pairs. A cascade round resolving a candidate set states each party's grouping on its own frames (a party whose records each own one matched position omits its own), both parties compute the round's accepted pairs from them, and each mapped-element entry is the set of positions those pairs rest on (The final mapped-element entry names the positions its record's pairs rest on). The three rules above are read over that pairing for such a round wherever the checking party holds the naming party's exact partition of the round's matched positions into records, which it does unless the naming party deduplicates and omitted its own grouping (below): the coverage rules become the entry-for-entry equality, the computed return count becomes one row per accepted pair, and injectivity modulo the grouping becomes injectivity over the accepted partner records -- each stated in full where the widened entry is. The two readings state the same thing wherever no candidate set widened the round, a position having one owner or a record one position, so a one-to-one exchange and a deduplicating exchange whose records each hold one value per key are held to exactly the rules above. What is read from the round's grouping rather than from the pass-1 list is the size of the group behind a position, and it stays the many side's own declaration either way, held to the grouping's own checks (The per-round grouping the two frames hold) and to the residual recorded below.

A widened "one"-side record is named by its canonical position. The widened reading needs the naming party's exact partition of the round's matched positions into its records. The checking party does not hold it where the naming party deduplicates on its own side and omitted its grouping for the round (An absent grouping is all ones), since a position of the naming party's then stands for a group of its rows, so that round's entries are read under the position reading above. Where the checking party keeps no duplicates -- the "one" side of many-to-one or one-to-many -- and a candidate set widened it, one of its accepted records may own several of the round's matched positions, and every entry naming that record MUST name its canonical position: the lowest of the round's matched positions the record owns, which the naming party reads off the checking party's grouping on the round's frames. The record's other matched positions go unnamed, so at such a round the coverage rule for a list from the many side is read over records -- every record the checking party accepted there is named -- rather than over positions. A list naming a record by any other position is a protocol violation: the checking party aborts with a classified protocol error, "the partner's mapped-element list names a position other than the canonical one of the record it matched" (linkViaPSI in packages/core/src/psi/link.ts), and produces no result.

Conformance: which position an entry names is fixed. Each of a widened record's matched positions passes every other check on the list -- it is a position the checking party matched in that round, and it expands to that one record -- so an implementation naming another of them, such as the position its accepted pair rests on, is otherwise conformant, and this implementation aborts on its list with the error above. An implementation interoperates at such a round only by naming the lowest owned position. The rule changes nothing at a round that did not widen the checking party, where each record owns one matched position and that position is its canonical one.

The both-sided expansion has no ceiling of its own. Under many-to-many the only bound the checks above put on a returned list's length -- and so on the pair count of the table derived from it -- is this party's own row count times the partner's record count declared on the terms exchange. single-pass binds the same product at its own boundary, where the sender receives the resolved table rather than deriving it (The both-sided table's bound under single-pass). The product is the honest shape of the pairing rather than an adversarial one: a value held by m of one party's rows and n of the other's contributes m * n pairs, so a table over two duplicate-rich datasets grows quadratically in them. No derived frame or dataset bound narrows it, since a widening on the records-per-value axis adds no PSI set entry and no value slot (The per-side rules), and the list goes in as many parts as it needs (A list of matched records is sent in parts), which leaves memory as the only thing beneath it. It takes no ceiling of its own, by design: the worst case is projectable before any round runs -- this party's own row count times the partner's record count declared on the terms exchange. So a ceiling would refuse an exchange whose bound the operator could have read off the agreed terms, and failing fast on a table that is merely large buys little where the alternative is the same failure a moment later. A partner overstating its per-value multiplicity moves the table within that same bound (the many side's per-value multiplicity is not independently bound, below), which costs result size and adds no disclosure the consent surfaces did not already cover, so what a ceiling would defend is the operator's own patience rather than a confidentiality property. The quadratic term is also narrower than it looks: it is the derived pair table and the result file, never the rounds, since by the same reasoning the encryption cost is values-only. The entity closure adds no term of its own -- it is one local pass over the pairs that table already holds, taken alongside one round label per pair and that round's blocks, read per matched value -- so it costs what the table costs and adds no frame, no round, and no disclosure beside the pairing that produced it. An advisory warning naming the pair count a run projects -- that projectable bound, and what each side's declared count contributes to it -- is admissible, and is a front end's discretion; a refusal is not. The bound that advisory reads is below.

The advisory bound is 10,000,000 projected pairs. PAIR_TABLE_ADVISORY_MAX_PAIRS (packages/core/src/pairTableProjection.ts), read against this party's declared record count times the partner's, both declared on the terms exchange, so both parties project the same figure. It equals the product above where this party's own cleaning does not fan out and exceeds it where it does. Derivation. The result file holds one row per pair under every cardinality, so a projected pair count converts to a result size through the writer's bytes per pair. Measured over the real writer (buildOutputTable plus the comma join both front ends serialize it with), the narrowest result an exchange can produce -- two columns, this party's identifier and the partner's row index, with no partner payload -- costs 6 to 15 bytes per pair. The range spans an absent identifier column through a six-digit one, and index columns addressing a thousand rows through three million. It is a range rather than a constant because the digits each column holds grow with the table. A result holding a UUID identifier or three payload columns costs 32 to 41 bytes per pair on the same measurement. Ten million pairs is therefore where that narrowest shape crosses order 100 MB -- 60 to 150 MB across the range -- and every wider shape crossed it well before. The web app's CSV intake cap (MAX_CSV_FILE_BYTES, apps/web/src/components/csvIntake.ts) is 200 MiB, the same order, so a result at the bound is still one the browser will read back. Rounded to one significant figure, and revisable as a one-line change: the number decides only whether a string is shown.

The advisory is composed in core and raised by the front ends. describeResolvedRunShape (same module) takes what runExchange hands its onProtocolConfirmed callback at this point -- after the terms exchange, before the first round -- and returns the strings; the run path itself raises nothing, since a warning emitted there would decide a front end's discretion for every seat. Two strings, neither of which is the always-stated matching sentence above (describeResolvedMatching, same module), and the first is not about size at all: a run whose resolved cardinality is a deduplicating one names that cardinality, whatever the projection, because the consent surfaces state each party's declared deduplicate and nothing else states what the pair resolved to. The second is the projection, raised only above the bound, naming the projected pair count and each side's contribution to it so an operator can tell which side drives it.

The callback holds the entitlements as well as the cardinality, because neither string follows from the label alone. A resolved cardinality determines whose duplicates match, not who holds what the matching produces, so the shape holds this party's own output.expectsOutput and whether this run withholds the partner's half of the table. Both readings turn on them. Only the declaring "many" party is required to expect output, so a one-to-many party can receive no result at all -- and naming "your result file" to it would contradict the completion the same run ends on; where it holds none its partner does, validateCompatibility refusing a pair in which neither expects output. And the key-group multiplicity a many-to-one run discloses is read by every partner except the single-pass blind helper, whose half is suppressed entirely and which therefore reads neither the group sizes nor its own membership (The disclosure delta a deduplicating match pays). The composition distinguishes both rather than stating the common case, so no configuration meets copy the run then contradicts.

This is not the single-pass refusal, and the two differ in what exists to enforce. The single-pass dataset ceiling aborts an over-budget exchange because the quantity that binds it -- the receiver's resident memory -- has no protocol bound at all: nothing in the specified checks caps it, so a fixed budget is imposed, non-configurable, and enforced as a fail-closed refusal in lockstep on both parties. The both-sided pair table has a protocol bound, stated above and enforced by the returned-list checks: this party's own row count times the partner's declared record count, every factor read off authenticated state. So the advisory imposes nothing; it names a figure no smaller than a bound already in force. The asymmetry is therefore in what each cardinality's binding quantity is bounded by, not in how tolerant each is of a large run.

The many side's per-value multiplicity is not independently bound. The checks above bind the aggregate and the positions -- a repeated position must name a value this party itself matched in that round, and a list's length is capped by a record count declared on the authenticated terms exchange -- but not the size of the group standing behind any one position. That size is the many side's own declaration throughout: the expanded return of pass 2 is checked against the count the many side's pass-1 list already implied, not against any quantity the receiving party derives from its own data or from the agreed terms. A many side that overstates a group therefore satisfies every check specified here, to the limit of its declared row count, and its partner reads a multiplicity that side's data does not hold. The values-per-record axis binds its comparable quantity -- the ragged index table's per-cell width is checked against a bound the receiver derives from authenticated session state (Wire-format deltas) -- and the records-per-value axis has no counterpart.

The injectivity clause is stated for the resolver shape this cascade implements. Its two halves read structure that shape holds: the "one" side's within-round uniqueness rule makes a position one value held by one row, and removal on a potential match keeps a row matched in round j out of every later round's candidate set, so two entries naming different (round, position) pairs name different rows. Under many-to-many the halves read with the group in place of the row, over the same structure: a position stands for the partner group holding one value, distinct positions in a round are distinct values held by disjoint groups, and removal takes every row of a matched group out of the later rounds. So runs answering different positions are disjoint, and the runs answering one position are the one group. Where a candidate set widened the round the clause reads over the accepted partner records rather than over the position, since a position there stands for neither one record nor one whole group (The final mapped-element entry names the positions its record's pairs rest on); what holds its two halves is the pairing both parties computed from the round's groupings, and it refuses the same merge and the same split. The single-resolver variant below holds neither by construction: the "one" party there contributes its whole dataset every round, and the obligation the "many" party takes on for it is the uniqueness rule, not that party's removals. One of its rows therefore stands behind a position in more than one round, and a conforming return would be refused as a merge of two groups. That variant is sanctioned and unbuilt; building it means re-deriving this check against what its resolver actually holds, rather than reusing the clause unchanged.

What is unbound is one-directional. It is the many side declaring the size of its own group to its partner, and it should not be read as the pair of freedoms it resembles. The other direction -- the "one" side deciding which of the many side's records fall into one group -- is bound, by the injectivity modulo the grouping above: the many side holds the position each of its own entries named, and refuses a returned list that merges two of its groups onto one row or splits one across two. The grouping a deduplicating result holds is always that party's own. Under many-to-many each party stands in both roles at once -- declaring its own group sizes and holding its partner's return to the grouping it sent -- so the asymmetry holds twice over rather than cancelling.

The residual is recorded, not closed. There is no authenticated quantity to bind a group size against. The agreed terms say nothing about how a party's own rows duplicate, and the only figure that would is the duplication structure the protocol keeps off the wire (Role resolution and work minimization), so any bound would be advertised by the party it binds -- a self-declaration again. What overstating buys is correspondingly small, and is stated here so a later change that widens it is recognizable as one. Which of the reading party's records matched is fixed by the coverage rule above, over positions that party itself matched, so an overstated group adds no matched record, no disclosed column, and no membership fact about that party, and the payload it discloses is for rows it already pairs. What the overstatement adds beyond that follows the shape the return takes. Entry for entry, every entry of one group names the same partner row, so it repeats a row the reading party already pairs and already discloses payload for, and inflates the overstating party's own result table with pairs naming its own rows. Under runs distinctness within a run rules that repeat out, so the entries an over-declared run adds must be filled with further partner rows: an over-declaration within the declaring party's counted rows can put in the reading party's table a partner row appearing nowhere in the honest one. That is the same self-declared multiplicity read through the run shape rather than a further admission -- the size of the group behind a position is the declaring party's own word either way, and the reading party holds nothing to weigh it against. Either way one group's inflation is bounded by the overstating party's row count declared on the terms exchange, which bounds the payload rows it can make its partner repeat with it, and the whole list by the product above. This would need revisiting if the multiplicity ever decided something the receiving party discloses -- a new column, a new row, or a matched-record set read from the returned list rather than from that party's own round output.

Within-run order is the arranging party's own, at both boundaries. At the single-pass boundary the freedom lives in one of the two configurations. Where the repeating half is the sender's own -- the receiver deduplicates -- the non-decreasing rule leaves the pairs sharing one of its row indices contiguous, as a run, and which pair takes which position inside a run is bound by nothing. The checks reach each entry's range, the two halves' equal length, and each half's order and distinctness rules as stated above, and the sender -- which receives that table rather than resolving it -- holds nothing to derive the arrangement from either. Where the repeating half is the partner's -- the sender deduplicates -- no order rule reaches that half, and none is needed: the sender's own half is strictly ascending and distinct, which pins every position, so the arrangement holds nothing beyond the pairing itself. The cascade's returned list holds a freedom of the same kind wherever an expansion produces it. Checked flat -- the reading a party takes when it keeps no duplicates of its own, under one-to-many -- distinctness alone reaches the list, and the arrangement inside each expanded run is the returning party's own. Checked entry for entry it is one row per entry, held to the grouping the party checking it sent. Checked run for run, the arrangement inside a run is again the returning party's own, constrained to be the same arrangement in every run of one group -- one consistent arrangement per group, so no pair is added, dropped, or re-attributed by the ordering. Whether the rows of a run are the honest group behind the position is the self-declared multiplicity recorded above, which no arrangement rule reaches.

What that leaves open is an ordering rather than a pairing, and it stays inside the artifacts of the party receiving the table or the run-grouped return: a run holds the same pairs whatever order they arrive in, so none is added, dropped, or re-attributed. What moves with the order is the order of that party's result file rows within one run, and its record's association-table commitment is computed over the table those rows were written from -- both per-party, local, and self-attested. A record's commitments are never compared against the partner's, and the pairing is not part of the signed receipt (EXCHANGE_RECORD.md), so the result file, the record, and the re-supply path that reopens that commitment agree with one another on whatever order arrived.

Where the "one" party receives no output. A cascade can run with only the receiving party resolving the final association (above). A deduplicate: true party is always entitled to output while its partner may not be, so in a deduplicating exchange that sole resolver is the "many" party, and it has one extra obligation. Where the "one" party does not itself apply the within-round uniqueness rule to its own values -- the variant in which one party contributes its whole dataset each round and the other resolves -- the "many" party MUST apply that rule on the partner's behalf, dropping from the round any value two or more of the partner's records hold. Omitting it would silently promote the exchange to many-to-many, delivering a match multiplicity neither party's terms declared.

single-pass is that shape by construction, and it is where the obligation is discharged structurally rather than taken on. Its receiver is the only resolver whatever the output entitlements, and role resolution makes the party entitled to output the receiver -- so in a deduplicating exchange whose "one" party expects none, the "many" party is the receiver, and it applies the "one" side's within-round uniqueness rule to that party's index table as part of the replay. The helper's own table withholding composes with it unchanged: the helper needs its half back to build a result (it has none) or to enrich a payload it discloses, and the multiplicity bears on neither condition. A "many" party can never be on the withheld side, since it must be entitled to output.

The both-sided table's bound under single-pass ​

Under single-pass the receiver resolves the whole table and the sender receives it, so at that boundary the sender holds no round, no block, and no grouping to check the table against. Under every other cardinality one half of the table keeps its distinctness and its range check caps the table's length (above). Under many-to-many neither does, so the table is held to a bound of its own.

The bound is this party's own row count times the partner's declared record count. The sender computes both factors itself -- its own rows from its own dataset, the partner's count from the terms exchange -- so nothing about the bound is read off the frame under check (assertPartnerPairTable, packages/core/src/utils/partnerIndices.ts). It is the same product the cascade's returned-list checks already bind a both-sided table to (The both-sided expansion has no ceiling of its own), so the two strategies bound the same quantity by the same figure. A table longer than it is a protocol violation: the exchange aborts with a classified protocol error and produces no result, before the table becomes the sender's match set, its payload row selection, or its attested record.

It is not operator-configurable, and it is not a ceiling. The value is a function of the two counts, so there is nothing to configure and nothing to raise. It refuses only a table no honest run of the two datasets could have produced -- a value m of one party's rows and n of the other's hold contributes m * n pairs, and the product is what every value summed reaches at most. What a legitimate large run meets instead is the channel's per-frame bound on the frame holding the table and the advisory above it, both stated with the measurement the figures come from.

It checks the returned table and does not gate at prepare time. The same product is projectable from the agreed terms before any round runs, and the cascade takes no projectable ceiling on it by design: refusing there would refuse an exchange whose bound the operator could have read off the agreed terms, and the projection is what the advisory names rather than what a refusal enforces. Two strategies differing on the same quantity would make linkage_strategy decide whether a projectable run is admitted at all, which no other bound does. So single-pass refuses nothing at prepare time that cascade admits; what it adds is a check on the artifact the cascade checks by other means.

Two further rules hold the same table. Its own half is non-decreasing -- the order the result rows, the payload rows, and the re-supply path read it in -- and no pair is named twice, a repeated pair being one link the attested result size counts twice and the result file writes twice. Every entry of each half is a whole number within the rows that half addresses, as at every other boundary (Partner-supplied index tables).

What the sender cannot check is the pairing itself. Within those rules the resolution is the receiver's word, which is this strategy's standing trust statement rather than a new one (Linkage strategies): its sender takes the whole resolution as the receiver's under every cardinality. What many-to-many changes is how much room that word has -- under one-to-one the table is a partial matching, here it is any sub-multiset of the product -- so a receiver could group the sender's records more widely than the rounds did, up to the bound. The closure check holds the RESOLVING party's own table to the round's blocks; the sender's guarantee is the bound, the two rules above, and the semi-honest model this project adopts (SECURITY_DESIGN.md). A sender that needs the pairing held to the round's own structure runs cascade, whose returned-list rule reads it.

The disclosure delta a deduplicating match pays ​

  • Key-group multiplicity. The many side's within-dataset duplicate values participate in a round instead of being dropped, and the mapped-element passes name the one side's matched position once per record in the group. The "one" party therefore learns, for each of its records that matched, how many of the many party's records share that linkage-key value, and which of the many party's rows they are -- the same opaque row-index layer the association table already holds for a one-to-one match, several rows against one of its own. Under one-to-one none of that is disclosed at all: the value is dropped before the round, so the partner learns neither that it matched nor that a group holds it. The disclosure is bounded to matched groups -- a group whose value the partner does not hold matches nothing and is never counted -- and it is a count and a set of row indices, never the key value behind them.
  • What a round's element count summarizes. A deduplicating party's per-round set holds one entry per distinct candidate value for that key, where a one-to-one party's holds one per value occurring exactly once among its candidates. Set beside the raw row count the terms exchange already holds (Role resolution and work minimization), the two are slightly different summaries of the same dataset; neither is the intersection, and which one is being read is itself fixed by the agreed terms.
  • No delta from the single-resolver obligation. The variant in which the "many" party applies the "one" side's uniqueness rule on its behalf is the one that already sends both datasets whole every round, whose disclosure is stated above. Applying the rule reads structure the resolver already holds.
  • Under single-pass one direction is already paid. That strategy hands the receiver the sender's whole per-key index table, which names which of the sender's records hold one value (Why the full index table). So where the "many" side is the sender, the key-group multiplicity above is not a delta of the cardinality at all: the receiver held that structure under one-to-one too. What the cardinality adds is what comes back -- where the "many" side is the receiver, the sender reads the size and rows of the group its own record took off the resolved table's repeated half, which is the first row's disclosure and no more. A helper whose table is withheld reads neither.
  • Exchange record. No delta. Commitments cover the association table's row-index pairs and the payload values, never key strings (EXCHANGE_RECORD.md), and a repeated row index on one side of a pair is still a pair. The recorded result size reads that table as pairs for the same reason, so both parties' records state one figure (Result size under a deduplicating cardinality).

The many-to-many entity closure ​

The (true, true) pair resolves to many-to-many, and the per-side rules above give its round-level pairing directly: both sides keep their within-dataset duplicates, and every candidate pair a round produces is accepted. A table where both sides hold multiplicity links records transitively -- two of one party's records reach each other through a partner record, and a partner record through them -- so it means something to either party only once that multiplicity is resolved into single entity clusters. A cluster is a connected component of the bipartite graph whose vertices are the two parties' matched records and whose edges are the association table's pairs: a set of this party's records together with the set of the partner's records they link to. A record that matched stands in exactly one cluster; a record that did not stands in none.

The definition reads the pairs and nothing else. It names no round, no linkage-key value, no cardinality, and no quantity either party declares, so a record contributing several values to a round changes which pairs the table holds and never what a cluster is. What such a record changes is the SHAPE the clusters take, stated below.

Each party computes the closure locally, from the table it already holds. No frame holds it, no round is added, and nothing is exchanged for it. Both output-entitled parties end the cascade holding the same association table (Deriving one table from the exchanged association maps), so both derive the same clusters from it, and that agreement is a property of the one table rather than of a further reconciliation. Every many-to-many party is entitled to output, a deduplicating party being required to receive it (The declaring party is the "many" side), so no party is left holding a multiplicity it cannot resolve. The reference implementation lives in core (entityClusters, packages/core/src/psi/entityClosure.ts), internal to that package rather than part of its API -- what core exports is the size-and-values summary the key-choosing paragraphs below state, not the clusters themselves; a consumer that wants clusters recomputes them from the result file's pairs by the rules this section states.

The closure fixes no order and no tiebreak, whatever a record's candidate set. A partition into connected components is a set-valued pure function of the edge set: reading the same pairs in another order yields the same components, so there is no choice for the two parties to agree on and none is specified. What they hold in common is the cluster SET, each cluster's two halves being its members in their own party's row space; the order clusters are listed in is each party's own presentation, since each orders over a table transposed from its partner's. Anything that would need a tiebreak -- refusing to chain two blocks joined by one record, or a maximum-cardinality variant of the pairing -- is a resolution rather than a closure and belongs in the one shared sweep, which already owns the canonical (sender row, receiver row) order (Record-level resolution).

The stake is commitment-grade, and the table carries it. Both parties commit over the association table in the exchange record, so two parties resolving one exchange differently is a verification failure rather than a degraded match. What holds them together is the table itself and two facts already normative. Both output-entitled parties end the cascade holding the same table, each in its own orientation (the two are transposes of one another), through the canonical (sender row, receiver row) order the one shared resolution function fixes (What the cascade realization owes). And at the round, many-to-many acceptance is total, so a round's accepted set is the pair set its two groupings determine -- a symmetric function of the two frames both parties hold, with no order to reproduce (The per-round grouping the two frames hold). The clusters follow from that one table as a pure function of it, so the closure adds no place for the two to diverge and needs no reconciliation of its own.

The closure is round-diagonal: a cluster may span several blocks of one round and never two rounds. A round's pairs partition into its per-(round, value) blocks -- one block per matched value, holding every pair between the m records of one party and the n of the other that contributed it -- and a record contributing several values to the round stands in several of that round's blocks, joining them into one component. One rule already specified bounds how far that reaches: every record on either side that appears in any of round j's candidate pairs leaves candidacy for every round after j, accepted or not (Multiplicity is within-round, in The per-side rules) -- a rule about records rather than about values, which a record holding several candidates meets exactly as one holding a single value does. Both strategies that pair the cardinality apply it over their own rounds (MANY_TO_MANY_IMPLEMENTED_BY_STRATEGY, packages/core/src/linkageTermsPolicy.ts, which both the agreed-terms refusal and singlePassResolves in packages/core/src/psi/link.ts read): the cascade round by round as each resolves, single-pass in the receiver's replay of the same rounds. Every pair of the table therefore belongs to one round and pairs two records matched in it, so no component reaches a second round. The invariant is four conditions:

  • no cluster spans two rounds;
  • each block is the whole m x n product of the records contributing its value;
  • no block is split across two clusters;
  • every pair the table holds is a pair of some block.

Where the block-diagonal claim went. Under the one-value-per-round restriction those four conditions read as block-diagonal: each cluster is exactly one block, since a record standing in one block per round leaves nothing to chain. That restriction was never a limit of what a round or a frame can state; it was a refusal of the candidate set, and the shape a round admitting one takes is round-diagonal. Nothing else about the closure moves with it: the definition, the local computation, and the absence of an order are all as stated above, and the sibling cardinalities' shapes are untouched -- many-to-one and one-to-many leave one side's contributed values unique within its dataset, so their tables hold at most one pair per record of the other side and chain nothing.

The smallest chained cluster. One linkage key, many-to-many, sender S and receiver R with two records each. S0 realizes the candidate set {ab, cd} and S1 realizes {cd}; R0 realizes {ab} and R1 realizes {cd}. The round's value-level matches are ab and cd, lifting into the candidate pairs (S0, R0), (S0, R1) and (S1, R1), every one of which is accepted. The four records form one cluster over two blocks, holding three pairs where the 2 x 2 product would hold four, and S1 and R0 stand in it sharing no value: what joins them is S0's two candidates. That is the shape block-diagonal excluded and round-diagonal admits, and the disclosure paragraph below is what it costs.

The invariant is held by a check, not by the paragraphs above. Each strategy runs it over every both-sided table it resolves, before returning it (assertRoundDiagonalClosure, packages/core/src/psi/entityClosure.ts), taking the round each pair was matched in beside the round's own blocks and requiring the four conditions of the clusters it computes; a table failing any of them aborts the run as an internal inconsistency and produces no result. Under single-pass that is the RECEIVER, the only party holding a round or a block: the sender is handed the resolved table and holds it to the pair-count bound instead, which is what that strategy has in place of the returned-list rule the cascade's own resolver applies. What it returns for a table that passes is the operator diagnostic the key-choosing paragraphs below state, read off the clusters and blocks it has just held to each other. A party that resolved no round composes none: under single-pass the sender holds the pairs and the clusters they fall into, but not the per-cluster value count, which is read from blocks it never saw. The blocks are read per matched VALUE rather than per record, since a producer putting one record in two of a round's blocks is exactly what a candidate set is and a grouping per record would flatten the shape the check exists to read. A closure over some later table that is not round-diagonal at all is a different procedure with a disclosure question of its own, and this is what makes that a visible change rather than a silent one.

The closure discloses nothing further; the widened shape changes what the grouping says. The closure reads one artifact this party already holds and puts nothing on the wire, so a many-to-many exchange discloses what its pairing discloses (The disclosure delta a deduplicating match pays, which each party pays in both directions here, together with The disclosure delta fan-out pays where a candidate set widened a round). What moves is the grouping's meaning. While the closure is block-diagonal a cluster's members all share one value under one key, so every grouping is evidenced by exactly the rule that formed it. A chained cluster is not: two of a party's records can stand in one cluster through a partner record no linkage key links them to, joined only by the several candidates one record contributed to the round -- S1 and R0 in the case above. Both parties read that grouping as one entity. That is the disclosure admitting candidate sets under many-to-many pays, stated here rather than argued away, and it is paid by the pairing rather than by the closure: the pairs a chain rests on sit in the association table both parties hold whether or not either computes clusters.

It adds no term to consent to, and the surfaces state it. What the two parties consent to is the pairing, and both halves of the chain are already on those surfaces: each party's deduplicate declaration and what a deduplicating match discloses (above), and, where a key expands one value into several candidates, that a record matches on any of them and leaves the later keys once any matched (The disclosure delta fan-out pays). The chain is those two consented facts read together over one table, so it adds no row, no column, no frame, and no round, and the closure that reads it is local to each party. What the widening owes is therefore this statement, the operator diagnostic below, and one sentence stating the two halves read together, rendered wherever a party consents to terms declaring both -- a candidate set the exchange matches and a deduplicate whose pair the accept takes from the other party (the candidateSetChainsGrouping consent fact, packages/core/src/consent/consentFacts.ts, resolved in packages/core/src/consent/invitationSummary.ts) -- not a further term to consent to.

A cluster is not written as a cluster. The result file holds one row per association pair under every cardinality, so a cluster writes one row per pair it holds: each of this party's records repeated once per partner record it pairs with, each row holding that partner record's row index and payload values. A cluster that is a single block of m of this party's records and n of the partner's writes m * n rows; one chaining several of a round's blocks writes fewer, the pairs its chain does not hold never having been matched. The payload frame is unmoved -- one row per matched record of the sender, whatever the cardinality (Output) -- so the multiplicity stays on the association table's side of the join. The attested result size is the pair count (EXCHANGE_RECORD.md), which under the closure is that per-cluster row count summed over the clusters; it is neither party's matched-record count and neither party's cluster count, and both parties derive the one figure from the one table. No column names the cluster: the pairs are what the record's commitments bind, a cluster id would move every value column's offset for every reader of every result, and a reader that wants clusters computes them from the pairs.

Choosing linkage keys under closure. A cluster is the set of records one round's matching joined, so a cluster is only as good as the key that formed it. A key that is not near-unique where it is present (Linkage keys) groups records that are not one individual, and the closure hands that grouping to both parties as a single entity. Four consequences an operator authoring a many-to-many template weighs:

  • The key a cluster forms on is the most precise one any of its members matched under, removal being within-round. A weaker key later in the cascade can form clusters of its own but cannot widen a cluster an earlier key formed, so adding one never regroups what a precise key already decided.
  • A key whose value names a household, a site, or an episode rather than an individual produces exactly the cluster that key describes -- everyone at the address, everyone at the clinic -- and the result states it as an entity grouping.
  • A key that expands one value into several candidates chains blocks. One record's candidates join every block they reach into one cluster, so a record holding many of them can pull a large share of a round into a single entity, and the record driving it need not be one either operator would look at twice. The protocol sets no ceiling on that, on the reasoning that declines one for the pair table: the inputs and the terms are the operator's own, the worst case is projectable from them before any round runs, and refusing would convert a large result into no result. What stands in its way is the key the operator authors and the diagnostic below.
  • The cluster size distribution is the diagnostic an operator reads off its own result, needing nothing from the partner: a template whose clusters are mostly 1 x 1 matched as a near-one-to-one exchange, while a cluster spanning a large share of either dataset is the signature of a key that identified a group rather than a person. Beside it, the count of distinct values a cluster formed on -- one for a single block, more for a chain -- separates a large cluster a genuinely shared value produced from one a record's candidate set chained together. Each figure is a party's own to derive from its own rows and its own table, raised at a front end's discretion on the footing the pair-table advisory takes, never a refusal. The two paragraphs below state where the figures come from and where each seat renders them.

Where the two figures come from, and what they hold. The closure check returns them for every table it passes (assertRoundDiagonalClosure, packages/core/src/psi/entityClosure.ts), so they are read off the clusters and blocks the check has already held to each other rather than recomputed from the result file.

  • The distribution. The cluster count, the records of each party standing in a cluster, and one entry per distinct shape a cluster takes: this party's records, the partner's, and the distinct values that formed it. Two clusters merge into one entry only where all three of those figures agree, so an entry's value count is exact for every cluster it stands for rather than an average over a size class. The entries are ordered by the records a cluster holds, largest first, which is the end an operator reads.
  • The value count. A cluster formed on as many distinct values as its records stand in blocks, one block being one matched value. The check's own conditions attribute each block to exactly one cluster -- a block split across two is refused, and a block naming a record the table pairs with none of the partner's is the same containment refused -- which is what makes the count exact rather than an estimate over the pairs.
  • What reaches a party. The run hands the summary out on its result under the same withholding gate the association table takes and under many-to-many alone (ExchangeResult.entityClusters, packages/core/src/exchange.ts). Every other cardinality leaves one side's records distinct, so its clusters follow from the table's own shape and no summary is composed.
  • What it never holds. Counts alone: no record, no row index, no linkage-key value, and no quantity the partner declared. So the summary states nothing beyond what this party's own run already held, with no partner-derived figure and nothing sent on the wire, and it refuses nothing.

Every seat states it from one composition. describeEntityClusters (packages/core/src/entityClusterReport.ts) composes the operator's sentence from that summary, and each front end renders it where the party reads its own result: the CLI on its post-run report beside the result file it wrote, the browser on the completion panel. The sentence is first-party prose over integers core formats itself, so no partner-authored text is interpolated into it and a display sink escapes it as it escapes any other message. Naming a shape per cluster is unbounded where a run produces many, so the sentence names the largest shapes and reports the rest as a count of the small end.

The reading guide is held to a cluster large against the run. The sentence closes with the guide -- narrow the key or the candidate values -- only where the largest cluster is past one record on a side and also holds more than a tenth of either party's matched records, the share the diagnostic above reads as a key that named a group rather than a person. For a 2 x 1 largest cluster (two of this party's records to one of the partner's) the guide is suppressed only once this party has at least 20 matched records and the partner at least 10, and the 1 x 2 mirror swaps the two counts; a small test run may see it on an ordinary result. A summary whose shape list is empty states the counts alone: the composition is total, so no summary a seat holds leaves it without a sentence.

The summary itself rides the CLI's machine interface, for the seats that read no sentence. A supervisor reading only file descriptor 3, and the console seat driving a CLI run, see no info log line, so the summary goes on the terminal result event as counts (CLI_EVENTS.md, which also bounds that event's shape list). The console renders it through the same composition the browser does, off the counts the event delivered rather than off a table it does not hold.

What this resolution leaves refused. A candidate set is not refused for reaching a many-to-many cascade -- not at the accept boundary, not at the agreed-terms run boundary, and not by the round's own single-candidate rule, which reads the strategy allowlist alone (resolvesCandidateSets in linkViaPSI, packages/core/src/psi/link.ts). Every other refusal stands, each on a reason this resolution does not touch:

  • single-pass pairs the cardinality over the same candidate sets, its receiver replaying the rounds this shape is fixed for and holding its table to the same four conditions. What differs is where the table is checked, not what it holds: the resolving receiver runs the closure check, and the sender holds the table it is handed to the pair-count bound.
  • psi-c refuses a candidate set on its own reason, the order a one-to-one count over one could depend on, and refuses deduplicate, which this cardinality needs, on another (Refusals under count-only).
  • The strategy allowlist stands, so a linkage_strategy added later refuses a candidate set until its own resolution is written.
  • The width bound and its two exceedance fates are unchanged. The cardinality widens the records-per-value axis and the candidate set the values-per-record axis, and each stays bounded where it already is (Matching multiplicity).
  • The closure check stands, holding the four conditions above.

Where each refusal over an invitation's own content fires. The checks an accepting party can settle from the invitation alone -- the terms document it adopts verbatim, that document's transforms, strategy, cardinality and disclosure lists, and the token's endpoint. Each MUST fire at the earliest boundary its inputs are knowable, so a partner cannot author an invitation that reaches a consent surface, is accepted, and is refused only once the exchange runs.

  • At the decode, meaning every parse of the terms document and the invitation-token decode over it (LinkageTermsSchema and InvitationTokenSchema, packages/core/src/config/): the psi-c shape rules a terms document holds; a candidate set under psi-c or under a linkage_strategy off the allowlist; both width ceilings; an element field or a swap target naming nothing the document declares; a swap pair whose two positions declare different transforms or different fuzzy comparisons; the transform regular-expression dialect with its pattern-length bound, the declared transform-param types, the param magnitude bounds (TRANSFORM_PARAMETER_BOUNDS.md), and a raw-pattern step -- replace_regex, extract_regex, filter_regex, split_on -- declaring no pattern, which the function reads no default for; a string that is not well-formed UTF-16, and a document nested past the depth bound; and the token's own coherence -- inviter_retains_files against the endpoint channel and against the split pair, the disclosed payload columns against payload.send in both directions, and the endpoint's shape.
  • At the accept boundary (deriveAcceptedLinkageTerms, packages/core/src/linkageTermsNegotiation.ts), the first point one party holds both documents: the agreed (true, true) pair under a strategy that pairs no both-sided cardinality, and the schema re-check of the mirrored document the acceptance derives, which exists only once it is derived.
  • At the local prepare step or the agreed-terms run boundary, each for an input no earlier boundary holds: the two presented documents against each other, and the presented deduplicate against the value the invitation declared, both of which exist only once the terms exchange has run; the two parties' algorithm values, and their payload declarations against the disclosure each asserts, both taking the agreed pair; the terms against this party's own columns, authored standardization, and input metadata -- satisfiability, standardization consistency, the payload send and disclosed-name rules, and the psi-c input-metadata rule -- each reading an input no invitation holds; the single-pass dataset ceiling, which reads this party's row count; and the candidate-set rule's standardization half, which is per-party and local.
  • A party's own terms meet the partner's decode at the local prepare step. prepareForExchange parses the local document with the schema the partner applies to received terms (LinkageTermsSchema, packages/core/src/config/linkageTermsSchema.ts) and refuses a document it rejects before any connection opens, so terms built in code rather than read from a file cannot reach the partner only to fail its parse. The refusal names the first failing field's path as the file spells it and, where the failing value is a scalar, that value; a failure over a non-scalar, such as the unique-key rule over the linkage_keys list, names the path alone.
  • A decode-settled rule keeps its later boundaries. The asserts over algorithm, the psi-c shape, the candidate set, the declared width against the strategy, and the width derivation itself each stay where they are, because a LinkageTerms value built or mutated without a parse reaches them, and a fault an operator authors across two configuration files reaches no parse of a single document.
  • An element transform whose factory rejects its declared params is refused where the terms are authored, where an invitation over them is minted, and at the accept boundary (assertTransformsCompile, packages/core/src/linkageSatisfiability.ts), which compiles the steps rather than inspecting them and holds a step-count cap and a wall-clock budget of its own. Both accept seats run it over the invitation's element transforms alone, ahead of any consent display or connection: deriveAcceptedLinkageTerms on the CLI, and prepareAcceptedInvitation in the browser. Past that point the compile is reached at key realization, on the run, which keeps it for terms that reached no accept.
  • A check reading this party's OWN configuration or data fires where that data is first bound, and again at the run. The binding point is the acceptance itself for an online CLI acceptance, which takes the input file there; the columns step for the browser acceptor; and the preflight for a configuration accepted offline and run later. It is not the accept boundary as such: the accepting party's data is not invitation content, and an acceptance that binds none refuses over none. The run boundary keeps the check whatever a binding point decided, because the binding can be redone -- an offline acceptance writes a configuration and stops, and the later alcove exchange reads a file the acceptance never saw -- so a binding point's verdict is about the file it graded and the run guard is the binding check.

What that enumeration excludes. Three classes, none of them a property of the invitation: checks over the partner's DATA and its wire frames -- the received payload, the matched-pair and returned-list rules, the intersection-count bound, and the frame-size caps; checks over the TRANSPORT -- host-key trust, the peer-role requirement, endpoint scheme and host validation, the polling advisories, and the shared secret and the token's expiry, which is a freshness property rather than a runnability one; and checks over the LOCAL ENVIRONMENT or this party's own configuration -- the signing mode, the certificate-mode and local-certificate rules, the disclosure-commitment and outbound-consent rules, and the configuration reconciliation each front end runs.

What this resolution owes. Two obligations it takes on rather than inherits, beside the closure check above.

  • The returned-list rules read the round's pairing, not one accepted partner rank. The injectivity clause and the run rules a returned list is held to (Deriving one table from the exchanged association maps) were derived where each accepted record named one position standing for one whole partner group. With a candidate set on both sides an entry names a SET of positions and the groups behind those positions overlap, so what a rule reads for a record is the deduplicated union of the owner groups its entry's positions name, taken over the ordinals the round's grouping declares (The per-round grouping the two frames hold) rather than over any single rank. Two entries whose accepted partner records coincide MUST come back with identical runs, two whose accepted partner records are disjoint with disjoint runs, and the rows within one run MUST be distinct -- the same three halves, read over those unions. Keying them to one rank instead would let a partner's fabricated edge merge two clusters, which is amplification a one-to-one table does not have. The check reads them over the partition itself (resolveRunGroupedReturn, packages/core/src/utils/partnerIndices.ts): which of the checking party's records the return claims each partner row for MUST be a set that party's own pairing accepted together, none of those sets may be left without a row, and no partner row may be named in two key rounds -- the disjoint-runs half of the rules above, read over the whole list -- so a list naming one row under two rounds is refused as the partner's protocol fault before the table is built.
  • The conformance reference is a specification oracle. The equivalence MUST that binds a cascade round to the table single-pass computes (Equivalence with the single-valued cascade) is scoped to a cardinality both strategies match, which this one is, so the two strategies' tables are asserted equal here as elsewhere. The oracle is what the corpus asserts against beside that: the round's pair set built directly from the two parties' incidences -- for each round, the union over its matched values of the product of the records contributing that value on each side, deduplicated. The oracle is correct by construction precisely because acceptance is total -- no order, no tiebreak, and no within-round removal enters it -- and it is a test artifact rather than a second shipped resolver, which is what the differential vectors are for. Those MUST run fixed inputs in both role assignments, asserting both parties' association tables and both parties' cluster sets equal, and MUST cover a chained cluster (the case above) alongside a table of single blocks.

The (true, true) pair runs on both linkage strategies. resolveLinkageCardinality (packages/core/src/exchange.ts) resolves the agreed pair after the terms exchange and before the first round, so an exchange whose two parties both declare deduplicate: true runs the pairing, the closure, and the accounting above, whichever strategy the terms name. The same pair on a strategy that pairs no both-sided cardinality is refused at that point instead (the note opening this section), naming the strategy that stands in the way rather than the cardinality; no strategy this document specifies is in that position.

The invitation route reaches the pair from the accepting side only. Acceptance never adopts the invitation's deduplicate for the accepting party: that party's own value is its own to declare, false where the accepting seat offers no control for it, and the declaration the acceptance retains records the inviting party's side alone (expected_partner_deduplicate, Terms-binding consent). No field records an inviting party's expectation of the acceptor's value, so an accepting party declaring deduplicate: true -- at a seat that offers the control, or later in its own configuration -- resolves the both-sided pair with no inviter-side declaration to hold it to: the terms exchange is where the inviting party's implementation sees the widened pair, and the run proceeds from there. It is the one-sided scope stated above, at the cardinality where both parties' records group.

Role resolution and work minimization ​

Both parties send their record counts on the terms exchange, then resolve locally which is the PSI base function receiver and which is the sender -- with no separate round-trip. Both the count's placement and the assignment are specified here. The reference implementations are exchangeTerms (which puts the counts on its messages) and resolveRole (the local decision over them), both in packages/core/src/protocolSetup.ts.

The counts ride the terms exchange. Each party's record count travels on its terms-exchange message envelope -- beside linkageTerms, like the receive ceiling, the save flag, the observed-host-key advertisement, the payload-intent flag (disclosesPayload, whose consumers are the single-pass table withholding above and the payload-disclosure refusal every strategy runs at the terms exchange), the signing certificate a party that will sign presents (Signing identity and certificate pinning below), the responder's wrapped rotation value responderValue on an authenticated run (The rotation at the terms exchange below), and the protocol version (Protocol-version reconcile below) -- on every exchange, both- and one-sided-output alike. So by the time the terms are agreed on, each party holds its own count and the partner's, and the role follows from a purely local computation with no additional messages. The count is per-party, per-run role and element-bounds metadata held on the envelope, never inside linkageTerms: it differs per party and per run, so folding it into the terms would make two honest parties compute different canonical/agreed-terms hashes -- it is kept out of them. This count is independent of the linkage rounds and is a separate quantity from the sender's record count packed into the single-pass reply (part (c) above), which is that party's own row count embedded so the receiver can size the index table. The integer sent is the party's declared record count -- its raw row count times the fan-out factor its own standardization declares (the width bound), so the two are the same number for a party whose cleaning does not fan out. It is the quantity the role assignment below uses, the single-pass pre-flight checks locally, and the data-dependent single-pass frame cap derives its bound from (see The single-pass dataset ceiling above). It stays on the wire even in the one-sided case, where the role decision does not consume it -- precisely because the frame cap and PSI element bounds do, on every exchange. (The quantity that actually drives that frame's size is the distinct-value count, which is not exchanged; the cap bounds it from the row and key counts.)

The payload column names ride the terms exchange too. A party's linkageTerms state the names of the payload columns its metadata discloses as its payload.send: the list as authored where it names exactly that set, and the disclosed names otherwise, each keeping an authored description (EXCHANGE_FILE.md). A party whose payload.send does not state what its metadata discloses is not refused locally; its partner meets the difference as a terms change. The names are part of its terms, so they reach the partner at the terms exchange, before any linkage round, on every run -- a run that matches nothing and a run refused after the terms exchange included. Column values never ride the terms exchange: they move only in the payload frame, for matched records.

The assignment. It is chosen to minimize total work -- total communication bytes plus total per-party compute -- for the densely-populated near-unique keys linkage typically uses:

  • When exactly one party expects output, that party is the receiver regardless of the counts -- it is the only party that learns the result.
  • When both parties expect output, the assignment is free. The rule is: the party with the smaller declared record count becomes the receiver; an equal-count tie is work-neutral and broken deterministically in favour of the initiator.

This is one rule for both linkage strategies; the rest of this section derives it, including the regime where a row-count rule cannot be optimal.

What a local fan-out does to the assignment. The count the rule weighs is the declared one, so a party whose own cleaning fans out is compared at 20 times its rows and trends toward sender, not receiver -- and toward it on a declaration only that party can make, which no partner can check (the width bound). Under single-pass the receiver is the sole resolver: it holds the sender's whole per-key value structure and replays the match locally (Why the full index table). A unilateral, unverifiable inflation of one's own declared count therefore moves the declaring party onto the side that is READ, never onto the side that reads: it cannot be used to reach the resolver's seat, and it buys nothing a party could not get by pre-fanning its file. The behaviour is driven, not asserted: the fan-out role case in packages/core/test/config/derivedKeyWidth.test.ts runs a fanning party against a larger partner and pins the resolved role to sender.

Why the receiver is the heavier side. In the PSI base function the receiver (client) encrypts its distinct-value set under its own key and sends it; the sender (server) encrypts its own set under its key and re-encrypts the receiver's set, returning both. So the receiver's set crosses the wire twice -- once as its request, once re-encrypted in the reply -- while the sender's crosses once, and the receiver's set drives more of the expensive curve operations (its own encryption, the sender's re-encryption, and the receiver later stripping its own key from the reply) than the sender's does. Writing D_recv and D_send for the two parties' pooled distinct-value counts, and at the 35 bytes per encrypted element given above, the dominant communication cost is 35 * (2*D_recv + D_send) bytes; both that and the curve-operation count are larger for whoever is the receiver, by an amount that grows with the receiver's distinct count. Total work is therefore minimized by putting the smaller set on the receiver.

The two strategies' cost terms. The cascade is one base-function round per key over a shrinking candidate set; every round has the 2*D_recv + D_send asymmetry above and nothing that opposes it. For the cascade, smaller-as-receiver is optimal, and the win over the other assignment is up to about 35 * (D_large - D_small) bytes (plus the matching curve operations); the per-round candidate set shrinks as the cascade proceeds, so the realized gap is at most that bound. The single-pass reply adds one term the cascade has no analogue for: the distinct-value index table, 4 * keyCount * senderRecordCount bytes (defined above), which scales with the sender's row count and is the one term that favours the opposite assignment -- making the smaller-row party the sender shrinks it. So single-pass has two asymmetric terms pulling against each other:

TermApprox. sizeFavours
Masked-set / re-encryptionabout 35 * (D_large - D_small) bytessmaller-as-receiver
Index table4 * keyCount * (rows_large - rows_small) bytessmaller-as-sender

where D_large / D_small and rows_large / rows_small are the two parties' distinct-value and row counts.

Why the single rule serves single-pass too. A linkage key earns its place by being near-unique where present (high positive-predictive value; see Linkage keys), and a value repeated within a dataset is dropped from its round, so where a key is also densely populated -- present on most records, with comparable density on both sides -- the participating distinct values track the row count: D ~= keyCount * rows. Under that assumption both terms have the same keyCount * (rows_large - rows_small) factor and reduce to the ratio of their coefficients, 35 to 4 -- the masked-set term outweighs the index table by about nine to one. Smaller-as-receiver wins, by a net of up to about 31 * keyCount * (rows_large - rows_small) bytes, reinforced by the curve-operation saving (which the index table, being integer work, does not offset). So in the typical case single-pass agrees with the cascade: one rule serves both.

When the rule is not optimal, and why it still stands. The index table can dominate whenever distinct participating values fall well below row counts (D much less than keyCount * rows), because the index table counts every record (a -1 for each absent value) and so scales with total rows, while the masked sets scale only with distinct participating values. That happens not only for a heavily-repeating low-precision key (bad design), but also for a precise key that is sparsely populated -- present on only a fraction of records, such as an SSN available for a minority of rows. Such a key is legitimate and exactly the kind the cascade combines with denser ones. It happens too for two parties whose per-key density differs markedly. In those cases the cheaper assignment can flip to smaller-rows-as-sender; the crossover is roughly when populated (key, record) cells average below 4 / 35 (about 11%) of the table. The decisive point is that all of these are invisible to a row-count rule: the quantity that distinguishes them is the distinct-value count -- and that count is not exchanged, because unlike the raw row count (mere dataset-size metadata) it would disclose data content: how much a party's key values repeat, and how sparsely a key is populated. Given only the row counts, smaller-rows-as-receiver is the best fixed rule for the typical case (near-unique, densely and comparably populated keys) and is the one implemented. It is not optimal on every input, and the residual gap on sparse or asymmetrically-dense keys is accepted, not closed. Closing it would mean exchanging distinct-value counts, trading away the privacy of each key's duplication and sparsity structure for a modest bandwidth saving -- a trade this protocol does not make.

Keys with a missing-field condition ​

A linkage key may state a condition naming a linkage field that must be missing on one side (Keys that run only where a field is missing). Such a key runs as two adjacent rounds at its own position in the key order, one per PSI role resolved above. In the first round the receiver builds the key only for its records with no valid value for the field, and the sender builds it for every record. In the second the two roles exchange. The reference implementation is linkageRounds (packages/core/src/linkageKeyCondition.ts).

What counts as missing. A record has no valid value for the field when, after its own party's cleaning, the field holds no value a key reads: the cleaning dropped the value, or the value is a filler date of birth. A value the cleaning finds invalid, such as an unparseable date or a placeholder, is therefore missing, never a disagreement.

Which pairs it matches. A record whose field is valid is not built for the round in which its role is filtered, so a pair whose two values are both valid never matches through the key, whether the values agree or not. The keys before it in the cascade claim the pairs whose values agree. A pair whose two records both lack the field matches in the first round, the receiver's.

No frame or field is added. Both parties derive the two rounds from the agreed terms and the resolved roles alone. The condition is part of the key, inside the terms both parties compare whole at the terms exchange and the agreed-terms hash covers.

Every per-round quantity counts both rounds: the round count the strategies and the frame bounds read, the stage list, and the effective key count, to which the key contributes its width once per round. Where the key is first, the first-round count at the start and the count against the partner's receive ceiling hold both its rounds to the bound, since in either role a party builds the key for every record in one of them. Under single-pass the two rounds are two keys of the one batch. A count-only (psi-c) exchange runs both rounds full-size, the filtered side giving a dummy to each of its records that has a valid value for the field (The count-only rounds).

What is admitted. Only a date_of_birth field is admitted. Terms whose condition names an undeclared field, a field of another type, or a field an element of the same key reads are refused when they are parsed. The field a condition names is one the key reads: it is listed in the record's matching basis.

An input with no column for the field. Where an input binds the field to no column, the key needs only the field's declaration in the terms, since no record holds a value: such a party runs the key with every record lacking the field, and in the round where its role is filtered it builds the key for every record. Where an explicit transformation or the input's metadata binds the field to a column the input lacks, the presence test of the binding rule reports the field, and the input cannot run the key. A date-of-birth column whose name type inference does not recognize, such as birth_dt, binds the field to no column, so it counts as no column and every record of that input lacks the field. Declaring the column in the input's metadata with type: date_of_birth and role: linkage binds it.

What a match discloses. Each round builds the key for only one side's records lacking the field, so a party receiving the result learns, for each pair matched through the key, which of the two records had no valid value. The terms review and consent surfaces state this for the key, in one line whichever round matched (missingFieldConditionStatement, packages/core/src/consent/consentFacts.ts). The round's element count is sent to the partner like every round's, so in the round where a party builds the key only for its records with no valid date of birth, that count tells the partner how many of that party's records still unmatched at that round have no valid date of birth, one element for each distinct value those records give the key. A count-only round sends no such count, since each of its sets has the party's declared record count; what the receiver's software learns from the key's two count-only rounds is under What each party learns, and on what basis. The web terms review also states the condition outside its key list, once for each distinct statement: the names of the keys that share it, one name to a row, then the statement (MissingFieldConditionBlock, apps/web/src/components/InvitationTerms.tsx). No separator joins two names, since a key name is the partner's free text and can hold one.

A terms change at the terms exchange ​

Two parties whose linkage terms differ meet the difference at the terms exchange as a delta (validateCompatibility and compareTerms, packages/core/src/linkageTermsNegotiation.ts) rather than as a bare mismatch:

  • received: the partner's payload.send names against the columns this party receives, as added (sent, not received) and removed (received, not sent). The receive side is this party's stated payload.receive; a party stating none has no received delta, and fills the list instead (EXCHANGE_FILE.md).
  • sent: this party's payload.send names against the partner's stated payload.receive, the same two lists. Adopting the partner's terms sends what the partner receives, so added (sent, not received) names the columns this party would no longer send and removed (received, not sent) the columns it would now send. A partner stating no payload.receive has no sent delta: the terms adopting its own keep this party's payload.send, the columns the run sends.
  • partnerDeduplicate: the partner's deduplicate against the value this party holds it to, where the caller supplies one (expectedPartnerDeduplicate); the term is each party's own, so with no such value it is not compared.
  • otherTerms: the diagnostic for each other agreed term the two copies disagree on.

Who refuses what. A party refuses on its received delta, its partnerDeduplicate, and otherTerms, never on its sent delta: what a party receives is its own decision, so the columns it sends are its partner's to take on or refuse. A party whose metadata changed the columns it sends therefore proceeds, and its partner decides.

Each party words a refusal from its own side. Every diagnostic compareTerms composes names this party's value as its own and the partner's as its partner's ("algorithm mismatch: yours is ..., your partner's is ..."), so a refusal is never relayed as text. The refusing party's abort names only which terms differ, one fixed reason per term: "the linkage terms differ in" followed by the term, from a closed list -- version, algorithm, linkage strategy, output, linkage fields, linkage keys, legal agreement, payload columns, deduplicate (termsDifferenceReason, packages/core/src/linkageTermsNegotiation.ts). The party reading the abort matches each reason against that list at the decode and words each named term from its own side, by comparing its own terms as it sent them with the partner's as message 1 or the responder's abort frame states them (partnerRefusalDifferences); the columns the partner refused on are the ones this party sends. A deduplicate refusal is stated as the partner expecting the other value from this party's own, since only the refusing party knows the value it holds its partner to. Where this party cannot read the partner's terms, or its comparison finds no difference in a named term, it names the term without values. Any other reason is partner-written text and is shown behind a label, as at every other abort. No frame changes: the values are in the terms both messages already contain.

Each side's next step. Both refusals end the run on a statement of the differences alone; the step that settles them is the app's, since only the app knows the command or control that does. Core records on each error which party refused, this-party on the refusing party's own and partner on the one its abort ends where the abort names a differing term or states TERMS_CHANGE_NOT_ACCEPTED_REASON, the fixed reason a party sends when it does not take a change on, matched as the exact string (termsDifferenceRefusedBy, packages/core/src/protocolSetup.ts), and each app states the step for that side and its own kind of run:

  • The CLI adds it beneath the error where it adds its other fixed next steps (termsRefusalNextStep, apps/cli/src/termsRefusalRemedy.ts). A run from a configuration and key file, alcove exchange, settles the terms through alcove update and alcove apply: the refusing party takes on its partner's terms from an update its partner makes, and the refused party sends its own for its partner to apply; either may instead change its configuration to match. An online alcove invite or alcove accept writes its configuration and key file only at the terms exchange, once the terms are agreed on, so a refusal leaves it neither, and its step is to agree on the terms and set the exchange up again from a fresh invitation. A quick exchange, whose terms each party infers from its own input file, settles them by agreeing on the columns and --linkage-strategy.
  • The web app's alert states what happened from this party's side and the seat's step, with the differences in its labelled block (failureFor, apps/web/src/exchange/useInviterExchange.ts): the inviter agrees on the terms and sends a fresh invitation, and the acceptor asks its partner for one. A recurring exchange's run that the partner refused records the refusal as its own failure kind (MANAGED_EXCHANGE_RECORD.md), and its alert, at the run and at a later visit, states the same refusal with the step its page offers: a terms update made or applied under Change terms. A refusal by this party is the refused terms change its page asks about (MANAGED_EXCHANGE.md).

The step names no partner value.

Built-in rules narrowed per party. The terms a quick exchange takes from the built-in rules hold the broader matching keys that the party's own input supplies, so each party narrows its own before the two are compared. alcove init INPUT_FILE writes the keys it narrows the same way into a configuration, which runs the lists it holds as written. Two files where only one has a phone, email, street number, ZIP, state or county column cannot run on the built-in rules: their terms differ, and the exchange refuses at the terms exchange, before any linkage key is sent. Such a pair agrees on its terms through an invitation instead, and both parties run the terms the invitation holds. On either party's refusal, where that party holds both terms documents, core records the names of its own keys its partner does not declare alike (termsDifferenceLocalKeys, packages/core/src/protocolSetup.ts), and where one of those keys reads a phone, email, street number, ZIP, state or county column, the CLI names that column beneath a quick exchange's step.

Taking a change on. Where the caller of exchangeTerms supplies onTermsChange (TermsChangeOptions, packages/core/src/protocolSetup.ts), a refusable delta is offered there before the party sends its decision, so before any linkage key or payload moves. The two seats differ:

  1. The responder, at message 1, may take on the whole of the initiator's terms: every agreed field the initiator's, output and payload mirrored, and its own identity and deduplicate kept (termsAdoptingPartnerTerms), then its payload.send stated from its own metadata. It sends the terms it took on as its message 2, so the initiator's comparison sees them agree.
  2. The initiator, at message 2, can differ from the responder only in its received delta, since the responder has already taken every other term on or refused. It may take on the responder's payload.send names as its payload.receive and nothing else (termsReceivingPartnerSend); its terms on the wire stay as its message 1 stated them.

A change is offered only where the adopted terms leave no refusal, and it continues the run only where it leaves unchanged every term the run was prepared under -- the version, algorithm, linkage strategy, output direction, linkage fields and keys (adoptableWithoutPreparing): the dataset, the declared record count, and the payload-intent flag this party has already sent were all derived from those. A change with a partnerDeduplicate delta does not continue either. runExchange supplies expectedPartnerDeduplicate (PreparedExchange.expectedPartnerDeduplicate) only alongside an onTermsChange; without one, the invitation binding refuses a differing value after the terms exchange (assertPresentedDeduplicateMatchesInvitation). A change that cannot continue is refused whatever the caller answers. A caller that throws, or declines, sends the fixed abort reason TERMS_CHANGE_NOT_ACCEPTED_REASON. With no onTermsChange, a refusable delta is refused as a TermsChangeRefusedError, a ProtocolRefusalError holding the delta.

Both parties agree on the same terms. A party that proceeded past a payload.receive naming other columns than its partner's payload.send took that set on, so after the exchange each party resolves its partner's stated payload.receive to its own payload.send names where the two differ (termsResolvingChangedPayloadReceive, packages/core/src/config/recurringTerms.ts). The initiator's taken-on receive list is the same names, by name alone, so both parties hold the same pair, the agreed-terms hash matches, and each later run's terms exchange compares the partner's stated payload.send against the set taken on. No frame changes: the delta is computed from the terms both messages already hold.

Mixed builds. A peer that predates this rule compares both directions and refuses a changed send, so a mixed pair refuses rather than resolving differently; the resolution above applies only where a party proceeded past a declared receive list, which such a peer never does.

Protocol-version reconcile at the terms exchange ​

Both parties advertise a build-level protocol version on their terms-exchange message, beside linkageTerms and the record count, and reconcile the partner's before weighing the terms. That version is PROTOCOL_VERSION (packages/core/src/protocolSetup.ts), an integer bumped only on a wire-incompatible protocol change, and only once the software is officially published (pre-release deltas hold it at 1: there are no deployed peers to reconcile). It rides the one bidirectional round-trip both parties already perform (message 1 for the initiator, message 2 for the responder), so the check adds no round trip; on the authenticated path it rides the AEAD channel and cannot be forged.

The reconcile is fail-closed: a partner advertising anything other than this build's exact version aborts the exchange with the actionable the partner is running an incompatible Alcove version; both parties must run the same version -- sent to the partner as the abort reason and thrown locally, so both sides name the real cause before the linkage rounds begin, instead of failing later with a cryptic frame-parse error. Any present value that is not the exact version is a mismatch: a different integer, or a garbled/wrong-typed value from a non-conforming or corrupted peer -- the field is read as unknown rather than a typed number precisely so such a value reconciles to the named skew instead of a generic "linkage terms failed to parse". This matches the mode-flag fast-fail precedent, not the fail-soft observed-host-key advisory. A partner advertising no readable version -- an absent field, a frame that is not an object, or a value the lenient probe below cannot read -- is refused on exactly the same terms. Pre-publication no build predating the field is deployed, so an unversioned advertisement identifies a non-conforming peer rather than an older Alcove, and admitting one would leave a leg of this reconcile open. The refusal is the same on both message paths and in both directions. The durable, forward-looking guarantee is that a partner not running this exact build fails cleanly, naming the cause, before the linkage rounds begin.

The responder's message-2 ABORT frame includes the version as its proceed frame does -- the one field spread onto an abort, where the save intent is not. The initiator reconciles that frame's version before it reads the decision, so an abort omitting the version would reach a same-version partner as a version skew and bury the reason the responder stated. The initiator's decision-only message 3 closes an exchange whose versions both parties have already reconciled, and no reconcile reads it, so it includes none.

Two ordering notes bound that guarantee. First, the version is read from a lenient probe over the raw frame before the strict envelope parse, on both message paths, so it is available to the reconcile independently of whether any other field parses. A future bump that reshapes a sibling field (a required recordCount, an optional save, ...) therefore does not bury the skew diagnosis behind a generic parse error: the older peer still reads the version, reconciles the mismatch, and reports the actionable "run the same version" message (and best-effort sends the abort) before the strict parse throws. This is a structural guarantee rather than a prose requirement that every field stay backward-parseable across a bump. Second, on the file-sync transport the terms exchange rides binary-envelope message frames (see FILE_SYNC.md), so for a skew that changes the envelope itself the file-sync reader's envelope-version-byte hint fires on the first frame before this terms reconcile is reached. This reconcile is the transport-agnostic check for skews whose envelope stays readable, and the only check on the WebRTC path, which has no such byte.

This marker is distinct from three neighbouring identifiers. The operator-authored linkage-terms version is compared for equality between the two parties' authored values and is not bound to the build a party runs (two parties can declare the same version while running different Alcove releases; see Versioning under the transform dialect). The file-sync MESSAGE_ENVELOPE_VERSION is the transport frame-format byte (FILE_SYNC.md); a foreign value there is the retroactive reader-side half of this same robustness work, translated into the same "likely incompatible partner version" hint at the read path rather than at the terms exchange. The KEX protocol-name tag (the Protocol-version tag under P-256 authenticated key exchange below) is the cryptographic-suite version hashed into the handshake transcript. PROTOCOL_VERSION is the additive terms-layer marker; a future runtime version-reconciliation mechanism may subsume it.

PSI-C ​

Not yet shipped: this section specifies count-only over every agreed linkage key. Its rounds are built in linkViaCountOnlyPSI (packages/core/src/psi/link.ts) behind a construction selector that only core's own tests set, so no run an operator starts reaches them. Under that selector the agreed-terms run boundary admits terms declaring single-pass or a missing-field condition, and the key-condition check in runExchange admits the condition; every other enforcement point still refuses them. The shipped build runs an earlier construction in its place: one round over exactly one linkage key, under the library's cardinality mode (the reveal flag cleared, the response sorted by ciphertext, the receiver's match returning only a size), over the key values exactly one of a party's records has, with terms declaring more than one key, single-pass, or a missing-field condition refused. This construction replaces it when it lands, and until then the user documentation describes the shipped build. Why the construction changed: psi-c-count-only.md.

PSI-C (linkage_terms.algorithm: psi-c) reports the size of a one-to-one linkage over the agreed keys and nothing else: no identifier, no matched pairing, no payload, no per-record result. It is a feasibility instrument -- the exchange two parties run to find out whether a linkage is worth negotiating at all. The PPRL using PSI is intended for operational use, while PSI-C is intended only for research.

It runs at the point in a partnership where the least trust has been established, which is what fixes the scope of the claim it makes (Threat-model scope of the count-only claim).

The count-only rounds ​

Every agreed key, in order, every round full-size. A count-only exchange runs one PSI base function round for each round the agreed keys run as, in the agreed key order: one for each entry of linkage_keys, and two adjacent rounds for a key with a missing-field condition (linkageRounds, packages/core/src/linkageKeyCondition.ts). These are the rounds of linkage_strategy: cascade, and terms declaring single-pass run them too (A declared single-pass runs the same rounds). No round's set is narrowed by an earlier round's matches, as a psi cascade round's is: neither party learns which of its own records matched, so there is nothing either could remove on the wire. Every round includes every record of both parties, and the cascade's removal takes place inside the receiver's computation of the count (Computing the count).

Roles. The sender is the base function's server and the receiver its client, assigned by the ordinary rule (Role resolution and work minimization): when exactly one party expects output that party is the receiver, and when both do the smaller declared count is. Below, n_R is the receiver's declared record count and n_S the sender's, both as the terms exchange states them.

One element per record. In every round each party's set has exactly one element for each of its records, so its element count is its declared record count, the figure the first-round count at the start and the count against the partner's receive ceiling weigh for a count-only round:

  • The receiver's request lists its records in a request order it draws once for the exchange, a uniformly random permutation of its records from the platform's cryptographically secure random generator, so position i of every round's request holds the same receiver record in every round, and only the receiver knows which. The request order MUST NOT be sent, written to disk, or logged, and MUST be discarded when the exchange ends, whether it completes or fails; each run of an exchange draws its own.
  • The sender's setup has one element for each of the sender's records.
  • A record's element is its round value (below) when the party builds the round for the record, the record has a value for the key (Key input data), and no other record the party builds the round for has that value for the key. Otherwise the element is a dummy.
  • A party builds a round for every one of its records, except in a round a missing-field condition filters on its side (below), where it builds the round only for its records with no valid value for the field.
  • A dummy is 32 bytes from the platform's cryptographically secure random generator, written as 64 lowercase hexadecimal characters, drawn anew for each record in each round and never reused. It contains no colon, so it never equals a round value, and two dummies coincide only with negligible probability. On the wire it cannot be told from a real element.

Uniqueness over every record the round is built for. A key value two or more of those records have is a dummy for every one of them. The test reads every record the party builds the round for, never only the records still unmatched, which is where the count can differ from a psi run's (Comparability with psi). That is the party's whole dataset, except in a round a missing-field condition filters on its side, where it is the party's records with no valid value for the field: a value one of those records shares only with records that have the field is real for it.

A key with a missing-field condition. Its two rounds are each full-size. In each, the party whose PSI role the round filters (Keys with a missing-field condition) builds the key only for its records with no valid value for the field, and every other record of that party has a dummy, so its set still has one element per record and the round's element count does not tell the partner how many of its records the condition excludes. The partner builds the key for every record. The round filtering the receiver's side comes first, so a pair whose two records both lack the field is accepted there, as under psi.

Round values are separated by round. A record's round value in round k, k being the round's 0-based position in the exchange's round order, is the decimal k, a colon, and the key value: 0: followed by the value in the first round. Both parties apply it to every real element before encryption. A party's PSI key may serve every round of the exchange, so without the prefix a value appearing under two keys would encrypt to the same element in both rounds, and the receiver, which knows which of its own records share a value across keys, could link those two rounds' slots to those records.

The sender's permutation. Once the terms exchange has given it n_R, the sender draws one permutation pi of the positions 0 to n_R - 1, uniformly at random from the platform's cryptographically secure random generator. In every round it processes the receiver's request in the library's revealing mode, then reorders the response under pi before it sends any part of it: slot s of the response as sent is the element at position pi(s) of the response the library returned. The same pi serves every round of the exchange. It MUST NOT be sent, written to disk, or logged, and MUST be discarded when the exchange ends, whether it completes or fails; each run of an exchange draws its own.

The library returns a revealing-mode response in request order, so slot s of every round answers the receiver's request position pi(s), which holds the same receiver record in every round. A slot is therefore a stable pseudonym of one receiver record for the whole exchange. Only the sender, which holds pi, can map a slot to a request position, and only the receiver, which holds its request order, can map a request position to a record.

The sender's pseudonyms and the index table. The sender draws a second permutation, sigma, of its own records 0 to n_S - 1, under the same rules as pi; sigma(j) is the pseudonym of its record j. The library sorts a setup by ciphertext, and reports the input index at each setup position (its sorting permutation). So in each round, after building the setup, the sender sends that round's index table: n_S entries, entry p being sigma of the record whose element is at position p of the setup as sent. The table is a binary frame of n_S little-endian Int32 values, so 4 * n_S bytes, and it is a permutation of 0 to n_S - 1. A matched setup position therefore names a sender pseudonym that is the same in every round, without naming a sender record.

The index table fits one frame. The table is sent as one frame, not in parts. The sender sends it only after holding its setup to the per-set maximum and to the partner's receive ceiling, and the receiver reads it only after a setup of n_S elements within its own ceiling. So no table a party sends has more than MAX_PSI_DECODE_ELEMENTS entries, and none is longer than 67,108,864 bytes. A receiver reads the table frame under the channel's per-frame bound and refuses one whose length is not 4 * n_S bytes once it has read it, so a partner that ignores the checks can make it read up to that bound first, as at any other frame. With its envelope, that is within the per-frame bound on either channel: MAX_WEBRTC_FRAME_BYTES, 268,435,456 bytes, on WebRTC, and MAX_FRAME_SIZE_BYTES, 536,870,888 bytes, for a message file (held by packages/core/test/psi/countOnlyIndexTableBound.test.ts).

The frames of one round. In the round order, each round is four frames:

  1. sender -> receiver -- the setup message over n_S elements, each encrypted once under the sender's key, as a Raw data structure, in parts (A PSI set is sent in parts).
  2. sender -> receiver -- the round's index table, one binary frame.
  3. receiver -> sender -- the request over n_R elements, each encrypted once under the receiver's key, in parts.
  4. sender -> receiver -- the response: the request re-encrypted under the sender's key and reordered under pi, in parts.

Each round sends 35 * (n_S + 2 * n_R) bytes of encrypted elements and 4 * n_S bytes of index table, whatever the key's coverage. After the last round comes the count-report leg where the entitlement calls for it (Count reporting and entitlement).

Each frame's size is checked against the declared counts. The receiver MUST refuse a setup whose element count is not n_S, and an index table whose length is not n_S or which is not a permutation of 0 to n_S - 1. The sender MUST refuse a request whose element count is not n_R. Each refusal is a protocol error raised before the round goes on (Partner-supplied index tables are checked against local state), and a response is held to its request's element count as every response is. These checks hold a conforming partner to the padded shape. They do not stop a party choosing its own inputs (Threat-model scope of the count-only claim).

Revealing mode, with Alcove applying the permutation. Both parties MUST construct their base-function objects with the library's reveal-intersection flag set, as a psi run does, and MUST NOT run the library's cardinality mode, the flag cleared, for a count-only round. In that mode the library's server sorts each round's response by ciphertext and takes no permutation, so no order can be held across rounds, and its match returns only a size. The revealing match returns, for each response element it matches, that element's position in the response as received and the setup position it equals; under pi the position is a slot. Two properties the cardinality mode's flag would give are therefore provided elsewhere:

  • The flag does not tell the algorithms apart. Both a psi and a psi-c round set it, so the library's own mode check does not refuse a partner running the other algorithm. That agreement is the agreed terms': algorithm is a mandatory-consistency term, compared at the terms exchange and again at the agreed-terms run boundary before any round (resolveCountOnlyRun, packages/core/src/exchange.ts), so a psi party and a psi-c party never reach a round together.
  • The receiver's engine, not the library, withholds the matches. The library hands the revealing match's pairs to whatever calls it; the receiver's engine keeps them (below).

The receiver's engine returns only the count. A count-only receiver's engine takes each round's setup, index table and response, keeps every round's matched pairs inside the engine across the exchange's rounds, and returns one figure, the count, after the last round. It MUST NOT return to its caller, report as progress, or log an association table, a slot, a pseudonym, a per-round or running match count, or any other per-record result; its progress reports count elements processed and nothing else. Its identifier-revealing operations, and any operation returning a per-round result, are refused in count-only mode. This holds on both backends, and whether the engine runs in process, in a worker, or across a pool of workers. Nothing the engine keeps is beyond what the receiver's software could derive from the frames with its own key, but keeping all of it inside the engine is what makes "the operator sees only the count" a property of the software rather than of each of its callers.

Raw is required. The setup message MUST use the Raw data structure, as every round of this protocol already does (a received setup that is not Raw is refused as a protocol error). It is what makes the count exact. The probabilistic structures the library also offers -- a Bloom filter and a Golomb-coded set -- admit false positives, which here would be spurious pairs in the count. Under psi such a false positive would be reported as a spurious pair an operator could notice; a count-only run reports no identifiers at all, so neither party can detect an inflated count, let alone correct it. The exactness requirement is therefore stricter for psi-c than for psi, not weaker.

Computing the count ​

The count is the size of the one-to-one cascade's association table in the permuted space, computed by the receiver's engine:

  1. The round's pairs. Before any element of a round's response is matched, the receiver's engine refuses, as a protocol error, a response that holds the same element twice or holds an element that is not 33 bytes in the protobuf framing. A conforming response never does: the receiver's elements are distinct, and the sender's processing maps distinct elements to distinct elements. The refusal does not depend on whether any element would have matched. That check reads only the framing, so a 33-byte element that is not a valid point, such as one whose x-coordinate is not reduced below the field prime or a point off the curve, passes it. The library refuses such an element during the match, as a library failure rather than a protocol error, after the elements before it have been fed to the match and progress has been reported. The round then ends with no count: the engine refuses the count and every later round call (held on both backends, for an x-coordinate not reduced, by packages/core/test/psi/psiEngineCountOnlyPermuted.test.ts). Where that refusal falls depends only on the element's position in the response, not on whether any element matched. In round k, for each response slot s whose decrypted element equals the setup element at position p, the engine forms the pair (s, T_k[p]), T_k being that round's index table. Each party's elements in a round are distinct, so a slot or a pseudonym occurs in at most one of the round's pairs; the engine refuses a round whose pairs repeat as an internal fault.
  2. The replay. Two sets start empty: the accepted slots and the accepted pseudonyms. Round by round, in the round order, every pair whose slot and pseudonym are both not yet accepted is accepted, and its slot and pseudonym join the two sets. A round's pairs are disjoint, so their order within the round does not change the result.
  3. The count is the number of pairs accepted.

The replay is symmetric in the two parties, so the count does not depend on which party is the receiver, and it is at most the smaller of the two record counts.

Comparability with psi. Where no key value is repeated within either party's dataset, the count equals the size of the association table a one-to-one psi run over the same terms and the same data produces, under either linkage strategy, since both produce the same table. Where a value is repeated within one party's dataset for a key, the two can differ, and only through such values:

  • The psi cascade applies its within-round uniqueness rule to the records still unmatched at that key's round (Matching Algorithms). A repeated value whose other holders an earlier key has matched is unique among the unmatched records, so it takes part in the psi round.
  • PSI-C gives every holder of a repeated value a dummy, so that value takes no part in the key's round.
  • A pair one run accepts and the other does not changes which records are still free for later keys, so the difference can propagate to later rounds and can go in either direction.

A round a missing-field condition filters keeps that difference and adds none. On the filtered side both tests read the records with no valid value for the field, psi only those of them still unmatched. A test over the party's whole dataset there would add a second difference, one no earlier match causes: a value that a record lacking the field shares only with records that have it would be a dummy under PSI-C and take part in the psi round.

Applying the psi rule exactly would mean sending every holder of a repeated value and resolving the repetition in the replay, since neither party knows on the wire which of its own records have matched. The receiver's software would then see a repeated value of its own as one setup position matched by several slots, and since the receiver knows which of its records share a value, those slots would be tied to those records. Testing over every record the round is built for keeps every element of a round distinct, at the cost of the difference above.

A declared single-pass runs the same rounds ​

Count-only terms declaring linkage_strategy: single-pass are accepted, and the exchange runs the rounds above, the cascade realization. The result is identical to what a batched realization would give:

  • Every count-only round is full-size, and no round's set depends on an earlier round's matches, since the cascade's removal takes place inside the receiver's replay.
  • Batching the rounds into one exchange would therefore change no element, slot, pseudonym, or pair. The count is the same, and so is what each party learns; the two would differ only in how many round trips the frames take.
  • So no second wire format is specified for count-only. The psi single-pass reply is not one: it pools each party's distinct values across keys under one row-keyed index table, so a round's slots would not line up and no set would have the declared record count.

The bounds a count-only run is held to are those of these rounds, never the single-pass dataset ceiling, which bounds that reply.

The declared strategy is stated as written. linkage_strategy stays the value the terms declare. The terms exchange compares it as it compares every mandatory-consistency term, the agreed-terms hash and the agreed-terms file beside the record cover it as written (EXCHANGE_RECORD.md), and both parties' terms review and consent surfaces state it as written. What those surfaces state a count-only run discloses is this section's, never a psi single-pass run's: a count-only run sends no single-pass index table and has no association table to withhold.

Refusals under count-only ​

A psi-c terms document declaring any of the following MUST be refused:

  • deduplicate: true on either party;
  • payload in either direction -- a non-empty payload.send or payload.receive, or input metadata that would transmit a column (is_payload: true on a column whose role is not ignored);
  • a candidate set for a key, from any producer -- an element transform declaring a fan-out function or generate_fuzzy_comparisons, or a key declaring swap. The half a party authors in its own data standardization is per-party and local, so no invitation holds it and a partner cannot derive it; that half MUST be refused at the prepare step, as the fan-out rule's local half is.

Every rule above that a terms document alone decides MUST be refused at each parse of that document, the invitation-token decode included: the parse is the first point the rule's inputs are knowable, and it precedes any consent surface the terms reach. Two halves a parse cannot decide are the input metadata that would transmit a column and the standardization half of the candidate-set rule, each of them per-party and local.

Each MUST be refused at three further points, so that no combination of authoring path and partner reaches a run: when the terms are authored or an invitation over them is minted; at the local prepare step, before any credential, terms, or data are sent; and at the agreed-terms run boundary once the terms exchange has completed, which is the first point at which both parties' values are known. The run-boundary check is a symmetric function of the agreed pair, computed identically on both parties, so the lockstep round cannot begin on one side and be refused on the other.

The refusal is fail-closed at all three: an over-broad psi-c term is never silently narrowed to the specified shape, never derived and then discarded, and never downgraded to a psi run. Narrowing would deliver a disclosure the operator did not agree to; downgrading would reveal matched identifiers under terms that asked for a count -- the exact substitution the count-only algorithm exists to prevent.

Why each is refused. Each reason is psi-c's own:

  • deduplicate. The count is the size of a one-to-one table. What a count means under a many-to-X cardinality is not specified here.
  • Payload. A count-only exchange discloses no column, which is what its record states (EXCHANGE_RECORD.md).
  • A candidate set, an interim limit. A candidate set can give one record several pairs in a round, and the number of those pairs a one-to-one resolution accepts depends on the order it meets them in. psi fixes that order, ascending sender row then receiver row (Record-level resolution). In the normative double-match case that order accepts two pairs, and an order meeting (S0, R1) first accepts one.
    • The receiver's engine holds a round's pairs only as slots and pseudonyms. By design pi hides the receiver's row behind each slot and sigma the sender's row behind each pseudonym, and both are drawn anew for each run. The engine cannot apply the psi order, so a count over candidate sets could differ from a psi run's on the same terms and data, and between two runs on the same data.
    • Lifting the refusal needs a resolution rule that does not depend on order, with its difference from psi stated, and every record's candidates padded to its key's declared width, so the number of a record's elements does not identify the record.
    • The refusal holds under both linkage strategies, so the cascade realizing candidate sets does not lift it (The combinations that stay unsupported).

Count reporting and entitlement ​

The receiver alone computes the count. Whether it travels back is decided by the agreed terms' output entitlement, derived identically on both parties before the rounds run:

  • Both parties entitled (expects_output true on both). After the last round the receiver sends a single count-report frame holding its tally to the sender over the authenticated channel. Only the receiver sends; the sender receives it and sends nothing back.
  • Exactly one party entitled. By the role rule the entitled party is the receiver, so it already holds the count and there is no count-report frame at all. The non-receiving sender receives nothing -- not the count, not an empty frame. Suppressing the frame entirely, rather than sending a placeholder, is the same discipline the single-pass table withholding follows: a frame that is present but empty would still mark, by its presence, that a count existed to report.

Both parties derive that decision from the same agreed terms, so the receiver never sends a frame the sender will not read and the sender never blocks awaiting one the receiver will not send. The reference implementations are linkViaCountOnlyPSI (packages/core/src/psi/link.ts), which runs the rounds and this leg, and reportsCountToSender (packages/core/src/protocolSetup.ts), the entitlement predicate both parties evaluate.

An exchange that ends before its last round has no count. Whether it ends between two rounds or within one, on an abort, a refusal or a lost connection, the receiver takes no count, so neither party holds one and no count-report frame is sent. The receiver's software asks its engine for the count only once every round has been matched (PSIParticipant.finishPermutedCount, packages/core/src/psi/participant.ts; held by packages/core/test/psi/countOnlyEveryKeyRun.test.ts).

The count-report frame's intersectionCount is bounded like every other partner-supplied integer: a non-negative safe integer no greater than MAX_RECORD_COUNT and no greater than the smaller of the two record counts the terms exchange stated, since a one-to-one table cannot exceed either party's dataset. The bound rejects an absurd figure at decode; it does not make the number checkable, which is the trust-contingency stated below rather than a gap in the bound.

What each party learns, and on what basis ​

Stated in the vocabulary the consent surfaces use -- enforced is a claim about the run (either it holds or the exchange aborts), trust-contingent is a claim about the partner, whom Alcove does not stop (CONSENT_BASIS_MARKERS, packages/core/src/consent/consentFacts.ts):

  • A party's own count-only outcome is enforced. Its own software shows its operator the count and nothing finer: the receiver's engine returns only the count, and the sender computes no match at all. Neither the partner's cooperation nor its honesty is required for this.
  • The sender learns nothing about the receiver's records but their number. It sees n_R encrypted elements a round, in the receiver's request order, and computes no match. Where only the receiver is entitled to the count, this is enforced for a receiver running this protocol against a sender deviating on the wire: no frame the receiver sends it contains more, and the sender receives no count. Where both parties are entitled to the count, a sender that substitutes points in its response learns whether the receiver's records at request positions it chose matched, but not which of the receiver's records those are (Threat-model scope). It is not enforced against a sender that chooses its input when both parties are entitled to the count. A sender whose input is one live candidate padded with values the receiver cannot hold reads that candidate's membership from the reported count, and two runs whose sets differ in one record give that record's. These are the crafted set and the differencing the threat-model scope states against the sender, turned on the receiver.
  • That the receiver cannot attribute a match to a record rests on the sender's own software. Only the sender draws pi and sigma, so a sender running this protocol protects its own records, and the receiver cannot tell whether a sender applied them. A receiver following the protocol cannot undo pi or sigma from its own side; what its own inputs and key coverage let it learn, and what a receiver that deviates from the protocol can recover, is stated in the next bullet and under the threat-model scope below.
  • The receiver's software learns more than the count, from genuine inputs, and that is accepted. Its engine keeps every round's pairs, so the receiver's software can derive, beyond the count: the number of matches on each key; the overlap between keys, how many slots matched on each combination of keys; and the pseudonymous pair table of slots against sender pseudonyms. And from its own records' key coverage: a slot that matched on a set of keys belongs to one of the receiver's records with a real value for every one of those keys, so where few of its records have that coverage the match narrows the slot to those records, or names one. None of this needs a deviation from the protocol, and none of it is shown to the operator. How often coverage singles out a record on real data is not measured.
  • A key with a missing-field condition adds two facts of the same kind, and they are accepted too. For each matched sender pseudonym the receiver's software learns which of the key's two rounds it matched in, and a match in the round filtering the sender's side tells it that the sender record behind the pseudonym has no valid value for the field, as a match on any key tells it that the record has a value for that key. A match in the round filtering the receiver's side narrows the slot to the receiver's own records with no valid value for the field, as its own coverage narrows any slot. Both are per-key results and coverage-based narrowing, the class the item above accepts, and neither is shown to the operator.
  • The sender's knowledge of the count is trust-contingent. In the both-entitled case the number the sender holds is the receiver's report; a receiver that reports a different one is not stopped by Alcove. This is the same posture as the psi association-table return leg, where the sender's half of the pairing likewise arrives as the receiver's word. A sender that needs the figure to be independently checkable does not get that from psi-c.

What the rounds disclose. Each party's set in every round has exactly its declared record count, which the terms exchange already states, so a frame's element count tells the partner nothing more, and in particular not how well a key covers the party's dataset, nor, in a round a missing-field condition filters, how many of the party's records lack the field. Without sigma, an index table is a uniformly random permutation. Neither party's frames state the count.

Threat-model scope of the count-only claim ​

The count-only claim holds in the semi-honest (honest-but-curious) model this project adopts; see SECURITY_DESIGN.md. It protects the sender against a receiver that contributes a genuine dataset, up to the disclosure from genuine inputs stated above. It does not protect the sender against a receiver that chooses its input set, and the choice is enough on its own:

  • A crafted pattern across the keys. A receiver gives each of its records its own pattern of keys with a real value and keys with a dummy, for instance by leaving a field blank in its own input so that a conforming party sends a dummy. A slot can only match in rounds where its record has a real value, so with patterns chosen so that none contains another, a slot that matched on every key where its record had a real value names the record. About log2(n_R) keys or more give enough such patterns for every record, and one run then yields the membership of most matched records. It needs no modified software and only one run. It is the same mechanism as the cross-key count, so no padding or permutation removes it.
  • A crafted set. A receiver whose input is one live candidate padded with values it knows the sender cannot hold reads that candidate's membership straight off a count of 0 or 1. Nothing distinguishes a crafted set from a genuine one on the wire: both are opaque encrypted elements, and only their number is visible.
  • Differencing across runs. Two count-only runs whose input sets differ in one record name that record's membership by the difference of their counts. This is the differencing attack SECURITY_DESIGN.md already names for linkage keys, applied to the count.

All three tamper with an input rather than merely observing one, so all three sit a step beyond honest-but-curious -- the same category as the record-count manipulation SECURITY_DESIGN.md records for role assignment -- and all three are accepted rather than prevented. The crafted pattern is the cheapest by far: one run, no modified software, and up to every matched record, where a crafted set or a differencing pair yields about one record a run. Preventing them would require binding a party's contributed set to a dataset it cannot choose per run, which nothing in this protocol does.

None of the three is a wire deviation. A wire deviation by a party running modified software is also a step beyond honest-but-curious. A sender that copies a response element into a second slot learns nothing from it: the receiver refuses a response that repeats an element before any of it is matched (Computing the count, step 1). Two wire deviations are accepted rather than prevented:

  • A deviating receiver can recover pi. The sender answers every round under one key and one pi, so a receiver that sends crafted requests across the rounds, the same elements shifted by one position or with one element replaced, rebuilds pi from which response elements are equal byte for byte. That yields per-record matches, the same result the crafted pattern across the keys gives with unmodified software in one run. The crafted rounds take the place of genuine key rounds, so the deviating receiver's own count is wrong, and the sender cannot detect it.
  • A deviating sender can substitute points in its response. The sender answers the request positions it chooses as the protocol does, and fills every other slot with distinct valid points that match no setup element. Such a response passes every check the receiver's engine makes. Request positions follow the receiver's request order, which the receiver draws at random for the exchange, so where both parties are entitled to the count, the reported count tells the sender whether the receiver's records at the chosen positions matched, but not which of the receiver's records those are. Where only the receiver is entitled, the sender receives no count. The count includes matches only at the positions the sender answered, so the count it receives omits every other match. The receiver's own count is wrong in the same way, and the receiver's engine accepts the response without an error, so the receiver is not told.

The sender can also give up the count-only property on the wire, by returning a response it did not reorder, or reordered under a permutation the receiver can predict. That reduces only the sender's own protection, and the receiver cannot detect it.

Two consequences follow, rather than being left to be derived. First, the count-only property is a property of the algorithm, not of the partner: it says the protocol hands the receiver's operator only a number, not that the receiver cannot arrange for its software to learn which records matched. Second, psi-c has this scope where it bites hardest. It is the algorithm run to decide whether a data-sharing agreement is worth having -- which is exactly when the signed agreement the honest-but-curious assumption leans on does not yet exist. A count-only run is a disclosure to be weighed on its own terms, not a free look before the real decision.

Datasets ​

Raw data ​

Data is input as a csv or other tabular data format. These files include rough metadata such as column names and storage types, which can be augmented with metadata from the exchange specification. Thus raw data consists of one or more columns, metadata for each column, and a record count.

Standardized data ​

A standardized dataset bridges between raw data and the fields expected as inputs to linkage keys. It is an abstraction over the application of data standardizing transformations. As noted in that section, there can be zero, one, or more than one standardized record for any input, so a standardized data element is essentially a mapping between an index into the raw dataset and a set of strings. These mappings are lazily evaluated and their results are cached. A standardized dataset is a collection of these mappings and the names of the linkage key fields they provide.

Linkage participation: the role axis ​

A column participates in matching -- its standardized value is hashed into a PSI set element -- when the operator roled it linkage, or when an explicit transform names it as a linkage field's input and it is undeclared (the metadata has no entry for it). There is one binding rule, shared by the dataset builder, the satisfiability pre-flight, and the default-standardization derivation so they cannot drift. A declared linkage field binds to a column when either (1) an explicit data standardizing transformation names the field as its output and names as its input a column that is not roled something other than linkage, or (2) absent such a transformation, the first role: linkage column whose semantic type equals the field's type. Rule (1) covers two cases beyond the plain one: a named column with no metadata entry -- undeclared -- still binds the field, and the presence test downstream is against the input file's header, not the metadata: the column is used for matching exactly as the transform states when the input holds it, and the field is reported unsatisfiable only when the input lacks it; and a named column that is present in the metadata but roled identifier, payload, or ignored resolves the field to no column at all. So a declared column roled other than linkage is never bound -- even as the only column of its type, and even when an explicit transformation names it: the declared role wins over a contradictory transform, and that contradictory transform suppresses the type fallback rather than re-enabling it. A field that binds to no column produces no key input and is reported as unsatisfiable by the satisfiability pre-flight; it is never silently dropped.

This keeps matching participation a separate axis from transmission. Transmission is governed by is_payload (a column is sent to the partner when is_payload is true and its role is not ignored); matching is governed by role: linkage for a declared column, or by an explicit transform's input for an undeclared one. Neither axis implies the other: a column that both matches and is transmitted is role: linkage with is_payload: true. Keeping the axes disjoint is what prevents a column the operator marked sent-to-partner, or as a local row identifier, from also being hashed into a cross-party match key without an explicit linkage designation.

This rule would only need revisiting if a disclosure model wanted a column to participate in matching without an explicit linkage role -- for example re-deriving participation from semantic type alone. The explicit role is required because a matchable type is not consent to match: a column roled for another purpose could otherwise be hashed into a key as a side effect of its type, on every linkage path and in the from-scratch authoring defaults, not only on a crafted import.

Transform regular-expression dialect ​

Some standardizing transformations include a partner-authored regular expression: the pattern of replace_regex, extract_regex, and filter_regex, the delimiter of split_on, and the pattern parse_date builds from its input_format (each MM/DD token expands to (\d{1,2}), each YYYY to (\d{4}), each YY to (\d{2}), every other character a literal, anchored ^...$). Tokens are matched greedily longest-first, so YYYY consumes its YY prefix and MM/DD/YYYY never tokenizes a two-digit year. These patterns are compiled and run per row over the other party's data. Because both parties MUST derive byte-identical keys, and the linkage terms are canonically hashed into the exchange-agreement receipt, both parties MUST evaluate every such pattern under one normative dialect; a pattern is otherwise meaningless across implementations (the protocol would inherit "whatever the running engine does"). The dialect is also a security boundary: it admits only patterns that match in time linear in the input, with a per-pattern constant, which the weighted-size cap below bounds as the pattern's compiled size and the cost of a single match, eliminating the catastrophic-backtracking denial of service a backtracking engine allows (see TRANSFORM_REGEX_DIALECT.md).

Engine and semantics. Patterns MUST be evaluated under an RE2-style engine: a Thompson NFA / lazy DFA that performs leftmost-first matching in time linear in the input length, with no backtracking. The reference implementation uses re2js (a pure-JavaScript RE2 port), the same build in the CLI (Node) and the web (browser). Matching is over Unicode code points of the NFC-normalized value. No flags are set: matching is case-sensitive, . matches any code point except newline (\n), and ^ and $ anchor the start and end of the whole value (not lines); inline flag groups ((?i), (?s), (?m)) within a pattern are permitted. The predefined classes \d and \w use their ASCII definitions, as JavaScript's do. \s is ASCII-only ([\t\n\f\r ]) -- note this is narrower than JavaScript's \s, which matches Unicode whitespace (a no-break space, a vertical tab, a line/paragraph separator) regardless of the u flag; likewise RE2's . excludes only \n, where JavaScript's . also excludes \r and the Unicode line separators. These are deliberate consequences of the engine, normative for both parties (who run the same engine, so they agree); an operator bringing a pattern over from a JavaScript engine should account for the narrower \s and broader ..

Permitted syntax. The dialect is the RE2 syntax this engine accepts with no flags: literal characters and escapes, character classes [...] (and negated [^...]), the predefined and Unicode-property classes (\d \w \s and \p{...}; whether re2js's Unicode property tables match RE2's beyond a spot check of \p{L} is unverified as of 2026-09-29), anchors (^ $ \b \B \A \z), greedy and lazy quantifiers (* + ? {n} {n,} {n,m} and their ? forms), grouping ((...), non-capturing (?:...), named (?P<name>...) or (?<name>...)), and alternation |. The product of the nested counted-repetition bounds applied to any subexpression MUST NOT exceed 1000 (RE2's repeat-count limit): a single {n} may range up to 1000, but a nested repetition such as (x{100}){11} is rejected because the product of its bounds (1100) exceeds 1000 even though each individual bound is under it. A flat (non-nested) concatenation of repetitions such as a{1000}b{1000} is not bounded by this rule -- the engine caps repeat counts, not total program size.

Size. A raw pattern MUST be rejected at terms validation, before any row is processed, when its source exceeds 1000 characters or its weighted size exceeds 1000. The weighted size is the compiled program's instruction count times one plus its capture-group count, as the reference engine reports them (programSize() and groupCount()): a{998} weighs exactly 1000 and a{999} weighs 1001. Both parties MUST compute it with the same engine build, so the count is part of the dialect. The cap applies to the four raw-pattern steps, not to the pattern parse_date builds. The cap bounds the compiled size and the cost of a single match; every regex step's search, including the find-all rescans of replace_regex and split_on whose cost can grow with the square of the value length, is charged to the per-row transform work budget, a local bound that changes no output a row inside it derives. Calibration and that bound: TRANSFORM_REGEX_DIALECT.md. The cap narrows which patterns are valid, a dialect change under Versioning below, and takes no bump: pre-publication there are no deployed peers to hold apart (Wire-format deltas: existing frames only, and no version bump).

Excluded. The dialect MUST NOT include the features RE2 omits, because they require backtracking: backreferences (\1, \k<name>) and lookaround ((?=), (?!), (?<=), (?<!)). The JavaScript \uHHHH / \u{...} escape is also excluded; use RE2's \x{...}. A pattern using any excluded feature, or otherwise not compiling under the engine, MUST be rejected at terms validation, before any row is processed (fail closed); an implementation MUST NOT fall back to a backtracking engine.

Operation semantics. replace_regex replaces every match (global). The replacement string recognizes $n and $nn (numbered groups), $<name> (named groups), $& (the whole match), $` (the text preceding the match), $' (the text following the match), and $$ (a literal $); every other $-sequence is emitted literally, INCLUDING a reference with a leading zero ($01) and a $<name> naming no group in the pattern. Those two are where the rule departs from JavaScript String.prototype.replace, which resolves $01 to group 1 and substitutes the empty string for an unknown $<name>: an implementation built on a JavaScript RegExp MUST special-case them, or the two parties derive different keys from the same terms. A numbered reference is resolved greedily by digit count where a two-digit group exists ($12 is group 12 when the pattern has twelve groups, otherwise group 1 followed by a literal 2), and a reference to a group the pattern does not have is literal. extract_regex yields the first capturing group when the pattern has one and it participated in the match; otherwise it yields the whole match. That covers both a pattern with no capturing group and one whose first group did not participate (an unmatched optional group such as a(z)? on input a). A selected value that is the empty string, or no match at all, yields null. filter_regex tests for a match anywhere in the value. split_on splits the NFC-normalized value on the delimiter; unlike JavaScript String.prototype.split, capturing groups in the delimiter are NOT emitted as output elements, and empty parts are discarded. When fewer than two non-empty parts remain, the whole unsplit value is emitted instead, so a leading or trailing delimiter is not stripped: with delimiter -, "SMITH-" yields {"SMITH-"} and "-SMITH" yields {"-SMITH"}. include_original (a boolean, default false) decides what the two-or-more case emits: true emits the unsplit NFC-normalized value together with the parts, false or absent emits the parts alone. It changes nothing where fewer than two non-empty parts remain, which emits the unsplit value under either setting. Both parties MUST resolve the parameter identically, an omitted include_original taking the same false on each, since the emitted set is the key's candidate set for the row. A delimiter that matches the empty string MUST be rejected at terms validation, before any row is processed (fail closed), because a zero-width match splits a value between its characters; the rejection is on every path partner-authored terms are parsed, and states a fixed message interpolating nothing the partner wrote. A delimiter matches the empty string when its match anchored at both ends succeeds on the empty value, as the empty delimiter, -*, and | do. The rejection narrows which patterns are valid, as the size cap does, and likewise takes no version bump. parse_date matches the NFC-normalized value against the anchored pattern built from input_format; on no match it yields null. From a match it resolves the four-digit year, zero-pads each MM and DD group to two digits, and requires all three components: if input_format has no year token (YYYY or YY), no MM, or no DD, it yields null. A YYYY group is taken verbatim as the year. A YY group is resolved to a four-digit year against a fixed protocol pivot: a two-digit yy <= 68 maps to 2000 + yy, otherwise to 1900 + yy, so the window is 1969-2068 (the POSIX two-digit-year convention). So 68 maps to 2068, 69 to 1969, 00 to 2000, and 99 to 1999. The pivot is a normative CONSTANT, identical on both parties by construction: there is no reference year, no agreed-terms date, and no clock read in this path, so two parties resolve every YY to the same century and derive byte-identical keys with nothing to agree on. The trade is a fixed cutoff rather than a moving one: a two-digit year can resolve to a year not yet reached (e.g. 50 -> 2050), which does not affect linkage because both parties resolve it identically. The YY token appears in an input_format; in an output_format it is not a substitution target (only YYYY/MM/DD are replaced, below), so a literal YY there collapses the year to a constant. It then applies a calendar check through the platform date parser in two parts. First, the assembled YYYY-MM-DD is parsed as an ISO 8601 date-only (UTC) value, and a string the parser cannot represent -- a coarse structural failure, a month outside 1-12 or a day outside 1-31 (e.g. month 13, day 32, month or day 0) -- yields null. Then the parsed UTC year, month, and day are round-tripped against the captured components, and any mismatch yields null. The round-trip is what rejects a day out of range for its specific month: the platform parser rolls such a date over internally, so 02/29 in a non-leap year, 02/30, and 04/31 each parse to a valid later date whose components no longer equal the captured digits, and the value is dropped rather than passed through. A reimplementation MUST match this exactly -- reject month not in 1-12 and day not in 1-31, and additionally reject any date whose parsed UTC components (year, month, day) do not equal the captured input digits, which is what catches a day its specific month does not have -- to derive identical keys. The result is output_format with every occurrence of each of YYYY, MM, and DD replaced by its (padded) value, substituted in that order -- all YYYY, then all MM, then all DD -- and every other character left literal. So an output_format of MM-MM-YYYY-DD-DD over 03/04/2021 yields 03-03-2021-04-04. An output_format that is the empty string MUST be rejected at terms validation, before any row is processed (fail closed), because it would render every date to the empty string; the rejection is on every path partner-authored terms are parsed, and states a fixed message interpolating nothing the partner wrote.

Versioning. A change to the permitted syntax, the flag set, the Unicode mode, or the engine's match/replace/split semantics is a wire-compatibility event: it changes what a pattern means or whether it is valid, and two parties running different dialects would silently derive different keys. The linkage-terms version field does not by itself prevent this -- it is an operator-authored schema version, compared only for equality between the two parties' authored values, and is not bound to the engine or dialect a party actually runs (two parties can declare the same version while running different Alcove releases). A dialect change therefore MUST be accompanied by a version bump in this specification and requires the two parties to run releases that implement the same dialect; the protocol does not currently negotiate or hash an engine/dialect version at runtime, so this is a process obligation rather than a guarantee the handshake enforces. Binding the dialect to a runtime-negotiated version is tracked as follow-up work.

Key input data ​

As defined in linkage terms, linkage fields are the inputs that are typically transformed to create the character strings that are concatenated to form statistical linkage keys. The linkage algorithm attempts to connect individuals - not just character strings - represented by their indices in the original dataset. Consequently, key inputs are realizations of linkage fields and are, like standardized data elements, maps between indices and sets of character strings of arbitrary size. In order to realize a complete linkage key, the set of all combinations of all key inputs is computed each and each combination is concatenated together into a string.

When that set has zero elements, i.e. one of the elements is NULL, it is omitted from the linkage protocol. When that set has more than one element the record fans out: it has several candidate values for that key, each of which enters the round as its own PSI entry, and the record-level pairing is resolved locally, from what the existing message flow already holds. No additional message, frame, or round is exchanged to prune a record whose candidates match more than once. For example, if one party has a member named "Mary Shaye-Smith" and another two members named "Mary Thorne" and "Mary Smith", both of the partner's records are record-level candidates for the same individual. A deterministic resolution rule accepts exactly one of them under a one-to-one cardinality, and the individual leaves the candidate set for every later key either way, so it cannot double-match on a less precise key. The normative rules -- value-level round participation, removal on a potential match, the resolution order and its tiebreak, the width bound, and the one linkage strategy fan-out runs under -- are specified in Fan-out matching: multi-value key candidates.

Fan-out widens what an exchange reveals about one individual, because each candidate value can independently reveal co-possession on a match, and a match on any single candidate is treated as sufficient evidence that two records represent the same person. That is a disclosure an operator opts into rather than one the protocol conceals: the per-(record, key) width is bounded normatively, the disclosure delta it pays is stated with the rules above, and an operator authoring one of these transformations is warned about the consequence.

Where a candidate set is refused. A candidate set the agreed terms declare is refused before the exchange runs under a combination that resolves none -- psi-c, or a strategy off the allowlist -- at the parse and the three further points Fan-out runs under both linkage strategies names (assertFanOutImplemented). A candidate set that reaches a strategy is checked there: the count-only round runs one value per record and refuses the set outright, while linkViaSinglePassPSI matches a set the party's declared slot width admits and refuses one wider than that declaration. linkViaPSI (the cascade) sends every candidate as its own PSI element and has no local check of a record's candidate count against the declared width. The count-only refusal rests on psi-c's own reason (Refusals under count-only). What reaches the strategy check on a party whose combination admits one is therefore a producer its declared width does not account for: multiplicity from a function unlisted in FAN_OUT_FUNCTION_NAMES. Each producer the declared factors know -- a fan-out, a generate_fuzzy_comparisons -- is accounted for by construction, its element's factor multiplying into the width its key declares. The pre-wire half of that bound covers only what the linkage terms declare -- a prepared exchange retains its built dataset rather than its standardization spec, so a count-only exchange with a standardization-authored fan-out, assembled outside prepareForExchange, is refused at the strategy, which is after the terms exchange has run. That refusal is data-dependent: it fires when a row actually splits (a fan-out whose delimiter matches no row runs to completion and narrows nothing), and on a multi-key cascade it fires at the affected key's round, after earlier rounds' elements have been built and exchanged. A splitting row escapes it exactly when realization disposes of its multiplicity first, which happens four ways. A record realizing more candidates than the width bound admits is dropped from that key's round by the bound itself, which is normative behavior rather than a refusal, so an exchange whose every splitting row is over-width runs to completion with those records sitting out the affected keys. A record whose fan-out projects past the assembly cap is likewise dropped, on the projected combination count rather than the assembled set (the width bound's own paragraph records that divergence) -- including a row whose combinations would have collided under the width bound had they been assembled. A record whose realized candidates collapse to a single distinct key string -- two candidates deduplicating to one, or an element transform filtering all but one -- is not refused: its realized set is a singleton, which matching honors as it honors any single-valued key, so matching on it narrows nothing. And a record whose every candidate is filtered away realizes the NULL/absent sentinel and sits the round out, the single-valued rule's own behavior. The collapse and empty realizations are deliberate non-refusals rather than escapes; the two drops do withhold candidates the terms declare, and are the normative exceedance behavior. Every candidate set that survives realization with two or more distinct values reaches the strategy's check: the count-only refusal, or single-pass's declared-width bound. The cascade has no such bound. That is latent, because split_on, the only multi-value producer, is listed in FAN_OUT_FUNCTION_NAMES; the cascade's refusal is deferred until a producer that is not listed can reach it.

The NULL/absent omission above is distinct from a realized key that is the empty string (""). A NULL/absent key (the realized set has zero elements) is the "no key" sentinel and the record is omitted from the round; a record whose realized set is the singleton {""} has a present, matchable key value and participates in matching like any other single-valued key. It is subject to the same within-dataset uniqueness rule, so a column in which every value standardizes to "" matches nothing (every "" is a within-dataset duplicate and is dropped from the round), while a lone "" on each side matches. This distinction would only need revisiting if "" were to regain a special meaning at the linkage layer. Because operators author and export their own standardization, "" is a fully supported key value in matching, and the blank-cell hazard -- a lone blank cell on each side matching spuriously -- is prevented explicitly at the standardization layer (the default per-type cleaning chains map an empty value to NULL) rather than silently in the matching code.

P-256 authenticated key exchange ​

Alcove authenticates every recurring exchange with a P-256 authenticated key exchange: the key-agreement primitive that produces the exchange session key (see SECURITY_DESIGN.md for the decision and its rationale). It performs an ephemeral P-256 ECDH keyed together with the pre-shared secret, plus an explicit, role-asymmetric mutual key confirmation, and yields a 32-byte session key with forward secrecy and mutual authentication. The handshake also includes a per-party request-encryption flag and returns a transcript-bound applyEncryption decision (own request OR peer request) that tells the caller whether to wrap the connection in the application-layer AEAD; see the Request-encryption flag discussion below. authenticateConnection (in auth.ts) runs it immediately after the connection opens and uses the resulting session key to key the application-layer AEAD and the wraps of the shared-secret rotation. It is implemented as runKex in packages/core/src/kex.ts and exported from @alcove/core. This section specifies the primitive and its wire format.

Primitive selection. The governing rule is to prefer a well-respected, ideally audited, dual-platform primitive and to hand-roll only the minimal glue. No production-ready, audited Noise Protocol Framework library with dual Node.js/browser support exists in the JavaScript ecosystem, so none qualifies. Every cryptographic operation in the handshake is therefore a platform crypto.subtle call: the ephemeral key generation (generateKey), the Diffie-Hellman (deriveBits over ECDH), the Noise chaining HKDF and the application HKDF (deriveBits over HKDF), SHA-256, HMAC-SHA-256, and the rotation's draw of the initiator's value (generateKey), its wrapping key (deriveKey over HKDF) and its AES-KW wrap (wrapKey and unwrapKey). No key-establishment path calls a third-party curve library, and no dependency is added. The only hand-written layer is the minimal NNpsk0 glue: the Noise key-schedule mixing and the two confirmation tags. The full Noise framework is not implemented. The Diffie-Hellman is a platform call, never hand-rolled curve math.

Construction. The handshake is pinned to the Noise NNpsk0 pattern over P-256 -- no static keys, the pre-shared secret mixed in at position 0, and ephemeral-ephemeral Diffie-Hellman -- followed by an explicit key-confirmation round whose construction is modelled on the bilateral key confirmation of NIST SP 800-56A Rev. 3 section 5.9.2 -- role-distinct MAC tags over the handshake transcript. The resemblance is to that construction and not to the scheme it is specified for: section 5.9.2 offers the feature for "any key-agreement scheme in which each party is required to own a static key-establishment key pair", and this handshake has no static keys at all, taking its authentication from the pre-shared secret instead. No other section of that publication reaches this handshake either -- section 6.1.2.3 records that "key confirmation is not incorporated for the C(2e, 0s) key-agreement schemes", the ephemeral-only category the Diffie-Hellman here belongs to -- so the citation names a construction the round is modelled on, never one it conforms to. Keying material comes from the Noise chaining key schedule specified below, not from an SP 800-56A / SP 800-56C key-derivation method: SP 800-56C Rev. 2 section 2 (Scope and Purpose) scopes that document's derivation methods to a shared secret produced by an SP 800-56A or SP 800-56B key-establishment scheme, and its sections 4 and 5 specify the one-step and two-step methods themselves; the chaining schedule is neither, though each of its steps is a single RFC 5869 extract-and-expand (see Key schedule below).

Every Noise citation in this section is to the Noise Protocol Framework, revision 34 of 2018-07-11. That document specifies the pattern and the processing rules this handshake follows; it does not offer P-256 as an instantiation. Its section 12 defines DH functions for 25519 and 448 and no others, and the DHLEN of its section 4.1 is a single constant for both the public-key encoding and the DH output, which a 65-byte uncompressed point and a 32-byte shared secret cannot both be. So the framework is what the construction is built from and not a specification it conforms to; the divergences from a stock NNpsk0 session are enumerated under Test vectors and interoperability below.

Curve and point encoding. The curve is P-256 (secp256r1 / prime256v1), whose domain parameters -- the field prime, the coefficients, the base point G, the order n, and the cofactor h = 1 -- are published in SP 800-186 section 3.2.1.3. FIPS 186-5 states no curve parameters of its own: it specifies ECDSA and refers the recommended curves to SP 800-186. An ephemeral public key is encoded on the wire as the uncompressed point 0x04 || X || Y of SEC 1 Ver. 2.0 section 2.3.3: 65 bytes, X and Y each a 32-byte big-endian field element. That encoding is pinned, not merely preferred. A share of any other length, or of length 65 with any prefix other than 0x04, is rejected without being decoded -- the compressed (0x02 / 0x03 || X, 33 bytes) and hybrid (0x06 / 0x07 || X || Y, 65 bytes) encodings of the very same point included. Only the compressed form is SEC 1's alongside the uncompressed one: for a point other than the identity, section 2.3.3 emits those two and no third, and section 2.3.4 admits a leading octet of 0x00, 0x02, 0x03 or 0x04 and rejects every other. The hybrid form belongs to IEEE Std 1363-2000 (informative Annex E.2.3.2, which admits 0x06 and 0x07 beside them) and to ANSI X9.62; it is named here because platforms decode it, not because SEC 1 defines it. Two reasons, either sufficient. The transcript binds the wire bytes rather than the decoded point, so a second admissible encoding of one point would let an in-path attacker re-encode a share and desynchronize the two sides' transcripts. And crypto.subtle.importKey does not answer the encoding question consistently across platforms (Node decodes compressed and parity-correct hybrid, Chromium decodes compressed and refuses hybrid), so a share's acceptance would otherwise depend on which peer received it. The shared secret is the 32-byte X coordinate of the shared point -- the Z that SP 800-56A Rev. 3 section 5.7.1.2 outputs from the ECC CDH primitive, which for a cofactor-1 curve is plain ECDH -- and is what deriveBits returns for ECDH over this curve.

Module boundary. The key schedule is composed in application JavaScript above primitive calls, and sits outside any validated cryptographic module boundary. Where a validated module is configured, it performs every cryptographic operation the handshake makes, all of which reach it through crypto.subtle -- the shared-secret computation as KAS-ECC-SSC per SP 800-56A Rev. 3 over P-256, and each extract-and-expand as the module's approved HKDF key-derivation function -- KDA HKDF Sp800-56Cr1, CAVP certificate A7440, on certificate 5438, the module the FIPS variant image holds (CONTAINER_IMAGES.md states both rows and points at the conditions that certificate attaches to them). It does not perform the mixing order, the domain-separation labels, the transcript binding, or the confirmation round, none of which any module certificate attests. In the browser there is no module beneath the platform at all. A composition of this shape yields validated primitives and never a validated protocol -- what a scoped claim may and may not say is in COMPLIANCE.md, and the decision behind the boundary is recorded in key-establishment-fips-boundary.md.

Protocol-version tag. The Noise "protocol name" is the version tag and is hashed into the initial handshake hash, binding every derived key and confirmation tag to it:

protocol_name = "alcove-kex-v3:NNpsk0_P256_SHA256"

A mismatched tag fails the handshake closed, and does so by construction. The tag is the entire input to h0, and ck0 = h0 (see Key schedule below), so two peers on different tags diverge at the first step of both the hash chain and the chaining-key chain. Every later value -- the handshake hash, the chaining key, the session key, and both confirmation tags -- differs, so the confirming peer's tag does not verify and the handshake aborts before any non-handshake frame is sent. There is no path on which two peers on different tags agree on a session key. The earlier tags, both under the product's former name (rename-to-alcove.md), were its kex-v1 tag for NNpsk0_25519_SHA256, which ran the exchange over X25519, and its kex-v2 tag for NNpsk0_P256_SHA256, the same P-256 suite; a peer on either and a peer on the current tag therefore fail closed on this mechanism rather than negotiating, which is the intended and only outcome -- wire compatibility with either earlier tag is not offered.

A future authenticated mode (for example a certificate-chain variant) is an additive new version selected out of band on the same mechanism, so the door stays open without any pluggable-auth framework today. The version tag is reserved for a genuine cryptographic-suite change and is not bumped for an additive transcript payload such as the request-encryption flag described below. That flag is a required field on the wire schemas, so a flag-aware and a flag-unaware peer already fail closed at parse -- the flag-aware side rejects a message that omits the required field, and the strict (.strict()) schema rejects a message that includes the unrecognized field -- before any transcript is computed.

The four alcove-kex-v2: domain-separation labels below keep their own namespace version, which does not track the suite tag (both earlier tags used the former name's kex-v1:* labels): the tag already separates one suite's transcript from another's totally, so re-versioning the labels would change derived bytes without separating anything the tag has not.

Key schedule (a minimal subset of the Noise SymmetricState: a chaining key ck and a handshake hash h). protocol_name is longer than the 32-byte hash output, so the Noise InitializeSymmetric reduces to h0 = SHA-256(protocol_name), and ck0 = h0. The NNpsk0 tokens (-> psk, e then <- e, ee, with an empty prologue) are then processed in order:

MixHash("")                       # empty prologue
MixKeyAndHash(psk)                # psk token at position 0
MixHash(e_initiator_public)       # initiator's e: MixHash ...
MixKey(e_initiator_public)        #                ... and MixKey (PSK mode)
MixHash(initiator_flag_byte)      # msg1 payload: initiator's request-encryption flag
MixHash(e_responder_public)       # responder's e: MixHash ...
MixKey(e_responder_public)        #                ... and MixKey (PSK mode)
MixHash(responder_flag_byte)      # msg2 payload: responder's request-encryption flag
MixKey(dh)                        # ee: dh = ECDH(e_self_private, e_peer_public)

Each e_*_public enters the schedule as its 65-byte wire encoding. Noise states its HKDF's input keying material as zero, 32, or DHLEN bytes (section 4.3), an enumeration a 65-byte share sits outside; what makes the schedule well defined anyway is that the RFC 5869 extract beneath it takes an input of any length. dh is 32 bytes, so every derived length below is unchanged from a 32-byte-share construction.

Each *_flag_byte is a single byte, 0x01 if that party requests the additional application-encryption layer and 0x00 otherwise, MixHash'd as that message's handshake payload right after the party's e token. Noise handles a payload differently, in two ways: its WriteMessage (section 5.3) takes the payload after every token of the message pattern, which in message 2 is after ee rather than after e, and it passes the payload through EncryptAndHash (section 5.2), which encrypts under the key the psk token has already established and MixHashes the ciphertext. The position difference does not change a derived byte, because MixHash and MixKey touch disjoint state (below); the plaintext-for-ciphertext difference does, and is one of the divergences listed under Test vectors and interoperability. It is MixHash only -- the flag need not be confidential and enters h alone, so the chaining key (and the confirmation key derived from it) stays flag-independent while h, the session key, and both confirmation tags depend on both flags. See the Request-encryption flag discussion below.

In a PSK handshake, Noise (rev 34 section 9.2) processes each e token with MixKey(e.public) in addition to MixHash(e.public), folding the ephemeral public keys into the chaining key as well as the hash; this implementation does both, so the mix chain is faithful NNpsk0. (MixHash touches only h and MixKey only ck, so their order within an e token does not affect the result, and the flag MixHashes likewise commute with the surrounding MixKey calls; the chaining-key order psk -> e_initiator -> e_responder -> ee and the hash order e_initiator -> initiator_flag -> e_responder -> responder_flag are what matter, and are preserved.) Here MixHash(x) sets h = SHA-256(h || x); MixKey(ikm) sets ck = HKDF-Noise(ck, ikm, 2)[0]; and MixKeyAndHash(ikm) sets (ck, temp_h) = HKDF-Noise(ck, ikm, 3)[0..1] then MixHash(temp_h). HKDF-Noise(ck, ikm, n) is the Noise chaining HKDF (Noise rev 34 section 4.3): temp_key = HMAC(ck, ikm), then output_i = HMAC(temp_key, output_{i-1} || byte(i)) for i in 1..n (with output_0 empty). This equals RFC 5869 HKDF with salt = ck and an empty info string, and is issued as exactly that: one crypto.subtle HKDF deriveBits call for n * 32 bytes, split into the n blocks. The equality is what makes the two forms interchangeable, and the vector generator writes out the HMAC chain while the implementation issues the single call, so the two are checked against each other on every run.

After the tokens, the session key and a distinct confirmation key are derived, and the two confirmation tags computed, using the application HKDF (hkdfDerive: HKDF-SHA-256, 32-byte zero salt, named info):

session_key       = HKDF(ck || h, "alcove-kex-v2:session", 32)
confirm_key        = HKDF(ck,      "alcove-kex-v2:confirm", 32)
initiator_confirm  = HMAC(confirm_key, "alcove-kex-v2:initiator-confirm" || h || wrapped_N_I)
responder_confirm  = HMAC(confirm_key, "alcove-kex-v2:responder-confirm" || h)

wrapped_N_I is the initiator's 40-byte wrapped rotation value (Shared-secret rotation), so a changed byte of it fails the initiator's tag. It is bound as the tag's MAC input only and is not mixed into h: its wrapping key derives from session_key, which derives from h. The responder computes the expected initiator tag once it has read message 3.

Deriving session_key over both ck (which holds the pre-shared secret, both ephemeral public keys, and the ECDH shared secret) and h (which holds the protocol-version tag, the empty prologue, the value temp_h that MixKeyAndHash(psk) derives from the pre-shared secret, both ephemeral public keys in role order each followed by its request-encryption flag) is the critical invariant. The session key depends on the ephemeral DH output, so it is neither the pre-shared secret alone -- which would have no forward secrecy -- nor the raw ECDH output alone, which would not be transcript-bound. The confirmation key is independent (distinct input keying material and a distinct label), and the two confirmation tags are role-asymmetric (distinct labels) and each binds the full transcript hash h. All labels here are namespaced alcove-kex-v2:. The domain-separation label space. The system's derivation labels form one disjoint space, enumerated here and cited from the sections that use each family: alcove-kex-v2:* (the session and confirmation derivations above), alcove-aead-v2:* (the application-layer AEAD), alcove-shared-secret-wrap-v2, alcove-shared-secret-wrap-responder-v2 and alcove-shared-secret-next-v2 (the rotation below), alcove-abort-token-v2:* (the file-sync abort marker), alcove-webrtc-peerid-v2:* (the rendezvous peer ids below), alcove-relay-key-v2 (the relay key below), alcove-relay-exchange-id-v2 (the exchange id a relay key is enrolled under without a token, below), alcove-relay-registrar-v2:* (the registrar request proof below, derived from the relay key), alcove-relay-websocket-v2:* (the WebSocket relay proof below, derived from the relay key), the alcove-signing-cert-* domains (the certificate signature and fingerprint, below), the alcove-signed-receipt-* labels (the receipt-content domain, and the receipt payload-MAC and binder derivations, which take the same session key as their input keying material), and alcove-terms-update-v2:* (the terms update's partnership identifier and MAC key, derived from the shared secret; see EXCHANGE_FILE.md). No derivation under one family can collide with one under another.

Disjointness here means exact-string-distinct and prefix-free for every label consumed as an HKDF info string, because that string is not length-prefixed: a label that is a prefix of another is not separated from it by the derivation. Three members travel instead as a field of a canonicalized JSON object, where the structure separates them: the certificate signature and fingerprint domains and the receipt-content domain. Each is a single fixed string and prefix-free among the rest all the same. No single label or family prefix -- a suffix-taking family's name through its : separator -- is a prefix of another (alcove-relay-key-v2, alcove-relay-exchange-id-v2, alcove-relay-registrar-v2: and alcove-relay-websocket-v2: share alcove-relay- and diverge after it; alcove-shared-secret-wrap-v2, alcove-shared-secret-wrap-responder-v2 and alcove-shared-secret-next-v2 share alcove-shared-secret- and diverge after it, the two wrap labels after alcove-shared-secret-wrap-), and a family that takes a suffix (alcove-aead-v2:<context>, alcove-abort-token-v2:<role>, alcove-webrtc-peerid-v2:<role>, alcove-relay-registrar-v2:<use> for proof-key and request, alcove-relay-websocket-v2:<use> for proof-key and hello, alcove-signed-receipt-payload-v2:<direction>, alcove-signed-receipt-binder-v2:<role>, alcove-terms-update-v2:<use>) draws it from a fixed, non-empty, colon-free and prefix-free set behind that separator, so no member of one family is a prefix of a member of another or of its own family. packages/core/test/labelSpace.test.ts checks both properties, and that every alcove- string in core's source is an enumerated member or listed outside the space. Adding a label, or widening one of those suffix sets, is a change made against these properties.

Message flow (initiator sends first; all messages are JSON objects; e is a base64url-encoded 65-byte uncompressed P-256 point, 87 base64url characters unpadded; confirm is a base64url-encoded 32-byte HMAC-SHA-256 tag, 43 characters; reqEnc is the party's request-encryption boolean; and initiatorValue is the base64url-encoded 40-byte wrapped_N_I, 54 characters):

  1. Initiator -> Responder: { kexMsg: "1", e: e_I, reqEnc: req_I }
  2. Responder -> Initiator: { kexMsg: "2", e: e_R, confirm: responder_confirm, reqEnc: req_R }
  3. Initiator -> Responder: { kexMsg: "3", confirm: initiator_confirm, initiatorValue: wrapped_N_I } or { kexMsg: "abort" }

The responder confirms first (in msg2); the initiator verifies responder_confirm before sending its own confirmation in msg3, and the responder verifies initiator_confirm on msg3, then unwraps wrapped_N_I. The initiator draws and wraps its rotation value only once responder_confirm has verified, so it sends no keying material before key confirmation; the abort form has no initiatorValue. A mismatched pre-shared secret therefore fails closed on both sides before any non-handshake frame is sent. Each side sends the tag for its own role and verifies the tag for the opposite role, so a reflected or echoed confirmation does not verify.

Request-encryption flag. Each party declares, on its own handshake message (req_I on msg1, req_R on msg2), whether it wants an additional application-encryption layer over the post-handshake connection. The flag is a required field on the strict message schemas. Both parties therefore hold both flags before the key schedule runs, so both compute the same negotiated decision apply_encryption = req_I OR req_R and bind both flags into the transcript hash h (as the per-message handshake payload, above) with no extra message or round-trip. Because h feeds both confirmation tags, a flag flipped on the wire makes the two sides derive different transcripts and the confirmation fails closed: the handshake cannot be downgraded to an encryption decision the parties did not agree on. The negotiation is a single fixed-meaning boolean, not an extensible options block -- open-ended in-handshake negotiation is the historical home of downgrade bugs. runKex returns apply_encryption as KexResult.applyEncryption; the caller applies the AEAD layer (see SECURITY_DESIGN.md) when it is true and a session key is in hand. Every channel requests it unconditionally: the file-sync transports (SFTP, file-drop) and WebRTC alike (CHANNEL_SECURITY.md).

Peer-share validation. A peer's e is validated in two layers before it reaches the Diffie-Hellman, and the split between them is deliberate:

  1. Encoding, in application code. The decoded share must be exactly 65 bytes with the 0x04 prefix (see Curve and point encoding above). Anything else is rejected without being decoded.
  2. Point validity, by the platform. The share is then imported through crypto.subtle.importKey, which rejects a point that is not on the curve, a coordinate at or above the field prime, and both byte strings a peer might send for the identity element. Those two strings are SEC 1's single 0x00 octet, which is the only encoding it gives the point at infinity (section 2.3.3 step 1), and the all-zero uncompressed string 0x04 || 0^32 || 0^32, which SEC 1 section 2.3.4 reads not as the identity but as the point (0, 0), off the curve. Driving the real API is what establishes this, on each platform, rather than assuming it; packages/core/test/kex.test.ts and the browser suite each make the measurement on their own platform.

P-256 has cofactor 1, so there is no small-order subgroup for a share to land in and the point at infinity is the whole of the degenerate case -- the contributory-check rationale behind the X25519 construction does not transfer. RFC 7748 section 7 states that rationale for the curves that document defines and rests it on their cofactors of 8 and 4, which let an input point of small order eliminate the other party's contribution. The all-zero check it points at (Section 6, whose detection prose sits in 6.1 with 6.2 cross-referencing it) is the detection for exactly that case, and neither the cofactor assumption nor the check it motivates applies to a cofactor-1 curve. An all-zeros shared secret is nonetheless still rejected, as a stated safety check rather than as the validation: the platform's rejection of the identity is a measured behavior rather than one this project controls, and a platform that admitted such a share would otherwise yield a shared secret an attacker knows, costing the handshake its forward secrecy silently.

Failure handling. All wire schemas use strict (.strict()) Zod parsing, so an unexpected field fails the parse. Tag comparison is constant-time. Every authentication failure throws a single generic, non-oracular error ("key exchange authentication failed") that does not reveal which check failed -- a malformed share, a rejected point, and a confirmation mismatch are indistinguishable to the peer -- while the error's security kind, not its message, carries the trust-boundary classification a consumer branches on. A silent peer reports the distinct "key exchange handshake timed out" after a per-message bound of 30 s plus two of the transport's inbound poll intervals; any other failure of a handshake receive -- a transport refusal, a dropped link -- is reported as itself, its own class and message, so it is never mistaken for the peer going silent. The poll intervals apply only to a transport that polls for inbound frames (file-sync, through MessageConnection.inboundPollIntervalMs): a reply there reaches the waiting party up to one interval after each of the two writes in the round trip. WebRTC pushes frames, so its bound is 30 s. The connection's own inactivity budget (inactivity_timeout_ms) still caps each wait. Every receive-side failure before the final message sends a best-effort { kexMsg: "abort" } so the peer stops waiting immediately rather than blocking until the timeout.

A failure of message 3. A message 3 that fails the parse, whose tag does not verify, or whose wrapped_N_I fails its AES-KW integrity check fails the responder with the same generic error, and the responder sends the abort; a message 3 that is itself the abort is not answered. The unwrap is a consistency check rather than a second defense: the tag already covers the wrapped bytes, so only a holder of the pre-shared secret that wrapped under another key reaches it. By then the initiator has completed, wrapped the connection in the application-layer AEAD, and sent terms message 1, and it waits for terms message 2 under the connection's inactivity budget rather than the per-message bound. The cleartext abort is not an AEAD envelope, so the AEAD layer refuses it as a security failure (CHANNEL_SECURITY.md) and the initiator fails at once.

Non-oracular covers the absence of a secret-dependent branch as well as the message: the only comparison against secret-derived material is the constant-time tag check, and the key schedule runs identically whatever the pre-shared secret's value. What a peer can still distinguish by wall-clock timing is a failure before the key schedule (a bad parse or a rejected share) from one after it (a tag mismatch). That split is a function of the peer's own input, which on the adversarial path the adversary authored, and not of any secret, so it discloses nothing the peer did not already know.

Test vectors and interoperability. The chaining-key chain above -- psk, then each e, then ee -- is faithful Noise NNpsk0, but the overall handshake is pinned by Alcove's own checked-in known-answer vector (packages/core/test/vectors/kex-vectors.json) rather than being wire-compatible with generic Noise. Four things diverge from a stock Noise NNpsk0 session. The Diffie-Hellman is over P-256, which the framework's section 12 does not specify. The protocol name differs, so the initial h differs. Each message's payload is a flag byte MixHash'd in the clear where Noise would EncryptAndHash it. And instead of Noise Split() the session key uses a custom KDF over ck || h with an added explicit confirmation round. So no end-to-end Noise vector corresponds. What does correspond is the underlying machinery, cross-checked against published references in packages/core/test/kex.test.ts: the Noise chaining HKDF against RFC 5869 test case 3, and the P-256 scalar multiplication and ECDH against the key pair published in RFC 6979 section A.2.5 over the base point SP 800-186 publishes. There ECDH(d, G) and ECDH(1, U) must both produce the published U X coordinate, so a wrong curve, a wrong base point, or a wrong shared-secret convention breaks the anchor. The vectors fix the pre-shared secret and both ephemeral private keys and record, for all four (initiator, responder) request-encryption flag combinations, the handshake hash, session key, and both confirmation tags (the chaining key and confirmation key are flag-independent and recorded once). They also fix both parties' rotation values and record, per combination, each wrapped value, and once the next secret derived from them and the vector's dh (Shared-secret rotation). They are reproduced by the independent generator at packages/core/test/vectors/generate-kex-vectors.mjs, which composes the schedule from Node's OpenSSL-backed crypto rather than from WebCrypto, computing each wrap with OpenSSL's id-aes256-wrap, checked against RFC 3394 section 4.6, and the next secret with node:crypto's HKDF. The same file is run against the browser build in real Chromium by apps/web/test/browser/kex.test.ts, so a CLI peer and a web peer are shown to derive an identical session from an identical transcript.

Shared-secret rotation ​

The pre-shared secret that authenticates a recurring exchange is a 32-byte value encoded as unpadded base64url (43 characters). Two mechanisms mint one, differing only in how the 32 bytes are produced:

  • Invitation tokens are generated directly from a CSPRNG (crypto.getRandomValues), giving 256 bits of entropy.
  • Rotation tokens are derived with the application HKDF (hkdfDerive: HKDF-SHA-256, 32-byte zero salt) from the handshake's key-agreement output and a 32-byte random value from each party, each value sent under AES-KW with a key derived from the 32-byte session key (above). The session key mixes the pre-shared secret with the ephemeral P-256 Diffie-Hellman, so a rotated secret is a full 32-byte value that stays secret against any party that does not hold both inputs; against a party that holds the superseded secret, its confidentiality rests on the ephemeral exchange alone, the ~128-bit P-256 forward-secrecy level:
wrap_key_I  = HKDF(session_key, "alcove-shared-secret-wrap-v2", 32)
wrap_key_R  = HKDF(session_key, "alcove-shared-secret-wrap-responder-v2", 32)
wrapped_N_I = AES-KW(wrap_key_I, N_I)
wrapped_N_R = AES-KW(wrap_key_R, N_R)
next_secret = HKDF(ka || N_I || N_R, "alcove-shared-secret-next-v2", 32)

The two values. The initiator draws N_I and the responder N_R, each 32 bytes, with crypto.subtle.generateKey as an extractable 256-bit HMAC key, the form wrapKey takes, and exports its raw bytes for the derivation. Neither value is stored. N_I is sent in key-exchange message 3 under the initiator's tag, once the initiator holds key confirmation (Message flow). N_R is sent in terms message 2, once the responder holds key confirmation (The rotation at the terms exchange).

The wraps. AES-KW is the RFC 3394 key wrap with its default initial value, the algorithm certificate 5438's security policy lists as AES-KW under SP 800-38F (CONTAINER_IMAGES.md); each wrapped value is 40 bytes. Each wrapping key is derived by deriveKey straight to a non-extractable AES-KW key, with the one usage wrapKey on the sender and unwrapKey on the receiver, wraps one value, and is discarded.

The key-agreement output. ka is every shared secret the handshake's key agreement produces, concatenated in the order the key schedule mixes them, and nothing derived from the pre-shared secret. For this handshake it is the 32-byte dh the ee step mixes, so the derivation's input is 96 bytes. The input has the form SP 800-56C Rev. 2 section 2 permits for a hybrid shared secret, "a concatenation consisting of a standard shared secret Z that was generated during the execution of a key-establishment scheme (as currently specified in [SP 800-56A] or [SP 800-56B]) followed by an auxiliary shared secret T that has been generated using some other method", with ka as Z and N_I || N_R as T. dh is SP 800-56A's shared-secret computation but not the output of an SP 800-56A scheme (COMPLIANCE.md, SC-12).

Why ka is mixed in. An active adversary holding the current secret that runs one handshake with each party can unwrap both values on one leg and wrap them on the other, but each leg has its own ka, so the two parties store different secrets and the adversary must stay in the middle for every later run; derived from the values alone, both parties would store one secret the adversary knows. No input depends on the current secret's value, so the next secret is independent of it. A recorder who later solves the Diffie-Hellman but never held a secret has ka and neither value.

Both parties recompute the next secret independently from the same ka, N_I and N_R, and each persists it as the secret for the next exchange. The rotation adds no message or round-trip: N_I is a field of message 3 and N_R a field of the terms exchange's existing reply. The three info strings are single fixed labels, not per-party or per-context families, and sit in the disjoint domain-separation label space above, so a next secret or a wrapping key can never collide with a session, AEAD, abort-marker, peer-id, certificate, or signed-receipt derivation.

The rotation at the terms exchange ​

The next secret is stored at the terms exchange every authenticated run makes after the handshake (exchangeTerms in packages/core/src/protocolSetup.ts), which awaits each side's store at its point in this order, for a run under a secret X:

  1. The responder reads terms message 1 and settles the terms. On agreement it checks X's expires, draws N_R, derives the next secret, holds it in memory, and sends terms message 2 with the field responderValue: wrapped_N_R, base64url, 54 characters.
  2. The initiator opens terms message 2 at the AEAD layer, unwraps N_R, derives the next secret, and settles the terms. On agreement it checks X's expires, stores the next secret, and only then sends terms message 3 proceed.
  3. The responder, on reading proceed, stores the next secret, whatever its clock reads, since the initiator already holds it.

Every refusal, on either side, sends an abort before the first store, so a run refused at the terms exchange stores nothing on either side. responderValue's rules:

  • On a proceeding terms message 2 of an authenticated run it is required. One missing, not 40 bytes of base64url, or failing its AES-KW integrity check ends the run on the initiator with an abort, and nothing is stored on either side. A failed unwrap is a security failure, and a consistency check rather than a second defense: the AEAD layer already binds the frame to this run and direction under a key from the same session key.
  • An abort holds none. The initiator stores nothing from an abort, whatever it holds.
  • An exchange that ran no key exchange has no session key. Its initiator refuses a terms message 2 holding the field rather than reading past it.

The terms message 2 schema drops an unknown key without error, so each rule is a check exchangeTerms makes, not one the parse gives. Two abort reasons are fixed:

  • A side whose store fails sends "the partner could not save the next shared secret": the initiator as terms message 3, the responder as its first frame after it, which the initiator reads past the terms exchange as the partner's abort.
  • A side whose expires has passed at its check sends "the partner's shared secret expired before the next one was saved".

A run cut between the initiator's store and the responder's -- proceed lost, the responder's store failing, or the responder stopping -- leaves the initiator on the next secret and the responder on X, which a re-invite recovers. An initiator that stored and then reads the responder's store-failure reason keeps it as its own constant (isPartnerNextSecretStoreFailure in packages/core/src/failureClass.ts), and each application reports that the partner could not save the new secret and that a fresh invitation is needed. One that kept the next secret, such as the CLI or a saved browser exchange, adds that this side holds it; the one-off browser exchange, which keeps no secret past a failed run, says that nothing changed on this side. Any other reason, or this one on a side that stored nothing, reads as any partner abort.

The zero-setup bootstrap frame. A third path establishes a secret of this same format, and it is the one that puts one on the wire. When both parties declared --save on a zero-setup exchange, the initiator generates 32 CSPRNG bytes exactly as an invitation token does and sends them to the responder on a dedicated { sharedSecret } frame; both return the value for their caller to persist. The frame is sent immediately after message 3 of the terms exchange and before the linkage rounds begin. Both parties learn from that terms exchange whether it is sent, so it is never negotiated further and neither side blocks awaiting a frame the other will not send. A zero-setup exchange runs no key exchange, so there is no session key and no application-layer AEAD above the transport, and the secret is protected by the transport alone for that one frame; the trust model that rests on, and the --retain-files at-rest consequence, are in SECURITY_DESIGN.md.

The operator-facing rotation policy -- that the secret is never reused, that a rotated token has no expiry by default, and the recovery path when the two parties' tokens desynchronize -- is in SECURITY_DESIGN.md.

expires enforcement and the 'expiring soon' advisory ​

A .alcove.key token may hold an expires instant written by either of two mechanisms: an invitation setup lifetime or the token_max_age_days policy. Enforcement is kind-agnostic by construction: it reads only the stored expires, never its origin, and the same recovery (re-invite) applies to both. So no consumer branches on origin and no provenance marker is stored. A marker would add state to the key file without changing any decision taken from it, and could be present on one party's key file and absent on the other's, in a model whose recoverability depends on both parties holding uniformly-interpreted key files. The operator-facing policy is in SECURITY_DESIGN.md.

Enforcement sites. Four checks read expires. Three are kind-agnostic and abort on any expires at or before the current time, directing both parties to re-invite: the load-time check before an exchange, the pre-handshake check, and the terms-exchange check (which catches a token that lapses after the pre-handshake check, at each side's last point before a store that would leave the partner behind). The fourth, the accept-time invitation check, is the only single-kind site -- unavoidably, since it validates the encoded invitation, which holds only an invitation lifetime. An invitation whose expiry cannot be compared -- the expires string does not parse, or the local clock does not -- is treated as expired there rather than accepted. There is no expired state in which the two kinds call for different recovery, so the expired-side message is uniform by intent rather than branched on origin.

What each of the three kind-agnostic checks covers. The load-time and pre-handshake checks read only local state, so they run before any rendezvous I/O and an expired or malformed token costs no connection. The terms-exchange check runs on the responder before it sends terms message 2 and on the initiator just before its store (The rotation at the terms exchange); once the responder has read proceed it stores whatever its clock reads. The responder's window, from its check to the initiator's store, is terms message 2's transit, bounded only by the connection's inactivity budget, and the initiator's is none. A token that lapses before either check is refused before either store, with the fixed abort reason that names it, so both parties re-invite rather than persist a secret rotated from an expired one.

'Expiring soon' threshold. The advisory fires when the loaded expires is within token_max_age_days / 3 days of the current time -- a fraction of the configured age rather than a fixed number of days, so it scales with the policy (a 90-day age warns with 30 days remaining, a 30-day age with 10) and is silent unless a policy is configured. It is suppressed when the exchange that reports it succeeds (rotation then stamps a farther-out expires) and shown only when no successful rotation refreshed the token -- a failed exchange.

Invitation token flagged 'expiring soon'. Because the advisory does not consult origin, a freshly minted invitation token can be flagged. This is reachable only on the offline re-invitation path (the inviter's key file is the only one that persists an invitation expires; the acceptor's offline key file holds none, and the online flow persists only the rotated token), before the first exchange rotates the token, and it survives only past a failed first exchange. The notice is accurate and its guidance is correct for either kind, so it is accepted rather than suppressed; suppressing it for invitation tokens alone would require persisting a per-file origin marker -- the invisible cross-party state the unified representation avoids.

Design rationale. The unification rests on the two sources sharing one recovery. A key file needs no conversion: it holds a bare expires, which is exactly what the uniform interpretation reads. It would need revisiting only if an expired max-age token were ever made recoverable without re-invitation, so that the expired-side action diverged by origin.

WebRTC rendezvous peer-id derivation ​

The WebRTC transport meets its peer with no coordination backend: both parties derive their PeerJS rendezvous peer ids deterministically from the invitation's 32-byte shared secret, so each side computes the other's id locally and they rendezvous on the PeerJS signaling broker without exchanging an id out of band. This precedes and is independent of the P-256 handshake above -- it places the two parties on the same WebRTC connection, over which the handshake then runs.

The derivation is HKDF-SHA-256 over the decoded 32-byte shared secret as input keying material, with a 32-byte zero salt (matching the application hkdfDerive above) and a role-separated info string:

inviter_info  = "alcove-webrtc-peerid-v2:inviter"
acceptor_info = "alcove-webrtc-peerid-v2:acceptor"
peer_id       = hex(HKDF-SHA-256(ikm = secret, salt = 0x00 * 32, info = role_info, 32)[0..15])

The peer id is the first 16 bytes of the HKDF output, encoded as lowercase hex. Hex (not base64url) is used because the PeerJS client rejects an id with a leading, trailing, or doubled - or _, which base64url output can contain. The inviter listens on its derived id (inviter_info) and the acceptor dials it.

The ids change only when the shared secret does. The terms exchange that follows a successful handshake rotates the secret once the terms are agreed on (see Shared-secret rotation) and so moves both ids, while a scheduled window the partner does not meet leaves them unchanged, and every later attempt registers the same ids at the coordination server until a run rotates the secret or the exchange is re-invited. The server can read the partnership's liveness and cadence from that repetition, never its content. The derivation takes no window or clock input, so two parties holding the same secret derive the same ids without agreeing on anything else; the operator's account of a missed window is in A missed window is neither desync nor attack.

This is a cross-implementation contract: both implementations compute the same ids from the same shared secret, which is what lets a CLI party and a browser party rendezvous with each other (see cli-webrtc-stack.md). On the CLI the role half of the derivation comes from the connection's role field, stamped by alcove invite and alcove accept; the browser takes it from which rendezvous function runs. The alcove-webrtc-peerid-v2: labels sit in the disjoint domain-separation label space above.

Conformance vectors. The contract is pinned by a checked-in known-answer vector at packages/core/test/vectors/webrtc-interop-vectors.json, on the precedent of kex-vectors.json above. It fixes one shared secret and records both derived ids, the encoded invitation a browser inviter mints from it (the whole base64url string, plus the canonical JSON body the checksum covers), and the signaling locator that invitation holds. It also records the locators a CLI inviter mints from a ws:/wss: URL together with the broker location both applications must resolve each of them to, and each side's pairing of rendezvous role to key-exchange handshake role and request-encryption flag. Both mint directions are recorded because each application resolves an absent locator field to a default of its own (see WEBRTC_TRANSPORT.md), so a locator is a contract only where what it omits is pinned as well as what it states. generate-webrtc-interop-vectors.mjs beside it reproduces the derivations from Node's OpenSSL-backed crypto rather than from WebCrypto, and restates the invitation body's key order literally rather than reading it off the schema. The divergence this guards against lives in each application's own call sites rather than in the shared library. So every application asserts its own construction against the file from its own suite -- the ids it registers and addresses, the token it consumes, the role and flag it hands the handshake -- rather than one test computing both sides through a single path. Each flow that reads the side-to-role table is driven through its own entry point rather than through the table. The derivations that run on crypto.subtle -- the peer ids and the invitation checksum -- are additionally run against the browser build in real Chromium by apps/web/test/browser/webrtcInterop.test.ts, as kex-vectors.json is, since which bytes a platform produces is a property of that platform.

Relay credential derivation ​

A party whose WebRTC connection needs a TURN relay uses the relay the invitation names, one its own side controls, or both (see The invitation's relay locator below), and authenticates to it with a credential derived from the invitation's 32-byte shared secret, so no relay password is sent in the invitation or out of band. The relay is coturn, in its use-auth-secret mode: it holds a table of shared secrets and accepts a time-limited credential signed under any of them. The reference relay under infra/relay holds one entry per exchange in that table, added and removed by its operator's register and revoke scripts, run on the relay host or through its registrar service, and retired by a sweep once the exchange's max-age lapses (infra/relay/README.md, Per-exchange keys).

The relay key. One key per exchange, which the relay's operator registers as an entry in the relay's secrets table. The key is derived from the exchange's current shared secret, so it changes at each rotation, and a relay must hold the key derived from the current secret:

relay_key = hex(HKDF-SHA-256(ikm = secret, salt = 0x00 * 32, info = "alcove-relay-key-v2", 32))

The key is the whole 32-byte output as 64 lowercase hex characters, and the relay stores that string as the entry. coturn keys its HMAC with the stored string's bytes, so the HMAC key is the 64 ASCII hex characters, not the 32 bytes they decode to. This is coturn's behavior rather than anything Alcove controls, so it holds only as far as it has been measured against a real coturn: against coturn 4.18.0, the version infra/relay/Dockerfile pins, a credential keyed with the characters was accepted and one keyed with the decoded bytes refused. infra/relay/verify.sh repeats that check against each running relay, and a relay on another coturn version must pass it before this paragraph is relied on there. The info string is a single fixed label in the domain-separation label space, so the relay's operator, who holds the relay key, learns no other key derived from the secret.

The exchange id. A party that enrolls the relay key at a registrar without a token (Enrolling at a default relay) enrolls it under an exchange id derived from the same secret:

exchange_id = hex(HKDF-SHA-256(ikm = secret, salt = 0x00 * 32, info = "alcove-relay-exchange-id-v2", 16))
  • Its form. 32 lowercase hex characters, within the registrar's id alphabet.
  • Why derived. Both parties compute it without exchanging anything, and a third party without the secret cannot predict it, so it cannot enroll the id under another key first.
  • What it reveals. Nothing about the secret or the relay key: the info string is a single fixed label in the domain-separation label space. It changes at each rotation, as the relay key does.
  • Test vector. For the secret of bytes 0x00..0x1f, exchange_id = ef946df1a03b076bd042fa7cf3cea8ed, computed by OpenSSL's HKDF (openssl kdf, with the info string above and a 16-byte output) and held by packages/core/test/relayCredential.test.ts.

The credential. coturn's time-limited credential format, minted locally from the relay key without contacting the relay:

username   = decimal(floor(now_unix_seconds) + ttl) + ":" + label
credential = base64(HMAC-SHA-1(key = utf8(relay_key), msg = utf8(username)))

The credential is standard base64 with padding. label is chosen by the party minting and is non-empty with no :, the separator between it and the expiry. ttl is whole seconds from 1 to 3600: one hour, the default of the relay's own infra/relay/mint-credential.sh, which is the length of one exchange rather than the life of the key. coturn recomputes the HMAC when the credential is presented and refuses it once the expiry in the username has passed. A value outside these bounds is refused rather than clamped.

HMAC-SHA-1 appears here only because coturn's credential format fixes it; it is not an Alcove security primitive. The credential authorizes use of the relay and nothing else: the exchange's confidentiality and authentication rest on DTLS and the key exchange above, not on it.

What the relay key grants. While the relay key is registered with a relay, holding the exchange's shared secret is equivalent to holding access to that relay: the key is computable from the secret, and the relay, not Alcove, decides which expiries it honors. The 3600-second ceiling above bounds only the credentials Alcove mints; anyone holding the key can mint one with a later expiry. Removing the key from the relay's secrets table is the revocation: coturn then refuses a new allocation under the key, and does not end one already open.

The invitation's relay locator ​

A webrtc connection endpoint MAY hold relay, the inviting party's relay locator: an object with an optional turn list and an optional stun list of urls, an optional websocket url, an optional websocketExchangeId, an optional registrar url, and no other key.

  • Grammar. Each turn url and each stun url holds the grammar of the connection block's own turn url and stun entries, one schema for both (TurnUrlSchema and StunUrlSchema in packages/core/src/config/connection.ts).
  • Bounds. A list present holds 1 to MAX_RELAY_LOCATOR_URLS (8) urls, each at most MAX_RELAY_LOCATOR_URL_LENGTH (1024) UTF-16 code units, and the locator names at least one url.
  • Registrar. registrar is the address of a registrar that enrolls the exchange's relay key without a token (Enrolling at a default relay): one https:// url naming a host and an optional port, the rule of relay_registrar's url, at most MAX_RELAY_LOCATOR_URL_LENGTH UTF-16 code units. A locator that names it names at least one turn url or a websocket url. A locator's registrar enrolls the key of its TURN relay, and of its WebSocket relay where it names no websocketExchangeId: a CLI run enrolls there when the TURN url it allocates on is one the locator names, or when it dials the locator's websocket url under the id derived from the secret (A command-line party). A locator does not say which of its relays its registrar serves, so a CLI run that dials the locator's WebSocket relay enrolls at the registrar even where that relay is the inviter's own and the registrar serves only the TURN relay beside it. Host case, a default port and one trailing / are normalized away when the url is parsed, so a party holds and mints the url as its origin. A url naming a path, a query, a fragment, a user or port 0 is refused, an empty ? or # included, and the refusal does not repeat it.
  • No credential. A username, credential, or any other key on the locator, and a turn entry written as an object rather than a url, is refused when the invitation is decoded and when it is minted -- refused, not stripped.
  • Composition. The inviter composes the locator from relays it uses itself (relayLocatorFromOwnRelay): the CLI from its connection's stun list and the url of each turn entry that sets no username or credential and that its allowed_relays keeps (endpointFromConnection in packages/core/src/config/endpointProducer.ts), the web app from the browser's own relay setting and a build's default relay (Enrolling at a default relay), each TURN url only where the allowed relays that apply keep it (a saved exchange's own, otherwise those set on the review of the invitation it creates), the default relay's following the browser's own and named only where the locator has room for it under MAX_RELAY_LOCATOR_URLS. Where the default relay is in use and the locator names its TURN url or its WebSocket relay url, the web app also names that relay's registrar as registrar; the CLI names none. With the relay switched off, the web app names no registrar, even where the browser's own relay setting names the default relay's TURN url and the locator names that url as one of the setting's (invitationRelayWithDefault in apps/web/src/psi/transport/defaultRelay.ts), so a CLI party accepting that invitation enrolls nowhere (A command-line party). Creating an invitation contacts no registrar. A turn entry with a static credential is left out, since the accepting party cannot authenticate to it. An inviter with no relay of its own, in a build that names no default relay, names none.
  • The WebSocket relay. websocket is the address of a WebSocket relay, in the grammar of the connection block's own websocket_relay (webSocketRelayUrlSchema in packages/core/src/config/connection.ts): one wss:// url naming a host, an optional port and a path, with no user, query or fragment, accepted only as the WHATWG URL parser's href gives it back, the trailing slash of an empty path aside, at most MAX_WEBSOCKET_RELAY_URL_LENGTH (1024) UTF-16 code units. A list in its place is refused. It counts as the one url a locator names.
  • The WebSocket relay an inviter names. The CLI names its connection's websocket_relay (endpointFromConnection). The web app names the browser's own relay setting's WebSocket relay url, or, where that names none, a build's default WebSocket relay (VITE_DEFAULT_RELAY_WEBSOCKET_URL; invitationRelayWithDefault in apps/web/src/psi/transport/defaultRelay.ts). A console names the WebSocket relay set with its coordination server instead, which its run states as websocket_relay (ownRelayInvitationLocator in apps/web/src/jobContract/signalingServer.ts). Each names it only where the allowed relays that apply keep it (webSocketRelayUrlIsAllowed in packages/core/src/config/connection.ts).
  • The WebSocket relay's exchange id. websocketExchangeId is the exchange id the websocket relay holds the exchange's relay key under, where the inviter enrolled the key there under an id of its own rather than the one derived from the secret; a party's hello to that relay names it (The WebSocket relay proof). It follows the grammar of relay_registrar's exchange_id: 1 to 128 of [A-Za-z0-9._-], not starting with - or alcove-verify-, not one or two dots alone, and with no run of 64 hex characters. A refusal does not repeat it. A locator that names it names a websocket url. The CLI names its relay_registrar's exchange_id beside the websocket_relay it names (endpointFromConnection), a console names its registrar's exchange id beside the WebSocket relay set with its coordination server (ownRelayInvitationLocator), and the web app names a saved exchange's registrar's exchange id (relayRegistrar) beside the browser's own WebSocket relay url on a re-invite (composeManagedReinvite in apps/web/src/psi/managed/managedReinvite.ts); none names one otherwise. It is not a credential: it names the key the relay checks a proof under.
  • Compatibility. The field was added to the strict webrtc endpoint without a token version bump, while no decoder was deployed; a decoder predating it refuses an invitation that holds it. registrar was added to the strict locator without a version bump too: a decoder predating it, an earlier web app or CLI build among them, refuses every invitation that names it, as an invitation a build makes with its default relay in use does. websocket was added to the strict locator without a version bump as well, so a decoder predating it refuses every invitation that names it. websocketExchangeId was added the same way, beside websocket only, so a decoder predating it refuses an invitation that names it, and one predating websocket already refuses every invitation that could. A registrar beside a websocket url and no turn url was admitted without a version bump too, so a decoder predating that refuses such a locator. The web app mints one where its default relay is in use and the locator names that relay's WebSocket relay url and no TURN url (Enrolling at a default relay).

Which relay a run uses. The accepting party keeps the locator on its connection as invitation_relay (EXCHANGE_REFERENCE.md), beside its own turn and stun rather than in them. Every run, browser and CLI alike, lists every relay it may use: its TURN urls are its own followed by the invitation's, each listed once, and its STUN urls likewise. A browser run reads its own relay from the browser's relay setting (relayForRun in apps/web/src/psi/transport/ownRelaySetting.ts). It adds a build's default relay's TURN url, last, once the run has enrolled there (Enrolling at a default relay). It lists the default STUN server only where neither the browser's relay setting nor the invitation names a STUN url; otherwise its STUN urls, the setting's and the invitation's, take the default's place, as a CLI run's do (buildIceServers in apps/web/src/psi/transport/rendezvous.ts). An acceptor's network address so goes to the STUN servers the invitation names in place of the default, and an acceptor that reaches none of the STUN servers it lists gathers no server-reflexive candidate, which the default might have given it. A STUN list saved in relay settings before the setting replaced the default replaces it from this version on, and STUN urls in the invitation relay of a saved exchange replace it from this version on too; the web app shows no notice of either change. An acceptor that cannot reach the STUN servers its invitation names gathers a server-reflexive candidate again once its own relay settings list a STUN url it can reach, Google's included, since its own and the invitation's urls are listed together. An acceptor whose web app predates this version still lists Google's server beside the invitation's, so an inviter's own STUN server keeps Google's server out of an exchange only where the acceptor's web app is this version or later. A CLI run reads its own relay from its connection's turn entries and stun list (selectRunRelay in packages/core/src/relayCredential.ts), and a url that an own turn entry and the invitation both name is listed once, as the own entry. A connection with no invitation_relay runs exactly its own lists. A CLI connection's allowed_relays (EXCHANGE_REFERENCE.md), and a browser run's allowed relays, a saved exchange's from its record's connection (managedAllowedRelays in apps/web/src/psi/managed/managedExchangeRecord.ts), an accepted invitation's from the Allowed relays set on its review (AcceptorLaunch in apps/web/src/exchange/useAcceptorExchange.ts), and a created invitation's from the Allowed relays set on its review before it was created (GeneratedInvitation in apps/web/src/psi/invitation.ts), then leave out every TURN url no entry names, matching an entry's host without resolving it and, where the entry names a port, the url's port, 3478 for a turn: url naming none and 5349 for a turns: one (turnUrlIsAllowed in packages/core/src/config/connection.ts). They leave the STUN urls and the partner's candidates alone. A browser run's list includes the build's default relay, which it does not enroll at where its allowed relays leave that relay out. A run whose every TURN url its allowed relays leave out continues without a relay (relayPlanForRun in apps/web/src/psi/transport/runRelayPlan.ts, webRtcDialFrom in apps/cli/src/run/prepare.ts). A CLI run so left warns, naming the setting; where the connection lists no stun url, the warning also names the built-in default STUN server (WERIFT_BUILT_IN_STUN_URI), which learns the host's public address.

Which WebSocket relays the allowed relays keep. An invitation names at most one WebSocket relay url, and the allowed relays keep it by a rule of its own, apart from the TURN urls:

  • The allowed relays. They keep a WebSocket relay url only where an entry names its host, matching without resolving the host and, where the entry names a port, the url's port, 443 for a url naming none (webSocketRelayUrlIsAllowed). A TURN url is kept by its own rule, and a WebSocket relay url is not kept by the TURN rule.
  • The list. A CLI run lists its connection's websocket_relay, then the invitation's websocket url (selectRunRelay). A browser run lists the browser's own relay setting's, then the invitation's, then a build's default WebSocket relay once its enrollment there confirmed (relayPlanForRun). Either lists each url once, less every one its allowed relays leave out, and a browser run leaves out the default relay's url where its enrollment did not confirm, even where another source names it, as it does the default relay's TURN url.
  • The exchange id of each. A run's proof to each relay it lists names an exchange id decided by the url (The WebSocket relay proof). The url the invitation names takes its websocketExchangeId, since the inviter proves to that relay under it. A url of the party's own takes its own registrar's exchange id: the CLI connection's relay_registrar, or a saved exchange's relayRegistrar in the browser. Any other url, and one with no exchange id named, takes the id derived from the secret, the one an enrollment without a token uses.
  • Dialing them. A run dials every WebSocket relay it lists as its rendezvous starts, and moves the exchange onto one only when no WebRTC path opens (WEBRTC_TRANSPORT.md). A run that sets allowed relays states the WebSocket relay urls they keep and leave out beside the TURN urls, and a run whose connection to the partner does not open names those left out.

Stated limits of listing every relay.

  • No preference for a party's own relay. ICE may carry an exchange over the invitation's relay, the project relay a hosted invitation names among them, where the party's own relay would serve, unless the party's allowed relays leave the invitation's relay out. A party's allowed relays keep its own allocations and enrollments on the relays it names, but the partner may still offer candidates relayed through another, and the exchange may then pass through a relay the party did not name.
  • A CLI run gathers from one TURN url and one STUN url. The CLI's WebRTC library takes the first TURN url and the first STUN url of the list it is given, so a CLI run gathers relay candidates from the first TURN url it lists (its own first turn entry, else the invitation's first url, among those its allowed_relays keeps) and server-reflexive candidates from the first STUN url it lists (its own first stun url, else the invitation's first). With the first TURN url left out by allowed_relays, a CLI run allocates on the first url the setting keeps, a host it does not contact with the setting unset. A CLI run whose connection has its own turn entry that its allowed_relays keeps allocates only on the first such entry, which is on the invitation's relay only when that entry names one of the invitation's urls; the exchange may still pass through the invitation's relay by way of the partner's relayed candidates. A CLI run enrolls at the registrar the invitation's relay names only when the url it allocates on is one the invitation names and takes the run's derived credential, or when it dials the invitation's WebSocket relay under the id derived from the secret (A command-line party); a browser partner enrolls there for its own allocation, so an exchange on a hosted invitation is still enrolled by the party that uses that relay. A url both the connection and the invitation name is the same relay to the run only when it is written identically; an own entry spelled differently is a different relay, allocated on without the invitation's enrollment. A browser run uses every server it lists.
  • Reports name more relays. What a run states about the relays it uses, its relay credential notice among them, names the invitation's relay beside the party's own, and a report that no relay candidate was gathered means none was gathered from any relay a browser run lists, or from the one TURN url a CLI run gathers from.

The run credential. A run that lists the invitation's TURN urls configures each of them with one credential, minted when the run starts (mintRunRelayCredential) and, in a CLI run, minted again for each connection attempt after the first while it waits for the partner (WEBRTC_TRANSPORT.md): the relay key derived from the shared secret the run holds, ttl = 3600, and label = alcove. A browser run presents that one credential to every TURN url it lists, its own relay's, the invitation's and the default relay's alike. A CLI run configures each of its own turn entries that sets no username or credential with the same minted credential, and every other entry with its own static pair, whether or not it also lists the invitation's urls; a run holding no shared secret refuses such an entry by its url before anything is dialed. Its WebRTC library contacts only the first TURN url it lists, so a CLI run presents a credential to that url alone (the stated limit). The secret rotates on every successful handshake, so each run's credential derives from a different key. Neither the credential nor the relay key is written anywhere. A CLI run that registers its rotated relay key also holds the pre-rotation shared secret in memory until the process exits, whether or not the registrar confirmed; it signs the registration with the relay key it derives from that secret, and the one thing it writes is a timestamp in the key file recording that the registrar has not yet confirmed the rotated key. A browser managed run that registers holds the pre-rotation secret the same way, in its own copy of the record read inside the run+rotate lock, until the run ends, and writes the same timestamp into the managed record (MANAGED_EXCHANGE_RECORD.md).

The registrar request proof ​

The reference relay's registrar (infra/relay/registrar.py; its contract is infra/relay/README.md, The registrar) takes one of two credentials in the Authorization header. The relay-owner token, under Bearer, enrolls an exchange (POST /exchanges/<exchange-id>) and is the relay operator's recovery route on PUT and DELETE. A registrar whose operator allows it also enrolls on a POST with no Authorization header (Enrolling without a token). A rotation (PUT) or revocation (DELETE) otherwise proves the caller holds the relay key the registrar has registered for the exchange:

proof_key = HKDF-SHA-256(ikm = unhex(relay_key), salt = 0x00 * 32, info = "alcove-relay-registrar-v2:proof-key", 32)
message   = "alcove-relay-registrar-v2:request" LF method LF exchange_id LF hex(SHA-256(body)) LF decimal(ts)
mac       = hex(HMAC-SHA-256(key = proof_key, msg = ascii(message)))
Authorization: Alcove-Relay-Proof ts=<decimal(ts)>,mac=<mac>
  • The key. The HKDF input is the relay key's 32 decoded bytes, not the 64 hex characters coturn keys its HMAC with. The proof key is a further derivation under its own label, used with HMAC-SHA-256 rather than coturn's HMAC-SHA-1 over the characters, so nothing signed for the registrar is a coturn credential; the message also starts with a letter, so it never parses as coturn's <expiry>:<label> username.
  • The message. Five ASCII fields joined by LF (0x0A), with no trailing LF. method is PUT or DELETE. exchange_id is the path segment as sent, within the registrar's id alphabet [A-Za-z0-9._-]. The body hash is the lowercase hex SHA-256 of the raw body bytes as sent, so no JSON is canonicalized; an empty body, a revocation's, hashes the empty string. ts is decimal Unix seconds with no leading zero. No field's alphabet admits LF, so the layout parses one way.
  • The header. ts and the lowercase-hex mac travel in the one Authorization header as ts=<digits>,mac=<64 hex>, never in a second header or the body, so a request holds the token or a proof and never both. The scheme name is matched without regard to case; a malformed value is answered 401.
  • Freshness. The registrar refuses a ts more than 300 s from its own clock with 401 and serverTime, its clock's Unix seconds, so a caller whose clock has drifted can sign again. Within the window nothing records a proof -- no timestamp column, nonce, or seen-set; the compare-and-swap below bounds a replay.
  • Verification. Under the table's write lock, the registrar reads the key the exchange holds and checks the MAC under it, and a rotation replaces that key in the same transaction. A proof made under any other key is refused 409, so of two rotations proven under one key only the first lands, and a replayed rotation is refused once the key has moved on. A PUT naming the key the exchange already holds is a renewal: its body shows possession of that key, so it is accepted with any well-formed proof inside the window, including one made under the key it replaced. That is how the second party to register a rotated key, whose proof is made under the key its partner has already replaced, renews the row rather than failing. The proof's MAC is not verified on that path: the key bytes in the body are the credential, so only a holder of the current key reaches it. A renewal restarts the row's lapse from the request's time and takes maxAgeDays from the body, so a replayed renewal re-applies that same body and restarts the lapse again, and nothing else. A replayed revocation finds no exchange enrolled.
  • What a caller without the key learns. A PUT or DELETE with a well-formed proof inside the window that does not verify is answered 409 with a not-enrolled message for an id the registrar does not hold and a does-not-verify message for one it does, so anyone can learn whether an id in the registrar's id space is enrolled. That is accepted so an operator sees which of the two remedies applies: enroll the exchange, or sign under the key it holds.
  • The label. alcove-relay-registrar-v2:* sits in the domain-separation label space. Its input keying material is the relay key, not the shared secret, so it gives the relay's operator, who holds the relay key, nothing derived from the secret.

Test vectors. Core's relayRegistrarAuthorization (packages/core/src/relayRegistrarProof.ts) and the registrar's verifier are each held to the block below by their own suite (packages/core/test/relayRegistrarProof.test.ts and scripts/relay-exchange-keys.test.mjs), which read it from this section. The first group holds the fields both requests share; the relay key is the one relayCredential.test.ts derives from the secret of bytes 0x00..0x1f. The values were computed by OpenSSL (openssl kdf ... HKDF and openssl dgst -sha256 -mac HMAC), outside either implementation.

text
relay_key     = 1d970dcfd9f445a4c83c4ee3b03845209f891b2931e8b7bb2b41a17dd2249a9e
exchange_id   = exchange-1
ts            = 1767225600
proof_key     = 4044b292b68d07e7d1dcbc69635477d8bfbc8839bad3232949526928bf8551a2

method        = PUT
body          = {"key": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", "maxAgeDays": 30}
body_sha256   = cf48f8f61701c63f7ffc27412b24764d56224256b29f11d5abe4d4400e39eff2
mac           = 9d940ce575ff546598a44d8d811958907d14225d2db6c30e04f77927491a22e3
authorization = Alcove-Relay-Proof ts=1767225600,mac=9d940ce575ff546598a44d8d811958907d14225d2db6c30e04f77927491a22e3

method        = DELETE
body          =
body_sha256   = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
mac           = f62d215cae1ac9c3dfdc844bb0cd4cc5758c362398c7986d39ce3da1a57ebe4b
authorization = Alcove-Relay-Proof ts=1767225600,mac=f62d215cae1ac9c3dfdc844bb0cd4cc5758c362398c7986d39ce3da1a57ebe4b

The WebSocket relay proof ​

A party shows a WebSocket relay that it holds the exchange's relay key with a proof made under a key of its own, derived from the relay key as the registrar proof's key is. The proof is the message and the tag below; how a relay's hello encodes them is specified in WEBSOCKET_RELAY.md, apart from the key lookup below.

proof_key = HKDF-SHA-256(ikm = unhex(relay_key), salt = 0x00 * 32, info = "alcove-relay-websocket-v2:proof-key", 32)
message   = "alcove-relay-websocket-v2:hello" LF role LF decimal(expiry)
tag       = HMAC-SHA-256(key = proof_key, msg = ascii(message))
  • The key. The HKDF input is the relay key's 32 decoded bytes, as for the registrar proof, under a label of its own, and the tag is the whole 32-byte MAC. So a relay's proof is neither a coturn credential nor a registrar proof, and neither of those is a relay's proof: a captured relay hello mints no TURN credential, and a captured TURN credential opens no relay socket. The message starts with a letter, so it never parses as coturn's <expiry>:<label> username.
  • The message. Three ASCII fields joined by LF (0x0A), with no trailing LF. role is the party's rendezvous role, inviter or acceptor (RENDEZVOUS_ROLES in packages/core/src/rendezvousRoles.ts). expiry is decimal Unix seconds with no leading zero, the second from which the relay refuses the proof. No field's alphabet admits LF, so the layout parses one way.
  • The key lookup. The hello MUST name the key the proof is under, by the exchange id (exchange_id, the <exchange_id> path segment of the enrollment request; exchangeId in packages/core/src/relayRegistrarClient.ts) under which the registrar stored the exchange's relay key, so the relay derives and checks one proof key, not one per enrolled exchange. The exchange id is not part of the signed message, since a tag verifies under one key only.
  • The expiry bound. A party sets expiry to the current Unix second plus whole seconds from 1 to 300 (WEBSOCKET_RELAY_PROOF_MAX_TTL_SECONDS in packages/core/src/websocketRelayProof.ts), the registrar's freshness window. A value outside these bounds is refused rather than clamped. As with the TURN credential, the bound covers only the proofs Alcove makes: anyone holding the relay key can make one with a later expiry, and a check does not refuse it.
  • What a check refuses. The relay reads a presented message and tag (parseWebSocketRelayProof) and checks them under the proof key of a relay key it holds (checkWebSocketRelayProof). It refuses a message over 128 bytes, not UTF-8 or not exactly in the layout above, or a tag that is not 32 bytes, as malformed. It refuses a role other than the one the presenting party claims, and an expiry at or before its own current Unix second. It refuses a tag other than the one the proof key makes over the role and the expiry, compared in constant time. A party whose clock runs 300 seconds or more behind the relay's cannot present a proof the relay accepts, and one less than that behind is accepted only while the remaining margin has not elapsed. The relay answers a stale proof with its current Unix second, so a party can sign again (WEBSOCKET_RELAY.md).
  • Replay. A captured proof can be replayed until its expiry, at most 300 seconds, which can open a relay socket in that role and take the role's slot first; it exposes no data, since the key exchange still needs the shared secret. The relay's hello does not bind the proof to its connection (WEBSOCKET_RELAY.md).
  • The label. alcove-relay-websocket-v2:* sits in the domain-separation label space. Its input keying material is the relay key, not the shared secret, so it gives the relay's operator, who holds the relay key, nothing derived from the secret.

Test vectors. For the secret of bytes 0x00..0x1f, whose relay key is the relay_key of the registrar proof's vectors above, and expiry = 1767225900, the proof key and each role's tag are below. They are generated with Node's crypto by packages/core/test/vectors/generate-relay-proof-vectors.mjs into relay-proof-vectors.json beside it, held to that generator by npm run check:vectors, and core is held to the file by packages/core/test/websocketRelayProof.test.ts. The proof key and both tags were also computed by OpenSSL (openssl kdf ... HKDF and openssl dgst -sha256 -mac HMAC).

text
proof_key    = 8e7bace691bc317879934f5d621ab3efd53de90159a314b45ecd3f8e5358c637
inviter_tag  = 89fd30c0579df6cda9001ae31346888fecbd72cd719fb3817387bd6f92a4b758
acceptor_tag = 862297ff4867555100900de9e235ec7e99128e4f1e800350d4a5afe8bc42c5a6

Enrolling without a token ​

A registrar whose operator turns it on (infra/relay/README.md, Enrollment without a token) also enrolls an exchange on a POST /exchanges/<exchange_id> that holds no Authorization header, so a party holding no relay-owner token can enroll the relay key it relays under. The token and proof routes above are unchanged by it.

  • The request. The body is {"key": <relay key>, "maxAgeDays": null}. A body naming any other maxAgeDays is answered 400 and nothing is written: the caller does not choose the lapse.
  • The lapse. The registrar fixes it at 4200 s (70 minutes) after the enrollment, the web application's 10-minute wait for its partner followed by one credential's ttl of 3600 s, and states it in the answer: maxAgeDays is null and lapsesAt is that time. A PUT under a proof on such a row, a renewal or a rotation, keeps that lapse whatever maxAgeDays its body names, so a row enrolled without a token is never extended past it; the key still authenticates until the next sweep removes the lapsed row, up to the sweep's interval later (infra/relay/README.md); only the relay-owner token's PUT, the operator's recovery route, replaces it.
  • The same key, and another. An enrollment naming the key the id already holds is answered 200 with the registration as held, its lapse unchanged, so either party, each deriving the key from the same secret, may enroll it. An enrollment naming another key is answered 409, as for the token's, so a caller cannot replace or revoke an exchange whose key it does not hold.
  • The rate limit. The registrar limits enrollments without a token from each client address within a window, and answers one past the limit 429 with Retry-After, the seconds until the window ends, writing nothing. Its values are the registrar's (infra/relay/README.md, Enrollment without a token).
  • The cap on each client. The registrar holds at most a set number of rows enrolled without a token that have not lapsed for each client address, and answers an enrollment of an id not yet held past it 429 with Retry-After, the seconds until the first of that address's rows lapses, writing nothing; an enrollment naming the key an id already holds is still answered 200. The cap is a small fraction of the ceiling below, so one address cannot fill the ceiling alone. Its value, and the prefix an IPv6 address counts under, are the registrar's (infra/relay/README.md, Enrollment without a token).
  • The ceiling. The registrar holds at most a set number of rows enrolled without a token that have not lapsed, and answers an enrollment of an id not yet held past it 503 with Retry-After, the seconds until the first of those rows lapses, writing nothing; an enrollment naming the key an id already holds is still answered 200. Its value, derived from what each row adds to the relay's authentication of every credential, is the registrar's (infra/relay/README.md, Enrollment without a token). Many addresses, each at its cap, can still reach it. At the ceiling, a browser run that could not enroll dials without the default relay, with its other relays or by STUN alone, until rows lapse.
  • The exchange id. Any id in the registrar's id alphabet. Since any caller may enroll, a third party that learns or guesses an id before the exchange's own party enrolls it can hold it under another key, and that party's enrollment is then answered 409; an id derived from a secret only the parties hold leaves nothing to guess.

Registering the rotated relay key ​

The CLI and the web application's managed re-run keep the relay key a registrar holds current by registering the key derived from each rotated secret. Both use one client, packages/core/src/relayRegistrarClient.ts; the CLI's side is apps/cli/src/relayKeyRotation.ts and the browser's apps/web/src/psi/managed/managedRelayRegistration.ts. The console registers nothing. A browser run of a build that names a default relay also enrolls at that relay, one-shot and managed alike, and so does a CLI run that allocates on an invitation relay naming a registrar with the credential it derives, or dials that relay's WebSocket relay under the derived id (Enrolling at a default relay).

  • Who registers. The party that supplies the relay. A CLI run registers only when its connection names a relay_registrar and one of this party's own turn entries has its credential minted from the secret. A browser managed run registers only when its record names the registrar it was enrolled at (relayRegistrar) and the browser's own relay setting names at least one TURN url, every one of which is minted from the secret. The relay the invitation names does not change that, since the run lists its own relay beside it. A run registers its rotated key at its own relay's registrar whatever its allowed relays keep.
  • When. After a run whose key exchange rotated the secret: once its outputs are written, before the terminal event, or after the failure of a run that failed past the rotation. A browser run registers before it releases the run+rotate lock.
  • A browser re-invite. A re-invite of a record that names a registrar registers the key derived from the fresh secret the same way, signed under the key of the secret it replaced, after the write that stores the fresh secret and before it releases the run+rotate lock. A registration it does not confirm cannot be retried by a run, since no key the browser holds can sign it: the record states that (MANAGED_EXCHANGE_RECORD.md), and the remedy is owner-token re-enrollment.
  • The request. PUT /exchanges/<exchange_id> with the body {"key": <relay key of the rotated secret>, "maxAgeDays": <authentication.token_max_age_days, or null>} and a proof made under the relay key of the pre-rotation secret. The body always holds maxAgeDays.
  • Retries. Each request has 15 s. No answer, a 408, 429, or 5xx is tried again after 2 s and after 5 s, three attempts in all. A 401 holding serverTime is signed again once at the registrar's time. Any other 401, and a 409, is final.
  • What confirms. A 2xx whose body is the registrar's answer (infra/relay/README.md): a JSON object of at most 4096 bytes stating maxAgeDays, a whole number or null, and lapsesAt, a UTC timestamp or null. Any other 2xx -- a body over that bound, not JSON, not an object, or missing either field -- confirms nothing and is tried again as an unanswered request is; an enrollment answered so stores no registrar. The removal from a registrar's error text that a command-line party's enrollment states applies to the text shown for a registration here too, and a very short relay_registrar.exchange_id is replaced wherever its characters occur in the registrar's text.
  • A cancelled browser run. The run's cancel ends the retries of the registration after the exchange but reaches no request: the attempt under way, or the next one when the cancel lands between attempts, runs to its 15 s and is the last. The pre-rotation secret that signs it is held nowhere else, so a registration abandoned there would leave the registrar holding a key only the relay-owner token can replace. The retry before the next run dials stops at the cancel.
  • No token. No request on this path holds the relay-owner token, and no refusal leads to one. The token is sent only at enrollment, which asks for it: alcove enroll-relay, or the Relay registration section of a saved exchange's page in the browser. Either sends a POST to enroll, or a PUT as the operator's recovery (--replace-relay-key, or the browser's replace choice). The browser holds the token in the form's state from entry until the request settles, and writes it to no storage.
  • The pending record. The rotation write that stores the rotated secret also stores relayRegistrationPendingSince: in the key file of a run that registers (EXCHANGE_FILE.md), or in a managed record that names a registrar, whether or not the run registers (MANAGED_EXCHANGE_RECORD.md). A confirmed registration removes it. A failure never rolls the secret back.
  • The retry before the next run dials. A run whose key file or record holds the pending registration registers the current key again before any contact with its partner, signed under that same key; a browser record whose pending registration a re-invite left sends nothing and stops the run with the re-enrollment step. The pre-rotation secret left with the run that rotated, so this succeeds only when the registrar already holds the current key, as when the rotation landed and its answer was lost. A refusal ends the run with the re-enrollment step, the shared secret unchanged: exit 77 on the command line, and in the browser a stopped run that records no lastRun. An unavailable registrar ends it the same way, with exit 69 on the command line.

Enrolling at a default relay ​

A web application build may name a default relay: a TURN url and its registrar's address (VITE_DEFAULT_RELAY_TURN_URL, VITE_DEFAULT_RELAY_REGISTRAR_URL; DEPLOYMENT.md). The build offers the relay switched off until the browser's user turns it on, or turns it on in every browser run (VITE_DEFAULT_RELAY_MODE, opt-in or on). Switched off, a run reads the relay's urls from one source, the invitation it accepted where that names the relay's TURN url and otherwise the browser's own relay setting, and uses each of them only where that source names it: the TURN url, and the enrollment below, only where the source names the TURN url, and the WebSocket relay url only where the source names the WebSocket relay url (defaultRelayForRun in apps/web/src/psi/transport/defaultRelay.ts). A source names a url only where it holds it exactly as the build names it: one written differently for the same server, in another letter case, with its default port written out, or without its ?transport= parameter, is not the default relay's, so a run listing it from the browser's own relay setting enrolls nowhere and the review shows no relay notice for it, though the run still contacts that server. Each browser run of such a build that uses the relay enrolls its relay key there right before it connects, and lists the relay once enrolled; the client is enrollRelayKeyWithoutToken in packages/core/src/relayRegistrarClient.ts, and the browser's side apps/web/src/psi/transport/defaultRelay.ts. The build may also name that relay's WebSocket relay url (VITE_DEFAULT_RELAY_WEBSOCKET_URL), whose relay reads the keys the registrar enrolls. A CLI run accepting an invitation such a build made enrolls there too when it allocates on that relay with the credential it derives, or dials its WebSocket relay url under the id derived from the secret (A command-line party, below).

  • Who and when. Every browser run that uses the relay, inviter or acceptor, one-shot or managed, right before it contacts the coordination server; a managed run enrolls after its retry of a pending registration. Each party derives the same key and id from the run's current secret, so the second enrollment repeats the first, and a later run on the same secret repeats it again. A managed run's rotated secret derives a fresh id, so it enrolls anew and renews nothing. Where the allowed relays that apply (a saved exchange's own, otherwise those set on the review of the invitation accepted or created) leave out the default relay's TURN url, the browser does not name that TURN url in an invitation it creates. Where they keep the relay's WebSocket relay url, a run using the relay still enrolls there for that relay alone, and an invitation it creates that names that url names the registrar beside it; where they keep neither url, it neither enrolls there nor names the registrar. Creating an invitation, a one-shot invitation or a managed re-invite, enrolls nothing, and an invitation created with the relay in use names it whatever its registrar would answer (The invitation's relay locator). One created with the relay switched off names no registrar, and names the relay's TURN url or WebSocket relay url only where the browser's own relay setting names that url, as one of the setting's.
  • How long a dial waits. One attempt, with no retry, for at most 3 seconds (DEFAULT_RELAY_DIAL_WAIT_MS in apps/web/src/psi/transport/defaultRelay.ts, an arbitrary working value); a run cancelled meanwhile stops waiting.
  • The request. POST /exchanges/<exchange_id>, the id derived from the secret, with no Authorization header and the body {"key": <relay key>, "maxAgeDays": null}. The registrar fixes the lapse and states it in its answer's lapsesAt.
  • What confirms. As for a rotated key's registration: a 2xx stating maxAgeDays and lapsesAt, the answer to an enrollment naming the key the id already holds included. A refusal, an unreachable registrar, and no answer within the wait confirm nothing. A 409 means the id is held under another key.
  • What the run lists. Its ICE servers hold the default STUN server only where neither the browser's relay setting nor the invitation names a STUN url, and those STUN urls in its place otherwise (Which relay a run uses); the default relay adds no STUN url. A confirmed enrollment adds the default relay's TURN url, where the allowed relays keep it, after the TURN urls of the browser's own relay and the invitation's, under the one credential minted from the secret. An unconfirmed one leaves it out, even where the invitation or the browser's own relay setting names it: the run says once, as the enrollment fails, that the relay could not be set up, and connects with its other relays. A confirmed enrollment also lists the default relay's WebSocket relay url, where the build names one, the run uses it and the allowed relays keep it, after the browser's own and the invitation's (Which WebSocket relays the allowed relays keep), and an unconfirmed one leaves that url out too. A run with the relay switched off where neither the invitation nor the browser's own relay setting names the TURN url enrolls nowhere, and lists the WebSocket relay url, where either names it, as that source's.
  • Managed runs. The registration of a managed run's own relay, above, is unaffected by it.
  • The key the default relay holds. The key enrolled is the exchange's relay key, so the default relay's operator holds a key that the exchange's other relays and registrar also accept, until the secret rotates. No separate key is derived for the default relay.

A command-line party. A CLI party learns the registrar from the invitation: an invitation such a build makes with the relay in use names it as its relay locator's registrar (The invitation's relay locator), and the accepting party keeps it in invitation_relay. The CLI's side is apps/cli/src/invitationRelayEnrollment.ts, over the same client.

  • Who and when. A CLI run whose invitation_relay names a registrar and whose first TURN url, the one it allocates on (the stated limit), is one the invitation names and takes the run's derived credential: the connection has no turn entry of its own that its allowed_relays keeps, or the first it keeps names that url and sets no username or credential. A run that lists the invitation's websocket url, where the invitation names no websocketExchangeId, enrolls there too, since it dials that relay under the derived id (runEnrollsAtInvitationRegistrar in packages/core/src/relayCredential.ts). It enrolls once per run: after its retry of a pending registration and before its first connection attempt. The attempts after the first while it waits for the partner (WEBRTC_TRANSPORT.md) enroll nothing, since they present the same key.
  • A run that does not enroll. A CLI run whose first TURN url is one the invitation does not name, or is its own turn entry setting a username and credential, and that does not dial the invitation's WebSocket relay under the derived id, contacts no registrar the invitation names, and states on the log that it did not, naming the url it allocates on. One whose allowed_relays leaves out every TURN url and the WebSocket relay url the invitation names states that as the reason instead, and one that allocates on no TURN url while it proves to the invitation's WebSocket relay under the invitation's websocketExchangeId states that. A browser partner enrolls there for its own allocation, so an exchange on a hosted invitation is still enrolled by the party that uses that relay. An invitation a browser created with the default relay switched off names no registrar, even where it names that relay's TURN url because the browser's own relay setting names it (Composition), so a CLI run accepting it that allocates on that url enrolls nowhere and states nothing about it. The relay then takes that allocation only where the browser inviter's own run enrolled the key there, which it does before it contacts the coordination server, and refuses it where that enrollment failed.
  • The request and its wait. The request above, one attempt with no retry, waited on for at most 3 seconds (INVITATION_RELAY_ENROLLMENT_WAIT_MS in apps/cli/src/invitationRelayEnrollment.ts, an arbitrary working value, the browser's own); a run interrupted meanwhile stops waiting.
  • What the run reports. A confirmed enrollment, the answer to a same-key repeat included, is stated on the log. A refusal (401 or 409), a 429, a registrar the run cannot reach, and no answer within the wait are each stated as a warning on the terminal and on the event stream under the source invitationRelayEnrollment (CLI_EVENTS.md), and the run goes on. A registrar's error text is shown with the exchange id and the relay key replaced by a fixed marker only where either appears in one of five forms (as written, URL-encoded, JSON-escaped, and base64 with and without padding), each matched by letter case, so the same value in another encoding (upper-case hex, for one) or cut short is shown as the registrar wrote it.
  • Any https host. The registrar an invitation names may be any https host, a loopback or private address on the accepting machine's network included; the run sends it the fixed request above, with no credential and following no redirect, after the operator saw the address on the acceptance review and accepted, and nothing checks the address.
  • What the run lists. The invitation's TURN urls after its own relay's (Which relay a run uses), whether or not it enrolled there or its enrollment confirmed, since the partner derives the same key and id and may have enrolled them. A relay_registrar of its own is registered as above whatever the enrollment's outcome.
  • The cap on each client. The registrar holds at most 10 rows enrolled without a token for each client address (infra/relay/README.md), so a host running more exchanges at once than that can be refused. The refusal is a 429, reported as above.

Testing the browser's relays ​

The web application's Relay server page tests whether this browser reaches each relay a browser run lists (apps/web/src/psi/transport/relayTest.ts). It tests reachability only: whether each relay answers this browser over each transport, not whether a relayed path between the two parties passes data.

  • When. Only when the user starts it. Before it contacts anything, the page lists each url it will contact, with its source and the transport it names; the info button on the test's heading explains what the test checks and that whoever runs each relay learns this browser's network address. Loading the page and saving a setting contact no relay. A browser without RTCPeerConnection is offered no test, and the page states that this browser cannot run it. While the test runs, the page announces how many relays it tests and the bound.
  • What it contacts. The TURN urls of this browser's saved relay setting, then the default relay's TURN url where this browser uses that relay, each once, in the order a run lists them (Which relay a run uses). No allowed relays leave any out: each exchange sets its own, so none is known on that page. A setting this build cannot read adds nothing, as for a run. An edit not yet saved is not tested, and the page says so while the TURN field differs from the saved setting. The invitation's relay is not known on that page, so it is not listed. No STUN url, no registrar and no coordination server is contacted.
  • The default relay. A run lists the default relay only once its enrollment at the relay's registrar confirms (Enrolling at a default relay). The test asks the relay without that enrollment and contacts no registrar, so it enrolls nothing and uses none of the registrar's enrollments for this client address.
  • The request. Each url on a peer connection of its own, with that url as its only ICE server, the relay transport policy, a data channel and a local offer. It presents a placeholder credential: the username alcove-relay-test and 16 random bytes in hex, drawn afresh for each url. No credential is derived from a shared secret, and a relay holding only exchange keys refuses it.
  • The bound. Each url is waited on for at most 30 seconds (RELAY_TEST_BOUND_MS, an arbitrary working value, the CLI relay check's own). Its peer connection is closed when its result is known, when gathering ends, at the bound, or when the user leaves the page.
  • The outcomes. One per url and transport: the transport the url names (turns: is TLS, turn: with transport=tcp is TCP, any other turn: url UDP), and any other transport the browser reports an error for on that url.
    • Answered. The browser reported a 401 for it, the relay refusing the placeholder credential, or gathered a relay candidate over it.
    • Refused. The browser reported any other STUN error code (300 to 699), the relay answering with a reason other than the credential.
    • No answer. The browser reported an error with any other code or with none, such as error 701, or reported nothing before the bound expired or gathering ended.
    • Not tested. The browser refused the url when the peer connection was built, with a SyntaxError or NotSupportedError, or failed while setting up that url's test.
  • The wording. Each result is written as a test result over its transport, never as an exchange failure. An answer and a refusal each state that this browser reaches the relay over that transport, and the explanation behind the info button on the test's heading states that the test does not show that the relay passes an exchange's traffic. A failure states its reason, the browser's error code and text or the bound, and a remedy. For no answer it is the remedy an exchange's relay failure gives (DEPLOYMENT.md), to check the relay's address and that this network allows connections to it; for a refusal, to check the relay's settings for that error or ask whoever runs it. Either ends with running the test again. No answer when gathering ended adds that a relay that refused the credential shows the same when the browser does not report the refusal. A url whose test could not be set up, or a test that failed as a whole, asks the user to reload the page and run it again. The browser's error text is escaped for display and bounded to 200 characters.

Stated limits of the test.

  • The 401 is unconfirmed. That Chromium reports a 401 for a refused credential before it gives up on the transport is to be confirmed on a real browser run against a TURN server. The unit tests mock the peer connection, and the browser suite drives real Chromium only against a relay whose name does not resolve, which Chromium reports as error 701. Where Chromium reports no 401, a relay that answered shows as no answer.
  • An answer is not a working relay. It does not show that the relay holds the exchange's key, or that a relayed path passes data.

Post-linkage steps ​

Third-party-verifiable proof of a data flow ​

Implemented (certificate mode, CLI): The signing identity that certificate-backed receipts rest on -- key generation, the self-signed certificate, fingerprint pinning, and the identity binding -- is implemented and specified under Signing identity and certificate pinning below. The signing/exchange step -- both parties signing the same canonical receipt content and swapping signatures over the live channel to produce one signed receipt -- is implemented for the CLI's authenticated exchanges, file-sync and WebRTC alike, when a signing block with mode: certificate is configured; see The signed-receipt step below and, for the signed byte layout and the signed receipt format, EXCHANGE_RECORD.md. Verifying a stored signed receipt is implemented too, in @alcove/core and behind both alcove verify-receipt and the web app's "Verify a receipt" page; see Verifying a stored signed receipt below. The session-derived MAC mode is not implemented; only the certificate mode is.

A receipt is produced at the conclusion of a successful exchange, after the result - and any payload columns - already exist on both sides. The receiver learns the intersection during the PSI rounds themselves: the association map is computed and shared as a step of the matching algorithm, and payload columns are transmitted immediately afterward. By the time a receipt could be assembled both parties already hold the result, so the receipt is a post-exchange audit artifact recording that the exchange occurred. It does not gate, withhold, or otherwise condition delivery of the result; the result is already in hand on both sides.

Each receipt's signed content is a hash of the exchange agreement, a session-keyed MAC of the payload that flowed in each direction, and a per-exchange replay binder, and each party's signing certificate states its identity (the self-asserted linkage_terms.identity field); the timestamp and the result size are in the self-attested exchange record rather than the receipt. The identity field is optional in the terms, and a receipt is the one artifact an unnamed party cannot take part in: the identity is what the certificate must authorize, so a party with none has nothing to be authorized against. A run with signing configured and either side unnamed is refused rather than producing an artifact whose identity binding could not be checked, and it is refused at terms agreement -- the first point at which both sides' identities are in hand -- so no linkage key and no payload row moves under a run that cannot finish. The hash of the agreement and the signature over the receipt are computed over a canonical byte serialization (RFC 8785, JSON Canonicalization Scheme) so that both parties - and an independent third party using a different implementation - derive byte-identical input and the hash and signature verify across implementations. That serialization is specified in CANONICAL_ENCODING.md. The two parties then swap signatures, and each retains one signed receipt holding both. A receipt can be signed in one of two modes, which differ sharply in what they prove:

  • Session-derived (symmetric). The signature is a MAC under the shared key-exchange session key. Both parties hold the same key, so either can forge a MAC the other could have produced. A session-derived receipt is therefore not evidence against a denying counterparty and is not verifiable by any third party; it yields only a tamper-evident local record - a party can later detect that its own stored copy was altered, but cannot use it to hold the partner to the exchange. This mode does not provide non-repudiation.
  • Certificate-backed (asymmetric). The signature is made with a party's long-lived signing private key, whose self-signed certificate the partner has pinned by fingerprint out-of-band at setup. Only the holder of that key could have produced the signature, and the pin ties the key to the partner, so the signature is verifiable by anyone who holds (or is given) the pinned fingerprint -- it does not depend on a certificate-authority chain. This is the only mode that provides non-repudiation, and the only mode in which the identity field has evidentiary weight: the certificate binds the asserted identity to the pinned key, whereas a session-derived receipt leaves it an unverified label. The signing identity, certificate format, fingerprint pinning, and the identity-binding rule are specified under Signing identity and certificate pinning below.

Because the receipt is collected after the result already exists on both sides, the signature swap is best-effort evidence, not a fairness or atomicity guarantee. Two limitations follow directly. First, the swap is not a fair exchange: a party may receive the partner's signature and then decline to send its own, and no protocol step can compel the second signature once the first has been handed over. Second, aborting does not undo the data exchange: because both parties already hold the result, terminating the program and restarting does not roll the data back. An aborting party can capture the partner's signature, withhold its own, and still keep the intersection. Restarting the exchange is not a remedy for data already transferred; it only repeats the work.

Even under the certificate-backed mode, a receipt proves narrowly. It evidences only that a specific data set flowed between these two parties -- the agreement and result it commits to, produced third-party-verifiable in that mode. It does not prove where either party's data came from (provenance), that the data is accurate, or that either party satisfied a legal or contractual obligation; those remain matters outside what the receipt can attest.

Retention, access controls, and log integrity beyond the receipt remain each party's internal compliance obligation.

Signing identity and certificate pinning ​

Error surface and exit codes. A local signing failure -- an identity file that is malformed, unreadable, holds a key that is not a valid P-256 point, or whose private key is not its certificate's -- is a usage error, so the CLI exits 64 (EX_USAGE): the CLI's identity-file loader raises UsageError for each, and core's parseSigningIdentity raises SigningError, a UsageError subclass, for the key and certificate checks. These are genuine usage and configuration errors. Partner-trust failures (a mismatched pinned fingerprint, a partner certificate whose self-signature does not verify, a certificate that does not authorize the agreed-terms identity) are security events and are not reported that way. The signed-receipt step wraps them in ReceiptVerificationError, a security-kind ConnectionError, which the CLI reports as a partner refusal (exit 76) rather than as an exit code indistinguishable from a malformed flag.

Certificate-backed receipts rest on a long-lived signing identity: an asymmetric keypair and a self-signed certificate that states the party's identity. Each party generates one (alcove fingerprint, configured by the signing block), persists it, and reuses it across every exchange and every partner -- it is not a per-exchange credential. It is therefore a distinct credential from the shared secret in the key file, which rotates every exchange: the signing key must be stable for its whole life so that a fingerprint a partner pinned once keeps verifying later receipts. Regenerating the identity is a deliberate act (alcove fingerprint --force) that produces a new key and a new fingerprint and invalidates any fingerprint a partner has already pinned.

The trust model is pinned self-signed: there is no certificate-authority chain and no revocation. A self-signed certificate proves only possession of its own key, not who the holder is; the binding to a real partner is the pinned signing.partner_fingerprint -- an unpadded base64url SHA-256 over the domain-separated canonical encoding (RFC 8785) of the certificate body. The fingerprint is not secret (it is a hash of a public certificate), so the channel it arrives over must be authentic, not confidential. This is the SSH-host-key / certificate-pinning model, not the web-PKI model. The rationale for choosing it over X.509 + CA for v1, and the point by which an authority-backed mode could layer on later, are in SECURITY_DESIGN.md.

A pin has one of two provenances, and they are not equally strong. An operator who obtains the partner's fingerprint out of band -- the partner runs alcove fingerprint and reads the value over a channel the operator trusts -- writes it into signing.partner_fingerprint before the first run, and the anchor is that channel. An operator who does not gets the pin from the exchange itself, and its anchor is the channel the invitation and shared secret travelled and nothing else. What that costs, and what a pre-pin restores, are stated in SECURITY_DESIGN.md.

Certificate presentation and first-contact pinning. Each party that will sign a receipt presents its own certificate on the terms exchange envelope, in a certificate field beside linkageTerms. That is the first authenticated step of the exchange and it precedes the bootstrap frame, every linkage key, and every payload row, so a refusal there costs no disclosure. The field is partner-controlled input and is parsed through the same bounded wire certificate schema the receipt frame uses, before any fingerprint or signature work reads it (PARSED_INPUT_BOUNDS.md). A party that will not sign -- one holding no signing identity, or no session key, and so no authenticated setup step at all -- presents no certificate field and pins nothing.

On receiving the partner's terms, a party that will sign resolves the fingerprint the rest of the run verifies against:

  • A pin is on file. The presented certificate's fingerprint MUST match it, compared in constant time over the decoded digest bytes. A run whose partner presents a different certificate is refused here.
  • No pin is on file. This is the first authenticated contact. The presented certificate MUST verify under its own key and MUST authorize the identity the partner used in the agreed terms (partnerTerms.identity, the same binding the swap holds it to), and its fingerprint is then adopted as this partnership's pin, reported to the operator, and recorded. A certificate failing either check would be pinned as a value no later run could satisfy, which is why both checks precede the adoption rather than waiting for the swap.
  • The partner presented no certificate, or one the wire format does not admit. Each is refused: a party that signs receipts has no run to complete against a partner that produces none, and a certificate field that fails the bounded parse leaves nothing to pin.

Every one of those refusals is a security-kind ReceiptVerificationError (CLI exit 76) rather than an operator configuration error: the disagreeing or missing value is the partner's. Each sends the partner a best-effort abort first, since the refusal is one-sided and a peer deriving none of its own would otherwise wait out its inactivity budget. No refusal changes what is on file: a divergent certificate leaves signing.partner_fingerprint exactly as it stands, and re-pinning is a deliberate edit, as changing a host-key pin is.

The value the resolution returns -- the pin on file, or the fingerprint just adopted -- is the one the signature swap verifies the presented certificate against, so one value governs both points. A partner that presents one certificate at the terms exchange and another at the swap fails there.

The certificate is a small canonical-JSON document rather than an X.509 structure, so the bytes that are signed and fingerprinted reuse the project's single canonicalization primitive (the same one the agreed-terms hash and the record commitments use) and reproduce across implementations; that serialization is specified in CANONICAL_ENCODING.md.

Certificate body. The body has a format version (alcove-signing-cert/v3 -- the single recognized literal, rejected rather than migrated if unrecognized, and also the format discriminant a future authority-backed representation would extend), the signature algorithm (ecdsa-p256-sha256), the party's identity, and the public key as a JWK. The on-disk signing identity -- the private key plus the certificate it issued -- has its own discriminant, alcove-signing-identity/v3. Both move together with the signature scheme and the public-key representation, so a document written under either earlier version is refused outright rather than reinterpreted. The identity a presented certificate states is bounded in length when the certificate is read but is not held to the terms document's control-character rule; display escaping at the surface that shows it governs how it renders.

Public key. An EC JWK -- the key format of RFC 7517, whose elliptic-curve members are specified in RFC 7518 section 6.2 -- with kty EC, crv P-256 (section 6.2.1.1), and the two affine coordinates x and y (sections 6.2.1.2 and 6.2.1.3). Each coordinate is exactly 32 bytes in canonical unpadded base64url (43 characters), left-padded to the field size. The fixed length is the RFC's own requirement -- each coordinate octet string "MUST be the full size of a coordinate for the curve" -- and unpadded is what base64url means in JOSE, a term RFC 7518 section 2 takes from the JWS specification (RFC 7515) rather than defining. The encoding is pinned, not merely preferred: the fingerprint a partner pins is a hash over the body's literal JWK strings, so a second encoding of one point would be a second fingerprint for one key. A coordinate with a leading zero byte (33 bytes) and a coordinate written with base64 padding are each rejected, though crypto.subtle.importKey decodes both to the same point. Below that pin, the load rejects a point that is not on the curve, a coordinate at or above the field prime, and the identity element -- which, P-256 having cofactor 1, is the whole of the degenerate case, so there is no small-order subgroup to reject separately.

The domain-separated bytes. Both operations below canonicalize the same two-member object, { domain, certificate }, and differ only in the domain value: alcove-signing-cert-signature/v2 for the signature and alcove-signing-cert-fingerprint/v2 for the fingerprint. certificate holds the body's four members -- version, algorithm, identity, and publicKey, itself holding kty, crv, x, and y -- and nothing else; the encoding sorts the members, so what is normative is the member set and each value, not a written order. The two labels stay at v2 while the body's own version moves, because that version is inside the bytes both labels cover: an alcove-signing-cert/v1 body and an alcove-signing-cert/v3 body are separated by the bytes themselves without a label bump. Those two statements are what lets a partner recompute a pinned fingerprint without this implementation.

Signature. ECDSA over P-256 with SHA-256, over those bytes under the signature domain and verified under the body's own public key, encoded as the fixed-length 64-byte raw r || s of IEEE Std 1363-2000, unpadded base64url. That standard's own signature output is a pair of integers; the octet-string form cited here is its informative Annex E.3.1, which converts each of the pair to an octet string of the group order's length -- leading zeros kept -- and concatenates them, and which offers the DER structure beside it as the other option. So the citation names a format, not a requirement, and the requirement is this document's. The encoding is pinned to that form: a signature field that decodes to any other length is rejected on load with a message naming the length seen, and a DER SEQUENCE of the same two field elements is refused on its length rather than decoded. crypto.subtle emits and accepts only this encoding, so the DER latitude that makes ECDSA a canonicalization hazard elsewhere does not arise through this call surface.

Fingerprint. A SHA-256 over the body under the distinct fingerprint domain above, so a signature and a fingerprint can never be confused for one another. The whole 32-byte digest is encoded as unpadded base64url (43 characters), the form a pin is written and stored in; the match itself runs over the decoded digest bytes. It covers the body and never the signature, which is what keeps it a known answer for a given key and identity: ECDSA signing is randomized, so two certificates over one body have different signatures and the same fingerprint.

Properties of the scheme. ECDSA over P-256 is chosen because it is on the approved-algorithm list of the FIPS 140-3 certificate this project targets and is reachable through crypto.subtle, so where a validated module is configured it performs the operation. Three properties an EdDSA scheme would supply are not available and are not relied on anywhere. Signing is randomized (WebCrypto exposes no RFC 6979 deterministic mode), so a signature is not a known answer and the checked-in vectors are verify-only. Signatures are malleable in s: a holder can negate it and produce a different 64-byte signature that still verifies. That is why nothing in this project hashes, commits to, dedupes, or identifies by a signature, and why EXCHANGE_RECORD.md states the two parties' artifacts agree by copying rather than by re-derivation. And key generation is unseedable, so a reproducible identity is built from a fixed private-key JWK rather than a seed. The single canonical signature encoding is retained by the pin above.

Module boundary. Both signing operations -- this certificate self-signature and the receipt signature specified below -- run through crypto.subtle, so key generation, signing, and verification are platform calls that a validated module configured beneath the platform performs. ECDSA is on the approved-algorithm list of the FIPS 140-3 certificate this project targets; a claim naming the curve and hash is written by reading that certificate's tested parameter set at the time the claim is made. Two limits stand regardless. The browser build of @alcove/core has the same signing path with no module beneath it at all. And the bytes being signed are an application composition that no certificate attests: the canonical encoding, the domain separation between the signature and fingerprint labels, and the per-signer binding below. What a scoped claim may and may not say is recorded in receipt-signing-fips-boundary.md.

No per-runtime adapter is needed for either operation. ECDSA over P-256 is universally available in browser WebCrypto and apps/web runs the same core code, so the web app follows the CLI by rebuild alone; the browser cross-implementation suite is what holds that.

Two checks gate accepting a certificate-backed receipt, and both must pass before the receipt is accepted:

  • Pinning. A presented partner certificate is trusted only if its self-signature verifies and its fingerprint matches the configured signing.partner_fingerprint. A certificate presented when no fingerprint is pinned, one whose self-signature does not verify, and one whose fingerprint does not match the pin are each rejected with a clear error. The fingerprint comparison is constant-time.
  • Identity binding. A receipt is accepted only if the identity asserted for the presenting party is the one the certificate authorizes. In the signed-receipt step the asserted identity is the partner's agreed-terms identity (partnerTerms.identity), so the pinned certificate must authorize the identity the partner used in the terms it agreed on -- not merely restate the certificate's own stated identity, which would make the check a tautology. Because identity is self-asserted free text (Consistency: none, and single-line by the schema rule that refuses a control character -- a line break included -- when the terms are parsed), the rule is an exact match of the full identity. It is compared over the same canonical identity bytes the record commits to and the receipt signs, so the certificate check and the signed record agree on what "identity" is. A party that uses a different identity string needs a different certificate (a deliberate regeneration); the certificate authorizes exactly the identity it states. A pair either side of which states no identity in its agreed terms is refused at terms agreement, before any linkage key, payload row, certificate, or signature crosses the channel: there is no asserted identity for the binding rule to compare, and substituting one would make the check vacuous. The same condition is held again at the swap itself, so the binding rule is never reached with a side unnamed.

The signed-receipt step ​

The step runs at the conclusion of a successful exchange, after the payload exchange, and only when a signing identity and the session key are both present. An exchange missing either takes the unsigned-record path alone: one with no key exchange, one with no signing identity, and a web app exchange, which runs the key exchange but passes neither to the step. Both parties sign one set of canonical receipt-content bytes and swap signatures over the live channel, producing a single signed receipt that holds both parties' certificates and signatures. The step is evidence collection over the completed exchange, not a gate: the result and payloads already exist on both sides, so it neither withholds nor conditions the result. The signed receipt's byte layout and file format are in EXCHANGE_RECORD.md.

What is signed. The receipt content covers only facts both parties derive byte-identically, so a signature over them is checkable against either party's view. Those facts are the agreed-terms hash (both compute the same value), a session-keyed MAC of the committed payload that flowed in each direction (keyed by the fixed initiator/responder direction under a per-direction key derived from the session key -- each party holds both directions and the session key, so both reproduce both MACs), and the per-exchange binder below. Keying the directional payload values off the session key -- not a bare hash -- keeps a third-party receipt holder, who has neither the session key nor the flowing data, from recomputing a MAC or brute-forcing a low-entropy payload from it, and keeps the no-data direction from being a public constant that would leak payload presence. The MAC keys are derived at sign/verify time and never stored in the receipt. One-party-only facts (a party's records_exposed, its retention pointer) and the salted record commitments (per-party fresh salts, so not byte-identical across parties) are excluded; the association-table pairing is excluded too, because a one-sided exchange leaves the helper without it, so it is not a fact both parties hold. The exact byte layout is specified in EXCHANGE_RECORD.md.

Per-signer binding. Each party signs bytes that bind its own certificate fingerprint and handshake role alongside the shared content, so the two { certificate, signature } blocks are not interchangeable: a holder cannot swap them to re-attribute a direction's payload to the wrong party. 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), so a swapped block fails. The shape is therefore shared content plus per-signer binding, not byte-identical bytes signed twice; the exact byte layout is specified in EXCHANGE_RECORD.md.

Replay binder. The content includes a per-exchange binder: a 32-byte tag derived one-way from the session key via HKDF-SHA-256 with the info string alcove-signed-receipt-binder-v2:<role> (<role> being initiator or responder), matching the derivation discipline of the AEAD keys and the abort token. Both parties fold in the initiator-role binder, so they compute the same value with no extra messages, and neither party unilaterally controls it (it is a one-way function of the mutually-derived session key). Because a different exchange has a different session key and hence a different binder, a receipt from one exchange does not verify against another: a signature over this exchange's content cannot be presented as evidence of a different one.

Certificate check before signature, fail-closed. Each party transmits its self-signed certificate in-band alongside its signature. On receiving the partner's frame, the receiver first trusts the certificate by pin: self-signature valid, fingerprint matches the configured signing.partner_fingerprint, and the certificate authorizes the identity the partner used in the agreed terms (partnerTerms.identity, not the certificate's own stated identity, which would make the check a tautology). Only then does it verify the signature over the locally-recomputed content bound to the partner's role. A missing pin, a self-signature that does not verify, a fingerprint mismatch, or a certificate that does not authorize the agreed-terms identity each terminates the exchange before the signature is examined. So a partner whose certificate is not the pinned identity, or whose pinned certificate is bound to a different identity than the one under which it agreed on the terms, is rejected without its signature ever being trusted. A pin/signature failure raises a security-kind ConnectionError (a ReceiptVerificationError), distinct from a plain transport drop, which the CLI reports as a partner refusal (exit 76).

Wire ordering. The signature swap follows the existing control-frame convention (see the payload exchange above): deterministic sender ordering -- the initiator sends its {certificate, signature} frame then receives and verifies the partner's; the responder receives and verifies first, then sends its own terminal frame. The responder verifies before it sends, so a fingerprint or signature failure terminates without disclosing its own signature. The receipt content itself is never on the wire: both parties recompute it locally from state they already hold, so a partner that signed different content simply fails verification.

A certificate-mode configuration that can neither pin nor obtain a pin is refused before the exchange runs. A signing block in certificate mode that has no signing.partner_fingerprint is a first authenticated contact on any run that presents and reads a certificate at the terms exchange, and it pins there. On a run that does not -- one holding no signing identity or no session key, so no authenticated setup step to present a certificate on -- there is no route to a pin at all, and the configuration is refused as an operator configuration error before any credential, terms, or data are sent: the same class of refusal an unimplemented signing.mode takes, enforced at the run boundary, which is the first point that knows whether the run signs in band. Host-key trust establishment may precede the refusal on the sftp leg.

The two paragraphs above are the reason. The swap is the last step of a successful exchange, so such a run would otherwise disclose this party's keys and payload, reach the swap, and terminate there against the missing pin -- with no result and no receipt written locally, since those are written after the exchange returns. And by the wire ordering, on the leg that sends its frame first the partner also holds this party's signed receipt when the run stops. What the run does leave is the self-attested record of the disclosure it already made, marked as a terminated run's. Nothing about the partner or the network can make such a run finish, and no invitation or acceptance path supplies the pin later -- the signing block is only ever the local party's own configuration -- so the outcome is settled while the operator is still configuring.

The requirement is not part of the block's schema. Keeping the cross-field rule out of it lets a partially-authored config -- no partner fingerprint yet -- still parse wherever the schema is used, and a reader that needs only identity_file, such as alcove fingerprint, reads it from the raw config text rather than through the schema. The gate itself is the exchange path's own assertion. The other locally-knowable way a certificate-mode run cannot finish -- local terms stating no linkage_terms.identity, which the swap refuses for the reason given under Identity binding above -- is refused in prepareForExchange, before the connection opens, with the same remedies (name the party, or run unsigned).

The partner's half is settled where it first becomes knowable. A run that will sign holds the agreed pair to both parties being named the moment the partner's terms arrive, and ends there -- before the bootstrap frame, any linkage key, and any payload row -- sending the partner an abort so a peer that derives no refusal of its own fails fast rather than waiting out its inactivity budget. That refusal is one-sided: only the signing party derives it from the agreed terms, so only its own sends stop at terms, decision, and abort. The unnamed partner, deriving no refusal of its own, may already have sent its masked PSI element set for the round before it processes the abort. That is within the threat model, since a masked set discloses nothing beyond the record count already advertised at terms, though on a file-sync channel those bytes still reach the drop directory until the next sweep removes them.

A certificate bound away from its own agreed terms is refused too, where it first becomes knowable. The third locally-knowable way a certificate-mode run cannot finish is a signing certificate bound to an identity other than the one this party's own agreed terms state. prepareForExchange cannot settle it, because the signing block holds only a path to the identity file and no certificate is in hand there. So the front end that loads that file refuses at the load instead -- still before any credential, terms, or data are sent, as the same class of operator configuration error, and with two remedies of its own (rebind the certificate, or edit the terms to the bound value). Host-key trust establishment may precede the refusal on the sftp leg. It is refused rather than reported because the run cannot finish either way, and the refusal at the load is the only one that spares the connection, the credentials it presents, and the terms it puts on the wire.

The exchange holds the same binding on the local side, at the two points the naming binding above is held. The first is the terms exchange. The identity the run signs under is this party's own agreed terms', settled before the run opened, and the certificate is in hand. So a diverging run -- a caller threading a signing identity past that front end -- is refused the moment the terms are agreed on, before the bootstrap frame, any linkage key, and any payload row, sending the partner the same one-sided abort for the same reason. The second is the swap itself, the point of use, where a certificate is about to reach the wire. A run arriving there by another route terminates before its certificate or signature goes out and whichever leg the handshake gave it. That refusal sends the abort too, reaching a partner parked on the receipt frame it will never get, which ends its run as a peer termination (An abort arriving where a round awaits binary) instead of waiting out its transport inactivity budget -- the wait that on a file-sync channel also holds the drop directory's bytes. Both points read one predicate over one pair of values, so the earlier refusal admits exactly the runs the later one does; the value held against is the identity this party's own agreed terms state, the same one its partner will authorize the presented certificate against, so neither leg's outcome depends on the role it drew. Both values that disagree are this party's own and nothing about the peer is consulted, so the refusal is an operator configuration error (the CLI's exit 64, and the config category of its event stream) rather than the security-kind ReceiptVerificationError the swap's partner-side refusals raise -- a caller must not read it as the peer having failed a trust boundary. Neither point yields a result or a receipt; a run refused at the terms exchange has disclosed nothing and leaves nothing, while one refused at the swap keeps the self-attested record of what it disclosed.

Known limitation (accepted, not mitigated). This is post-result evidence, not a fair exchange. Because the result already exists on both sides, a party may capture the partner's signature and decline to send its own; any failure terminates the run, which must be restarted, and restarting does not undo the data already exchanged. A terminated swap persists no partial receipt: a partner signature received without completing the local swap is not written as a valid signed receipt. It does leave the self-attested record of the disclosure that already happened, which is a separate artifact and no part of the receipt -- exactly because the data cannot be undone, the disclosure is one an operator's accounting must contain.

Verifying a stored signed receipt ​

The live step above verifies the partner's certificate and signature as they arrive. A stored signed receipt is verified again, later and offline, by whoever holds it: either party re-checking its own exchange, or a third party the receipt was handed to. Verification is read-only -- it never mutates or re-signs the artifact -- and, for any receipt of the shape the parse produces, it never throws: every field value in it may be hostile (an identity the canonical encoder refuses, a malformed signature, a public key that is not a valid point) and each check still yields a status. The guarantee covers the values, not the shape: a hand-built receipt missing a structural member is a programming error, not an input class, and an unrecognized version is refused at parse rather than reinterpreted (see EXCHANGE_RECORD.md). Four checks run per party, and none of them short-circuits another, so the verdict states every finding rather than the first.

  • Receipt signature. The signed bytes are re-derived from the receipt's shared content, the fingerprint of that party's certificate, and that party's role, then checked under the certificate's public key. Because each party's signed bytes name that party, a swapped pair of { certificate, signature } blocks fails.
  • Certificate identity binding. The certificate's self-signature covers the body -- version, algorithm, identity, and public key -- so it is what ties the identity string the receipt displays to the key that signed. A certificate that does not self-verify binds nothing, and is reported as its own failure rather than as a bad signature.
  • Certificate anchoring. Every value the verifier holds that ties a certificate to a party known outside the receipt -- a fingerprint pinned out-of-band, and the fingerprint of the verifier's own signing identity -- is compared (constant-time, over the decoded digest) against both certificates. Each value anchors at most one certificate: the values are assigned to the two certificates as a matching, and values with the same digest count once, so one pinned value, two spellings of it, or a pin equal to the verifier's own identity can never anchor both. A pinned value matching neither certificate means the receipt is not the pinned party's, and fails. The verifier's own identity matching neither fails only when the verifier named it, which asserts the receipt is one it signed; an identity Alcove resolved on the verifier's behalf is reported instead and leaves that certificate unanchored, since it says only that this is not the verifier's own exchange. A failure is attributed to the value that did not match, so a rotated local identity never recasts a correctly pinned partner certificate as a mismatch.
  • Asserted identity. When the verifier can state which two identities this exchange was between -- from its own exchange record, or from both parties' terms -- each certificate must authorize one of them. The pair is matched in either order: no artifact outside the signed receipt says which party held which handshake role, and the per-signer signature binding above is what fixes a certificate to its role.

Two further checks are whole-receipt rather than per-party. The receipt's agreed-terms hash is compared against the value the verifier holds (or re-derives from both parties' terms -- its own, and the partner's from the receipt's unsigned envelope or a document supplied in that copy's place), which is what ties the signatures to a specific set of agreed terms. The per-exchange binder is compared against the receiptBinder of the exchange record for the run, when the verifier holds that record, which is what ties them to one run of those terms. The agreed-terms hash and both certificates repeat byte for byte across recurring runs of a partnership, and the two payload MACs that do vary are not recomputable offline, so nothing else in the bundle separates one run from the next. A record holding a different binder fails the verification, a record holding none says its exchange produced no receipt at all and likewise fails, and no record at all leaves the pairing unchecked -- incomplete, never verified. The four outcomes and what each means for a holder of one artifact are specified in EXCHANGE_RECORD.md.

The binder is still never recomputed: it is derived from the session key, which only the two parties ever held and neither retains, so an offline verifier confirms that the signers signed a receipt holding this value and that the run's own record holds it too, without being able to derive it. A binder substituted into both artifacts at once is therefore detectable only during the live step, where both parties derive it independently.

What a pass means: both certificates anchored. The verdict is tri-state -- verified, incomplete, or failed -- so "not checked" is never reported as "it checked out". A receipt holds two certificates and a verdict speaks for both, so verified is reached only when each of them is anchored, and it names what anchored each one. Where the anchors come from:

  • A party to the exchange anchors the partner's certificate with the fingerprint it pinned out-of-band, and its own certificate with its own signing identity -- derived from the certificate half of the identity file it holds, or from the certificate it exported from that file, never restated. A typed self-pin would be a value the verifier could only copy off the receipt it is checking. Only the certificate half is ever used: verification signs nothing, so no private signing key is imported on any path, and the browser surface takes the exported certificate alone rather than a file that holds a key. Certificates are public either way, so this anchor -- like a pinned fingerprint -- says whose certificate it is and nothing more: an assembler can place the verifier's certificate in a receipt the verifier never signed, and what refuses that receipt is the receipt signature beside the certificate, which verifies only under the private key the verifier holds.
  • A verifier that was party to no exchange has no identity to derive and anchors each certificate with a fingerprint held out-of-band for that signer. Supplying one such value anchors one certificate, and this is the general mechanism: it is the only one open to a verifier with no signing identity of its own.
  • Anything short of both anchors is incomplete, never verified, and the verdict names the unanchored certificate. That includes the third-party auditor with no pinned value at all, and the party that pinned its partner but could not anchor its own certificate. incomplete already means "no check failed, but some could not run", which is exactly what an unanchored certificate leaves; it is not a failure, and the CLI reports it with an exit code of its own, distinct from a failure's (CLI.md). A run that anchored both certificates but paired the receipt to no exchange record is incomplete on the same rule, for the same reason.

That an unpinned run cannot reach verified is by design rather than a gap: a signed receipt is self-consistent by construction, so signature verification alone proves only that whoever holds the two embedded certificates' keys signed this content -- which anyone can arrange with two certificates of their own. The signatures, the identity bindings and (given the data) the record's commitments are still checked and reported; what is reported as not established is certificate trust.

What an unanchored certificate leaves open. An unanchored certificate is checked only against itself -- its self-signature, its signature over the shared content, and the expected identities the verifier supplies. Whoever assembled the receipt can satisfy all three with a certificate it minted itself, since the expected identities come from the verifier's own record or terms rather than from anything the certificate is bound to out-of-band. A verdict over one anchored certificate therefore establishes that the anchored party signed this exchange and says nothing about who the other signer is, which is why the top-line verified is withheld until both certificates are anchored rather than qualified in prose beside it.

A self-signed certificate's third-party assurance is weaker than a CA-backed one either way; the authority-backed mode that would close that gap is deferred (see SECURITY_DESIGN.md).

Self-attested record ​

Once an exchange has disclosed, on every path, each party produces a self-attested record of what it disclosed -- a local, unsigned audit artifact that stands on its own as a disclosure-log entry, so an operator can populate a HIPAA accounting of disclosures or a FERPA disclosure record without separately retaining and re-matching the original linkage-terms config. It is built from data both sides already hold, so it needs no private key and adds no protocol round-trip. It is explicitly not a signed or non-repudiable receipt and not evidence against the partner. The record is unsigned: a session-derived MAC is not non-repudiation (see the two signing modes above), so an unsigned record is an accurate, local audit artifact rather than one that overstates what it proves. Non-repudiation is the signed receipt's job, and the receipt is a separate artifact produced alongside this one.

The durability point is this party's payload send. A record is owed from the moment the transport takes this party's payload frame -- the point from which the disclosure it attests has occurred -- and not from the moment the run finishes. Everything after that send is inside the region: the rest of the payload exchange (the initiator sends before it receives, so the partner's reply and its schema check fall here), the refusal of a payload column sent to a party that receives none, the refusal of a payload whose columns differ from the partner's agreed payload.send, and the signed-receipt step. The rule covers all of them: a run that terminates anywhere in the region still leaves this party the record of what it had already disclosed, and the exchange hands that record to its caller on the failure exactly as it returns one on success. What is refused is the reverse reading -- a terminated run's record passing for a completed run's. Every record has a mandatory outcome stating which it is, so a reader of the artifact alone tells them apart and can see that no receipt accompanies a terminated one. The values, the properties that fix how the row is read (the cause of the termination among the things it does not state), what a record commits to for a partner payload that never arrived, and the reason a terminated record keeps its binder are in EXCHANGE_RECORD.md. A failure before the send produces no record and owes none: the failure is reported through the run's error and the machine-readable event stream rather than through a record. The send itself opens the region both when it resolves and when the transport rejects it as a publish it can neither confirm nor retract, which may already have reached the partner; every other rejected send is a settled non-publish and owes nothing (EXCHANGE_RECORD.md).

What the record attests is the hand-off, not delivery. It states this party's own act of disclosure -- the payload frame handed to the transport -- and not the partner's confirmed receipt of it, on both legs: a send resolves on the local hand-off, and a frame still buffered when the channel closes on an error may never leave this machine (see COMMUNICATION.md). An accounting is better served by a record that over-reports a disclosure whose frame never arrived than by one that omits a disclosure that did.

The receipt's own all-or-nothing rule stands beside this untouched. A terminated swap persists no partial or unverifiable signed receipt, and the self-attested record is not one: it is the separate, unsigned artifact, and it attests only what this party itself disclosed.

The record format itself -- the record and verification-keys file shapes, the format version, the agreed-terms hash, the HMAC commitment scheme, the readable governance metadata, the result-size and per-direction disclosure framing, and the record's privacy properties -- has its own specification: EXCHANGE_RECORD.md. The signed receipt reuses that record's commitment scheme and on-disk format.

Output ​

The basic output is an association table between each party's element. As noted above, if parties supplied identifier columns with their inputs and flagged them in their metadata, the association table will be between each party's identifiers. Otherwise, the table references the row indices of each dataset.

The result file identifies our matched record in its first column (the identifier value, or the local row index when no identifier column exists), followed by a row_id column holding the partner's 0-based row index for that match. The partner's payload columns follow, and after them the columns of this party's own input the local include_own_columns config key selects, if any. This partner row-index column is written for every match, not only for identifier-free exchanges, because it is the partner side of the association-table pairing the exchange-record commitment binds -- writing it keeps the result sufficient to reproduce (and so verify) that commitment from the holder's own retained files (see EXCHANGE_RECORD.md). The column is headed row_id, disambiguated to their_row_id (then their_row_id_2, ...) if that name collides with any column already headed.

The result file has one row per association pair. Under one-to-one that is one row per matched record, the two readings coinciding. Under a deduplicating cardinality they part, and the pair reading is the one the file takes. Where several of the partner's records link to one of this party's, that party's first-column value repeats down the column, once per partner record, each row holding its own partner row index and that partner record's payload values. Where the multiplicity runs the other way, the one partner record's row index and payload values are written against each of this party's records in the group. Where both sides have it the two readings compose, and one entity cluster that is a single block of m of this party's records and n of the partner's writes m * n rows -- each of this party's records once per partner record it pairs with, in this party's row order -- while one chaining several of a round's blocks writes fewer, one row per pair some block of that round names. No column marks a cluster's boundary; the pairs are what the file holds and what the record's commitments bind. The count the exchange record attests over the same table is fixed separately, at EXCHANGE_RECORD.md.

If parties elected to transmit payload data, the relevant columns for the appropriate rows will be transmitted in-band over the secure connection and appended to the output in-the-clear, after the partner row-index column. Each partner payload column keeps its original name.

A party may also write columns of its OWN input after the partner's, through the include_own_columns key above, which selects either the columns it discloses to the partner (disclosed) or every column its metadata block declares (all). Under all with no metadata block, where the metadata is inferred, it selects every column of the input file, including those inference leaves undeclared; a column an authored block leaves undeclared is not written. The values are read from the matched local record each result row addresses, so under a deduplicating cardinality they repeat once per pair as the first column's value does; the row count is unchanged. The key is local to the party that sets it: nothing about the frames, the agreed terms, the agreed-terms hash, or the partner's own result changes with it, and the columns it writes are covered by no commitment in the exchange record (EXCHANGE_RECORD.md). Absent the key the result holds the leading columns and the partner's payload alone. The identifier column is not among the own columns written under either selection, since the result's first column already holds its value.

A party running the cascade strategy may also write a last column, headed matched_on_key, naming the linkage key each pair was matched on: the name the agreed terms give the key whose round accepted the pair. For a key with a missing-field condition the name is followed by whose record lacked the field, from the writing party's side: <name> (your record has no valid date of birth) or <name> (partner's record has no valid date of birth). A party asks for it through the local include_matching_key config key, which the CLI's --include-matching-key and --no-include-matching-key override for one run. Each party counts its own rounds, so the column is local in the same way as the own columns above: nothing about the frames, the agreed terms, the agreed-terms hash, or the partner's own result changes with it, and no commitment in the exchange record covers it. It is written last so the leading columns a re-supplied result is read by keep their positions. Its name is reserved ahead of every header but the local first column's, so where it is written it is always headed matched_on_key, a partner payload or own column of that name being the one renamed, and only a local first column already headed matched_on_key displaces it to own_matched_on_key. The single-pass sender is handed the resolved table without the rounds behind it, and a count-only run writes no result, so neither writes the column.

Every header in the result is distinct. Names are assigned in one order -- the local first column, then the matching-key column where it is written, then the own columns above, then the partner's payload columns, then the partner row-index column -- and each takes its preferred name unless an earlier one already holds it. A name already taken falls back to a prefixed variant and then a numbered one: their_<name>, their_<name>_2, ... for a column of the partner's, and own_<name>, own_<name>_2, ... for one of this party's. A partner payload column therefore takes their_ when its name equals the local first column's name (the identifier column, or row_id when there is none), when it equals the matching-key column's name where that column is written, when it equals one of the own columns written beside it, or when an earlier partner column has already taken it. A partner repeating one payload column name more than once is uniquified through this same chain: each repetition after the first is a collision against the headers already assigned, so no two headers coincide even where the partner's own column list holds a duplicate name.

A payload frame has one row per matched record of the sender, not one per pair, whatever the cardinality. A payload row is addressed by the sender's own 0-based row index and those indices MUST be distinct within a frame -- a repeat names two payload rows for one record without saying which is the record's -- so a sender transmits each of its own matched rows exactly once and the receiver joins every association entry naming that row back to it. The multiplicity a deduplicating cardinality creates therefore lives on the association table's side of that join and never on the frame, which is what keeps the two parties' payload commitments over byte-identical data (EXCHANGE_RECORD.md).

A payload frame's rows are positional against the columns it names: each row contains exactly one value per named column, in that order, and one row index per row. A received frame whose rows do not is a protocol violation, refused where the frame is parsed -- the exchange aborts with a classified protocol error and produces no result, like every other malformed frame -- rather than at the stage that reads it. That is what keeps a party's self-attested record coherent in both halves: the committed payload binds the column names and the row values together, while the readable governance list holds the names alone, so a record that commits row values names the columns those values came from (EXCHANGE_RECORD.md). A party whose own payload had already crossed still keeps the record of that disclosure, refusing the reply being no undoing of it, and commits no received payload in it. The reference implementation is the payload wire schema in packages/core/src/payloadExchange.ts.

Every header and value in the result file is an RFC 4180 field, escaped against the delimiter the file is written with (a comma unless csv_delimiter names another): a value containing that delimiter, a double quote, CR, or LF is wrapped in double quotes with its embedded quotes doubled, and any other value is written bare. Escaping happens once, where the table is formatted -- the writers that emit it (the CLI's file and stdout branches, the web app's download blob) join the formatted fields and add nothing. A matched value therefore round-trips: re-parsing the result yields the value the exchange committed to, which is what lets a holder re-supply the result to reproduce the record's commitments (see EXCHANGE_RECORD.md). A committed null and a committed empty string are the one exception, both written as an empty cell and both read back as an empty string.

See also ​