# The vocabulary that crosses module boundaries.
#
# A module can be as opinionated as it likes INSIDE its own prefix. This file is the part it
# does not get to decide alone, because a second module has to agree: the keys that join two
# modules, the tables everyone shares, and how money is carried.
#
# It exists because the four-module suite run on 2026-09-18 found the same bug twice in a
# different costume — one name, two meanings, discovered at runtime:
#
# * `bp_id` is a BIGINT in the party master and a VARCHAR in invoicing, so the join the
# party master exists to provide fails outright rather than returning wrong rows;
# * `discount_pct` is a FRACTION in the CRM and a PERCENTAGE in invoicing, so a 10% discount
# handed across the seam bills as 0.10% and nothing errors.
#
# Neither is exotic. Both are what happens when two modules are built a month apart and each
# one is right on its own. `npm run check:packages` reads this file and says so before a
# customer does.
# ── keys that appear in more than one module ───────────────────────────────────────────────
# One type, everywhere. Changing one of these after a module has stored it is a data
# migration, not a rename — which is why they are declared before the second module lands.
keys:
bp_id:
type: BIGINT
owner: business-partner
not_to_be_confused_with: >-
bp_key — the customer's OWN identifier for a party (a domain, a legacy customer number,
a tax id), which is a VARCHAR and is not this. Invoicing named a bp_key column "bp_id"
and the two meanings ended up in one column, told apart only by whether they parse as a
number. A module that accepts an arbitrary customer key is storing a bp_key; say so.
why: >-
the party id is walked as a graph node, and graph_khop() takes an integer start node, so
the integer is the constraint and every other module carries it as one.
account_id:
type: BIGINT
owner: gl
why: the chart of accounts is walked for rollups, same reason as bp_id
journal_id:
type: BIGINT
owner: gl
why: derived by the caller from the source document, so a retry names the same journal
tax_code:
type: VARCHAR
owner: tax
why: >-
a jurisdiction's code (S, RC, Z) is a string the customer recognises. Owned by the tax module
(M1, 2026-10-18): before it, invoicing owned the rates until a ledger was installed, and then the
ledger did; now the ledger maps each code to an account (gl's account map) and owns neither the
code nor its rate.
invoice_id:
type: VARCHAR
owner: ar
why: derived from the source document, and a customer's own numbering is not always numeric
migrated_form: '"odoo:<db>:<account.move id>" (an Odoo migration names the row after its Odoo record, so a re-run matches it)'
person_id:
type: BIGINT
owner: hr
not_to_be_confused_with: >-
person_key, the VARCHAR it is derived from ('staff:<no>', else 'email:<normalised personal
email>'), and fixed once set. NOT the work email, which does not exist until the account is
provisioned. NOT the pool user's sub, which hr_person.pool_user_sub maps to it once the person is
invited to sign in. NOT bp_id: every person also has a business partner (bp_key 'person:<person_id>'),
and a module dealing with them as a PARTY (a contract counterparty, a supplier) stores the bp_id.
A column that holds a person under another name (manager_override_person_id, an owner) is still
this type.
why: >-
derived like bp_id (int.from_bytes(blake2b(person_key, digest_size=7))), so a double-submitted
hire or a re-import names the same person, and an integer, because a person is a graph node
(person, position, org unit) and graph_khop() takes an integer start node. Not employee_id: a
person may be a freelancer, and the same person may come back as either.
contract_id:
type: VARCHAR
owner: contracts
why: >-
derived by the caller from the work that asks for the contract (hr: the run step), so a retry
names the same contract; hr stores it on the run step that produced it and on the person's
filed document
product_id:
type: VARCHAR
owner: product
not_to_be_confused_with: >-
a quote or order LINE id (quote_line_id, so_line's (sales_order_id, line_no)): a line names a product
and copies its description, unit, tax category and invoicing policy at that moment.
why: >-
the customer's own code (a SKU: one variant of a product template; a product with no variants is its template's only product), fixed once set. brick-quote 0.2.0 already stores it as VARCHAR on
quote_lines and quote_prices, so the type is what exists.
price_list_id:
type: VARCHAR
owner: product
why: "the customer's own code for a list (LIST-GBP); brick-quote's price_book_id becomes it in the migration"
unit_code:
type: VARCHAR
owner: product
why: "UN/ECE Recommendation 20 (C62, HUR, DAY, MON): the code a UBL / Peppol invoice line carries"
tax_category:
type: VARCHAR
owner: tax
not_to_be_confused_with: >-
tax_code. A product carries a CATEGORY (standard, reduced, construction_service); the tax module turns
(category, the customer's treatment, the date) into a CODE. A product that stores a code bills a UK
consumer and an overseas business the same, which is wrong for one of them.
why: the product side of the tax rule, so products never carry a rate
sales_order_id:
type: VARCHAR
owner: sales-order
not_to_be_confused_with: >-
purchase_order_id (a PURCHASE order, owned by purchasing). Named sales_order_id so the two never share a
column name; and not the order NUMBER (SO-2026-0001), which is stamped at confirmation.
why: 'derived from its source ("quote:<quote_id>@v<version>"), so handing an accepted quote over twice names one order'
migrated_form: '"odoo:<db>:<sale.order id>" (an Odoo migration names the row after its Odoo record, so a re-run matches it)'
purchase_order_id:
type: VARCHAR
owner: purchasing
not_to_be_confused_with: >-
sales_order_id (a SALES order) and the order NUMBER (PO-2026-0001), which is stamped when the order is sent.
why: derived from its source document, so a retried or re-imported purchase order names one order (wave 2, 2026-10-22)
location_id:
type: VARCHAR
owner: inventory
why: >-
the customer's own code for a warehouse location (WH1/STOCK); purchasing names it on a receipt and
sales-order reads it on a reservation, so all three agree on one string (wave 2, 2026-10-22)
period_id:
type: VARCHAR
owner: gl
why: >-
'YYYY-MM', a fiscal period a person recognises; every brick that posts or closes a period names it
this way, so a close in gl and a cut-off in inventory or purchasing join on one value (wave 2, 2026-10-22)
company_code:
type: VARCHAR
owner: gl
rule: >-
1 to 4 upper-case letters or digits, the customer's own (UK01); fixed once a journal uses it. A bucket has ONE
default company, and a posting or read that names none lands in it, so a single-company bucket and every
built caller work unchanged (gl GET /companies/{company_code})
why: the legal entity a ledger belongs to; a short code finance recognises, carried by every posting brick (wave 2, 2026-10-22)
partner_company:
type: VARCHAR
owner: gl
not_to_be_confused_with: >-
trading_partner, the same value (the other company's company_code) as ar and purchasing name it on their own
documents and in their GET /intercompany/open query. On a gl journal line it is partner_company, and every
caller sends it under that name (SAP-scope, 2026-10-22).
why: the intercompany dimension on balances; consolidation eliminates by it
ledger_id:
type: VARCHAR
owner: gl
why: >-
a parallel ledger of one company ("IFRS", "LOCAL"; 2 to 8 upper-case letters or digits); a posting that names
none lands in every ledger of its company. fixed-assets posts one depreciation area per ledger (SAP-scope, 2026-10-22)
profit_centre:
type: VARCHAR
owner: gl
why: finance's code; derived on a journal line when the caller sends none, carried on balances (SAP-scope, 2026-10-22)
segment:
type: VARCHAR
owner: gl
why: finance's code (IFRS 8), derived from the profit centre; carried on balances (SAP-scope, 2026-10-22)
asset_id:
type: VARCHAR
owner: fixed-assets
why: >-
"<company_code>-<asset_no>-<sub_no>", stamped once from the asset class series (migrated:
"odoo:<db>:account.asset:<id>"); gl stores it on a journal line, inventory and purchasing name it on a
capitalised receipt (SAP-scope, 2026-10-22)
warehouse_id:
type: VARCHAR
owner: inventory
why: the customer's own short code (WH1), fixed once set; product's site_id and sales-order's lines name it (SAP-scope, 2026-10-22)
lot_code:
type: VARCHAR
owner: inventory
why: a batch, keyed (product_id, lot_code); purchasing and sales-order name it on receipts, deliveries and returns (SAP-scope, 2026-10-22)
serial:
type: VARCHAR
owner: inventory
why: a serial number of one tracked unit, unique per product; named on receipts, deliveries, returns and assets (SAP-scope, 2026-10-22)
order_type_code:
type: VARCHAR
owner: sales-order
why: the sales document type (STANDARD, RUSH, RETURNS, CONS_FILL, ...), fixed once confirmed; inventory reads it on delivery (SAP-scope, 2026-10-22)
rebate_agreement_id:
type: VARCHAR
owner: sales-order
why: 'RB-{yyyy}-{bp_id} or the customer''s own code; ar carries it on a rebate settlement credit (SAP-scope, 2026-10-22)'
rma_ref:
type: VARCHAR
owner: sales-order
why: '"so:{sales_order_id}", the returns authorisation inventory receives a customer return against (SAP-scope, 2026-10-22)'
product_type_code:
type: VARCHAR
owner: product
why: the material type (TRADING, FINISHED, RAW, SERVICE, NONSTOCK, PACKAGING, or the customer's own); decides kind and numbering (SAP-scope, 2026-10-22)
valuation_class:
type: VARCHAR
owner: product
why: the account-determination class on a product; inventory's account map is keyed on it (SAP-scope, 2026-10-22)
payment_id:
type: VARCHAR
owner: payments
not_to_be_confused_with: >-
a bank's own reference (bank_reference), which is an attribute; and supplier payments, which belong to
purchasing.
why: 'derived from where the money came from ("bank:<account>:<statement entry>", "provider:<p>:<ref>"), so a re-import is idempotent'
migrated_form: '"odoo:<db>:<model>:<id>" (an Odoo migration names the row after its Odoo record, so a re-run matches it)'
shop_id:
type: VARCHAR
owner: pos
why: "the customer's own code for a shop (LON-SOHO); payments names it on a cash bag or card settlement, reports on sales (wave 3 draft, 2026-10-25)"
receipt_id:
type: VARCHAR
owner: pos
why: '"<shop_id>-<register_id>-<session_no>-<receipt_no>", minted on the register so an offline sale needs no server; inventory names it on a consumption move ref, tax on a document_ref (wave 3 draft, 2026-10-25)'
migrated_form: '"odoo:<db>:pos.order:<id>"'
bom_id:
type: VARCHAR
owner: manufacturing
why: '"<product_id>:<usage>[:<alternative>]" (ROAST-ETH:production, LATTE-REG:sale); pos freezes it with its version on a receipt line (wave 3 draft, 2026-10-25)'
production_order_id:
type: VARCHAR
owner: manufacturing
why: '"<company_code>-MO-<yyyy>-<nnnnn>"; inventory names it on issue and receipt moves, gl on WIP and variance lines (wave 3 draft, 2026-10-25)'
migrated_form: '"odoo:<db>:mrp.production:<id>"'
so_line_no:
type: INTEGER
owner: sales-order
why: >-
an order line is keyed (sales_order_id, line_no) on so_line; ar's invoice lines store both as
(sales_order_id, so_line_no) so invoiced quantities reconcile against the order (M1, 2026-10-22)
project_id:
type: VARCHAR
owner: projects
why: '"pj:<source_kind>:<source_id>" (pj:sales_order:<sales_order_id>, pj:contract:<contract_id>, pj:manual:<uuid>), derived from the sale; timesheets stores it on a line, ar in a timesheet source_id (wave 4 draft, 2026-10-25)'
sheet_id:
type: VARCHAR
owner: timesheets
why: '"<person_id>:<iso week>" (4410:2026-W47) with a revision; sales-order stores it inside delivery_ref "ts:<sheet_id>:r<rev>:<line_no>", payroll and time-off on what they derived from it (wave 4 draft, 2026-10-25)'
pay_run_id:
type: VARCHAR
owner: payroll
why: '"<company_code>-<schedule>-<tax year>-<period>" (UK01-M-2026-27-M08); payments stores it as a payout batch_ref and in payout_ref "prl:<pay_run_id>:<person_id>:net", gl in source_document "prl:<pay_run_id>", hr as the sent_ref of its extract (wave 4 draft, 2026-10-25)'
tax_year:
type: VARCHAR
owner: payroll
why: "the UK tax year as HMRC writes it, '2026-27' (6 April to 5 April); not period_id, which is gl's fiscal month (wave 4 draft, 2026-10-25)"
absence_ref:
type: VARCHAR
owner: time-off
why: '"<company_code>-TOF-<yyyy>-<nnnnn>", one yearly series for requests and recorded absences (migrated "odoo:<db>:hr.leave:<id>"); payroll stores it on a payslip line, timesheets on an absence day (wave 4 draft, 2026-10-25)'
leave_type_code:
type: VARCHAR
owner: time-off
why: "the customer's code (ANNUAL, SICK, UNPAID, MATERNITY); payroll maps its pay_treatment, never the code, to pay elements (wave 4 draft, 2026-10-25)"
currency:
type: VARCHAR
owner: none (ISO 4217)
why: >-
always three letters, the ISO 4217 alphabetic code (GBP, EUR, JPY); its minor unit (2 for GBP, 0 for JPY, 3 for BHD) decides
the rounding of a paid amount. Amounts are never summed across currencies.
cost_centre:
type: VARCHAR
owner: gl
why: >-
finance's code, which a person recognises. hr (assignments, positions, org units) and
purchasing (requests) and inventory (goods issues) store it as a reference, validated through gl's
GET /cost-centres/{cost_centre}.
# ── units, where the name does not carry them ──────────────────────────────────────────────
# A column called `discount_pct` reads as a percentage to everyone except the module that
# wrote it as a fraction. Say which, once.
units:
discount_pct: percent # 18 means 18% — a module storing 0.18 must convert at its seam
tax_rate: percent
# ── units of measure (wave 2, 2026-10-22: SAP MARM / MEAN / order price unit / condition pricing unit) ──
# Every module that writes a quantity of a product follows this, so a pack bought, a bottle sold and a litre
# counted are one number in the product's base unit, and a price per 100 never loses a decimal.
uom:
unit_code: "UN/ECE Rec 20 (C62 one, KGM, LTR, MTR, HUR), or the customer's own pack code (PK24) carrying an einvoice_unit_code; keys.unit_code"
conversion: >-
to the product's BASE unit as an exact fraction of two positive integers: quantity_base = quantity x
numerator / denominator. Never a decimal factor, never a float. The product module is the one source
(GET /products/{product_id}/convert); a document line COPIES unit_code, unit_numerator, unit_denominator
at line time and never asks again. A conversion a document or stock has used is frozen; a new pack size is
a new unit_code.
quantity: { sql: "NUMERIC(19, 4)", json: string, rule: "rounded only to the unit's quantity_decimals; a quantity that does not convert exactly into the base unit's decimals is refused, never rounded" }
measures: { sql: "NUMERIC(19, 6)", json: string, columns: [gross_weight, net_weight, volume, length, width, height], rule: "each with its unit (weight_unit_code: a mass unit, volume_unit_code: a volume unit, dimension_unit_code: a length unit); stated PER ONE of the product unit they sit on (one PK24 weighs 19.2 KGM); a document copies them at line time; never name a weight or volume column *_total (check:packages reads 'total' as money)" }
roles: [base, sales, purchase, issue, production, warehouse, delivery] # product.<role>_unit_code; issue = consumption, warehouse = handling and counts, delivery = the multiple a delivery is in
rounding: "per (product, role): min_quantity and rounding_multiple, exact, in the unit the rule was written in (prod_unit_role, inherited template -> SKU like every per-unit row); the product module answers whether a quantity meets them (GET /products/{product_id}/rounding); the CALLING module decides what an unmet rule does (a gate, warn or block)"
pricing: >-
a price is per PRICING_QUANTITY of a PRICING UNIT (2.4500 per 100 C62): a line carries pricing_unit_code,
pricing_numerator, pricing_denominator (the pricing unit to the base unit, copied) and pricing_quantity (a
positive whole number, default 1) beside its own unit. quantity_priced = quantity_base x pricing_denominator /
pricing_numerator (exact); the line amount is quantity_priced x unit_price / pricing_quantity, less any
discount, rounded ONCE to the currency's minor unit (half up). The pricing unit may differ from the line's
unit: purchasing's ORDER PRICE UNIT (10 pieces priced per kg) and a price row kept in another unit.
reserved_until_m7: "catch_quantity, catch_unit_code (a parallel unit recorded per move, catch weight) and lot_numerator, lot_denominator (a lot's own conversion): columns reserved and NULL until milestone M7 (inventory deferred: catch_weight, batch_uom); no route accepts them before then"
# ── money ──────────────────────────────────────────────────────────────────────────────────
money:
sql: NUMERIC(18, 2)
json: string # exact on the wire; a float re-introduces the drift we avoid
columns_matching: "amount|total|price|balance|debit|credit|subtotal|cost"
# a name can contain a money word and hold nothing of the kind: cost_centre is a label,
# price_book_id is an identifier. Suffixes are how they are told apart.
not_money_suffixes: ["_id", "_no", "_code", "_key", "_book", "_pct", "_centre", "_center", "_mode", "_ref", "_by", "_on", "_at"] # _on/_at: dates (priced_on)
also_not_money: ["normal_balance"]
# ONE exception, and it is precise. A RATE or a per-unit amount, which is multiplied before anything
# is paid (an hourly or day rate, a salary per basis, a pay component per unit), is NUMERIC(19, 4):
# 13.3333 an hour is a real rate, and two decimals would bill a 37.5-hour week 0.12 short. What is
# PAID or POSTED (a payable line, an invoice total, a journal amount) is still NUMERIC(18, 2), rounded
# ONCE, at that line, to the currency's minor unit. Name rate columns rate_amount and paid ones
# *_amount / total, so a reader can tell them apart. On the wire both are strings.
# (check:packages enforces NUMERIC for every money column; it does not check the precision.)
rates:
sql: NUMERIC(19, 4)
columns: [rate_amount, unit_price, unit_cost] # unit_price: a price per unit on a price row, order or invoice line (M1); unit_cost: a stock or purchase cost per unit (wave 2); the line value is NUMERIC(18, 2)
per_quantity: >-
unit_price and unit_cost are per pricing_quantity of pricing_unit_code (units.uom.pricing), default 1 of the
line's own unit. A price per 100 or per 1000 is how a 0.0012345 piece price stays exact in four decimals:
never divide it down to a per-piece rate and store that. pricing_quantity is a count, not money.
payable: NUMERIC(18, 2) # e.g. hr_payroll_line.payable_amount: rate × quantity, rounded once
fx:
sql: NUMERIC(19, 6) # an exchange rate, not money: units of `to` for one `from` (1 GBP = 1.172345 EUR)
rule: a converted amount is rounded once, to the target currency's minor unit, at the line that is posted
# ── table ownership ────────────────────────────────────────────────────────────────────────
# The naming IS the contract: a prefix says "this module owns it", no prefix says "this is
# shared master data and exactly one module writes it". A module-owned table sitting
# unprefixed is a collision waiting for the module that has not been written yet.
prefixes:
business-partner: "" # owns the shared party master; see shared_tables
crm: crm_
ar: ar_
gl: gl_
purchasing: pur_ # the sixth module, prefixed from its first table
hire: hire_ # declared before the module is built — that is the point
hr: hr_ # people, starters & leavers; declared with its spec (2026-10-17)
contracts: ct_ # contracts & e-signature; short, so the root table is ct_contract, not contract_contracts
product: prod_ # products & price lists (M1); the product master itself is the shared table `product`
tax: tax_ # tax (M1); `tax_code` and `tax_category` are its shared tables
sales-order: so_ # sales orders (M1)
payments: pay_ # receipts, allocation, refunds, collections (M1)
inventory: inv_ # stock, locations, moves, reservations (wave 2)
consolidation: cons_ # group consolidation (SAP-scope, 2026-10-22)
fixed-assets: fa_ # asset accounting (SAP-scope, 2026-10-22)
pos: pos_ # point of sale (wave 3 draft, 2026-10-25)
expenses: exp_ # expense claims (wave 3 draft, 2026-10-25)
manufacturing: mfg_ # bills of materials, production orders (wave 3 draft, 2026-10-25)
time-off: tof_ # leave and absence (wave 4 draft, 2026-10-25)
timesheets: ts_ # weekly timesheets, labour cost, time billing (wave 4 draft, 2026-10-25)
payroll: prl_ # UK payroll, RTI, payslips (wave 4 draft, 2026-10-25)
# Table prefixes per BRICK REPO (reference.repos). A brick repo is one module's code split into
# bricks, each owning its own prefix, so the module prefix above does not apply to it. A repo not
# listed here skips the prefix rule. `business_partner` is the one shared table a prefix cannot name.
repo_prefixes:
brick-business-partner: [bp_, business_partner]
brick-activity: [activity_]
brick-pipeline: [pipeline_]
brick-quote: [quote_]
brick-lead: [lead_]
brick-hr: [hr_]
brick-contracts: [ct_, contract_capability_]
# Each brick repo's entry in a compose.yaml: the member name and the Brick object
# erp_platform.compose loads ("package.module:OBJECT"). `erp-compose init` writes compose.yaml
# from these, so the format is never retyped: in the lab (2026-10-17) a model that found and
# cloned the right bricks invented its own compose.yaml format and the composition never loaded.
# scripts/check.py: every brick repo in any module's reference.repos has an entry here.
brick_entries:
# Keyed by the brick's distribution name (= the reference.repos name). `name` is the brick's registered
# Brick("<name>") AT THE TAG THE MODULES PIN (conventions.module_names: the module id; erp-compose check refuses a
# member whose name differs from its Brick's). `brick` imports the module that registers every use case.
erp-brick-sales-order: { name: sales-order, brick: "brick_sales_order.application.use_cases:SO" }
erp-brick-product: { name: product, brick: "brick_product.application.use_cases:PRODUCT" }
brick-business-partner: { name: business-partner, brick: "brick_bp.application.use_cases:BP", process_view: "none: master data only, no lifecycle to sign" }
erp-brick-ar: { name: ar, brick: "brick_ar.application.use_cases:AR" }
erp-brick-gl: { name: gl, brick: "brick_gl.application.use_cases:GL" }
erp-brick-tax: { name: tax, brick: "brick_tax.application.use_cases:TAX" }
erp-brick-payments: { name: payments, brick: "brick_payments.application.use_cases:PAY" }
erp-brick-inventory: { name: inventory, brick: "brick_inventory.application.use_cases:INV" }
erp-brick-purchasing: { name: purchasing, brick: "brick_purchasing.application.use_cases:PURCHASING" }
brick-consolidation: { name: consolidation, brick: "brick_consolidation.application.use_cases:CONS" }
erp-brick-fixed-assets: { name: fixed-assets, brick: "brick_fixed_assets.application.use_cases:FA" }
erp-brick-pos: { name: pos, brick: "brick_pos.application.use_cases:POS" }
erp-brick-expenses: { name: expenses, brick: "brick_expenses.application.use_cases:EXP" }
erp-brick-manufacturing: { name: mfg, brick: "brick_manufacturing.application.use_cases:MFG" } # retiring: Brick("mfg") -> manufacturing on its next release
erp-brick-timesheets: { name: timesheets, brick: "brick_timesheets.application.use_cases:TS" }
brick-time-off: { name: time-off, brick: "brick_time_off.application.use_cases:TOF" }
erp-brick-payroll: { name: payroll, brick: "brick_payroll.application.use_cases:PRL" }
brick-hr: { name: hr, brick: "brick_hr.application.use_cases:HR" }
brick-contracts: { name: contract, brick: "brick_contracts.application.use_cases:CT" } # v0.1.x registers "contract"; main is "contracts" (next release)
brick-projects: { name: pj, brick: "brick_projects.application.use_cases:PJ" } # retiring: pj -> projects on its next release
brick-activity: { name: activity, brick: "brick_activity.application.use_cases:ACT" } # crm bricks, not modules: keep their names
brick-pipeline: { name: pipeline, brick: "brick_pipeline.application.use_cases:PIPE" }
brick-quote: { name: quote, brick: "brick_quote.application.use_cases:QUOTE" }
brick-lead: { name: lead, brick: "brick_lead.application.use_cases:LEAD" }
shared_tables:
- business_partner # written only by business-partner
- bp_role
- bp_edges
- tax_code # written only by tax (M1); the ledger maps codes to accounts
- tax_category # written only by tax; product stores it on each product
- product # written only by product; quote, sales-order, ar and purchasing read it
- platform_meta # what this bucket IS — written once by migrate.py --stamp,
# read by db.py before the first query. Platform layer, not
# a module's: every app in the bucket reads the same row.
# The top of every module spec's `code:` block (the blog renders spec.yaml from this repo; lib/modules.js reads
# index.json `code_entry`). In the lab (2026-10-18) Sonnet started from dodil://erp/modules/hr/spec.yaml, never
# read the build map or the show card, and described the process in prose: so the FIRST line an agent reads names
# the map and the first two commands. {id} / {ID} / {init} are filled per module; a module's own `start_here`
# (module.yaml) wins over the template.
code_entry:
platform_tag: v0.9.13
start_here: >-
START HERE: read dodil://erp/build (the map) first; this spec is a vocabulary, not the method. Then, in the
project folder: (1) {init}; (2) SHOW the customer the diagram — `erp-compose process --module {id} --playbook
<playbook>` for a brick with playbooks (hr), else `erp-compose bpmn --from ERP_{ID}.md --out processes/{id}.bpmn`
— and paste its step names, in order with who does each, into your message. The signed artifact is the BPMN:
never prose, never "open bpmn.io".
# ── conventions every brick follows (M1 Lead-to-Cash, 2026-10-22) ─────────────────────────────
conventions:
business_timezone:
rule: >-
ONE value per bucket. "Today" in every brick is the date in this zone; no brick keeps its own copy,
because two todays disagree at midnight. Stored once beside platform_meta and changed only by a
person holding an owner's <ns>:settings:controls in any brick; each brick reads it, none writes its own.
owner: platform (a single bucket-level control, erp-platform #5)
status: >-
resolved 2026-10-22: no module spec carries its own business_timezone config row any more (ar, gl, inventory,
payments, product and tax removed theirs); each reads this one, and its seams say so
functional_currency:
owner: gl
rule: >-
per company_code, a dated control in gl (fixed once a journal uses it); every other module reads it through
gl's GET /companies/{company_code} and keeps no row of its own
readiness:
shape: "{gate, met, reason, fix}"
rule: >-
a transition with preconditions answers a list of these; met is computed at read time, reason says why
not in the customer's words, fix names the action (and who can take it). A brick asking another brick
whether it may proceed (sales-order -> payments credit_ok, ar -> tax) reads this list, never its tables.
odoo_migration:
id_map: >-
every brick that migrates keeps a <prefix>_migration_map of (odoo db, odoo model, odoo id) -> our natural
key, written by its <ns>:migration:* machine permission; later bricks resolve Odoo ids through the
earlier brick's map, never by matching names.
map_route: >-
the ONE route every migrating brick offers for it: GET /migration-map/{db}/{model}?ids=<comma-separated odoo ids>
-> [{odoo_model, odoo_id, ours}], where ours is this brick's natural key; an id the brick has not migrated is
omitted (never null, never an error), so the caller sees exactly what is missing. Every spec's seams name this
form and no other (wave 2, 2026-10-22).
order: [business-partner, product, tax, gl, sales-order, ar, payments, purchasing, inventory, fixed-assets, consolidation]
payment_terms: >-
resolved 2026-10-22: sales-order writes ar's own payment-terms key odoo:<id> (the o2c choice); there is no
separate "ar.terms" slice. tax, ar and payments read terms through that key.
rule: >-
a brick's slice runs only after the bricks it maps through; history is read-only and never re-priced or
recomputed; the reconciliation is signed by the customer before go-live (erp-roadmap #57).
module_names:
rule: >-
A module is named by its module id (the folder under modules/, the `id:` of its module.yaml) and by nothing
else: in every seam's `with` and `owner`, in a brick's registered name (Brick("<id>")), in a compose.yaml
member name, in source_module on a journal and in an event's prefix. The id keeps its hyphen
(sales-order, business-partner, fixed-assets); where a name must be an identifier (an environment variable, a
tool prefix) the hyphen becomes an underscore and nothing else changes (SALES_ORDER_BASE_URL, sales_order_).
why: >-
the wave-2 end-to-end wiring (2026-10-25, the procure-to-pay harness) composed bricks registered as inv, pay
and so beside purchasing's seams to inventory and payments: erp-compose matches a seam to a member by name, so
every seam between them read ABSENT and nothing was wired. Short names are a second vocabulary nobody signs.
retiring: >-
the short names still registered by built bricks (inv -> inventory, pay -> payments, so -> sales-order,
bp -> business-partner, pj -> projects, contract -> contracts). Each brick renames on its next release, and its
brick_entries row below changes in the same change (erp-compose check refuses a member whose name differs from
its Brick's, so the row follows the brick, never leads it). activity, pipeline, quote and lead are bricks of the
crm module, not modules, and keep their names. A ref prefix inside an id (move_ref "pur:<receipt_id>:<line_no>",
request_ref "inv:repl:...") is part of a key that is already stored, not a module name, and does not change.
tool_names:
rule: >-
An MCP tool name is unique across a composition: one composition serves ONE MCP endpoint, so two bricks
offering the same tool name cannot both be composed (erp-compose check: "tool X is offered by both A and B").
A tool is its use case's name (erp-platform serves the use case under its function name), so the rule is on
use-case names, and it is the method's existing one (purchasing v2: "use-case names are unique across the
method"): a use case whose verb and object could exist in another module names its module's noun between
them, <verb>_<noun>_<object>, as the specs' transition keys already do (sign_purchasing_migration,
accept_tax_suggestion, cancel_draft_invoice, reconcile_gl_migration). The noun is the one the module's spec
already uses in that family (invoice for ar's migration, cons for consolidation, assets for fixed-assets, exp,
mfg, pos), else the module id with underscores (ar, gl, inventory, purchasing, payments). It applies to every
use case of these families in every module, whether or not a second module has one yet: account maps
(get / set / list_<noun>_account_map), migration (load_<noun>_from_odoo, reconcile / explain / sign / discard /
reload_<noun>_migration, list_<noun>_migration_runs, get_<noun>_migration_run, get_<noun>_migration_map,
get_<noun>_migration_status), AI suggestions (accept / reject_<noun>_suggestion) and drafts
(cancel_draft_<object>). A use case named for the module's own business object (accept_quote, approve_bill)
is unique already. No signed transition key is renamed by this rule: the specs were unique; the bricks drifted.
why: >-
the wave-2 end-to-end wiring (2026-10-25) found set_account_map and get_account_map in ar, gl, inventory and
purchasing; list/get_migration_run(s) in ar, gl and consolidation; get_migration_map in gl and fixed-assets;
accept_suggestion in sales-order and tax; cancel_draft in ar and sales-order; reconcile_odoo in purchasing and
tax; sign_migration in payments and purchasing. No two of them could be composed together.
where: >-
each module.yaml lists the names under `tool_names:` ({tool, route, was}: the name its brick must serve, the
route it is served on, and the colliding name a built brick uses today); the route and the permission do not
change, only the use case's (the tool's) name.
address:
shape: >-
{lines: [string] (1 to 4 lines, each at most 70 characters: building, street, locality), city (string, 1 to
70), postcode (string or null, at most 16; null only where the country has none), region (string or null, at
most 70: county, state, province), country (ISO 3166-1 alpha-2, upper case: GB, DE, FR)}
rule: >-
ONE postal address shape on every wire between modules (a ship-to on a sales order and the purchase request it
raises, a supplier's address on a bill sent to tax's content check, a delivery address). It is an object,
never a single string: a string cannot be told apart into the country that decides the place of supply.
A module stores it as columns <name>_lines (JSON array), <name>_city, <name>_postcode, <name>_region,
<name>_country, or as one JSON column <name>; the wire shape is the same either way.
why: >-
the wave-2 end-to-end wiring (2026-10-25): sales-order sent ship_to_address as an object and purchasing
expected a string, so a drop-ship request was refused at intake.
legacy: >-
a string received where this shape is expected is accepted until each module's next major version as
{lines: [the string], city: "", postcode: null, region: null, country: the document's country}, and never
sent again.