brick-business-partner
The shared party master: one record per company or person you do business with, so sales,
invoicing and purchasing all mean the same customer. A brick on
erp-platform; spec modules/business-partner in the blog repo.
Every action is both an API route and an MCP tool.
What it does
| Story | How |
|---|---|
| BP-01 one record however many modules use it | a partner has roles — customer, supplier, carrier, your own — so the company you sell to and buy from is one partner |
| BP-02 importing the same company twice creates it once | bp_id is derived from your business key (bp_key: a customer number, tax id or domain), normalised, so a re-import lands on the same row |
| BP-03 subsidiaries linked to their parent | typed edges (subsidiary_of, branch_of, framework_governs); the family walk follows one type only, so a framework agreement never leaks into an ownership tree. A company has one parent of each kind; cycles are refused |
| BP-04 other modules join the shared list | promote_partner creates-or-reuses and adds a role; modules call it and never write these tables |
| BP-05 hr keeps a person's partner right, as its only writer | sync_person (POST /partners/persons/sync, bp:person:sync) with the person card {person_id, legal_name, preferred_name, work_email, work_phone, country, roles, works_for}: a partner of kind person, bp_key person:<person_id> (so hr knows the bp_id without asking), roles exactly employee / worker / supplier as given, and a works_for edge to the company a freelancer works through. A second identical call changes nothing. A private field (personal email or phone, home address, tax, bank, NI…) is refused — "a person partner carries a name and a work contact only" — and any other unknown field is refused by name, never dropped |
| BP-06 people are not visible to the whole company | without bp:person:read a person partner is absent from every list and family walk, and opening one is 404, never 403; with it, both kinds are listed. Organisations keep today's rule: every signed-in user reads them |
| BP-07 erasure reaches the shared record | sync_person(..., anonymise=true): the name and work contact become Erased person / empty; the bp_id, roles and links stay so other modules' records still join; a second call changes nothing, and a later sync of the same person is refused (a new engagement is a new person_id). The history records field NAMES only for persons, so it holds nothing an erasure would have to find |
A partner can be blocked (no new business — can_transact is false) or archived, always with a reason.
0.3.2 — runs on erp-platform 0.8.1 to 0.9.x (2026-10-18)
The platform range widens to <0.10, so bricks generated by erp-compose scaffold (erp-platform 0.9) can be composed with business partners. No behaviour change.
0.3.1 — on erp-platform 0.8.1 (2026-10-18)
The erp-platform range moves to >=0.8.1,<0.9 so a composition with erp-brick-hr 0.5.0 resolves; no code change.
Repo dodil/erp-brick-business-partner (renamed from dodil/brick-business-partner; the old URL still resolves).
0.3.0 — the CRM audit's wave 1 (2026-10-17)
| Finding | Now |
|---|---|
| C3 two stewards at once: one silently undid the other | a partner carries a version (GET returns it, and sends it as the ETag). upsert_partner, set_partner_status and remove_role take version — or an If-Match: "3" header — and answer 409 "changed since you opened it" when it is stale. Without one, a change that lost a race is re-run on the latest partner, so both land |
C4 bp_id past 2^53 rounded to another customer in JavaScript and in agents | every id leaves as a string of digits ("45665172796086509"), through both doors; it comes in as digits or a number. OpenAPI and the MCP schema say type: string, format: int64 |
| H12 a NUL in a name, a 5 000-character name or a 23-digit id was a 500 | bounded inputs (erp-platform 0.5 types): 422 naming the field. Names ≤ 200, keys ≤ 200, country 2, domain ≤ 253 |
| H14 a rep could make any company a supplier | with bp:partner:promote alone, promote_partner gives the customer role only; any other role needs bp:partner:write (seemo, 2026-10-17) |
Fixed against the reference package (blogs/code/erp-business-partner/v1)
- A re-import reset
statusto active, unblocking a blocked customer. Status now changes only throughset_partner_status. - A re-import without a tax id blanked the one on file. Fields left out are left as they are.
bp_keywas stored in whatever case it arrived in while the id was derived lower-case.- A 56-bit id collision between two different keys would have overwritten the other partner. Refused.
- Only self-parenting was checked; A → B → A and two parent companies are now refused.
promoteread CRM's own table. It takes the details in the call instead.created_atwas rewritten on every save (DataK3 tables#417; the platform repository works around it).
The id derivation is unchanged (blake2b, 56 bits), so ids already in a bucket stay valid.
Who
Every signed-in user may read organisation partners. Changing them needs bp:partner:write (the data
steward); family links need bp:family:write; other modules need bp:partner:promote — which gives the
customer role only; promoting to any other role needs bp:partner:write too. No identity → 401.
Person partners: written only with bp:person:sync (hr's service identity), read only with bp:person:read
(hr, contracts, finance). No steward or module permission writes one — "people are written by hr" (409 to a
caller who may see people, 404 to one who may not, so a write never confirms a person exists).
Tables
business_partner, bp_role (the Partner aggregate), bp_edges (the family, and works_for), bp_events
(history). The names are the spec's and the deployed package's. bp_id is BIGINT everywhere
(modules/shared.yaml).
0.2.0 migration (additive): business_partner gains kind, preferred_name, work_email, work_phone,
anonymised_at — at the end of its CREATE and as explicit ALTERs. A row from before has kind NULL and reads
as an organisation; an organisation's save never names the new columns. erp-migrate marks the ALTERs
DISRUPTIVE (reads on the table stall for minutes after an ADD COLUMN): migrate the test copy first, and the
live one at a quiet hour, before rolling the 0.2.0 image (0.2.0 reads the new columns).
0.3.0 migration (additive): business_partner.version (INTEGER, NULL reads as 0) at the end of the CREATE and
as an ALTER — DISRUPTIVE like 0.2.0's — and the claim table business_partner_versions. Migrate before rolling
0.3.0.
Run
pip install -e '.[test]' # needs erp-platform installed
pytest -q # rules + both doors on SQLite
BUCKET=<scratch> DODIL_ORG=<org> python scripts/live_check.py # the stories on DataK3
Reading ids through the CLI or an agent's data_pg: CLIs before 0.1.80 passed BIGINT through a float
in JSON output, so ids above 2^53 — nearly every bp_id — came back wrong (fixed in 0.1.80). On an older
CLI, read them with CAST(bp_id AS VARCHAR). The app's own driver was never affected.
Not in 0.3
Merging duplicates; contacts (people at a partner — the CRM's); addresses; a screen. Person partners carry the
version too, but sync_person takes none: hr is their single writer, and a sync that lost a race is re-run.
License: Apache-2.0, see LICENSE