Skip to content

financing — full tool reference

Accounts, journal entries, ingest, staging, reconciliation, routing.

Endpointhttps://financing.mcp.devfellowship.com/mcp
Packagepackages/dfl-mcp-financing
Tools79
ToolDescription
ingest_nubankIngest a Nubank PF or PJ statement (CSV format) into financial.reconciliation_staging. DIRECTION: the description verb (“Transferência enviada” / “recebida”, “Pagamento de boleto efetuado”) is persisted as source_raw.type = debit|credit — the rung publish_batch_atomic grades ABOVE sign(amount). A description that names no direction gets NO type: ungradeable is reported honestly, never guessed. Rows land with status=pending_review and surface on the preview screen at https://financing.devfellowship.com/financial/reconciliation/preview where Tainan approves/edits before they become canonical journal entries. PDF support is v0.2 — for now, export as CSV from the Nubank app.
ingest_bbIngest a Banco do Brasil PJ (BBPJ) checking-account statement (CSV export) into financial.reconciliation_staging. DIRECTION: the Tipo Lançamento column (Entrada/Saída) is persisted as source_raw.type = debit|credit, corroborated by the Valor C/D suffix — the rung publish_batch_atomic grades ABOVE sign(amount). The two columns contradicting each other, or a token neither recognises, yields NO type plus a warning; it is never guessed. Rows land with status=pending_review and surface on the preview screen at https://financing.devfellowship.com/financial/reconciliation/preview where Tainan approves/edits before they become canonical journal entries. IMPORTANT: the BB export is latin-1 (ISO-8859-1) encoded — if you read the file as a binary blob, decode it with Buffer.toString(“latin1”) BEFORE passing it here. Balance-marker rows (Saldo Anterior / Saldo do dia / S A L D O) are filtered. BB Rende Fácil rows (internal checking↔savings liquidity sweeps, zero accounting meaning) are PERMANENTLY FILTERED AT INGEST — per Tainan’s 2026-07-08 decision they never enter staging at all (superseding the older keep-but-suppress behavior). The skipped count is reported in the response and logged.
ingest_wiseIngest a Wise balance statement (CSV export) into financial.reconciliation_staging, and record what period was read into financial.bank_statements. Wise keeps ONE BALANCE PER CURRENCY and exports one file per balance, named statement_<accountId><CCY><start>_<end>.csv — so pass the original filename as file_hint and the period is read from it, or state period_start/period_end yourself. THE PERIOD IS REQUIRED: this tool REFUSES rather than guessing one, because a fabricated period recorded as coverage reads as evidence. AN EMPTY STATEMENT IS A VALID, USEFUL INGEST — a header-only export means “no movement in this window”, which is a fact worth recording; without it, “Wise was quiet” and “nobody ever read Wise” are indistinguishable. DIRECTION: Wise prints a Transaction Type column, but this parser has no samples of its vocabulary yet, so rows carry NO source_raw.type. Ungradeable is reported honestly; it is never derived from sign(amount). The raw token is stored so the lexicon can be written when real rows arrive. Rows land with status=pending_review and surface at https://financing.devfellowship.com/financial/reconciliation/preview.
ingest_c6Ingest a C6 Bank PF checking-account statement into financial.reconciliation_staging with source_type=bank_statement, source_bank_name=‘C6 Bank’. INPUT IS TEXT, NOT A PDF: C6 exports a password-protected PDF, so extract it first with pdftotext -upw &lt;password> -layout &lt;file.pdf> - and pass the result as statement_text. The -layout flag is REQUIRED — the parser reads the column positions it preserves, and the password never reaches this server. The rows carry only DAY/MONTH; the YEAR comes from the month section header above them (“Julho 2026 ( 01/07/2026 - 31/07/2026 )”), so a statement crossing a year boundary is dated correctly on both sides. Running-balance lines (Saldo do dia / Saldo Anterior / S A L D O) are filtered. Dedup is keyed on the transaction CONTENT (date + amount + normalized description + intra-day sequence), NOT on a file hash, so re-sending an overlapping period does not re-insert rows — while two genuinely identical same-day charges stay two rows. CHECKSUM GATE: each month header prints C6’s own Entradas/Saídas totals. This tool sums the rows it parsed and compares them BEFORE writing anything. On a mismatch it inserts NOTHING and returns the per-month comparison, because a half-read statement lands a wrong number that only surfaces later as a reconciliation gap. The comparison table is returned on success too. DIRECTION: source_raw.type = debit|credit is persisted ONLY when the Tipo column’s FIRST WORD is one (Entrada/Saída) — the rung publish_batch_atomic grades ABOVE sign(amount). Outros gastos, Pagamento and Débito de Cartão are CATEGORIES, not directions (the “Débito” there is the CARD type), so those rows carry NO type and stay honestly ungradeable. Rows land status=pending_review for human approval at https://financing.devfellowship.com/financial/reconciliation/preview.
ingest_onchainIngest an on-chain transaction history CSV (Etherscan / BSCScan / Snowtrace / Arbiscan) into financial.reconciliation_staging with source_type=onchain. Plan locks ETH, Hyperliquid, BTC, Polygon, Arbitrum as primary chains. Rows land in status=pending_review — human approves on the preview screen before posting.
ingest_onchain_alchemyAUTOMATIC on-chain ingest: pulls wallet transfers from Alchemy (alchemy_getAssetTransfers) into financial.reconciliation_staging with source_type=onchain and status=pending_review. This is the keyless-of-human sibling of ingest_onchain, which needs a PASTED Etherscan CSV and therefore never ran — the on-chain queue stopped at 2026-06-08 while roughly 21.000 USD of pods income went unbooked, which in turn let the matching BRL landings be double-counted as TPL-REV-MISC revenue. Wallets are resolved from financial.onchain_address_book (by address, label, or ledger account code); an address the book does not know is REFUSED, never scanned on trust. IDEMPOTENT on the (chain, tx_hash, log_index) natural key the database enforces with a partial UNIQUE, so re-running the same range writes nothing new — and dry_run runs the SAME duplicate check as the write, so the preview predicts the write. Accepts ingest_onchain’s chain spellings and canonicalizes eth → ethereum before keying on it (writing “eth” would miss the 99 existing “ethereum” rows and duplicate them). Alchemy serves ethereum / arbitrum / base / polygon / hyperevm — hyperevm (chainId 999) is reached through the hyperliquid-mainnet host and replaces the hyperscan.com Blockscout source, which now answers HTTP 403 behind a Cloudflare challenge. hyperliquid (the SEPARATE L1 DEX ledger), btc and solana are REFUSED with the reason, never silently skipped. On hyperevm Alchemy returns metadata:null for EVERY transfer, so the date is resolved from blockNum through eth_getBlockByNumber; a transfer whose block does not resolve is REFUSED and NAMED in the response, never dated with a fallback. An UNRECOGNIZED argument key is REFUSED and named — every parameter here has a default wider than the typo it replaces, so a dropped key looks like a working call. WATERMARKED per chain + owned wallet in financial.ingest_watermarks: an OMITTED from_date now starts from the stored position of each wallet instead of 90 days back, and the reply names the bound AND its provenance (explicit / watermark / default) for every wallet, so a narrow read is never mistakable for a wide one. An EXPLICIT from_date always wins. The position advances ONLY when the scan PROVED it reached the end of that wallet’s stream — a page-cap hit, a max_transfers truncation or an insert error writes last_status truncated/error and leaves the position exactly where it was, because a watermark that advances on a partial read makes the skipped window invisible for ever. from_date defaults to 90 days back when no watermark exists, never all-time. CONSULTS financial.onchain_token_ignores: a row whose (chain, token_address) is on the denylist is still WRITTEN, then hidden with source_raw.suppressed=true. It is never skipped — a skip loses the receipt, cannot be reversed, and leaves the natural key free so the next sweep re-creates the row, which is the very repetition the denylist was meant to stop. The reply states rows_auto_suppressed_denylisted and NAMES the tokens, so a hidden row is never confused with a broken ingest; un_suppress_staging reverses it. rows_already_ingested is SPLIT into rows_already_ingested_live and rows_already_ingested_suppressed, which always sum to it. A SUPPRESSED duplicate is a row that IS in reconciliation_staging and is HIDDEN from list_staging and from the Preview: the dedup skips it for ever, so NO re-ingest can restore it, and un_suppress_staging with an explicit id list is the only route back. When any suppressed duplicate is found the reply carries a top-level suppressed_reingest_warning and suppressed_staging_ids (capped at 100, with the truncation stated), so “rows_new: 0” is never mistaken for “there is nothing to import”. dry_run=true (DEFAULT) reports what would be inserted without writing, and READS the watermark without writing it. RLS-scoped user JWT.
ingest_hyperliquid_fillsLand Hyperliquid TRADE FILLS in financial.reconciliation_staging as source_type=onchain, status=pending_review, so on-chain activity reaches the review queue without anyone remembering to ask. The reading half is dfl-financing bun run src/cli.ts hyperliquid-fills --emit-staging, which fetches userFillsByTime, resolves the spot index through spotMeta, reconciles against the weekly balance snapshot and then STOPS — it writes nothing, because a data write goes through an MCP tool carrying the caller’s user-JWT, never service_role and never a PR. Paste its JSON array into fills. EVENTS ONLY, NEVER BALANCE DELTAS: one fill, one row; amount is that fill’s own effect on the base asset (gross size minus a fee paid in that SAME asset — a USDC fee never moves the BTC position), recomputed here rather than trusted, because a balance delta has no date and no counterparty and injecting one manufactures a plug. CUTOFF 2026-01-20: an earlier fill is already in the ledger through the Airtable migration, so it is WITHHELD with its reason attached, never dropped silently — and the cutoff is re-applied here rather than trusted from the emitter. PERPS ARE REFUSED: a bare coin such as “HYPE” is a PERPETUAL, not spot (this account has an Open Short and a liquidated Close Short), and reading it as spot would book a 4.87 HYPE sale that never happened; only a fill that PROVES it is spot (a resolved @&lt;index> pair) is admitted, and the rest is reported as not_spot. TWO ROWS PER FILL WHEN THERE IS A FEE: a spot fill has three legs (asset out, quote in, fee) and a journal_entry_template binds exactly two accounts, so the fee becomes its OWN row — the sale keeps GROSS proceeds and the fee posts separately (source_ref + ":fee"). ⚠️ Only when the fee is NOT paid in the base asset: when it is, base_net already subtracts it and a second row would post the same fee twice. USD VALUE WHEN THE VENUE MEASURED IT: usd_value_at_block is filled from raw.quote_amount (px × sz, two verbatim fields of one event) when the quote asset is USD-pegged (USDC, USDT, USDT0, USD₮0), and from the fee amount on a USD-pegged fee. It stays NULL for any other quote asset — no price oracle runs here, because a value nobody measured would feed the dust rule a number nobody measured. UNROUTABLE ROWS ARE COUNTED, NOT HIDDEN: the reply carries rows_without_template and an unroutable list naming the template code each one needs. A row with ai_template_code = NULL is not queued, it is STUCK. Template codes are SUGGESTED, never verified against the catalogue, and a missing one degrades to null rather than failing the ingest. IDEMPOTENT on ingest_row_hash = sha256(hyperliquid:fill:&lt;fillIdentityKey>), swept status-agnostically and enforced by uq_reconciliation_staging_tenant_ingest_row_hash, so a re-run over an overlapping window inserts nothing. tid is NOT that key: all eleven Spot Dust Conversion fills report tid 0 AND an all-zero hash, so keying on tid would fold eleven events into one row and lose ten. log_index stays NULL on purpose — a fill has no ordinal inside its order that survives a re-read, and several fills SHARE one order hash. A payload whose ingest_row_hash is not the digest of its own source_ref, whose amount is not the fill’s own arithmetic, or whose direction contradicts its side is REFUSED whole — nothing is written, because partially trusting a payload that lied once is worse. dry_run=true (DEFAULT) reports what would be inserted, what already exists, and every withheld row with its reason, using the SAME duplicate sweep the write path uses. WATERMARKED per owned address in financial.ingest_watermarks, and its last_read_through NEVER advances — deliberately. This tool performs no fetch of its own: the read happened in dfl-financing’s hyperliquid-fills --emit-staging CLI over an unknown window, so it can prove it processed every element it was handed but NOT that the payload reached the end of the venue stream. Each run is therefore recorded with last_status=truncated and the reason, so the source is never mistaken for one nobody looked at, and the position is never moved past a window that may not have been read. Phase 2 of plan 20260820-one-call-ingest-sweep moves the fetch into this package and gives this stream a real completeness signal. Rows land pending_review on https://financing.devfellowship.com/financial/reconciliation/preview — this FEEDS the human review gate, it does not bypass it. RLS-scoped user-JWT.
ingest_hyperliquid_transfersLand Hyperliquid DEPOSITS, WITHDRAWALS and TRANSFERS in financial.reconciliation_staging as source_type=onchain, status=pending_review — the sibling of ingest_hyperliquid_fills, which reads userFillsByTime and therefore sees TRADES ONLY. Deposits, withdrawals and internal movement change a cash account and no trade explains them: a −4.000 USDC withdrawal to Arbitrum on 2026-06-24 was never booked and is part of the +64.901,23 phantom surplus on BlackL - Hyperliquid/USD, the largest single gap in the register. 🚨 THAT WITHDRAWAL IS A send, NOT A withdraw: withdraw is only the legacy USDC-to-Arbitrum bridge path, so an implementation that handles deposit/withdraw and stops misses exactly the transaction this tool exists for. THE DESTINATION ADDRESS CLASSIFIES THE EVENT: 0x2222…2222 is HYPE’s system address (HyperCore → HyperEVM, own wallet to own wallet); 0x2000…0000 is the USDC system address and leaves for another chain over CCTP; 0x2000…00<idx> is any other token’s system address; our own address inbound; anything else is a real counterparty — and the prefix is matched EXACTLY, because this account received 830791.76195 MAX from 0x207700bd207df757825f9193ef9c648c1c65e06a, which also begins 0x20 and is an ordinary counterparty. FEES ARE THEIR OWN ROW, NEVER NETTED: send/spotTransfer carry fee + feeToken + nativeTokenFee and feeToken is frequently NOT the token that moved (2026-03-23: 3.0 UETH moved, 1.0 USDC charged), so each fee is booked against feeToken as a separate row in that currency; the rows of one event always SUM to its true effect on the account. INTERNAL MOVEMENT IS WITHHELD WITH ITS REASON, never dropped and never posted: spot↔perp (accountClassTransfer, and a send between two DEXes of the same wallet), spot↔staking and trading↔vault move nothing across the boundary, and booking this account’s 17 accountClassTransfer events — roughly 100.000 USD — would invent an economic event for each. CUTOFF 2026-01-20: an earlier event is already in the ledger through the Airtable migration, so it is WITHHELD and COUNTED, never dropped. THE RECONSTRUCTION IS THE EVIDENCE: every response rebuilds spot / perp / staked balances from userNonFundingLedgerUpdates + userFunding + delegatorHistory + delegatorRewards + userFillsByTime (read-only; fills stay the other tool’s to stage) and diffs them against the live venue balances — measured on 0x8577a1a3…8edd, every spot token closes to EXACTLY zero and USDC to 0,00015 across spot+perp. HYPE is unreconcilable without the staking feeds: cStakingTransfer reports only 2 of the 5 real spot↔staking moves, and it is the SAME event as the finalized delegatorHistory withdrawal (identical time, identical amount, both hash 0x0000…0000), so counting both double-books 90 HYPE and counting neither loses it. IDEMPOTENT on ingest_row_hash = sha256(source_ref) where source_ref carries sha256(address‖endpoint‖time_ms‖canonical_json(delta)) plus the leg name — the key NEVER reads hash, because three of this account’s events report 0x0000…0000 and no ledger event carries a tid, the same defect that forced the fills tool off tid. NONCE IS THE JOIN, NEVER THE IDENTITY: a send to 0x2000…0000 carries a nonce that appears verbatim in the destination chain’s CCTP hookData (1782333848945 = 0x0000019efb603d71 in the Arbitrum mint 0xfc71d2cc…4dc6), and it is emitted as cctp_nonce_hex — matching on amount and date returns the WRONG transaction, since that mint was 3999.8 after the CCTP fee while an unrelated 4000.000000 USDC burn exists on HyperEVM the same day. A leg below 5e-9 is REPORTED rather than written: numeric(20,8) rounds it to zero and approve_staging gates on a non-zero amount, so it would be unapprovable for ever. Wallets resolve from financial.onchain_address_book — there is no all-wallets default. An UNRECOGNIZED argument key is REFUSED and named. dry_run=true (DEFAULT). Rows land pending_review on the Reconciliation Preview — this FEEDS the human gate, it does not bypass it. No credential of any kind is needed: every Hyperliquid endpoint here is public and keyless. WATERMARKED per owned address in financial.ingest_watermarks — but the watermark RECORDS the read and does NOT narrow it: this tool has no lower-bound parameter, because the reconstruction that proves the event set is complete can only run from time zero. The position advances ONLY when the read PROVED it reached the end: a userNonFundingLedgerUpdates page-cap hit, a max_rows truncation or an insert error writes last_status truncated/error and leaves the position exactly where it was. dry_run READS the watermark and never writes it. RLS-scoped user-JWT, never service_role.
ingest_uuvIngest a UUV / Binance “Deposit & Withdrawal” or “Trade History” CSV export into financial.reconciliation_staging with source_type=binance. Trade rows expand into two staging rows (base + quote legs) so the preview screen shows the full swap. Rows land in status=pending_review — human approves before posting journal entries.
ingest_wooviIngest a Woovi Pix-account CSV export (Revera/DFL-PJ payment account) into financial.reconciliation_staging with source_type=bank_statement, source_bank_name=‘Woovi’. Signed Valor Numérico → CREDIT (funding-in from Nubank-PJ batch) / DEBIT (fellow payment out). Dedup keyed on EndToEndId (idempotent). Non-Confirmado rows are skipped. Rows land status=pending_review for human approval at https://financing.devfellowship.com/financial/reconciliation/preview. DIRECTION: the Tipo de Entrada column (charges export) or which side of the transfer is the Woovi account (movement export, where Valor is UNSIGNED) is persisted as source_raw.type = debit|credit — the rung publish_batch_atomic grades ABOVE sign(amount). A blank or unrecognised Tipo de Entrada yields NO type plus a warning, never a credit default.
ingest_binanceIngest a Binance “Transaction History” account-ledger CSV export (header: User ID,Time,Account,Operation,Coin,Change,Remark) into financial.reconciliation_staging as source_type=binance, status=pending_review. Rows surface on the preview screen at https://financing.devfellowship.com/financial/reconciliation/preview for human approve/edit before becoming canonical journal entries — nothing is posted. This is the canonical-ledger flavour (1 row per ledger line; Change is the signed amount). It is DISTINCT from ingest_uuv, which parses the separate “Deposit & Withdrawal History” and “Trade History” exports. A since cutoff (default 2026-01-20, Tainan’s data de corte) drops older rows. Optionally pass the Deposit-History CSV (deposit_csv_text) to fold each on-chain deposit’s network/address/TXID into the matching Deposit ledger row’s metadata (match by coin+amount) so the future matcher can bind Binance deposit ↔ on-chain send.
list_stagingList financial.reconciliation_staging rows for a tenant. Useful when the LLM needs to show the user what is currently waiting for approval. Defaults to status=pending_review, limit=50. Uses the per-session user JWT and is RLS-scoped — only rows for tenants the caller belongs to are returned. Suppressed rows (internal transfers such as BB Rende Fácil auto-sweeps, flagged source_raw.suppressed=true) are HIDDEN BY DEFAULT — pass include_suppressed=true to surface them. Each row also carries a read-time suggested_account (the target chart-of-accounts node: {tenant_slug, code, name, account_holder} — distinct from cost_center) derived from the matched routing rule / counterparty; this is DISPLAY ONLY and does NOT post a journal entry.
classifyRe-run the classifier on a specific reconciliation_staging row. A routing-match step runs FIRST: routing_memory (by row signature) then the highest-priority active routing_rule whose match is satisfied → sets target_tenant_slug + ledger_template_code (+ matched_rule_id, row_signature). Only when nothing matches does it fall back to heuristic + LLM. Also attaches a best-effort Itera cost-center SUGGESTION when the description looks like an accounting-office payment. ALSO resolves account_holder_id (the real-entity dimension): all crypto (onchain/binance) → Tainan; otherwise the resolved tenant slug maps to its account holder (dfl-ecosystem → devfellowship, tainan-pf → Tainan); unresolved stays NULL for human fill. Updates ai_* + the routing + account_holder columns in place. Does NOT change status — human approval on the preview screen remains required; nothing is posted. NEVER clears an ai_template_code the row already has: a classification with no template leaves the existing one alone, and on a row flagged was_manually_edited=true a DIFFERENT suggestion is refused too (the deliberate decision outranks the heuristic). The reply always carries a template block saying which of applied / replaced / preserved / unchanged happened, and why.
classify_bulkRe-run the classifier over MANY financial.reconciliation_staging rows at once. DETERMINISTIC BY DEFAULT (skip_llm=true): rows resolve via routing_rules → routing_memory → confident heuristic → demoted intra-tenant hint → sign fallback, plus the canonical correction step — NO LLM, fully reproducible. Designed for the iterate loop: improve routing_rules → re-run this (pre-LLM) → review before/after in staging → repeat. NEVER changes status — every row stays pending_review; nothing is approved, rejected, or executed. NEVER clears an ai_template_code a row already has, and never replaces one on a row flagged was_manually_edited=true — every such row is named in summary.templates_preserved. dry_run=true (default) returns the before/after diffs WITHOUT writing; set dry_run=false to persist the ai_* + routing + holder/cost_center fields. Selection REQUIRES an explicit selector: staging_ids (an array-of-UUID allowlist, takes precedence, min 1) OR a filter (status default pending_review, ai_category, source_type — at least one field set), capped by limit (default 50, max 500). A call with NEITHER is REFUSED — “re-classify everything up to the limit” is not reachable by omission, only by stating filter: {“status”: “pending_review”} outright. An UNRECOGNIZED key is REFUSED too, naming the key, rather than silently dropped: a dropped selector key (staging_id, ids, id) leaves no selector, and because this tool RE-RUNS the classifier the resulting sweep OVERWRITES hand-made human corrections on every row it touches. The summary buckets each row by which LAYER resolved it; the needs_rule buckets (intra_tenant_hint + sign_fallback) are the rows that still lack a routing_rule — your signal for the next iteration. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are touched.
validate_staging_rowRecord a HUMAN classification decision on a reconciliation_staging row and LEARN from it. Updates the row (target_tenant_slug, ledger/template, counterparty, cost-center) AND upserts financial.routing_memory keyed by the row signature, so the same payer/signature auto-classifies next time (closes the loop with the classify routing-match step). Use this when Tainan fills in who an orphan Pix arrival belongs to in the Preview. Does NOT change status — the row stays pending_review; nothing is posted. Both the row UPDATE and the routing_memory upsert go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies).
reconcile_matchLink the legs of ONE money-movement across on-chain → Binance → bank into a reconciliation_group, by ordered passes: (1) TXID exact onchain.tx_hash ↔ binance deposit txid; (2) fuzzy amount±tolerance + date-window + direction for onchain↔binance deposit, binance USDC→BRL conversion, and binance fiat-withdraw↔bank arrival; (3) intra-Binance balance flow (asset continuity + time ordering) bridging Deposit→Sold (USDC) and Revenue→Fiat-Withdraw (BRL) so the salary chain collapses to ONE group spanning onchain→deposit→sold→revenue→withdraw→bank. Default date window is ±2 days. Writes a reconciliation_runs row (params + counts + orphan report in summary), reconciliation_groups rows (confidence + leg count + label), and sets reconciliation_group_id on matched staging rows. Also (Pass 5) groups on-chain SAME-TX swap/deposit legs — two+ onchain rows sharing a tx_hash where one is out (asset A) and another in (asset B, A≠B): ETH→earnETH, HYPE→stHYPE, USDC↔token, LST wrap/unwrap — into an onchain_swap group (a wash: DR asset-B / CR asset-A, no income; a stablecoin/fiat leg is flagged gain_loss_review). Orphan legs (e.g. a fiat withdraw with no bank arrival → missing extrato) are reported. Grouping metadata ONLY on pending_review rows — nothing approved/posted. ADDITIVE BY DEFAULT: the run proposes groups only for pending_review rows that carry NO reconciliation_group_id, and every stamping UPDATE is guarded with reconciliation_group_id IS NULL, so it is incapable of moving or clearing a link that already exists. A row already grouped is reported in legs_skipped_already_grouped and left exactly as it is. dry_run DEFAULTS TO TRUE and returns the full plan plus a destruction block naming what a rebuild WOULD take away — group count, row count, how many of those rows are already posted (journal_entry_id) or human-annotated (reviewer_notes), and how many the matcher could never re-stamp because it loads only pending_review rows. rebuild=true is the DESTRUCTIVE path: it deletes every matcher-owned group of the tenant and unstamps every row pointing at them. It is lossy, not idempotent — measured on prod 2026-08-19, it would have unstamped 169 rows of which 119 could never be re-stamped, including 108 already posted to the ledger. rebuild=true with dry_run=false therefore REQUIRES expect (groups_deleted / rows_unstamped / group_ids) and refuses on any mismatch; when any row to be unstamped carries a journal_entry_id, expect.rows_unstamped_with_journal_entry must name that exact number — omitting it is a refusal. An UNRECOGNIZED argument key is REFUSED and named before anything is read. Read AND write run on the caller’s user-JWT under RLS. NEVER service_role.
list_accountsList financial.ledger_accounts (the chart of accounts) for a tenant. Filter by account type (asset/liability/equity/revenue/expense) and/or parent_id (pass parent_id to list a parent account’s sub-accounts; pass parent_id=“root” to list only top-level accounts). RLS-scoped via the per-session user JWT — only accounts for tenants the caller belongs to are returned. Useful to find account ids before composing a journal entry, or to inspect a sub-account tree (e.g. Itera modeled as dedicated sub-accounts under the devfellowship chart).
create_accountCreate a financial.ledger_accounts row (a chart-of-accounts entry). Pass parent_id to create a SUB-ACCOUNT under an existing account — this is how a sub-entity such as Itera is modeled: dedicated sub-accounts inside the devfellowship chart, NOT a separate account_holder. The account type is one of asset/liability/equity/revenue/expense and must match (or be consistent with) the parent’s type for a clean tree. Codes are unique per tenant (UNIQUE (tenant_id, code)); a sub-account convention is to prefix the parent code (e.g. parent 1010 → sub 1010-ITERA). Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped); the INSERT is enforced by the financial.* WITH CHECK policies (dfl-schema migration 20260626170000_financial_rls_member_write_policies). There is no free-form tag column on accounts — use cost_center_id on journal entry lines for per-line dimensions.
update_accountUpdate the mutable fields of a financial.ledger_accounts row (a chart-of-accounts entry) — toggle is_active (activate/deactivate, e.g. retire a wrong FX account without deleting its history), rename it, or set/clear its description. UPDATE-ONLY: there is NO delete path (accounts keep their journal-entry history). Select the account by id (UUID) OR by code + tenant_id (codes are unique per tenant, so tenant_id is required with code). At least one mutable field (is_active, name, description) must be provided. RLS-scoped (per-session user JWT) — only accounts in the caller’s tenant can be updated.
list_journal_entriesList financial.journal_entries (headers only) for a tenant, newest first. Filter by status (draft/posted/voided). RLS-scoped via the per-session user JWT. Use get_journal_entry to fetch the full double-entry lines of a specific entry.
get_journal_entryFetch a single financial.journal_entries row plus its journal_entry_lines (the double-entry debit/credit lines). RLS-scoped via the per-session user JWT. Also reports whether the entry balances (Σ debit == Σ credit).
create_journal_entryCreate a DRAFT financial.journal_entries row plus its double-entry lines. Each line is {account_id, debit | credit}: provide debit OR credit (one positive, the other omitted/0). The entry MUST balance: Σ debit == Σ credit. All account_ids must exist for the tenant. Lands as status=draft — call post_journal_entry to confirm (human-confirm gate, mirrors the staging pending_review → executed model). For Itera, post lines against the Itera sub-accounts to keep its movements as plain journal entries in the shared chart. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped); the INSERT is enforced by the financial.* WITH CHECK policies (dfl-schema migration 20260626170000_financial_rls_member_write_policies).
create_journal_entry_templateCreate a financial.journal_entry_templates row — an alias of {debit_ledger_account_id, credit_ledger_account_id, optional default_cost_center_id} keyed by a code. Routing rules reference templates via ledger_template_code. IDEMPOTENT on (tenant_id, code): re-running returns the existing row (created=false). Both accounts must already exist for the tenant — this does NOT create accounts. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies).
update_journal_entry_templateRepoint an EXISTING financial.journal_entry_templates row, keyed by (tenant_id, code). Where create_journal_entry_template is create-only, this is update-only — it moves a template’s leg(s) to different ledger accounts (the primary use: move a template’s CASH leg from a generic placeholder account to a real wallet ledger account, e.g. tainan-pf expense templates moving their credit leg from “1.1.1 Checking” to “1.1.1.18 Nubank”). Pass any of debit_ledger_account_id / credit_ledger_account_id / name / description / default_cost_center_id / is_active — only supplied fields change. Any new ledger account id must already exist for the tenant (validated before the update; this does NOT create accounts). NO-OP when the patch matches current values (changed=false). Fails clearly if the template code does not exist for the tenant (use create_journal_entry_template to create one). Both the account-validation READ and the UPDATE go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies).
create_asset_typeRegister a new asset in financial.asset_types — the global lookup that every ledger line, wallet and staging row resolves its currency against. Needed BEFORE create_wallet or any posting can reference the asset: wallets.asset_type_id is NOT NULL, and publish_batch_atomic refuses a staging row whose currency does not resolve here. GLOBAL data — there is no tenant_id, so one row is visible to every tenant and INSERT is restricted to a SUPERADMIN by RLS (dfl-schema 20260817140000). A duplicate symbol is REFUSED (never silently reused) and the existing row id is returned, because two rows for one symbol let two lines claim the same currency and compare unequal. UPDATE and DELETE are deliberately NOT available anywhere: re-labelling an asset would silently re-interpret every posted line referencing it, so a correction goes through a dfl-schema migration a human reads.
create_walletCreate a financial.wallets row pointing at an EXISTING ledger account (ledger_account_id). A wallet binds {account_holder_id, wallet_type_id, asset_type_id, ledger_account_id, name} so the reconciliation Preview can attribute a real bank/brokerage account to its ledger cash account (e.g. binding the Woovi 1.1.1.03 / Nubank-PJ 1.1.1.02 ledger accounts, which already exist, to their wallet rows). The ledger account must ALREADY exist for some tenant — this tool does NOT create accounts (use create_account for that). IDEMPOTENT on name (case-insensitive exact): re-running with the same wallet name returns the existing wallet (created=false) and BACKFILLS any provided field that differs — e.g. set chain + address on a row that had them NULL (updated=true). Keying on name (NOT ledger_account_id) lets distinct chain/asset wallets that share one ledger account coexist. For ON-CHAIN wallets pass chain (normalized slug, e.g. ethereum/arbitrum/base/hyperevm/hyperliquid/solana) + address (EVM 0x… or Solana base58) so the row joins to financial.onchain_balance_snapshots by (address, chain) and resolves per-wallet in the Preview. account_holder_id, wallet_type_id, asset_type_id, ledger_account_id and name are required; chain / address / asset_instrument_id / financial_entity_id / metadata are optional. Both the account-validation READ and the INSERT/UPDATE go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies).
update_walletPatch an EXISTING financial.wallets row by id (the complement of create_wallet). Changeable fields: name (rename), ledger_account_id (repoint to a different EXISTING ledger account), wallet_type (pass wallet_type_id as a uuid OR wallet_type as a case-insensitive name/slug, e.g. “Checking” / “checking” → Checking Account), and is_active. Only the fields you pass are changed. A new ledger_account_id is validated to exist + be visible to the caller. dry_run=true (DEFAULT) returns the before/after diff without writing — set dry_run=false to persist. At least one patch field is required. Every read AND the UPDATE run through the caller’s user-JWT under RLS (member+, tenant-scoped; USING/WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies) — never service-role. Use case: rename a wallet + fix its wallet_type (e.g. ‘Brokerage’→‘Checking’) while keeping its ledger account.
delete_walletHARD-delete financial.wallets rows by an EXPLICIT allowlist of ids (wallet_ids) — NEVER a filter/query-based delete, so a sweep cannot happen by accident. Two guards, both fail-closed: (1) FK-dependents — DYNAMICALLY discovers, via the financial.count_fk_dependents RPC (which queries pg_constraint at call time, never a hardcoded table pair), every foreign key referencing financial.wallets; a wallet with ANY dependent row in ANY referencing table is REFUSED, naming the table(s) and row count(s). A future FK is covered automatically, no tool change needed. (2) Non-zero balance (financial.v_wallets.balance) — refused unless allow_nonzero_balance=true. reason is REQUIRED (recorded in the structured log + echoed in the response — a hard delete leaves no row to stamp it onto). dry_run=true (DEFAULT) returns exactly what WOULD be deleted plus the refused list, without writing; set dry_run=false to persist. Feature-guarded: until the dfl-schema migration creating financial.count_fk_dependents is merged, EVERY wallet is refused (fail closed, never an unguarded delete). Every read AND the DELETE run on the caller’s user-JWT under RLS (member+, account-holder-scoped) — never service-role.
register_onchain_addressRegister a public wallet address in financial.onchain_address_book — the book that decides which wallets are read on chain at all. ingest_onchain_alchemy REFUSES an address the book does not list, and the balance collector scans only what the book lists, so a wallet with no entry here can never show a real balance. CHAIN VOCABULARY: this table stores a chain FAMILY — only evm or solana. It does NOT store a network. financial.onchain_balance_snapshots.chain stores the network instead (ethereum, arbitrum, base, hyperevm, hyperliquid, solana, binance), so the two tables do not share a vocabulary and a book row written as “arbitrum” joins to nothing — a silent empty join, not an error, because the column has no check constraint. A network slug (ethereum, arbitrum, base, polygon, hyperevm, solana) is accepted and TRANSLATED to its family, and the response reports the translation; any other value is refused with the accepted list. IDEMPOTENT on (tenant, chain family, address, case-insensitive): registering an address that is already registered UPDATES its label instead of failing, so re-running a batch of ten addresses is safe. EVM addresses are stored lowercase (every consumer compares with lower(), and the unique index is on lower(address)); Solana addresses are stored exactly as given, because base58 is case-significant. is_own_wallet is REQUIRED and has no default: true means our custody, false means a counterparty (the Binance deposit address is false). A default would silently label a counterparty as ours, and a default scan would then import that counterparty’s entire transfer history into our book. This is NOT update_wallet. Binding a financial.wallets row to a chain + address is update_wallet’s job and stays there; this tool writes a different table. A wallet usually needs both writes. Use list_onchain_addresses to see what is already registered. Reads and write both run on the caller’s user-JWT under RLS; there is no tenant_id parameter, so a call cannot address another tenant.
list_onchain_addressesList financial.onchain_address_book — every wallet address that is read on chain at all. Use before register_onchain_address to see what is already registered: registering is idempotent, so re-adding a known address under a different label RENAMES the entry that ingest_onchain_alchemy selects wallets by. The chain column here is a chain FAMILY (evm or solana), NOT a network. financial.onchain_balance_snapshots.chain holds the network (ethereum, arbitrum, base, hyperevm, hyperliquid, solana, binance), so the two columns do not join. Filter by family (a network slug is translated to its family) and by own wallets only. RLS-scoped through the caller’s user-JWT — only your tenants are visible, and there is no tenant_id parameter.
read_solana_transactionsREAD-ONLY. Enumerate every asset movement of one Solana address over a time or slot window, from the chain itself. Returns, per movement: the transaction signature, the slot, the block time, the entry date, the direction, the asset, the SPL mint, the decimals, the exact amount and a best-effort counterparty. THIS IS THE SOLANA COUNTERPART OF ingest_onchain_alchemy, which cannot serve Solana: alchemy_getAssetTransfers is EVM-only and refuses the chain by name. It WRITES NOTHING — no staging row, no journal entry. Ingesting pre-cutoff on-chain history on top of Airtable-migrated history double-counts, so read and compare first. Direction is the SIGN OF THE NET DELTA of the address in each transaction, not an instruction-level parse: a swap, an LP move and a plain transfer all reduce to how much of what left or arrived. Amounts are exact decimal strings computed in integer units — the float uiAmount field is never read. IT ALSO RECONSTRUCTS A PAST NATIVE SOL BALANCE WITHOUT AN ARCHIVE NODE: with reconstruct_opening_balance (default true) it reads the balances as of now and subtracts the window net, giving the balance at from_date. That reconstruction is WITHHELD, with the reason named, whenever the window is truncated, has unreadable transactions, or does not run to the present. It is also withheld when include_failed=false excluded a failed transaction fee — an incomplete net looks exactly like a complete one. It never reconstructs an SPL opening balance. Incoming SPL transfers can name the destination token account without naming its owner, so owner-address signatures are not exhaustive. Needs ALCHEMY_API_KEY (the same key the EVM readers use) or SOLANA_RPC_URL in the server’s environment; it refuses by name when neither is set, and never falls back to the rate-limited public endpoint, which would silently under-report.
ignore_onchain_tokensAdd tokens to financial.onchain_token_ignores, the per-tenant denylist that keeps spam airdrops and dust out of the RED “sem atribuição” panel on /reconciliation/preview. KEYED ON (chain, token_address) — NEVER on the ticker. A ticker is attacker-controlled and collides: on prod 2026-08-18 “DRV” existed on base AND ethereum at two unrelated contracts, one of them ours, so a list written on “DRV” cannot express which was meant. Pass native=true instead of token_address for a network own asset, which has no contract. TWO EFFECTS, NOT ONE, and neither one DELETES anything. (1) SNAPSHOT: financial.onchain_balance_snapshots is untouched, no number moves, and v_onchain_snapshot_unattributed still RETURNS the row with is_ignored=true plus the reason — the panel filters it and states how many it hid, from the same array. (2) INGEST (since PR #349): the shared staging writer reads this same table, so every NEW on-chain row for a denylisted token is WRITTEN and then stamped source_raw.suppressed=true with a reason. It is never skipped — a skip loses the receipt and frees the (chain, tx_hash, log_index) slot, which is what made the next sweep re-create the row. So an entry hides the token on two surfaces and drops it from neither. Reversible with un_ignore_onchain_tokens, but NOT symmetrically: that tool stops FUTURE suppression and does not clear a stamp already written — see its own description. BATCHED: send every target in one call; spam arrives in batches and so should the denylist. Every target is validated BEFORE any write, so a bad one means nothing is written at all. dry_run defaults to TRUE — the response reports, per target, how many currently unattributed rows it would hide and their USD total. REPLY SHAPE: ignored counts denylist rows ACTUALLY WRITTEN and is therefore 0 in a dry run; the targets that would be written are counted in would_ignore and already carry the per-target status “would_ignore”. Never read ignored > 0 as proof of a write without also reading mode.dry_run. matched_rows COUNTS THE SNAPSHOT SIDE ONLY — it never counts staging rows, in either mode. So matched_rows=0 is still reported loudly, but it proves LESS than the number alone suggests: it proves the target hides no BALANCE row today, which remains the signal that catches a wrong chain name or a wrong contract. It does NOT prove the entry is inert. The ingest effect applies to every future on-chain row for that token whatever matched_rows says. Measured on prod 2026-08-20: TMX (base / 0x945aa7c3ab890a4837a8a6a7b0ee0b82ae8e4bd1) reported matched_nothing=1 while its staging row existed all along. Read a 0 as “check the chain and the contract”, never as “this entry does nothing”. Reads and writes both run on the caller user-JWT under RLS; there is no tenant_id parameter, so a call cannot address another tenant.
un_ignore_onchain_tokensRemove tokens from financial.onchain_token_ignores so they appear again in the RED “sem atribuição” panel. The exact inverse of ignore_onchain_tokens and takes the same targets: (chain, token_address), or native=true for a network own asset. It deletes a denylist row and nothing else, and THAT IS NOT A FULL INVERSE. On the SNAPSHOT side it is: the balance itself was never modified, so the row simply stops being marked is_ignored and returns to the panel. On the INGEST side it is NOT: the delete only stops FUTURE on-chain rows from arriving suppressed. Staging rows that the ingest already stamped source_raw.suppressed=true STAY suppressed, and nothing in this tool touches them. To bring those back, find them with list_staging include_suppressed=true and clear the stamp with un_suppress_staging, which takes an explicit staging_ids array. Adding an entry is one call; undoing it fully is two. A target that is not on the denylist is reported as an idempotent no-op, not an error. dry_run defaults to TRUE and reports, per target, how many SNAPSHOT rows would become visible again and their USD total — that count never includes the suppressed staging rows, which this tool can neither count nor reverse. REPLY SHAPE: un_ignored counts denylist rows ACTUALLY DELETED and is therefore 0 in a dry run; the targets that would be deleted are counted in would_un_ignore and already carry the per-target status “would_un_ignore”. Never read un_ignored > 0 as proof of a delete without also reading mode.dry_run. RLS-scoped user-JWT; you can only remove your own tenant entries.
list_onchain_token_ignoresShow every entry in financial.onchain_token_ignores for your tenant, each with the number of currently unattributed rows it hides, their USD total, and how many of them carry no price. Also reports the totals: how many unattributed rows are hidden and how many remain visible. A denylist you cannot inspect is a trap, which is why this ships with the writer and not after it. Every count here measures the SNAPSHOT side only; each entry ALSO suppresses new on-chain staging rows at ingest, and that side is not counted on this page. An entry whose matched_rows is 0 is still called out, because it is the signal that catches a wrong chain or contract — but it means the entry hides no BALANCE row today, not that the entry is inert. RLS-scoped user-JWT.
list_asset_symbol_aliasesShow every row of financial.asset_symbol_aliases, resolved to the asset it means: the alias spelling, the canonical symbol and name, the asset_type_id, the recorded reason, and when the row was created and last updated. This is the ONE list that BOTH the preview and the write read. Before dfl-schema #850 the aliases lived only in TypeScript, so the dry run resolved UETH and financial.publish_batch_atomic — which resolves in plain SQL — refused the same batch; a preview that passes and a write that refuses is worse than either answer alone. GLOBAL data: there is no tenant_id and no tenant parameter, so one row is how EVERY tenant resolves that spelling. Resolution order is ALWAYS exact asset_types.symbol FIRST and this table SECOND, never the reverse and never a heuristic — so an alias that shadows a real asset symbol is dead data, and the database refuses to create one. NOTE ON AUTHORSHIP: the table records no author. dfl-schema #850 shipped it with no created_by column, so “who added it” lives in the reason text and nowhere else — read the reason, not a field. Removals DO record their author, in financial.asset_symbol_alias_removals. REMOVAL: use delete_asset_symbol_alias. It hard-deletes the row and writes an append-only tombstone carrying the original reason, the removal reason and who removed it, so nothing is lost. Removal is superadmin-only and it is NOT free: every staging row whose currency resolved only through that alias stops resolving, and the fail-closed publish path then refuses the WHOLE batch. There is still no UPDATE path — correcting a target is remove-then-create, two audited events. 🚨 AN ALIAS IS A SPELLING, NEVER A DERIVATIVE. An alias says “this is another SPELLING of the same asset” — one unit of the alias IS one unit of the canonical asset, at a fixed 1:1, for ever (a bridge, a rename, a glyph: UETH is Unit-bridged ETH; USD₮0 is USDT0 with Tether ₮ glyph U+20AE). It must NEVER be used for a DERIVATIVE whose exchange rate DRIFTS against the underlying: stHYPE, vHYPE, kHYPE, LHYPE, earnETH, THBILL, and every wrapped-yield / liquid-staking token (stETH, wstETH, rETH, cbETH, weETH, ezETH, sDAI, sUSDe, jitoSOL, mSOL). Those are worth MORE of the underlying every day the yield accrues. Aliasing one MISSTATES THE BALANCE by the accrued yield, the journal entry still BALANCES so nothing raises, and the misstatement GROWS without limit. A drifting token needs its own asset_types row (create_asset_type), never an alias. Read on the caller user-JWT under RLS.
create_asset_symbol_aliasAdd one or more rows to financial.asset_symbol_aliases — the ONE list that BOTH the preview and the SQL write path (financial.publish_batch_atomic) read when a source reports a different SPELLING of an asset the ledger already carries. Use it when the fail-closed publish path refuses a batch because a currency “did not resolve to a financial.asset_types row”, AND you have confirmed the spelling is the same asset. 🚨 AN ALIAS IS A SPELLING, NEVER A DERIVATIVE. An alias says “this is another SPELLING of the same asset” — one unit of the alias IS one unit of the canonical asset, at a fixed 1:1, for ever (a bridge, a rename, a glyph: UETH is Unit-bridged ETH; USD₮0 is USDT0 with Tether ₮ glyph U+20AE). It must NEVER be used for a DERIVATIVE whose exchange rate DRIFTS against the underlying: stHYPE, vHYPE, kHYPE, LHYPE, earnETH, THBILL, and every wrapped-yield / liquid-staking token (stETH, wstETH, rETH, cbETH, weETH, ezETH, sDAI, sUSDe, jitoSOL, mSOL). Those are worth MORE of the underlying every day the yield accrues. Aliasing one MISSTATES THE BALANCE by the accrued yield, the journal entry still BALANCES so nothing raises, and the misstatement GROWS without limit. A drifting token needs its own asset_types row (create_asset_type), never an alias. WHAT IT REFUSES, before writing anything: (1) a canonical asset that does not exist — register it with create_asset_type first; (2) an alias whose spelling IS already a real financial.asset_types symbol, which would shadow a genuine asset and be dead data, because the exact match always wins first; (3) an alias of an alias, or an alias of itself — the resolver looks up the exact symbol, then this table ONCE, and stops, so a chain resolves to nothing; (4) RE-POINTING an existing alias at a different asset, because it would silently change where every future row with that spelling posts; (5) any name on the literal never-alias denylist (stHYPE, vHYPE, kHYPE, LHYPE, earnETH, THBILL, stETH, wstETH, rETH, cbETH, weETH, ezETH, rsETH, sDAI, sUSDe, sUSDS, jitoSOL, mSOL); (6) a canonical_symbol and an asset_type_id that name two DIFFERENT assets. IDEMPOTENT: re-sending a pair that is already on the list is a no-op reported as already_exists, never an error. ALL-OR-NOTHING: every entry is validated BEFORE any write, so one bad entry means NOTHING is written — a half-applied alias list cannot be taken back on a user-JWT. REPLY SHAPE: created counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run; the entries that would be written are counted in would_create and carry the per-entry status “would_create”. Never read created > 0 as proof of a write without also reading mode.dry_run. dry_run defaults to TRUE. REVERSIBLE, BUT NEVER CHEAP: delete_asset_symbol_alias can remove a wrong alias on a user-JWT (superadmin, with a mandatory reason, recorded in an append-only tombstone). That is NOT permission to guess: removing an alias makes every staging row that resolved only through it unresolvable, so a batch that published yesterday refuses today, and it refuses WHOLE. There is still NO update path — a wrong TARGET costs a remove plus a create. GLOBAL data: no tenant_id and no tenant parameter. The INSERT policy is iam.is_superadmin(), so a non-superadmin is refused by the database, by name. Reads and the write both run on the caller user-JWT under RLS.
delete_asset_symbol_aliasRemove one or more rows from financial.asset_symbol_aliases. Use it when an alias was WRONG — the spelling turned out to be a different asset, or a drifting derivative that should have been its own asset_types row. THE ROW IS HARD-DELETED, and an append-only TOMBSTONE is written to financial.asset_symbol_alias_removals in the SAME transaction: the alias spelling, the asset it meant, the ORIGINAL reason, YOUR reason for removing it, and who you are. Nothing is lost — the evidence moves, it does not disappear. Hard delete rather than an is_active flag on purpose: no reader anywhere has to remember to filter, and the spelling is FREED, so the documented recovery — register it as its own asset with create_asset_type — actually works. ⚠️ WHAT REMOVAL MEANS: an alias is the ONLY thing that lets a source spelling resolve. Take it away and every financial.reconciliation_staging row whose currency resolved ONLY through it becomes unresolvable — so a batch that published fine yesterday will REFUSE today. It refuses WHOLE: financial.publish_batch_atomic is fail-closed, so one unresolvable row takes the good rows beside it down too, and nothing posts. Already-posted journal entries are NOT touched or re-valued; the damage is to what you publish NEXT. Run list_staging for that currency first, or read the affected_staging_rows count this tool reports — the dry run counts them for you, under your own RLS scope. REFUSES, before removing anything: (1) a spelling that is the CANONICAL asset of one or more aliases, e.g. “ETH” when you meant “UETH” — it names the candidates rather than guessing, because a no-op reported as success on a call aimed at the wrong row is worse than an error; (2) two casings of the SAME spelling in one call — matching folds on upper(), so they are one row; (3) a blank entry; (4) a reason shorter than 10 characters. IDEMPOTENT: a spelling that is on no list reports not_found, never an error — re-sending a removal after a timeout must not look like a failure. ALL-OR-NOTHING: every entry is decided BEFORE any removal, and the database call is one transaction, so one bad entry means NOTHING is removed. A half-applied removal leaves a resolution table that is neither the old one nor the new one. REPLY SHAPE: removed counts rows ACTUALLY DELETED and is therefore 0 in a dry run; the entries that would be deleted are counted in would_remove and carry the per-entry status “would_remove”. Never read removed > 0 as proof of a deletion without also reading mode.dry_run. dry_run defaults to TRUE, and a dry run reports the full decision plus the affected staging-row count for every entry. THIS IS NOT AN UNDO FOR A RE-POINT. There is no UPDATE path on this table, by design: re-pointing an alias silently changes where every future row with that spelling posts, and the entry still balances so nothing raises. To correct a target, remove and then create — two audited events, each with its own reason, which is the honest record of what happened. 🚨 AN ALIAS IS A SPELLING, NEVER A DERIVATIVE. An alias says “this is another SPELLING of the same asset” — one unit of the alias IS one unit of the canonical asset, at a fixed 1:1, for ever (a bridge, a rename, a glyph: UETH is Unit-bridged ETH; USD₮0 is USDT0 with Tether ₮ glyph U+20AE). It must NEVER be used for a DERIVATIVE whose exchange rate DRIFTS against the underlying: stHYPE, vHYPE, kHYPE, LHYPE, earnETH, THBILL, and every wrapped-yield / liquid-staking token (stETH, wstETH, rETH, cbETH, weETH, ezETH, sDAI, sUSDe, jitoSOL, mSOL). Those are worth MORE of the underlying every day the yield accrues. Aliasing one MISSTATES THE BALANCE by the accrued yield, the journal entry still BALANCES so nothing raises, and the misstatement GROWS without limit. A drifting token needs its own asset_types row (create_asset_type), never an alias. GLOBAL data: no tenant_id and no tenant parameter. Removal is SUPERADMIN-only (financial.remove_asset_symbol_aliases checks iam.is_superadmin()), so a non-superadmin is refused by the database, by name. Every read and the removal run on the caller user-JWT under RLS — there is no service-role path here and this table must never acquire one.
redate_snapshot_observationsRepair financial.onchain_balance_snapshots rows whose recorded observation date (snapshot_at) disagrees with when the balance was actually read (fetched_at). WHY THE CLASS EXISTS: snapshot_at has a now() DEFAULT and a DEFAULT applies on INSERT ONLY, so whenever an adapter’s upsert anchor does not move between two runs the later run takes the ON CONFLICT DO UPDATE branch on the OLDER row — the balances are replaced and the date label stays put. The row then claims to be an observation of a day on which nobody looked. Measured on prod 2026-08-17, chain=binance: 39 of 52 rows mislabelled across three groups, with only 2026-07-13 honest. dfl-financing #215 fixed the Binance anchor going forward and could not fix either the written rows or the class — this tool keys on the SYMPTOM (snapshot_at vs fetched_at) and contains no chain, adapter or date, so it finds the same defect in any other adapter without a code change. IT ONLY EVER WRITES snapshot_at := fetched_at. It never computes, rounds or infers a date. A row with a NULL fetched_at has no recorded truth, so it is REPORTED as unrepairable rather than guessed at. INSPECT THEN APPLY: dry_run=true is the DEFAULT and reports every candidate grouped by (current date → true date, adapter, chain) with a count, plus the unrepairable list and the collision verdict, writing nothing. dry_run=false REQUIRES expect — name the row count and/or the exact (from → to) groups you believe you are changing — and REFUSES, writing nothing, when reality disagrees. The check runs in BOTH directions: an expected group that is absent refuses, and a real group you did not list refuses too, because a repair that silently re-dates more rows than you pictured is worse than no tool (a correctly-dated row is indistinguishable from a correctly-dated row afterwards). TOLERANCE: min_drift_hours, default 24 hours — one whole day. A row qualifies only when BOTH its UTC calendar date differs AND at least that many hours separate the two stamps, so a row read five seconds after midnight is NOT treated as mislabelled. COLLISION SAFETY: snapshot_at is part of two partial UNIQUE indexes (onchain_balance_snapshots_daily_dedup_idx for wallet_id IS NOT NULL, and …_daily_dedup_unattributed_idx keyed on lower(address)/lower(chain) for wallet_id IS NULL, dfl-schema 20260817220000). Both keys are reconstructed in the PREVIEW, per row, so a move onto an occupied date is reported rather than raised as a 23505 mid-apply. ANY collision refuses the whole apply. A row whose target is held by ANOTHER row that is itself moving away is not a collision but an ORDERING constraint, and the writes are ordered so the holder vacates first; a cycle of such constraints is refused. IDEMPOTENT: after a successful apply the same call finds nothing to do and says so (already_consistent), rather than refusing on the now-stale expectation. The chain / source_adapter / snapshot_ids filters NARROW the candidates only — the collision check always reads the whole visible table, because the row a candidate would land on top of can sit outside any filter. An UNRECOGNIZED argument key is REFUSED and named. Read AND write run on the caller’s user-JWT under RLS. There is no tenant parameter and no way to name another tenant’s rows. NEVER service_role.
record_onchain_balance_snapshotsRecord 1 to 25 precise on-chain balance observations in financial.onchain_balance_snapshots. dry_run defaults to true. The tool validates the caller-visible wallet, address, chain, asset UUID, asset symbol, decimals, balance bucket, source provenance, observation time, and stable holding identity. Exact decimal strings never pass through JavaScript numbers. Identical repeats are no-ops. Conflicts are refused; the tool never overwrites or deletes a snapshot. It writes one atomic array through the caller’s user JWT under RLS. It does not use service_role. It writes no wallet, ledger, staging, period-lock, or coverage-watermark row. The result is source-observation evidence only. It does not certify full transaction history, an opening balance, or wall coverage.
post_journal_entryConfirm a DRAFT financial.journal_entries row → status=posted (sets posted_at). This is the human-confirm gate, mirroring the staging pending_review → executed model. Refuses to post anything that is not currently a draft, and re-verifies the entry balances before posting. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies).
delete_draft_entriesHARD-delete DRAFT financial.journal_entries (and their journal_entry_lines via ON DELETE CASCADE) that were created in error — used to clean up malformed reconciliation restatement drafts. Selection is an EXPLICIT allowlist of journal_entry UUIDs (entry_ids) — NEVER a query/filter, so a sweep cannot happen by accident. HARD GUARD: every id is fetched and its status verified; an id that is not found (or not visible under RLS) OR is NOT status=draft (i.e. posted/voided) is REFUSED with a reason and NEVER deleted — a posted or voided ledger record is impossible to delete through this tool. dry_run=true (DEFAULT) returns what WOULD be deleted (id, entry_date, description, status, line_count) plus the refused list, WITHOUT mutating anything; set dry_run=false to perform the status-guarded hard delete. RLS-scoped (per-session user JWT) — only entries in the caller’s tenants are visible/deletable. NOT service_role.
void_journal_entriesVOID individually named POSTED financial.journal_entries — the missing inverse of post_journal_entry for a single entry. Nothing else reaches these: reverse_batch requires a reconciliation_batch_id (NULL on hand-booked entries), delete_draft_entries only touches drafts, and reset_unposted_staging refuses any staging row whose entry is posted. VOID, NEVER DELETE: the write is a status flip to voided plus void_reason + voided_by, so the entry and its journal_entry_lines survive as the audit record (voided_at is stamped by the trg_journal_entry_status trigger). Selection is EXACTLY ONE of entry_ids (an explicit UUID allowlist) or reference_id + reference_type (one whole linked group) — there is NO filter mode and no limit, so a sweep cannot happen by accident, and an unrecognized key is REFUSED rather than silently dropped. KEY INVARIANT: voiding some but not all still-POSTED members of a linked (reference_type, reference_id) group is REFUSED and the missing members are NAMED — a linked group is ONE booking (the production book_linked_entry groups are cross-tenant pairs), and half-voiding it leaves one leg alive and one dead with nothing in the schema to detect it. allow_partial=true is the only override and it REQUIRES partial_reason, which is stamped into void_reason with the ids left posted. ⚠️ WHAT THE GUARD CANNOT PROVE: the sibling lookup is RLS-scoped, so a linked group spanning tenants is fully visible only to a caller reaching BOTH tenants — a group shown with ONE member may be half a hidden pair. That is NOT guessed at in code: every group reports its members with tenant_id and a top-level warning names the single-member groups. READ IT. An already-voided entry is an idempotent reported NO-OP (not an error, no second write); a draft entry is REFUSED with its status (use delete_draft_entries). Period locks are checked before writing, because trg_enforce_period_lock RAISES on voiding an entry dated inside a closed period. ALL-OR-NOTHING: if ANY requested entry is refused, NOTHING is written, even with dry_run=false. reason is REQUIRED and is stamped per entry into void_reason. dry_run=true (DEFAULT — note reverse_batch defaults it to FALSE, this does not) reports the entries, their amounts, the ledger accounts touched and the resulting balance delta per account, so the caller can verify against financial.v_projected_wallet_balances afterwards. RLS-scoped (per-session user JWT) — only entries in the caller’s tenants are visible/voidable. NOT service_role.
redate_journal_entriesMove the entry_date of individually named financial.journal_entries — and NOTHING else. Requires an EXPLICIT journal_entry_ids array (min 1); there is NO filter/sweep mode, no date-range selector and no limit, and an empty array is REFUSED rather than read as “everything”. NEVER touches amounts, ledger accounts, entry_type, status, description, reference links, journal_entry_lines, or reconciliation_staging.journal_entry_id: the write patch is whitelisted at runtime to entry_date plus the metadata audit stamp. Use this INSTEAD of void-and-recreate when a booking is right but its DATE is wrong — void-and-recreate mints new entry ids and orphans every reconciliation_staging row pointing at the old one. reason is REQUIRED (min 20 chars) and is appended to metadata.redate_history, an append-only array of {from,to,reason,at,by,tool}. CLOSED PERIODS: refused when EITHER the current date OR the target date falls on-or-before the tenant financial.period_locks.locked_through watermark — moving an entry out of a closed period rewrites a published total just as moving one in does. That refusal is enforced HERE, in application code, because enforce_period_lock only fires on a status transition and does NOT see a posted→posted re-date. There is no override flag; move the watermark deliberately and re-run. A voided entry is REFUSED (frozen audit record). An entry already on the target date is a reported no-op. ALL-OR-NOTHING: any refusal writes nothing. dry_run=true (DEFAULT) previews without writing; set dry_run=false to persist. RLS-scoped per-session user JWT for read AND write, never service_role, no tenant_id parameter. REPLY SHAPE: redated counts entries ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed entries are counted in would_redate and carry per-entry status “would_redate” with after_is_projected=true. Never read redated > 0 as proof of a write without also reading mode.dry_run.
backfill_journal_line_asset_typeFill the MISSING asset_type_id on the lines of POSTED financial.journal_entries — and write NOTHING else. Repairs the population that migration 20260709160000 grandfathered and that dfl-schema #888 (trg_reassert_integrity_posted_lines) has now FROZEN: since #888 the judge re-runs on every line write of a posted entry, so a NULL-carrying entry cannot be touched at all until it is repaired whole. TWO RESOLUTION RULES, in order: (a) account_name_suffix — the ledger account NAME contains ”/”, so the asset is the substring after the LAST ”/” (Clearing/YT-LBTC → YT-LBTC), resolved case-insensitively against financial.asset_types.symbol; (b) entry_currency — otherwise the currency the ENTRY carries (journal_entries.metadata.currency, else the tenant financial.tenants.settings.currency). There is NO literal fallback currency and NO default asset: an unresolvable suffix or an undeclared currency SKIPS the entry with a reason, because a wrong asset_type_id still balances per asset and nothing downstream would ever go red on it. ONE STATEMENT PER ENTRY: UPDATE … WHERE journal_entry_id = $1 AND asset_type_id IS NULL, so PostgreSQL fires the AFTER-ROW judge at the END of the statement, when the entry is already whole — a line-by-line repair would be refused on the first line. An entry whose NULL lines resolve to MORE THAN ONE asset is REFUSED, never half-written. PROJECTED JUDGE: the post-fill state is judged with the same rules as assert_journal_entry_integrity BEFORE writing, so an entry that would become CROSS-ASSET while missing usd_value is skipped by name (usd_value needs a price at the entry date — a different problem, NEVER written here). CLOSED PERIODS: an entry dated on-or-before its tenant financial.period_locks.locked_through is skipped, because enforce_period_lock_lines would RAISE; there is no override flag. The write patch is whitelisted AT RUNTIME to asset_type_id alone, and financial.journal_entries is never written. Selection is an EXPLICIT journal_entry_ids array OR a non-empty filter (entry_date_from / entry_date_to / ledger_account_ids), never both and never neither; an empty array and an empty {} filter are both REFUSED. limit defaults to 25 entries, maximum 200. reason is REQUIRED (min 20 chars) and is logged, not persisted. NOT all-or-nothing across the batch — refusals are the normal case in a grandfathered population — but ATOMIC PER ENTRY. REPLY SHAPE: repaired counts entries ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed entries are counted in would_repair and carry after_is_projected=true. Never read repaired > 0 as proof of a write without also reading mode.dry_run. Every planned line is reported with its resolved symbol AND which rule produced it, so the mapping is auditable without writing a query. RLS-scoped per-session user JWT for the read AND the write, never service_role, no tenant_id parameter.
post_from_stagingPromote an APPROVED financial.reconciliation_staging row into the canonical double-entry ledger and stamp the row executed. Builds the balanced journal entry from the row’s classification (ai_template_code → debit/credit accounts, amount on both sides), posts it atomically via the financial.create_journal_entry(jsonb) RPC (one transaction, server-side balance assertion), then sets journal_entry_id + status=executed on the staging row. The CASH leg comes from the ROW’s own bank account (ledger_account_id, else source_bank_name → wallet), not from the template: when the template names a generic ANCESTOR cash account the row’s real bank sub-account is substituted in, and an unresolvable or unrelated cash leg REFUSES. Each leg is denominated in ITS OWN account’s currency (the wallet bound to that account): a conversion between two assets posts the row’s magnitude on its own leg and usd_value_at_block on the USD leg, with a matching usd_value on both so the entry balances in dollars, and a crypto asset leg against a revenue/expense account books that P&L leg in USD from usd_value_at_block instead of writing a token quantity into an income account. A row that needs that second amount and does not carry it REFUSES — no rate is invented and no fallback to a single amount happens. The per-leg result is reported as legs (mode same-asset | cross-asset | pnl-usd). Refuses to post a row that is not ‘approved’, that lacks a template/amount, or whose template is unknown for the resolved tenant. If the RPC is not deployed yet it falls back to the create-draft + post path (allow_fallback). Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies).
execute_groupSettle a whole financial.reconciliation group (a transfer’s classified send-leg + its matched counterpart legs) as ONE action: post EXACTLY ONE balanced journal entry from the classified (TPL-XFER-*) primary leg’s template, then mark ALL still-in-staging legs status=executed pointing at the SHARED journal entry — so the counterpart leg reaches a terminal state and leaves staging instead of orphaning. Posts atomically via financial.create_journal_entry(jsonb) (fallback to create-draft + post). Each leg is denominated in ITS OWN account’s currency (the wallet bound to that account): a conversion posts the primary leg’s magnitude on its own leg and usd_value_at_block on the USD leg, with a matching usd_value on both so the entry balances in dollars, and a crypto asset leg against a revenue/expense account books that P&L leg in USD instead of writing a token quantity into an income account. A movement that needs that second amount and does not carry it REFUSES — no rate is invented. The per-leg result is reported as legs (mode same-asset | cross-asset | pnl-usd). Idempotent (already-executed group → no-op). Refuses a group with no classified leg, no positive amount, or an unknown template. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies).
book_linked_entryBook N balanced journal-entry legs ATOMICALLY across accounts and/or tenants for a cross-boundary financial event (transfer+FX, capital contribution / on-behalf, internal transfer). Takes a high-level INTENT (type + typed params) and encodes each recipe’s accounting rule once, wiring the correct bridge account (FX gain-loss / equity / suspense). Accounts and tenants may be passed as UUIDs OR chart codes / slugs (resolved on the caller user-JWT, RLS-scoped). Each tenant’s entry itself balances (Σ debit == Σ credit); all legs are created or none (compensating rollback). Pass an idempotency_key to make retries return the existing entries instead of double-booking. Posts by default (post=false leaves drafts). Reads AND writes go through the caller user-JWT under RLS (member+, tenant-scoped) — the financial.* member-write policies enforce the INSERT/UPDATE. Leg maps — transfer_fx: DR to_account(amount_to)/CR from_account(amount_from)/delta→fx_account (gain=CR, loss=DR). capital_contribution: payer DR investment/CR cash ; owner DR expense/CR equity. internal_transfer: DR to/CR from (or bridged via suspense 1.1.9).
list_routing_rulesList financial.routing_rules for a tenant — the declarative human classification rules (pattern → target tenant + ledger account) the reconciliation classifier consults. Ordered highest-priority first (that is the rule the matcher applies first). RLS-scoped via the per-session user JWT — only rules for tenants the caller belongs to are returned.
create_routing_ruleCreate a financial.routing_rules entry — a declarative human classification rule (pattern match → target_tenant_slug + optional ledger_template_code, with a note capturing the human rationale). This is where “this counterparty/pattern means X” knowledge lives so the reconciliation classifier can use it, instead of rotting in a plan doc. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped; routing_rules already allowed member writes, kept here for consistency). IDEMPOTENT: if an identical match already exists for this tenant the existing rule is returned (no duplicate inserted) unless force_duplicate is set.
update_routing_rulePatch an existing financial.routing_rules entry by id — change priority, toggle active, edit the match pattern, retarget the tenant, set/clear the ledger_template_code, adjust the split, or update the note rationale. Only the fields you pass are changed. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped).
create_routing_memoryRecord a learned (validated) classification decision in financial.routing_memory — the “a human confirmed that THIS row signature routes to tenant X / template Y” layer that lets the same signature auto-classify next time. UPSERT semantics on row_signature: if the signature already exists, hit_count is incremented, last_seen bumped, and the validated decision refreshed. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped).
list_routing_memoryList financial.routing_memory rows — the learned/validated classification decisions (row_signature → validated tenant + template, with hit_count reuse stats). Most-recently-seen first. Optionally filter to one signature. RLS-scoped via the per-session user JWT.
routing_memory_reachabilityREAD-ONLY measurement: how many financial.routing_memory rows the classifier can actually reach AND APPLY, split by signature scheme (A = this package, exact amount; B = the dfl-financing SPA, banded amount), how many staging rows match under each, how many rows the human-settled gate withholds from scheme B, and every scheme A/B conflict. Only memories carrying a validated_tenant_slug are counted, because applyRouting refuses the rest — a tenant-less memory can never apply, so counting it would report reachability the engine does not have. The excluded rows are reported under unapplicable_no_tenant rather than dropped. 🚨 Score any ratio against memories.applicable, NEVER memories.total. Writes nothing. RLS-scoped via the per-session user JWT.
settled_memory_disagreementsREAD-ONLY report (no write path of any kind, not a dry-run flag): every already-settled financial.reconciliation_staging row carrying a financial.routing_memory decision that DISAGREES with what the row was actually classified as. Dual-key matching made 304 previously-unreachable memories reachable, which made ~428 already-settled prod rows suddenly match one; applyRouting withholds those on purpose, because silently re-posting over a human decision is worse than the bug it would fix. This lists only the withheld memories that are actually WRONG. Agreements are counted and never listed — a report of all 428 rows is unreadable and therefore useless. Each item is decidable on one line: row identity + date + amount, what the row was classified as (target_tenant_slug / ai_template_code / cost_center_id / routing_source), what the memory says, which signature scheme reached it (a = exact amount, the MCP scheme; b = banded, the dfl-financing SPA scheme), why it was withheld, and which fields differ. Generic: the comparison window is parameters (settled scope, statuses, entry-date range, schemes, withheld_only, limit), not today’s incident. A NULL memory field is treated as NO OPINION, never as a disagreement. Both signatures and the settled gate come from @devfellowship/financing-core — the same functions applyRouting itself calls — so the report and the engine cannot drift apart. 🚨 ALWAYS read verdict before disagreements.count: reconciliation_staging is RLS tenant-scoped and an identity outside those tenants reads 0 rows with HTTP 200 and error:null, not a 403 — so the tool reports CANNOT_READ_CORPUS instead of rendering an empty, reassuring report. RLS-scoped via the per-session user JWT; never service_role.
insert_bank_balance_snapshotManually persist ONE bank or brokerage balance into financial.bank_balance_snapshots (Phase 6 of the reconciliation Preview — the Current-balances panel reads this table). Use when Tainan sends a bank statement PDF / Binance screenshot over Telegram: upload the artifact, read the balance, then call this with the parsed values + source_artifact_url (which VERSIONS the source document). bank is the canonical key — “Banco do Brasil” | “Nubank PF” | “Nubank PJ” | “Binance” — and should match reconciliation_staging.source_bank_name so the panel lines up with the staging legs. account_holder is the human-readable holder (“Tainan-PF”, “devfellowship”); if it matches a known holder the account_holder_id FK is resolved automatically (pass account_holder_id explicitly to override). balance_at is WHEN the balance was observed (statement/screenshot date), not now. Idempotent on (account_holder, bank, currency, balance_at) — re-entering a corrected statement updates the existing row. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies). Feature-guarded: until the dfl-schema migration creating the table is merged, the tool returns a clear “not migrated yet” error.
delete_bank_balance_snapshotHard-delete ONE financial.bank_balance_snapshots row by id (the complement of insert_bank_balance_snapshot / set_wallet_fiat_balance). Use to remove a stray or smoke-test snapshot that should NOT appear in the Reconciliation Preview’s Current-balances panel. The DELETE runs through the caller’s user-JWT under RLS (member+, tenant-scoped) — RLS confines it to the caller’s tenant, so a row outside the caller’s visibility matches nothing (returns deleted=false) and is NOT removed. NEVER service-role. Feature-guarded: until the dfl-schema migration creating the table is merged, returns a clear “not migrated yet” error.
set_wallet_fiat_balanceRecord the REAL (bank-statement) balance for a financial.wallets wallet, so the Reconciliation Preview can compare PROJECTED (ledger-derived) vs REAL per wallet (plan 20260625-financial-wallet-projected-balance). Name the WALLET — by wallet (its name, case-insensitive, e.g. “BB PJ” / “Binance/BRL”) OR by wallet_id (uuid). The tool resolves the wallet → its canonical bank key (financial_entities.name, which lines up with reconciliation_staging.source_bank_name), holder (account_holders.name), and currency (asset_types.symbol), then upserts one financial.bank_balance_snapshots row. amount is the real balance, balance_at is WHEN it was observed (statement date, not now). Pass source_artifact_url to version the source statement/screenshot. currency/account_holder fall back to the wallet’s own values but can be overridden. Idempotent on (account_holder, bank, currency, balance_at) — re-entering a corrected statement updates the existing row. If the wallet name is ambiguous (>1 match) or not visible, returns a clear error listing the candidate wallet names/ids. Both the wallet-resolution READ and the snapshot UPSERT go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies). Feature-guarded: until the dfl-schema migration creating the table is merged, returns a clear “not migrated yet” error.
update_stagingEdit classification/attribution fields (account_holder_id, ledger_account_code, ledger_template_code, ai_category, cost_center_id, usd_value_at_block) of financial.reconciliation_staging rows — the fields human review needs to correct before approval. Selection REQUIRES an explicit selector: staging_ids (an array-of-UUID allowlist, takes precedence, min 1) OR a filter (status/ai_category/source_type, at least one field set) with a limit cap (default 50, max 500). A call with NEITHER is REFUSED — “patch everything up to the limit” is not reachable by omission, only by stating filter: {“status”: “pending_review”} outright. An UNRECOGNIZED key is REFUSED too, naming the key, rather than silently dropped: a dropped selector key (staging_id, ids, id) leaves no selector, and on 2026-08-12 three such calls each naming ONE row returned processed:50 against the production queue. NEVER changes the status field. Rows with status=executed are SKIPPED by default (pass allow_executed=true to override). account_holder_id is validated: it must belong to the same tenant as the row (cross-tenant holder → skip with error). ledger_account_code is resolved to financial.ledger_accounts.id for the row’s tenant — rejected if not found. dry_run=true (DEFAULT) returns a before/after diff without writing anything. Set dry_run=false to persist. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are touched. REPLY SHAPE: updated counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed rows are counted in would_update and carry per-row status “would_update” with after_is_projected=true, because their after block is a projection and not the stored row. Never read updated > 0 as proof of a write without also reading mode.dry_run.
suppress_stagingAudit-preserving SOFT-suppress of specific financial.reconciliation_staging rows: stamps source_raw.suppressed=true (+ optional suppressed_reason + suppressed_at) so the rows are HIDDEN from list_staging / the reconciliation Preview by default — WITHOUT deleting them and WITHOUT changing status. Use for spurious / sign-flip / duplicate-mirror rows a human identifies (e.g. a bad-sign import batch). Requires an explicit staging_ids array — there is NO filter-based bulk suppress; suppression is a deliberate per-id action. NEVER changes status, amount, journal_entry_id, or any classification field. Rows with status=executed are SKIPPED by default (pass allow_executed=true to override). Rows already suppressed are SKIPPED (idempotent no-op). dry_run=true (DEFAULT) returns a before/after preview without writing; set dry_run=false to persist. Reversible — clear the flag to un-suppress. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are touched. REPLY SHAPE: suppressed counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed rows are counted in would_suppress and carry per-row status “would_suppress” with after_is_projected=true, because their after block is a projection and not the stored row. Never read suppressed > 0 as proof of a write without also reading mode.dry_run.
un_suppress_stagingReverse a prior suppress_staging: restore a suppressed financing staging row back to pending_review (active). RLS-scoped to the caller. Clears source_raw.suppressed (sets it false + optional unsuppressed_reason + unsuppressed_at) so the rows are VISIBLE again in list_staging / the reconciliation Preview — WITHOUT deleting them and WITHOUT changing status. Requires an explicit staging_ids array — there is NO filter-based bulk un-suppress; un-suppression is a deliberate per-id action. NEVER changes status, amount, journal_entry_id, or any classification field. Rows with status=executed are SKIPPED by default (pass allow_executed=true to override). Rows NOT currently suppressed are SKIPPED (idempotent no-op). dry_run=true (DEFAULT) returns a before/after preview without writing; set dry_run=false to persist. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are touched. REPLY SHAPE: unsuppressed counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed rows are counted in would_unsuppress and carry per-row status “would_unsuppress” with after_is_projected=true, because their after block is a projection and not the stored row. Never read unsuppressed > 0 as proof of a write without also reading mode.dry_run.
annotate_suppression_reasonWrite the audit reason on reconciliation_staging rows that are ALREADY suppressed. This exists because suppress_staging SKIPS an already-suppressed row, so it cannot repair a missing reason — a row hidden with no stated reason is invisible to review AND unexplainable. Writes ONLY source_raw.suppressed_reason (the mirror trigger copies it to the suppressed_reason column). NEVER changes suppressed, status, amount, journal_entry_id or any classification field, so it cannot change what a row means — only what it says about why it is hidden. Requires an explicit staging_ids array; there is NO filter-based bulk annotate. A row that is NOT suppressed is SKIPPED (annotating it would be meaningless, and the trigger would discard the write). A row that ALREADY has a reason is SKIPPED unless overwrite=true. dry_run=true (DEFAULT) previews without writing. REPLY SHAPE: annotated counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed rows are counted in would_annotate and carry per-row status “would_annotate” with after_is_projected=true. Never read annotated > 0 as proof of a write without also reading mode.dry_run.
upsert_bank_wallet_aliasAdd or update a row in financial.bank_wallet_aliases — the shared copy of the statement-label -> wallet-name map, so SQL, views and the app can join it instead of re-deriving it (re-deriving it by hand was wrong twice in one hour on 2026-08-26). THE POINT: adding a bank is a ROW, NOT A DEPLOY. bank_label is the label a STATEMENT carries, exactly as it lands in reconciliation_staging.source_bank_name (e.g. “Banco do Brasil”); wallet_name is the LEDGER label, exactly as it appears in financial.wallets.name (e.g. “BB PJ/BRL”). Lower priority is tried first, preserving the ORDER of the TypeScript array, which is the tie-break when one label matches wallets in more than one tenant. WARNING: the TypeScript constant BANK_WALLET_ALIASES is the FLOOR and is never overridden — the two are UNIONED — so deactivating a row here CANNOT remove an alias that ships in the constant; that stays a code change, deliberately, because a table that wins outright would let a partial seed silently break postings the constant already resolved. The map is GLOBAL — a bank label means the same bank in every book — so there is no tenant on the row; tenant scoping belongs to the LOOKUP (see financial.v_wallet_reality_feed). The tool WARNS when wallet_name matches no wallet, because an alias pointing at a non-existent wallet resolves nothing and would fail only later, at post time. dry_run=true (DEFAULT) previews without writing.
list_wallet_reality_feedRead financial.v_wallet_reality_feed — one row per wallet, answering “does this wallet have anything to check itself against?”. USE THIS INSTEAD OF HAND-WRITING THE JOIN: doing it by hand was wrong twice in one hour on 2026-08-26, because reconciliation_staging.ledger_account_id is NULL on 86.8% of rows and source_bank_name carries the STATEMENT label (“Banco do Brasil”), not the wallet name (“BB PJ/BRL”). Four independent surfaces count as evidence, and THEY DO NOT MEAN THE SAME THING: has_staging = transaction rows reached staging (by ledger_account_id, by source_bank_name, or through the financial.bank_wallet_aliases map); has_statement = a bank statement WINDOW WAS READ for this account — it is the ONLY flag that proves someone actually looked at a period, so “no feed” and “never read” are told apart HERE and nowhere else; has_balance_snapshot = a balance was observed for the bank label; has_onchain = an on-chain balance snapshot exists for the wallet; has_any_feed = the OR of the four. 🚨 ALWAYS READ alias_rows AT THE TOP LEVEL OF THE RESPONSE. It is a GLOBAL count of active rows in financial.bank_wallet_aliases. When it is 0 the alias leg matched NOTHING, so every bank wallet reads “no feed” for a reason that has nothing to do with the wallet — that is exactly the 2026-08-26 error arriving through a new door. Seed the map with upsert_bank_wallet_alias before you believe the row list. Defaults are tuned to the question the tool is named for: only_missing=true (show what has NO feed), include_clearing=false (a clearing account is a bookkeeping waypoint, not money anyone holds), include_inactive=false. RLS-scoped through the caller’s user-JWT — only your tenants are visible, and there is no tenant_id parameter.
reject_stagingMove specific financial.reconciliation_staging rows to the TERMINAL status=rejected state with a reviewer_notes audit stamp — the canonical terminal for a row that must never be posted (e.g. a confirmed phishing / address-poisoning token, or a bogus import artefact). This is the missing reject terminal: update_staging & validate_staging_row NEVER change status, suppress_staging only soft-hides (reversible, status unchanged), and post_from_staging is the ACCEPT terminal (executed). NEVER touches amount, journal_entry_id, source_raw, or any classification field — only status, reviewer_notes, and reviewed_at. Requires an explicit staging_ids array (no filter-based bulk reject) and a reason. Rows with status=executed are SKIPPED by default (pass allow_executed=true to override); rows already rejected are SKIPPED (idempotent no-op). dry_run=true (DEFAULT) returns a before/after preview without writing; set dry_run=false to persist. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are touched. REPLY SHAPE: rejected counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed rows are counted in would_reject and carry per-row status “would_reject” with after_is_projected=true, because their after block is a projection and not the stored row. Never read rejected > 0 as proof of a write without also reading mode.dry_run.
link_staging_legsRecord that one or more financial.reconciliation_staging rows are the COUNTERPART LEG of a money movement ALREADY booked by a posted journal entry — the second statement’s view of a transfer between two accounts we own. Writes the convention the ledger already uses for linked money movements: both legs share one reconciliation_group_id, the surviving leg stays status=executed carrying the journal_entry_id, and the linked leg goes status=rejected with an audit note, so the pair nets to zero and the movement is booked exactly once. Fills the gap left by reconcile_match, which loads ONLY pending_review rows and therefore can never pair a pending row with an already-executed leg. journal_entry_id is REQUIRED and must be a posted, non-voided entry for the caller’s tenant — the tool NEVER searches for a match by amount; amount and date are corroborating CHECKS and a row that fails either is skipped. The amount may equal the entry total OR any per-asset side total, so a CROSS-ASSET entry (balanced in USD, not in units) matches on the side written in the row’s own asset. Rows already executed are skipped; rows already rejected are grouped without changing status (the repair path for a transfer whose legs were both rejected and never linked). Idempotent — a repeat call for the same entry reuses the existing link group. A linked (rejected) row cannot be posted by approve_staging, post_from_staging, publish_batch or execute_group; only the deliberate unreject_staging returns it to review. dry_run=true (DEFAULT) previews without writing. The group is written status=resolved so a later reconcile_match run cannot delete the link. RLS-scoped (per-session user JWT), never service_role. REPLY SHAPE — a dry run NEVER reports a write. Two axes, kept apart: (1) WHAT THE CALL DID is the per-row action and the summary counters — linked and grouped_only count rows ACTUALLY WRITTEN and are therefore 0 in a dry run, while the previewed rows are counted in would_link / would_group_only and carry per-row action “would_link” / “would_group_only” plus after_is_projected=true; (2) WHAT THE ROW BECOMES is before.status / after.status, which keep their domain values (pending_review / rejected / executed) in BOTH modes and never take a would_* value. The group write is reported the same way: reconciliation_group_created records a real INSERT (false in a dry run, always) and would_create_reconciliation_group records the preview, while surviving_legs_stamped / would_stamp_surviving_legs report the surviving-leg stamp. In a dry run that would mint a NEW group, reconciliation_group_id is null because the id does not exist yet. Never read linked > 0 as proof of a write without also reading mode.dry_run.
unreject_stagingReverse a prior reject_staging: move specific financial.reconciliation_staging rows from the TERMINAL status=rejected state BACK to status=pending_review (the active review queue), with a reviewer_notes audit stamp. This unblocks reconciliation — reject_staging was a one-way terminal, so a row rejected in error (or one that only looks spurious until later context arrives) got stuck in rejected forever. NEVER touches amount, journal_entry_id, source_raw, or any classification field — only status (→ pending_review), reviewer_notes (the reason APPENDED to preserve the prior rejection note), and reviewed_at. Requires an explicit staging_ids array (no filter-based bulk un-reject) and a reason. Only rows currently status=rejected are eligible; rows in any other status (pending_review/approved/executed/error) are SKIPPED with a clear per-row reason (already-pending_review is an idempotent no-op). dry_run=true (DEFAULT) returns a before/after preview without writing; set dry_run=false to persist. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are touched. REPLY SHAPE: unrejected counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed rows are counted in would_unreject and carry per-row status “would_unreject” with after_is_projected=true, because their after block is a projection and not the stored row. Never read unrejected > 0 as proof of a write without also reading mode.dry_run.
reset_unposted_stagingReturn financial.reconciliation_staging rows that claim status=executed but NEVER reached the ledger back to status=pending_review (the active review queue), and CLEAR the broken journal_entry_id + executed_at. status=executed is an unbacked claim — no FK and no CHECK tie it to the entry actually posting — so a row can sit in executed while its journal_entry_id points at a DRAFT entry that never posted (often an empty shell with zero lines) or is NULL outright. No other tool can fix that: update_staging refuses status changes, unreject_staging only reverses rejected, un_suppress_staging only reverses suppression, reverse_batch only covers batch-published rows. HARD GUARD (NO override parameter exists): a row whose journal_entry_id points at a POSTED entry is ALWAYS REFUSED — real posted work can never be un-executed here, because a reset row re-enters the approval queue and would double-book. The guard proves non-posting rather than assuming it, so an entry that cannot be read (deleted, or invisible under RLS) is refused too — run this BEFORE delete_draft_entries, never after. ⚠️ WHAT THE GUARD CANNOT PROVE: a row with journal_entry_id IS NULL has no pointer to follow, and a NULL pointer does NOT mean the transaction is unbooked — the entry may live on the COUNTERPART leg’s staging row, because one bank transfer appears on TWO bank statements but needs ONE journal entry (measured 2026-08-11: 8 TPL-WOOVI-FUND Nubank PJ outflows were NULL and all CORRECT; resetting them would have double-posted 60.252,50). That evidence lives on the other bank and this tool never sees it, so it is NOT detected in code. Instead every dry_run row carries its template code, description, amount, date, bank and tenant, each NULL-pointer row carries a caution, and the summary hoists a top-level warnings entry naming the ids — READ IT before you confirm. A voided entry is refused by DEFAULT; pass allow_voided=true to include it (it has no live ledger effect, but it IS real historical work and journal_entry_id is the only link to it). Selection is an EXPLICIT allowlist of staging row UUIDs — there is no filter mode, so a sweep cannot happen by accident. ALL-OR-NOTHING: if ANY requested id is refused, NOTHING is written, even with dry_run=false. Only status=executed rows are eligible; any other status is refused with a reason. reason is REQUIRED and is stamped into reviewer_notes together with the journal_entry_id being cleared, so a reset row stays distinguishable from one never processed. dry_run=true (DEFAULT) returns would_reset + refused without mutating anything. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are visible/resettable. NOT service_role.
approve_stagingMove financial.reconciliation_staging rows from status=pending_review to status=approved — the MIDDLE step of the pending_review → approved → executed lifecycle, which until now NO MCP tool performed (only the preview UI did). That gap made publish_batch unreachable: publish_batch skips every row that is not approved, and it is the ONLY publish path that is previewable (dry_run), grouped into one reconciliation_batches row, and reversible with reverse_batch. This tool is what makes that path usable. APPROVAL MEANS READY TO POST, so a row that publish_batch would then silently skip is REFUSED here rather than moved one step down the pipeline: the row must have a resolved posting account (ai_template_code OR ledger_account_id), a resolvable tenant (target_tenant_id, or a target_tenant_slug that actually resolves), a currency, and a non-zero amount. ⚠️ A NEGATIVE amount is POSTABLE and is NOT refused — a bank outflow is stored negative and posts as its absolute value (the template, not the sign, decides debit vs credit); only a ZERO or NULL amount is refused. Only pending_review rows are eligible: an executed row is REFUSED and there is NO override parameter, rejected and error rows are REFUSED and named, and an already-approved row is an idempotent no-op SKIP that does not abort the batch. A row soft-hidden by suppress_staging is refused (un-suppress it first). Selection is an EXPLICIT allowlist of staging row UUIDs — there is no filter mode, so “approve everything matching a filter” cannot happen. ALL-OR-NOTHING: if ANY requested id is refused, NOTHING is written, even with dry_run=false. reason is REQUIRED and is recorded into reviewer_notes (a pre-existing note is preserved, never clobbered). dry_run=true (DEFAULT) returns the before/after diff plus the refused list without writing. NEVER touches amount, journal_entry_id, source_raw, or any classification field. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are visible/approvable. NOT service_role.
backfill_nubank_identifier_refsIdempotent maintenance backfill: re-keys LEGACY Nubank financial.reconciliation_staging rows onto the statement’s native Nubank Identificador (source_raw.identifier), rewriting source_ref to bank_statement:nubank:<identifier> and ingest_row_hash to sha256(that ref). Fixes the re-ingest-creates-duplicates bug: legacy rows were keyed on the old file-hash scheme, so a full re-ingest (which now keys on the Identificador) produced a DIFFERENT ingest_row_hash and slipped past the dedup sweep. After this backfill, a re-ingest of the same months stays deduped. Only touches rows with source_type=‘bank_statement’ AND source_raw.bank=‘nubank’ AND a non-empty source_raw.identifier. NEVER changes status / amount / journal_entry_id. Idempotent (an identifier_used_as_ref guard makes re-runs a no-op) and collision-safe (a ingest_row_hash already owned by another row is REPORTED and SKIPPED, never overwritten). RLS-scoped (per-session user JWT) — only the caller’s tenants’ rows are re-keyed. dry_run=true (DEFAULT) reports how many rows WOULD be re-keyed without writing; set dry_run=false to persist.
backfill_row_signaturesIdempotent maintenance backfill: compute and persist the stable row_signature on every financial.reconciliation_staging row where it IS NULL. Uses the ONE canonical definition — rowSignature() from classifier/routing.ts, the same pure function applyRouting and validate_staging_row call — so no second signature space is created. WHY ROWS ARE NULL: before 2026-08-21 the column was only written when a routing rule/memory MATCHED (updateClassification, behind if (c.routing)) or when a human validated the row; ingest never wrote it. Rows matching nothing kept NULL forever (340 of 1073 on prod). WHAT THAT BROKE: the phase-1.5 re-import duplicate sweep, which skips NULL signatures (findExistingRowSignatureMatches), so a third of the corpus was invisible to it. It did NOT break routing_memory auto-application — applyRouting recomputes the signature from the row in memory and never reads this column. BLAST RADIUS: writes EXACTLY ONE column, row_signature. Never status, ai_template_code, ai_category, ai_confidence, was_manually_edited, target_tenant_slug or journal_entry_id. Nothing is approved, posted, rejected or re-routed. Rows whose new signature already exists as a routing_memory key are REPORTED in memory_matches and otherwise untouched — a future classify of such a row would resolve from memory, which is worth seeing before it happens. IDEMPOTENT BY CONSTRUCTION: only NULL rows are selected, so a second run reports zero. RLS-scoped (per-session user JWT). dry_run=true (DEFAULT) reports what WOULD be stamped without writing; set dry_run=false to persist.
publish_batchPublish an EXPLICIT list of APPROVED financial.reconciliation_staging rows as ONE reversible batch: creates a financial.reconciliation_batches row, posts one journal entry per staging_id (stamped with the batch id via the create_journal_entry RPC), marks each staging row executed, and stores a per-wallet projected-balance snapshot on the batch. The batch can later be undone with reverse_batch (VOID + RESET — no reversing-entries). Rows that are not approved / not resolvable / already executed are skipped with a reason (partial publish is fine — it is reversible). dry_run=true previews what would post/skip without writing. Feature-guarded until the dfl-schema batches migration is applied. Writes run on the caller’s user-JWT under RLS.
reverse_batchUndo a published reconciliation batch by VOID + RESET (NOT reversing-entries): voids every posted journal entry stamped with the batch id (existing void mechanism → voided_at + void_reason) and resets each linked staging row to approved/journal_entry_id=NULL so it is re-publishable, then marks the batch reverted. Idempotent (already-reverted → no-op). Never hard-deletes; audit preserved. dry_run=true reports the counts that WOULD change without writing. Feature-guarded until the dfl-schema batches migration is applied. The reverse_batch RPC is SECURITY DEFINER, invoked on the caller’s user-JWT.
get_batchRead one financial.reconciliation_batches row: label, status (open|published|reverted), created/reverted provenance, its journal entries (id/date/status/posted_at/voided_at), and the stored projected_snapshot / real_snapshot. Read-only, RLS-scoped. Feature-guarded until the dfl-schema batches migration is applied.
list_batchesList financial.reconciliation_batches rows (newest first), optionally filtered by tenant and status (open|published|reverted). Read-only, RLS-scoped. Feature-guarded until the dfl-schema batches migration is applied.
check_batch_divergenceCompare a batch’s stored projected-balances snapshot (captured at publish time) against freshly-fetched REAL balances — bank via financial.bank_balance_snapshots (latest per bank+currency) and on-chain via the canonical financial.v_onchain_latest_deduped view (latest per wallet+token, never a raw SUM across snapshot days). Returns per-wallet projected/real/diff, a total absolute divergence, a batch-level diverged boolean, a verdict (clean | diverged | coverage_failure), and a coverage report that SURFACES every excluded wallet (no real balance, or a snapshot older than max_snapshot_age_hours). This is a FAIL-CLOSED verification control: set require_full_coverage=true (STRONGLY recommended at reconciliation close) so any missing/stale evidence yields verdict=coverage_failure (diverged=true) instead of a false-green pass. Read-only, RLS-scoped. Feature-guarded until the dfl-schema batches migration is applied.
snapshot_account_driftCompute computed-vs-real balance drift for every active wallet and persist it as one financial.reconciliation_runs row per tenant plus its financial.reconciliation_diffs rows. This is the durable per-account state snapshot — without it, resuming the reconciliation after any gap means re-deriving every balance by hand. Computed = the balance derived from POSTED journal entries (financial.v_wallets). Real = the newest snapshot: onchain_balance_snapshots for wallets, bank_balance_snapshots for banks. UNIT DISCIPLINE: a token account is scored against a token QUANTITY and a USD/BRL account against a currency VALUE. A wallet whose only real balance is in the other unit is EXCLUDED, never compared — comparing a book quantity against an on-chain USD value is what produced the false “phantom token” reading of 2026-07-04. COVERAGE: every wallet appears either in the scored rows or in the exclusion list with a reason (no-real-balance / stale-real-balance / unit-mismatch). Nothing is dropped silently, so “all green” can never mean “nobody looked”. A wallet closes when the absolute drift is within the floor OR the relative drift is within close_pct — the floor exists because a percentage rule alone can never close a small account (BB PJ holds R$100, where 1% is one real). dry_run defaults TRUE: it measures and reports without writing. Set dry_run=false to persist.
account_close_readinessREAD-ONLY. Per wallet: the posted BOOK balance, the REAL balance and its date (financial.bank_balance_snapshots for fiat, financial.onchain_balance_snapshots for on-chain), the current drift, the staging queue by status with PER-CURRENCY sums, how many queued rows have no template, how many would post to the wrong place (same logic as staging_routing_audit — shared, not forked), the projected balance after the queue posts, and the residual gap that would remain. Sorted CHEAPEST-TO-CLOSE FIRST, and the ordering criterion is printed in the ordering block rather than hidden in a comparator: the score is a COUNT OF OPEN ITEMS, never a money amount, so it is comparable across a BRL bank and a token wallet. UNIT DISCIPLINE: on-chain wallets are compared in TOKEN UNITS, not USD — the ledger 1.2.2.x accounts hold a quantity per asset (27.470,22 SPECTRA is about US$72). Every figure carries its unit; a token quantity is never compared against a currency value. COVERAGE: a queue row that cannot be attributed to a wallet is reported in an explicit unattributed bucket with a reason. That bucket is computed BEFORE the wallet filter and is always reported in full, so narrowing the report can never make a problem vanish. financial.v_projected_wallet_balances is read as a CROSS-CHECK: its own attribution uses a naive source_bank_name = wallet name comparison and silently drops the rows that fail it, so a view_projected_agrees:false marks a wallet whose rows the view lost. This tool NEVER writes.
staging_routing_auditREAD-ONLY audit of the reconciliation staging queue: flag every queued row whose classification would post to the wrong place. Reports, per category, a count, per-currency sums and row samples. Categories: no_template; template_missing_for_tenant; generic_cash_leg (INFORMATIONAL — the template cash leg is a strict ANCESTOR of the row bank account, which PR #287 substitutes at post time); wrong_bank (ERROR — the template cash leg is a DIFFERENT SPECIFIC account, a sibling, which #287 does NOT substitute, so the entry posts silently wrong); ambiguous_cash_leg (the post refuses); inactive_account_leg; template_has_no_cash_leg; template_legs_unreadable. The row bank account is resolved through the SAME alias table the poster uses (db/cash_leg.ts BANK_WALLET_ALIASES / financial.bank_name_wallet_candidates), because source_bank_name does NOT equal the wallet name (Nubank PF -> Nubank/BRL, Banco do Brasil -> BB PJ) and a naive comparison manufactures false mismatches. COVERAGE: a row whose bank cannot be resolved is reported in an explicit unattributed bucket with a reason, never dropped — a count of zero problems must never be an artefact of a filter. Sums are ALWAYS per currency, never one mixed total. ALSO REPORTS row_signature COVERAGE (signature_coverage): how many staging rows carry a row_signature, overall / per source_type / in the last 24h, with a verdict of complete | incomplete | REGRESSED. Measured over the ENTIRE table, deliberately NOT limited by this tool’s own statuses / tenant filters — coverage of a filtered slice is the same partial-but-healthy-looking number the metric exists to catch. A NULL signature makes a row invisible to the phase-1.5 re-import duplicate sweep; it does NOT affect routing_memory auto-application, which recomputes the signature and never reads the column. Fix a backlog with backfill_row_signatures; a REGRESSED verdict means a write path broke — find it instead. This tool NEVER writes and NEVER rejects anything.
find_staging_duplicatesREAD-ONLY. Find reconciliation_staging rows that were ingested more than once, and report each group with EVERY copy’s ingestion date so an operator can tell a re-import from a genuine same-day repeat. The ingest dedup keys only on ingest_row_hash, and a re-import of the same statement gets a DIFFERENT ingest_row_hash, so it never fires; this tool groups on the business identity of the movement instead — (tenant, entry_date, amount, currency, normalized description). Each group carries signal=likely_reimport (copies arrived in more than one ingestion run) or signal=same_ingestion_run (every copy arrived together — two identical R$225 payments on one day are REAL, so do NOT reject on this alone), plus distinct_ingest_row_hashes and distinct_row_signatures, which explain why the dedup missed the group. Each group ALSO carries distinct_natural_keys and copies_without_natural_key: natural_key is the identity column, so distinct_natural_keys=1 means the ingest ladder would now catch the group, >1 means the copies are genuinely distinct movements, and 0 means no copy carries a key yet (pre-migration rows, or a source no spec claims) so the group says nothing either way. This tool NEVER rejects, mutates or suppresses anything. It has no write path. Use reject_staging, with a human decision, to act on what it reports.

Ingest Nubank Statement

Ingest a Nubank PF or PJ statement (CSV format) into financial.reconciliation_staging. DIRECTION: the description verb (“Transferência enviada” / “recebida”, “Pagamento de boleto efetuado”) is persisted as source_raw.type = debit|credit — the rung publish_batch_atomic grades ABOVE sign(amount). A description that names no direction gets NO type: ungradeable is reported honestly, never guessed. Rows land with status=pending_review and surface on the preview screen at https://financing.devfellowship.com/financial/reconciliation/preview where Tainan approves/edits before they become canonical journal entries. PDF support is v0.2 — for now, export as CSV from the Nubank app.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID (Tainan PF, DFL PJ, ITERA, Tainan ME).
csv_textstringyesRaw CSV text from Nubank. Columns: Data,Valor,(Identificador),Descrição — DD/MM/YYYY dates, signed period-decimal amounts.
file_hintstringnoOptional original filename for the source_raw audit trail.
holderenumnoWhich Nubank account this statement is from: PF (Tainan pessoa física — salary-chain arrivals + personal expenses) or PJ (Revera company). Differentiates source_bank_name into “Nubank PF”/“Nubank PJ” for the Preview Bank filter + badges. Omit only when genuinely unknown (stays plain “Nubank”). One of: PF, PJ.
max_rowsnumbernoSafety cap; default 500.
closing_balancenumbernoThe REAL balance the account holds at the end of this statement — the number the bank app shows. Supplying it is what turns the ingest from “unverified” into a real verdict: the tool compares it against the ledger’s opening balance plus this file’s net, and reports MISMATCH when they disagree, which is how a payments-only export gets caught. Omit it and the tool falls back to the newest financial.bank_balance_snapshots row for this bank, and says so.
closing_balance_as_ofstringnoISO date (YYYY-MM-DD) the supplied closing_balance was observed. Defaults to the date of the LAST row in this file. A date before that is refused as stale; more than a week after it is refused as unattributable — the gap would have to be filled from the ledger under test.
balance_tolerancenumbernoAbsolute tolerance for |implied − real|, in the statement’s currency. Default 0.01 — the width of currency rounding noise, nothing more. Widening it hides real money: any value large enough to absorb an unexplained residual is large enough to absorb a missing payment. Raise it only to acknowledge a residual you have already diagnosed.

Ingest Banco do Brasil PJ Statement

Ingest a Banco do Brasil PJ (BBPJ) checking-account statement (CSV export) into financial.reconciliation_staging. DIRECTION: the Tipo Lançamento column (Entrada/Saída) is persisted as source_raw.type = debit|credit, corroborated by the Valor C/D suffix — the rung publish_batch_atomic grades ABOVE sign(amount). The two columns contradicting each other, or a token neither recognises, yields NO type plus a warning; it is never guessed. Rows land with status=pending_review and surface on the preview screen at https://financing.devfellowship.com/financial/reconciliation/preview where Tainan approves/edits before they become canonical journal entries. IMPORTANT: the BB export is latin-1 (ISO-8859-1) encoded — if you read the file as a binary blob, decode it with Buffer.toString(“latin1”) BEFORE passing it here. Balance-marker rows (Saldo Anterior / Saldo do dia / S A L D O) are filtered. BB Rende Fácil rows (internal checking↔savings liquidity sweeps, zero accounting meaning) are PERMANENTLY FILTERED AT INGEST — per Tainan’s 2026-07-08 decision they never enter staging at all (superseding the older keep-but-suppress behavior). The skipped count is reported in the response and logged.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID (the Revera / devfellowship financial tenant for this BB account).
csv_textstringyesRaw CSV text from the BB PJ export, ALREADY DECODED AS latin-1. Header: “Data”,“Lançamento”,“Detalhes”,“N° documento”,“Valor”,“Tipo Lançamento”. Valor is BR-formatted with C/D suffix (e.g. “-13.215,00 D”).
monthstringnoOptional MM/YYYY hint for the source_raw audit trail (e.g. “12/2025”).
file_hintstringnoOptional original filename for the source_raw audit trail.
max_rowsnumbernoSafety cap; default 500.
closing_balancenumbernoThe REAL balance the account holds at the end of this statement — the number the bank app shows. Supplying it is what turns the ingest from “unverified” into a real verdict: the tool compares it against the ledger’s opening balance plus this file’s net, and reports MISMATCH when they disagree, which is how a payments-only export gets caught. Omit it and the tool falls back to the newest financial.bank_balance_snapshots row for this bank, and says so.
closing_balance_as_ofstringnoISO date (YYYY-MM-DD) the supplied closing_balance was observed. Defaults to the date of the LAST row in this file. A date before that is refused as stale; more than a week after it is refused as unattributable — the gap would have to be filled from the ledger under test.
balance_tolerancenumbernoAbsolute tolerance for |implied − real|, in the statement’s currency. Default 0.01 — the width of currency rounding noise, nothing more. Widening it hides real money: any value large enough to absorb an unexplained residual is large enough to absorb a missing payment. Raise it only to acknowledge a residual you have already diagnosed.

Ingest Wise (TransferWise) Statement

Ingest a Wise balance statement (CSV export) into financial.reconciliation_staging, and record what period was read into financial.bank_statements. Wise keeps ONE BALANCE PER CURRENCY and exports one file per balance, named statement_<accountId><CCY><start>_<end>.csv — so pass the original filename as file_hint and the period is read from it, or state period_start/period_end yourself. THE PERIOD IS REQUIRED: this tool REFUSES rather than guessing one, because a fabricated period recorded as coverage reads as evidence. AN EMPTY STATEMENT IS A VALID, USEFUL INGEST — a header-only export means “no movement in this window”, which is a fact worth recording; without it, “Wise was quiet” and “nobody ever read Wise” are indistinguishable. DIRECTION: Wise prints a Transaction Type column, but this parser has no samples of its vocabulary yet, so rows carry NO source_raw.type. Ungradeable is reported honestly; it is never derived from sign(amount). The raw token is stored so the lexicon can be written when real rows arrive. Rows land with status=pending_review and surface at https://financing.devfellowship.com/financial/reconciliation/preview.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID.
csv_textstringyesRaw CSV text from the Wise export. Header: “TransferWise ID”,Date,“Date Time”,Amount,Currency,Description,… A header-only file is accepted on purpose.
file_hintstringnoOriginal filename, e.g. “statement_43140909_BRL_2026-01-01_2026-08-25.csv”. The period and the balance currency are read from it.
currencystringnoBalance currency (BRL/USD/EUR) when the filename does not carry it.
period_startstringnoInclusive start of the period THIS FILE covers. Overrides the filename.
period_endstringnoInclusive end of the period THIS FILE covers. Overrides the filename.
max_rowsnumbernoSafety cap; default 500.

Ingest C6 Bank (PF) Statement

Ingest a C6 Bank PF checking-account statement into financial.reconciliation_staging with source_type=bank_statement, source_bank_name=‘C6 Bank’. INPUT IS TEXT, NOT A PDF: C6 exports a password-protected PDF, so extract it first with pdftotext -upw &lt;password> -layout &lt;file.pdf> - and pass the result as statement_text. The -layout flag is REQUIRED — the parser reads the column positions it preserves, and the password never reaches this server. The rows carry only DAY/MONTH; the YEAR comes from the month section header above them (“Julho 2026 ( 01/07/2026 - 31/07/2026 )”), so a statement crossing a year boundary is dated correctly on both sides. Running-balance lines (Saldo do dia / Saldo Anterior / S A L D O) are filtered. Dedup is keyed on the transaction CONTENT (date + amount + normalized description + intra-day sequence), NOT on a file hash, so re-sending an overlapping period does not re-insert rows — while two genuinely identical same-day charges stay two rows. CHECKSUM GATE: each month header prints C6’s own Entradas/Saídas totals. This tool sums the rows it parsed and compares them BEFORE writing anything. On a mismatch it inserts NOTHING and returns the per-month comparison, because a half-read statement lands a wrong number that only surfaces later as a reconciliation gap. The comparison table is returned on success too. DIRECTION: source_raw.type = debit|credit is persisted ONLY when the Tipo column’s FIRST WORD is one (Entrada/Saída) — the rung publish_batch_atomic grades ABOVE sign(amount). Outros gastos, Pagamento and Débito de Cartão are CATEGORIES, not directions (the “Débito” there is the CARD type), so those rows carry NO type and stay honestly ungradeable. Rows land status=pending_review for human approval at https://financing.devfellowship.com/financial/reconciliation/preview.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID. C6 is a Tainan-PF account, so normally the tainan-pf tenant.
statement_textstringyesThe pdftotext -upw &lt;password> -layout extraction of the C6 statement PDF. Never the PDF itself, and never the password.
cutoff_datestringnoDrop rows on/before this ISO date — useful when re-sending a period already closed.
file_hintstringnoOriginal filename, for the source_raw audit trail.
max_rowsnumbernoSafety cap; default 500.
acknowledge_checksum_mismatchstringnoESCAPE HATCH — normally leave this unset. Ingest anyway despite a failed statement checksum, by stating IN WRITING why the mismatch is acceptable (min 20 chars, e.g. “C6 support confirmed the printed February total omits the reversed PIX”). Prose is required on purpose: a boolean gets set by habit, a written reason does not. The reason and the failing months are stamped into source_raw.checksum_override on every row it admits, so the override stays visible next to the data it let in. First check the extraction is the FULL statement — a truncated paste is the usual cause.
closing_balancenumbernoThe REAL balance the account holds at the end of this statement — the number the bank app shows. Supplying it is what turns the ingest from “unverified” into a real verdict: the tool compares it against the ledger’s opening balance plus this file’s net, and reports MISMATCH when they disagree, which is how a payments-only export gets caught. Omit it and the tool falls back to the newest financial.bank_balance_snapshots row for this bank, and says so.
closing_balance_as_ofstringnoISO date (YYYY-MM-DD) the supplied closing_balance was observed. Defaults to the date of the LAST row in this file. A date before that is refused as stale; more than a week after it is refused as unattributable — the gap would have to be filled from the ledger under test.
balance_tolerancenumbernoAbsolute tolerance for |implied − real|, in the statement’s currency. Default 0.01 — the width of currency rounding noise, nothing more. Widening it hides real money: any value large enough to absorb an unexplained residual is large enough to absorb a missing payment. Raise it only to acknowledge a residual you have already diagnosed.

Ingest On-Chain Transactions

Ingest an on-chain transaction history CSV (Etherscan / BSCScan / Snowtrace / Arbiscan) into financial.reconciliation_staging with source_type=onchain. Plan locks ETH, Hyperliquid, BTC, Polygon, Arbitrum as primary chains. Rows land in status=pending_review — human approves on the preview screen before posting.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID.
csv_textstringyesRaw CSV from an Etherscan-family explorer (normal txs or ERC-20 transfers — header sniffed).
chainenumnoChain hint — recorded in source_raw.chain on each staging row. One of: eth, hyperliquid, btc, polygon, arbitrum.
max_rowsnumberno—

Ingest On-Chain Transfers (Alchemy)

AUTOMATIC on-chain ingest: pulls wallet transfers from Alchemy (alchemy_getAssetTransfers) into financial.reconciliation_staging with source_type=onchain and status=pending_review. This is the keyless-of-human sibling of ingest_onchain, which needs a PASTED Etherscan CSV and therefore never ran — the on-chain queue stopped at 2026-06-08 while roughly 21.000 USD of pods income went unbooked, which in turn let the matching BRL landings be double-counted as TPL-REV-MISC revenue. Wallets are resolved from financial.onchain_address_book (by address, label, or ledger account code); an address the book does not know is REFUSED, never scanned on trust. IDEMPOTENT on the (chain, tx_hash, log_index) natural key the database enforces with a partial UNIQUE, so re-running the same range writes nothing new — and dry_run runs the SAME duplicate check as the write, so the preview predicts the write. Accepts ingest_onchain’s chain spellings and canonicalizes eth → ethereum before keying on it (writing “eth” would miss the 99 existing “ethereum” rows and duplicate them). Alchemy serves ethereum / arbitrum / base / polygon / hyperevm — hyperevm (chainId 999) is reached through the hyperliquid-mainnet host and replaces the hyperscan.com Blockscout source, which now answers HTTP 403 behind a Cloudflare challenge. hyperliquid (the SEPARATE L1 DEX ledger), btc and solana are REFUSED with the reason, never silently skipped. On hyperevm Alchemy returns metadata:null for EVERY transfer, so the date is resolved from blockNum through eth_getBlockByNumber; a transfer whose block does not resolve is REFUSED and NAMED in the response, never dated with a fallback. An UNRECOGNIZED argument key is REFUSED and named — every parameter here has a default wider than the typo it replaces, so a dropped key looks like a working call. WATERMARKED per chain + owned wallet in financial.ingest_watermarks: an OMITTED from_date now starts from the stored position of each wallet instead of 90 days back, and the reply names the bound AND its provenance (explicit / watermark / default) for every wallet, so a narrow read is never mistakable for a wide one. An EXPLICIT from_date always wins. The position advances ONLY when the scan PROVED it reached the end of that wallet’s stream — a page-cap hit, a max_transfers truncation or an insert error writes last_status truncated/error and leaves the position exactly where it was, because a watermark that advances on a partial read makes the skipped window invisible for ever. from_date defaults to 90 days back when no watermark exists, never all-time. CONSULTS financial.onchain_token_ignores: a row whose (chain, token_address) is on the denylist is still WRITTEN, then hidden with source_raw.suppressed=true. It is never skipped — a skip loses the receipt, cannot be reversed, and leaves the natural key free so the next sweep re-creates the row, which is the very repetition the denylist was meant to stop. The reply states rows_auto_suppressed_denylisted and NAMES the tokens, so a hidden row is never confused with a broken ingest; un_suppress_staging reverses it. rows_already_ingested is SPLIT into rows_already_ingested_live and rows_already_ingested_suppressed, which always sum to it. A SUPPRESSED duplicate is a row that IS in reconciliation_staging and is HIDDEN from list_staging and from the Preview: the dedup skips it for ever, so NO re-ingest can restore it, and un_suppress_staging with an explicit id list is the only route back. When any suppressed duplicate is found the reply carries a top-level suppressed_reingest_warning and suppressed_staging_ids (capped at 100, with the truncation stated), so “rows_new: 0” is never mistaken for “there is nothing to import”. dry_run=true (DEFAULT) reports what would be inserted without writing, and READS the watermark without writing it. RLS-scoped user JWT.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID that owns the wallets and the staging rows.
chainenumnoChain to scan. Default ethereum. Accepts ingest_onchain’s spellings (eth, hyperliquid, btc, polygon, arbitrum) plus the canonical database slugs (ethereum, base, hyperevm, solana). ‘eth’ is canonicalized to ‘ethereum’ — the slug the table stores — before anything is keyed on it. Alchemy serves ethereum / arbitrum / base / polygon / hyperevm (hyperevm is chainId 999, reached through the hyperliquid-mainnet host). hyperliquid — the SEPARATE L1 DEX ledger — plus btc and solana are REFUSED with the reason, never silently skipped. One of: eth, hyperliquid, btc, polygon, arbitrum, ethereum, base, hyperevm, solana.
from_datestringnoLower bound (inclusive), YYYY-MM-DD. Transfers before this date are not staged. Default: 90 days before today — deliberately NOT all-time. Pass 2026-06-09 to resume exactly where the last ingest stopped.
addressesstring[]noExplicit wallet addresses to scan. Each MUST already exist in financial.onchain_address_book for this tenant and chain family — an unknown address is REFUSED, never scanned on trust. An empty array is REFUSED because it would fall through to the every-own-wallet default.
address_labelsstring[]noSelect wallets by their financial.onchain_address_book label (exact, case-insensitive), e.g. “BlueL cold wallet (EVM)”. A label that does not exist is REFUSED, and the error lists the labels that do.
ledger_account_codestringnoSelect wallets by the ledger account their address-book entry points at, e.g. “1.1.1.35” (BlueL - Ethereum/USD). NOTE: the own cold wallets currently have ledger_account_id NULL in the address book, so this selector resolves nothing for them today and says so instead of falling back. Use address_labels until those rows are linked.
max_transfersnumbernoSafety cap on staged rows per call. Default 500, max 2000. Candidates are ordered OLDEST FIRST, so a truncated run makes progress and the next run continues from where it stopped instead of re-reading the same page.
dry_runbooleannoWhen true (DEFAULT), fetch, normalize, classify and run the SAME duplicate check the write path runs, then report what WOULD be inserted — without writing. Set false to persist.

Ingest Hyperliquid Trade Fills

Land Hyperliquid TRADE FILLS in financial.reconciliation_staging as source_type=onchain, status=pending_review, so on-chain activity reaches the review queue without anyone remembering to ask. The reading half is dfl-financing bun run src/cli.ts hyperliquid-fills --emit-staging, which fetches userFillsByTime, resolves the spot index through spotMeta, reconciles against the weekly balance snapshot and then STOPS — it writes nothing, because a data write goes through an MCP tool carrying the caller’s user-JWT, never service_role and never a PR. Paste its JSON array into fills. EVENTS ONLY, NEVER BALANCE DELTAS: one fill, one row; amount is that fill’s own effect on the base asset (gross size minus a fee paid in that SAME asset — a USDC fee never moves the BTC position), recomputed here rather than trusted, because a balance delta has no date and no counterparty and injecting one manufactures a plug. CUTOFF 2026-01-20: an earlier fill is already in the ledger through the Airtable migration, so it is WITHHELD with its reason attached, never dropped silently — and the cutoff is re-applied here rather than trusted from the emitter. PERPS ARE REFUSED: a bare coin such as “HYPE” is a PERPETUAL, not spot (this account has an Open Short and a liquidated Close Short), and reading it as spot would book a 4.87 HYPE sale that never happened; only a fill that PROVES it is spot (a resolved @&lt;index> pair) is admitted, and the rest is reported as not_spot. TWO ROWS PER FILL WHEN THERE IS A FEE: a spot fill has three legs (asset out, quote in, fee) and a journal_entry_template binds exactly two accounts, so the fee becomes its OWN row — the sale keeps GROSS proceeds and the fee posts separately (source_ref + ":fee"). ⚠️ Only when the fee is NOT paid in the base asset: when it is, base_net already subtracts it and a second row would post the same fee twice. USD VALUE WHEN THE VENUE MEASURED IT: usd_value_at_block is filled from raw.quote_amount (px × sz, two verbatim fields of one event) when the quote asset is USD-pegged (USDC, USDT, USDT0, USD₮0), and from the fee amount on a USD-pegged fee. It stays NULL for any other quote asset — no price oracle runs here, because a value nobody measured would feed the dust rule a number nobody measured. UNROUTABLE ROWS ARE COUNTED, NOT HIDDEN: the reply carries rows_without_template and an unroutable list naming the template code each one needs. A row with ai_template_code = NULL is not queued, it is STUCK. Template codes are SUGGESTED, never verified against the catalogue, and a missing one degrades to null rather than failing the ingest. IDEMPOTENT on ingest_row_hash = sha256(hyperliquid:fill:&lt;fillIdentityKey>), swept status-agnostically and enforced by uq_reconciliation_staging_tenant_ingest_row_hash, so a re-run over an overlapping window inserts nothing. tid is NOT that key: all eleven Spot Dust Conversion fills report tid 0 AND an all-zero hash, so keying on tid would fold eleven events into one row and lose ten. log_index stays NULL on purpose — a fill has no ordinal inside its order that survives a re-read, and several fills SHARE one order hash. A payload whose ingest_row_hash is not the digest of its own source_ref, whose amount is not the fill’s own arithmetic, or whose direction contradicts its side is REFUSED whole — nothing is written, because partially trusting a payload that lied once is worse. dry_run=true (DEFAULT) reports what would be inserted, what already exists, and every withheld row with its reason, using the SAME duplicate sweep the write path uses. WATERMARKED per owned address in financial.ingest_watermarks, and its last_read_through NEVER advances — deliberately. This tool performs no fetch of its own: the read happened in dfl-financing’s hyperliquid-fills --emit-staging CLI over an unknown window, so it can prove it processed every element it was handed but NOT that the payload reached the end of the venue stream. Each run is therefore recorded with last_status=truncated and the reason, so the source is never mistaken for one nobody looked at, and the position is never moved past a window that may not have been read. Phase 2 of plan 20260820-one-call-ingest-sweep moves the fetch into this package and gives this stream a real completeness signal. Rows land pending_review on https://financing.devfellowship.com/financial/reconciliation/preview — this FEEDS the human review gate, it does not bypass it. RLS-scoped user-JWT.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID (the tenant that owns this Hyperliquid account).
fillsobject[]yesThe JSON array printed by hyperliquid-fills --emit-staging, verbatim. Each element carries date, description, amount, currency, source_ref, ingest_row_hash, direction and raw. Paste it unedited: ingest_row_hash must be sha256(source_ref) and amount must equal the fill’s own arithmetic, both of which are re-checked.
dry_runbooleannoDEFAULT true. Reports what would be inserted, what already exists and what is withheld, writing nothing. Pass false to write.
max_rowsnumbernoSafety cap on rows INSERTED; default 500. Withheld rows do not count against it.

Ingest Hyperliquid Transfers (deposits, withdrawals, sends)

Land Hyperliquid DEPOSITS, WITHDRAWALS and TRANSFERS in financial.reconciliation_staging as source_type=onchain, status=pending_review — the sibling of ingest_hyperliquid_fills, which reads userFillsByTime and therefore sees TRADES ONLY. Deposits, withdrawals and internal movement change a cash account and no trade explains them: a −4.000 USDC withdrawal to Arbitrum on 2026-06-24 was never booked and is part of the +64.901,23 phantom surplus on BlackL - Hyperliquid/USD, the largest single gap in the register. 🚨 THAT WITHDRAWAL IS A send, NOT A withdraw: withdraw is only the legacy USDC-to-Arbitrum bridge path, so an implementation that handles deposit/withdraw and stops misses exactly the transaction this tool exists for. THE DESTINATION ADDRESS CLASSIFIES THE EVENT: 0x2222…2222 is HYPE’s system address (HyperCore → HyperEVM, own wallet to own wallet); 0x2000…0000 is the USDC system address and leaves for another chain over CCTP; 0x2000…00<idx> is any other token’s system address; our own address inbound; anything else is a real counterparty — and the prefix is matched EXACTLY, because this account received 830791.76195 MAX from 0x207700bd207df757825f9193ef9c648c1c65e06a, which also begins 0x20 and is an ordinary counterparty. FEES ARE THEIR OWN ROW, NEVER NETTED: send/spotTransfer carry fee + feeToken + nativeTokenFee and feeToken is frequently NOT the token that moved (2026-03-23: 3.0 UETH moved, 1.0 USDC charged), so each fee is booked against feeToken as a separate row in that currency; the rows of one event always SUM to its true effect on the account. INTERNAL MOVEMENT IS WITHHELD WITH ITS REASON, never dropped and never posted: spot↔perp (accountClassTransfer, and a send between two DEXes of the same wallet), spot↔staking and trading↔vault move nothing across the boundary, and booking this account’s 17 accountClassTransfer events — roughly 100.000 USD — would invent an economic event for each. CUTOFF 2026-01-20: an earlier event is already in the ledger through the Airtable migration, so it is WITHHELD and COUNTED, never dropped. THE RECONSTRUCTION IS THE EVIDENCE: every response rebuilds spot / perp / staked balances from userNonFundingLedgerUpdates + userFunding + delegatorHistory + delegatorRewards + userFillsByTime (read-only; fills stay the other tool’s to stage) and diffs them against the live venue balances — measured on 0x8577a1a3…8edd, every spot token closes to EXACTLY zero and USDC to 0,00015 across spot+perp. HYPE is unreconcilable without the staking feeds: cStakingTransfer reports only 2 of the 5 real spot↔staking moves, and it is the SAME event as the finalized delegatorHistory withdrawal (identical time, identical amount, both hash 0x0000…0000), so counting both double-books 90 HYPE and counting neither loses it. IDEMPOTENT on ingest_row_hash = sha256(source_ref) where source_ref carries sha256(address‖endpoint‖time_ms‖canonical_json(delta)) plus the leg name — the key NEVER reads hash, because three of this account’s events report 0x0000…0000 and no ledger event carries a tid, the same defect that forced the fills tool off tid. NONCE IS THE JOIN, NEVER THE IDENTITY: a send to 0x2000…0000 carries a nonce that appears verbatim in the destination chain’s CCTP hookData (1782333848945 = 0x0000019efb603d71 in the Arbitrum mint 0xfc71d2cc…4dc6), and it is emitted as cctp_nonce_hex — matching on amount and date returns the WRONG transaction, since that mint was 3999.8 after the CCTP fee while an unrelated 4000.000000 USDC burn exists on HyperEVM the same day. A leg below 5e-9 is REPORTED rather than written: numeric(20,8) rounds it to zero and approve_staging gates on a non-zero amount, so it would be unapprovable for ever. Wallets resolve from financial.onchain_address_book — there is no all-wallets default. An UNRECOGNIZED argument key is REFUSED and named. dry_run=true (DEFAULT). Rows land pending_review on the Reconciliation Preview — this FEEDS the human gate, it does not bypass it. No credential of any kind is needed: every Hyperliquid endpoint here is public and keyless. WATERMARKED per owned address in financial.ingest_watermarks — but the watermark RECORDS the read and does NOT narrow it: this tool has no lower-bound parameter, because the reconstruction that proves the event set is complete can only run from time zero. The position advances ONLY when the read PROVED it reached the end: a userNonFundingLedgerUpdates page-cap hit, a max_rows truncation or an insert error writes last_status truncated/error and leaves the position exactly where it was. dry_run READS the watermark and never writes it. RLS-scoped user-JWT, never service_role.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID — the tenant that owns this Hyperliquid account.
addressstringnoThe Hyperliquid wallet to read. It MUST already exist in financial.onchain_address_book for this tenant (chain family evm) — an address the book does not know is REFUSED, never read on trust. Give this or address_label, not both.
address_labelstringnoSelect the wallet by its financial.onchain_address_book label instead, e.g. “BlackL cold wallet (EVM)”. Exact, case-insensitive.
dry_runbooleannoDEFAULT true. Fetches, projects, classifies and runs the SAME ingest_row_hash sweep the write path runs, then reports what WOULD be inserted plus every withheld row and the full reconstruction — writing nothing. Pass false to write.
reconcilebooleannoDEFAULT true. Reconstructs every balance from userNonFundingLedgerUpdates + userFunding + delegatorHistory + delegatorRewards + userFillsByTime and diffs it against the live venue balances. Set false ONLY to skip the extra reads — the reconstruction is the evidence that the transfer set is complete, and without it a missing delta kind looks like success.
max_rowsnumbernoSafety cap on rows INSERTED; default 500, max 2000. Withheld rows do not count against it.

Ingest UUV (Binance) Export

Ingest a UUV / Binance “Deposit & Withdrawal” or “Trade History” CSV export into financial.reconciliation_staging with source_type=binance. Trade rows expand into two staging rows (base + quote legs) so the preview screen shows the full swap. Rows land in status=pending_review — human approves before posting journal entries.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID.
csv_textstringyesRaw CSV from Binance — either Deposit&Withdrawal History or Trade History (header sniffed).
max_rowsnumberno—

Ingest Woovi (Revera/DFL-PJ Pix) Export

Ingest a Woovi Pix-account CSV export (Revera/DFL-PJ payment account) into financial.reconciliation_staging with source_type=bank_statement, source_bank_name=‘Woovi’. Signed Valor Numérico → CREDIT (funding-in from Nubank-PJ batch) / DEBIT (fellow payment out). Dedup keyed on EndToEndId (idempotent). Non-Confirmado rows are skipped. Rows land status=pending_review for human approval at https://financing.devfellowship.com/financial/reconciliation/preview. DIRECTION: the Tipo de Entrada column (charges export) or which side of the transfer is the Woovi account (movement export, where Valor is UNSIGNED) is persisted as source_raw.type = debit|credit — the rung publish_batch_atomic grades ABOVE sign(amount). A blank or unrecognised Tipo de Entrada yields NO type plus a warning, never a credit default.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID (dfl-ecosystem for the Woovi/Revera account).
csv_textstringyesRaw Woovi CSV export. 25-column Pix layout incl. Valor Numérico, EndToEndId, Tipo de Entrada, Recebido Em (DD/MM/YYYY HH:MM:SS).
cutoff_datestringnoISO date (YYYY-MM-DD); rows with entry_date on/before this are dropped. Consistent with other sources (e.g. 2026-01-20).
file_hintstringnoOptional original filename for the source_raw audit trail.
max_rowsnumbernoSafety cap; default 500.
closing_balancenumbernoThe REAL balance the account holds at the end of this statement — the number the bank app shows. Supplying it is what turns the ingest from “unverified” into a real verdict: the tool compares it against the ledger’s opening balance plus this file’s net, and reports MISMATCH when they disagree, which is how a payments-only export gets caught. Omit it and the tool falls back to the newest financial.bank_balance_snapshots row for this bank, and says so.
closing_balance_as_ofstringnoISO date (YYYY-MM-DD) the supplied closing_balance was observed. Defaults to the date of the LAST row in this file. A date before that is refused as stale; more than a week after it is refused as unattributable — the gap would have to be filled from the ledger under test.
balance_tolerancenumbernoAbsolute tolerance for |implied − real|, in the statement’s currency. Default 0.01 — the width of currency rounding noise, nothing more. Widening it hides real money: any value large enough to absorb an unexplained residual is large enough to absorb a missing payment. Raise it only to acknowledge a residual you have already diagnosed.

Ingest Binance Transaction-History Ledger

Ingest a Binance “Transaction History” account-ledger CSV export (header: User ID,Time,Account,Operation,Coin,Change,Remark) into financial.reconciliation_staging as source_type=binance, status=pending_review. Rows surface on the preview screen at https://financing.devfellowship.com/financial/reconciliation/preview for human approve/edit before becoming canonical journal entries — nothing is posted. This is the canonical-ledger flavour (1 row per ledger line; Change is the signed amount). It is DISTINCT from ingest_uuv, which parses the separate “Deposit & Withdrawal History” and “Trade History” exports. A since cutoff (default 2026-01-20, Tainan’s data de corte) drops older rows. Optionally pass the Deposit-History CSV (deposit_csv_text) to fold each on-chain deposit’s network/address/TXID into the matching Deposit ledger row’s metadata (match by coin+amount) so the future matcher can bind Binance deposit ↔ on-chain send.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID (the financial tenant that owns this Binance account).
csv_textstringyesRaw Binance Transaction-History CSV text. Header: User ID,Time,Account,Operation,Coin,Change,Remark. Time is YY-MM-DD HH:MM:SS (UTC-3).
deposit_csv_textstringnoOptional Binance Deposit-History CSV (Time,Coin,Network,Amount,Address,TXID,Status). Used to enrich Deposit ledger rows with on-chain network/address/TXID — does NOT create separate deposit rows.
sincestringnoISO cutoff date (YYYY-MM-DD). Rows with entry_date <= since are dropped. Default 2026-01-20.
file_hintstringnoOptional original filename for the audit trail.
max_rowsnumbernoSafety cap; default 1000.

List Reconciliation Staging Rows

List financial.reconciliation_staging rows for a tenant. Useful when the LLM needs to show the user what is currently waiting for approval. Defaults to status=pending_review, limit=50. Uses the per-session user JWT and is RLS-scoped — only rows for tenants the caller belongs to are returned. Suppressed rows (internal transfers such as BB Rende Fácil auto-sweeps, flagged source_raw.suppressed=true) are HIDDEN BY DEFAULT — pass include_suppressed=true to surface them. Each row also carries a read-time suggested_account (the target chart-of-accounts node: {tenant_slug, code, name, account_holder} — distinct from cost_center) derived from the matched routing rule / counterparty; this is DISPLAY ONLY and does NOT post a journal entry.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID.
statusenumnoFilter by status. Default: pending_review. One of: pending_review, approved, rejected, executed, error.
limitnumbernoDefault 50.
include_suppressedbooleannoInclude suppressed rows (internal transfers like BB Rende Fácil auto-sweeps). Default false — suppressed rows are hidden from the reconciliation preview.

Re-classify Staging Row

Re-run the classifier on a specific reconciliation_staging row. A routing-match step runs FIRST: routing_memory (by row signature) then the highest-priority active routing_rule whose match is satisfied → sets target_tenant_slug + ledger_template_code (+ matched_rule_id, row_signature). Only when nothing matches does it fall back to heuristic + LLM. Also attaches a best-effort Itera cost-center SUGGESTION when the description looks like an accounting-office payment. ALSO resolves account_holder_id (the real-entity dimension): all crypto (onchain/binance) → Tainan; otherwise the resolved tenant slug maps to its account holder (dfl-ecosystem → devfellowship, tainan-pf → Tainan); unresolved stays NULL for human fill. Updates ai_* + the routing + account_holder columns in place. Does NOT change status — human approval on the preview screen remains required; nothing is posted. NEVER clears an ai_template_code the row already has: a classification with no template leaves the existing one alone, and on a row flagged was_manually_edited=true a DIFFERENT suggestion is refused too (the deliberate decision outranks the heuristic). The reply always carries a template block saying which of applied / replaced / preserved / unchanged happened, and why.

ParameterTypeRequiredDescription
staging_idstringyesUUID of the reconciliation_staging row to re-classify.

Bulk Re-classify Staging Rows (deterministic by default)

Re-run the classifier over MANY financial.reconciliation_staging rows at once. DETERMINISTIC BY DEFAULT (skip_llm=true): rows resolve via routing_rules → routing_memory → confident heuristic → demoted intra-tenant hint → sign fallback, plus the canonical correction step — NO LLM, fully reproducible. Designed for the iterate loop: improve routing_rules → re-run this (pre-LLM) → review before/after in staging → repeat. NEVER changes status — every row stays pending_review; nothing is approved, rejected, or executed. NEVER clears an ai_template_code a row already has, and never replaces one on a row flagged was_manually_edited=true — every such row is named in summary.templates_preserved. dry_run=true (default) returns the before/after diffs WITHOUT writing; set dry_run=false to persist the ai_* + routing + holder/cost_center fields. Selection REQUIRES an explicit selector: staging_ids (an array-of-UUID allowlist, takes precedence, min 1) OR a filter (status default pending_review, ai_category, source_type — at least one field set), capped by limit (default 50, max 500). A call with NEITHER is REFUSED — “re-classify everything up to the limit” is not reachable by omission, only by stating filter: {“status”: “pending_review”} outright. An UNRECOGNIZED key is REFUSED too, naming the key, rather than silently dropped: a dropped selector key (staging_id, ids, id) leaves no selector, and because this tool RE-RUNS the classifier the resulting sweep OVERWRITES hand-made human corrections on every row it touches. The summary buckets each row by which LAYER resolved it; the needs_rule buckets (intra_tenant_hint + sign_fallback) are the rows that still lack a routing_rule — your signal for the next iteration. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are touched.

ParameterTypeRequiredDescription
staging_idsstring[]noExplicit allowlist of reconciliation_staging row UUIDs to re-classify. Takes precedence over filter. Must hold at least one UUID — an empty array is REFUSED, because it would fall through to the filter sweep rather than select nothing.
filterobjectnoRow selector used when staging_ids is absent. At least one field must be set — &#123;&#125; is REFUSED, because an empty filter is the same unbounded sweep as no selector at all.
skip_llmbooleannoSkip the LLM layer (deterministic mode). DEFAULT true — the primary, reproducible use. Set false to allow the LLM fallback for unresolved rows.
dry_runbooleannoWhen true (DEFAULT), compute before/after diffs WITHOUT writing. Set false to persist the re-classification.
limitnumbernoSafety cap on how many rows are processed. Default 50.

Validate Staging Row (learn-loop)

Record a HUMAN classification decision on a reconciliation_staging row and LEARN from it. Updates the row (target_tenant_slug, ledger/template, counterparty, cost-center) AND upserts financial.routing_memory keyed by the row signature, so the same payer/signature auto-classifies next time (closes the loop with the classify routing-match step). Use this when Tainan fills in who an orphan Pix arrival belongs to in the Preview. Does NOT change status — the row stays pending_review; nothing is posted. Both the row UPDATE and the routing_memory upsert go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies).

