N-way match, end to end
Time: 40 minutes
What you'll build: a three-way match that groups purchase-order lines, receipts, and invoices on a shared key, sums partial deliveries and partial invoices inside each group, classifies every group, keeps the exceptions in a table, investigates them with a bounded agent, pauses for a payment-release approval, and leaves a record.
This is the pipeline the shipped case CTR-11 runs. Build it yourself to understand every setting, or open CTR-11 in Process Studio and skip to the results.
Base URL used below: https://your-hyphen.example.com with X-Org-Id.
Prerequisites
- An organization on a Hyphen engine. N-way matching is generally available; no feature flag.
- Three datasets that share one key. In this guide: purchase-order lines, receipts, and invoices keyed on
authorization_line_id. - A registered action or two for the agent to look things up with, for Step 6.
Step 1: Shape the legs
Each leg is a named set of records. The step accepts two to four.
"sets": [
{ "key": "purchase_order", "records": "@input.purchase_order_rows" },
{ "key": "receipt", "records": "@input.receipt_rows" },
{ "key": "invoice", "records": "@input.invoice_rows" }
]
records is an inline array or an @path reference to one: workflow input, a prior step's output, or a custom-table read. Every record needs the match-key fields and the fields you will compare.
Two rules about the fields you compare:
- Use additive fields. Configured numeric fields are summed within each group before the tolerance check. Quantity and extended line amount add up across partial receipts and partial invoices; a repeated unit price does not.
- Normalize before you match. Match keys compare exactly. Trim, case, and pad them upstream, or in a step before this one.
{ "authorization_line_id": "AUTH-1001-1", "quantity": 40, "line_amount": 4800.00, "date": "2026-08-02" }
Step 2: Pick the match keys
"matchOn": ["authorization_line_id"]
Every field in matchOn must match exactly across legs for records to land in the same group. A composite key such as ["contract_id", "line_number"] works the same way. A record whose key appears in only one leg is an orphan.
Step 3: Set the tolerances
"tolerances": {
"line_amount": { "type": "currency", "value": 0.01, "pairs": { "purchase_order:invoice": 0.05 } },
"quantity": { "type": "abs", "value": 0 }
}
| Setting | Meaning |
|---|---|
type: "abs" |
The summed values may differ by at most value in the field's own units |
type: "currency" |
The same, for money; use it for amounts so the comparison is read as money in the record |
value |
The allowed difference. Zero means the sums must be equal |
pairs |
Override value for one pair of legs, written "<leg>:<leg>". The order in the string does not matter |
A field with no tolerance entry is not compared. A group with every configured comparison inside tolerance is full; one outside is variance, and the discrepancy names the field and the legs.
Two more settings carry over from the two-way matcher:
dateWindowDayscompares each record'sdatefield across legs and allows that many days of drift.fuzzyThreshold(0 to 100) allows near-equal text, as in the two-way matcher.
rules accepts the same condition objects a step filter uses (equal, greaterThan, and, and the rest), evaluated on the grouped records.
Step 4: Keep the exceptions
Create the table the step writes non-full groups and orphans into, keyed on run_id and match_key so a replay updates a row rather than adding one:
curl -X POST https://your-hyphen.example.com/custom-tables \
-H "X-Org-Id: acme-corp" \
-H "Content-Type: application/json" \
-d '{
"name": "nway_match_exceptions",
"fields": {
"run_id": "text",
"match_key": "text",
"classification": "text",
"missing_legs": "text",
"discrepancy_fields": "text",
"details": "text",
"status": "text"
}
}'
Then point the step at it:
"exceptionTable": "nway_match_exceptions"
The runtime upserts one row per non-full group and per orphan, and never writes a full group:
| Column | Contents |
|---|---|
run_id |
The run that produced it |
match_key |
The group's key; for an orphan, orphan:<leg>:<index>:<digest> |
classification |
missing_leg, variance, or orphan |
missing_legs |
Comma-separated leg keys that were absent |
discrepancy_fields |
Comma-separated fields outside tolerance |
details |
Bounded JSON: the legs present, the roll-ups, and each discrepancy |
status |
pending_review when written; yours to advance |
Every row also carries provenance naming the runtime and the run.
Step 5: Put the step together and run it
{
"name": "three_way_match_before_payment",
"definition": {
"actions": [
{
"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"
}
}
]
}
}
Run it with three small sets:
curl -X POST https://your-hyphen.example.com/workflows/wfl-…/execute \
-H "X-Org-Id: acme-corp" \
-H "Content-Type: application/json" \
-d '{ "input": {
"purchase_order_rows": [
{ "authorization_line_id": "AUTH-1001-1", "quantity": 40, "line_amount": 4800.00 },
{ "authorization_line_id": "AUTH-1001-2", "quantity": 10, "line_amount": 1500.00 },
{ "authorization_line_id": "AUTH-1001-3", "quantity": 5, "line_amount": 250.00 }
],
"receipt_rows": [
{ "authorization_line_id": "AUTH-1001-1", "quantity": 25, "line_amount": 3000.00 },
{ "authorization_line_id": "AUTH-1001-1", "quantity": 15, "line_amount": 1800.00 },
{ "authorization_line_id": "AUTH-1001-2", "quantity": 10, "line_amount": 1500.00 }
],
"invoice_rows": [
{ "authorization_line_id": "AUTH-1001-1", "quantity": 40, "line_amount": 4800.00 },
{ "authorization_line_id": "AUTH-1001-2", "quantity": 10, "line_amount": 1650.00 },
{ "authorization_line_id": "AUTH-9999-1", "quantity": 1, "line_amount": 99.00 }
]
} }'
Read the run status. The step wrote four context keys; they are fixed and not configurable:
| Key | What you get for the input above |
|---|---|
@matchGroupCounts |
groups: 2, full: 1, missing_leg: 0, variance: 1, orphan: 2 |
@matchGroups |
AUTH-1001-1 is full: two partial receipts sum to the order and the invoice. AUTH-1001-2 is variance on line_amount |
@matchOrphans |
The AUTH-1001-3 order line and the AUTH-9999-1 invoice, each present in one leg only |
@matchGroupExceptions |
The three non-full results, the same rows now in nway_match_exceptions |
A key that appears in one leg only is an orphan, not a group. missing_leg is for a key present in at least two legs but not all of them: add a receipt for AUTH-1001-3 and it becomes a missing_leg group with the invoice absent.
The full result carries schema_version: "hyphen.match-group.v1". Classification is deterministic: the same rows and the same tolerances always produce the same groups.
Step 6: Investigate the exceptions
Give one bounded agent a fixed read tool for the exception table. It explains the batch; it does not reclassify.
Register the read tool once:
{
"action_name": "read_nway_match_exceptions",
"kind": "custom-table",
"description": "Read N-way match exceptions for the current run.",
"properties": {
"table": "nway_match_exceptions",
"operation": "read",
"where": { "run_id": "@__run_id" },
"limit": 1000
}
}
{
"type": "loop",
"filter": { "condition": { "greaterThan": [{ "length": "@matchGroupExceptions" }, 0] } },
"properties": {
"mode": "react",
"objective": "Read the current run's N-way match exceptions with the supplied tool. Explain each exception for a procurement reviewer, cite its match key and the legs involved, and do not decide whether to pay.",
"tools": [
{ "type": "action", "name": "read_nway_match_exceptions" }
],
"max_iterations": 6,
"result_key": "explanations"
}
}
The tool is read-only and fixed to the current run's exception rows. The match evidence reaches the agent separately from model-authored context, and the runtime grounds its claims on that evidence: 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 there are no orphans.
Step 7: Pause for the decision
{
"type": "PbotApproval",
"filter": { "condition": { "greaterThan": [{ "length": "@matchGroupExceptions" }, 0] } },
"properties": {
"comment": "Release payment for the full groups and decide the exceptions",
"request_payload": {
"counts": "@matchGroupCounts",
"exceptions": "@matchGroupExceptions",
"explanations": "@explanations"
}
}
}
The run pauses. The reviewer sees the counts, the exceptions, and the explanations in their task list. A rejected approval ends the run; the exception rows already written stay.
Step 8: Act and record
After approval, record the authorization with an idempotent upsert so a replay cannot double-book it:
{
"type": "custom-table",
"properties": {
"table": "payment_release_authorizations",
"operation": "upsert",
"keys": ["run_id"],
"values": ["@__run_id"],
"fields": {
"run_id": "@__run_id",
"full_groups": "@matchGroupCounts.full",
"exceptions": "@matchGroupCounts.missing_leg",
"approved": "@__approved",
"reviewer_note": "@__comment"
}
}
}
The record of the run, at GET /runs/:runId/evidence, keeps the counts, each exception, the explanations, the decision with who made it, and the effect in order.
Large inputs, replays, and signals
- Large inputs. Use
doc:references for large datasets. The result contract and exception rows stay the same. - Replays. Because exception rows are keyed on
run_idandmatch_key, running the same input again updates the same rows. - Webhooks.
nway_matcher.started,nway_matcher.completed,nway_matcher.failed.
As an agent tool
Register the step as an action of kind nway_match and declare it as a tool. The stored sets, record sources, match keys, tolerances, and rules are authoritative; the model supplies only the record fields those sources declare and cannot change the configuration.
This suits a bounded batch the model can hold in context. For thousands of rows, run the match as a workflow step and give the agent the exceptions, or expose a workflow tool that reads the rows from a table.
{
"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.
Large legs inside an agent
Three documents of 5,000 rows each will not fit through the model, and they do not have to. Pass the references instead of the rows: the model calls the tool with "purchase_order_rows": "doc:doc_…" and the engine resolves each reference to its rows before the match runs. The rows never enter the model's context. What the model gets back is bounded:
- the tool result is cut at 10,000 characters, keeping the counts and array lengths as a summary and the first part as a preview;
- the counts also reach the model as trusted match evidence, separate from anything it wrote, and its claims are checked against them. A "clean match" claim with exceptions present is refused and the model is told the real counts.
Two things to know when writing the objective:
- Name the documents by id, not by
doc:reference. Adoc:doc_…token written into the objective expands in place, so the whole document lands in the prompt. Three 5,000-row documents expanded that way exceed the run's input-token budget and the run stops asbudget_exceededbefore the first model call. - Tell the model to pass references. It will otherwise try to paste rows into the call, and its reply is capped per call, so a large paste is cut short and fails to parse.
Measured on a local engine: three 5,000-row CSVs passed by reference matched in one tool call, and the agent completed in nine seconds with 4,700 full groups, 100 variances, 200 missing legs, and 20 orphans. The same documents referenced with doc: in the objective stopped on the input budget in three seconds.
Budgets per run default to 100,000 input tokens, 50,000 output tokens, 150,000 in total, 100 model calls, and five minutes of wall time; an agent's config.llm_budget can raise or lower them. Documents extract up to 10,000 rows each.
Pitfalls
- Unit price instead of extended amount. Partial deliveries then look like variances. Compare quantity and extended amount.
- Keys that almost match.
AUTH-1001-1andauth-1001-1are two groups. Normalize first. - Expecting left and right. There are no
outputMatched,outputUnmatchedLeft, oroutputUnmatchedRightsettings; a group has no stable sides. Read the four fixed keys. - More than four legs. Split the match, or fold two sources into one leg upstream.
- A missing exception table. Without
exceptionTable, the exceptions exist only in the run context.
What You Built
- three legs grouped on one key, with partial receipts and invoices summed inside each group
- per-field tolerances, with a per-pair override
- an exception table that survives replays
- a bounded agent that explains exceptions and cannot decide them
- an approval that pauses the run, and an idempotent record after it
- the same pipeline as CTR-11, which you can now run from Process Studio instead
Verified on a local engine: an agent given the rows above in its objective called the tool once with the three arrays and reported groups: 2, full: 1, variance: 1, orphan: 2.
→ Next: Invoice reconciliation, the two-way version