Skip to content

Deduplicating matching semantics: the direction, the axis, and the round boundary ​

Status: specified, and run end to end -- every cardinality under both linkage strategies, through the result file, the exchange record, the entity closure, and the consent surfaces that state what the widening discloses. The normative rules are in PROTOCOL.md, with the record's count and re-supply rows in EXCHANGE_RECORD.md and the operator-facing term in EXCHANGE_REFERENCE.md. This note records why the rules have that shape, the alternatives weighed, and what stays open. See docs/notes/README.md.

This is design rationale. Nothing here binds an implementation; the normative rows live in the spec documents linked above, and this note does not restate them.

The problem ​

deduplicate is schema-complete, exchanged, consented to, and refused at run time. What it was not was specified: the protocol document held design intent rather than rows, and the surfaces describing the term disagreed about which DIRECTION the multiplicity runs -- a field comment reading "this party's records may match more than one of the partner's" against an operator-facing description of grouping one's own inputs. Those are opposite exchanges. Since nothing runs, the disagreement costs nothing at run time; it would have cost a great deal at implementation time, when two surfaces claiming opposite cardinalities are read as requirements.

Why the declaring party is the "many" side ​

The direction is not free to choose: the term's name and its operator-facing question already fix it. A party is asked whether it wants to deduplicate ITS OWN inputs. Deduplicating one's own inputs means discovering that several of one's own rows are one individual, and the only evidence the exchange can offer for that is that those rows all name the same partner record. So the declaring party contributes several records per matched value, which is the "many" side. Reading it the other way -- one of my records matching several of the partner's -- would deduplicate the PARTNER's data and hand the grouping to the party that did not ask for it.

The requirement that a deduplicating party receive output falls out of the same reading rather than being a separate policy. The grouping is what the exchange produces for that party; a "many" party entitled to no output would have widened its own match, disclosed to its partner how its records group, and taken nothing back.

Why the incidence is named once, ahead of two features ​

Two independent lines of work widen matching, and they widen it in opposite directions over the same object. split_on and generate_fuzzy_comparisons give one record several candidate VALUES for a key; deduplicate gives one value several RECORDS. Specified separately, each would have had to restate what a match means and how a value-level match becomes a record pair -- and two restatements drift.

Naming the (record, value) incidence and its attribution rule once, in terms neither feature owns, buys three things. Each feature's rule becomes a statement about one axis and reads the other's output rather than redefining it. The question "does this widening move a derived bound?" gets a structural answer instead of a case-by-case one: the values-per-record axis can raise a party's distinct-value count and therefore falsifies the assumption the single-pass bounds rest on, while the records-per-value axis provably cannot. And the interface is fixed without deciding either fuzzy-comparison or fan-out design question -- both remain free to choose their own width, resolution, and cap.

Why multiplicity is within-round ​

The consequential choice in this specification is whether a later, weaker key may add a link onto a record the other side already matched in an earlier round. Two readings were available.

Within-round only (the rule taken). A record on either side that appears in any of a round's candidate pairs leaves candidacy for every later round. A group is therefore formed by one key: the most precise key on which its members share a value with one partner record.

Cross-round accumulation. A matched record on the "one" side stays in candidacy, so a later key can attach further records of the many side to a group an earlier key opened.

Three things decided it for the within-round reading.

  • It is the reading the protocol's existing per-round candidate filtering already describes, and the reading the sibling multiplicity axis was specified under: fan-out's removal on a potential match takes every record appearing in a round's candidate pairs out of candidacy whether or not it was paired. Two multiplicity features with opposite candidacy rules would be a poor pair to hand an implementer.
  • It preserves the cascade's ordering assumption. The keys are ordered most precise first, and removal is what makes an earlier key's verdict final. Keeping a matched record in candidacy means a weaker key can revise a stronger key's grouping, which inverts that assumption.
  • It is the lower-disclosure choice, and the reversible one. Accumulation keeps already-matched records contributing values to later rounds, so the partner observes matches on weaker keys against records the cascade would have removed. Nothing is implemented yet, so widening the rule later is a specification change with no deployed peer to reconcile; narrowing it later would take back a capability an operator had.