ParameterTypeRequiredDescription
staging_idstringyesUUID of the reconciliation_staging row being validated.
target_tenant_slugstringyesTenant the human routed this row to (e.g. “tainan-pf”, “dfl-ecosystem”).
template_codestringnoConfirmed ledger/template code (e.g. “salary_received_pf”), or null.
counterparty_namestringnoCorrected/confirmed payer name. Folded into source_raw and into the signature.
counterparty_docstringnoConfirmed payer CPF/CNPJ (digits). Folded into source_raw and into the signature.
cost_centerstringnoOptional free-text cost-center tag → source_raw.cost_center.
cost_center_idstringnoExplicit first-class cost-center UUID override (financial.cost_centers, dfl-schema #432) — written VERBATIM to cost_center_id. When omitted, it is auto-resolved from the source_raw.cost_center hint (mapped through the cost_centers catalog) then vendor/description detection. Pass null to clear it. The resolved/overridden value is also persisted on routing_memory.validated_cost_center_id so the same signature auto-classifies its cost center next time.
reviewer_notesstringnoOptional reviewer note for the audit trail.
account_holder_idstringnoExplicit account-holder UUID override (financial.account_holders) — the real economic entity. devfellowship = 1192c975-0381-4666-aa2e-241221744e17; Tainan = 0a0d87ec-6c00-41b2-a6e3-0b323f6653c2. When omitted, it is auto-resolved from source_type (crypto → Tainan) + the target tenant slug. Pass null to clear it.
allow_unknown_slugbooleannoPermit a target_tenant_slug outside the known set. Default false.
learnbooleannoWhether to upsert routing_memory from this decision. Default true.

Multi-Hop Reconciliation Matcher (additive by default)

Link the legs of ONE money-movement across on-chain → Binance → bank into a reconciliation_group, by ordered passes: (1) TXID exact onchain.tx_hash ↔ binance deposit txid; (2) fuzzy amount±tolerance + date-window + direction for onchain↔binance deposit, binance USDC→BRL conversion, and binance fiat-withdraw↔bank arrival; (3) intra-Binance balance flow (asset continuity + time ordering) bridging Deposit→Sold (USDC) and Revenue→Fiat-Withdraw (BRL) so the salary chain collapses to ONE group spanning onchain→deposit→sold→revenue→withdraw→bank. Default date window is ±2 days. Writes a reconciliation_runs row (params + counts + orphan report in summary), reconciliation_groups rows (confidence + leg count + label), and sets reconciliation_group_id on matched staging rows. Also (Pass 5) groups on-chain SAME-TX swap/deposit legs — two+ onchain rows sharing a tx_hash where one is out (asset A) and another in (asset B, A≠B): ETH→earnETH, HYPE→stHYPE, USDC↔token, LST wrap/unwrap — into an onchain_swap group (a wash: DR asset-B / CR asset-A, no income; a stablecoin/fiat leg is flagged gain_loss_review). Orphan legs (e.g. a fiat withdraw with no bank arrival → missing extrato) are reported. Grouping metadata ONLY on pending_review rows — nothing approved/posted. ADDITIVE BY DEFAULT: the run proposes groups only for pending_review rows that carry NO reconciliation_group_id, and every stamping UPDATE is guarded with reconciliation_group_id IS NULL, so it is incapable of moving or clearing a link that already exists. A row already grouped is reported in legs_skipped_already_grouped and left exactly as it is. dry_run DEFAULTS TO TRUE and returns the full plan plus a destruction block naming what a rebuild WOULD take away — group count, row count, how many of those rows are already posted (journal_entry_id) or human-annotated (reviewer_notes), and how many the matcher could never re-stamp because it loads only pending_review rows. rebuild=true is the DESTRUCTIVE path: it deletes every matcher-owned group of the tenant and unstamps every row pointing at them. It is lossy, not idempotent — measured on prod 2026-08-19, it would have unstamped 169 rows of which 119 could never be re-stamped, including 108 already posted to the ledger. rebuild=true with dry_run=false therefore REQUIRES expect (groups_deleted / rows_unstamped / group_ids) and refuses on any mismatch; when any row to be unstamped carries a journal_entry_id, expect.rows_unstamped_with_journal_entry must name that exact number — omitting it is a refusal. An UNRECOGNIZED argument key is REFUSED and named before anything is read. Read AND write run on the caller’s user-JWT under RLS. NEVER service_role.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID.
dry_runbooleannoWhen true (DEFAULT), compute the whole match, report it together with the destruction block, and write NOTHING. Set false to write.
rebuildbooleannoDESTRUCTIVE. When false (DEFAULT) the run is ADDITIVE: it proposes groups only for pending_review rows that carry no reconciliation_group_id, and never clears or moves an existing one. When true it first DELETES every matcher-owned group of the tenant and unstamps every row pointing at them — which permanently loses the link for any row the matcher can no longer see (anything not pending_review). true + dry_run=false REQUIRES expect.
expectobjectnoREQUIRED when rebuild=true AND dry_run=false: name what you believe the rebuild destroys. Read the destruction block of the preview first. The rebuild refuses, and writes nothing, when reality disagrees.
amount_tolerance_pctnumbernoFractional amount tolerance for fuzzy matching (0.02 = ±2%). Default 0.02.
date_window_daysnumberno± days window for cross-surface date proximity. Default 2.

List Chart-of-Accounts (Ledger Accounts)

List financial.ledger_accounts (the chart of accounts) for a tenant. Filter by account type (asset/liability/equity/revenue/expense) and/or parent_id (pass parent_id to list a parent account’s sub-accounts; pass parent_id=“root” to list only top-level accounts). RLS-scoped via the per-session user JWT — only accounts for tenants the caller belongs to are returned. Useful to find account ids before composing a journal entry, or to inspect a sub-account tree (e.g. Itera modeled as dedicated sub-accounts under the devfellowship chart).

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID.
account_typeenumnoFilter by account type. One of asset, liability, equity, revenue, expense. One of: asset, liability, equity, revenue, expense.
parent_idstringnoFilter by parent account. A UUID lists that account’s direct sub-accounts; the literal “root” lists only top-level accounts (parent_id IS NULL).
active_onlybooleannoOnly is_active=true accounts. Default false (all).
limitnumbernoDefault 200.

Create Ledger Account (Chart-of-Accounts entry)

Create a financial.ledger_accounts row (a chart-of-accounts entry). Pass parent_id to create a SUB-ACCOUNT under an existing account — this is how a sub-entity such as Itera is modeled: dedicated sub-accounts inside the devfellowship chart, NOT a separate account_holder. The account type is one of asset/liability/equity/revenue/expense and must match (or be consistent with) the parent’s type for a clean tree. Codes are unique per tenant (UNIQUE (tenant_id, code)); a sub-account convention is to prefix the parent code (e.g. parent 1010 → sub 1010-ITERA). Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped); the INSERT is enforced by the financial.* WITH CHECK policies (dfl-schema migration 20260626170000_financial_rls_member_write_policies). There is no free-form tag column on accounts — use cost_center_id on journal entry lines for per-line dimensions.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID (e.g. the devfellowship tenant).
account_typeenumyesasset | liability | equity | revenue | expense (financial.account_types.id). One of: asset, liability, equity, revenue, expense.
codestringyesAccount code, unique per tenant. e.g. “1010” or “1010-ITERA” for a sub-account.
namestringyesHuman-readable account name.
descriptionstringnoOptional description / note.
parent_idstringnoOptional parent ledger_account UUID — set to make this a sub-account.

