Skip to content

Banking Exception Resolution — Design Doc & Implementation Status

Status: fully implemented. This is the analog for banking (MFB) of what docs/exception_resolution.md already describes and ships for NIP (commercial). Everything in "Implementation status" below is real, wired, and test-covered — schema, fetch-function filtering, matching, the write path, recheck, cleanup, OUTCOME_HANDLERS/_TABLE_BY_DOMAIN registration, and (as of this update) landing a brand-new document into gl_transactions/bank_statements via _ingest_banking_document. See "Implementation status" for the design.

Note on this doc's history: sections below through "Decisions — resolved during implementation" describe the FIRST implementation pass (a hand-written signal-key-equality SQL tier). That tier was replaced — see "Superseding decision: reuse the core matcher directly" — but the earlier research (why reconcile_period was rejected, the matches-table/write-path findings, etc.) is still accurate and still the reasoning basis for the current design. Read the whole doc in order; don't skip to "Implementation status" without the context above it.

Read docs/exception_resolution.md first; this doc assumes familiarity with it and only explains where banking differs.

Purpose

NIP's not_found_gl/not_found_bank exception clusters can already be resolved: upload a new document, and a cheap, partner-wide, constant-BigQuery-call-count bulk match runs against the existing unreconciled backlog. Banking's not_found_gl/not_found_bank clusters (exception_service/banking/rules/not_found.py) have no resolution path yet — docs/exception_resolution.md's Open Items already flags this ("banking's not_found_gl/not_found_bank isn't wired to any resolution handler"). This doc researches what wiring it up actually requires, and lands on a recommended design — but banking's underlying matching machinery is different enough from NIP's that this isn't a copy-paste, and a few real decisions need confirming before implementation starts.

How this doc was produced

Investigated banking's actual architecture in depth — three separate research passes — specifically to answer a question raised twice during review: can this just call the standard, already-in-production reconciliation process, instead of building something parallel? The honest answer turned out to be "partially" — some pieces of the standard pipeline are genuinely reusable as-is, one path that looked promising turned out to be built on dead code, and one piece (the actual match decision) has no existing equivalent for banking's fuzzy, no-hard-key matching and needs new logic. All three findings are below, with the reasoning kept in so a reviewer doesn't have to just trust the conclusion.

Key findings

  1. Banking's transaction tables: gl_transactions/bank_statements (shared/core/infrastructure/schema.py) — the analog of NIP's cba_transactions/nip_transactions. Both carry composite_key, session_id (REQUIRED, same as NIP), is_reconciled (default FALSE). Neither has an ingestion_purpose column — that concept (distinguishing "normal reconciliation ingestion" from "exception-resolution re-ingestion") exists only on NIP's tables today.
  2. Dedup guard: the composite_key-based existence check lives in the generic ingestion ETL base (microservices/ingestion/src/ingestion/etl/reconciliation/base.py), not a banking-specific repository — already shared infrastructure, works for banking uploads as-is, no changes needed.
  3. Ingestion entrypoint: MicroFinanceBankReconciliationETL (microservices/ingestion/src/ingestion/etl/reconciliation/mfb/etl.py) — banking's analog of CommercialNipReconciliationETL, built on the same shared BaseReconciliationETL. This is what a resolution handler would call to land a newly-uploaded document.
  4. unmatched_gl/unmatched_bank are real, persisted tables (not views), written during a normal reconciliation run itself. Nothing recomputes them on demand. Resolution needs its own targeted cleanup step (delete by record_id) for rows it newly reconciles — same shape as NIP's remove_from_unmatched, doesn't exist yet for banking.
  5. No hard join key. NIP had reference_no/settlement_session_id — exact identifiers, cheap to MERGE on. Banking has neither. Matching is either:
  6. signal_key equality — a name-based key computed from each row's extracted_names JSON, falling back to cleaned narration. The expression logic lives in exception_service/banking/signals/signal_builder.py's build_signal_key_expr/build_canonical_name_key_expr — pure Python functions that return SQL fragments, genuinely reusable outside their current caller.
  7. fuzzy narration similarity / exact-amount-proximityexception_service/banking/rules/fuzzy_narration_match.py, EDIT_DISTANCE-based with window-function uniqueness constraints. More powerful, meaningfully more complex.
  8. build_signal_cte (the CTE wrapper every banking SQL rule uses to get signal_key onto a row) is hard session_id-scoped — it reads from unmatched_gl ug WHERE ug.id = @session_id (and the bank equivalent). Not reusable as-is for a partner-wide resolution rematch. A resolution-specific variant querying gl_transactions/bank_statements directly (by partner_id/tenant_id/branch_id + date window, not one session) is needed — the same move NIP's own redesign already made away from session-scoped views.
  9. Banking's exception-classification rules are SELECT-only. fuzzy_narration_match.py, amount_mismatch.py, date_difference.py identify and report candidate clusters — none of them ever write is_reconciled=TRUE. There is no existing MERGE-writing matcher anywhere in exception_service/banking/. The classification layer is purely diagnostic.
  10. The real is_reconciled-writing matcher is the core streaming/batch engineSmartReconciliationService/StreamingReconciliationMixin.reconcile_period (services/banking/reconciliation.py, domain/banking/streaming_reconciliation.py). See "The central decision" below for whether resolution can just call into this.
  11. No existing "rematch without re-ingesting" endpoint exists for banking. The closest thing, reprocess_cross_month (services/banking/reconciliation.py:1656), is a different, unrelated feature — whole-prior-month reprocessing with its own summary, not scoped to individual pending clusters, no ingestion_purpose-style tagging. (It turns out to matter for a different reason — see below.)
  12. NIP already made this exact call. NIP's own resolution never invokes the full NIP reconciliation pipeline either — it uses its own lightweight, purpose-built bulk-MERGE tiers (outcomes/not_found/matching/nip.py). The "cheap and efficient" bar this doc is trying to hit for banking IS "don't invoke the real pipeline" — a principle that turns out to apply even more strongly to banking, whose pipeline has heavier side effects than NIP's.