The cost is real, and it is stated here rather than left for the reader to discover. A many-side record that holds no value for the key its duplicate group formed on -- a row missing the SSN its siblings matched by -- does not join the group by matching the same partner record on name and birth date later, because that partner record has left candidacy. Multi-key deduplication therefore groups on one key per group rather than accumulating evidence across keys. This is the point in this specification most likely to be revisited, and the change would be localized: the per-side candidacy rule, the candidate-set construction on the "one" side, and the disclosure row that follows from it.

Why the many side collapses to a value and re-expands locally ​

A round's PSI set is a set of values, and the association map addresses positions in it. So the many side contributes each distinct value once and attributes a match on it to every one of its records holding that value, rather than entering the same value once per record. Entering it per record would give two indistinguishable positions for one value, which nothing downstream can attribute -- the re-expansion has to be local whatever the wire does.

That choice is what keeps the widening free of any frame change. The many side's set is no larger than a one-to-one party's would be (it is smaller, since duplicates collapse), so no derived bound moves, and the single-pass index table's one-value-per-cell shape already expresses everything a deduplicating party's records hold. The only thing that grows is the mapped-element list, which grows by the multiplicity the receiving party can compute from what it already holds -- so the growth is checked rather than merely tolerated.

Why no strategy restriction, where fan-out took one ​

Fan-out was confined to single-pass while the cascade's realization was written, because resolving it needs the PARTNER's grouping of matched values into records and the cascade's per-round frames delivered that grouping only after the candidate sets it should have informed were already committed. The realization put the grouping on the round's own frames (cascade-fan-out.md), so the confinement is gone; what it left behind is the reason a deduplicating cardinality never needed one, since it is tempting to assume the two multiplicity features share a constraint.

They do not, and the axis distinction is exactly why. Grouping one's OWN records by value needs only one's own data. Each party computes its own removals from its own round output, in the round, with no knowledge of how the partner's matched values group. There is no second computation to diverge from, so the cascade supports a deduplicating cardinality on the frames it already sends. The generalization it does need -- an expanded translated list -- is a length change to an existing frame, not a new shape.

Single-pass takes even that much less. Its receiver already holds the sender's whole per-key index table, so it already sees which of the sender's records hold one value -- the structure a deduplicating round is built on is the disclosure the strategy makes anyway. What the cardinality reaches there is two clauses of a purely local replay: which values a side keeps in a round, and which of a round's candidate pairs the sweep accepts. No frame gains a field, and no bound derived from the declared sizes moves, since those count (key, record) cells and duplication adds none. What the boundary does need re-derived is the check on the resolved table the sender receives: distinctness on one of its two halves is exactly what the widening spends, and distinctness is also what caps the table's length, so the half that keeps it is the one the length is anchored on.

Why the many-to-many closure is a shape rather than an algorithm ​

The (true, true) pair is not hard to pair: both sides keep their duplicates and every candidate pair is accepted. What it is not, at that point, is an answer. A table where both sides have multiplicity links records transitively, and turning it into something either party can act on means resolving it into entity clusters -- with all the care that transitive closure demands in general, since a closure can join two records through a third with no rule joining them directly.

In the scope the cardinality first ran in, that general worry did not arise. many-to-many was matched by the cascade alone, a candidate set under it was refused at the time, and a record leaves candidacy as soon as it appears in a round's candidate pairs. Between them a record contributed one value to at most one matched round, so the table was a disjoint union of complete blocks -- one per matched value -- and the connected components WERE those blocks. What the cardinality owed was therefore the normative rows and the pinning of that shape, not a graph traversal and not a disclosure paragraph. The rows are in PROTOCOL.md, which now states the widened shape too (the section below).

One thing followed from the block shape that a general closure does not give, and it is the reason the widening below owes a disclosure paragraph: the disclosure a general closure adds -- two of a party's records grouped through a partner record that no linkage key links them through -- cannot occur while the shape holds. What does not depend on the shape is that no exchange step is needed: both output-entitled parties end the cascade holding the same table, so a closure computed over that one artifact agrees across parties by construction. Exchanging cluster identifiers was considered on the way past and is worse on both counts -- a frame, a round, and a further disclosure, to reach agreement the parties already have.

