Instructions for agents working on this brick
Built from the gl module spec (erp-method modules/gl/module.yaml, version 3, signed 2026-10-22). Read erp-platform's
BRICKS.md before changing anything: the rules and why each one exists. The brick is the SOLE writer of journals; every module that
posts money (ar, payments, tax, purchasing, inventory, fixed-assets) posts through POST /journal-entry/journals.
Where a change goes
| You are changing... | It goes in |
|---|---|
| what makes a journal valid (balance, purposes, dimensions, field status) | domain/journal.py and its test in tests/test_posting.py |
| how a line is split by profit centre and segment | domain/splitting.py (tests/test_dimensions.py) |
| how a share is cut | domain/money.py largest_remainder (never a float) |
| the one write path (number, chain, lines, split lines, period guard) | application/ledgerops.py write_posted — nothing else writes a journal |
| the carry-forward | application/carry.py (derived, never posted) |
| the close gates | application/gates.py (the cockpit and get_period_readiness read the same list) |
| who may, and which rows | the permission on the declaration + brick.company_row / can_see (a company outside the caller's access is 404) |
| another module's route | infrastructure/seams.py only, shaped from THEIR spec route; tests use tests/fakes.py |
| the Odoo reader | infrastructure/odoo.py (a port); tests/fake_odoo.py computes Odoo's OWN aggregates from its rows |
| a column or table | infrastructure/tables.py, then python scripts/gen_schema.py (schema.sql + migration 00001), and a NEW migration for a later release (additive only) |
Never add an HTTP route or an MCP tool by hand. Never import SQLAlchemy, FastAPI or MCP in domain/. Never write another brick's tables.
A use case is named as its workflow transition in the spec (tests/test_process.py fails if one is missing).
The rules that must not be undone
- A journal is never edited or deleted. A correction is a reversing journal keyed
derived_id(journal_id, "reversal"); its id is the claim, so reversing twice (or twice at once) posts one. The original readsreversed; neither is deleted. - Source postings are idempotent by source.
journal_id = derived_id(source_document, event[, ledger_id]); the same body again answers the journal (replay), a different body is409 journal_conflict, two callers racing for one id land one (a plain INSERT of the key). - Claims are plain INSERTs (
gl_number_series,gl_chain_claim, the journal id,gl_recurring_posting,gl_fx_reval_run): the loser's whole transaction is rolled back andbrick.claims()runs it again against the committed state. Never an upsert on a claim. - No read-your-writes inside a transaction. Lines are written, committed, THEN balances are re-derived in a second transaction
(
derive_balances, a SUM). A use case that posts two journals of ONE company in one transaction must not claim a number twice. - The period lock is a guard on the write path:
assert_period_still_openupdates the period rowWHERE status='open'in the posting's own transaction; a close that won the race leaves no row to update and the posting is refused. Do not read the period and trust it. - Balances, trial balances and the carry-forward are derived from split lines with SUM and never typed or incremented. A common journal
(
ledger_idNULL) counts in every ledger of its company; a ledger-specific one only in its own. - A dated row starts after today (account map, controls, group mapping, alternative numbers, company access, segment assignments); the FIRST row of a key may carry any date (the day the thing began). An FX rate is dated today or earlier and never changed.
- Second person = email AND sub (
brick.same_person): approve_manual, approve_recurring, approve_accrual, approve_allocation_cycle, approve_budget, sign_off_close, reopen_period, accept_ic_difference. - A control is the owner's (
set_gl_control,gl:settings:controls);set_gl_settingrefuses one. Nobody holds a control they control. - 'Today' is the business date in
business_timezone(brick.today, read through one function), neverdate.today()or UTC. - Shape is checked after the permission. Doors check size only;
brick._checkedvalidates codes, periods, years and dates inside the use case, so an unauthorised caller is 403, never told what is wrong with their body (the generated probes depend on it). - History is read-only. A migrated journal equals Odoo's move to the penny, is written without the period guard, keeps Odoo's entry number, and is never rewritten; a signed run is frozen; a later run only adds what Odoo posted after it.
- AI steps only suggest (
ai.py): findings are listed with evidence, a person accepts or dismisses each; nothing changes a journal.
Before you commit
pytest -q --tb=short # rules, both doors, access, concurrency, probes, process, schema, composition, migration
ruff check src tests
python scripts/gen_schema.py --check
erp-compose check tests/tier1.compose.yaml --strict --roles src/brick_gl/roles.yaml