BALANCE-RESOLVE (v6) — single-phase balance ownership¶
Retired for pipeline banks in v8.6.0. Every pipeline bank now scrapes via the direct-API hard model (
API-DIRECT-SCRAPE), whose shape emitsctx.balanceResolutiondirectly — so the standaloneBALANCE-RESOLVEphase below no longer runs for any bank. The phase and its ESLint isolation canaries stay in the tree to guard the boundary. This page is retained as the design record of the v6 ownership model.Lands in v8.4.0 (commit
c267d48b). The architectural shift that closes the v4 "universal-empty" gate and removes ~370 LOC of v5 attribution code from SCRAPE.
What changed¶
| v5 (before) | v6 (now) | |
|---|---|---|
| Who fetches balance? | SCRAPE attributed per-account responses from the captured pool | BALANCE-RESOLVE issues live api.fetchPost / fetchGet calls |
| Where does the per-card extractor live? | Strategy/Scrape/Account/BalanceExtractor.ts | Mediator/BalanceResolve/BalanceExtractor.ts |
| What does SCRAPE.post emit? | perAccountResponses (a partial Pool with attribution heuristics) | accountIdentities (cardDisplayId + cardUniqueId + bankAccountUniqueId triples) + balanceFetchTemplate (URL + method + body key) |
| Universal-empty result | "scrape failed" (Procedure fail) — ate legitimate empty months | Pass-through — only fails if BALANCE-RESOLVE hard-fails on universal MISS |
Sub-step contract¶
sequenceDiagram
participant SP as SCRAPE.post
participant RP as BR.pre
participant RA as BR.action
participant API as api.fetchPost / fetchGet
participant RPO as BR.post
participant RF as BR.final
participant PR as PipelineResult
SP->>RP: accountIdentities + balanceFetchTemplate
RP->>RP: dedupe by bankAccountUniqueId
RP->>RA: balanceFetchPlan (one entry per BA + __BULK__ for bulk)
RA->>API: Promise.all per plan entry
API-->>RA: per-BA response (or fail → quarantine)
RA->>RA: per-card extract (Visa Cal nested cards, Amex cardChargeNext)
RA->>RPO: balanceExtracted (Map)
RPO->>RPO: partition resolved vs missed
alt every card missed (universal miss)
RPO-->>RF: Procedure fail (scrape miss)
else partial / all resolved
RPO->>RF: balanceValidation { resolvedIds, missedIds, totalAccounts }
RF->>PR: balanceResolution (Map)
end
.pre — build the plan¶
Reads scrape.accountIdentities + scrape.balanceFetchTemplate. Default-deny when either is absent — returns Procedure fail. Emits balanceFetchPlan:
| Template kind | Plan output |
|---|---|
POST + postBodyKey | One entry per unique bankAccountUniqueId, body = { [postBodyKey]: id } |
GET + urlQueryKey | One entry per unique BA, ?<urlQueryKey>=<id> substituted |
GET + urlPathInterpolation | One entry per unique BA, <ID> replaced in URL path |
| Bulk (no key) | One entry with bankAccountUniqueId: '__BULK__' covering every card |
Source: BalanceFetchPlanner.ts.
.action — fetch + extract¶
- Dispatches all plan entries under one
Promise.allfor concurrency. - Quarantine pattern: per-fetch failures emit
balance-resolve.fetch.failure(warn) and produce'MISS'for the affected bank account. Other accounts continue. - Per-card extraction handles three shapes:
- Single-account banks (Hapoalim, Discount, Beinleumi) — straight body extraction.
- Per-card-record banks (Visa Cal —
result.bigNumbers[].cards[], Amex/Isracard —data.cardsList[].cardChargeNext.billingSumSekel) — find the card bycardUniqueIdor one of the display-id fields (last4Digits,cardSuffix,cardLast4,shortCardNumber,card4Number), then run the bulk extractor on its sub-record. - Bulk endpoints —
responses.get('__BULK__')services every card via the same body.
Source: BalanceResolveActions.ts (executeBalanceResolveAction).
.post — partition + universal-miss gate¶
| Condition | Outcome |
|---|---|
totalAccounts === 0 | succeed (no work to validate) |
missedIds.length === totalAccounts && totalAccounts > 0 | Procedure fail — universal MISS = real scrape failure |
| Otherwise | succeed; emit per-account balance.miss warn for each missed card |
.final — emit balanceResolution¶
Builds Map<accountNumber, number> (legitimate 0 preserved; 'MISS' → 0). Writes to ctx.balanceResolution which PipelineResult reads when assembling the final accounts array.
Observability¶
| Event | Level | When |
|---|---|---|
balance-resolve.fetch.start | info | Before each live fetch (one per plan entry) |
balance-resolve.fetch.success | info | Successful response |
balance-resolve.fetch.failure | warn | Quarantined per-fetch failure |
balance-resolve.post resolved=N missed=M total=K | debug | At .post partition |
balance.miss account=*** message=... | warn | One per missed account |
balance-resolve.final | info | REVEAL log at .final |
Every event carries correlationId: randomUUID() (one per phase invocation) + bankAccountTail4: maskTail4(bankAccountUniqueId) (last-4 of the BA, never the full id). See Observability → Structured events for the redaction guarantee.
Lint enforcement¶
Three ESLint canaries lock the boundary at commit time:
| Canary | What it rejects |
|---|---|
balance-resolve-isolation.canary.ts | BALANCE-RESOLVE code reaching outside its own folder |
no-balance-in-scrape.canary.ts | SCRAPE-zone code importing balance extractor / planner |
balance-fetch-only-in-balance-resolve.canary.ts | BalanceFetchPlanner imported anywhere outside BalanceResolve/ |
A PR that violates any of the three fails the canaries gate of the pre-commit hook (and the build CI gate).
Test coverage¶
| File | Tests |
|---|---|
BalanceResolveActionsV6.test.ts | Happy path: PRE → ACTION → POST → FINAL per shape (single, per-BA, bulk) |
BalanceFetchPlanner.test.ts | Planner dedup, default-deny, malformed URL |
BalanceResolveActionsCoverage.test.ts | Edge branches: null/array/primitive POST body, malformed JSON, extractor-false fall-through |
BalanceResolveCrossBank.test.ts | Per-shape cross-bank smoke (Hapoalim / Visa Cal / Amex) |
BalanceResolvePhase.test.ts | Phase orchestrator delegates to the right action |
BalanceExtractor.test.ts | BFS+ILS depth-5 extractor against every bank shape |