Why the block shape is pinned by a check ​

The shape above is a runtime claim, and it rests on preconditions that code enforces rather than on a coincidence. Those are separately revisable -- a candidate set is precisely what puts one record in two of a round's blocks, and what keeps it out is a refusal held at every point the deduplicate pair is knowable rather than any limit of what a round can state -- while a paragraph asserting the shape would go on reading correctly after that refusal moved. The section below is that refusal moving, and the check is what made the move a visible one. So the claim is an executable check over the built table, the artifact every consumer reads, and a table outside the shape aborts the run.

The check is implied by rules that already run: the returned list is held to the partition this party's own pairing states, which is the same statement made on the frame. Two things make it worth its cost anyway. It is stated over the table rather than over the frames, so it survives a change of derivation that the frame-side argument would have to be re-made for -- and re-making that argument is exactly the step a change is likely to skip. And it reads the round's blocks per matched value rather than per record, so a producer that puts one record in two of a round's blocks is expressed rather than flattened into that record's one grouping, which is what let the check narrow to the widened shape rather than being torn out with the refusal.

Why the closure widened rather than staying refused ​

The block shape's third precondition was a refusal, and the refusal's stated reason was its weak part. A candidate set under many-to-many was refused because single-pass paired no both-sided cardinality, so a cascade realization would have had no single-pass table for the equivalence obligation to name. But that referent did not exist for this cardinality at the time: single-pass had never paired it, under a candidate set or without one, so the obligation was being cited where it had never applied rather than being lifted. Meanwhile the refusal stopped being theoretical. The built-in rule set declares a swapped key (swap(LN, FN) + DOB, packages/core/src/defaults/builtInLinkageTerms.ts) and APPLIED_SETTINGS.fuzzyComparisons is on, so a swapped key is a candidate set and a both-sided deduplicate over the shipped default keys meets the refusal.

The decision taken, and what PROTOCOL.md now carries:

  • The cluster definition does not change. It is a connected component of the association table's pairs and reads nothing else -- no round, no key value, no declared quantity -- so widening what the table holds cannot widen what a cluster is. What changes is the invariant pinned on it: block-diagonal narrows to round-diagonal, a cluster spanning several of one round's blocks and never two rounds. Removal on a potential match is what holds the round boundary, and it is stated over records rather than over values, so a candidate set leaves it standing.
  • No canonical order and no tiebreak. A component partition is a pure function of the edge set, so the closure introduces no choice for the two parties to agree on and none is invented. Anything that would need one -- refusing to chain two blocks joined by a single record, or a maximum-cardinality pairing -- is a resolution rather than a closure, and belongs in the one shared sweep that already owns the canonical order. Keeping every determinism-bearing choice in one place is what keeps the argument short enough to check.
  • The equivalence obligation is dropped for this cardinality and replaced, not waited on. What holds the two parties together is internal: one table both end the cascade holding, through one shared resolution function, and total acceptance at the round, which leaves a round's accepted set a symmetric function of the two groupings with no order for either to reproduce. The conformance reference becomes a specification oracle built from the two incidences, correct by construction because acceptance is total. So a single-pass many-to-many need not land first, and whichever leg lands second re-derives against the shape fixed here rather than deciding it twice. It landed second, and the differential vectors run both strategies over the same fixtures anyway -- an equality the structure gives rather than one the specification demands, the shared sweep resolving both.

The widening has a cost, and the point of writing it down is that the block shape is what let the earlier version skip it. The disclosure the block shape excluded now arises: two of a party's records can land in one cluster through a partner record no linkage key links them to, joined by the several candidates one record contributed to the round. It is stated in the spec beside the shape. It adds no term to consent to, and not because the disclosure is small -- both halves of the chain are already on those surfaces, the deduplicating match's grouping disclosure and the per-candidate matching a key expanding one value declares, and the chain is those two read together over one table, with no row, column, frame, or round added and the closure local to each party. What the surfaces owe is the sentence stating the two halves together, on every seat that consents to terms declaring both, rather than a second term.

