Skip to main content

Posting rules: the map

A posting rule answers one question: given this business document, which accounts move, in which direction, by how much? GreatBook answers it in three places, deliberately kept apart, and every one of them obeys the same law.

The three rule surfaces

They are separate because they are triggered by different things and reviewed by different people, not because they disagree about accounting. Where they touch the same fact they share code: the Bookkeeper’s post-back opens its AP obligation through the backend’s own settlement.record_obligation and writes its VAT register line through the backend’s own tax.record_vat_entry, rather than inserting rows of its own.
A parallel insert would drift the moment either side gained a column. Reusing the single writer per row type is what lets a bill that arrived as a scanned document and a bill raised through create_bill be read the same way by derive.obligation_due, by the AP-by-party index, and by close_checks.ap_control_tie.

The law: recognise, or refuse - never guess

Every rule surface refuses rather than defaults. This is the single most load-bearing convention in the posting layer, and each refusal below exists because the alternative balances and therefore passes every downstream check.
The suspense accounts are reserved for humans. 4900 (uncategorized income) and 6900 (uncategorized expense) exist, and a person may deliberately code something to them. No agent path may default to them - resolve_expense_account raises instead, and validate_adjustment_lines refuses any proposed line that names one, so the rule holds even for lines the Bookkeeper did not build itself.

Three things every posted line states

These are properties of the line, not of the document, and they hold on all three rule surfaces. A real account code. Resolved from the dual chart - the internal code, with its Circular 200 twin carried as a mapping. Its currency. Stated on every line, including a base-currency one, because _prepare_line reads line.currency or base_currency: an absent currency is not “unknown”, it is an assertion that the amount is already in the book’s own currency. Omitting it is what once posted a 1,000 USD bill as 1,000 dong. Its Tier-1 dimensions. activity and flow are required on every line; the writer raises DimensionRequiredError without them. The document path defaults activity to operating and derives flow as domestic or cross_border from whether the line’s currency equals the base currency, and carries the resolved party as source lineage when the match step found one.
No rule surface proposes an exchange_rate. The rate is the engine’s to resolve inside the posting transaction, as of the entry date. A rate read at propose time would be a second opinion about the same number, taken at a different moment, with nothing forcing the two to agree.

What the document path covers today, stated plainly

posting_rules.build_proposal implements creates_ap only. A supplier payable posts; any other accounting_effect raises UnmappedAccountError rather than guessing. That is a deliberate floor, not an oversight: the effects a document can carry are declared across 146 registry types, and each one reaches the ledger only once its rule is written and tested. Everything else in the rule catalogue - receipts, payments, the costing chain, payroll, adjustment notes, FX revaluation - is reachable through the sub-ledger operations, which are complete and tested, and through the Accountant’s adjustment path.

The catalogue

Rules are code, not configuration - on purpose

There is no data-driven posting-rules layer today. Which accounts a document type moves is a rule with ledger consequences, so it lives in deployed, tested code, and changing it is a deploy. What is editable as knowledge is the agent’s judgment: how the Clerk classifies a document among the 146 registry types, what counts as a supplier bill. That lives in the SOP store. The dividing line is drawn in full on SOP versus code.
The Bookkeeper’s SOP rulebook is generated from posting_rules, not retyped from it. A hand-maintained rulebook drifts, and a drifted rulebook is worse than none: the agent then cites, with a citation, a procedure the system no longer follows. A deploy that changes a posting rule must reseed the SOP datasets, or the agents keep describing the old entry.

Where it is enforced

The test that would fail if it broke

langgraph_chat/agents/bookkeeper/tests/ carries one file per rule, each named after the defect it pins: test_input_vat_split.py, test_ap_control_tie.py, test_fx_par_posting.py, test_trust_boundary.py, test_duplicate_double_post.py. Three of those files post the pre-fix shape and assert the old behaviour is still detectable - test_the_pre_fix_shape_leaves_the_control_account_untied, test_the_pre_fix_shape_leaves_input_vat_off_the_books, test_the_pre_fix_shape_posts_a_thousand_usd_as_a_thousand_dong. A test suite that can no longer see the bug cannot prove the fix.