The central decision: can resolution just call the standard reconcile process?

This was investigated twice, deliberately, rather than assumed away.

Attempt 1 — reuse reconcile_period/reconcile_daily wholesale

Rejected. reconcile_period (domain/banking/streaming_reconciliation.py:681) is one monolithic, stateful pipeline: global matched-ID set tracking across batches, semaphore-bounded fetch→match→flush per batch, a per-batch Pub/Sub publish, an end-of-session Pub/Sub sentinel, an LLM-generated summary call (gemini_service.explain_reconciliation), and an aggregate session-record store (reconcile_repo.store, which itself fires twice — once per batch in the background, once again at session end).

Checked every side effect for an existing skip switch:

Side effect Skippable today?
Per-batch Pub/Sub publish (publisher.publish_matches) Yes — pass on_batch_flushed=<callback>; a real, already-plumbed parameter that bypasses this entirely.
End-of-session Pub/Sub sentinel (publisher.publish_session_end) No — unconditional, no parameter. Would need new code.
LLM summary (gemini_service.explain_reconciliation) No — unconditional, no parameter. Would need new code.
Aggregate session-record store (reconcile_repo.store, both call sites) No — unconditional, no parameter. Would need new code.

Only 1 of 4 side effects is avoidable without touching the core engine. The other 3 would each need a new flag/branch added to shared production code before this pipeline could be called narrowly and safely — real, invasive work, not a quick reuse.

