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.

flowchart LR PO["Purchase orders"] --> N{"N-way match"} R["Receipts"] --> N I["Invoices"] --> N N --> G["Groups<br/>full / missing_leg / variance"] N --> O["Orphans"] N --> C["Counts"]

Basic Usage

json
{
  "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:

json
{
  "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:

json
{
  "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:

json
{
  "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:

json
{
  "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:

  1. 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.
  2. 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.
  3. An earlier tool result. A db or custom-table action 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