dodil/erp-platform

BRICKS.md

Building a brick

The rules a brick follows, and why. Each was learned from a defect — the reason is there so the rule can be judged, not just obeyed.

Shape

src/brick_<name>/
  domain/          rules — plain Python. No SQLAlchemy, FastAPI, MCP or identity.
  application/     use cases — declared with @BRICK.use_case; who may, and which rows.
  infrastructure/  tables + repository (erp_platform.repository.Aggregate)
  app.py           app = BRICK.app()
  schema.sql       inside the package, so an installed brick can migrate itself
tests/

There is no api/ or mcp/ folder: both doors are generated from the use-case declarations, so they cannot drift apart.

A new brick starts from its module spec: erp-compose scaffold modules/<id>/module.yaml --out brick-<id> (0.9) writes this shape — and every rule below — deterministically, leaving TODO(rule) where only the business can decide and one xfail test per story. Fill the TODOs; do not re-shape the skeleton. There is no scaffold --update.

Ownership

  • A brick owns the tables it writes, and is the only writer. Split bricks by who writes, not by noun. A brick nobody would deploy alone is too small.
  • Prefix every table with the brick's name. At tier 1 bricks share a bucket.
  • Read another brick through its seam or its published contract, never its tables — at tier 3 they are in another bucket.

Use cases

  • The first parameter is caller. Row visibility is decided inside the use case from caller — that is why an agent sees exactly what its person sees.
  • Refuse with Refused(status, reason), never an HTTP exception; a broken domain rule becomes a 409 with the rule's own words. A row the caller may not see is 404, never 403 — a 403 confirms it exists.
  • query is GET and read-only; command is not GET. Mark idempotent=True only when a repeat truly changes nothing; mark destructive=True when it removes something. These become the hints an agent's client shows a person before acting.
  • Tools are use cases (accept_quote), never table CRUD (update_quote_row).
  • A docstring is required — it is what an agent reads to decide whether to call the tool.
  • Bound every input with erp_platform.types (0.5): BigId for a 64-bit key, Name/Email/Note or text(n) for strings, IsoDate/IsoTime, Amount, Quantity, Percent, Limit. An unbounded int or str is a promise the engine cannot keep: the CRM audit found a 2**31 quantity, a 1e20 amount, a 23-digit id and a 200 KB name each coming back as a 500. The doors also refuse control characters in every string.
  • 64-bit ids leave as strings: Brick(..., ids_as_strings=True) (name other id fields in id_keys). A JavaScript client or an agent reads a JSON number as a double; past 2**53 it is another customer's id.
  • A command that makes something new gets an Idempotency-Key for free (header; idempotency_key through MCP): a client that lost the reply sends the same key again and gets the first answer. Say so in nothing — the doors add it to every idempotent=False command.
  • A route only another brick calls is tool=False, with a permission no person role holds (pipeline's open_opportunity_from_source, pipeline:opportunity:link_source): no agent sees it, and the matrix probes it through the API door only.
  • Ask a neighbour AS THE CALLER when the answer is about what they may see: SEAM.get(path, caller=caller). Without caller= the neighbour answers the seam's service identity, which is the right thing for a fact ("is this deal's quote accepted?") and the wrong thing for a reference a person typed ("attach my quote to deal O-1" — may THEY see O-1?).

Data

  • Natural primary keys, never SERIAL. DataK3 mints an id when a write omits one, so a missing id fails silently.
  • Every child table's key starts with the root's key — that is how a save finds stale rows at any depth.
  • Money is NUMERIC(18,2) (NUMERIC(18,3) where a three-place currency can occur) and Money in Python, carried to the currency's minor unit; it leaves both doors as an exact string. A rate is NUMERIC(19,4) and a Rate; what it computes is exact and rounded ONCE per payable line (round_once), never per day.
  • Business time comes from work.now(), not datetime.now(), wherever a due date or a board is decided — that is what lets a demo move the clock. Claim leases, tokens and audit stamps use real time.
  • Two people editing one record: opt in to optimistic versions. Aggregate(root, *children, version="version") with an INTEGER version column at the end of the root's CREATE (and an ALTER), plus PART.version_ddl()'s claim table in schema.sql. Hand the version a screen or an agent loaded back as expected_version; a stale save raises repository.Conflict, which both doors answer 409 "changed since you opened it" and nothing of it is written. A Conflict is not retried — the person reloads. Without it the last writer silently wins (hr SC-HR-08). A caller who sends NO version asked for "this change on whatever is there": wrap the unit of work in repository.retry_on_conflict(fn) so a lost race is re-run on the latest — never retry one who sent a version. use_case(if_match="version") lets a screen send it as If-Match: "3"; etag="version" on the GET hands it back as the ETag.
  • Load and save through the repository, one aggregate per unit of work. Never session.merge(), never a bare DELETE, never a hand-written upsert (DataK3's ON CONFLICT differs from Postgres — see README).
  • Raw-driver writes tell the identity map (0.9.13). Inside a request an aggregate (and a repository.memo read) is read once and then answered from the request's map, which learns of writes through SQLAlchemy's cursor events. SQL sent on the raw driver — your own psycopg pipeline(), driver_connection.cursor().execute, a DBAPI cursor — bypasses them: call repository.note_write(sql, conn) for each such write (or repository.forget_all() after the batch), or the next load in the same request answers rows from before it. Writes through conn.execute, repository.upsert and Aggregate.save need nothing. Known raw write paths (2026-10-25): brick-hr infrastructure/batching.py (its pipelined batches) is the one found in the bricks; it must call it before it runs on 0.9.13.
  • Schema changes are additive. A new column goes at the END of its CREATE and as an explicit ALTER; DROP, RENAME and type changes are a separate release after the rollback window. erp-migrate refuses them.

The process view: every brick shows its process as BPMN (0.8)

The customer signs a DIAGRAM before anything is built, so every brick must be able to draw its process. Declare the workflow once, in the shape of a module spec's workflows:, and the platform serves it:

# application/use_cases.py — process.yaml beside it, listed in [tool.setuptools.package-data]
QUOTE.declare_process("process.yaml")      # → show_process_bpmn(process_id?) at GET /process/bpmn
# application/process.yaml
version: 1
actors:
  - { id: rep, name: Sales rep }
  - { id: customer, name: The customer, external: true }   # outside the system: the process waits on it
workflows:
  - entity: quote
    states: [draft, sent, accepted]
    terminal: [accepted]
    transitions:
      - { key: draft_quote,  from: none,  to: draft,    by: rep }
      - { key: send_quote,   from: draft, to: sent,     by: rep, gate: "at least one line" }
      - { key: accept_quote, from: sent,  to: accepted, by: customer }
  • Keys are the BPMN ids (Task_<key>, Flow_<a>__<b>, Gateway_<key>__if, Timer_<key>; pattern.md, "Stable ids"): frozen once signed, a renamed concept gets a new key. erp_platform.bpmn renders it — the same generator as erp-compose bpmn --from module.yaml|ERP_<MODULE>.md, so a brick's diagram and a design's diagram compare.
  • It is a route, not a tool (every brick has one, and one tool name cannot be offered twice): a composition gives agents ONE show_process_bpmn(brick, process_id?) at GET /composition/process/{brick}.
  • A brick whose process is configured in rows draws it from them instead (brick-hr's show_playbook_bpmn, which counts as its process view).
  • erp-compose check asks for one. A brick built for erp-platform ≥ 0.8 (its own erp-platform>= floor) with no process view is REFUSED; one pinned older is WARNED, so today's compositions keep building. A pure master-data brick waives it in compose.yaml, with the reason, which the check prints: - { name: bp, brick: "brick_bp.application.use_cases:BP", version: 0.3.0, process_view: none } # master data only
  • Runtime configuration must not drift the process silently. A use case that changes configuration in a way that changes the process view (brick-hr's change_playbook, a new stage, an approval switched on) records a PENDING change against the signed diagram — the same signoff/<module>/<process>.pending.json that erp-compose change propose writes — instead of changing the signed process under the customer. The signed BPMN drives every change; the code and the rows follow (erp-method phase/change). Not yet built in any brick (brick-hr: tracked as an issue).

Capabilities: asking another brick to act in the outside world

A brick that needs something DONE in a customer's tool — an account created, a person added to a group, an email sent — names a capability from capabilities.yaml (in src/erp_platform/, symlinked at the repo root), never a vendor. hr requires identity.provision_user; google-workspace provides it; the composition says which, or nobody.

Consumer. Declare it and call it:

ACCOUNTS = CapabilitySeam("identity.provision_user")     # also list it: Brick("hr", requires=[...])

r = ACCOUNTS.call(f"{run_id}|{step_no}", {...}, actor=caller["email"])
if isinstance(r, ManualTask):           # no provider, or it has no connection (rule 8)
    store r.as_row() as a task for a person; show r.plan; when they finish: r.complete(provider_ref=...)
elif r.status == "done": ...            # in_progress / failed retryable: try on a later tick
  • Derive the key from the work (run_id|step_no), never mint one: the same key is how a retried tick gets the stored answer instead of a second account.
  • No provider is a first-class path, not an error. The ManualTask carries the capability and the exact validated input it would have sent. Wiring a provider later turns the same step automatic — no new step kind, no playbook change. Store as_row() (capability, idempotency_key, input_json, plan, reason, destructive, summary) in a table YOUR brick owns; ManualTask.from_row() reads it back.
  • Input is checked against the contract before anything is sent (InvalidCapabilityInput); a 4xx from the provider raises CapabilityRefused — both are your bug. A provider that is down is a failed, retryable Result.
  • Before a destructive capability (the contract marks them), put an approval step in front of the call.

Provider. Decorate a handler; the platform does rules 1–7:

@GWS.capability("identity.provision_user", secrets=())     # secrets: input fields kept out of the log
def provision_user(call: Call):
    ...                                  # look up first, then create: idempotent AT THE VENDOR too
    return account_id, {"account_id": ..., "primary_email": ..., "created": created}, "Account created."
  • Raise Transient for a vendor blip (retried with backoff in the call, then failed retryable), Failed for a permanent failure (stored and replayed), ConnectionRequired when the customer has not connected the tool. Never return a secret in the output.
  • Put capabilities.ddl("<brick>") in your schema.sql — the claims and action-log tables, prefixed with your brick's name. erp-compose check refuses a provider without them.
  • Publish <brick>:capability:invoke in the module spec: it is the permission every capability route checks.
  • Tests point BRICK.capability_connection at a SQLite engine's begin (tests/test_capabilities.py).

Composition. compose.yaml may choose: capabilities: {identity.provision_user: gws, mail.send: manual}. Two bricks providing one capability need an entry; a required capability with no provider is reported manual and allowed.

The public door: links for people with no login

A counterparty signing, a starter's intake, a client approving a proof: they hold a link, not an account. The platform owns the link (erp_public_link, migrated by erp-compose migrate / erp-migrate --platform) and the app that serves it; a brick issues links and says what they show and do.

Issue (from any use case — a send, a reissue):

link_id, url = CT.links.issue("contract", f"{cid}|{n}", signer_email, "SIGN",
                              expires_in=timedelta(days=14), actor=caller["email"], conn=conn)
  • The url is returned ONCE: put it in the mail (or a ManualTask's "copy the link"), never in a table or a log. Only its sha256 is stored. Keep link_id — it is how you revoke, reissue and read status.
  • One link per person per grant (READ, APPROVE, SIGN, SUBMIT, TRACK): a second issue for the same person, subject and grant revokes the first. Never one link per document — a shared link cannot say who acted.
  • revoke(link_id, actor=, reason=), reissue(link_id) (the old one stops working; the new url once), status(kind, id) → per person: seen (first/last/count) apart from used, and the state.
  • Pass conn to issue inside your own transaction, so the send and its links commit together.

Handle — what a link of a grant and subject kind renders (GET) and does (POST):

@CT.public("SIGN", subject_kind="contract")
def signing_page(link, req) -> Page: ...                 # public.Page(title, html) or public.Download

@signing_page.act                                        # single use: claimed before, released if refused
def sign(link, req) -> Page:
    consent_and_sign(link.actor, link.subject_id, typed_name=req.form["name"])
  • The handler gets the resolved Link (subject, recipient, grant, link_id) — never the token — and req (method, ip, user agent, form, files).
  • It calls the brick's OWN use cases with link.actor: auth_source "public", no roles, no permissions. The use case decides what a public actor may do: check public.subject_of(caller) matches the record and answer 404 when it does not. No staff permission is ever satisfied by a public actor.
  • Escape every value with public.esc; forms post back to the same address. No script runs (the CSP).
  • A Refused from the act is shown in its words and leaves the link usable; a 404 is the one dead-link page.
  • An act that may repeat (an intake saved in parts) is .act(single_use=False); call link.use() when it is final. link.use(conn) claims inside your transaction, so the act and the claim commit together.

TRACK links need no handler: issue(..., "TRACK") is an open pixel, issue(..., "TRACK", destination="https://…") a click to an allow-listed host (PUBLIC_REDIRECT_HOSTS). Opens are noisy (Apple Mail Privacy Protection, image proxies); scanners click before people. Report them as such.

The two-app shape. The staff app never serves a link; the public app (erp-compose serve --public) serves only the door, /healthz and /readyz — no brick route, no MCP — never migrates, runs without a pool and with its own narrower service account (erp-compose plan prints it), access log off. A public handler is code in your brick that runs on the internet-facing app: keep it to rendering and calling your use cases.

Permissions: a brick decides by permission; roles are the customer's

A brick ships default roles (its actors, each a list of the module's permissions: the scaffold writes them to tests/scenarios/roles.yaml); a composition maps its own role names onto permissions in its pool. Brick code never compares a role name — not "manager" in caller["roles"], not auth.is_manager, not owner_scope(caller) without see_all=. erp-compose check lists every such line (a problem under --strict); a real exception says so on the line: # role-check: <reason>.

  • The format is module:entity:action, three [a-z_]+ segments. A use case requires a concrete one.
  • Wildcards belong to grants: pj:item:*, pj:*:read, pj:*:*, *:*:*, segment by segment, nothing partial. A malformed grant is ignored (logged once).
  • Seeing every row is a permission: <ns>:<root>:read_all, checked with auth.owner_scope(caller, see_all=…).
  • Machine permissions are what only a service client may hold — a seam-only route another brick calls, the scheduler's tick. Declare them: Brick("pipeline", machine_permissions=("pipeline:opportunity:link_source",)). No wildcard reaches them; only an exact grant does. A step only the system runs is one of these, held by the scheduler's service client — never a role named system.
  • Effective permissions: erp-compose check compose.yaml --roles roles.yaml prints what each role can run per brick with the wildcards expanded, and warns when a wildcard reaches an approval or destructive command the role does not name. The probes hold the matcher to every use case: CAT.permission_matrix() adds *:*:* (every person use case, no machine one), <module>:*:* (its module only) and malformed grants (nothing); the scaffold's tests/test_probes.py does the same per command.

Identity and agents

  • People sign in to the composition's dodil-appid pool; the gateway verifies the token and mints X-Dodil-User. A brick never verifies tokens itself.
  • Two kinds of agent connection — never mix them. A machine (a job, Claude Code, the API) uses a service client (client_credentials). A person connecting Claude.ai or ChatGPT signs in (authorization_code + PKCE): a service-client token carries no person, so every row would be visible.
  • Set DODIL_USER_POOL and PUBLIC_BASE_URL in every deploy — the first tells agents where to sign in, the second is the only Host the agent door will answer.

How to write scenarios

Unit tests prove a brick's rules; a SCENARIO proves a business promise end to end — through the composition's doors only (HTTP routes, MCP tools, public links), never by writing a table — and runs two ways from one file: in process (erp-scenarios run: the whole composition, each brick on its own SQLite, real ASGI and a real MCP session; seconds) and live (erp-scenarios run --live: a real deployment, as real people with real tokens). erp_platform.scenarios is the harness; tests/test_scenarios.py and tests/scenarios/ are its proof.

The catalogue convention. Every scenario has an id SC-<MODULE>-nn (SC-HR-08, SC-CT-22) — the same id the module spec lists under tests: scenarios: — and names the stories it proves (stories=["HR-14"]), so a failure reads as the promise that broke. Tags say what it exercises: permissions, concurrency, clock, public, failure (fault injection — in process only), money. Write it as the spec says it:

CAT = Catalogue("hr", compose=HERE / "compose.yaml", roles=HERE / "roles.yaml",
                ticks=["POST /hr/tick"], tick_as="scheduler", clock="hr")

@CAT.scenario("SC-HR-08", "two admins change the same person at once", stories=["HR-14"],
              tags=["concurrency"], personas=["hr_admin", "hr_admin#2"])
def two_admins(s):
    s.given("two HR admins open the same person")
    p = s.as_("hr_admin").post("/hr/hires", hire(s)).ok()
    s.when("both save a different pay slice at the same moment")
    rs = s.concurrently(2, lambda i: s.as_("hr_admin", i + 1).post(f"/hr/people/{p['person_id']}/pay",
                                                                    slice(i, expected_version=p["version"])))
    s.then('one commits; the other is refused 409 "changed since you opened it"')
    rs.expect({200: 1, 409: 1})
  • Personas are roles from the composition's role catalogue (roles.yaml: role → permissions, the file the pool is seeded from); "clerk#2" is another person with the same role, s.anonymous nobody. Declare them on the scenario — acting as an undeclared one is an error.
  • Assertions are exact. .not_found() is 404 and only 404; .forbidden(perm) is 403 naming the permission; .field("total", Decimal("100.00")) compares money to the minor unit and refuses a float; .same_as(r) is status AND body (idempotency); s.eventually(...) polls, ticking if asked. A scenario fails, errors (a broken scenario), or is skipped with a reason — never silently passed.
  • Independent. Make your own data with s.key("O") / s.email() (unique, prefixed with the run id, so a live run's rows can be told apart); never lean on another scenario's rows or order (--shuffle checks).
  • The clock. s.clock.set("2026-10-25T01:30 Europe/London"), .advance("+3 working days"), then s.tick(). In process it moves work.now(); live only through a demo clock route — democlock.mount(BRICK) on ONE brick of the composition (<brick>:clock:set, 404 under DODIL_ENV=prod|live). Without one, clock scenarios are reported SKIPPED live; run them against a live dev bucket by recording the dates relative to today instead.
  • Public links: a use case hands the url back once (a "copy the link" answer, or a ManualTask's plan); s.link(url).open() / .submit(form, files={"field": (name, bytes, type)}) as the person holding it.
  • Failures (in process): s.fault.seam_down/seam_slow/seam_failing, capability(name, "absent" | "failing" | "failed" | "connection_required"), db_error("40001" | "57P03" | "08006", use_case=..., after=...), crash_after("pkg.module:function") (a crash between two steps, then the retry). with s.fault.scope() lifts them before the scenario's next step. Live runs skip them, saying so.
  • Generated probes, not hand-written negatives. CAT.permission_matrix(rows=Rows(...)) (every persona × every use case × both doors: 401 / exactly-403 / served, and 404 for another person's record; expect= {(role, use_case): 404} where even a 403 would confirm a record exists — hr's manager on show_sensitive), CAT.idempotency(recipes=...) (every idempotent=True command replayed through both doors answers the same and moves nothing), CAT.mass_assignment(recipes=...) (extra fields refused or ignored, never applied). A command with no recipe FAILS its probe — add one, or list it in exceptions with the reason.

Where catalogues live. A brick ships its own in tests/scenarios/ (catalogue.py, a compose.yaml of the brick alone or with the neighbours it seams to, roles.yaml), run in its suite like tests/test_scenarios.py runs this repo's. A composition ships its own in <composition>/scenarios/: the cross-brick stories, run in CI in process and, against the demo deployment, live — erp-scenarios run <composition>/scenarios/catalogue.py --live --base-url … --public-base-url … --bucket <bucket stamped demo> --org <org>, with ERP_SCENARIO_SECRETS pointing at a directory (files mode 600) whose personas.json holds the pool users (password grant, field email) and service clients (client_credentials). The live runner refuses a bucket not stamped demo unless --allow; it injects no faults and caps concurrency at 10. Reports: the console table, --junit for CI, --markdown with each failure's request → response (tokens, secrets and link tokens redacted).

Tests

  • Rules without a database (tests/test_rules.py): one test per sentence a business person would agree to.
  • Public handlers over the public app (public.app(BRICK.public_handlers), public.configure(engine.begin) on SQLite): the page as the recipient, the act once, the 404 for another subject and for a used link.
  • Both doors over the real app with storage swapped for an in-memory repository (tests/test_doors.py): the same outcome through the API and through MCP, the 404 for another person's row, the 403 without the permission.
  • Write them from the module's Given/When/Then stories.