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.

json
"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.
json
{ "authorization_line_id": "AUTH-1001-1", "quantity": 40, "line_amount": 4800.00, "date": "2026-08-02" }

Step 2: Pick the match keys

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

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

  • dateWindowDays compares each record's date field 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:

bash
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:

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

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

bash
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:

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

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

json
{
  "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_id and match_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.

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.

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. A doc: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 as budget_exceeded before 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-1 and auth-1001-1 are two groups. Normalize first.
  • Expecting left and right. There are no outputMatched, outputUnmatchedLeft, or outputUnmatchedRight settings; 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