Update Ledger Account (Chart-of-Accounts entry)

Update the mutable fields of a financial.ledger_accounts row (a chart-of-accounts entry) — toggle is_active (activate/deactivate, e.g. retire a wrong FX account without deleting its history), rename it, or set/clear its description. UPDATE-ONLY: there is NO delete path (accounts keep their journal-entry history). Select the account by id (UUID) OR by code + tenant_id (codes are unique per tenant, so tenant_id is required with code). At least one mutable field (is_active, name, description) must be provided. RLS-scoped (per-session user JWT) — only accounts in the caller’s tenant can be updated.

ParameterTypeRequiredDescription
idstringnoUUID of the ledger_accounts row to update. Takes precedence over code/tenant_id.
codestringnoAccount code (financial.ledger_accounts.code). Requires tenant_id — codes are unique per tenant. Ignored when id is provided.
tenant_idstringnoTenant UUID — required when selecting by code.
is_activebooleannoActivate (true) or deactivate (false) the account.
namestringnoNew human-readable account name.
descriptionstringnoSet the description / note. Pass null to clear.

List Journal Entries

List financial.journal_entries (headers only) for a tenant, newest first. Filter by status (draft/posted/voided). RLS-scoped via the per-session user JWT. Use get_journal_entry to fetch the full double-entry lines of a specific entry.

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID.
statusenumnoFilter by status. Omit to list all. One of: draft, posted, voided.
limitnumbernoDefault 50.

