A general ledger is not one thing — it's a system of record plus half a dozen workflows that all read the same accounts, journals, and ledger. The usual result is four engines (a posting DB, a close/reporting warehouse, an FX revaluation job, a reconciliation tool) with sync jobs between them, and a "single source of truth" that is really four copies drifting apart — and, worst of all, a trial balance that doesn't tie in a multi-currency book because each system rounds and translates differently. This suite is the opposite: the whole GL on one DataK3 bucket. Accounts, journals, journal lines and the ledger are the shared double-entry spine; balance-gated posting, period close, subledger reconciliation, FX revaluation and the financial statements are workflows that JOIN that spine directly — one copy of the rows, queried by content (SQL) and by relationship (the account-hierarchy graph).
The problem — and why it matters
A mid-market ERP's GL module lists at six to seven figures a year, and that price buys the four stitched
systems above. Each workflow here is worth a tutorial on its own, and has one. The point of the suite is
that they compose with zero glue: the close snapshots the same ledger the posting engine wrote; the
balance sheet foots the same accounts the close just locked; the FX reval and the trial balance read the
same rows. No connector, no nightly export, no reconciliation between products.
But the sharpest payoff is the one a stitched stack quietly gets wrong. In a book that holds a foreign receivable, the trial balance only ties once revaluation has run — and proving that live, to the cent, is the heart of this page. Six base skills, each independently live-validated, compose into the suite:
| # | Skill | What it owns | Reads (shared master) |
|---|---|---|---|
| 1 | gl/core | accounts, journals, journal_lines, ledger, account_edges + the gl_accounts graph | — (the root) |
| 2 | gl/journal-entry | journal_audit | accounts, journals, journal_lines, ledger |
| 3 | gl/period-close | periods, trial_balance | accounts, journals, journal_lines, ledger |
| 4 | gl/subledger-reconciliation | subledger_items, reconciliation | ledger, accounts |
| 5 | gl/multi-currency | fx_rates, fx_reval | accounts, journals, journal_lines, ledger |
| 6 | gl/financial-reporting | income_statement, balance_sheet | accounts, ledger, journal_lines, gl_accounts |
This page does not re-teach them — follow each link for the mechanics. Here we show how they assemble
into one bucket, and how the multi-currency trial-balance seam is resolved. The machine-readable manifest is
the suites/gl contract.
The components teach; the suite ships
Each of the six posts above is a standalone package you can read and run — that is what they are for, and
each component's own package download is still exactly that. But the deployable artifact is the suite.
code/gl-suite-app is the whole GL as one app: a single FastAPI with a router
per component, one canonical models.py covering all 14 tables, the one guarded posting.py write
path every journal goes through, and plain imports (no importlib loader) — over one bucket and one
dodil-appid pool, so six components mean one sign-in and one bill. That is what you fetch to stamp a
customer system; the component posts are what you read to know why each part is shaped the way it is,
and how to derive a different customer's version. It ships by the ordinary git cycle (repo → CI → registry →
CD): Ship a DODIL app. The tar also carries PLATFORM.md — the
platform invariants, so whoever downloads it gets the rules along with the code.
How it composes
Three rules make six skills one GL.
1. One bucket, 14 disjoint tables. Every skill's tables are globally unique names, so they coexist in a
single bucket with no collision — 5 core + 1 journal-entry + 2 period-close + 2 subledger-reconciliation +
2 multi-currency + 2 financial-reporting. gl_accounts is a graph, not a table. Installing the suite is
installing the six skills, in order, into the same --bucket.
2. Masters are owned once, joined everywhere. gl/core owns the four masters. No other skill
re-declares them — they JOIN them. The posting engine writes journal_lines keyed to the same
journal_id core owns and re-materializes the same ledger; the close snapshots that ledger; the balance
sheet foots the same accounts; the reconciliation ties a control account off the same ledger balance. One
row, many readers:
In the gl bucket, show the cross-skill joins that prove shared masters: period-close's trial_balance grand total over core's journals, financial-reporting's balance sheet footing core's ledger, and subledger-reconciliation's AP tie-out against core's control account.
data_sqltrial_balance (period-close) grand total = 0.00 across 11 accounts — snapshotted from the same journal_lines core owns. balance_sheet (financial-reporting): assets 969500.00 = liabilities 40000.00 + equity 860000.00 + net income 69500.00, ties to 0.00. reconciliation (subledger-reconciliation): AP control 2100 gl_balance 40000.00 = Σ open subledger_items 40000.00, variance 0.00. Three skills, one ledger, no join table between products.
export BUCKET=gl
# period-close (trial_balance) ⋈ gl/core (journals/journal_lines) — same rows, two skills
dodil data sql -b "$BUCKET" "SELECT COUNT(*) AS rows, ROUND(SUM(balance),2) AS grand_total
FROM trial_balance WHERE period = '2026-09'"
# 11 | 0.00
# financial-reporting (balance_sheet) — does the sheet foot? assets − (liab + equity + net income)
dodil data sql -b "$BUCKET" "SELECT
(SELECT amount FROM balance_sheet WHERE period='2026-09' AND section='assets')
- ( (SELECT amount FROM balance_sheet WHERE period='2026-09' AND section='liabilities')
+ (SELECT amount FROM balance_sheet WHERE period='2026-09' AND section='equity')
+ (SELECT amount FROM balance_sheet WHERE period='2026-09' AND section='net_income') ) AS ties"
# 0.00
# subledger-reconciliation (reconciliation) ⋈ gl/core (ledger) — control account tie-out
dodil data sql -b "$BUCKET" "SELECT control_account_id, gl_balance, subledger_sum, variance, reconciled
FROM reconciliation WHERE recon_id = 'AP-2100'"
# 2100 | 40000.00 | 40000.00 | 0.00 | true3. One graph, assembled once. gl_accounts is created from a node table (accounts) and an edge table
(account_edges) by a CREATE GRAPH that snapshots its edges at creation — edges added afterwards are
invisible. So gl/core inserts nodes + edges but runs no CREATE GRAPH; the single create is deferred
to gl/financial-reporting at the tail, the only skill that reads it — and it must run after
gl/multi-currency, or the balance sheet foots at stale FX. The account tree points child → parent, so a
rollup account's descendant leaves are the incoming direction:
In gl, run the single deferred graph assembly (gl_accounts) after core has loaded all account_edges, then roll Total Assets (1000) and Total Revenue (4000) up to their posting leaves over the graph and foot them against the ledger.
data_pgOne CREATE GRAPH gl_accounts over core's fully-loaded account_edges. graph_khop('gl_accounts', 1000, 5, 'in') JOIN ledger → Cash 560000.00, Accounts Receivable 109500.00, Fixed Assets 300000.00 = assets 969500.00. graph_khop('gl_accounts', 4000, 5, 'in') folds in 4100 Product / 4200 Service / 4900 Unrealized FX Gain/Loss (the account multi-currency added) = revenue 479500.00. Bolt agrees: MATCH (root)<-[:account_edges*1..5]-(child) WHERE id(root)=4000 returns 4100/4200/4900.
# financial-reporting runs the ONE assembly, after core's edges are all written (snapshot rule)
dodil data pg -b gl "CREATE GRAPH gl_accounts NODES (accounts KEY account_id) EDGES (account_edges SRC src DST dst)"
# roll Total Assets up to its posting leaves and foot against the ledger — one hop-ranked query
dodil data pg -b gl "
SELECT k.node, a.name, l.balance
FROM graph_khop('gl_accounts', 1000, 5, 'in') k
JOIN accounts a ON a.account_id = k.node
JOIN ledger l ON l.account_id = k.node
ORDER BY k.node"
# 1100 Cash 560000.00 · 1200 Accounts Receivable 109500.00 · 1500 Fixed Assets 300000.00 (Σ = assets 969500.00)NOTE
graph_khop() is a top-level-SELECT-only, integer-literal-start table function. The graph plane
intercepts SELECT … FROM graph_khop(…); it cannot sit inside INSERT … SELECT, a UNION branch, or
take a column as its start node. Pattern: run the graph rollup as a verifying SELECT, then
materialize the report with a type-grouped INSERT … ON CONFLICT. The graph proves each leaf's type
equals its statement root; the write groups by the denormalized accounts.type.
What composing actually caught
The argument for a suite is usually stated as convenience — one bucket, one deploy. That undersells it. When the six components were finally stood up together on one bucket (2026-09-08), five bugs surfaced that every component had hidden while it ran alone, because alone each one was internally consistent:
- Four of the six posting routes never re-derived the ledger. A revaluation wrote a balanced FX
journal and the ledger kept its old balance — so the 3,230.00 gain existed in
journal_linesand in no report. Standalone,multi-currencytreatedledgeras read-only input and was self-consistent. rederive_ledgeroverwroteledger.currencywith each component's ownFUNCTIONAL_CURRENCY, wiping the EUR marker that the FX revaluation reads to pick its rate pair. It only bit when a second component posted to an account the first one owned.ledger.balancehad two incompatible readings — foreign-currency units in one component, the functional-currency signed net everywhere else — so the value was converted twice.- And the one worth remembering: the revaluation was not idempotent, and the books still balanced. It read a ledger balance its own journal had already moved, so three runs produced 3,230.00, then 3,278.xx, then 3,326.39 — with the trial balance sitting at 0.00 the entire time. Silently wrong, not loudly broken. A double-entry system can be perfectly balanced and still wrong, which is exactly the failure an auditor is paid to find and a test suite usually is not.
None of these are exotic. They are the ordinary consequence of components that share tables being verified in isolation — and they are the reason this suite is presented as one system rather than six tutorials that happen to sit in the same folder.
The multi-currency trial balance — the seam, resolved
Here is the piece a stitched stack gets wrong, and the reason a GL is the least forgiving workload the pg wire will ever carry.
gl/multi-currency carries an open foreign-currency account at its transaction-currency balance in
the single-balance ledger. So after a EUR sale, Accounts Receivable holds 100000.00 EUR — and a
naive SUM(ledger.balance) mixes currencies and is simply not a trial balance. The correct convention,
and the canonical suite trial balance, is in the functional currency: convert each account's ledger
balance at the current period-end rate (functional-currency accounts convert 1:1). That single
convention is what lets a multi-currency book tie — and revaluation is exactly what makes it tie.
The accounting story, proven live, in order:
In gl, post a EUR sale, show the functional-currency trial balance is off by exactly the unposted FX movement, run the multi-currency revaluation, and show it nets back to 0.00.
data_pg(a) EUR sale posted: DR Accounts Receivable 108000.00 / CR Product Revenue 108000.00 @ 1.08 — the ledger carries AR at its EUR principal 100000.00, currency EUR. (b) A naive SUM(ledger.balance) reads -8000.00 — meaningless, it mixes EUR and USD. The FUNCTIONAL trial balance at the period-end rate (EUR→USD 1.095000) reads +1500.00 — off by EXACTLY the unposted FX movement: 100000.00 × (1.095000 − 1.080000) = 1500.00. (c) Revaluation posts the balanced FX journal (DR AR 1500.00 / CR Unrealized FX Gain/Loss 1500.00; fx_reval orig 108000.00 → revalued 109500.00, gain 1500.00) and re-strikes AR into the functional currency (109500.00 USD). (d) The functional trial balance now nets to 0.00 — and so does the naive SUM, because every balance is now functional. Revaluation is what makes the multi-currency book tie.
export BUCKET=gl
# (b) BEFORE reval — the naive sum mixes currencies; the FUNCTIONAL trial balance is off by the FX movement
dodil data pg -b "$BUCKET" "
SELECT ROUND(SUM(l.balance),2) AS naive_raw_tb,
ROUND(SUM(l.balance * COALESCE(fx.rate, 1.0)),2) AS functional_tb_period_end
FROM ledger l
LEFT JOIN fx_rates fx
ON fx.from_ccy = l.currency AND fx.to_ccy = 'USD' AND fx.rate_date = '2026-09-30'"
# naive_raw_tb = -8000.00 functional_tb_period_end = 1500.00 ← off by exactly the unposted FX movement
# (c) revaluation posts the balanced unrealized FX journal and re-strikes AR into functional currency
dodil data pg -b "$BUCKET" "INSERT INTO journal_lines (journal_id,line_no,account_id,debit,credit,line_memo)
VALUES (8,1,1200,1500.00,0.00,'FX gain'),(8,2,4900,0.00,1500.00,'Unrealized FX Gain/Loss')"
# fx_reval: account 1200, orig 108000.00 → revalued 109500.00, gain_loss 1500.00 @ 1.095000
# (d) AFTER reval — the functional trial balance ties to the cent
dodil data pg -b "$BUCKET" "
SELECT ROUND(SUM(l.balance),2) AS naive_raw_tb,
ROUND(SUM(l.balance * COALESCE(fx.rate, 1.0)),2) AS functional_tb_period_end
FROM ledger l
LEFT JOIN fx_rates fx
ON fx.from_ccy = l.currency AND fx.to_ccy = 'USD' AND fx.rate_date = '2026-09-30'"
# naive_raw_tb = 0.00 functional_tb_period_end = 0.00 ← the book tiesIMPORTANT
Run revaluation before the close. The functional trial balance is off by the unposted FX movement
until gl/multi-currency recognizes it — +1500.00 here, exactly 100000.00 × (1.095000 − 1.080000).
Revaluation posts the unrealized gain to P&L (Unrealized FX Gain/Loss) and re-strikes the receivable
into the functional currency, and only then does the trial-balance snapshot net to 0.00 and the balance
sheet foot. That is why the suite's install order puts gl/multi-currency at position 5 and
gl/period-close/gl/financial-reporting after it. Money is DECIMAL(18,2), FX rates DECIMAL(18,6),
never a float — the 1500.00 gain reads back exact, not 1500.0000004.
One-shot — build the suite (install order matters)
With the DODIL MCP connected, one prompt scaffolds the full suite in order into one bucket:
Scaffold the full GL suite in one bucket (suites/gl).
Bucket: gl. Functional currency: USD. Load the demo COA. Install the six base skills IN ORDER into that
one bucket:
1. gl/core — accounts/journals/journal_lines/ledger + account_edges + the demo COA
(the AR leaf 1200 is EUR-denominated; add a 4900 Unrealized FX Gain/Loss
account) + a balanced opening journal + the materialized ledger.
Populate account_edges but DEFER the single CREATE GRAPH.
2. gl/journal-entry — balance-gated, idempotent ON CONFLICT posting into core's journals;
re-materialize the ledger. Post a EUR sale here.
3. gl/multi-currency — fx_rates + fx_reval; revalue the open EUR balance INTO USD at the
period-end rate BEFORE the close, so the functional trial balance ties.
4. gl/period-close — trial-balance snapshot (nets 0.00) + period lock over core.journals.
5. gl/subledger-reconciliation — tie the AP subledger to control account 2100 (variance 0.00).
6. gl/financial-reporting — run the SINGLE CREATE GRAPH gl_accounts, then P&L + balance sheet
rolled up by account type over it (LAST — it reads everything).
Then prove the assembly: 14 tables coexist; the functional trial balance is off by the FX movement before
reval and 0.00 after; the balance sheet foots; the AP tie-out variance is 0.00. Ask me the union questions
once (bucket, functional_currency, coa_preset, period) — don't re-ask what composition already answers.The order is not cosmetic. gl/core owns the masters, so it goes first. gl/multi-currency revalues the
open FX balance and must precede the close and the reports — otherwise the trial-balance snapshot and
the balance sheet foot at stale rates. gl/financial-reporting runs the single CREATE GRAPH and reads
every other skill's output, so it goes last. Want an industry cut? Add overlay: finserv | manufacturing
— an additive diff on top of the same 14 tables (a segregation-of-duties close sign-off, or
inventory/WIP/COGS variance journals), never a fork.
Building just part of it
You don't have to install all six. To build a subset, install gl/core first — it
owns the four masters every workflow reads — then add only the workflow skills you want. Each workflow
tutorial names its master dependencies (its contract's consumes block is the machine-readable list):
gl/journal-entry posts into journals/journal_lines;
gl/subledger-reconciliation reads ledger/accounts. Each workflow
can also run fully standalone on an empty bucket — it ships a stub-masters step that creates just the
masters it reads — but once two workflows share the same masters, install gl/core once instead of letting
each stub its own (avoids divergent seed data and a double-typed ledger). And skip the graph (no
CREATE GRAPH) unless you install gl/financial-reporting, which owns
the single deferred assembly; every other workflow is pure SQL and needs no graph.
The questions, asked once
Composition removes the redundant asks. The suite interview is short:
- Asked once (shared): the
bucket, thefunctional_currency(every account, the close snapshot, the reval target and the statements inherit it), thecoa_preset(one demo COA drives every skill's Test), and theperiod(shared by the close, the reval, and the reports). - Removed by composition: journal-entry/period-close/multi-currency never ask "seed a COA?" or "stub the
masters?" — their accounts, journals and ledger come from core; financial-reporting never asks "build
an account hierarchy?" — core loads the edges and reporting runs the one
CREATE GRAPH. - Still per-workflow (genuine knobs): the
retained_earnings_accountthe close rolls into, thecontrol_accountsmap the reconciliation ties, theauto_reverseon the reval. Real best-practice decisions — the overlay defaults them, you tune them.
Verify
The full single-bucket assembly and the FX-seam narrative were live-validated on 2026-09-03 (org IHDIASH, one throwaway bucket, torn down after) — the composition itself, not a re-test of each skill:
# 1) 14 tables from all six skills coexist in ONE bucket, NO name collision
dodil data table list -b gl # 14 (5 core + 1 journal-entry + 2 period-close + 2 recon + 2 fx + 2 reporting)
# 2) the FX seam BEFORE reval — naive sum mixes currencies; functional TB off by exactly the FX movement
dodil data pg -b gl "SELECT ROUND(SUM(l.balance),2) AS naive,
ROUND(SUM(l.balance * COALESCE(fx.rate,1.0)),2) AS functional
FROM ledger l LEFT JOIN fx_rates fx ON fx.from_ccy=l.currency AND fx.to_ccy='USD' AND fx.rate_date='2026-09-30'"
# naive -8000.00 | functional 1500.00 (= 100000.00 × (1.095000 − 1.080000))
# 3) AFTER reval — the functional trial balance ties to the cent
# naive 0.00 | functional 0.00
# 4) ONE graph, assembled once; roll Total Assets up to its leaves and foot the ledger
dodil data pg -b gl "SELECT k.node, l.balance FROM graph_khop('gl_accounts', 1000, 5, 'in') k
JOIN ledger l ON l.account_id = k.node ORDER BY k.node"
# 1100 560000.00 · 1200 109500.00 · 1500 300000.00 (Σ = assets 969500.00)
# 5) period lock over shared journals: an operational journal into a CLOSED period is rejected
# journal 100 (source manual, 2026-09 closed) → absent · journal 101 (2026-10 open) → present
# 6) balance sheet foots because the trial balance is 0.00
dodil data sql -b gl "SELECT section, amount FROM balance_sheet WHERE period='2026-09'"
# assets 969500.00 = liabilities 40000.00 + equity 860000.00 + net income 69500.00The 14-table coexistence, the shared-master JOINs, the single CREATE GRAPH, the AP tie-out, the period
lock, and — the headline — the functional trial balance going off by exactly +1500.00 before reval and
back to 0.00 after are all proven live. The Ignite engines each skill deploys (the gl-posting-engine,
the gl-period-close-engine, the gl-reval-engine) are validated in their own tutorials; the suite test is
the data-plane composition — that six skills share one bucket, one set of masters, one account graph,
and one functional-currency trial balance without collision.
Connect your tools
Everything the six skills wrote lives in the one DataK3 bucket, reachable by your own stack — psql and any
Postgres driver over the pg wire, a Neo4j driver over Bolt against gl_accounts, gRPC for the table engine.
A finance team drives the ledger from psql or a BI tool over the same wire; the trial balance, the
statements and the reconciliation are just rows. data connect gl prints the endpoints
(pg.uk-lon-1.dodil.io:5432, bolt+s://bolt.uk-lon-1.dodil.io:7687, table-rpc.uk-lon-1.dodil.io:443);
point BI, a dashboard, or your app straight at the live rows. Full, live-validated walkthrough:
Connect your tools.
Composes
This page is a composition, not a fork:
- Six base skills —
gl/core,gl/journal-entry,gl/period-close,gl/subledger-reconciliation,gl/multi-currency,gl/financial-reporting— each its own tutorial and its own## Test. - The suite manifest (
suites/gl) — the ordered install DAG, the shared-master wiring, the single deferred graph rule, and the functional-currency trial-balance rule that resolves the multi-currency seam. - Overlays (
gl/overlays/*) — additive industry diffs (finserv regulated close, manufacturing cost accounting) that apply on top of the assembled suite.
Read the six to learn each workflow; read this to assemble them into one general ledger — one bucket, one trial balance that ties.