# 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: gl
why: >-
a jurisdiction's code (S20, RC, Z) is a string the customer recognises; invoicing owns
the rates only until a ledger is installed, and then the ledger owns them.
invoice_id:
type: VARCHAR
owner: ar
why: derived from the source document, and a customer's own numbering is not always numeric
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
cost_centre:
type: VARCHAR
owner: gl
why: >-
finance's code, which a person recognises. hr (assignments, positions, org units) and
purchasing (requests) store it as a reference. Until gl carries cost centres (its known gaps
list dimensions), it is a code validated against nothing, and still a VARCHAR, so the join exists the day it does.
# ── 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
# ── 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]
payable: NUMERIC(18, 2) # e.g. hr_payroll_line.payable_amount: rate × quantity, rounded once
# ── 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
# 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:
brick-business-partner: { name: bp, brick: "brick_bp.application.use_cases:BP" }
brick-activity: { name: activity, brick: "brick_activity.application.use_cases:ACT" }
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" }
brick-hr: { name: hr, brick: "brick_hr.application.use_cases:HR" }
brick-contracts: { name: contract, brick: "brick_contracts.application.use_cases:CT" }
shared_tables:
- business_partner # written only by business-partner
- bp_role
- bp_edges
- tax_code # invoicing seeds it; the ledger owns it once installed
- 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.