Get Journal Entry (with lines)

Fetch a single financial.journal_entries row plus its journal_entry_lines (the double-entry debit/credit lines). RLS-scoped via the per-session user JWT. Also reports whether the entry balances (Σ debit == Σ credit).

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID.
entry_idstringyesjournal_entries.id

Create Journal Entry (draft, double-entry)

Create a DRAFT financial.journal_entries row plus its double-entry lines. Each line is {account_id, debit | credit}: provide debit OR credit (one positive, the other omitted/0). The entry MUST balance: Σ debit == Σ credit. All account_ids must exist for the tenant. Lands as status=draft — call post_journal_entry to confirm (human-confirm gate, mirrors the staging pending_review → executed model). For Itera, post lines against the Itera sub-accounts to keep its movements as plain journal entries in the shared chart. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped); the INSERT is enforced by the financial.* WITH CHECK policies (dfl-schema migration 20260626170000_financial_rls_member_write_policies).

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID.
entry_datestringyesEntry date (YYYY-MM-DD).
descriptionstringnoEntry memo / description.
reference_typestringnoOptional reference_type tag (e.g. “itera”).
reference_idstringnoOptional reference_id UUID.
linesobject[]yesAt least two lines forming a balanced double entry (Σ debit == Σ credit).

Create Journal-Entry Template (idempotent)