Hub smearing was weighed as a candidate for a protocol ceiling and declined on the same reasoning that declines one for the pair table (below). One multi-valued record can chain a large share of a round into a single component, but the inputs and the terms are the operator's own, the worst case is projectable from them before a round runs, and a refusal would convert a large result into no result. The alternative kept is the same one too: a diagnostic the operator reads off its own artifacts, here the count of distinct values a cluster formed on set beside the cluster size distribution.

Why the pair table takes no ceiling ​

Both sides keeping their duplicates makes the pair count quadratic in the duplication, and the question that raises is whether the protocol should cap it. It does not, and where the quadratic term falls due is the accounting in PROTOCOL.md.

A cap would be a limit on the operator's own dataset choices, refusing an exchange because its inputs are what they are and converting a large result into no result. Failing fast earns that where the failure is otherwise obscure or expensive to reach, and here it is neither: the alternative is the same failure a moment later, in the operator's own workspace, with the table in hand to look at. The cost accounting is what makes the trade cheap to take, since the growth lands on artifacts the operator holds rather than on the rounds.

The alternative kept is an advisory warning naming the pair count a run projects, left to a front end's discretion. It is a different artifact from a protocol ceiling on the same reasoning that declines the ceiling: the dataset is the operator's, so the projection informs them without deciding for them, and which surface is worth showing it on is a question about that front end rather than about the exchange.

Why a cluster is not a column ​

The grouping a many-to-many result holds is the closure's output, and the obvious way to hand it to a reader is a cluster identifier beside each result row. That is not done, by design.

The result file's shape is critical for the re-supply path: verification reads the fixed leading columns and takes everything after them as the partner's payload values. An added column shifts that boundary for every reader of every result, including results already written -- which is the same objection that declined a local row-index column below, on the same file, and the two would compound. The pairs are also what the record's commitments bind, so a cluster column would be a cleartext annotation nothing in the record stands behind.

A separate cluster or entity VIEW -- an artifact or a screen presenting the grouping as a grouping -- was proposed and set aside rather than declined on the merits. It is a front-end question, it costs nothing to add later, and no consumer asks for it yet; a reader that wants clusters computes them from the pairs the file already holds.

Why the many side's per-value multiplicity stays a self-declaration ​

This is the question the specification left to the work that implements the cardinality, and the answer is that there is nothing to bind it against. The normative statement of the limit and the residual is in Deriving one table from the exchanged association maps; the reasoning is here.

Two candidate quantities exist, and neither is one. The agreed terms describe what a linkage key is, never how a party's own rows duplicate under it. The only per-party figure that would is the duplication structure itself, which the protocol keeps off the wire by design -- it is the same fact work minimization declines to exchange -- so a bound derived from it would be advertised by exactly the party it binds, which is the self-declaration again with an extra frame.

That leaves the question of what overstating buys, and the answer is what makes recording the residual acceptable rather than merely unavoidable. Which of the receiving party's records matched is decided by that party's own round output, through the coverage rule; the returned list can repeat a position but cannot introduce one. So an overstated group adds no matched record, no disclosed column, and no membership fact on the receiving side. It repeats a row that side already pairs, inflates the overstater's own table with pairs naming its own rows, and is capped by the row count that party stated on the terms exchange.

Only one direction is a residual, and reading the two together overstates what is open. The unbound quantity is the many side's declaration of the size of its OWN group. The opposite freedom -- its partner deciding which of the many side's records belong to one group, on the way back -- is closed by a check rather than recorded, because there the many side does hold something to check against: the position each of its own entries named. That is the difference between the two. A self-declared quantity has no local counterpart; a partner's regrouping of a list this party wrote has one, entry for entry.

The reason to write this down rather than close it is that the argument is contingent on where the matched-record set comes from. A change that let the returned list decide membership -- rather than confirming a set the receiving party computed -- would turn a cheap overstatement into a disclosure, and would be recognizable as one against this paragraph.

Why the recorded result size is the pair count ​

Once the multiplicity reaches the surfaces downstream of the association table, "the result size" stops naming one number. A table with several links per record admits three readings -- its pair count, this party's matched-record count, and the partner's -- and the exchange record has one field. The normative row is in EXCHANGE_RECORD.md; the case for it is here.

