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 |
matchOn |
string[] | Yes | 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 |
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 |
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.
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.
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