Create a financial.journal_entry_templates row — an alias of {debit_ledger_account_id, credit_ledger_account_id, optional default_cost_center_id} keyed by a code. Routing rules reference templates via ledger_template_code. IDEMPOTENT on (tenant_id, code): re-running returns the existing row (created=false). Both accounts must already exist for the tenant — this does NOT create accounts. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies).

ParameterTypeRequiredDescription
tenant_idstringyesOwning tenant UUID.
codestringyesStable template code (e.g. “TPL-OE-INCOME”).
namestringyesHuman label.
descriptionstringno—
debit_ledger_account_idstringyesledger_accounts.id to DEBIT (e.g. the cash/asset account the money lands in).
credit_ledger_account_idstringyesledger_accounts.id to CREDIT (e.g. the revenue account, for income templates).
default_cost_center_idstringnoOptional default cost_center_id applied to the lines.

Update Journal-Entry Template (repoint a leg)

Repoint an EXISTING financial.journal_entry_templates row, keyed by (tenant_id, code). Where create_journal_entry_template is create-only, this is update-only — it moves a template’s leg(s) to different ledger accounts (the primary use: move a template’s CASH leg from a generic placeholder account to a real wallet ledger account, e.g. tainan-pf expense templates moving their credit leg from “1.1.1 Checking” to “1.1.1.18 Nubank”). Pass any of debit_ledger_account_id / credit_ledger_account_id / name / description / default_cost_center_id / is_active — only supplied fields change. Any new ledger account id must already exist for the tenant (validated before the update; this does NOT create accounts). NO-OP when the patch matches current values (changed=false). Fails clearly if the template code does not exist for the tenant (use create_journal_entry_template to create one). Both the account-validation READ and the UPDATE go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies).

ParameterTypeRequiredDescription
tenant_idstringyesOwning tenant UUID.
codestringyesStable template code identifying the template to repoint (e.g. “TPL-EXP-GENERAL”).
debit_ledger_account_idstringnoNew ledger_accounts.id for the DEBIT leg. Must exist for the tenant.
credit_ledger_account_idstringnoNew ledger_accounts.id for the CREDIT leg. Must exist for the tenant. For an expense template the credit leg is the cash leg.
namestringnoNew human label.
descriptionstringnoNew description (pass null to clear).
default_cost_center_idstringnoNew default cost_center_id (pass null to clear).
is_activebooleannoActivate / deactivate the template.

Create Asset Type (register a new currency/asset)

Register a new asset in financial.asset_types — the global lookup that every ledger line, wallet and staging row resolves its currency against. Needed BEFORE create_wallet or any posting can reference the asset: wallets.asset_type_id is NOT NULL, and publish_batch_atomic refuses a staging row whose currency does not resolve here. GLOBAL data — there is no tenant_id, so one row is visible to every tenant and INSERT is restricted to a SUPERADMIN by RLS (dfl-schema 20260817140000). A duplicate symbol is REFUSED (never silently reused) and the existing row id is returned, because two rows for one symbol let two lines claim the same currency and compare unequal. UPDATE and DELETE are deliberately NOT available anywhere: re-labelling an asset would silently re-interpret every posted line referencing it, so a correction goes through a dfl-schema migration a human reads.

ParameterTypeRequiredDescription
symbolstringyesTicker as it appears on-chain or on the statement, e.g. “MSTRX”, “USDC”, “BRL”. This is the value staging rows carry in currency and the key publish_batch_atomic matches on, so it must be EXACTLY the string the source emits — not a prettified version of it.
namestringyesHuman label, e.g. “MicroStrategy (tokenised)”.
asset_category_idstringyesfinancial.asset_categories.id. The existing set is Fiat Currency, Cryptocurrency, Equity, Fixed Income and Physical Asset — pick one, this tool does not create categories.
decimalsnumberyesOn-chain decimals (18 for most ERC-20s, 8 for BTC, 2 for fiat). Wrong decimals do NOT rescale anything already stored — they change how the same number is read.
is_base_currencybooleannoDefault false. Only a reporting base currency sets this.
logo_urlstringnoOptional logo URL for the UI.
metadataobjectnoOptional JSON blob, e.g. {“contract”:“0x…”,“chain”:“ethereum”}.

Create Wallet (bind a bank account to a ledger account)

Create a financial.wallets row pointing at an EXISTING ledger account (ledger_account_id). A wallet binds {account_holder_id, wallet_type_id, asset_type_id, ledger_account_id, name} so the reconciliation Preview can attribute a real bank/brokerage account to its ledger cash account (e.g. binding the Woovi 1.1.1.03 / Nubank-PJ 1.1.1.02 ledger accounts, which already exist, to their wallet rows). The ledger account must ALREADY exist for some tenant — this tool does NOT create accounts (use create_account for that). IDEMPOTENT on name (case-insensitive exact): re-running with the same wallet name returns the existing wallet (created=false) and BACKFILLS any provided field that differs — e.g. set chain + address on a row that had them NULL (updated=true). Keying on name (NOT ledger_account_id) lets distinct chain/asset wallets that share one ledger account coexist. For ON-CHAIN wallets pass chain (normalized slug, e.g. ethereum/arbitrum/base/hyperevm/hyperliquid/solana) + address (EVM 0x… or Solana base58) so the row joins to financial.onchain_balance_snapshots by (address, chain) and resolves per-wallet in the Preview. account_holder_id, wallet_type_id, asset_type_id, ledger_account_id and name are required; chain / address / asset_instrument_id / financial_entity_id / metadata are optional. Both the account-validation READ and the INSERT/UPDATE go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies).

ParameterTypeRequiredDescription
ledger_account_idstringyesEXISTING financial.ledger_accounts.id the wallet binds to (e.g. Woovi 1.1.1.03).
account_holder_idstringyesfinancial.account_holders.id that owns the wallet (the tenant-scoping FK).
wallet_type_idstringyesfinancial.wallet_types.id (e.g. bank account / brokerage / on-chain).
asset_type_idstringyesfinancial.asset_types.id — the wallet currency/asset (e.g. BRL).
namestringyesHuman label, e.g. “Woovi” / “Nubank PJ” / “BlackL - Arbitrum/ETH”. Also the idempotency + backfill key (case-insensitive exact).
chainstringnoOptional normalized chain slug for ON-CHAIN wallets (ethereum/arbitrum/base/hyperevm/hyperliquid/solana/fuel/near). Combined with address, lets the wallet join to financial.onchain_balance_snapshots by (address, chain). OMIT to leave an existing value untouched; pass null to CLEAR it.
addressstringnoOptional on-chain address for ON-CHAIN wallets (EVM 0x… or Solana base58). OMIT to leave an existing value untouched; pass null to CLEAR it.
asset_instrument_idstringnoOptional financial.asset_instruments.id. OMIT to leave an existing value untouched; pass null to CLEAR it.
financial_entity_idstringnoOptional financial.financial_entities.id — the canonical bank/brokerage key that lines up with reconciliation_staging.source_bank_name. OMIT to leave an existing value untouched; pass null to CLEAR it.
metadataobjectnoOptional JSON metadata blob.

Update Wallet (rename / repoint ledger / fix type / toggle active)

Patch an EXISTING financial.wallets row by id (the complement of create_wallet). Changeable fields: name (rename), ledger_account_id (repoint to a different EXISTING ledger account), wallet_type (pass wallet_type_id as a uuid OR wallet_type as a case-insensitive name/slug, e.g. “Checking” / “checking” → Checking Account), and is_active. Only the fields you pass are changed. A new ledger_account_id is validated to exist + be visible to the caller. dry_run=true (DEFAULT) returns the before/after diff without writing — set dry_run=false to persist. At least one patch field is required. Every read AND the UPDATE run through the caller’s user-JWT under RLS (member+, tenant-scoped; USING/WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies) — never service-role. Use case: rename a wallet + fix its wallet_type (e.g. ‘Brokerage’→‘Checking’) while keeping its ledger account.

ParameterTypeRequiredDescription
wallet_idstringyesUUID of the financial.wallets row to update.
namestringnoNew human label for the wallet (rename), e.g. “DFL Caixa Geral”.
ledger_account_idstringnoRepoint the wallet to a different EXISTING financial.ledger_accounts.id. Validated to exist + be visible to the caller before writing.
wallet_type_idstringnoNew financial.wallet_types.id. Takes precedence over wallet_type (name/slug).
wallet_typestringnoNew wallet type by case-insensitive name OR slug (e.g. “Checking”, “Checking Account”, “checking”). Resolved to financial.wallet_types.id; ambiguous label → error. Ignored when wallet_type_id is also provided.
asset_type_idstringnoRe-denominate the wallet: the financial.asset_types id it should hold. Pass this OR asset_symbol, not both. Changes what the CONTAINER claims to hold — it does NOT rewrite history, because the asset lives on each journal_entry_lines row.
asset_symbolstringnoRe-denominate by symbol instead of id, e.g. “USDC”. Matched case-insensitively against financial.asset_types.symbol. Refused if the symbol is unknown — this tool never creates an asset type.
allow_asset_history_mismatchbooleannoRequired to re-denominate a wallet whose ledger account already carries journal lines in a DIFFERENT asset. Without it the tool refuses and reports the line counts per asset, so the mismatch is a decision someone made on the numbers rather than a side effect they did not see.
is_activebooleannoToggle the wallet active/inactive flag.
dry_runbooleannoWhen true (DEFAULT), compute the before/after diff WITHOUT writing. Set false to persist the change.

Delete Wallet (hard delete — allowlist only, FK + balance guarded)

HARD-delete financial.wallets rows by an EXPLICIT allowlist of ids (wallet_ids) — NEVER a filter/query-based delete, so a sweep cannot happen by accident. Two guards, both fail-closed: (1) FK-dependents — DYNAMICALLY discovers, via the financial.count_fk_dependents RPC (which queries pg_constraint at call time, never a hardcoded table pair), every foreign key referencing financial.wallets; a wallet with ANY dependent row in ANY referencing table is REFUSED, naming the table(s) and row count(s). A future FK is covered automatically, no tool change needed. (2) Non-zero balance (financial.v_wallets.balance) — refused unless allow_nonzero_balance=true. reason is REQUIRED (recorded in the structured log + echoed in the response — a hard delete leaves no row to stamp it onto). dry_run=true (DEFAULT) returns exactly what WOULD be deleted plus the refused list, without writing; set dry_run=false to persist. Feature-guarded: until the dfl-schema migration creating financial.count_fk_dependents is merged, EVERY wallet is refused (fail closed, never an unguarded delete). Every read AND the DELETE run on the caller’s user-JWT under RLS (member+, account-holder-scoped) — never service-role.

ParameterTypeRequiredDescription
wallet_idsstring[]yesREQUIRED explicit allowlist of financial.wallets UUIDs to delete. NEVER a query/filter — only these exact ids are considered, and only those that pass BOTH guards are deleted.
reasonstringyesREQUIRED audit reason for the deletion (e.g. “duplicate wallet bound to the same ledger account 1.1.1.01, this is the inactive one, Tainan TG 2026-08-11”). Recorded in the structured log and echoed in the response — a hard delete leaves no row to stamp it onto.
allow_nonzero_balancebooleannoAllow deleting a wallet whose financial.v_wallets.balance is not exactly 0. Default false — a wallet holding value is refused rather than silently vanishing.
dry_runbooleannoWhen true (DEFAULT), compute would_delete + refused WITHOUT writing. Set false to perform the hard delete of the wallets that pass both guards.

Register On-Chain Address (address book entry)

Register a public wallet address in financial.onchain_address_book — the book that decides which wallets are read on chain at all. ingest_onchain_alchemy REFUSES an address the book does not list, and the balance collector scans only what the book lists, so a wallet with no entry here can never show a real balance. CHAIN VOCABULARY: this table stores a chain FAMILY — only evm or solana. It does NOT store a network. financial.onchain_balance_snapshots.chain stores the network instead (ethereum, arbitrum, base, hyperevm, hyperliquid, solana, binance), so the two tables do not share a vocabulary and a book row written as “arbitrum” joins to nothing — a silent empty join, not an error, because the column has no check constraint. A network slug (ethereum, arbitrum, base, polygon, hyperevm, solana) is accepted and TRANSLATED to its family, and the response reports the translation; any other value is refused with the accepted list. IDEMPOTENT on (tenant, chain family, address, case-insensitive): registering an address that is already registered UPDATES its label instead of failing, so re-running a batch of ten addresses is safe. EVM addresses are stored lowercase (every consumer compares with lower(), and the unique index is on lower(address)); Solana addresses are stored exactly as given, because base58 is case-significant. is_own_wallet is REQUIRED and has no default: true means our custody, false means a counterparty (the Binance deposit address is false). A default would silently label a counterparty as ours, and a default scan would then import that counterparty’s entire transfer history into our book. This is NOT update_wallet. Binding a financial.wallets row to a chain + address is update_wallet’s job and stays there; this tool writes a different table. A wallet usually needs both writes. Use list_onchain_addresses to see what is already registered. Reads and write both run on the caller’s user-JWT under RLS; there is no tenant_id parameter, so a call cannot address another tenant.

ParameterTypeRequiredDescription
chainstringyesThe chain FAMILY the address belongs to: “evm” or “solana”. A network slug (ethereum / arbitrum / base / polygon / hyperevm / solana) is also accepted and is translated to its family — one EVM address is valid on every EVM network, which is why the book files by family. Anything else is refused.
addressstringyesThe public address. EVM: “0x” plus exactly 40 hexadecimal characters. Solana: 32 to 44 base58 characters. Validated BEFORE the write — a wrong address makes the collector read somebody else’s wallet.
labelstringyesHuman label, e.g. “BlueL cold wallet (EVM)” or “Trezor - Tainan”. This is also what ingest_onchain_alchemy selects wallets by, and it is the field a repeat registration updates.
is_own_walletbooleanyesREQUIRED, no default. true = a wallet we hold. false = a counterparty address (e.g. the Binance deposit address). Getting this wrong mislabels a counterparty as ours, and a default scan then imports their whole history.
tenantstringnoWhich of YOUR tenants owns the entry — slug or uuid. Optional when you belong to exactly one tenant. Resolved through an RLS-scoped read, so a tenant you do not belong to is not addressable here.
ledger_account_idstringnoOptional financial.ledger_accounts.id this address settles into. Validated to exist in the same tenant. Leave unset for an own cold wallet that fans out to many per-asset accounts — the existing own-wallet rows have it NULL on purpose.
notesstringnoOptional free-text note.

List On-Chain Address Book

List financial.onchain_address_book — every wallet address that is read on chain at all. Use before register_onchain_address to see what is already registered: registering is idempotent, so re-adding a known address under a different label RENAMES the entry that ingest_onchain_alchemy selects wallets by. The chain column here is a chain FAMILY (evm or solana), NOT a network. financial.onchain_balance_snapshots.chain holds the network (ethereum, arbitrum, base, hyperevm, hyperliquid, solana, binance), so the two columns do not join. Filter by family (a network slug is translated to its family) and by own wallets only. RLS-scoped through the caller’s user-JWT — only your tenants are visible, and there is no tenant_id parameter.

ParameterTypeRequiredDescription
tenantstringnoWhich of YOUR tenants to list — slug or uuid. Optional when you belong to exactly one.
chainstringnoOptional filter. A family (evm / solana) or a network slug, which is translated to its family. Omit for every family.
own_wallets_onlybooleannoOnly entries with is_own_wallet=true (our custody). Default false — counterparty entries such as the Binance deposit address are included.

Read Solana Transactions For One Address

READ-ONLY. Enumerate every asset movement of one Solana address over a time or slot window, from the chain itself. Returns, per movement: the transaction signature, the slot, the block time, the entry date, the direction, the asset, the SPL mint, the decimals, the exact amount and a best-effort counterparty. THIS IS THE SOLANA COUNTERPART OF ingest_onchain_alchemy, which cannot serve Solana: alchemy_getAssetTransfers is EVM-only and refuses the chain by name. It WRITES NOTHING — no staging row, no journal entry. Ingesting pre-cutoff on-chain history on top of Airtable-migrated history double-counts, so read and compare first. Direction is the SIGN OF THE NET DELTA of the address in each transaction, not an instruction-level parse: a swap, an LP move and a plain transfer all reduce to how much of what left or arrived. Amounts are exact decimal strings computed in integer units — the float uiAmount field is never read. IT ALSO RECONSTRUCTS A PAST NATIVE SOL BALANCE WITHOUT AN ARCHIVE NODE: with reconstruct_opening_balance (default true) it reads the balances as of now and subtracts the window net, giving the balance at from_date. That reconstruction is WITHHELD, with the reason named, whenever the window is truncated, has unreadable transactions, or does not run to the present. It is also withheld when include_failed=false excluded a failed transaction fee — an incomplete net looks exactly like a complete one. It never reconstructs an SPL opening balance. Incoming SPL transfers can name the destination token account without naming its owner, so owner-address signatures are not exhaustive. Needs ALCHEMY_API_KEY (the same key the EVM readers use) or SOLANA_RPC_URL in the server’s environment; it refuses by name when neither is set, and never falls back to the rate-limited public endpoint, which would silently under-report.

ParameterTypeRequiredDescription
addressstringyesThe Solana address to read, base58. An EVM 0x… address is REFUSED with a reason rather than answered with an empty list, because an empty list reads as “this wallet never moved” and that is the expensive wrong conclusion.
from_datestringnoInclusive lower bound of the window, YYYY-MM-DD UTC. Omit to read the address from its first transaction. This is also the instant an opening balance is reconstructed AT.
to_datestringnoInclusive upper bound of the window, YYYY-MM-DD UTC. Omit for “up to now”. NOTE: setting this DISABLES the opening-balance reconstruction, because the identity balance_at(t) = balance_now − net(t…now) needs the window to run up to the present.
from_slotnumbernoInclusive lower slot bound, applied in ADDITION to from_date. Use for slot-exact windows.
to_slotnumbernoInclusive upper slot bound, applied in ADDITION to to_date. Also disables reconstruction.
max_signaturesnumbernoSafety cap on signatures read per call. Default 500, maximum 5000. When the cap truncates the window the response says so and the opening-balance reconstruction is withheld.
include_failedbooleannoInclude transactions that FAILED on chain. Default false. A failed transaction moves no asset, so it yields ONE fee-only SOL movement flagged on_chain_failed=true. Set it true when the closure must be EXACT: measured on 7iReWZK2… on 2026-09-07, dropping the 16 failed transactions of a 223-signature history left the reconstructed opening balance at -0,007509583 SOL instead of 0, and that residual is exactly their accumulated fee.
reconstruct_opening_balancebooleannoDefault TRUE. Read the CURRENT balances and subtract the window net, giving the balance at from_date WITHOUT an archive node. Refused, with the reason stated, whenever the window is incomplete or does not run to the present — an incomplete net yields a wrong opening balance that looks exactly like a right one.
mint_symbolsobjectnoExtra mint → ticker labels, merged over the built-in table (USDC, USDT, wSOL). Every movement carries its mint regardless: the mint is the identity of an SPL token, the ticker is not, and an unknown mint is reported AS the mint, never as a guessed ticker.