The deciding property is cross-party agreement. Both parties end a cascade holding the same table, so the pair count is one figure they derive identically -- exactly as the result size is one figure under one-to-one. A matched-record count is not: the "many" side would record more than its partner, and two records of one exchange would state two different sizes with nothing in either saying which reading it took. That is a poor property for an artifact whose whole purpose is to stand alone in front of an auditor.

The second reason is what the field is for. A record supports an accounting of disclosures, and what was disclosed is a set of linkages: this record of mine set against that record of yours. Under multiplicity the entity count is smaller than the linkage count on one side and larger on the other, and it is the linkage count that says how much crossed. Reading it as entities would also make the figure depend on a quantity the protocol leaves as the many side's own self-declaration (see the section above), where the pair count is at least a quantity both parties checked each other's contribution to.

Recording both was considered and set aside. The distinct matched-record count is derivable by each holder from its own retained result, so a second field would tell that holder nothing it could not compute -- while adding a figure to the artifact it hands an auditor, and taking a format version bump to do it. A reader who wants the entity-level figure reads the result file.

Why the repeated copies must agree rather than be committed per pair ​

A record that stands in several pairs has its values written down the result file once per pair and committed once, because the payload commitments bind one row per matched record while the file is one row per pair. Reproducing the committed rows from the file therefore means collapsing the copies -- and a collapse that simply kept the first copy would leave every later copy's value cells reproduced by nothing. Neither commitment would reach them: the association-table commitment binds the pairing, not the values, and the payload commitment binds a row those copies were never compared against. The rule taken is in EXCHANGE_RECORD.md.

There were two ways to close that, and the difference between them is what the artifact costs. Committing the received payload per PAIR would match the file exactly and remove the collapse, but it changes the committed bytes: a format version bump, a commitment holding the same information at the file's redundancy, and the loss of the property that a sender's payload-sent commitment and its receiver's payload-received commitment cover byte-identical data. Requiring the copies to agree costs nothing in the artifact and nothing on the wire, and it is not an approximation: the copies ARE the sender's one row, so any disagreement is an alteration of the retained file and is reported as one.

What that buys is the property a reader of a verified record is entitled to assume: every value cell the result holds is reproduced by something the record binds. The first copy reproduces the committed row; every later copy has to equal it.

Why the result file gains no local row-index column ​

The result file identifies this party's matched record by its identifier value, or by the local row index when the exchange used no identifier column (PROTOCOL.md). The re-supply path that reopens a record's commitments maps that first column back to an input row, and where the identifier column has duplicate values it maps every duplicate to the first occurrence -- so the reproduction is exact only for an identifier unique per row.

That edge predates any multiplicity, but a deduplicating exchange is where it stops being an edge. The input such an exchange sets out to group holds several rows for one individual, and if its identifier column names the individual rather than the row, those rows share a value by construction. The reconstruction then reproduces the first of them for every later one and the commitments report a mismatch -- a correct verdict on an unmodified pair of files, which is the worst kind.

Including this party's own row index in the result, beside the partner's, would close it: the re-supply would read the index directly instead of inferring it. That is not done here, by design. It is a change to the shape of a file operators hold, script against, and hand to their own downstream systems; it moves the value columns' offset for every reader of every result, including results already written; and it widens what a result discloses in the identifier-free case not at all but in the identified case slightly, by adding a second locator for each matched record. Those are its own decision with its own doc row, not a line to slide in beside a commitment fix.

What is done here instead is to make the condition clear where it is met: the reconstruction's duplicate-identifier warning states that a deduplicating input has duplicates by construction when its identifier names the individual, that every later duplicate reproduces the first row's values, and that an identifier unique per row reproduces exactly. An operator reading it is reading it about their own files.

Why acceptance defaults the acceptor's own side to false ​

An invitation holds one linkage-terms document, and deduplicate is per-party. So an accepted invitation has to get the acceptor's own value from somewhere, and there were three places it could come from.

Copying the inviter's. This is what a naive derivation does, and it is the one reading that is unsafe. The value an invitation states is one document and the value its author presents at the terms exchange is another, so an inviter could state true, have the acceptor take the "many" side and disclose how its records group, then present false and run the pair as many-to-one at the acceptor's expense. The acceptor would have consented to a widening of its own data's disclosure that it never asked for.

