dodil/erp-method

modules/shared.yaml

# 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.