Ignore On-Chain Tokens (spam / dust denylist)

Add tokens to financial.onchain_token_ignores, the per-tenant denylist that keeps spam airdrops and dust out of the RED “sem atribuição” panel on /reconciliation/preview. KEYED ON (chain, token_address) — NEVER on the ticker. A ticker is attacker-controlled and collides: on prod 2026-08-18 “DRV” existed on base AND ethereum at two unrelated contracts, one of them ours, so a list written on “DRV” cannot express which was meant. Pass native=true instead of token_address for a network own asset, which has no contract. TWO EFFECTS, NOT ONE, and neither one DELETES anything. (1) SNAPSHOT: financial.onchain_balance_snapshots is untouched, no number moves, and v_onchain_snapshot_unattributed still RETURNS the row with is_ignored=true plus the reason — the panel filters it and states how many it hid, from the same array. (2) INGEST (since PR #349): the shared staging writer reads this same table, so every NEW on-chain row for a denylisted token is WRITTEN and then stamped source_raw.suppressed=true with a reason. It is never skipped — a skip loses the receipt and frees the (chain, tx_hash, log_index) slot, which is what made the next sweep re-create the row. So an entry hides the token on two surfaces and drops it from neither. Reversible with un_ignore_onchain_tokens, but NOT symmetrically: that tool stops FUTURE suppression and does not clear a stamp already written — see its own description. BATCHED: send every target in one call; spam arrives in batches and so should the denylist. Every target is validated BEFORE any write, so a bad one means nothing is written at all. dry_run defaults to TRUE — the response reports, per target, how many currently unattributed rows it would hide and their USD total. REPLY SHAPE: ignored counts denylist rows ACTUALLY WRITTEN and is therefore 0 in a dry run; the targets that would be written are counted in would_ignore and already carry the per-target status “would_ignore”. Never read ignored > 0 as proof of a write without also reading mode.dry_run. matched_rows COUNTS THE SNAPSHOT SIDE ONLY — it never counts staging rows, in either mode. So matched_rows=0 is still reported loudly, but it proves LESS than the number alone suggests: it proves the target hides no BALANCE row today, which remains the signal that catches a wrong chain name or a wrong contract. It does NOT prove the entry is inert. The ingest effect applies to every future on-chain row for that token whatever matched_rows says. Measured on prod 2026-08-20: TMX (base / 0x945aa7c3ab890a4837a8a6a7b0ee0b82ae8e4bd1) reported matched_nothing=1 while its staging row existed all along. Read a 0 as “check the chain and the contract”, never as “this entry does nothing”. Reads and writes both run on the caller user-JWT under RLS; there is no tenant_id parameter, so a call cannot address another tenant.

ParameterTypeRequiredDescription
targetsobject[]yesThe tokens to denylist. One call per batch — the MCP is rate-limited and spam airdrops arrive dozens at a time.
reasonstringyesREQUIRED, and stored on every row. A short class, not a story: “spam_airdrop”, “dust”, “not_ours”. It is what a person reading the denylist in six months needs to decide whether the entry still holds.
notestringnoOptional free text: who asked, which message, what was measured.
tenantstringnoWhich of YOUR tenants owns these entries — slug or uuid. Optional when you belong to exactly one tenant. Resolved through an RLS-scoped read.
dry_runbooleannoWhen true (DEFAULT) nothing is written; the response still reports exactly what each target would hide. Set false to persist.

Un-Ignore On-Chain Tokens (remove from the denylist)

Remove tokens from financial.onchain_token_ignores so they appear again in the RED “sem atribuição” panel. The exact inverse of ignore_onchain_tokens and takes the same targets: (chain, token_address), or native=true for a network own asset. It deletes a denylist row and nothing else, and THAT IS NOT A FULL INVERSE. On the SNAPSHOT side it is: the balance itself was never modified, so the row simply stops being marked is_ignored and returns to the panel. On the INGEST side it is NOT: the delete only stops FUTURE on-chain rows from arriving suppressed. Staging rows that the ingest already stamped source_raw.suppressed=true STAY suppressed, and nothing in this tool touches them. To bring those back, find them with list_staging include_suppressed=true and clear the stamp with un_suppress_staging, which takes an explicit staging_ids array. Adding an entry is one call; undoing it fully is two. A target that is not on the denylist is reported as an idempotent no-op, not an error. dry_run defaults to TRUE and reports, per target, how many SNAPSHOT rows would become visible again and their USD total — that count never includes the suppressed staging rows, which this tool can neither count nor reverse. REPLY SHAPE: un_ignored counts denylist rows ACTUALLY DELETED and is therefore 0 in a dry run; the targets that would be deleted are counted in would_un_ignore and already carry the per-target status “would_un_ignore”. Never read un_ignored > 0 as proof of a delete without also reading mode.dry_run. RLS-scoped user-JWT; you can only remove your own tenant entries.

ParameterTypeRequiredDescription
targetsobject[]yesThe tokens to remove from the denylist.
tenantstringnoWhich of YOUR tenants — slug or uuid. Optional when you belong to exactly one.
dry_runbooleannoWhen true (DEFAULT) nothing is deleted; the response still reports the effect.

List the On-Chain Token Denylist

Show every entry in financial.onchain_token_ignores for your tenant, each with the number of currently unattributed rows it hides, their USD total, and how many of them carry no price. Also reports the totals: how many unattributed rows are hidden and how many remain visible. A denylist you cannot inspect is a trap, which is why this ships with the writer and not after it. Every count here measures the SNAPSHOT side only; each entry ALSO suppresses new on-chain staging rows at ingest, and that side is not counted on this page. An entry whose matched_rows is 0 is still called out, because it is the signal that catches a wrong chain or contract — but it means the entry hides no BALANCE row today, not that the entry is inert. RLS-scoped user-JWT.

ParameterTypeRequiredDescription
tenantstringnoWhich of YOUR tenants — slug or uuid. Optional when you belong to exactly one.
chainstringnoOptional network filter (base, arbitrum, ethereum, …).

List Asset Symbol Aliases (alias → canonical asset)

Show every row of financial.asset_symbol_aliases, resolved to the asset it means: the alias spelling, the canonical symbol and name, the asset_type_id, the recorded reason, and when the row was created and last updated. This is the ONE list that BOTH the preview and the write read. Before dfl-schema #850 the aliases lived only in TypeScript, so the dry run resolved UETH and financial.publish_batch_atomic — which resolves in plain SQL — refused the same batch; a preview that passes and a write that refuses is worse than either answer alone. GLOBAL data: there is no tenant_id and no tenant parameter, so one row is how EVERY tenant resolves that spelling. Resolution order is ALWAYS exact asset_types.symbol FIRST and this table SECOND, never the reverse and never a heuristic — so an alias that shadows a real asset symbol is dead data, and the database refuses to create one. NOTE ON AUTHORSHIP: the table records no author. dfl-schema #850 shipped it with no created_by column, so “who added it” lives in the reason text and nowhere else — read the reason, not a field. Removals DO record their author, in financial.asset_symbol_alias_removals. REMOVAL: use delete_asset_symbol_alias. It hard-deletes the row and writes an append-only tombstone carrying the original reason, the removal reason and who removed it, so nothing is lost. Removal is superadmin-only and it is NOT free: every staging row whose currency resolved only through that alias stops resolving, and the fail-closed publish path then refuses the WHOLE batch. There is still no UPDATE path — correcting a target is remove-then-create, two audited events. 🚨 AN ALIAS IS A SPELLING, NEVER A DERIVATIVE. An alias says “this is another SPELLING of the same asset” — one unit of the alias IS one unit of the canonical asset, at a fixed 1:1, for ever (a bridge, a rename, a glyph: UETH is Unit-bridged ETH; USD₮0 is USDT0 with Tether ₮ glyph U+20AE). It must NEVER be used for a DERIVATIVE whose exchange rate DRIFTS against the underlying: stHYPE, vHYPE, kHYPE, LHYPE, earnETH, THBILL, and every wrapped-yield / liquid-staking token (stETH, wstETH, rETH, cbETH, weETH, ezETH, sDAI, sUSDe, jitoSOL, mSOL). Those are worth MORE of the underlying every day the yield accrues. Aliasing one MISSTATES THE BALANCE by the accrued yield, the journal entry still BALANCES so nothing raises, and the misstatement GROWS without limit. A drifting token needs its own asset_types row (create_asset_type), never an alias. Read on the caller user-JWT under RLS.

ParameterTypeRequiredDescription
alias_symbolstringnoOptional filter: show only the row for this spelling. Matched case-insensitively, the way the unique index and the resolver both match (upper()).
canonical_symbolstringnoOptional filter: show only the aliases that mean this canonical asset, e.g. “ETH”. Matched case-insensitively.

Create Asset Symbol Alias (another spelling of an existing asset)

Add one or more rows to financial.asset_symbol_aliases — the ONE list that BOTH the preview and the SQL write path (financial.publish_batch_atomic) read when a source reports a different SPELLING of an asset the ledger already carries. Use it when the fail-closed publish path refuses a batch because a currency “did not resolve to a financial.asset_types row”, AND you have confirmed the spelling is the same asset. 🚨 AN ALIAS IS A SPELLING, NEVER A DERIVATIVE. An alias says “this is another SPELLING of the same asset” — one unit of the alias IS one unit of the canonical asset, at a fixed 1:1, for ever (a bridge, a rename, a glyph: UETH is Unit-bridged ETH; USD₮0 is USDT0 with Tether ₮ glyph U+20AE). It must NEVER be used for a DERIVATIVE whose exchange rate DRIFTS against the underlying: stHYPE, vHYPE, kHYPE, LHYPE, earnETH, THBILL, and every wrapped-yield / liquid-staking token (stETH, wstETH, rETH, cbETH, weETH, ezETH, sDAI, sUSDe, jitoSOL, mSOL). Those are worth MORE of the underlying every day the yield accrues. Aliasing one MISSTATES THE BALANCE by the accrued yield, the journal entry still BALANCES so nothing raises, and the misstatement GROWS without limit. A drifting token needs its own asset_types row (create_asset_type), never an alias. WHAT IT REFUSES, before writing anything: (1) a canonical asset that does not exist — register it with create_asset_type first; (2) an alias whose spelling IS already a real financial.asset_types symbol, which would shadow a genuine asset and be dead data, because the exact match always wins first; (3) an alias of an alias, or an alias of itself — the resolver looks up the exact symbol, then this table ONCE, and stops, so a chain resolves to nothing; (4) RE-POINTING an existing alias at a different asset, because it would silently change where every future row with that spelling posts; (5) any name on the literal never-alias denylist (stHYPE, vHYPE, kHYPE, LHYPE, earnETH, THBILL, stETH, wstETH, rETH, cbETH, weETH, ezETH, rsETH, sDAI, sUSDe, sUSDS, jitoSOL, mSOL); (6) a canonical_symbol and an asset_type_id that name two DIFFERENT assets. IDEMPOTENT: re-sending a pair that is already on the list is a no-op reported as already_exists, never an error. ALL-OR-NOTHING: every entry is validated BEFORE any write, so one bad entry means NOTHING is written — a half-applied alias list cannot be taken back on a user-JWT. REPLY SHAPE: created counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run; the entries that would be written are counted in would_create and carry the per-entry status “would_create”. Never read created > 0 as proof of a write without also reading mode.dry_run. dry_run defaults to TRUE. REVERSIBLE, BUT NEVER CHEAP: delete_asset_symbol_alias can remove a wrong alias on a user-JWT (superadmin, with a mandatory reason, recorded in an append-only tombstone). That is NOT permission to guess: removing an alias makes every staging row that resolved only through it unresolvable, so a batch that published yesterday refuses today, and it refuses WHOLE. There is still NO update path — a wrong TARGET costs a remove plus a create. GLOBAL data: no tenant_id and no tenant parameter. The INSERT policy is iam.is_superadmin(), so a non-superadmin is refused by the database, by name. Reads and the write both run on the caller user-JWT under RLS.

ParameterTypeRequiredDescription
aliasesobject[]yesThe aliases to add. One entry adds one alias; send several in one call when a single decision covers them (UETH and UBTC are the same Unit bridge and the same ruling).
reasonstringyesREQUIRED, and stored on every row that does not carry its own. WHY the alias is CORRECT, in words a human can audit six months from now: the bridge, the contract address, the person who decided and when. financial.asset_symbol_aliases.reason is NOT NULL and non-empty on purpose — an alias nobody can audit is how a WRONG alias survives, and there is no delete path to take it back.
dry_runbooleannoWhen true (DEFAULT) NOTHING is written; the response still reports the full decision for every entry, including every refusal and every derivative warning. Set false to persist.

Delete Asset Symbol Alias (remove a wrong spelling rule, with a reason)

Remove one or more rows from financial.asset_symbol_aliases. Use it when an alias was WRONG — the spelling turned out to be a different asset, or a drifting derivative that should have been its own asset_types row. THE ROW IS HARD-DELETED, and an append-only TOMBSTONE is written to financial.asset_symbol_alias_removals in the SAME transaction: the alias spelling, the asset it meant, the ORIGINAL reason, YOUR reason for removing it, and who you are. Nothing is lost — the evidence moves, it does not disappear. Hard delete rather than an is_active flag on purpose: no reader anywhere has to remember to filter, and the spelling is FREED, so the documented recovery — register it as its own asset with create_asset_type — actually works. ⚠️ WHAT REMOVAL MEANS: an alias is the ONLY thing that lets a source spelling resolve. Take it away and every financial.reconciliation_staging row whose currency resolved ONLY through it becomes unresolvable — so a batch that published fine yesterday will REFUSE today. It refuses WHOLE: financial.publish_batch_atomic is fail-closed, so one unresolvable row takes the good rows beside it down too, and nothing posts. Already-posted journal entries are NOT touched or re-valued; the damage is to what you publish NEXT. Run list_staging for that currency first, or read the affected_staging_rows count this tool reports — the dry run counts them for you, under your own RLS scope. REFUSES, before removing anything: (1) a spelling that is the CANONICAL asset of one or more aliases, e.g. “ETH” when you meant “UETH” — it names the candidates rather than guessing, because a no-op reported as success on a call aimed at the wrong row is worse than an error; (2) two casings of the SAME spelling in one call — matching folds on upper(), so they are one row; (3) a blank entry; (4) a reason shorter than 10 characters. IDEMPOTENT: a spelling that is on no list reports not_found, never an error — re-sending a removal after a timeout must not look like a failure. ALL-OR-NOTHING: every entry is decided BEFORE any removal, and the database call is one transaction, so one bad entry means NOTHING is removed. A half-applied removal leaves a resolution table that is neither the old one nor the new one. REPLY SHAPE: removed counts rows ACTUALLY DELETED and is therefore 0 in a dry run; the entries that would be deleted are counted in would_remove and carry the per-entry status “would_remove”. Never read removed > 0 as proof of a deletion without also reading mode.dry_run. dry_run defaults to TRUE, and a dry run reports the full decision plus the affected staging-row count for every entry. THIS IS NOT AN UNDO FOR A RE-POINT. There is no UPDATE path on this table, by design: re-pointing an alias silently changes where every future row with that spelling posts, and the entry still balances so nothing raises. To correct a target, remove and then create — two audited events, each with its own reason, which is the honest record of what happened. 🚨 AN ALIAS IS A SPELLING, NEVER A DERIVATIVE. An alias says “this is another SPELLING of the same asset” — one unit of the alias IS one unit of the canonical asset, at a fixed 1:1, for ever (a bridge, a rename, a glyph: UETH is Unit-bridged ETH; USD₮0 is USDT0 with Tether ₮ glyph U+20AE). It must NEVER be used for a DERIVATIVE whose exchange rate DRIFTS against the underlying: stHYPE, vHYPE, kHYPE, LHYPE, earnETH, THBILL, and every wrapped-yield / liquid-staking token (stETH, wstETH, rETH, cbETH, weETH, ezETH, sDAI, sUSDe, jitoSOL, mSOL). Those are worth MORE of the underlying every day the yield accrues. Aliasing one MISSTATES THE BALANCE by the accrued yield, the journal entry still BALANCES so nothing raises, and the misstatement GROWS without limit. A drifting token needs its own asset_types row (create_asset_type), never an alias. GLOBAL data: no tenant_id and no tenant parameter. Removal is SUPERADMIN-only (financial.remove_asset_symbol_aliases checks iam.is_superadmin()), so a non-superadmin is refused by the database, by name. Every read and the removal run on the caller user-JWT under RLS — there is no service-role path here and this table must never acquire one.

ParameterTypeRequiredDescription
alias_symbolsstring[]yesThe ALIAS spellings to remove — the alias_symbol column, not the canonical asset symbol. Matched case-insensitively, the way the unique index and the resolver both match (upper()). Naming the canonical asset instead is REFUSED with the candidate aliases listed, never guessed at.
reasonstringyesREQUIRED. WHY the alias was WRONG, in words a human can audit six months from now: what the spelling actually turned out to be, who decided, and when. It is stored on the tombstone next to the reason the alias was created with, so the two read as one story. The database enforces the 10-character floor as well — a DELETE statement has nowhere to put a reason, which is exactly why removal goes through a function and not through a grant.
dry_runbooleannoWhen true (DEFAULT) NOTHING is removed; the response still reports the full decision for every entry, every refusal, and how many staging rows each removal would make unresolvable. Set false to persist.

Re-date Mislabelled Balance Snapshots (snapshot_at := fetched_at)

Repair financial.onchain_balance_snapshots rows whose recorded observation date (snapshot_at) disagrees with when the balance was actually read (fetched_at). WHY THE CLASS EXISTS: snapshot_at has a now() DEFAULT and a DEFAULT applies on INSERT ONLY, so whenever an adapter’s upsert anchor does not move between two runs the later run takes the ON CONFLICT DO UPDATE branch on the OLDER row — the balances are replaced and the date label stays put. The row then claims to be an observation of a day on which nobody looked. Measured on prod 2026-08-17, chain=binance: 39 of 52 rows mislabelled across three groups, with only 2026-07-13 honest. dfl-financing #215 fixed the Binance anchor going forward and could not fix either the written rows or the class — this tool keys on the SYMPTOM (snapshot_at vs fetched_at) and contains no chain, adapter or date, so it finds the same defect in any other adapter without a code change. IT ONLY EVER WRITES snapshot_at := fetched_at. It never computes, rounds or infers a date. A row with a NULL fetched_at has no recorded truth, so it is REPORTED as unrepairable rather than guessed at. INSPECT THEN APPLY: dry_run=true is the DEFAULT and reports every candidate grouped by (current date → true date, adapter, chain) with a count, plus the unrepairable list and the collision verdict, writing nothing. dry_run=false REQUIRES expect — name the row count and/or the exact (from → to) groups you believe you are changing — and REFUSES, writing nothing, when reality disagrees. The check runs in BOTH directions: an expected group that is absent refuses, and a real group you did not list refuses too, because a repair that silently re-dates more rows than you pictured is worse than no tool (a correctly-dated row is indistinguishable from a correctly-dated row afterwards). TOLERANCE: min_drift_hours, default 24 hours — one whole day. A row qualifies only when BOTH its UTC calendar date differs AND at least that many hours separate the two stamps, so a row read five seconds after midnight is NOT treated as mislabelled. COLLISION SAFETY: snapshot_at is part of two partial UNIQUE indexes (onchain_balance_snapshots_daily_dedup_idx for wallet_id IS NOT NULL, and …_daily_dedup_unattributed_idx keyed on lower(address)/lower(chain) for wallet_id IS NULL, dfl-schema 20260817220000). Both keys are reconstructed in the PREVIEW, per row, so a move onto an occupied date is reported rather than raised as a 23505 mid-apply. ANY collision refuses the whole apply. A row whose target is held by ANOTHER row that is itself moving away is not a collision but an ORDERING constraint, and the writes are ordered so the holder vacates first; a cycle of such constraints is refused. IDEMPOTENT: after a successful apply the same call finds nothing to do and says so (already_consistent), rather than refusing on the now-stale expectation. The chain / source_adapter / snapshot_ids filters NARROW the candidates only — the collision check always reads the whole visible table, because the row a candidate would land on top of can sit outside any filter. An UNRECOGNIZED argument key is REFUSED and named. Read AND write run on the caller’s user-JWT under RLS. There is no tenant parameter and no way to name another tenant’s rows. NEVER service_role.

ParameterTypeRequiredDescription
dry_runbooleannoWhen true (DEFAULT), report every candidate row grouped by (current date → true date, adapter, chain) with its collision verdict, and write NOTHING. Set false to apply — which additionally requires expect.
expectobjectnoREQUIRED when dry_run=false: name what you believe you are changing. At least one of rows or groups must be set. The apply refuses, and writes nothing, when reality disagrees.
min_drift_hoursnumbernoTolerance. DEFAULT 24 hours — one whole day. A row is a candidate only when BOTH conditions hold: its UTC calendar date differs from fetched_at’s, AND at least this many hours separate the two stamps. The second condition is what keeps a row read five seconds after midnight from counting as mislabelled — the calendar boundary is the only thing between its two stamps, and its date label is right.
chainstringnoNarrow the CANDIDATES to one chain (e.g. “binance”, “ethereum”). Never narrows the collision check, which always reads the whole visible table.
source_adapterstringnoNarrow the CANDIDATES to one adapter (e.g. “binance-balance”). Never narrows the collision check.
snapshot_idsstring[]noNarrow the CANDIDATES to this explicit allowlist of financial.onchain_balance_snapshots ids. A row in this list that is NOT mislabelled is still left alone — the list can only narrow what the drift test already found, never force a re-date.

Record On-Chain Balance Observations

Record 1 to 25 precise on-chain balance observations in financial.onchain_balance_snapshots. dry_run defaults to true. The tool validates the caller-visible wallet, address, chain, asset UUID, asset symbol, decimals, balance bucket, source provenance, observation time, and stable holding identity. Exact decimal strings never pass through JavaScript numbers. Identical repeats are no-ops. Conflicts are refused; the tool never overwrites or deletes a snapshot. It writes one atomic array through the caller’s user JWT under RLS. It does not use service_role. It writes no wallet, ledger, staging, period-lock, or coverage-watermark row. The result is source-observation evidence only. It does not certify full transaction history, an opening balance, or wall coverage.

ParameterTypeRequiredDescription
dry_runbooleannoDefault true. A preview performs all reads and validation, then writes nothing.
observationsobject[]yesA bounded list of precise balance observations. No network fan-out occurs.

Post Journal Entry (draft → posted)

Confirm a DRAFT financial.journal_entries row → status=posted (sets posted_at). This is the human-confirm gate, mirroring the staging pending_review → executed model. Refuses to post anything that is not currently a draft, and re-verifies the entry balances before posting. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies).

ParameterTypeRequiredDescription
tenant_idstringyesTarget tenant UUID.
entry_idstringyesjournal_entries.id of the draft to post.
posted_bystringnoOptional user UUID to record as posted_by (auth.users.id).

Delete DRAFT Journal Entries (hard delete — allowlist only)

HARD-delete DRAFT financial.journal_entries (and their journal_entry_lines via ON DELETE CASCADE) that were created in error — used to clean up malformed reconciliation restatement drafts. Selection is an EXPLICIT allowlist of journal_entry UUIDs (entry_ids) — NEVER a query/filter, so a sweep cannot happen by accident. HARD GUARD: every id is fetched and its status verified; an id that is not found (or not visible under RLS) OR is NOT status=draft (i.e. posted/voided) is REFUSED with a reason and NEVER deleted — a posted or voided ledger record is impossible to delete through this tool. dry_run=true (DEFAULT) returns what WOULD be deleted (id, entry_date, description, status, line_count) plus the refused list, WITHOUT mutating anything; set dry_run=false to perform the status-guarded hard delete. RLS-scoped (per-session user JWT) — only entries in the caller’s tenants are visible/deletable. NOT service_role.

ParameterTypeRequiredDescription
entry_idsstring[]yesREQUIRED explicit allowlist of financial.journal_entries UUIDs to delete. NEVER a query/filter — only these exact ids are considered, and only those that are status=draft are actually deleted (others are refused).
dry_runbooleannoWhen true (DEFAULT), return what WOULD be deleted (with line counts) + the refused list WITHOUT deleting anything. Set false to perform the hard delete of the verified-draft ids.

Void POSTED Journal Entries (allowlist or whole linked group)

VOID individually named POSTED financial.journal_entries — the missing inverse of post_journal_entry for a single entry. Nothing else reaches these: reverse_batch requires a reconciliation_batch_id (NULL on hand-booked entries), delete_draft_entries only touches drafts, and reset_unposted_staging refuses any staging row whose entry is posted. VOID, NEVER DELETE: the write is a status flip to voided plus void_reason + voided_by, so the entry and its journal_entry_lines survive as the audit record (voided_at is stamped by the trg_journal_entry_status trigger). Selection is EXACTLY ONE of entry_ids (an explicit UUID allowlist) or reference_id + reference_type (one whole linked group) — there is NO filter mode and no limit, so a sweep cannot happen by accident, and an unrecognized key is REFUSED rather than silently dropped. KEY INVARIANT: voiding some but not all still-POSTED members of a linked (reference_type, reference_id) group is REFUSED and the missing members are NAMED — a linked group is ONE booking (the production book_linked_entry groups are cross-tenant pairs), and half-voiding it leaves one leg alive and one dead with nothing in the schema to detect it. allow_partial=true is the only override and it REQUIRES partial_reason, which is stamped into void_reason with the ids left posted. ⚠️ WHAT THE GUARD CANNOT PROVE: the sibling lookup is RLS-scoped, so a linked group spanning tenants is fully visible only to a caller reaching BOTH tenants — a group shown with ONE member may be half a hidden pair. That is NOT guessed at in code: every group reports its members with tenant_id and a top-level warning names the single-member groups. READ IT. An already-voided entry is an idempotent reported NO-OP (not an error, no second write); a draft entry is REFUSED with its status (use delete_draft_entries). Period locks are checked before writing, because trg_enforce_period_lock RAISES on voiding an entry dated inside a closed period. ALL-OR-NOTHING: if ANY requested entry is refused, NOTHING is written, even with dry_run=false. reason is REQUIRED and is stamped per entry into void_reason. dry_run=true (DEFAULT — note reverse_batch defaults it to FALSE, this does not) reports the entries, their amounts, the ledger accounts touched and the resulting balance delta per account, so the caller can verify against financial.v_projected_wallet_balances afterwards. RLS-scoped (per-session user JWT) — only entries in the caller’s tenants are visible/voidable. NOT service_role.

ParameterTypeRequiredDescription
entry_idsstring[]noSelector A — an EXPLICIT allowlist of financial.journal_entries UUIDs to void. NEVER a query/filter: only these exact ids are considered. Mutually exclusive with reference_id. Every requested entry must pass every guard or NOTHING is written.
reference_idstringnoSelector B — void the WHOLE linked group carrying this reference_id (requires reference_type). This is the SAFE selector for linked bookings: it cannot half-void a group by construction, because it selects every visible member. Mutually exclusive with entry_ids.
reference_typestringnoREQUIRED together with reference_id (e.g. “book_linked_entry”). reference_id alone is refused: reference_id is not unique across reference_types, so a bare id could select a different domain’s rows.
reasonstringyesREQUIRED audit note, stamped into void_reason on EVERY voided entry (e.g. “hand-made from staging rows that returned to pending_review — voiding to prevent a double-book; the statement rows carry the real dates”). Never omitted, never shared-and-unexplained.
dry_runbooleannoWhen true (DEFAULT), report exactly what WOULD change — entry ids, amounts, the ledger accounts touched and the resulting balance delta per account — WITHOUT writing. Set false to persist the void. NOTE: reverse_batch in this package defaults this to FALSE; this tool does NOT.
allow_partialbooleannoDEFAULT false. When false, voiding some but not all still-posted members of a linked (reference_type, reference_id) group is REFUSED and the missing members are named. Set true ONLY to deliberately split a group — it then REQUIRES partial_reason, which is stamped into void_reason along with the ids of the legs left posted.
partial_reasonstringnoREQUIRED when allow_partial=true, refused otherwise. Explains WHY the linked group is being split. Stamped into void_reason on every voided entry together with the ids left posted, so a half-voided group is self-explaining in the ledger instead of being an unaccountable flag.

Re-date Journal Entries (entry_date ONLY)

Move the entry_date of individually named financial.journal_entries — and NOTHING else. Requires an EXPLICIT journal_entry_ids array (min 1); there is NO filter/sweep mode, no date-range selector and no limit, and an empty array is REFUSED rather than read as “everything”. NEVER touches amounts, ledger accounts, entry_type, status, description, reference links, journal_entry_lines, or reconciliation_staging.journal_entry_id: the write patch is whitelisted at runtime to entry_date plus the metadata audit stamp. Use this INSTEAD of void-and-recreate when a booking is right but its DATE is wrong — void-and-recreate mints new entry ids and orphans every reconciliation_staging row pointing at the old one. reason is REQUIRED (min 20 chars) and is appended to metadata.redate_history, an append-only array of {from,to,reason,at,by,tool}. CLOSED PERIODS: refused when EITHER the current date OR the target date falls on-or-before the tenant financial.period_locks.locked_through watermark — moving an entry out of a closed period rewrites a published total just as moving one in does. That refusal is enforced HERE, in application code, because enforce_period_lock only fires on a status transition and does NOT see a posted→posted re-date. There is no override flag; move the watermark deliberately and re-run. A voided entry is REFUSED (frozen audit record). An entry already on the target date is a reported no-op. ALL-OR-NOTHING: any refusal writes nothing. dry_run=true (DEFAULT) previews without writing; set dry_run=false to persist. RLS-scoped per-session user JWT for read AND write, never service_role, no tenant_id parameter. REPLY SHAPE: redated counts entries ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed entries are counted in would_redate and carry per-entry status “would_redate” with after_is_projected=true. Never read redated > 0 as proof of a write without also reading mode.dry_run.

ParameterTypeRequiredDescription
journal_entry_idsstring[]yesREQUIRED explicit allowlist of financial.journal_entries UUIDs to re-date. NEVER a query/filter: only these exact ids are considered. There is no date-range selector, no account selector and no limit — re-dating is a deliberate per-entry action. An empty array is REFUSED, not treated as “select nothing”.
new_entry_datestringyesREQUIRED target date as YYYY-MM-DD (a calendar date — financial.journal_entries.entry_date is a DATE column, not a timestamp). Applied to EVERY entry named in journal_entry_ids. An entry already on this date is a reported no-op, never a write.
reasonstringyesREQUIRED audit note, at least 20 characters, appended per entry to metadata.redate_history (an append-only array). A date change with no recorded why is unauditable: the entry afterwards looks exactly like an entry that was always dated that way. Say WHICH date is wrong and WHY the new one is right — e.g. “opening-balance and data-gap write-offs belong at the ledger wall date, not inside the March reporting period”.
dry_runbooleannoWhen true (DEFAULT), report the full before/after plan per entry — dates, line count and total — WITHOUT writing. Set false to persist. NOTE: reverse_batch in this package defaults this to FALSE; this tool does NOT.

Backfill journal_entry_lines.asset_type_id (whole entry, one statement)

