dodil/erp-method

node_modules/.cache/erp-method/v0.1.15/modules/business-partner/module.yaml

# Business partner — the module source. See modules/crm/module.yaml for the format.

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/brick-business-partner.git
      ssh: git@git.dodil.io:dodil/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"