Refusing the invitation. The conservative answer, and the one held while the question was open: a deduplicating invitation was refused at accept, and the mint surfaces were closed to match. It is safe, and it costs the entire invite-and-accept path a capability the run has -- a many-to-one exchange was reachable only from two separately authored configurations, which is the setup an invitation exists to avoid.

Deriving it as false. The answer taken. It rests on reading the term as what it is: a statement about the DECLARING party's own data, not a property of the exchange the two parties negotiate. An invitation is the inviting party speaking about its own side, so it states that side and no more; the acceptor's side starts closed and stays closed unless that party authors it.

Three things follow, and they are why this is preferred to the refusal it replaces.

  • The unsafe reading becomes unrepresentable rather than refused. There is no value an invitation can state, and no value its author can present at the terms exchange, that puts the acceptor on the "many" side -- so the flip is closed by the shape of the derivation rather than by a check that has to fire.
  • What the acceptor consents to is a run that happens. The disclosure a deduplicating match makes runs toward the party that receives the result, which under an accepted invitation that shares one is the acceptor: it reads the grouping, it does not pay it. Where the inviter is the sole receiver, Alcove presents the acceptor no group sizes and no row positions -- a display-scoped withholding, not a wire one: the matching rounds still deliver that grouping to the acceptor's own software, as shared-consent-summary.md records. That is a different sentence rather than the same one, so the consent surfaces hold both, selected by the invitation's output shape.
  • The default is the narrow one for the axis the derivation governs. false is one-to-one on the acceptor's own side, the behavior every non-deduplicating exchange already runs, so no invitation groups the acceptor's records.

The derivation governs the acceptor's own side and only that side, so the inviter's side takes a check rather than a shape. Two documents state it -- the invitation the acceptor consented to, and the terms their author presents after the connection opens -- and the term's per-party reading is exactly why no compatibility rule compares them. What the acceptor pays for a value it did not consent to is real even though its own records stay ungrouped: a presented true against a declared false is the widening below, more of the acceptor's records matching and disclosing their membership on the inviter's say-so alone. So the acceptance retains the declaration and the run holds the presented value to it, refusing before any key or payload moves. Retaining it in memory would bind only the one run an online acceptance conducts, and the acceptance's whole point is the config it leaves behind, so the declaration is persisted beside the payload records the acceptance already writes -- the normative field and the paths that write it are in EXCHANGE_FILE.md. The check is scoped to the invitation path by design: an exchange authored from two configuration files has no declaration between the parties, and the differing pair there is not a contradiction but the ordinary one-sided setup.

What the derivation does NOT hold constant is how many of the acceptor's records match, and the consent copy states it because the derivation cannot close it. A value the inviter holds on several rows is ambiguous under one-to-one and drops out of the round, so the acceptor's record holding it goes unmatched; under the deduplicating run the inviter contributes that value once and the record matches, disclosing its membership and any payload columns the acceptor sends. So the inviter's declaration alone widens the acceptor's own outbound disclosure, on identical acceptor data.

That is a widening rather than a new capability, which is why it is disclosed rather than refused: an inviter that collapsed its own duplicate rows before the exchange would match exactly the same records one-to-one, so nothing here is reachable only through the term. The acceptor is told, because a party told only that its records are not grouped would read the setting as costing it nothing.

The cost was a real asymmetry: from an invitation, only the inviting party could be the "many" side, and an acceptor that wanted ITS records grouped fell back to the per-party configuration path. The derivation is what closes the inviter's route to that side, not what closes the acceptor's own: the accepting party's value is an input to it, closed where the accepting seat offers no control and set by the operator where one does.

The web accept screen offers it, and states the pair rather than one value. The control sits with the invitation's own deduplicate in the terms the accepting party reviews, so the two settings are read where they are set, and the copy beside it names both parties' values and what that combination discloses (describeDeduplicatePair) rather than only whose records the invitation groups. The acceptor-side direction note keeps its widening sentence -- what the inviting party's value costs this party either way -- and closes on the control instead of on a configuration file the accepting operator may not have.

