Run the Reconciliation Matching Engine
| Owner | Classification | Version | Effective | Next review | Status |
|---|---|---|---|---|---|
| Platform Engineering | Internal | 0.2 | 2026-09-01 | 2026-12-01 | Draft |
Purpose. Operating guide for the reconciliation matching engine — how matched output becomes reconciliation work owned by a preparer, the bridge that makes that link, and the match-status semantics that are easy to read wrong. Rule-level detail (per-config rules, thresholds, field mappings) is reference material and is not repeated here.
The chain (matching → account → preparer)
Matching output, reconciliation items, and preparer assignment live in different tables joined by one bridge. Miss the bridge and matching looks account-less.
finbase.matching_output ← engine output (per source↔target candidate)
│ matchtype ∈ {Automatic, Manual, Suggested, Partial} ← the REAL status
│ matchstatus ∈ {Matched, Unmatched} ← coarse (see semantics)
│ matching_config_id · sourceentity · sourceid
▼
reconciliation.openitemtransitionrule ← THE BRIDGE (matchingconfigid ↔ accountid)
│ accountid · matchingconfigid · sourceentity · glmapping · field mappings
▼ (applied by the transitionopenitems workflow)
reconciliation.reconciliationitem ← open items landed on an account
│ accountid · matchingconfigid · matchingitemid · istransitioneditem
▼
reconciliation.periodsummary ← the account × period reconciliation
│ accountid · preparerid
▼
PREPARER (periodsummary.preparerid → finbase.businessuser → sys.user)The account is stamped onto each reconciliation item from the transition rule (accountid = rule.accountid) — the rule is the matching_config ↔ account declaration. There is no other place that link is made.
Two feeds into reconciliation items — and which one runs today
Don't conflate these. An open item reaches a reconciliation by one of two paths:
- A · Direct import — the path that runs today. Open items are loaded via
reconciliationitemimport(or seed). They carry no matching linkage (matchingconfigidNULL,istransitioneditemunset,createdbyNULL). Every currentreconciliationitemcame this way. - B · Matching transition — the newer, not-yet-wired feed.
matching_output→transitionopenitems(viaopenitemtransitionrule) →reconciliationitemstamped withmatchingconfigid/accountid. Needs the bridge (0 rules today), so it does not run — which is why the chain above is severed and every item'smatchingconfigidis NULL.
So the matching engine and the reconciliation open items are, today, two separate worlds: matching output never becomes reconciliation work.
Open-item lifecycle & rollover
- A
reconciliationitemis one row:opendate→ (open) →closedate, withitemeffectiveperiod. There is noperiodid— the dates are the period linkage. - Rollover = persistence, not copy. An open item (
closedateNULL) stays open across periods, ageing byopendate(agingbucket), until it is cleared/closed. There is no per-period duplication — the same row appears in every period it is open. (The period-maintenance workflows have no "copy to next period" step, consistent with this persist-until-closed model.) - The "rolled-over open items" a preparer sees are therefore the
reconciliationitemrows — already reflected in the Unreconciled figure (period + preparer scoped viaperiodpreparermetrics), not in matching counts.
Trigger
| Method | Detail |
|---|---|
| Matching | The recon matching workflow produces finbase.matching_output for a matching_config (applicationcode in recon/reconhub). |
| Transition | transitionopenitems (Temporal, reconciliationwf) reads an openitemtransitionrule, pulls its config's matchstatus='Unmatched' output, and writes reconciliationitem rows stamped with the rule's accountid. |
| Assignment | setupreconciliation builds periodsummary rows for a period and carries the preparer/reviewer/approver assignment (from accountsettings). |
The wfw worker and Temporal must be running; triggers async-start and return a handle, not a result.
Stages
- Match — the engine writes
matching_output, one row per source↔target candidate. - Transition —
transitionopenitemsmoves Unmatched (open) items onto an account asreconciliationitemrows (auto-matched items are cleared and are not transitioned). - Reconcile / certify — the assigned preparer works the account's items; review and certification follow.
Prerequisites that silently disable the link
These fail green — matching succeeds and the data simply never reaches a preparer.
| Requirement | Symptom if missing |
|---|---|
openitemtransitionrule seeded with accountid for each recon matching_config | matching_output fills, but reconciliationitem.accountid/matchingconfigid stay NULL — matching is account-less and cannot roll up to a preparer |
transitionopenitems actually run after matching | open items never appear on the account's reconciliation |
matching_config.source_entity resolves via tabledefinition.targettable | the transition activity throws (no source table to read) |
preparer assigned on periodsummary/accountsettings | items land on an account owned by no one |
The bridge (openitemtransitionrule.accountid) is an onboarding config decision — it is not something a workflow invents. Seeding matching alone leaves the chain severed at the bridge.
Match-status semantics (read before counting)
matchstatusis coarse — only{Matched, Unmatched}. The real status is inmatchtype:Automatic > Manual > Suggested > Partial > else Unmatched.- A Suggested match is deliberately stored as
matchstatus='Unmatched'+matchtype='Suggested', N rows per source (one per candidatetargetid, sharing amatchsetid). Socount(matchstatus='Unmatched')overstates breaks — suggestions are candidates awaiting review, not exceptions. - Canonical derivation exists —
matching_output.service.ts :: getEntityStatusDistributioncollapses each source viaMAX(CASE … matchtype …), withMatched = All − Unmatched. Always derive frommatchtype, never from rawmatchstatus. - Open vocabulary decisions are tracked in blitz#1380 (the
mo_unmatched_uniquepartial index omitstargetidand rejects legitimate 1:N suggestion sets; the prisma comment claimingmatchstatus ∈ {auto_matched, suggested, no_match}is stale). Until resolved: derive frommatchtype.
Preparer-scoped rollup (the intended query)
getEntityStatusDistribution is scoped per (matching_config, sourceentity). A preparer/period rollup joins through the bridge:
preparer's periodsummaries (preparerid, periodid)
→ accountid
→ openitemtransitionrule.accountid ⇒ matchingconfigid (the bridge)
→ finbase.matching_output (matching_config_id)
→ aggregate by matchtype (Automatic / Suggested / Unmatched / …)Blocked only on the bridge being seeded (needs the accountid each recon config reconciles). Without it, matching can only be surfaced engine-level (by config / applicationcode), not per preparer.
matching_output has no period either — it's run-scoped
Beyond the missing account link, matching_output carries no periodid — only run_id, run_timestamp, createdon. So an aggregate over it is all-time / all-runs cumulative (e.g. 2,039 Automatic spans all 3 runs), not "this close". Period scoping would need a run_id → period or sourceid → reconrecord.date → period mapping — neither exists cleanly today.
Consequence for dashboards: matching counts are neither period- nor preparer-scoped, so they do not belong on a period+preparer view (the preparer dashboard). They fit an engine / controller / admin surface where a tenant-wide "N auto-matched" is the right altitude. On the preparer dashboard, the real rolled-over open items are the Unreconciled figure (from periodpreparermetrics), which is period+preparer scoped — matching is not a substitute for it.
What's derivable (today / once wired / caveats)
The schema is sufficient to derive everything we'd want — it's a wiring question, not a model-shape problem.
Derivable today (already wired):
- Open items per preparer / per period / by type / aged —
reconciliationitem(accountid,opendate/closedate,itemeffectiveperiod) →periodsummary(accountid,preparerid). Theopendate→closedatelifecycle is the rollover, so carry-forward + ageing fall out for free. - Preparer reconciled / unreconciled / auto-certified $ and % —
periodpreparermetrics.
Derivable once the bridge is seeded (openitemtransitionrule + transitionopenitems):
- Unmatched/suggested items attributed to an account + preparer (the transition stamps
accountid+matchingconfigidand the item carries lifecycle dates → period- and preparer-scoped automatically). - Auto-match rate per account/preparer via the config↔account bridge (
openitemtransitionrule.accountid ⇒ matchingconfigid ⇒ matching_output).
Caveats (true even when fully wired):
- Auto-matched items never become
reconciliationitems (transitionopenitemstransitions unmatched only). Attribute auto-matched through the config↔account bridge — a different join than open-items-per-preparer. Both work; not the same path. - Period-scoping matching needs a convention —
matching_outputhas noperiodid. Transitioned items get period free (viaopendate); auto-matched needs arun → period(or source-date → period) mapping. A decision, not a given. - Granularity ceiling — one transition rule stamps one
accountidper config. A config whose output spans many accounts (e.g. PAY2CREDIT across many customers) needs per-account rules or a mapping to split it, else everything lands on one account.
Net: derive whatever we need, provided we (a) seed the config↔account bridge, (b) adopt a run→period convention for auto-matched, (c) model configs↔accounts at the reporting granularity. None of these fight the schema.
Verify
-- 1. engine output present, by REAL status (not raw matchstatus)
SELECT matchtype, matchstatus, count(*)
FROM finbase.matching_output GROUP BY 1,2 ORDER BY 3 DESC;
-- 2. is the bridge configured? (0 rows = severed chain)
SELECT count(*) FROM reconciliation.openitemtransitionrule WHERE isactive;
-- 3. did the transition stamp accounts onto items?
SELECT count(*) total,
count(matchingconfigid) linked,
count(accountid) with_account
FROM reconciliation.reconciliationitem;
-- 4. can a preparer be reached from a matched item?
SELECT ps.preparerid, count(*)
FROM reconciliation.reconciliationitem ri
JOIN reconciliation.periodsummary ps ON ps.accountid = ri.accountid
WHERE ri.matchingconfigid IS NOT NULL
GROUP BY 1;Current state (2026-08-29)
The demo/use-case data is seeded at the matching layer only:
finbase.matching_output≈ 2,086 rows —Automatic/Matched2,048 ·Manual/Matched6 ·Suggested/Unmatched32. Populated config: 31 PAY2CREDIT (reconhub, paymentadvice ↔ creditcardsales). Configs 27RECON-BANK-GL/ 28RECON-SUB-GLexist but have 0 output (never run).matching_outputspans 3 runs (run_id), so any aggregate is all-time.reconciliation.openitemtransitionrule= 0 rows → bridge unconfigured.reconciliation.reconciliationitem≈ 284 rows, all open (closedateNULL),itemeffectiveperiod = 2026-07-31, none transitioned (istransitioneditemunset,createdbyNULL) → direct-imported (path A),matchingconfigidall NULL. These are the real rolled-over open items; matching output has not reached them.
To make matching preparer-visible: seed an openitemtransitionrule (config → accountid), run transitionopenitems, confirm assignment on periodsummary.
Operational constraints
- The
wfwworker must be running. In development it bundles workflows from source on respawn; in production a changedreconciliationwfworkflow requiresbuild:workflowbefore deploy. transitionopenitemstransitions Unmatched items only — auto-matched items are terminal and never become reconciliation items; attribute auto-matched to a preparer via the config↔account bridge, not via reconciliation items.
Related
- How-to: Run the Cash Application Engine — shares
finbase.matching_config/matching_output; samematchtypesemantics. - Reference (blitz repo):
src/libs/recon/docs/— matching config JSON + seed SQL.
Revision history
| Version | Date | Author | Change |
|---|---|---|---|
| 0.1 | 2026-08-29 | Platform Engineering | Initial draft — matching→account→preparer chain, the transition-rule bridge, matchtype vs matchstatus (#1380), preparer rollup query, verification, current state. |
| 0.2 | 2026-09-01 | Platform Engineering | Added the two-feeds distinction (direct import vs matching transition) + open-item lifecycle/rollover (persist-until-closed, no periodid); documented that matching_output is run-scoped (no period) → matching counts belong on engine/controller views, not the period+preparer dashboard. |