Settlement, rails and the AP/AR spine
Sub-ledger 1, migration0003.
It is the largest of the seven and the one every other family eventually settles through.
What it records
Every one of these posts its journal entry through the single writer in the same transaction.
The account legs
Straight from the module docstring, which is the authoritative statement of them:The rails
A rail is a channel money actually moves through, and it reconciles against its own source rather than against one global cash figure.
The discipline is per-rail, per-currency, never one global cash check.
A single “cash reconciles” test passes while a Vietnamese bank shortfall is offset by a Hong Kong surplus, which is two errors reported as no errors.
Nine rails times their currencies gives the Close list a reconciliation per channel, each against evidence that exists independently of the book.
The single obligation writer
This is the most important rule on the page, and it was learned from a live failure.A control-account leg and its sub-ledger row are one fact. Never post one without the other.
settlement.record_obligation is the single writer of the obligations table.
Every path that credits AP control 2000 or debits AR control 1100 goes through it:
The third row is the fix.
Before it, the Bookkeeper credited AP
2000 through the ledger writer, and that path never called settlement.py.
So every supplier bill that entered as a document was invisible to the AP sub-ledger, and ap_control_tie could never tie while intake postings existed.
It was found by the first live Close run, which is the correct way for that class of defect to be found: a reconciliation that reports rather than refuses, run against a real book, naming the exact accounts that do not explain themselves.
The obligation the agent path opens is derived in
posting_rules.build_proposal from the same amount that built the control leg, so the sub-ledger row and the GL credit cannot disagree about what the document said.
It reaches the writer through graph.trusted_record, never through checkpoint state, so it inherits the posting mandate’s trust exactly as the lines do.What it refuses, and what it does not
- A payable naming no supplier raises
MissingCounterpartyErrorand posts nothing.obligations.party_codeisNOT NULL, because an unattributable payable cannot be aged or chased. - A missing due date is not a refusal. The column is nullable,
derive.obligation_duenever reads it, and nocreates_apdocument schema carries payment terms. Recording an honest NULL beats inventing a date. party_codeis the resolved party-master id when the counterparty resolves, and the extracted supplier name when it does not. So the rule narrows the one-supplier-two-keys split rather than closing it. The outcome is recorded asparty_resolutionin the posting audit detail, and like the obligation id it is never read back as an input.
Replay-safe source identity
Obligations now carry the durable journal source tuple introduced by migration0048: organization, source type, source id and entry set, linked to the opening journal entry.
That identity follows the same idempotency authority as the control-account write rather than guessing from a human document number.
An exact replay returns the existing obligation.
A replay under the same source identity with different accounting facts conflicts instead of inserting a second row or silently adopting the new values.
Two genuine documents that quote the same supplier bill number remain distinct when their source identities differ.
AP aging reads obligations, not journal descriptions
The AP aging surface is obligations-authoritative. Each row begins with the durable obligation identity, counterparty-resolution state, document and nullable due date, native/base opening amounts and opening journal link. Allocations, advance applications and adjustment notes derive settlement components and the open balance; journal rows contribute exact event/control evidence rather than acting as the payable inventory. All money remains fixed-point strings, and an amount that cannot be established isnull, never zero.
If two obligations share a human document number and later cash or adjustment activity cannot be assigned to one exact obligation, GreatBook keeps both rows but reports their affected balances inconclusive instead of applying the activity twice.
The book-wide reconciliation compares obligations-derived AP with control account 2000 in base currency and retains complete, partial and inconclusive coverage.
Pre-fix live rows.
Payables the Bookkeeper posted before it opened obligations carry no obligations row and still fail the AP control tie until a backfill or a book reset.
That is a data decision, not a code one.
Capability and MCP status
The 116-record1.14.1 release-source registry describes obligations-authoritative AP aging as the current outcome greatbook.ap.aging.read@1.
Registry 1.14.1 releases the dedicated read-only greatbook_ap_aging_read adapter as well as the shared Books tools for Settlement registers and tie-outs.
The two reads remain different contracts, and both are part of the live public MCP inventory at registry 1.14.1.
Neither read creates or changes an obligation, allocation, settlement, or payment.
Where it is enforced
The test that would fail if it broke
backend/tests/test_settlement.py covers the operations and their legs.
backend/tests/test_obligation_idempotency.py covers exact replay, conflicts and distinct same-number obligations.
langgraph_chat/devcenter/tests/test_ap_aging.py covers obligations-derived balances, exact evidence, ambiguity, pagination and base-currency reconciliation.
The regression for the obligation fix is langgraph_chat/agents/bookkeeper/tests/test_ap_control_tie.py, and it has an unusual shape worth naming: test_the_pre_fix_shape_leaves_the_control_account_untied posts the old shape and asserts the tie fails.
That means the file cannot pass against a check that has quietly stopped checking, which is the failure mode a passing assertion alone cannot catch.
What goes wrong without it
An untied control account is a book that says you owe 2.4 billion dong and cannot tell you to whom. Every aging report, every supplier statement reconciliation, every cash-flow forecast is built on a total that nothing itemises. And because the GL entry balanced, nothing refused it. This is exactly the class of defect that Close checksums exist for and GL invariants cannot catch: the entry was internally valid and externally unexplainable.Related
- The seven sub-ledgers - how the families interlock
- The Close checksums -
AP-CONTROL, the per-rail reconciliations, the cross-foot - Nothing stored that can be derived - why there is no
duecolumn - The Bookkeeper - the document path that opens obligations