Where the inviting party receives no result, the pair sentence is not the whole account. An invitation declaring expects_output: false with share_with_partner: true hands the result to the accepting party alone, so a sentence naming the parties the result reaches names the reader by itself and reads as nobody else learning the grouping -- while the inviting party's process still reads it, in the register duplicate_grouping_display_limit holds for the mirror shape. The seat therefore states that shape in its own sentence and renders the classified fact beside it, selected from the run's own resolution (withholdsInviterAssociationTable): the trust-contingent one where the rounds reach that process, and the enforced one on the single-pass combination where the exchange withholds its half of the table. The register split is the same one the sole-receiver statement holds: what a client presents is the sentence's, what the exchange does with the grouping is a classified fact's.

The combination the run refuses is refused at the seat. Both parties deduplicating under a strategy that pairs no many-to-many is a property of the PAIR, so the screen reads it through resolveLinkageCardinality -- the boundary the run resolves the joint cardinality at -- rather than re-deriving the rule, and refuses exactly what the run refuses, before any key or payload moves. The refusal names the strategy to change and the one-sided pair to fall back to, so it is the combination that is refused rather than the setting.

The derivation hides a strategy-plus-deduplicate combination from the acceptor's own document, and the refusal timing is set so that one would be answered anyway. The acceptor's own side starts false, so an invitation pairing deduplicate: true with a strategy that matches no deduplicating cardinality would be in shape in the document the acceptor holds and would otherwise reach the agreed-terms boundary before anything answered it, having first rendered a consent display for a run that cannot happen. The accept path therefore reads the INVITING party's terms for that pair, alongside the count-only shape rules it already reads there: linkage_strategy is a mandatory-consistency term, so the invitation's value is the agreed one, and such a combination is answered before any consent display and before any connection. Both shipped strategies match one, so nothing reaches that refusal; it is kept because the shape of the hole is a property of the derivation rather than of the strategies, and a strategy added without a deduplicating match would fall straight into it. The agreed-terms boundary keeps the same refusal for the pair no accept path sees -- two separately authored configurations, where the combination is the partner's own document and arrives at the terms exchange -- and it stays symmetric there, each party asserting over both documents. A value the accepting party sets for its own side does not appear in the inviter's terms either, so the derivation asserts both rules a second time over the document it builds: that is what answers a count-only invitation, whose shape holds neither party's value open.

Why the direct exchange states the pair rather than refusing it ​

