Skip to main content

Settlement, rails and the AP/AR spine

Sub-ledger 1, migration 0003. 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 MissingCounterpartyError and posts nothing. obligations.party_code is NOT NULL, because an unattributable payable cannot be aged or chased.
  • A missing due date is not a refusal. The column is nullable, derive.obligation_due never reads it, and no creates_ap document schema carries payment terms. Recording an honest NULL beats inventing a date.
  • party_code is 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 as party_resolution in 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 migration 0048: 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 is null, 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-record 1.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.