N-Way Match
The N-way matcher reconciles two to four named datasets as one group. A purchase order line, its receipts, and its invoices land in one match group keyed on a shared field, with per-field tolerances and a classification for every group.
It is a separate step kind from the two-way Matcher. Existing two-way definitions, outputs, and callers are unchanged. N-way matching is generally available and needs no feature flag.
Basic Usage
{
"type": "nway_match",
"properties": {
"sets": [
{ "key": "purchase_order", "records": "@input.purchase_order_rows" },
{ "key": "receipt", "records": "@input.receipt_rows" },
{ "key": "invoice", "records": "@input.invoice_rows" }
],
"matchOn": ["authorization_line_id"],
"tolerances": {
"line_amount": { "type": "currency", "value": 0.01 },
"quantity": { "type": "abs", "value": 0 }
},
"exceptionTable": "nway_match_exceptions"
}
}
Records from the three sets that share an authorization_line_id form one group. Configured numeric fields are summed within each group before the tolerance check, so one purchase-order line reconciles against partial receipts and partial invoices without pairwise chaining.
Properties Reference
| Property | Type | Required | Description |
|---|---|---|---|
sets |
array (2 to 4) | Yes | Named legs. Each has a key and records: an inline array or an @path reference. Totals mode with compare: "none" also accepts one |
matchOn |
string[] | Yes, unless passes is set |
Fields that must match exactly to form a group |
tolerances |
object | No | Per-field policy: { "type": "abs" | "currency", "value": number, "pairs"?: { "<leg>:<leg>": number } }. pairs overrides the value for one pair of legs |
dateWindowDays |
number | No | Date tolerance in days, as in the two-way matcher |
fuzzyThreshold |
number | No | Text similarity threshold 0 to 100, as in the two-way matcher |
rules |
array | No | Custom conditions, as in the two-way matcher |
exceptionTable |
string | No | A custom table that receives one row per non-full group and per orphan |
outputReference |
string | No | Store a reference to the full result instead of inlining it, as in the two-way matcher |
The optional v2 properties add key policy, duplicate checks, ordered passes, netting, row identity, approved pairings, exception packets, open items across runs, and totals mode.
There is no joinOn alias and no output-key settings. The two-way outputMatched, outputUnmatchedLeft, and outputUnmatchedRight names are not reused because a group has no stable left or right side.
For partial deliveries and partial invoices, use additive fields such as quantity and extended line amount rather than a repeated unit price.
Result
The step writes fixed context keys; they are not configurable:
| Context key | Contents |
|---|---|
@matchGroups |
Grouped records: the legs present or missing, roll-ups, discrepancies, and a classification |
@matchOrphans |
Records whose match key appears in only one leg |
@matchGroupCounts |
Totals for groups, full, missing_leg, variance, and orphan |
@matchGroupExceptions |
The non-full groups and orphans, the same rows written to exceptionTable |
Without the optional v2 properties, the full result carries schema_version: "hyphen.match-group.v1".
Classification
| Classification | Meaning |
|---|---|
full |
Every leg is present and every configured comparison is within tolerance |
missing_leg |
At least one requested leg is absent from the group |
variance |
Every leg is present, but a configured comparison is outside tolerance |
orphan |
A record has no counterpart in any other leg; reported under orphans |
Classification is deterministic. The same inputs and the same tolerances always produce the same groups.
Exception Retention
When exceptionTable is set, the runtime upserts one row for every non-full group and every orphan. Give the table run_id and match_key as its business key; a replay then updates the same exception rather than creating a duplicate. Each row carries the classification, the missing legs, the discrepancy fields, bounded details, a status, and runtime provenance. Full groups are never written as exceptions.
Optional v2 Properties
Every property in this section is opt-in. A step that sets none of them runs exactly as described above. A step that sets any of them, including mode: "records", returns schema_version: "hyphen.match-group.v2": the same result shape (groups, orphans, and counts), plus the classes and fields below. A row the matcher cannot place with certainty becomes an exception, never a guess. Treat any class you do not recognize as an exception.
| Property | Shape | Description |
|---|---|---|
keyPolicy |
{ "missing", "blank" } |
missing: "fail" (default, the step fails) or "unkeyed". blank (null, empty, or whitespace): "group" (default, blank keys group by value as in v1) or "unkeyed". An unkeyed row is set aside for later passes and, if none places it, reported as an orphan flagged unkeyed |
multiplicity |
{ "expect": "one", "legs"?, "fingerprint"? } |
A group with more than one row from a listed leg is duplicate. legs defaults to every leg. Rows of one listed leg that share every fingerprint value make each group holding them possible_duplicate |
passes |
array (1 to 4) | Ordered passes, each { "name", "matchOn", "candidates"?, "tolerances"?, "dateWindowDays"? }. Replaces the top-level matchOn. A pass sees only the rows earlier passes left. With candidates: "unique", a group in which any leg has more than one row is ambiguous |
netting |
array (up to 4) | Rules that net rows within one leg before any pass: link: { "from", "to" } or pairOn, plus opposite (required with pairOn) and equal. A netted pair is class netted |
identity |
{ "scope"?, "batch", "row" } |
The fields holding each row's batch and row id. scope is a label or an @path. A row without its identity, or one identity twice in a leg, fails the step |
manualMatches |
{ "records" } or { "table" } |
Approved pairings, applied before anything else: an @path or an array, or every active pairing of the identity scope read from a custom table. Requires identity. A pairing whose rows changed, are missing, or are already used is manual_conflict |
packets |
object | Splits exceptions into packets with immutable evidence. See Packets |
carryForward |
{ "table" } |
Keeps exceptions open across runs in a custom table, per identity scope. Requires identity |
mode |
string | "records" (default) or "totals" |
totals |
object | Totals mode: exactly one amount field, { "<field>": { "signBy", "positive" } }. A row adds when its signBy value equals positive and subtracts otherwise |
balances |
{ "field", "opening", "closing", "required"? } |
Rows whose field equals opening or closing are balance rows. Records mode leaves them out of pairing. Totals mode proves opening plus movements equals closing per leg; with required: true, every set must hold exactly one opening and one closing row per group |
compare |
string | Totals mode: "pairs" (default) compares sides; "none" runs only the balance proofs |
compareOn |
string | Totals mode: "net" (default) compares net movements; "position" compares ending positions (a leg's closing balance, or opening plus net when it has no closing row) |
side |
string, on a set | Totals mode: sets with the same side are summed before comparison. Defaults to the set's key |
Totals mode does not accept keyPolicy, multiplicity, passes, netting, manualMatches, or carryForward. Records mode does not accept totals, compare, or compareOn.
Packets
| Field | Default | Description |
|---|---|---|
size |
10 |
Cases per packet, 1 to 20 |
groupBy |
"class" |
"class", "pattern", or "none" |
evidence |
"artifact" |
The only accepted value |
skipClaimed |
None | { "table" }: leaves out cases whose every source row is already claimed in that table; a manual_conflict case is always packaged. Requires identity |
patternOn |
None | Up to 5 fields whose values become part of the pattern. Requires groupBy: "pattern" |
history |
None | { "table", "minApprovals"?, "exclude"? }: counts approved dispositions per pattern and suggests rules. minApprovals is 2 to 1000 (default 5); exclude lists up to 20 dispositions not to count. Requires groupBy: "pattern" and identity |
Each packet stores an immutable evidence artifact, schema hyphen.nway-evidence-packet.v1, holding the complete source rows of every case. Suggested rules are never applied automatically.
v2 Classes
| Classification | Produced by | Outcome |
|---|---|---|
full |
As in v1 | Cleared |
netted |
netting |
Cleared |
missing_leg, variance |
As in v1 | Exception |
ambiguous |
candidates: "unique" |
Exception; the group carries candidate_count |
duplicate |
multiplicity |
Exception |
possible_duplicate |
multiplicity.fingerprint |
Exception |
manual_conflict |
manualMatches |
Exception; the group carries conflict_reason |
orphan |
As in v1 | Exception; unkeyed_orphans lists the ones keyPolicy set aside |
When more than one class applies to a group, the first in this order wins: ambiguous, duplicate, possible_duplicate, missing_leg, variance, full.
Every group carries matched_by (the pass, the netting rule, or manual:<approval_id>) and pass. @matchGroupCounts adds a count for every class, unkeyed, and by_pass (groups per pass, per netting rule, and for manual), plus balance_rows and carried when they are not zero. Every class except full and netted goes to @matchGroupExceptions and exceptionTable.
The step writes these extra context keys:
| Context key | Written when | Contents |
|---|---|---|
@matchPackets |
packets is set |
One entry per packet: packet_id, run_id, classification, case_count, case_keys, evidence (artifact_id, version, sha256, doc_reference), and, when identity is set, claim_rows, case_claims (unclaimed rows per case), claim_moves (rows already claimed, with the decision and version holding them), and supersessions (the pairing each manual_conflict case reports, with its approval). Pattern packets add pattern_key, pattern_description, scope (with identity), and history (with packets.history) |
@matchRuleSuggestions |
packets.history is set |
One entry per pattern in this run whose counted approvals reach minApprovals and all chose one disposition |
@matchCarryForward |
carryForward is set |
{ "table", "open", "closed" } |
v2 Exception Columns
For a v2 result, exceptionTable must also declare matched_by, candidate_count, candidate_ids, unkeyed, and source_row_ids. With packets, it must also declare packet_id, evidence_artifact_id, evidence_version, evidence_sha256, and evidence_offset, and with groupBy: "pattern", pattern_key. A table missing any of them fails the step with NWAY_EXCEPTION_TABLE_V2_COLUMNS_MISSING and the missing column names, before any row is written. Tables used only for v1 results need no change.
Examples
Two passes, with blank references set aside, payouts expected once, and same-leg reversals netted:
{
"type": "nway_match",
"properties": {
"sets": [
{ "key": "ledger", "records": "@input.ledger_rows" },
{ "key": "settlement", "records": "@input.settlement_rows" }
],
"keyPolicy": { "missing": "unkeyed", "blank": "unkeyed" },
"multiplicity": { "expect": "one" },
"netting": [
{ "name": "linked_reversals", "leg": "ledger", "link": { "from": "reversal_of", "to": "ref" }, "opposite": "direction", "equal": ["amount_minor"] }
],
"passes": [
{ "name": "reference", "matchOn": ["ref", "direction"] },
{ "name": "amount_date_account", "matchOn": ["direction", "amount_minor", "value_date", "account"], "candidates": "unique" }
],
"tolerances": { "amount_minor": { "type": "abs", "value": 0 } }
}
}
A statement that must prove its own balances before anything is matched:
{
"type": "nway_match",
"properties": {
"mode": "totals",
"compare": "none",
"sets": [{ "key": "statement", "records": "@input.statement_rows" }],
"matchOn": ["account_id", "currency"],
"totals": { "amount_minor": { "signBy": "direction", "positive": "credit" } },
"balances": { "field": "row_type", "opening": "opening_balance", "closing": "closing_balance", "required": true },
"tolerances": { "amount_minor": { "type": "abs", "value": 0 } }
}
}
The guide covers each option with worked results, and the data contract for statement reconciliation: N-way match, end to end.
Large Inputs
The same result contract applies as inputs grow. Use doc: references to keep large datasets out of model context, and use exceptionTable when reviewers need durable exception rows.
The runtime emits nway_matcher.started, nway_matcher.completed, and nway_matcher.failed on the organization's webhooks.
Usage as an Agent Tool
Register an nway_match action and declare it as a tool:
{
"action_name": "three_way_match",
"kind": "nway_match",
"description": "Match purchase-order, receipt, and invoice rows on the authorization line.",
"properties": {
"sets": [
{ "key": "purchase_order", "records": "@input.purchase_order_rows" },
{ "key": "receipt", "records": "@input.receipt_rows" },
{ "key": "invoice", "records": "@input.invoice_rows" }
],
"matchOn": ["authorization_line_id"],
"tolerances": { "line_amount": { "type": "currency", "value": 0.01 } }
}
}
Where @input.purchase_order_rows comes from. In an action used as a tool, @input means the tool call, not the workflow input and not the objective. The engine reads each leg's path, takes the field name after @input., and makes it a required parameter of the tool. So this action is presented to the model as a tool with three array parameters, and a call from the model looks like this:
{
"action": "three_way_match",
"action_input": {
"purchase_order_rows": [ { "authorization_line_id": "AUTH-1001-1", "quantity": 40, "line_amount": 4800 } ],
"receipt_rows": [ { "authorization_line_id": "AUTH-1001-1", "quantity": 40, "line_amount": 4800 } ],
"invoice_rows": [ { "authorization_line_id": "AUTH-1001-1", "quantity": 40, "line_amount": 4800 } ]
}
}
The model fills those three arrays from what it can see, which is one of three places:
- The objective. Paste the rows into it, as JSON or as a table. This is the only way in for a standalone agent started with
POST /agents/execute, whose body has no input field. - A document. Reference an uploaded CSV in the objective with
doc:doc_…; its rows are expanded in place and the model copies them into the call. - An earlier tool result. A
dborcustom-tableaction the agent called first, whose rows it then passes on.
Whatever the model sends under other names, such as its own sets or tolerances, is discarded and logged.
For large legs the model passes document references ("purchase_order_rows": "doc:doc_…") and the engine resolves them before the match runs; the rows never enter the model's context, and the result comes back bounded with its counts as trusted evidence. Details and measurements are in the guide.
The stored set definitions, record sources, match key, tolerances, and rules are authoritative; the model cannot replace them and supplies only the record fields those sources declare. Each records path of the form @input.<field> becomes a required array parameter of the tool named <field>, so here the model is asked for purchase_order_rows, receipt_rows, and invoice_rows and copies them from its objective, a doc: reference, or an earlier tool result. Suits a bounded batch; for large sets run the step in a workflow and hand the agent the exceptions. The walkthrough is N-way match, end to end. The runtime attaches the match evidence separately from model-authored context and grounds the agent's claims on it: a discrepancy claim is allowed only when the latest counts contain a missing leg, a variance, or an orphan, and a clean-match claim only when every group is full and the orphan count is zero.
The optional v2 properties that change how rows match also apply to a tool call; packets and carryForward take effect only in a workflow step. For a v2 result, grounding counts every exception class as a discrepancy and treats netted groups as cleared, like full.
Example: three-way match before payment release
The shipped case CTR-11 matches purchase orders, receipts, and invoices with this step, persists and investigates every non-full group, pauses for a payment-release approval, and records the authorization with an idempotent upsert. The AP Invoice Reconciliation template shows the same step in a starter definition.
→ Next: Custom Table