A direct exchange has no invitation between the two parties: each infers its terms from its own file and declares its own deduplicate on its own command line (--deduplicate on the zero-setup command, which the console's Direct confirm step offers beside the linkage strategy). So the seat holds one of the two values the pair is made of, where the accept seat holds both.

That is what decides the shape of the refusal. The accept seat refuses a settled pair: the invitation declares the inviting party's value, the operator sets its own, and the two together are what the run would resolve, so refusing them costs nothing that could have run. Refusing this party's deduplicate under single-pass at a direct seat would refuse a many-to-one match both strategies pair -- the partner leaving its own side closed -- and it would put the console's gate ahead of the command line it exists to prototype, which runs that configuration. The seat therefore reads the run's own both-sided refusal (assertBothSidedDeduplicateImplemented, the one refusal resolveLinkageCardinality makes over the pair rather than over one document) against the pair both parties declaring the term would make, and STATES the combination the run refuses in core's own words rather than holding Run on it. Reading the whole boundary would put every refusal under the screen's fixed title, which names the partner's declaration as the condition. The run's own refusal is unmoved: it stands after the terms exchange and before any linkage-key or payload data moves, symmetric on both parties.

The direction note takes the same reasoning. The accept seat's note asserts whose records are grouped, and both of its variants say something about the other party's side -- that it is never grouped, or that the operator sets it here. Neither is true at a seat that cannot read the partner's declaration, so the direct seat closes on where that side is declared instead (DEDUPLICATE_PARTNER_DECLARED_SIDE_NOTE), asserting nothing about a value nothing on the screen decides.

The seat's copy is also written in the second person, where every other seat's names the two invitation roles. The panel states there is no invitation for the partner to review, so nothing on it tells the operator which of those roles is theirs, and a reader mapping them the other way round reads the disclosure direction inverted -- the one thing the sentences exist to state. The matching-multiplicity headline, the account of what a deduplicating match discloses (DEDUPLICATE_PARTNER_DECLARED_DISCLOSURE_STATEMENT) and the direction note therefore name the operator and its partner, stating the same disclosure, the same direction and the same integrity limit their party-named siblings do.

What single-pass needed to pair the both-sided cardinality ​

The strategy's obstacle was never the resolution. Its receiver already holds both parties' whole per-key structure and replays the rounds locally, so the "many" rule on both sides is the two flags its replay already reads. The obstacle was the SENDER's side of the boundary: it receives the resolved table and, under every other cardinality, one half of that table keeps its distinctness, whose range check caps the table's length. A both-sided multiplicity leaves neither half distinct, so that check had nothing to hold the table's size to and the pair was refused rather than run unbounded.

What replaces it is the product the cascade's returned-list checks already bind the same table to: this party's rows times the partner's declared record count, both quantities the sender holds without reading the frame. Two properties made that the right figure rather than a new ceiling. It is the honest maximum -- a table above it is one no run of the two datasets could have produced -- so it refuses nothing an operator could have wanted. And it is the quantity the cascade binds, so the two strategies do not differ on it, which is also why the bound checks the returned table and takes no projectable refusal at prepare time: the cascade deliberately takes none on the same product, and the projection is what the advisory names instead.

The figure was measured rather than assumed, at the quadratic scale that reaches it: both parties on one shared value, so the resolved table is the whole product. What the measurement settled is that accepting a table at the bound is safe to do -- the per-pair cost is linear and modest on both sides, and the channel's per-frame bound beneath it caps a received table well before the product does. The numbers and the method are in PROTOCOL.md, beside the strategy's cell budget, which was measured by the same harness.

What the sender still cannot check is the pairing. That is the strategy's standing position rather than a new one -- its sender takes the resolution as the receiver's word under every cardinality -- but the room that word has is wider here, a both-sided table being any sub-multiset of the product where a one-to-one table is a partial matching. The bound, the non-decreasing rule and the no-repeated-pair rule are what the sender holds it to; the closure check holds the resolving party's own table to the round's blocks, and a party that wants the pairing held to the round's structure at its own boundary runs the cascade.

What stays open ​

  • Cross-round accumulation, above: the rule taken is the conservative one, and the case for the other is a real one.
  • The accept-time choice on the seat that does not offer it. The web accept screen offers the accepting party its own deduplicate, and the direct exchange offers each party its own (both above). alcove accept does not: it prompts for consent to the invitation rather than authoring terms beside it, so a control there is a prompt-shape question of its own.
  • The injectivity check under the single-resolver variant, the limit stated in PROTOCOL.md: the clause is derived from the candidacy rules the built resolver shape holds, and the send-everything variant would need it re-derived rather than reused.
  • Fan-out combined with a deduplicating cardinality. Both axes run under either strategy, at every cardinality, so an operator can configure them together, and the specified composition is what resolves it: the relaxed acceptance clause binds the many side's record, the width bound and the per-value uniqueness rule bind the fan-out, and the sweep applies them in the one canonical order. That is the design the two axes' rules imply rather than a separately weighed one, and the worked cases behind it are unit cases rather than a measured exchange at scale. What stays open is guidance for a template that declares both. The consent copy has the combination's own treatment: the fan-out note states the pairing count under each deduplicate pair, one-sided pairs included, and one sentence states the two halves read together wherever the terms declare both a candidate set the exchange matches and a deduplicate whose pair the accept takes (candidateSetChainsGrouping, and the entity closure it is read from).
  • How the grouping is shown to a person. The result FILE is fixed -- one row per pair, PROTOCOL.md -- because the record's commitments are reproduced from it and it therefore could not stay a front-end question. What a result VIEW makes of a group, and whether an operator is shown the grouping as a grouping rather than as repeated rows, is untouched.
  • An exact local locator in the result. Above: a local row-index column would make the re-supply exact under a duplicated identifier, at the cost of a result-file shape change; the condition is warned about instead.