# Business partner — the module source. See modules/crm/module.yaml for the format.
start_here: >-
START HERE, before reading on: dodil://erp/build is the map (read it first; this spec is a vocabulary,
not the method). Then, in the project folder, two commands: (1) `uvx --from git+https://git.dodil.io/dodil/erp-platform.git@v0.8.2 erp-compose init business-partner`;
(2) SHOW the customer the diagram: `uvx --from git+https://git.dodil.io/dodil/erp-platform.git@v0.8.2 erp-compose bpmn --from ERP_BUSINESS_PARTNER.md
--out processes/business-partner.bpmn` (before ERP_BUSINESS_PARTNER.md exists: `--from` this spec's
module.yaml), then paste its step names in order, with who does each, into your message. The signed
artifact is that BPMN: never prose, never "open bpmn.io". Cards: dodil://erp/phase/show, dodil://erp/phase/signoff.
# Version 4 (2026-10-26, DRAFT for sign-off, spec gaps wave 4; version 3 stays the signed one until it is signed):
# - seams only, no process change: GET /partners/{bp_id} listed for expenses (a re-billed customer) and pos (an
# account customer at the till)
#
# Version 3 (2026-10-25, DRAFT for sign-off; the fixes the bricks and the end-to-end wiring found, and wave 3's routes):
# - sales-order is listed as a caller of GET /partners/{bp_id} (SO-40 partner functions, the ship-to's country)
# - GET /migration-map is guarded by bp:migration:read (machine, other modules' migration clients), the permission
# gl, inventory and ar already name; until the code checks it, the row says so
# - use cases (MCP tools) of the shared families name the module noun (tool_names)
#
id: business-partner
name: Customers & suppliers (shared)
short: Business partner
version: 4
maturity: deployed # runs live; its own package had no schema.sql/migrate.py until the suite test needed one
summary: >-
One record per company or person you do business with, shared by every other module — so
sales, invoicing and purchasing all mean the same customer.
facets:
domain: master-data
industries: [] # cross-industry
customer_words:
- "one customer list"
- "our customers and suppliers"
- "the same client in sales and accounting"
replaces: [the customer tab in every spreadsheet, duplicate customer lists across tools]
pillars: [sql, graph]
first_module_fit: foundation # not a module anyone asks for; installed with the first module that needs a party
depends_on: []
feeds: [crm, ar, purchasing, gl, hr, contracts]
owns_master_data: [business_partner, bp_role, bp_edges]
references_master_data: []
actors:
- id: data_steward
name: Whoever keeps the customer list clean
does: merges duplicates, fixes names, links subsidiaries to parents
sees: every partner
permissions: [bp:partner:write, bp:family:write]
- id: any_module
name: Other modules (sales, invoicing, purchasing)
does: create-or-reuse a partner, read partners and families
sees: every partner
permissions: [bp:partner:read, bp:partner:promote, bp:migration:read] # bp:migration:read: their migration clients only (machine)
- id: people_module
name: The people module (hr), through its service identity
does: the SINGLE writer of person partners (name, work contact, roles employee / worker / supplier, the works_for edge), and anonymises them on erasure
sees: person partners, and organisation partners as any module does
permissions: [bp:person:sync, bp:person:read]
- id: person_reader
name: Roles that deal with people as parties (contracts, finance)
does: reads person partners, for a contract counterparty or a supplier payment
sees: person partners and organisation partners
permissions: [bp:person:read]
- id: system
name: The system
does: derives ids and enforces the sole-writer rule on every write
sees: every partner, through its own service account
permissions: [] # service account, not a pool user
stories:
- id: BP-01
kind: person
actor: data_steward
story: As the person who keeps the customer list, I want one record per real company however many modules use it, so that reports agree.
given: sales and invoicing both refer to Acme
when: I open Acme
then: I see one partner with two roles (customer, and supplier if we also buy from them)
reference: implemented
- id: BP-02
kind: system
actor: system
trigger: whenever any module creates a partner
story: The system derives the partner id from a stable business key, so that importing the same company twice creates it once.
given: a partner with key acme.com
when: it is created twice, by two modules
then: one row, the same id both times
reference: implemented (bp_id = blake2b(bp_key), 56-bit)
- id: BP-03
kind: person
actor: data_steward
story: As the data steward, I want to link subsidiaries to their parent, so that group-level reporting is possible.
given: Acme UK and Acme DE
when: I link both to Acme Group as subsidiary_of
then: the family walk returns both; a framework-agreement link does not leak into the ownership tree
reference: implemented (typed edges)
- id: BP-04
kind: integration
actor: any_module
trigger: when sales converts a new company
story: Sales promotes a company into the shared list instead of keeping its own.
given: a CRM organization row
when: promote is called
then: a partner exists (created or reused) with the customer role
owner: business-partner
reference: implemented (POST /partners/promote)
# person partners — specced 2026-10-17 with the hr module (a pilot engagement); not yet built
- id: BP-05
kind: integration
actor: people_module
trigger: when hr hires a person, changes their name or work contact, or a role-bearing event takes effect
story: hr keeps the person's partner right, as its only writer, so that every module addresses a person with one key.
given: a person in hr with person_id 812…, and no partner for them
when: hr calls bp:person:sync with bp_key 'person:<person_id>', the legal and preferred name, the work email and phone, the roles and, for a company freelancer, works_for → the company's partner
then: >-
one partner of kind person exists (bp_id derived from the bp_key, so hr knows it without asking), with
exactly those fields; a second identical call changes nothing; a payload carrying a personal email,
phone, home address, tax or bank field is refused ("a person partner carries a name and a work contact
only"); bp:partner:write cannot edit a person partner ("people are written by hr")
owner: business-partner
reference: not built
- id: BP-06
kind: person
actor: any_module
story: As anyone signed in without bp:person:read, I should not see people in the partner list, so that a starter's name is not visible to the whole company before they start.
given: an organisation partner Acme and a person partner for a starter hired today
when: I list partners or open the person partner by id
then: I see Acme; the person partner is not in the list and opening it is not found (404, not 403); a caller with bp:person:read sees both
screen: Partners
reference: not built
- id: BP-07
kind: integration
actor: people_module
trigger: when hr applies an erasure or a retention rule to a person
story: hr anonymises the person's partner, so that erasure reaches the shared record too.
given: a person partner whose person is erased in hr
when: hr calls bp:person:sync with anonymise=true
then: the name and work contact are replaced with a fixed placeholder, the bp_id and roles history stay (other modules' records still join), and a second call changes nothing
owner: business-partner
reference: not built
workflows: []
entities:
- id: business_partner
meaning: a company or person you do business with
natural_key: "bp_id derived from bp_key (a domain, a legacy customer number, a VAT id; for a person, 'person:<person_id>')"
key_attributes: [bp_key, kind (organisation | person), legal_name, preferred_name, work_email, work_phone, country, tax_id]
note: "kind person: written only by hr through bp:person:sync, name and work contact only, readable only with bp:person:read"
master_data: SHARED — this module is the sole writer
reference_table: business_partner
- id: bp_role
meaning: what the partner is to us — customer, supplier, carrier; and for a person, employee or worker
natural_key: [bp_id, role]
master_data: SHARED — sole writer
reference_table: bp_role
- id: bp_edge
meaning: "a typed relationship between partners (subsidiary_of, framework_governs, works_for — a person to the company they freelance through)"
natural_key: [src, dst, rel]
master_data: SHARED — sole writer
reference_table: bp_edges
master_data:
owns: [business_partner, bp_role, bp_edges]
references: []
warning: >-
DataK3 accepts foreign keys but does not enforce them. "Only this module writes partners" IS
the integrity mechanism — other modules call its routes, never its tables.
jobs: []
seams:
- story: BP-04
with: crm
owner: business-partner
rule: other modules call POST /partners/promote or POST /partners
- story: BP-05
with: hr
owner: business-partner
rule: "hr calls bp:person:sync (POST /partners/persons/sync); it never writes business_partner, bp_role or bp_edges, and no other module may write a person partner"
- story: BP-07
with: hr
owner: business-partner
rule: hr calls bp:person:sync with anonymise=true
# ── inbound: other bricks read the partner register (added 2026-10-23, seam-gap PR; read from brick-business-partner use_cases.get_partner / bp_migration_map) ──
- story: BP-01
with: ar
owner: business-partner
route: GET /partners/{bp_id}
permission: bp:partner:read
request: "path bp_id (BIGINT)"
request_schema:
- { field: bp_id, type: bp_id, required: true, in: path }
response: "200 {bp_id, bp_key, legal_name, country, tax_id, domain, status, roles, can_transact, kind, version}; 404 when there is no such partner (also a person the caller may not see)"
rule: "ar reads only existence (200 = known, 404 = add the customer to the register first); it never writes a partner"
status: "added 2026-10-23, seam-gap PR"
- story: BP-01
with: pos
owner: business-partner
route: GET /partners/{bp_id}
permission: bp:partner:read
request: "path bp_id (BIGINT)"
request_schema:
- { field: bp_id, type: bp_id, required: true, in: path }
response: "200 {bp_id, bp_key, legal_name, country, tax_id, domain, status, roles, can_transact, kind, version}; 404 when there is no such partner (also a person the caller may not see)"
rule: "ar reads only existence (200 = known, 404 = add the customer to the register first); it never writes a partner"
status: "business-partner v4 2026-10-26 (spec gaps wave 4): listed for pos POS-14 (an account customer at the till)"
- story: BP-01
with: expenses
owner: business-partner
route: GET /partners/{bp_id}
permission: bp:partner:read
request: "path bp_id (BIGINT)"
request_schema:
- { field: bp_id, type: bp_id, required: true, in: path }
response: "200 {bp_id, bp_key, legal_name, country, tax_id, domain, status, roles, can_transact, kind, version}; 404 when there is no such partner (also a person the caller may not see)"
rule: "ar reads only existence (200 = known, 404 = add the customer to the register first); it never writes a partner"
status: "business-partner v4 2026-10-26 (spec gaps wave 4): listed for expenses EXP-21 (a re-billed customer)"
- story: BP-01
with: payments
owner: business-partner
route: GET /partners/{bp_id}
permission: bp:partner:read
request: "path bp_id (BIGINT)"
request_schema:
- { field: bp_id, type: bp_id, required: true, in: path }
response: "200 {bp_id, bp_key, legal_name, country, tax_id, domain, status, roles, can_transact, kind, version}; 404 when there is no such partner"
rule: "payments asks AS THE CALLER before it takes a receipt for a customer: 200 = known, 404 = refused with 'add the customer to the partner register first', 401/403 = the caller may not look that customer up"
status: "added 2026-10-23, seam-gap PR"
- story: BP-01
with: tax
owner: business-partner
route: GET /partners/{bp_id}
permission: bp:partner:read
request: "path bp_id (BIGINT)"
request_schema:
- { field: bp_id, type: bp_id, required: true, in: path }
response: "200 {bp_id, bp_key, legal_name, country, tax_id, domain, status, roles, can_transact, kind, version}; 404 when there is no such partner"
rule: "tax reads legal_name and address for the customer on a tax document; the register returns no `address` today, so tax prints an empty one (open question: an address on the partner view)"
status: "added 2026-10-23, seam-gap PR"
- story: BP-01
with: purchasing
owner: business-partner
route: GET /partners/{bp_id}
permission: bp:partner:read
request: "path bp_id (BIGINT)"
request_schema:
- { field: bp_id, type: bp_id, required: true, in: path }
response: "200 {bp_id, bp_key, legal_name, country, tax_id, domain, status, roles, can_transact, kind, version}; 404 when there is no such partner"
rule: "purchasing reads the supplier it is about to bill or pay (legal_name, can_transact); it creates suppliers only through POST /partners/promote"
status: "added 2026-10-23, seam-gap PR"
- story: BP-01
with: sales-order
owner: business-partner
direction: inbound
route: GET /partners/{bp_id}
permission: bp:partner:read
request: "path bp_id (BIGINT): each of sold-to, ship-to, bill-to and payer"
request_schema:
- { field: bp_id, type: bp_id, required: true, in: path }
response: "200 {bp_id, bp_key, legal_name, country (ISO 3166-1 alpha-2), tax_id, domain, status, roles, can_transact, kind, version}; 404 when there is no such partner"
rule: "sales-order accepts a partner function only for an active partner with the role it plays (SO-40) and copies the ship-to's country as ship_to_country; the register keeps no postal address (sales-order copies or types the address on the order)"
status: "added 2026-10-25 (v3): the caller side is sales-order v7 SO-40"
- story: BP-02
with: ar
owner: business-partner
route: "GET /migration-map/{db}/{model}?ids="
permission: bp:migration:read # v3: machine; enforced_today: none checked (any signed-in caller) until the code checks it
request: "path db (the Odoo database), model res.partner; query ids=1,2,3 (comma-separated Odoo ids, at most 500)"
request_schema:
- { field: db, type: string, required: true, in: path }
- { field: model, type: string, required: true, in: path, values: [res.partner] }
- { field: ids, type: array, required: true, in: query, items: [integer] }
response: "200 [{odoo_model, odoo_id, ours: bp_id as a string}]; an id with no mapping is omitted, never a 404"
rule: "the one map route (shared.yaml conventions.odoo_migration.map_route); ar resolves each Odoo partner of an invoice to a bp_id through it, so business-partner's migration runs before ar's"
status: "added 2026-10-23, seam-gap PR"
- story: BP-02
with: payments
owner: business-partner
route: "GET /migration-map/{db}/{model}?ids="
permission: bp:migration:read # v3: machine; enforced_today: none checked (any signed-in caller) until the code checks it
request: "path db (the Odoo database), model res.partner; query ids=1,2,3 (comma-separated Odoo ids, at most 500)"
request_schema:
- { field: db, type: string, required: true, in: path }
- { field: model, type: string, required: true, in: path, values: [res.partner] }
- { field: ids, type: array, required: true, in: query, items: [integer] }
response: "200 [{odoo_model, odoo_id, ours: bp_id as a string}]; an id with no mapping is omitted, never a 404"
rule: "payments maps each Odoo receipt's partner to a bp_id through it"
status: "added 2026-10-23, seam-gap PR"
- story: BP-02
with: tax
owner: business-partner
route: "GET /migration-map/{db}/{model}?ids="
permission: bp:migration:read # v3: machine; enforced_today: none checked (any signed-in caller) until the code checks it
request: "path db (the Odoo database), model res.partner; query ids=<one Odoo id>"
request_schema:
- { field: db, type: string, required: true, in: path }
- { field: model, type: string, required: true, in: path, values: [res.partner] }
- { field: ids, type: array, required: true, in: query, items: [integer] }
response: "200 [{odoo_model, odoo_id, ours: bp_id as a string}]; an id with no mapping is omitted, never a 404"
rule: "tax resolves one Odoo partner id to a bp_id when it migrates a cash-basis or CIS row"
status: "added 2026-10-23, seam-gap PR"
tool_names: # shared.yaml conventions.tool_names (2026-10-25): use-case (MCP tool) names unique across a composition; the route and permission are unchanged
- { tool: get_partner_migration_map, route: "GET /migration-map/{db}/{model}", was: "bp_migration_map (unique today; renamed to the family's form at the next release)" }
screens:
- { id: partners, name: Partners, stories: [BP-01, BP-06], actors: [data_steward, any_module],
note: "the reference is API-only; a customer system usually reaches partners through the modules that use them" }
- { id: partner, name: Partner, stories: [BP-03], actors: [data_steward] }
permissions:
namespace: bp # short form; every permission below carries it
catalog:
- bp:partner:read
- bp:partner:write
- bp:partner:promote
- bp:family:write
- bp:person:sync
- bp:person:read
- bp:migration:read # v3, machine: other modules' migrations read GET /migration-map/{db}/{model} (shared.yaml conventions.odoo_migration)
machine:
- bp:migration:read
# WHAT THE CODE ENFORCES TODAY, which is not yet what the catalog above says. The catalog is
# the target shape (<module>:<object>:<verb>, one namespace per module); these are the names
# erp-business-partner actually checks. Configure a pool from the catalog alone and every
# write answers 403 — so the true names are published here until the code is migrated and
# the deployed role catalogs are updated with it.
enforced_today:
- erp:partners:write
- "GET /migration-map: no permission checked yet (bp:migration:read is the target, v3)"
row_visibility:
rule: >-
every signed-in user may read ORGANISATION partners; PERSON partners are readable only with
bp:person:read (hr, contracts and finance roles) and written only by hr (bp:person:sync); the
steward writes organisation partners
enforced: "the write routes require bp:partner:write (organisations) or bp:person:sync (persons); every read filters person partners unless the caller holds bp:person:read; other modules call promote"
not_found_not_forbidden: true
config_rows:
- { table: bp_role, changes: "the roles a partner can have to you — customer, supplier, carrier, and any of your own (employee and worker are hr's, for person partners)" }
in_code_not_rows:
- "that bp_id is derived from bp_key, so the same company imports once"
- "that a family walk filters on the relationship type"
- "that only this module writes partner tables"
- "that a person partner is written only through bp:person:sync, carries a name and a work contact only, and is readable only with bp:person:read"
tests:
acceptance: "one per story id above, named for it"
access:
- "a caller without bp:partner:write cannot create or edit a partner"
- "a request with no gateway identity is rejected"
- "a caller without bp:person:read gets 404 on a person partner and never sees one in a list; organisation partners are unaffected"
- "bp:partner:write cannot create or change a person partner"
invariants:
- "creating the same partner twice yields one row and the same id"
- "bp:person:sync twice with the same payload changes nothing; a payload with a private field is refused"
- "a typed family walk never returns a partner linked by another relationship type"
- "a partner cannot be its own parent"
scale:
reads: "the tables reader serves 16 concurrent reads; the family walk is a recursive CTE — cache it per request, not per row"
hot_paths: ["partner lookup by bp_key during import", "family walk for group rollups"]
workers: none
volumes: "hundreds of thousands of partners; the 56-bit derived id has ample headroom"
# What differs between customers — with something for them to react to, and where one exists,
# something they can look up.
#
# A business user can rarely recall a policy on demand and can always react to one. "Most firms
# like yours chase at 7, 14 and 30 days — does that match?" gets a truer answer, faster, than
# "when do you chase?". Read it out, let them correct it, write down what they said.
#
# Two kinds of answer, and they must never be confused:
#
# standard: a NAMED external authority the customer can look up and hold us to — a
# directive, an accounting standard, a statute. Adopt it, or decide deliberately
# to differ and know why. If a line here cannot be cited, it does not belong.
# typical: arrangements we have SEEN. No authority, no survey behind them, no implication
# that a customer doing otherwise is doing it wrong. Conversation, not evidence.
#
# Calling convention a standard is how an implementation ends up defending a choice nobody
# ever made.
customer_varies:
- question: What identifies a customer for you — a website, a customer number, a tax id?
changes: bp_key
typical:
b2b_services: a customer number they already use in their old system
vat_registered_trade: the tax id, because it is what the invoice must carry anyway
warning: a web domain looks stable and is not — it moves on a rename or acquisition
- question: Do you buy from any of the companies you sell to?
changes: roles
- question: Do you deal with groups of companies?
changes: bp_edges
reference:
# `name` is the brick's DISTRIBUTION name (pyproject [project].name): erp-compose init pins
# `<name> @ git+<https>@<tag>`, and pip refuses a pin whose name differs from the package's.
repos:
- name: brick-business-partner
role: brick
https: https://git.dodil.io/dodil/erp-brick-business-partner.git
ssh: git@git.dodil.io:dodil/erp-brick-business-partner.git
tag: v0.4.0
read_first: [README.md, AGENTS.md]
- name: erp-platform
role: platform
https: https://git.dodil.io/dodil/erp-platform.git
ssh: git@git.dodil.io:dodil/erp-platform.git
tag: v0.9.13
read_first: [README.md, AGENTS.md]
packages: []
contracts: [] # the skill-contract tier is retired; the spec IS the contract
build_guide: null
known_gaps:
- "no merge-duplicates (a person asking to merge two partners cannot yet)"
- "no addresses; contacts (people at a partner) are the CRM's, held by brick-activity, not here"
- "no screen: API and MCP only"
- "sync_person takes no version: hr is a person partner's single writer, and a sync that lost a race is re-run"
- "invoicing's column named bp_id holds what this module calls a bp_KEY, not a bp_id, so the cross-module join needs a CAST. This module is right; the naming downstream is not - design/ar-bp-id-change-request.md"