How do I populate spec_example_transactions from my core banking system?¶
Engineering walkthrough — Data Integration Team. Foundational.
The story¶
You've got an upstream core banking system with a gl_postings
table (or its local equivalent — general_ledger.entry,
accounting.posting_detail, etc.). It carries one row per posting
leg already, which is the natural granularity of our
spec_example_transactions table. You need to write the ETL job that
lands it in our two-table schema by the morning cut so the four
L2-fed dashboards (L1 Reconciliation, L2 Flow Tracing,
Investigation, Executives) work.
The good news: it's mostly a column-rename. The contract is small (12 mandatory columns + a handful of conditional ones — see Schema_v6.md → ETL contract / minimum viable feed). The bad news: skip the wrong column and a downstream check goes silent. So this walkthrough covers the canonical projection plus the per-column failure modes.
The question¶
"For my core banking system's gl_postings table, what's the
canonical projection that maps it to spec_example_transactions? What
columns must I populate, and what columns can wait until v2?"
Where to look¶
Two reference points:
docs/Schema_v6.md— column-level contract and per-column failure modes ("If you skip this, what dashboard breaks?").common/l2/schema.py::emit_schema(l2_instance)— the source of truth for the prefixed DDL. Every base table, view and matview your dashboards read is emitted here, all under the L2 instance prefix. Call it from Python (or apply directly viarecon-gen schema apply --execute) to see the rendered output for your L2 instance.
The spec_example in this walkthrough's SQL is your L2 instance name
(e.g., spec_example); your ETL substitutes it once when wiring
the projection.
What you'll see in the demo¶
Run (substitute your own L2 path for the bundled fixture below):
from recon_gen.common.l2.loader import load_instance
from recon_gen.common.l2.schema import emit_schema
l2 = load_instance("tests/l2/spec_example.yaml")
print(emit_schema(l2, prefix="spec_example")[:4000])
The first CREATE TABLE block is spec_example_transactions itself
— the column list, types and constraints your projection has to
satisfy. The second is spec_example_daily_balances. Read both
end-to-end before writing the projection; they're the contract.
The end-to-end mapping from core_banking.gl_postings →
spec_example_transactions is the INSERT INTO ... SELECT FROM
in Drilling in below — a real projection against a
hypothetical core-banking source schema.
What it means¶
For every row your ETL writes, you're committing to a contract:
- The 12 mandatory columns (per Schema_v6.md → minimum viable feed) get the row visible on the dashboards at all.
transfer_parent_idpopulated only for chained transfers (e.g.,external_txn → payment → settlement → saleor any reversal chain your L2 declares). Skip it and pipeline-traversal walkthroughs (Money Trail, Account Network) silently return nothing for the affected rows.origin = 'ExternalForcePosted'on Fed / processor force-posts. Skip it and the L1 drift split between bank-initiated activity and force-posted activity collapses (rows look like normal operator postings, drift checks under-fire).metadataJSON — the universal extras container. Skip it on day 1 if your downstream consumer doesn't need it; populate it in priority order (sourcefirst, then per-rail_namekeys per the catalog). The catalog tables in Schema_v6 list the keys + what each one drives.
Everything else (account_parent_role, transfer_completion,
template_name, bundle_id, supersedes) is conditional —
populate when the downstream consumer demands it. See Schema_v6.md
→ ETL contract / minimum viable feed
for the full column-by-column gate.
Drilling in¶
The mapping pattern looks like this for a customer-DDA posting (from Schema_v6 Example 1, abbreviated):
INSERT INTO spec_example_transactions (
id, transfer_id, rail_name, origin,
account_id, account_name, account_parent_role, account_role,
account_scope, amount_money, amount_direction, status,
posting, metadata
)
SELECT
p.posting_id AS id, -- your PK (column is `id`, not `transaction_id`)
p.transfer_id, -- your transfer grouping
p.rail_name, -- map your enum to ours
'InternalInitiated' AS origin, -- or 'ExternalForcePosted' for Fed
p.account_number AS account_id,
a.account_name,
a.parent_role AS account_parent_role,
a.account_role,
a.account_scope, -- 'internal' / 'external'
CAST(ROUND(p.signed_amount * 100) AS BIGINT) AS amount_money, -- dollars → cents (AO.1)
CASE WHEN p.signed_amount < 0 THEN 'Debit' ELSE 'Credit' END AS amount_direction, -- NOT NULL; must agree with amount_money's sign
CASE WHEN p.posting_status = 'P' THEN 'Posted' ELSE 'Failed' END AS status,
p.posting_timestamp AS posting,
JSON_OBJECT('source' VALUE 'core_banking') AS metadata
FROM core_banking.gl_postings p
JOIN core_banking.accounts a ON a.account_number = p.account_number
WHERE p.posting_date >= CURRENT_DATE - INTERVAL '7 days';
A few things to note about this projection:
statusmaps from your status enum to ours. Anything that's notPostedMUST bePendingorFailed(no fourth state) — the drift check and net-zero check bothWHERE status = 'Posted'to exclude in-flight or rejected legs.amount_moneyis signed by the v6 sign convention:Credit ⇒ amount_money ≥ 0(money IN to the account),Debit ⇒ amount_money ≤ 0(money OUT). The schema enforces this pairing via a CHECK constraint, so a row with conflicting sign + direction won't INSERT.spec_example_daily_balances.moneyfor any account-day equalsSUM(amount_money)up to that day, so getting this sign right is what makes the drift check honest. If your upstream uses the opposite sign convention, flip it here, not later in a view — every downstream check assumes the v6 convention. Stored as integer cents (avoids float dust) — the projection above multiplies by 100 + casts to BIGINT at the ETL boundary; see Schema_v6 → Money is stored as integer cents. Python ETLs should reach forrecon_gen.common.money.Centsinstead of the inline SQL CAST —Cents.from_dollars(...).valuerejects float-init Decimals that re-introduce float dust.amount_directionis a required'Debit' | 'Credit'enum. Derive it from your upstream's signed amount via theCASE WHEN p.signed_amount < 0 THEN 'Debit' ELSE 'Credit' ENDshape above. The base table's CHECK pairs direction with money's sign (Debit ⇒ money ≤ 0, Credit ⇒ money ≥ 0), so the two columns MUST agree or the row fails to land.metadatacarriessourceon every row from this projection (driven by theJSON_OBJECT(... VALUE 'core_banking')literal). That single key is enough to satisfy the Investigation provenance walks on day 1.
Next step¶
Once your projection is wired up:
- Populate a small slice — one day, one source system. Don't try to backfill 90 days on the first run.
- Wire the companion daily_balances feed
(How do I populate
spec_example_daily_balancesfrom my core banking system?). Drift checks compare STORED balance (daily_balances.money) against RECOMPUTED (SUM amount_money) — without both feeds, the drift surface is meaningless. - Run the validation walkthrough (How do I prove my ETL is working before going live?) — it walks you through the net-zero, drift-recompute and parent-chain integrity checks you should run before declaring the load complete.
- Open the L1 Reconciliation Dashboard's L1 Exceptions sheet — if the KPI reads 0 with no detail rows, your feed landed and the contract holds. If KPIs spike unexpectedly, see What do I do when the demo passes but my prod data fails? for the symptom-organized debug recipes. See it live: https://recon-gen-spec.hotchkiss.io/
- Iterate on metadata — once the minimum feed is stable,
layer in
transfer_parent_idand the per-rail_namemetadata keys per the Metadata JSON columns contract.
If your upstream source isn't a gl_postings table — say it's a
processor report, a Fed statement file or a sweep-engine log — the
same projection shape applies, the inbound columns just differ. The
same INSERT INTO spec_example_transactions pattern holds
regardless of source; for the Fed-statement case (the one that sets
origin = 'ExternalForcePosted') see
How do I tag a force-posted external transfer correctly?.
Related walkthroughs¶
- How do I populate
spec_example_daily_balancesfrom my core banking system? — the SIBLING walkthrough. Drift checks need both feeds; ship them together. - How do I prove my ETL is working before going live? — the NEXT step after writing the projection. Validates the invariants the dashboards depend on.
- How do I tag a force-posted external transfer correctly? —
the canonical pattern for Fed-statement ingest, which is the one
case where
origin = 'ExternalForcePosted'. - How do I add a metadata key without breaking the dashboards? — the extension contract for when your team needs a new metadata field.
- Schema_v6 → ETL contract / minimum viable feed — the column-by-column day-1 minimum the projection must satisfy.
- Investigation: Where did this transfer originate? —
a DOWNSTREAM consumer walkthrough: what an analyst does with
the
spec_example_transactionsrows your projection lands.