Fill the MISSING asset_type_id on the lines of POSTED financial.journal_entries — and write NOTHING else. Repairs the population that migration 20260709160000 grandfathered and that dfl-schema #888 (trg_reassert_integrity_posted_lines) has now FROZEN: since #888 the judge re-runs on every line write of a posted entry, so a NULL-carrying entry cannot be touched at all until it is repaired whole. TWO RESOLUTION RULES, in order: (a) account_name_suffix — the ledger account NAME contains ”/”, so the asset is the substring after the LAST ”/” (Clearing/YT-LBTC → YT-LBTC), resolved case-insensitively against financial.asset_types.symbol; (b) entry_currency — otherwise the currency the ENTRY carries (journal_entries.metadata.currency, else the tenant financial.tenants.settings.currency). There is NO literal fallback currency and NO default asset: an unresolvable suffix or an undeclared currency SKIPS the entry with a reason, because a wrong asset_type_id still balances per asset and nothing downstream would ever go red on it. ONE STATEMENT PER ENTRY: UPDATE … WHERE journal_entry_id = $1 AND asset_type_id IS NULL, so PostgreSQL fires the AFTER-ROW judge at the END of the statement, when the entry is already whole — a line-by-line repair would be refused on the first line. An entry whose NULL lines resolve to MORE THAN ONE asset is REFUSED, never half-written. PROJECTED JUDGE: the post-fill state is judged with the same rules as assert_journal_entry_integrity BEFORE writing, so an entry that would become CROSS-ASSET while missing usd_value is skipped by name (usd_value needs a price at the entry date — a different problem, NEVER written here). CLOSED PERIODS: an entry dated on-or-before its tenant financial.period_locks.locked_through is skipped, because enforce_period_lock_lines would RAISE; there is no override flag. The write patch is whitelisted AT RUNTIME to asset_type_id alone, and financial.journal_entries is never written. Selection is an EXPLICIT journal_entry_ids array OR a non-empty filter (entry_date_from / entry_date_to / ledger_account_ids), never both and never neither; an empty array and an empty {} filter are both REFUSED. limit defaults to 25 entries, maximum 200. reason is REQUIRED (min 20 chars) and is logged, not persisted. NOT all-or-nothing across the batch — refusals are the normal case in a grandfathered population — but ATOMIC PER ENTRY. REPLY SHAPE: repaired counts entries ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed entries are counted in would_repair and carry after_is_projected=true. Never read repaired > 0 as proof of a write without also reading mode.dry_run. Every planned line is reported with its resolved symbol AND which rule produced it, so the mapping is auditable without writing a query. RLS-scoped per-session user JWT for the read AND the write, never service_role, no tenant_id parameter.

ParameterTypeRequiredDescription
journal_entry_idsstring[]noEXPLICIT allowlist of financial.journal_entries UUIDs to repair. Mutually exclusive with filter; exactly one of the two is REQUIRED. An empty array is REFUSED, not treated as “select nothing”.
filterobjectnoScan for POSTED entries carrying at least one line with asset_type_id IS NULL. At least ONE filter key is REQUIRED — an empty object {} is REFUSED rather than read as a full sweep. There is NO tenant selector: visibility comes from your JWT under RLS.
limitnumbernoMaximum ENTRIES considered in one call. Default 25, hard maximum 200. Applies to both selection modes, so an over-long explicit id list is refused rather than silently truncated.
reasonstringyesREQUIRED note, at least 20 characters, saying WHY this backfill is being run. It is echoed in the reply and written to the structured log. It is deliberately NOT persisted on the row: that would need a second write surface the patch whitelist forbids, and trg_activity_journal_entry_lines already records the before/after of every line write.
dry_runbooleannoWhen true (DEFAULT), report the full per-entry plan — every line, its resolved symbol and which rule produced it — WITHOUT writing. Set false to persist.

Post From Staging (approved staging row → posted journal entry)

Promote an APPROVED financial.reconciliation_staging row into the canonical double-entry ledger and stamp the row executed. Builds the balanced journal entry from the row’s classification (ai_template_code → debit/credit accounts, amount on both sides), posts it atomically via the financial.create_journal_entry(jsonb) RPC (one transaction, server-side balance assertion), then sets journal_entry_id + status=executed on the staging row. The CASH leg comes from the ROW’s own bank account (ledger_account_id, else source_bank_name → wallet), not from the template: when the template names a generic ANCESTOR cash account the row’s real bank sub-account is substituted in, and an unresolvable or unrelated cash leg REFUSES. Each leg is denominated in ITS OWN account’s currency (the wallet bound to that account): a conversion between two assets posts the row’s magnitude on its own leg and usd_value_at_block on the USD leg, with a matching usd_value on both so the entry balances in dollars, and a crypto asset leg against a revenue/expense account books that P&L leg in USD from usd_value_at_block instead of writing a token quantity into an income account. A row that needs that second amount and does not carry it REFUSES — no rate is invented and no fallback to a single amount happens. The per-leg result is reported as legs (mode same-asset | cross-asset | pnl-usd). Refuses to post a row that is not ‘approved’, that lacks a template/amount, or whose template is unknown for the resolved tenant. If the RPC is not deployed yet it falls back to the create-draft + post path (allow_fallback). Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies).

ParameterTypeRequiredDescription
staging_idstringyesUUID of the approved reconciliation_staging row to post.
posted_bystringnoOptional user UUID recorded as posted_by on the journal entry (auth.users.id).
require_statusenumnoThe staging status the row MUST currently be in to be posted. Default ‘approved’ (the human-confirm gate). Only relax to ‘pending_review’ for explicit migrations. One of: approved, pending_review.
allow_fallbackbooleannoIf the create_journal_entry RPC is not deployed yet, fall back to the create-draft + post path. Default true. Set false to hard-require the atomic RPC.

Execute Group (linked money-movement → ONE transfer JE, all legs terminal)

Settle a whole financial.reconciliation group (a transfer’s classified send-leg + its matched counterpart legs) as ONE action: post EXACTLY ONE balanced journal entry from the classified (TPL-XFER-*) primary leg’s template, then mark ALL still-in-staging legs status=executed pointing at the SHARED journal entry — so the counterpart leg reaches a terminal state and leaves staging instead of orphaning. Posts atomically via financial.create_journal_entry(jsonb) (fallback to create-draft + post). Each leg is denominated in ITS OWN account’s currency (the wallet bound to that account): a conversion posts the primary leg’s magnitude on its own leg and usd_value_at_block on the USD leg, with a matching usd_value on both so the entry balances in dollars, and a crypto asset leg against a revenue/expense account books that P&L leg in USD instead of writing a token quantity into an income account. A movement that needs that second amount and does not carry it REFUSES — no rate is invented. The per-leg result is reported as legs (mode same-asset | cross-asset | pnl-usd). Idempotent (already-executed group → no-op). Refuses a group with no classified leg, no positive amount, or an unknown template. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies).

ParameterTypeRequiredDescription
reconciliation_group_idstringyesUUID of the financial.reconciliation_groups row (linked money-movement) to settle.
posted_bystringnoOptional user UUID recorded as posted_by on the journal entry (auth.users.id).
allow_fallbackbooleannoIf the create_journal_entry RPC is not deployed yet, fall back to the create-draft + post path. Default true. Set false to hard-require the atomic RPC.
override_already_postedobjectnoESCAPE HATCH for the ALREADY-POSTED gate, for the case where this movement really is a SECOND, distinct transfer that merely looks like the one already booked. Requires expect_journal_entry_ids listing EXACTLY the already-posted entry ids the refusal reported (a missing id, or an id that is no longer colliding, refuses the override) plus a written reason. Never pass it without reading the named entries first.

Book Linked Entry (N balanced legs across accounts/tenants, atomic)

Book N balanced journal-entry legs ATOMICALLY across accounts and/or tenants for a cross-boundary financial event (transfer+FX, capital contribution / on-behalf, internal transfer). Takes a high-level INTENT (type + typed params) and encodes each recipe’s accounting rule once, wiring the correct bridge account (FX gain-loss / equity / suspense). Accounts and tenants may be passed as UUIDs OR chart codes / slugs (resolved on the caller user-JWT, RLS-scoped). Each tenant’s entry itself balances (Σ debit == Σ credit); all legs are created or none (compensating rollback). Pass an idempotency_key to make retries return the existing entries instead of double-booking. Posts by default (post=false leaves drafts). Reads AND writes go through the caller user-JWT under RLS (member+, tenant-scoped) — the financial.* member-write policies enforce the INSERT/UPDATE. Leg maps — transfer_fx: DR to_account(amount_to)/CR from_account(amount_from)/delta→fx_account (gain=CR, loss=DR). capital_contribution: payer DR investment/CR cash ; owner DR expense/CR equity. internal_transfer: DR to/CR from (or bridged via suspense 1.1.9).

ParameterTypeRequiredDescription
typeenumyesThe cross-boundary event recipe. v1 supports these three. One of: transfer_fx, capital_contribution, internal_transfer.
memostringyesHuman memo — becomes the entry/line description.
entry_datestringnoEntry date (YYYY-MM-DD). Defaults to today (UTC).
idempotency_keystringnoStable key — a repeat call with the same key returns the existing entries (no double-book).
postbooleannoPost the entries (draft → posted). Default true. false leaves them as drafts.
posted_bystringnoOptional user UUID recorded as posted_by (auth.users.id).
tenantstringno[transfer_fx | internal_transfer] tenant UUID or slug.
from_accountstringno[transfer_fx | internal_transfer] source account id or code (credited).
to_accountstringno[transfer_fx | internal_transfer] destination account id or code (debited).
amount_fromnumberno[transfer_fx] amount leaving from_account (source currency).
amount_tonumberno[transfer_fx] amount arriving in to_account (destination currency).
fx_accountstringno[transfer_fx] FX / crypto gain-loss account id or code. Required when amount_to != amount_from.
paying_tenantstringno[capital_contribution] tenant paying the cost (UUID or slug).
paying_accountstringno[capital_contribution] payer cash/bank account id or code (credited).
owning_tenantstringno[capital_contribution] tenant that owns the cost (UUID or slug).
expense_accountstringno[capital_contribution] owner expense account id or code (debited).
equity_accountstringno[capital_contribution] owner equity account id or code (credited).
investment_accountstringno[capital_contribution] payer investment account id or code (debited).
amountnumberno[capital_contribution | internal_transfer] the single event amount.
via_suspensebooleanno[internal_transfer] bridge through a suspense/clearing account (two entries). Default false.
suspense_accountstringno[internal_transfer] suspense account id or code when via_suspense. Default ‘1.1.9’.

List Routing Rules

List financial.routing_rules for a tenant — the declarative human classification rules (pattern → target tenant + ledger account) the reconciliation classifier consults. Ordered highest-priority first (that is the rule the matcher applies first). RLS-scoped via the per-session user JWT — only rules for tenants the caller belongs to are returned.

ParameterTypeRequiredDescription
tenant_idstringyesOwning tenant UUID.
active_onlybooleannoIf true, only active rules. Default: false (show all).
limitnumbernoDefault 100.

Create Routing Rule

Create a financial.routing_rules entry — a declarative human classification rule (pattern match → target_tenant_slug + optional ledger_template_code, with a note capturing the human rationale). This is where “this counterparty/pattern means X” knowledge lives so the reconciliation classifier can use it, instead of rotting in a plan doc. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped; routing_rules already allowed member writes, kept here for consistency). IDEMPOTENT: if an identical match already exists for this tenant the existing rule is returned (no duplicate inserted) unless force_duplicate is set.

ParameterTypeRequiredDescription
tenant_idstringyesOwning tenant UUID (who the rule belongs to).
matchobjectyesThe pattern jsonb: keys like source_type, counterparty_doc (CNPJ digits only), lancamento, counterparty_name, amount_min/amount_max, description_regex. The matcher applies the highest-priority ACTIVE rule whose pattern matches a staging row.
target_tenant_slugstringyesRoute matching rows to this tenant (e.g. “tainan-pf” or “dfl-ecosystem”).
prioritynumbernoHigher wins on overlap. Default 100. Specific rules (e.g. a CNPJ) should out-rank generic defaults.
activebooleannoDefault true.
ledger_template_codestringnojournal_entry_template code to book against. Set NULL if no clean template exists yet and record the intended account in note (a human will map the exact code later).
splitobjectnoOptional multi-way split jsonb. Omit for a single-target rule.
notestringnoThe human rationale (free text). Strongly recommended.
created_bystringnoWho/what authored the rule (e.g. “claude-main”).
allow_unknown_slugbooleannoPermit a target_tenant_slug outside the known set (new-tenant onboarding). Default false.
force_duplicatebooleannoBypass the idempotency check and insert even if an identical match exists. Default false.

Update Routing Rule

Patch an existing financial.routing_rules entry by id — change priority, toggle active, edit the match pattern, retarget the tenant, set/clear the ledger_template_code, adjust the split, or update the note rationale. Only the fields you pass are changed. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped).

ParameterTypeRequiredDescription
idstringyesUUID of the routing_rules row to update.
prioritynumbernoHigher wins on overlap. Default 100. Specific rules (e.g. a CNPJ) should out-rank generic defaults.
activebooleanno—
matchobjectnoThe pattern jsonb: keys like source_type, counterparty_doc (CNPJ digits only), lancamento, counterparty_name, amount_min/amount_max, description_regex. The matcher applies the highest-priority ACTIVE rule whose pattern matches a staging row.
target_tenant_slugstringnoRetarget the rule to a different tenant slug.
ledger_template_codestringnoSet the ledger template code, or pass null to clear it.
splitobjectnoOptional multi-way split jsonb. Omit for a single-target rule.
notestringnoReplace the human rationale note.
allow_unknown_slugbooleannoPermit a target_tenant_slug outside the known set. Default false.

Create / Reinforce Routing Memory

Record a learned (validated) classification decision in financial.routing_memory — the “a human confirmed that THIS row signature routes to tenant X / template Y” layer that lets the same signature auto-classify next time. UPSERT semantics on row_signature: if the signature already exists, hit_count is incremented, last_seen bumped, and the validated decision refreshed. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped).

ParameterTypeRequiredDescription
row_signaturestringyesStable hash/signature of a staging row’s salient fields (the dedupe key).
validated_tenant_slugstringnoThe tenant a human confirmed for this signature (e.g. “tainan-pf”).
validated_template_codestringnoThe ledger/template code a human confirmed for this signature, or null.
allow_unknown_slugbooleannoPermit a validated_tenant_slug outside the known set. Default false.

List Routing Memory

List financial.routing_memory rows — the learned/validated classification decisions (row_signature → validated tenant + template, with hit_count reuse stats). Most-recently-seen first. Optionally filter to one signature. RLS-scoped via the per-session user JWT.

ParameterTypeRequiredDescription
signaturestringnoIf set, return only the memory row for this exact row_signature.
limitnumbernoDefault 50.

Routing Memory Reachability

READ-ONLY measurement: how many financial.routing_memory rows the classifier can actually reach AND APPLY, split by signature scheme (A = this package, exact amount; B = the dfl-financing SPA, banded amount), how many staging rows match under each, how many rows the human-settled gate withholds from scheme B, and every scheme A/B conflict. Only memories carrying a validated_tenant_slug are counted, because applyRouting refuses the rest — a tenant-less memory can never apply, so counting it would report reachability the engine does not have. The excluded rows are reported under unapplicable_no_tenant rather than dropped. 🚨 Score any ratio against memories.applicable, NEVER memories.total. Writes nothing. RLS-scoped via the per-session user JWT.

ParameterTypeRequiredDescription
max_rowsnumbernoCap on staging rows scanned. Default 20000 (the whole table today).

Settled rows whose withheld routing memory disagrees

READ-ONLY report (no write path of any kind, not a dry-run flag): every already-settled financial.reconciliation_staging row carrying a financial.routing_memory decision that DISAGREES with what the row was actually classified as. Dual-key matching made 304 previously-unreachable memories reachable, which made ~428 already-settled prod rows suddenly match one; applyRouting withholds those on purpose, because silently re-posting over a human decision is worse than the bug it would fix. This lists only the withheld memories that are actually WRONG. Agreements are counted and never listed — a report of all 428 rows is unreadable and therefore useless. Each item is decidable on one line: row identity + date + amount, what the row was classified as (target_tenant_slug / ai_template_code / cost_center_id / routing_source), what the memory says, which signature scheme reached it (a = exact amount, the MCP scheme; b = banded, the dfl-financing SPA scheme), why it was withheld, and which fields differ. Generic: the comparison window is parameters (settled scope, statuses, entry-date range, schemes, withheld_only, limit), not today’s incident. A NULL memory field is treated as NO OPINION, never as a disagreement. Both signatures and the settled gate come from @devfellowship/financing-core — the same functions applyRouting itself calls — so the report and the engine cannot drift apart. 🚨 ALWAYS read verdict before disagreements.count: reconciliation_staging is RLS tenant-scoped and an identity outside those tenants reads 0 rows with HTTP 200 and error:null, not a 403 — so the tool reports CANNOT_READ_CORPUS instead of rendering an empty, reassuring report. RLS-scoped via the per-session user JWT; never service_role.

ParameterTypeRequiredDescription
settledenumnoWhich rows to compare. ‘only’ (default) = rows a human already settled (status approved/rejected/executed, OR reviewed_at set, OR was_manually_edited) — the population the gate withholds from. ‘exclude’ = open rows. ‘any’ = no filter. One of: only, exclude, any.
statusesstring[]noExtra status filter applied on top of settled. Omit for any status.
entry_date_fromstringnoInclusive lower bound on entry_date (YYYY-MM-DD).
entry_date_tostringnoInclusive upper bound on entry_date (YYYY-MM-DD).
schemesenum[]noSignature schemes to look memories up by. Default both. ‘a’ = exact amount (MCP), ‘b’ = banded amount (dfl-financing SPA, 304 of 308 memory keys).
withheld_onlybooleannoDefault true — report only memories the engine would NOT apply. Set false to also see memories that WOULD apply yet disagree with the persisted classification.
limitnumbernoCap on LISTED items (default 200). The count is never capped.
max_rowsnumbernoCap on staging rows scanned. Default 20000 (the whole table today).

Insert Bank / Brokerage Balance Snapshot (manual)

Manually persist ONE bank or brokerage balance into financial.bank_balance_snapshots (Phase 6 of the reconciliation Preview — the Current-balances panel reads this table). Use when Tainan sends a bank statement PDF / Binance screenshot over Telegram: upload the artifact, read the balance, then call this with the parsed values + source_artifact_url (which VERSIONS the source document). bank is the canonical key — “Banco do Brasil” | “Nubank PF” | “Nubank PJ” | “Binance” — and should match reconciliation_staging.source_bank_name so the panel lines up with the staging legs. account_holder is the human-readable holder (“Tainan-PF”, “devfellowship”); if it matches a known holder the account_holder_id FK is resolved automatically (pass account_holder_id explicitly to override). balance_at is WHEN the balance was observed (statement/screenshot date), not now. Idempotent on (account_holder, bank, currency, balance_at) — re-entering a corrected statement updates the existing row. Writes go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies). Feature-guarded: until the dfl-schema migration creating the table is merged, the tool returns a clear “not migrated yet” error.

ParameterTypeRequiredDescription
account_holderstringyesHuman-readable account holder label, e.g. “Tainan-PF” or “devfellowship”.
bankstringyesCanonical bank/brokerage key — “Banco do Brasil” | “Nubank PF” | “Nubank PJ” | “Binance”.
amountnumberyesBalance amount in currency (e.g. 12345.67). Can be negative for overdraft.
balance_atstringyesWhen the balance was OBSERVED (ISO 8601, e.g. “2026-06-10” or “2026-06-10T12:00:00Z”).
currencystringnoISO-4217 currency code. Defaults to BRL if omitted.
institutionstringnoOptional longer/legal institution name when it differs from bank.
source_artifact_urlstringnoPublic URL of the source artifact (statement PDF / screenshot) — versions the source.
raw_notestringnoFree-form note captured at entry time (e.g. “saldo disponível, sem limite”).
account_holder_idstringnoExplicit financial.account_holders UUID. Overrides the label→id auto-resolution.

Delete Bank / Brokerage Balance Snapshot

Hard-delete ONE financial.bank_balance_snapshots row by id (the complement of insert_bank_balance_snapshot / set_wallet_fiat_balance). Use to remove a stray or smoke-test snapshot that should NOT appear in the Reconciliation Preview’s Current-balances panel. The DELETE runs through the caller’s user-JWT under RLS (member+, tenant-scoped) — RLS confines it to the caller’s tenant, so a row outside the caller’s visibility matches nothing (returns deleted=false) and is NOT removed. NEVER service-role. Feature-guarded: until the dfl-schema migration creating the table is merged, returns a clear “not migrated yet” error.

ParameterTypeRequiredDescription
snapshot_idstringyesUUID of the financial.bank_balance_snapshots row to delete.

Set Wallet Fiat Balance (record the REAL bank number)

Record the REAL (bank-statement) balance for a financial.wallets wallet, so the Reconciliation Preview can compare PROJECTED (ledger-derived) vs REAL per wallet (plan 20260625-financial-wallet-projected-balance). Name the WALLET — by wallet (its name, case-insensitive, e.g. “BB PJ” / “Binance/BRL”) OR by wallet_id (uuid). The tool resolves the wallet → its canonical bank key (financial_entities.name, which lines up with reconciliation_staging.source_bank_name), holder (account_holders.name), and currency (asset_types.symbol), then upserts one financial.bank_balance_snapshots row. amount is the real balance, balance_at is WHEN it was observed (statement date, not now). Pass source_artifact_url to version the source statement/screenshot. currency/account_holder fall back to the wallet’s own values but can be overridden. Idempotent on (account_holder, bank, currency, balance_at) — re-entering a corrected statement updates the existing row. If the wallet name is ambiguous (>1 match) or not visible, returns a clear error listing the candidate wallet names/ids. Both the wallet-resolution READ and the snapshot UPSERT go through the caller’s user-JWT under RLS (member+, tenant-scoped; WITH CHECK policies in dfl-schema migration 20260626170000_financial_rls_member_write_policies). Feature-guarded: until the dfl-schema migration creating the table is merged, returns a clear “not migrated yet” error.

ParameterTypeRequiredDescription
walletstringnoWallet NAME (case-insensitive exact), e.g. “BB PJ” or “Binance/BRL”. One of wallet / wallet_id is required.
wallet_idstringnoExplicit financial.wallets UUID. Wins over wallet when both are given. One of wallet / wallet_id is required.
amountnumberyesThe REAL balance amount in currency (e.g. 12345.67). Can be negative for overdraft.
balance_atstringyesWhen the balance was OBSERVED (ISO 8601, e.g. “2026-06-25” or “2026-06-25T12:00:00Z”). NOT now.
currencystringnoISO-4217 / asset symbol. Defaults to the wallet’s own currency (asset_types.symbol), else BRL.
source_artifact_urlstringnoPublic URL of the source artifact (statement PDF / screenshot) — versions the source.
raw_notestringnoFree-form note captured at entry time (e.g. “saldo disponível, sem limite”).
account_holderstringnoOverride the holder label. Defaults to the wallet’s holder (account_holders.name).
account_holder_idstringnoExplicit financial.account_holders UUID. Defaults to the wallet’s account_holder_id.

Patch Classification Fields on Staging Rows

Edit classification/attribution fields (account_holder_id, ledger_account_code, ledger_template_code, ai_category, cost_center_id, usd_value_at_block) of financial.reconciliation_staging rows — the fields human review needs to correct before approval. Selection REQUIRES an explicit selector: staging_ids (an array-of-UUID allowlist, takes precedence, min 1) OR a filter (status/ai_category/source_type, at least one field set) with a limit cap (default 50, max 500). A call with NEITHER is REFUSED — “patch everything up to the limit” is not reachable by omission, only by stating filter: {“status”: “pending_review”} outright. An UNRECOGNIZED key is REFUSED too, naming the key, rather than silently dropped: a dropped selector key (staging_id, ids, id) leaves no selector, and on 2026-08-12 three such calls each naming ONE row returned processed:50 against the production queue. NEVER changes the status field. Rows with status=executed are SKIPPED by default (pass allow_executed=true to override). account_holder_id is validated: it must belong to the same tenant as the row (cross-tenant holder → skip with error). ledger_account_code is resolved to financial.ledger_accounts.id for the row’s tenant — rejected if not found. dry_run=true (DEFAULT) returns a before/after diff without writing anything. Set dry_run=false to persist. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are touched. REPLY SHAPE: updated counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed rows are counted in would_update and carry per-row status “would_update” with after_is_projected=true, because their after block is a projection and not the stored row. Never read updated > 0 as proof of a write without also reading mode.dry_run.

ParameterTypeRequiredDescription
staging_idsstring[]noExplicit allowlist of reconciliation_staging row UUIDs to patch. Takes precedence over filter. Must hold at least one UUID — an empty array is REFUSED, because it would fall through to the filter sweep rather than select nothing.
filterobjectnoRow selector used when staging_ids is absent. At least one field must be set — &#123;&#125; is REFUSED, because an empty filter is the same unbounded sweep as no selector at all.
limitnumbernoSafety cap on how many rows are processed. Default 50.
account_holder_idstringnoSet the account_holder_id (financial.account_holders UUID). Must belong to the row’s tenant — cross-tenant holder causes the row to be skipped. Pass null to clear.
ledger_account_codestringnoSet the ledger account by code (financial.ledger_accounts.code for the row’s tenant). Resolved to ledger_accounts.id — row is skipped if no matching account is found.
ledger_template_codestringnoSet ai_template_code directly. Pass null to clear.
ai_categorystringnoOverride the ai_category classification label.
cost_center_idstringnoSet the cost_center_id (financial.cost_centers UUID). Pass null to clear.
usd_value_at_blocknumbernoSet the usd_value_at_block — the on-chain USD value of the row at the block it settled (used e.g. to correct a USDC receipt that ingested with a NULL price). Pass null to clear.
allow_executedbooleannoAllow patching rows with status=executed (which have a posted journal entry). Default false — executed rows are skipped with a warning.
dry_runbooleannoWhen true (DEFAULT), compute before/after diffs WITHOUT writing. Set false to persist the changes.

Soft-Suppress Reconciliation Staging Rows

Audit-preserving SOFT-suppress of specific financial.reconciliation_staging rows: stamps source_raw.suppressed=true (+ optional suppressed_reason + suppressed_at) so the rows are HIDDEN from list_staging / the reconciliation Preview by default — WITHOUT deleting them and WITHOUT changing status. Use for spurious / sign-flip / duplicate-mirror rows a human identifies (e.g. a bad-sign import batch). Requires an explicit staging_ids array — there is NO filter-based bulk suppress; suppression is a deliberate per-id action. NEVER changes status, amount, journal_entry_id, or any classification field. Rows with status=executed are SKIPPED by default (pass allow_executed=true to override). Rows already suppressed are SKIPPED (idempotent no-op). dry_run=true (DEFAULT) returns a before/after preview without writing; set dry_run=false to persist. Reversible — clear the flag to un-suppress. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are touched. REPLY SHAPE: suppressed counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed rows are counted in would_suppress and carry per-row status “would_suppress” with after_is_projected=true, because their after block is a projection and not the stored row. Never read suppressed > 0 as proof of a write without also reading mode.dry_run.

ParameterTypeRequiredDescription
staging_idsstring[]yesREQUIRED explicit set of reconciliation_staging row UUIDs to soft-suppress. No filter-based bulk suppress is offered — suppression is a deliberate per-id action.
reasonstringnoOptional audit reason folded into source_raw.suppressed_reason (e.g. “sign-flip spurious mirror, 2026-05-16 bad-sign import batch”).
allow_executedbooleannoAllow suppressing rows with status=executed (which have a posted journal entry). Default false — executed rows are skipped with a reason.
dry_runbooleannoWhen true (DEFAULT), compute before/after WITHOUT writing. Set false to persist the source_raw.suppressed=true stamp.

Un-Suppress (Restore) Reconciliation Staging Rows

Reverse a prior suppress_staging: restore a suppressed financing staging row back to pending_review (active). RLS-scoped to the caller. Clears source_raw.suppressed (sets it false + optional unsuppressed_reason + unsuppressed_at) so the rows are VISIBLE again in list_staging / the reconciliation Preview — WITHOUT deleting them and WITHOUT changing status. Requires an explicit staging_ids array — there is NO filter-based bulk un-suppress; un-suppression is a deliberate per-id action. NEVER changes status, amount, journal_entry_id, or any classification field. Rows with status=executed are SKIPPED by default (pass allow_executed=true to override). Rows NOT currently suppressed are SKIPPED (idempotent no-op). dry_run=true (DEFAULT) returns a before/after preview without writing; set dry_run=false to persist. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are touched. REPLY SHAPE: unsuppressed counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed rows are counted in would_unsuppress and carry per-row status “would_unsuppress” with after_is_projected=true, because their after block is a projection and not the stored row. Never read unsuppressed > 0 as proof of a write without also reading mode.dry_run.

ParameterTypeRequiredDescription
staging_idsstring[]yesREQUIRED explicit set of reconciliation_staging row UUIDs to un-suppress (restore). No filter-based bulk un-suppress is offered — un-suppression is a deliberate per-id action.
reasonstringnoOptional audit reason folded into source_raw.unsuppressed_reason (e.g. “false-positive suppress, row is a real transaction after all”).
allow_executedbooleannoAllow un-suppressing rows with status=executed (which have a posted journal entry). Default false — executed rows are skipped with a reason.
dry_runbooleannoWhen true (DEFAULT), compute before/after WITHOUT writing. Set false to persist the source_raw.suppressed=false stamp.

Annotate the Suppression Reason on Already-Suppressed Staging Rows

Write the audit reason on reconciliation_staging rows that are ALREADY suppressed. This exists because suppress_staging SKIPS an already-suppressed row, so it cannot repair a missing reason — a row hidden with no stated reason is invisible to review AND unexplainable. Writes ONLY source_raw.suppressed_reason (the mirror trigger copies it to the suppressed_reason column). NEVER changes suppressed, status, amount, journal_entry_id or any classification field, so it cannot change what a row means — only what it says about why it is hidden. Requires an explicit staging_ids array; there is NO filter-based bulk annotate. A row that is NOT suppressed is SKIPPED (annotating it would be meaningless, and the trigger would discard the write). A row that ALREADY has a reason is SKIPPED unless overwrite=true. dry_run=true (DEFAULT) previews without writing. REPLY SHAPE: annotated counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed rows are counted in would_annotate and carry per-row status “would_annotate” with after_is_projected=true. Never read annotated > 0 as proof of a write without also reading mode.dry_run.

ParameterTypeRequiredDescription
staging_idsstring[]yesREQUIRED explicit set of reconciliation_staging row UUIDs to annotate. No filter-based bulk annotate is offered.
reasonstringyesThe audit reason. Say WHY the row is hidden and cite the decision that authorises it, e.g. “BB Rende Facil internal sweep, zero accounting meaning, Tainan decision 2026-07-08”. A reason nobody can check is not better than no reason, so a minimum length is enforced.
overwritebooleannoReplace a reason that is already present. Default false — a row that already carries a reason is SKIPPED, because that reason was written deliberately.
dry_runbooleannoWhen true (DEFAULT), compute before/after WITHOUT writing. Set false to persist.

Map a Statement Bank Label to a Ledger Wallet Name

Add or update a row in financial.bank_wallet_aliases — the shared copy of the statement-label -> wallet-name map, so SQL, views and the app can join it instead of re-deriving it (re-deriving it by hand was wrong twice in one hour on 2026-08-26). THE POINT: adding a bank is a ROW, NOT A DEPLOY. bank_label is the label a STATEMENT carries, exactly as it lands in reconciliation_staging.source_bank_name (e.g. “Banco do Brasil”); wallet_name is the LEDGER label, exactly as it appears in financial.wallets.name (e.g. “BB PJ/BRL”). Lower priority is tried first, preserving the ORDER of the TypeScript array, which is the tie-break when one label matches wallets in more than one tenant. WARNING: the TypeScript constant BANK_WALLET_ALIASES is the FLOOR and is never overridden — the two are UNIONED — so deactivating a row here CANNOT remove an alias that ships in the constant; that stays a code change, deliberately, because a table that wins outright would let a partial seed silently break postings the constant already resolved. The map is GLOBAL — a bank label means the same bank in every book — so there is no tenant on the row; tenant scoping belongs to the LOOKUP (see financial.v_wallet_reality_feed). The tool WARNS when wallet_name matches no wallet, because an alias pointing at a non-existent wallet resolves nothing and would fail only later, at post time. dry_run=true (DEFAULT) previews without writing.

ParameterTypeRequiredDescription
bank_labelstringyesStatement label, as it lands in reconciliation_staging.source_bank_name.
wallet_namestringyesLedger wallet name, exactly as it appears in financial.wallets.name.
prioritynumbernoAscending; lower is tried first. Default 100.
reasonstringyesWhy this alias exists. A mapping nobody can check is a mapping nobody can correct.
is_activebooleannoSet false to stop applying this ROW (see the constant-floor warning).
dry_runbooleannoWhen true (DEFAULT), preview without writing.

List Wallets With (Or Without) Evidence To Check Themselves Against

