In a regulated bank, a journal is not just an accounting entry — it's a control. Under SOX, the person who prepares a journal cannot be the person who approves it, and a journal that posts without an approver of record is an audit finding. A generic GL will happily post any journal that balances. This overlay makes preparer ≠ approver a deterministic hard gate the ledger can never bypass — a self-approved journal is blocked from posting even though it balances — and every approval transition an immutable audit row. The period close inherits the same discipline: it cannot complete while a single journal in the period is still unapproved.
The problem — and the money
The buyer is the bank's controller or SOX-compliance lead who signs the 302/404 attestation. A material
journal posted without segregation of duties — or a period closed with unapproved entries in it — is not a
reconciliation nuisance, it's a control deficiency, with remediation cost and restatement exposure
measured in millions. A vanilla GL enforces exactly one invariant: debits equal credits. That is necessary
but nowhere near sufficient for a regulated close. The payoff here is a refusal: a perfectly balanced
journal whose approved_by equals its prepared_by is blocked from posting — the books stay clean,
and the attestation holds. In finserv, the deterministic gate is the feature.
This is the GL suite + the finserv overlay. The six base skills — core, journal-entry, period-close and the rest — are unchanged. The overlay only adds three columns, one audit table, and two deterministic gates layered over the balance gate and the close. Same bucket, one copy of the rows.
What finserv adds
A small, additive diff on top of the suite (skill: gl/finserv → the
gl/overlays/finserv contract):
- Segregation-of-duties columns — on
gl/corejournals(prepared_by,approved_by— the hard-gate input — andapproval_statedraft|pending|approved), added withALTER TABLE … ADD COLUMNover the pg wire. A column-add, never a second copy. - One table —
journal_approvals(keyapproval_id): the immutable segregation-of-duties trail — one append-only row per approval transition (actor, action, prev/new state, evidence). - Two gate changes — journal-entry's balance gate (Σdebit = Σcredit) still runs, but a deterministic
SoD hard gate layers over it: a journal may post only when
approved_by IS NOT NULL AND approved_by <> prepared_by AND approval_state = 'approved'. And period-close gains a sign-off gate: a period cannot close while any journal in it is still unapproved. - Presets —
require_dual_control = true;require_close_signoff = true; a self-approved journal and a dual-controlled one seeded so the gate is visible on day one.
Apply the diff — the ALTERs, the audit table, and the seed that drives the gate:
In the gl bucket, apply the finserv overlay: ALTER journals ADD prepared_by, approved_by, approval_state; create journal_approvals; then stage two balanced draft journals — J2 (prepared_by=alice, approved_by=alice, self-approved) and J3 (prepared_by=alice, approved_by=bob, approval_state approved).
data_pg→data_table_create→data_table_upsertAdded prepared_by/approved_by/approval_state to journals (ALTER over pg — the master, not a copy; all three land nullable). Created journal_approvals. Staged J2 (self-approved: alice/alice) and J3 (dual-controlled: alice/bob, approved) as balanced drafts — the inputs the SoD gate reads.
export BUCKET=gl # the SAME bucket the GL suite built — the overlay only ADDs
# 1) segregation-of-duties column-adds on the gl/core journals master — ALTER over the pg wire, never a copy
dodil data pg -b "$BUCKET" "ALTER TABLE journals ADD COLUMN prepared_by VARCHAR"
dodil data pg -b "$BUCKET" "ALTER TABLE journals ADD COLUMN approved_by VARCHAR"
dodil data pg -b "$BUCKET" "ALTER TABLE journals ADD COLUMN approval_state VARCHAR"
# 2) the immutable segregation-of-duties trail
dodil data table create journal_approvals -b "$BUCKET" --merge-key approval_id \
--columns-json '[{"name":"approval_id","type":"string","nullable":false},{"name":"journal_id","type":"long","nullable":true},{"name":"actor","type":"string","nullable":true},{"name":"action","type":"string","nullable":true},{"name":"prev_state","type":"string","nullable":true},{"name":"new_state","type":"string","nullable":true},{"name":"evidence_json","type":"string","nullable":true},{"name":"ts","type":"string","nullable":true}]'
# 3) stage two BALANCED draft journals — one self-approved (blocked), one dual-controlled (posts)
dodil data table upsert journals -b "$BUCKET" \
--row '{"journal_id":2,"period":"2026-09","source":"manual","status":"draft","memo":"Payroll accrual (self-approved)","prepared_by":"alice","approved_by":"alice","approval_state":"pending"}' \
--row '{"journal_id":3,"period":"2026-09","source":"manual","status":"draft","memo":"Revenue accrual (dual-controlled)","prepared_by":"alice","approved_by":"bob","approval_state":"approved"}'
dodil data table upsert journal_lines -b "$BUCKET" \
--row '{"journal_id":2,"line_no":1,"account_id":5200,"debit":25000.00,"credit":0.00}' \
--row '{"journal_id":2,"line_no":2,"account_id":1100,"debit":0.00,"credit":25000.00}' \
--row '{"journal_id":3,"line_no":1,"account_id":1200,"debit":40000.00,"credit":0.00}' \
--row '{"journal_id":3,"line_no":2,"account_id":4100,"debit":0.00,"credit":40000.00}'Now the payoff — the SoD hard gate blocking a balanced self-approved journal, and the guarded posting path:
In gl, compute each draft journal's gate verdict: blocked when approved_by is null, or approved_by = prepared_by, or approval_state <> 'approved', else post_ok — carrying the per-journal imbalance. Then post only the ones the gate clears with a guarded UPDATE, and show which journal actually flipped to posted.
data_sql→data_pgJ2 (alice/alice, imbalance 0.00) = blocked: preparer=approver (SoD) — balanced but self-approved, so it never posts. J3 (alice/bob, approved) = post_ok. The guarded UPDATE flipped only J3 to posted; J2 stayed draft. journal_approvals holds the transitions.
# the deterministic SEGREGATION-OF-DUTIES HARD GATE — layered OVER the balance gate
dodil data sql -b "$BUCKET" "
SELECT j.journal_id, j.prepared_by, j.approved_by, j.approval_state,
(SELECT SUM(l.debit)-SUM(l.credit) FROM journal_lines l WHERE l.journal_id=j.journal_id) AS imbalance,
CASE WHEN j.approved_by IS NULL THEN 'blocked: no approver'
WHEN j.approved_by = j.prepared_by THEN 'blocked: preparer=approver (SoD)'
WHEN j.approval_state <> 'approved' THEN 'blocked: not approved'
ELSE 'post_ok' END AS gate
FROM journals j WHERE j.status='draft' ORDER BY j.journal_id"
# 2 | alice | alice | pending | 0.00 | blocked: preparer=approver (SoD) <- balanced, still blocked
# 3 | alice | bob | approved | 0.00 | post_ok
# the ONLY posting path — a guarded UPDATE that posts a draft ONLY when SoD is satisfied
dodil data pg -b "$BUCKET" "UPDATE journals SET status='posted'
WHERE journal_id=2 AND approved_by IS NOT NULL AND approved_by <> prepared_by AND approval_state='approved'"
dodil data pg -b "$BUCKET" "UPDATE journals SET status='posted'
WHERE journal_id=3 AND approved_by IS NOT NULL AND approved_by <> prepared_by AND approval_state='approved'"
# read back: J2 still 'draft' (blocked by the guard), J3 'posted' — preparer never approves their own journalThe balance gate still runs — a journal that doesn't balance is rejected exactly as in the base journal-entry skill. But balancing is no longer sufficient to post: a balanced self-approved journal is held. The hard gate is a deterministic SQL predicate, not a judgement call; that's the point.
And the close inherits the same discipline — a period cannot close while it holds an unapproved journal:
In gl, check whether period 2026-09 can close: count its journals still draft and unapproved (approved_by null, or approved_by = prepared_by, or approval_state <> 'approved'). Then void the unapproved ones and re-check.
data_sql→data_pgPeriod 2026-09 had 2 unapproved journals (J2 self-approved + J4 no approver) -> 'close blocked: unapproved journals'. After voiding both (status='rejected'), 0 remain -> close_ok, and the trial balance stayed exactly 0.00.
# the CLOSE SIGN-OFF GATE — a period with any unapproved journal cannot close
dodil data sql -b "$BUCKET" "
SELECT p.period_id, p.status,
(SELECT COUNT(*) FROM journals j
WHERE j.period=p.period_id AND j.status='draft'
AND (j.approved_by IS NULL OR j.approved_by=j.prepared_by OR j.approval_state<>'approved')) AS unapproved,
CASE WHEN (SELECT COUNT(*) FROM journals j
WHERE j.period=p.period_id AND j.status='draft'
AND (j.approved_by IS NULL OR j.approved_by=j.prepared_by OR j.approval_state<>'approved')) > 0
THEN 'close blocked: unapproved journals' ELSE 'close_ok' END AS close_gate
FROM periods p WHERE p.period_id='2026-09'"
# 2026-09 | open | 2 | close blocked: unapproved journals
# void the unapproved journals, then the close proceeds (trial balance stays 0.00)
dodil data pg -b "$BUCKET" "UPDATE journals SET status='rejected' WHERE journal_id IN (2,4)"
# re-run the gate -> unapproved = 0 -> close_okScaffold it — the one-shot
With the DODIL MCP connected, one prompt composes the suite and this overlay:
Scaffold a GL for my bank — the GL suite plus the finserv overlay.
Base: the full gl suite (chart of accounts + account-hierarchy graph + journal-entry / period-close /
financial-reporting / subledger-reconciliation / multi-currency) on one bucket, money DECIMAL(18,2),
idempotent ON CONFLICT posting.
Then apply the finserv overlay on the SAME bucket:
1. ALTER journals ADD prepared_by, approved_by, approval_state (pg wire — the master, not a copy).
2. Create journal_approvals (key approval_id) — an append-only row per approval transition.
3. Layer a DETERMINISTIC segregation-of-duties hard gate over the balance gate: a journal may post ONLY
when approved_by is set, differs from prepared_by, and approval_state='approved' — a self-approved
journal is blocked even when it balances.
4. Add a close sign-off gate: a period cannot close while any journal in it is unapproved.
Seed the demo above and show the hard gate holding a balanced self-approved journal.Verify
Every result below was live-validated on 2026-09-03 (org IHDIASH, throwaway bucket glovlfinserv0903,
torn down after):
# columns land (nullable, over the pg wire)
dodil data pg -b "$BUCKET" "ALTER TABLE journals ADD COLUMN approved_by VARCHAR" # + prepared_by, approval_state
# the hard gate holds a balanced self-approved journal: J2 (alice/alice) = blocked (SoD)
dodil data sql -b "$BUCKET" "SELECT journal_id, prepared_by, approved_by, approval_state FROM journals WHERE status='draft' ORDER BY journal_id"
# the guarded post flips only the dual-controlled one: J2 stays draft, J3 -> posted
dodil data sql -b "$BUCKET" "SELECT journal_id, status FROM journals WHERE journal_id IN (2,3) ORDER BY journal_id"
# the close sign-off gate + trial balance stays exactly 0.00
dodil data sql -b "$BUCKET" "SELECT CAST(SUM(balance) AS VARCHAR) AS trial_balance FROM ledger" # -> "0.00"The ALTER-adds, the journal_approvals trail, the segregation-of-duties hard gate blocking a balanced
self-approved journal, the guarded posting path, and the close sign-off gate are all proven live — with the
trial balance holding at exactly 0.00 throughout (ledger: Cash 1000000.00, AR 40000.00, Common Stock
-1000000.00, Product Revenue -40000.00).
Connect your tools
Everything the overlay wrote lives in the one DataK3 bucket, reachable by your own stack — a GRC / audit
tool reads journal_approvals and journals.approval_state over the Postgres wire; a controller's close
dashboard runs the sign-off gate as a plain SQL query before locking the period. data connect gl prints
the endpoints. Full, live-validated walkthrough: Connect your tools.
Composes
This page is not a fork of the GL — it is a composition:
- Base: the six GL suite skills (
gl/core,gl/journal-entry,gl/period-close, …) — the system of record, unchanged. - Overlay:
gl/overlays/finserv— the additive diff above (the segregation-of-duties columns, thejournal_approvalstrail, the SoD hard gate over the balance gate, the close sign-off gate).
Read the base to learn the mechanics; this overlay is the small, industry-specific diff on top.