# 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.
id: business-partner
name: Customers & suppliers (shared)
short: Business partner
version: 1
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]
- 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
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
# 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
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:
# Public repos, anonymous clone, one release tag each. The old code/ packages are retired (archive/code).
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.3.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.5.3
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"