Read financial.v_wallet_reality_feed — one row per wallet, answering “does this wallet have anything to check itself against?”. USE THIS INSTEAD OF HAND-WRITING THE JOIN: doing it by hand was wrong twice in one hour on 2026-08-26, because reconciliation_staging.ledger_account_id is NULL on 86.8% of rows and source_bank_name carries the STATEMENT label (“Banco do Brasil”), not the wallet name (“BB PJ/BRL”). Four independent surfaces count as evidence, and THEY DO NOT MEAN THE SAME THING: has_staging = transaction rows reached staging (by ledger_account_id, by source_bank_name, or through the financial.bank_wallet_aliases map); has_statement = a bank statement WINDOW WAS READ for this account — it is the ONLY flag that proves someone actually looked at a period, so “no feed” and “never read” are told apart HERE and nowhere else; has_balance_snapshot = a balance was observed for the bank label; has_onchain = an on-chain balance snapshot exists for the wallet; has_any_feed = the OR of the four. 🚨 ALWAYS READ alias_rows AT THE TOP LEVEL OF THE RESPONSE. It is a GLOBAL count of active rows in financial.bank_wallet_aliases. When it is 0 the alias leg matched NOTHING, so every bank wallet reads “no feed” for a reason that has nothing to do with the wallet — that is exactly the 2026-08-26 error arriving through a new door. Seed the map with upsert_bank_wallet_alias before you believe the row list. Defaults are tuned to the question the tool is named for: only_missing=true (show what has NO feed), include_clearing=false (a clearing account is a bookkeeping waypoint, not money anyone holds), include_inactive=false. RLS-scoped through the caller’s user-JWT — only your tenants are visible, and there is no tenant_id parameter.

ParameterTypeRequiredDescription
only_missingbooleannoOnly wallets with has_any_feed=false. DEFAULT TRUE — the question this tool is named for is “what has NO feed”. Pass false to list every wallet with its flags.
include_clearingbooleannoInclude clearing accounts (the code-8 subtree). Default false: a clearing account is a bookkeeping waypoint, not money anyone holds, so “it has no feed” is expected and is noise in this answer.
include_inactivebooleannoInclude wallets with is_active=false. Default false.
min_abs_balancenumbernoOnly wallets whose balance is at least this far from zero, in either direction. Default 0 (no filter). Use it to rank by what a wrong answer would cost.
limitnumbernoDefault 100, hard maximum 500.

Reject Reconciliation Staging Rows (terminal)

Move specific financial.reconciliation_staging rows to the TERMINAL status=rejected state with a reviewer_notes audit stamp — the canonical terminal for a row that must never be posted (e.g. a confirmed phishing / address-poisoning token, or a bogus import artefact). This is the missing reject terminal: update_staging & validate_staging_row NEVER change status, suppress_staging only soft-hides (reversible, status unchanged), and post_from_staging is the ACCEPT terminal (executed). NEVER touches amount, journal_entry_id, source_raw, or any classification field — only status, reviewer_notes, and reviewed_at. Requires an explicit staging_ids array (no filter-based bulk reject) and a reason. Rows with status=executed are SKIPPED by default (pass allow_executed=true to override); rows already rejected are SKIPPED (idempotent no-op). dry_run=true (DEFAULT) returns a before/after preview without writing; set dry_run=false to persist. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are touched. REPLY SHAPE: rejected counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed rows are counted in would_reject and carry per-row status “would_reject” with after_is_projected=true, because their after block is a projection and not the stored row. Never read rejected > 0 as proof of a write without also reading mode.dry_run.

ParameterTypeRequiredDescription
staging_idsstring[]yesREQUIRED explicit set of reconciliation_staging row UUIDs to reject. No filter-based bulk reject is offered — rejection is a deliberate per-id action.
reasonstringyesREQUIRED audit reason written to reviewer_notes (e.g. “address-poisoning phishing token (fake USDC 0x524068d1…)”).
allow_executedbooleannoAllow rejecting rows with status=executed (which have a posted journal entry). Default false — executed rows are skipped with a reason.
dry_runbooleannoWhen true (DEFAULT), compute before/after WITHOUT writing. Set false to persist the status=rejected transition.

Link Staging Legs to an Already-Posted Journal Entry (counterpart leg, books once)

Record that one or more financial.reconciliation_staging rows are the COUNTERPART LEG of a money movement ALREADY booked by a posted journal entry — the second statement’s view of a transfer between two accounts we own. Writes the convention the ledger already uses for linked money movements: both legs share one reconciliation_group_id, the surviving leg stays status=executed carrying the journal_entry_id, and the linked leg goes status=rejected with an audit note, so the pair nets to zero and the movement is booked exactly once. Fills the gap left by reconcile_match, which loads ONLY pending_review rows and therefore can never pair a pending row with an already-executed leg. journal_entry_id is REQUIRED and must be a posted, non-voided entry for the caller’s tenant — the tool NEVER searches for a match by amount; amount and date are corroborating CHECKS and a row that fails either is skipped. The amount may equal the entry total OR any per-asset side total, so a CROSS-ASSET entry (balanced in USD, not in units) matches on the side written in the row’s own asset. Rows already executed are skipped; rows already rejected are grouped without changing status (the repair path for a transfer whose legs were both rejected and never linked). Idempotent — a repeat call for the same entry reuses the existing link group. A linked (rejected) row cannot be posted by approve_staging, post_from_staging, publish_batch or execute_group; only the deliberate unreject_staging returns it to review. dry_run=true (DEFAULT) previews without writing. The group is written status=resolved so a later reconcile_match run cannot delete the link. RLS-scoped (per-session user JWT), never service_role. REPLY SHAPE — a dry run NEVER reports a write. Two axes, kept apart: (1) WHAT THE CALL DID is the per-row action and the summary counters — linked and grouped_only count rows ACTUALLY WRITTEN and are therefore 0 in a dry run, while the previewed rows are counted in would_link / would_group_only and carry per-row action “would_link” / “would_group_only” plus after_is_projected=true; (2) WHAT THE ROW BECOMES is before.status / after.status, which keep their domain values (pending_review / rejected / executed) in BOTH modes and never take a would_* value. The group write is reported the same way: reconciliation_group_created records a real INSERT (false in a dry run, always) and would_create_reconciliation_group records the preview, while surviving_legs_stamped / would_stamp_surviving_legs report the surviving-leg stamp. In a dry run that would mint a NEW group, reconciliation_group_id is null because the id does not exist yet. Never read linked > 0 as proof of a write without also reading mode.dry_run.

ParameterTypeRequiredDescription
journal_entry_idstringyesREQUIRED UUID of the POSTED financial.journal_entries row that already books this movement. The caller must name it — the tool never infers it from the amount.
staging_idsstring[]yesREQUIRED explicit set of reconciliation_staging row UUIDs to link as counterpart legs of that entry. No filter-based bulk link is offered — linking is a deliberate per-id action.
reasonstringyesREQUIRED audit reason written to reviewer_notes on each linked row (e.g. “Woovi-side view of a Nubank PJ → Woovi funding transfer already booked from the Nubank leg”).
labelstringnoOptional label for the reconciliation group (e.g. “Internal transfer Nubank PJ ↔ Woovi (own accounts, nets to zero)”). Defaults to a label derived from the journal entry.
date_window_daysnumberno± days a target row’s entry_date may differ from the journal entry’s date and still be accepted. Default 3. A row outside the window is SKIPPED, not linked.
amount_tolerancenumbernoAbsolute amount tolerance when comparing |row.amount| to the figures the entry carries. Default 0 (EXACT match required). A row outside the tolerance is SKIPPED, not linked. The row may equal the entry total OR any per-asset side total, so a CROSS-ASSET entry (balanced in USD, not in units) is matched on the side written in the row’s own asset.
dry_runbooleannoWhen true (DEFAULT), compute the full before/after plan WITHOUT writing. Set false to persist the group, the reconciliation_group_id stamps and the status=rejected transitions.

Un-Reject (Restore) Reconciliation Staging Rows

Reverse a prior reject_staging: move specific financial.reconciliation_staging rows from the TERMINAL status=rejected state BACK to status=pending_review (the active review queue), with a reviewer_notes audit stamp. This unblocks reconciliation — reject_staging was a one-way terminal, so a row rejected in error (or one that only looks spurious until later context arrives) got stuck in rejected forever. NEVER touches amount, journal_entry_id, source_raw, or any classification field — only status (→ pending_review), reviewer_notes (the reason APPENDED to preserve the prior rejection note), and reviewed_at. Requires an explicit staging_ids array (no filter-based bulk un-reject) and a reason. Only rows currently status=rejected are eligible; rows in any other status (pending_review/approved/executed/error) are SKIPPED with a clear per-row reason (already-pending_review is an idempotent no-op). dry_run=true (DEFAULT) returns a before/after preview without writing; set dry_run=false to persist. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are touched. REPLY SHAPE: unrejected counts rows ACTUALLY WRITTEN and is therefore 0 in a dry run; previewed rows are counted in would_unreject and carry per-row status “would_unreject” with after_is_projected=true, because their after block is a projection and not the stored row. Never read unrejected > 0 as proof of a write without also reading mode.dry_run.

ParameterTypeRequiredDescription
staging_idsstring[]yesREQUIRED explicit set of reconciliation_staging row UUIDs to un-reject (restore to pending_review). No filter-based bulk un-reject is offered — un-rejection is a deliberate per-id action.
reasonstringyesREQUIRED audit reason appended to reviewer_notes (e.g. “false-positive reject, this is a legit OpenEnglish salary Pix after all”).
dry_runbooleannoWhen true (DEFAULT), compute before/after WITHOUT writing. Set false to persist the status=pending_review transition.

Reset Never-Posted “executed” Staging Rows → pending_review

Return financial.reconciliation_staging rows that claim status=executed but NEVER reached the ledger back to status=pending_review (the active review queue), and CLEAR the broken journal_entry_id + executed_at. status=executed is an unbacked claim — no FK and no CHECK tie it to the entry actually posting — so a row can sit in executed while its journal_entry_id points at a DRAFT entry that never posted (often an empty shell with zero lines) or is NULL outright. No other tool can fix that: update_staging refuses status changes, unreject_staging only reverses rejected, un_suppress_staging only reverses suppression, reverse_batch only covers batch-published rows. HARD GUARD (NO override parameter exists): a row whose journal_entry_id points at a POSTED entry is ALWAYS REFUSED — real posted work can never be un-executed here, because a reset row re-enters the approval queue and would double-book. The guard proves non-posting rather than assuming it, so an entry that cannot be read (deleted, or invisible under RLS) is refused too — run this BEFORE delete_draft_entries, never after. ⚠️ WHAT THE GUARD CANNOT PROVE: a row with journal_entry_id IS NULL has no pointer to follow, and a NULL pointer does NOT mean the transaction is unbooked — the entry may live on the COUNTERPART leg’s staging row, because one bank transfer appears on TWO bank statements but needs ONE journal entry (measured 2026-08-11: 8 TPL-WOOVI-FUND Nubank PJ outflows were NULL and all CORRECT; resetting them would have double-posted 60.252,50). That evidence lives on the other bank and this tool never sees it, so it is NOT detected in code. Instead every dry_run row carries its template code, description, amount, date, bank and tenant, each NULL-pointer row carries a caution, and the summary hoists a top-level warnings entry naming the ids — READ IT before you confirm. A voided entry is refused by DEFAULT; pass allow_voided=true to include it (it has no live ledger effect, but it IS real historical work and journal_entry_id is the only link to it). Selection is an EXPLICIT allowlist of staging row UUIDs — there is no filter mode, so a sweep cannot happen by accident. ALL-OR-NOTHING: if ANY requested id is refused, NOTHING is written, even with dry_run=false. Only status=executed rows are eligible; any other status is refused with a reason. reason is REQUIRED and is stamped into reviewer_notes together with the journal_entry_id being cleared, so a reset row stays distinguishable from one never processed. dry_run=true (DEFAULT) returns would_reset + refused without mutating anything. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are visible/resettable. NOT service_role.

ParameterTypeRequiredDescription
staging_idsstring[]yesREQUIRED explicit allowlist of financial.reconciliation_staging row UUIDs to reset. NEVER a query/filter — only these exact ids are considered. Every one of them must pass the guard or NOTHING is written.
reasonstringyesREQUIRED audit note, appended to reviewer_notes along with the journal_entry_id being cleared (e.g. “executed but the journal entry never posted — empty draft shell, returning to review per 2026-08-11 reconciliation audit”).
dry_runbooleannoWhen true (DEFAULT), return exactly what WOULD change (would_reset) plus the refused list WITHOUT writing anything. Set false to persist the reset.
allow_voidedbooleannoWhen true, a row whose journal_entry_id points at a VOIDED entry is eligible instead of refused. Default false. A voided entry has no live ledger effect, but it IS real historical work and journal_entry_id is the only link to it — so this is a deliberate opt-in. It does NOT and CANNOT affect the posted-entry guard.

Approve Reconciliation Staging Rows (pending_review → approved)

Move financial.reconciliation_staging rows from status=pending_review to status=approved — the MIDDLE step of the pending_review → approved → executed lifecycle, which until now NO MCP tool performed (only the preview UI did). That gap made publish_batch unreachable: publish_batch skips every row that is not approved, and it is the ONLY publish path that is previewable (dry_run), grouped into one reconciliation_batches row, and reversible with reverse_batch. This tool is what makes that path usable. APPROVAL MEANS READY TO POST, so a row that publish_batch would then silently skip is REFUSED here rather than moved one step down the pipeline: the row must have a resolved posting account (ai_template_code OR ledger_account_id), a resolvable tenant (target_tenant_id, or a target_tenant_slug that actually resolves), a currency, and a non-zero amount. ⚠️ A NEGATIVE amount is POSTABLE and is NOT refused — a bank outflow is stored negative and posts as its absolute value (the template, not the sign, decides debit vs credit); only a ZERO or NULL amount is refused. Only pending_review rows are eligible: an executed row is REFUSED and there is NO override parameter, rejected and error rows are REFUSED and named, and an already-approved row is an idempotent no-op SKIP that does not abort the batch. A row soft-hidden by suppress_staging is refused (un-suppress it first). Selection is an EXPLICIT allowlist of staging row UUIDs — there is no filter mode, so “approve everything matching a filter” cannot happen. ALL-OR-NOTHING: if ANY requested id is refused, NOTHING is written, even with dry_run=false. reason is REQUIRED and is recorded into reviewer_notes (a pre-existing note is preserved, never clobbered). dry_run=true (DEFAULT) returns the before/after diff plus the refused list without writing. NEVER touches amount, journal_entry_id, source_raw, or any classification field. RLS-scoped (per-session user JWT) — only rows for the caller’s tenants are visible/approvable. NOT service_role.

ParameterTypeRequiredDescription
staging_idsstring[]yesREQUIRED explicit allowlist of financial.reconciliation_staging row UUIDs to approve. NEVER a query/filter — only these exact ids are considered. Every one of them must pass the gate or NOTHING is written.
reasonstringyesREQUIRED audit reason recorded into reviewer_notes (e.g. “C6 CDB outflows verified against the 2026-07 statement totals, approving for batch publish”). A pre-existing reviewer note is preserved and this reason is appended to it.
dry_runbooleannoWhen true (DEFAULT), return exactly what WOULD change (would_approve) plus the refused and skipped lists WITHOUT writing anything. Set false to persist status=approved.
override_already_postedobjectnoESCAPE HATCH for the ALREADY-POSTED gate, for the case where a grouped leg really is a SECOND, distinct movement that merely looks like the one already booked. Requires expect_journal_entry_ids listing EXACTLY the already-posted entry ids the refusal reported (a missing id, or an id that is no longer colliding, refuses the override) plus a written reason. Never pass it without reading the named entries first.

Backfill Legacy Nubank Rows to Native Identificador Key

Idempotent maintenance backfill: re-keys LEGACY Nubank financial.reconciliation_staging rows onto the statement’s native Nubank Identificador (source_raw.identifier), rewriting source_ref to bank_statement:nubank:<identifier> and ingest_row_hash to sha256(that ref). Fixes the re-ingest-creates-duplicates bug: legacy rows were keyed on the old file-hash scheme, so a full re-ingest (which now keys on the Identificador) produced a DIFFERENT ingest_row_hash and slipped past the dedup sweep. After this backfill, a re-ingest of the same months stays deduped. Only touches rows with source_type=‘bank_statement’ AND source_raw.bank=‘nubank’ AND a non-empty source_raw.identifier. NEVER changes status / amount / journal_entry_id. Idempotent (an identifier_used_as_ref guard makes re-runs a no-op) and collision-safe (a ingest_row_hash already owned by another row is REPORTED and SKIPPED, never overwritten). RLS-scoped (per-session user JWT) — only the caller’s tenants’ rows are re-keyed. dry_run=true (DEFAULT) reports how many rows WOULD be re-keyed without writing; set dry_run=false to persist.

ParameterTypeRequiredDescription
tenant_idstringnoOptional tenant UUID to scope the backfill to a single tenant. Omit to cover every tenant the caller can see (still RLS-scoped to the caller’s memberships).
limitnumbernoMax legacy nubank rows to scan in one pass. Default 5000.
dry_runbooleannoWhen true (DEFAULT), report the re-key candidate count WITHOUT writing. Set false to persist the source_ref/ingest_row_hash rewrite + identifier_used_as_ref flag.

Backfill Missing Staging row_signature (idempotent)

Idempotent maintenance backfill: compute and persist the stable row_signature on every financial.reconciliation_staging row where it IS NULL. Uses the ONE canonical definition — rowSignature() from classifier/routing.ts, the same pure function applyRouting and validate_staging_row call — so no second signature space is created. WHY ROWS ARE NULL: before 2026-08-21 the column was only written when a routing rule/memory MATCHED (updateClassification, behind if (c.routing)) or when a human validated the row; ingest never wrote it. Rows matching nothing kept NULL forever (340 of 1073 on prod). WHAT THAT BROKE: the phase-1.5 re-import duplicate sweep, which skips NULL signatures (findExistingRowSignatureMatches), so a third of the corpus was invisible to it. It did NOT break routing_memory auto-application — applyRouting recomputes the signature from the row in memory and never reads this column. BLAST RADIUS: writes EXACTLY ONE column, row_signature. Never status, ai_template_code, ai_category, ai_confidence, was_manually_edited, target_tenant_slug or journal_entry_id. Nothing is approved, posted, rejected or re-routed. Rows whose new signature already exists as a routing_memory key are REPORTED in memory_matches and otherwise untouched — a future classify of such a row would resolve from memory, which is worth seeing before it happens. IDEMPOTENT BY CONSTRUCTION: only NULL rows are selected, so a second run reports zero. RLS-scoped (per-session user JWT). dry_run=true (DEFAULT) reports what WOULD be stamped without writing; set dry_run=false to persist.

ParameterTypeRequiredDescription
tenant_idstringnoRestrict to one tenant UUID. Omit to cover every tenant the caller can see (still RLS-scoped to the caller’s memberships).
source_typestringnoRestrict to one source_type (‘bank_statement’ | ‘binance’ | ‘onchain’ | …).
statusesstring[]noRestrict to these staging statuses. Omit to cover every status — a missing signature on an executed or rejected row still blinds the duplicate sweep, which is status-agnostic by design.
limitnumbernoMax NULL-signature rows to scan in one pass. Default 5000.
dry_runbooleannoWhen true (DEFAULT), report what WOULD be stamped without writing. Set false to persist.

Publish Batch (reversible batch publish of approved staging rows)

Publish an EXPLICIT list of APPROVED financial.reconciliation_staging rows as ONE reversible batch: creates a financial.reconciliation_batches row, posts one journal entry per staging_id (stamped with the batch id via the create_journal_entry RPC), marks each staging row executed, and stores a per-wallet projected-balance snapshot on the batch. The batch can later be undone with reverse_batch (VOID + RESET — no reversing-entries). Rows that are not approved / not resolvable / already executed are skipped with a reason (partial publish is fine — it is reversible). dry_run=true previews what would post/skip without writing. Feature-guarded until the dfl-schema batches migration is applied. Writes run on the caller’s user-JWT under RLS.

ParameterTypeRequiredDescription
staging_idsstring[]yesREQUIRED explicit set of APPROVED reconciliation_staging row UUIDs to publish together as one batch (e.g. only the BlueL + BlackL rows). NOT “everything approved”.
labelstringyesHuman label for the batch (e.g. “BlueL+BlackL Jun-2026 salary reconciliation”).
sourcestringnoOptional provenance tag for the batch (e.g. “claude-main”, “preview-ui”).
posted_bystringnoOptional user UUID recorded as created_by on the batch (auth.users.id).
dry_runbooleannoWhen true, preview which rows WOULD post vs skip WITHOUT creating a batch or posting. Default false (this tool executes).
override_already_postedobjectnoESCAPE HATCH for the ALREADY-POSTED gate, for the case where a grouped leg really is a SECOND, distinct movement that merely looks like the one already booked. Requires expect_journal_entry_ids listing EXACTLY the already-posted entry ids the refusal reported (a missing id, or an id that is no longer colliding, refuses the override) plus a written reason. Never pass it without reading the named entries first.

Reverse Batch (void posted entries + reset staging rows)

Undo a published reconciliation batch by VOID + RESET (NOT reversing-entries): voids every posted journal entry stamped with the batch id (existing void mechanism → voided_at + void_reason) and resets each linked staging row to approved/journal_entry_id=NULL so it is re-publishable, then marks the batch reverted. Idempotent (already-reverted → no-op). Never hard-deletes; audit preserved. dry_run=true reports the counts that WOULD change without writing. Feature-guarded until the dfl-schema batches migration is applied. The reverse_batch RPC is SECURITY DEFINER, invoked on the caller’s user-JWT.

ParameterTypeRequiredDescription
batch_idstringyesUUID of the reconciliation_batches row to reverse.
reasonstringnoOptional reason stamped as void_reason on the voided entries + on the batch.
dry_runbooleannoWhen true, report how many entries would be voided / staging rows reset WITHOUT calling the RPC. Default false (this tool executes).

Get Batch (metadata + entries + snapshots)

Read one financial.reconciliation_batches row: label, status (open|published|reverted), created/reverted provenance, its journal entries (id/date/status/posted_at/voided_at), and the stored projected_snapshot / real_snapshot. Read-only, RLS-scoped. Feature-guarded until the dfl-schema batches migration is applied.

ParameterTypeRequiredDescription
batch_idstringyesUUID of the reconciliation_batches row to read.

List Batches

List financial.reconciliation_batches rows (newest first), optionally filtered by tenant and status (open|published|reverted). Read-only, RLS-scoped. Feature-guarded until the dfl-schema batches migration is applied.

ParameterTypeRequiredDescription
tenantstringnoOptional tenant_id (financial.tenants) to filter batches by.
statusenumnoOptional batch status filter. One of: open, published, reverted.
limitnumbernoMax rows to return (default 50, max 200).

Check Batch Divergence (projected snapshot vs real balances)

Compare a batch’s stored projected-balances snapshot (captured at publish time) against freshly-fetched REAL balances — bank via financial.bank_balance_snapshots (latest per bank+currency) and on-chain via the canonical financial.v_onchain_latest_deduped view (latest per wallet+token, never a raw SUM across snapshot days). Returns per-wallet projected/real/diff, a total absolute divergence, a batch-level diverged boolean, a verdict (clean | diverged | coverage_failure), and a coverage report that SURFACES every excluded wallet (no real balance, or a snapshot older than max_snapshot_age_hours). This is a FAIL-CLOSED verification control: set require_full_coverage=true (STRONGLY recommended at reconciliation close) so any missing/stale evidence yields verdict=coverage_failure (diverged=true) instead of a false-green pass. Read-only, RLS-scoped. Feature-guarded until the dfl-schema batches migration is applied.

ParameterTypeRequiredDescription
batch_idstringyesUUID of the reconciliation_batches row to check.
thresholdnumbernoAbsolute tolerance for |projected − real| before a wallet counts as diverged. Default 0.01 (sub-cent rounding noise).
max_snapshot_age_hoursnumbernoFreshness SLA: a real balance whose snapshot is older than this many hours is STALE — listed in coverage.excluded_details and, under require_full_coverage, fails closed. Default 168 (7d) for standing checks; pass 48 at reconciliation-close time.
require_full_coveragebooleannoFAIL-CLOSED mode. When true, if ANY in-scope wallet is excluded — no real balance, or a stale snapshot — the tool returns verdict=coverage_failure with diverged=true instead of a clean pass. Default false for back-compat; set true at reconciliation close so the batch can never be certified against incomplete/stale evidence.

Snapshot Account Drift (computed vs real, per account)

Compute computed-vs-real balance drift for every active wallet and persist it as one financial.reconciliation_runs row per tenant plus its financial.reconciliation_diffs rows. This is the durable per-account state snapshot — without it, resuming the reconciliation after any gap means re-deriving every balance by hand. Computed = the balance derived from POSTED journal entries (financial.v_wallets). Real = the newest snapshot: onchain_balance_snapshots for wallets, bank_balance_snapshots for banks. UNIT DISCIPLINE: a token account is scored against a token QUANTITY and a USD/BRL account against a currency VALUE. A wallet whose only real balance is in the other unit is EXCLUDED, never compared — comparing a book quantity against an on-chain USD value is what produced the false “phantom token” reading of 2026-07-04. COVERAGE: every wallet appears either in the scored rows or in the exclusion list with a reason (no-real-balance / stale-real-balance / unit-mismatch). Nothing is dropped silently, so “all green” can never mean “nobody looked”. A wallet closes when the absolute drift is within the floor OR the relative drift is within close_pct — the floor exists because a percentage rule alone can never close a small account (BB PJ holds R$100, where 1% is one real). dry_run defaults TRUE: it measures and reports without writing. Set dry_run=false to persist.

ParameterTypeRequiredDescription
tenant_idsstring[]noRestrict to these tenant UUIDs. Default: every tenant the caller can read.
periodstringnoPeriod label YYYY-MM stamped on the run and every diff row. Default: current UTC month.
max_real_age_daysnumbernoA real balance older than this is EXCLUDED as stale rather than scored (default 7). A drift verdict resting on a month-old anchor is not a verdict.
close_pctnumbernoRelative drift at or under this closes an account (default 1).
red_pctnumbernoAbove this, and above the floor, is RED (default 3).
abs_floor_fiatnumbernoAbsolute floor for USD/BRL accounts (default 50).
abs_floor_cryptonumbernoAbsolute floor for token accounts (default 0.0001).
dry_runbooleannoDefault TRUE — measure and report, write nothing.

Account Close Readiness (which account can we close next, and what does it cost)

READ-ONLY. Per wallet: the posted BOOK balance, the REAL balance and its date (financial.bank_balance_snapshots for fiat, financial.onchain_balance_snapshots for on-chain), the current drift, the staging queue by status with PER-CURRENCY sums, how many queued rows have no template, how many would post to the wrong place (same logic as staging_routing_audit — shared, not forked), the projected balance after the queue posts, and the residual gap that would remain. Sorted CHEAPEST-TO-CLOSE FIRST, and the ordering criterion is printed in the ordering block rather than hidden in a comparator: the score is a COUNT OF OPEN ITEMS, never a money amount, so it is comparable across a BRL bank and a token wallet. UNIT DISCIPLINE: on-chain wallets are compared in TOKEN UNITS, not USD — the ledger 1.2.2.x accounts hold a quantity per asset (27.470,22 SPECTRA is about US$72). Every figure carries its unit; a token quantity is never compared against a currency value. COVERAGE: a queue row that cannot be attributed to a wallet is reported in an explicit unattributed bucket with a reason. That bucket is computed BEFORE the wallet filter and is always reported in full, so narrowing the report can never make a problem vanish. financial.v_projected_wallet_balances is read as a CROSS-CHECK: its own attribution uses a naive source_bank_name = wallet name comparison and silently drops the rows that fail it, so a view_projected_agrees:false marks a wallet whose rows the view lost. This tool NEVER writes.

ParameterTypeRequiredDescription
tenant_idsstring[]noRestrict to these tenant UUIDs. Default: every tenant the caller can read.
wallet_name_containsstringnoCase-insensitive substring filter on the wallet name. Filters the WALLET list only — the unattributed bucket and the coverage counts still cover every scanned row.
statusesstring[]noStaging statuses counted as ‘the queue’. Default [‘pending_review’,‘approved’] — the rows that still have to post.
include_suppressedbooleannoInclude soft-suppressed staging rows in the queue. Default false.
limitnumbernoMaximum wallets returned, cheapest-to-close first (default 50).
close_pctnumbernoRelative drift at or under this is within tolerance (default 1).
red_pctnumbernoAbove this is RED (default 3).
abs_floor_fiatnumbernoAbsolute tolerance floor for fiat/USD accounts (default 50). The floor exists because a percentage rule alone can never close a small account.
abs_floor_cryptonumbernoAbsolute tolerance floor for token accounts, in tokens (default 0.0001).

Staging Routing Audit (which queued rows would post to the wrong place)

READ-ONLY audit of the reconciliation staging queue: flag every queued row whose classification would post to the wrong place. Reports, per category, a count, per-currency sums and row samples. Categories: no_template; template_missing_for_tenant; generic_cash_leg (INFORMATIONAL — the template cash leg is a strict ANCESTOR of the row bank account, which PR #287 substitutes at post time); wrong_bank (ERROR — the template cash leg is a DIFFERENT SPECIFIC account, a sibling, which #287 does NOT substitute, so the entry posts silently wrong); ambiguous_cash_leg (the post refuses); inactive_account_leg; template_has_no_cash_leg; template_legs_unreadable. The row bank account is resolved through the SAME alias table the poster uses (db/cash_leg.ts BANK_WALLET_ALIASES / financial.bank_name_wallet_candidates), because source_bank_name does NOT equal the wallet name (Nubank PF -> Nubank/BRL, Banco do Brasil -> BB PJ) and a naive comparison manufactures false mismatches. COVERAGE: a row whose bank cannot be resolved is reported in an explicit unattributed bucket with a reason, never dropped — a count of zero problems must never be an artefact of a filter. Sums are ALWAYS per currency, never one mixed total. ALSO REPORTS row_signature COVERAGE (signature_coverage): how many staging rows carry a row_signature, overall / per source_type / in the last 24h, with a verdict of complete | incomplete | REGRESSED. Measured over the ENTIRE table, deliberately NOT limited by this tool’s own statuses / tenant filters — coverage of a filtered slice is the same partial-but-healthy-looking number the metric exists to catch. A NULL signature makes a row invisible to the phase-1.5 re-import duplicate sweep; it does NOT affect routing_memory auto-application, which recomputes the signature and never reads the column. Fix a backlog with backfill_row_signatures; a REGRESSED verdict means a write path broke — find it instead. This tool NEVER writes and NEVER rejects anything.

ParameterTypeRequiredDescription
tenant_idsstring[]noRestrict to these tenant UUIDs. Default: every tenant the caller can read.
statusesstring[]noStaging statuses to audit. Default [‘pending_review’,‘approved’] — the rows that can still post. Pass explicitly to audit e.g. executed rows.
include_suppressedbooleannoInclude soft-suppressed rows (source_raw.suppressed=true). Default false.
categoriesstring[]noRestrict the reported categories. Default: all. Counts are still computed over every scanned row.
sample_limitnumbernoRow samples per category (default 5). Counts and sums always cover EVERY row, not just the samples.

Find Staging Duplicates (rows ingested more than once)

READ-ONLY. Find reconciliation_staging rows that were ingested more than once, and report each group with EVERY copy’s ingestion date so an operator can tell a re-import from a genuine same-day repeat. The ingest dedup keys only on ingest_row_hash, and a re-import of the same statement gets a DIFFERENT ingest_row_hash, so it never fires; this tool groups on the business identity of the movement instead — (tenant, entry_date, amount, currency, normalized description). Each group carries signal=likely_reimport (copies arrived in more than one ingestion run) or signal=same_ingestion_run (every copy arrived together — two identical R$225 payments on one day are REAL, so do NOT reject on this alone), plus distinct_ingest_row_hashes and distinct_row_signatures, which explain why the dedup missed the group. Each group ALSO carries distinct_natural_keys and copies_without_natural_key: natural_key is the identity column, so distinct_natural_keys=1 means the ingest ladder would now catch the group, >1 means the copies are genuinely distinct movements, and 0 means no copy carries a key yet (pre-migration rows, or a source no spec claims) so the group says nothing either way. This tool NEVER rejects, mutates or suppresses anything. It has no write path. Use reject_staging, with a human decision, to act on what it reports.

ParameterTypeRequiredDescription
tenant_idsstring[]noRestrict to these tenant UUIDs. Default: every tenant the caller can read.
statusesstring[]noStaging statuses to scan. Default [‘pending_review’,‘approved’] — the ACTIVE queue. Add ‘rejected’ or ‘executed’ to see the history of an already-handled re-import.
include_suppressedbooleannoInclude soft-suppressed rows (source_raw.suppressed=true). Default false.
entry_date_fromstringnoOnly rows with entry_date >= this (YYYY-MM-DD).
entry_date_tostringnoOnly rows with entry_date <= this (YYYY-MM-DD).
min_copiesnumbernoMinimum copies for a group to be reported. Default 2.
same_run_window_secondsnumbernoCopies whose ingestion times are within this window count as ONE ingestion run (default 60). This is what separates likely_reimport from same_ingestion_run.
limitnumbernoMaximum groups returned (default 100). group_count reports the TRUE total.