One genuinely good finding from this attempt: when matching_method != "bulk" (banking's normal mode), _fetch_batch_window_data's fetch is not session-scoped at all — it already pulls the whole partner+tenant+branch unreconciled backlog for the given date window, regardless of which session originally landed each row. That's exactly the partner-wide scope this feature needs. The fetch shape was never the problem — the side effects are.

Attempt 2 — reuse reprocess_cross_month's pattern

reprocess_cross_month already does something narrower than a full reconcile run, which looked like a promising template: it fetches a specific unreconciled window via get_unmatched_gl_for_window/get_unmatched_bank_for_window (shared/core/repositories/banking/reconciliation_repository.py:564-710 — confirmed clean: partner+tenant+branch-wide, date-windowed, excludes already-reconciled rows), runs the result through DateRangeReconciliationHandler (shared/utils/date_range_map.py:28 — a cheap, stateless Polars date+amount pre-filter with no session dependency, confirmed safe to instantiate and call from anywhere), then hands the candidate pairs to self.batch_llm_matcher._run_individual_pipeline(...) (services/banking/reconciliation.py:1913) to actually decide which pairs are real matches.

That method does not exist anywhere in the codebase. Grepped batch_match_engine.py and every mixin (MFBReconciliationMixin, PaymentReconciliationMixin, AssetReconciliationMixin) — no _run_individual_pipeline is defined anywhere. reprocess_cross_month's match-decision step is dead/broken code today, not a battle-tested reference implementation. Building banking exception resolution on top of it would mean silently depending on (or first having to fix) unrelated, currently-broken production code as a prerequisite. Rejected for that reason — not because the pattern itself (fetch narrowly → prefilter → decide → write) is wrong in principle; it's the right shape, just resting on a broken piece right now.

(Separately worth flagging to whoever owns reprocess_cross_month: this is a live bug, unrelated to this feature. Not addressed here — see Open Decisions.)

What IS real and reusable, found in the same investigation

repo.mark_transactions_as_processed (shared/core/repositories/banking/repository.py:3475-3583) — a genuine, working, already-in-production bulk UPDATE ... SET is_reconciled, updated_at WHERE partner_id AND tenant_id AND record_id IN UNNEST(@record_ids) against gl_transactions/bank_statements, batched at 5,000 records with concurrent-write retry already built in. This is the real "standard reconcile process" write path — directly usable, no new UPDATE SQL needs writing. It does not touch unmatched_gl/unmatched_bank (confirmed — every existing caller treats that as a separate step), so a remove_from_unmatched equivalent is still needed regardless of which matching approach gets chosen.

Conclusion

Reuse every real, already-working piece of the standard pipeline — the fetch functions and mark_transactions_as_processed — and write new logic only for the one piece that has no existing equivalent for banking's fuzzy, no-hard-key matching: deciding which GL row matches which bank row. That's the smallest amount of genuinely new code possible, while staying off both the monolithic pipeline (Attempt 1) and the broken LLM-pipeline path (Attempt 2).

Issues found on closer implementation review

Checked the recommended design's actual reuse targets line-by-line before treating this as ready to build. Four real issues surfaced — the design below already reflects the fixes, but they're called out explicitly here since they weren't visible from the earlier architecture-level pass.

  1. mark_transactions_as_processed alone is not enough — it never records which row matched which. Read it in full (shared/core/repositories/banking/repository.py:3475-3586): it's a bulk UPDATE ... SET is_reconciled, updated_at and nothing else. Unlike NIP's cba_transactions/nip_transactions (which carry match_id/match_method columns directly), banking's gl_transactions/bank_statements have no such columns — match pairing lives in a separate matches table (schema.py:272-291: match_id, gl_record_id, bank_record_id, confidence, strategy, details, keyed by session id). Normal reconciliation writes to matches via reconcile_repo.store(...) — the same heavy, side-effect-laden function already rejected in "The central decision" (it also unconditionally writes an aggregate summary row, requires a data["summary"] key to even be called, and is the mechanism that produces the fake-session-in-reporting problem). Using only mark_transactions_as_processed would leave resolution-reconciled rows with no match-pair record at all — inconsistent with every other reconciled row, and invisible to anything that reports off matches. Fix: there's already a narrower, purpose-built function for exactly this — store_matches_streaming(session_id, matches, tenant_id, partner_id, branch_id) (reconciliation_repository.py:1387-1441). No fake summary needed, a real streaming insert into matches alone. Added as an explicit write step below.
  2. The ingestion_purpose exclusion filter (design step 2) must be added as an optional parameter, not a hardcoded WHERE. fetch_gl_transactions_polars/fetch_bank_statements_polars (repository.py:2776 / :2960) are the exact functions resolution's own candidate-fetch step (step 3) needs to reuse — and they're also where the new "exclude exception_resolution rows from normal runs" filter needs to go. If that filter is added as an unconditional WHERE ingestion_purpose = 'reconciliation', resolution's own reuse of the same function would filter out its own freshly-ingested rows, breaking the very fetch it needs. These functions already have the right idiom to copy — session_id is optional and only applied when passed ("AND session_id = @session_id" if session_id else "", line 2807); ingestion_purpose filtering needs the identical optional-parameter treatment, not a blanket clause.
  3. Resolution's own fetch/match step must NOT apply that exclusion filter to itself. Confirmed via NIP's own precedent (outcomes/not_found/matching/nip.py:26-30): NIP's resolution matching deliberately does NOT filter by ingestion_purpose, and its docstring says exactly why — doing so "would silently exclude the newly-uploaded exception_resolution rows this feature depends on." The same applies to banking: a resolution-ingested row that doesn't match anything on its first pass must still be visible to a LATER resolve call (it will never again be visible to a normal reconciliation run, since normal runs now exclude it per point 2) — so banking's new match-tier fetch must pass ingestion_purpose=None (fetch both tags) where a normal run would pass ingestion_purpose="reconciliation".
  4. mark_transactions_as_processed has no branch_id scoping (only partner_id/tenant_id + record_id IN UNNEST(...), confirmed in the signature). This is pre-existing behavior, already relied on as-is elsewhere (reprocess_cross_month uses the same function) — not something resolution introduces — but it means this write path implicitly assumes record_id is unique per partner/tenant across every branch. Not a blocker, just an inherited assumption worth being aware of rather than discovering mid-incident.

Mirrors NIP's package shape (outcomes/not_found/handler.py dispatches by request.domain; outcomes/not_found/matching/<vertical>.py holds vertical-specific matching), substituting banking's real pieces where NIP used its own hard-key MERGEs:

  1. Ingest: MicroFinanceBankReconciliationETL.run(...) (mirrors handler.py's existing _etl.run(...) call for NIP), landing rows into gl_transactions/bank_statements under a deterministic session_id — promote _deterministic_ingestion_session_id (currently private to NIP's handler.py) into exception_service/utils.py, since it's now needed by a second real consumer and carries no NIP-specific logic (it only hashes tenant/partner/branch/file_urls). Tag ingested rows with a new ingestion_purpose="exception_resolution" value.
  2. Schema: add an ingestion_purpose column to gl_transactions/bank_statements (mirrors NIP's cba_transactions/nip_transactions column). Thread an optional ingestion_purpose parameter (default "reconciliation", mirroring the existing optional session_id idiom already in fetch_gl_transactions_polars/fetch_bank_statements_polars — see issue #2 above) through banking's core query sites (_fetch_batch_window_data, the banking exception-service SQL rules) so a resolution re-ingestion is never swept into the next real reconciliation run as ordinary data. Must be opt-in, not a hardcoded WHERE — see issues #2/#3 above for why. This is the single largest piece of new surface area in this whole design — banking's core engine touches more query sites than NIP's did, so this needs care, not a one-line change.
  3. Fetch candidates: reuse fetch_gl_transactions_polars/fetch_bank_statements_polars (or the narrower get_unmatched_gl_for_window/get_unmatched_bank_for_window, the same functions reprocess_cross_month already relies on), scoped to partner_id/tenant_id/branch_id + is_reconciled=FALSE + a date window derived from the pending clusters — reuse window_from_clusters from exception_service/utils.py as-is, no changes needed there. Called with no ingestion_purpose filter (fetch both "reconciliation" and "exception_resolution"-tagged rows) — per issue #3, this is resolution's own rematch step, not a normal run, and must see previously-stuck resolution rows too.
  4. Match tier 1 (v1 scope — signal_key equality): computed inline via build_signal_key_expr/build_canonical_name_key_expr (the real matching semantics already live and in use, NOT build_signal_cte, which is session-scoped). Whether expressed as a bulk MERGE (mirroring NIP's bulk_match_by_reference_no shape) or as a SELECT feeding candidate (gl_record_id, bank_record_id) pairs into the write step below (mirroring reprocess_cross_month's fetch-then-write shape, reusing real functions instead of writing new UPDATE SQL) is a small remaining implementation choice — recommend the latter, since it reuses more real, tested pieces of the standard pipeline instead of hand-writing an UPDATE.
  5. Match tier 2 (fuzzy narration / exact-amount proximity): explicitly deferred, not built in this first pass — flagged below as an open decision. NIP itself shipped with 2 tiers to start; a 3rd tier here can follow the same path once there's real usage data to justify it.
  6. Write — two calls, not one (see issue #1 above):
  7. mark_transactions_as_processed(gl_record_ids, bank_record_ids, partner_id, tenant_id) — flips is_reconciled=TRUE on both sides. Real, working, no new code.
  8. store_matches_streaming(session_id, matches, tenant_id, partner_id, branch_id) — writes the actual pairing (gl_record_id/bank_record_id/confidence/strategy="signal_key") into the matches table, so resolution-produced matches are indistinguishable from normal-reconciliation ones to anything reading matches. Also real, working, no new code. session_id here is the deterministic resolution session_id from step 1.
  9. Recheck: same shape as NIP's _recheck_reconciledSELECT which known row_uids from pending not_found_gl/not_found_bank clusters are now is_reconciled=TRUE on gl_transactions/bank_statements.
  10. Cleanup: a new remove_from_unmatched-equivalent — DELETE FROM unmatched_gl/unmatched_bank WHERE record_id IN UNNEST(...) for newly-reconciled rows. Confirmed nothing existing does this for banking today; every caller of mark_transactions_as_processed treats it as a wholly separate step.
  11. Finalize, and generalize now rather than later: _recheck_reconciled/_finalize/remove_from_unmatched inside handler.py are currently hardcoded to NIP's tables/repository — a gap already flagged in docs/exception_resolution.md's Open Items ("ATM-wiring reality check"). With banking becoming a second real consumer, this is the right moment to generalize these three into parameterized shared helpers (table names + repository/fetch functions as parameters) rather than hand-writing a third near-duplicate copy whenever ATM eventually arrives.

Mechanical wiring points

Small, once the design above is confirmed:

  • resolution/repository.py: register "mfb": BANKING_EXCEPTION_CLUSTERS_TABLE in _TABLE_BY_DOMAIN.
  • resolution/service.py: register ("mfb", "not_found_gl")/("mfb", "not_found_bank") in OUTCOME_HANDLERS, pointing at the same not_found.resolve dispatcher — already vertical-agnostic at this layer, no change needed to the dispatcher's signature.
  • outcomes/not_found/handler.py: add an elif request.domain == "mfb": branch calling a new _resolve_banking_match(...), mirroring _resolve_nip_match's role — but internally calling the fetch functions + signal-key match step + mark_transactions_as_processed + store_matches_streaming described above, not a from-scratch MERGE.
  • New outcomes/not_found/matching/banking.py — banking's match-tier logic, mirroring matching/nip.py's role in the package, but calling into the reused pipeline pieces above instead of hand-written MERGE SQL.
  • No change needed to models.py's ClusterType enum (already covers not_found_gl/not_found_bank as the cross-domain union used by both verticals) or ResolveRequest.domain (already returns "mfb" for customer_type="mfb").

Decisions — resolved during implementation

  1. Reuse-first design (fetch functions + signal-key match step + mark_transactions_as_processed + store_matches_streaming; skip the monolithic pipeline and the broken reprocess_cross_month LLM path). Implemented as designed.
  2. v1 match scope = signal_key equality only, fuzzy tier deferred. Implementedoutcomes/not_found/matching/banking.py's bulk_match_by_signal_key is the only tier; see Open Items below for the deferred fuzzy tier.
  3. Added ingestion_purpose to gl_transactions/bank_statements, threaded as an optional parameter (default "reconciliation", None skips the filter entirely) on fetch_gl_transactions_polars/fetch_bank_statements_polars — mirrors the existing session_id-optional idiom already in those functions, so every existing caller (core matching engine, reversal engine, batch/dataflow workers) is unaffected by default, and resolution's own match step passes no filter at all (see issue #3 above). Implemented and tested.
  4. Promoted deterministic_ingestion_session_id out of NIP's handler.py into exception_service/utils.py — now imported by both _resolve_nip_match and _resolve_banking_match. Implemented.
  5. Generalized _recheck_reconciled to take table names + an id_column parameter ("row_uid" for NIP, "record_id" for banking) instead of hardcoding NIP's shape. _finalize needed no change at all — it already only called vertical_repo.remove_from_unmatched(gl_ids, bank_ids), which works via duck typing for any repository exposing that method; ReconciliationRepository.remove_from_unmatched (new, mirrors NIP's) is what makes banking's call work. Implemented and tested.
  6. Fuzzy narration/exact-amount-proximity tier 2 — superseded, see below (no longer a separate deferred tier; folded into the replacement design).
  7. reprocess_cross_month's broken _run_individual_pipeline call — left as discovered, not fixed here (unrelated feature). Flagged as an open item below.

Superseding decision: reuse the core matcher directly, retire the SQL tier

The signal-key-equality SQL tier above (item 2 in "Decisions — resolved") had a real, confirmed gap: a newly-ingested row with no extracted_names computes a narration-derived signal_key, while its real counterpart (an already-processed row) likely has a name-derived one — the two aren't equal even when they describe the same person, so the match is silently missed. Fixing this properly meant asking the same question that motivated rejecting reconcile_period in the first place: is there a piece of the real engine that's actually safe to call?

There is. reconcile_period's own matching step delegates to exactly one call: BatchLLMReconciliationMatcher.match_transactions_in_batch (domain/banking/batch_match_engine.py). Traced it fully:

  • Side-effect-free — no ContextVars, no session state, nothing tying it to the streaming pipeline around it. Every side effect rejected in "The central decision" above (Pub/Sub publish, LLM summary, session store) lives strictly above this call, in streaming_reconciliation.py's outer loop — not inside the matcher.
  • Degrades gracefully without extracted_names — it runs 4 phases in order: amount+date pairing → fuzzy narration similarity (no LLM) → name-component verification (needs extracted_names, skips cleanly without it) → LLM disambiguation fallback. A row lacking extracted_names still gets real matching power from phases 1–2, unlike the retired SQL tier's exact-equality-only approach.
  • Its output maps directly onto the write path already builtMatchResult (confidence, strategy, gl_record, bank_record, details) is exactly the shape store_matches_streaming already expects.
  • The real cost: it needs a GeminiLLMService instance (only actually used by the LLM-fallback phase, but required at construction), and — confirmed by search — this matcher class has zero existing standalone tests anywhere in the repo. This resolution feature is the first to exercise it in isolation (see tests/test_banking_exception_resolution.py's matcher-focused tests, all with a mocked gemini_service — no real Vertex AI call ever fires in the test suite).

Hard constraint honored: every change for this stayed inside exception_service/. BatchLLMReconciliationMatcher and GeminiLLMService are imported, read-only — no engine file (domain/banking/, services/banking/, etc.) was edited.

Design, replacing the retired SQL tier: 1. outcomes/not_found/matching/banking.py's bulk_match_by_signal_key is removed. Replaced by match_via_core_engine, in the same file. 2. Fetches candidates via the same fetch_gl_transactions_polars/fetch_bank_statements_polars calls the SQL tier's design already established (ingestion_purpose=None — same reasoning as before, unchanged). 3. Constructs GeminiLLMService() + BatchLLMReconciliationMatcher(gemini_service=...) fresh per call (mirrors NIP's NipReconciliationRepository() fresh-per-call pattern). 4. Builds customer_settings from a new BankingMatchConfig/DEFAULT_BANKING_MATCH_CONFIG dataclass (outcomes/not_found/matching/banking_config.py, exactly mirroring exception_service/banking/config.py's existing ExceptionConfig/DEFAULT_CONFIG pattern) — ResolveRequest's new optional matching_method/amount_tolerance/recon_time_search_forward/recon_time_search_backward fields always override the config default when the caller supplies them; the config only supplies the fallback. 5. Filters match_transactions_in_batch's output down to pairs touching a known pending row_uid (the matcher has no "target vs counterpart" concept — that scoping is exception_service's job, same as the retired SQL tier's own WHERE clause used to do). 6. Write path unchanged: mark_transactions_as_processed + store_matches_streaming, now fed real confidence/strategy/details from the matcher instead of the SQL tier's hardcoded 0.75/"signal_key"/{}.

No two-tier design — the core matcher isn't layered on top of the SQL tier as a fallback; it fully replaces it. The SQL tier's exact-key equality is a special case of what phases 2–3 already do more thoroughly, so keeping both would only add branching complexity for no coverage gain.

New real costs, accepted deliberately: an LLM call can now fire (phase 4, for genuinely ambiguous pairs only — phases 1–2 stay LLM-free and cover the common case), and this is the first production dependency on a matcher class with no prior isolation tests. Both are the accepted price of actually catching what the SQL tier missed.

Sequential walkthrough — function to function

A single "approved, file attached" resolve call for domain="mfb" runs through these functions, in this order. Read this alongside "Implementation status" below, which explains WHY each piece is built the way it is.

  1. resolve() (outcomes/not_found/handler.py) — entry point, called once per API request.
  2. decision != "reconcile" short-circuits to _bulk_status_flip() (no re-match) — not this path ("approved" on a not_found cluster is now a plain override that also short-circuits here — see docs/exception_resolution.md's "Branch scoping"/decision-value sections).
  3. Fetches every pending not_found_gl/not_found_bank cluster for the partner/tenant via repo.get_pending_clusters_by_type(...) — partner+tenant-wide, no branch filter (ResolveRequest has no branch_id field at all).
  4. Builds row_uid_to_cluster / gl_known_row_uids / bank_known_row_uids via index_known_row_uids() — the orphan rows this call is trying to satisfy.
  5. Reads the uploaded file off the request (bank_file_url for not_found_gl, gl_file_url for not_found_bank — see _EXPECTED_FILE_FIELD). No file → _write_noop_no_file_provided().
  6. request.domain == "mfb" → dispatches to _resolve_banking_match().

  7. _resolve_banking_match() (handler.py) — banking's pipeline body. UNLIKE this walkthrough's original (pre-branch-removal) shape, pending here can now span multiple branches (see docs/exception_resolution.md's "Branch scoping") — but the call still only ever does ONE ingest+match pass, scoped to the MAJORITY branch:

  8. banking_repo = ReconciliationRepository() — one repo instance for the whole call.
  9. pending is grouped by each cluster's own already-stored branch_id column, purely to find branch_id/branch_pending = whichever group has the most clusters — every OTHER branch's clusters are left alone this call (they're still in the full pending list _finalize sees at the end, so they get a "still pending" audit row, same as a genuine no-match). This deliberately does NOT loop per branch: the ingest ETL tags every row of an uploaded file with one branch_id uniformly (no per-row derivation), so ingesting the same file once per branch would insert duplicate transaction rows under multiple, mostly-wrong branch tags — a real correctness bug, not just an efficiency one.
  10. ingestion_session_id = deterministic_ingestion_session_id(tenant_id, partner_id, branch_id, file_urls)branch_id here is the majority branch's own real value (not read from the request, which has none).
  11. start_date, end_date = window_from_clusters(branch_pending).
  12. Splits file_urls into gl_urls/bank_urls (only one side is ever non-empty).
  13. Calls _ingest_banking_document(...) (step 3 below), then match_via_core_engine(...) (step 5), then _recheck_reconciled(...) (step 6), then _finalize(...) (step 7, against the FULL pending list so non-majority-branch clusters still get audited as still-pending), and returns _finalize's result.

  14. _ingest_banking_document() (handler.py) — thin wrapper, forwards straight to:

  15. ingest_banking_document() (outcomes/not_found/banking_ingest.py) — the real ingestion logic:

  16. Determines side ("gl" or "bank_statement") and file_url (whichever one was actually uploaded).
  17. gemini = GeminiLLMService() — one instance, reused for both sub-steps below.
  18. _infer_column_mapping(gemini, file_url, side):
    • _ext_from_url() / _processor_and_file_type() pick the matching static sampler by extension — SmartReconciliationService._process_excel_for_column_match / _process_csv_for_column_match / _process_pdf_for_column_match (borrowed read-only).
    • Samples a few rows of the file (CSV stays a URL for this step; Excel/PDF get downloaded via AppUtil.get_file_stream_or_path).
    • gemini.set_model(target_mapping_model(...)) then gemini.adaptive_header_transformation(target_fields, sample_rows, switch_type="none") — the same LLM call behind POST /reconcile/target-field-suggestion — returns suggested system → title mappings.
    • Reshapes the result into {"gl": {system: title}} or {"bank_statement": {system: title}} (drops anything mapped to "others").
  19. Downloads the file again, this time in FULL (a real stream, not a sample) via AppUtil.get_file_stream_or_path.
  20. etl = MicroFinanceBankReconciliationETL(gemini_service=gemini, bq_repository=banking_repo) then await etl.process_both_streams(tenant_id=..., session_id=..., partner_id=..., branch_id=..., gl_files=[...] or [], bank_files=[...] or [], column_mappings=..., period_start_date=..., period_end_date=...) — the exact same parser/inserter every normal banking upload goes through (full Excel/CSV/PDF support), living in the separate ingestion microservice, called but never modified.
  21. _tag_ingestion_purpose(banking_repo, table, session_id, partner_id, tenant_id) — one UPDATE gl_transactions/bank_statements SET ingestion_purpose = 'exception_resolution' WHERE session_id = @session_id AND partner_id = @partner_id AND tenant_id = @tenant_id, via banking_repo.run_dml_with_retry(...).
  22. Returns to _resolve_banking_match().

  23. match_via_core_engine() (outcomes/not_found/matching/banking.py):

  24. Fetches candidates via banking_repo.fetch_gl_transactions_polars(...) / fetch_bank_statements_polars(...), both with ingestion_purpose=None (sees ordinary AND resolution-tagged rows — must, or a resolution-ingested row that misses once becomes invisible to every future attempt).
  25. Builds customer_settings from DEFAULT_BANKING_MATCH_CONFIG, overridden by any matching_method/amount_tolerance/recon_time_search_forward/recon_time_search_backward the request supplied.
  26. BatchLLMReconciliationMatcher(gemini_service=GeminiLLMService()).match_transactions_in_batch(gl_df, bank_df, customer_settings) — the real core-engine matcher (amount+date pairing → fuzzy narration → name verification → LLM disambiguation fallback).
  27. Filters matches down to only pairs touching a gl_known_row_uids/bank_known_row_uids entry (an actual pending cluster's orphan row) and returns that list.

Back in _resolve_banking_match(): if matches is non-empty, banking_repo.mark_transactions_as_processed(...) then banking_repo.store_matches_streaming(...) write the result.

  1. _recheck_reconciled() (handler.py) — two concurrent SELECTs against gl_transactions/bank_statements confirming which known row_uids are now is_reconciled = TRUE.

  2. _finalize() (handler.py), run concurrently:

  3. banking_repo.remove_from_unmatched(...) — cleans resolved rows out of unmatched_gl/unmatched_bank.
  4. _cluster_bookkeeping() — one bulk repo.bulk_enrich_and_approve_clusters(...) UPDATE + one bulk repo.bulk_write_resolution_rows(...) audit INSERT covering every resolved cluster (plus a "still pending" audit row for anything that didn't match).
  5. Returns the final per-cluster result list up through _resolve_banking_match()resolve() → the API caller.

Implementation status

Fully implemented and test-covered (tests/test_banking_exception_resolution.py, plus updates to tests/test_exception_resolution_not_found.py, tests/test_exception_resolution_models.py, and tests/test_exception_service_utils.py): - Schema: ingestion_purpose column on gl_transactions/bank_statements. - fetch_gl_transactions_polars/fetch_bank_statements_polars's optional ingestion_purpose parameter. - outcomes/not_found/matching/banking.py's match_via_core_engine — reuses BatchLLMReconciliationMatcher.match_transactions_in_batch directly (read-only import from domain/banking/batch_match_engine.py), fed by the same fetch functions, filtered to known pending row_uids. See "Superseding decision" above for why this replaced the original signal-key SQL tier. - outcomes/not_found/matching/banking_config.py's BankingMatchConfig/DEFAULT_BANKING_MATCH_CONFIG — fallback customer_settings values, overridden by ResolveRequest's new optional matching_method/amount_tolerance/recon_time_search_forward/recon_time_search_backward fields when the caller supplies them. - ReconciliationRepository.remove_from_unmatched — deletes by record_id from unmatched_gl/unmatched_bank, via run_dml_with_retry. - outcomes/not_found/handler.py's _resolve_banking_match — the full pipeline (match via the core matcher → mark_transactions_as_processedstore_matches_streaming_recheck_reconciled_finalize), plus the elif request.domain == "mfb": branch in resolve(). - resolution/repository.py's _TABLE_BY_DOMAIN["mfb"] and resolution/service.py's OUTCOME_HANDLERS[("mfb", "not_found_gl"/"not_found_bank")]. - _ingest_banking_document (outcomes/not_found/handler.py, thin wrapper) / outcomes/not_found/banking_ingest.py (the real logic) — lands a newly-uploaded GL export or bank statement into gl_transactions/bank_statements so match_via_core_engine has real rows to match against. Design, in full: 1. Column-mapping inference reuses the same LLM-based auto-mapping behind the platform's own POST /reconcile/target-field-suggestion endpoint (SmartReconciliationService.column_match, services/banking/reconciliation.py) — but at the PRIMITIVE level: the static sample processors (_process_excel_for_column_match/_process_csv_for_column_match/_process_pdf_for_column_match) plus a standalone GeminiLLMService.adaptive_header_transformation call, instead of constructing a full SmartReconciliationService (whose constructor eagerly builds a second BigQueryRepository, a ReconciliationPublisherHandler, a BatchLLMReconciliationMatcher, an OptimizedGLReconciliation, and Redis trackers that column_match's own logic never touches). column_match itself can't be called as-is here either way: it hard-requires both gl_file_url/bank_file_url populated, but not_found resolution only ever uploads ONE side. Known simplification: only column_match's direct system→title mappings are taken (its "others"/extra_fields refinement — computed-remaining-columns, asset-class dedup — is skipped; this only affects bookkeeping metadata, never the core fields matching depends on). 2. Full file parsing + insert reuses BaseReconciliationETL.process_both_streams (via MicroFinanceBankReconciliationETL) — the same engine every normal banking upload goes through, full Excel/CSV/PDF support. This is a deliberate reversal of the "avoid the heavy pipeline" stance taken for reconcile_period/matching above: there, the pipeline's side effects (branch auto-creation, Pub/Sub name extraction) were wasteful for narrow matching. Here they're wanted: branch auto-creation is a safe no-op (the branch already exists — branch_id is already known from the pending cluster), and Pub/Sub-driven name extraction is exactly how extracted_names gets populated for free, with no separate NameExtractor call needed. 3. ingestion_purpose tagging: process_both_streams has no such parameter (it's exception_service's own column, invisible to engine code), so newly-inserted rows land tagged NULL — which fetch_gl_transactions_polars/fetch_bank_statements_polars's own COALESCE(ingestion_purpose, 'reconciliation') filter would otherwise treat as ordinary "reconciliation" data, defeating the whole point of tagging. Fixed with one follow-up UPDATE ... SET ingestion_purpose = 'exception_resolution' WHERE session_id = @session_id AND partner_id = @partner_id AND tenant_id = @tenant_id, scoped by the deterministic session_id unique to this resolve call, via banking_repo.run_dml_with_retry. 4. Efficiency: the SAME ReconciliationRepository instance _resolve_banking_match already constructs (banking_repo) is reused as bq_repository for the ETL — no second BigQuery-backed repository. The SAME GeminiLLMService instance is reused for both the mapping call and the ETL construction — one Vertex AI client per resolve call, not two.

Everything above lives in exception_service/ (outcomes/not_found/banking_ingest.py + the handler.py wrapper); SmartReconciliationService, BaseReconciliationETL/MicroFinanceBankReconciliationETL, and GeminiLLMService are imported and called, never modified.

Open items (not implemented this pass)

  • partner_name/branch_name passed to process_both_streams fall back to partner_id/branch_id (no cheap name lookup identified) — cosmetic, since branch auto-creation is a no-op given the branch already exists; not load-bearing.
  • reprocess_cross_month's broken _run_individual_pipeline call — a real, pre-existing bug discovered during this research, unrelated to this feature. Not fixed here.
  • BankingMatchConfig's default values (amount_tolerance=0.01, recon_time_search_forward/backward=30 days) are reasonable placeholders, not sourced from real per-partner configuration (none is reachable without reaching into engine internals). Payload overrides handle any call that needs something different; revisit the defaults themselves once real usage shows they're off.
  • Phase 4 (LLM disambiguation) cost/latency is unconditional — no way to cap/disable it for a resolve call that wants to stay LLM-free (phases 1–3 only). Would need a customer_settings flag the matcher already respects, or doesn't — not checked in this pass.
  • Full auth/actor-identity/read-history-API gaps — platform-wide items already tracked in docs/exception_resolution.md's Open Items and apply equally here.