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 fromcaller— 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. queryis GET and read-only;commandis not GET. Markidempotent=Trueonly when a repeat truly changes nothing; markdestructive=Truewhen 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):BigIdfor a 64-bit key,Name/Email/Noteortext(n)for strings,IsoDate/IsoTime,Amount,Quantity,Percent,Limit. An unboundedintorstris 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 inid_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-Keyfor free (header;idempotency_keythrough 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 everyidempotent=Falsecommand. - A route only another brick calls is
tool=False, with a permission no person role holds (pipeline'sopen_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). Withoutcaller=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) andMoneyin Python, carried to the currency's minor unit; it leaves both doors as an exact string. A rate isNUMERIC(19,4)and aRate; what it computes is exact and rounded ONCE per payable line (round_once), never per day. - Business time comes from
work.now(), notdatetime.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 INTEGERversioncolumn at the end of the root's CREATE (and an ALTER), plusPART.version_ddl()'s claim table in schema.sql. Hand the version a screen or an agent loaded back asexpected_version; a stale save raisesrepository.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 inrepository.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 asIf-Match: "3";etag="version"on the GET hands it back as theETag. - 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.memoread) 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 psycopgpipeline(),driver_connection.cursor().execute, a DBAPI cursor — bypasses them: callrepository.note_write(sql, conn)for each such write (orrepository.forget_all()after the batch), or the next load in the same request answers rows from before it. Writes throughconn.execute,repository.upsertandAggregate.saveneed nothing. Known raw write paths (2026-10-25): brick-hrinfrastructure/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-migraterefuses 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.bpmnrenders it — the same generator aserp-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?)atGET /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 checkasks for one. A brick built for erp-platform ≥ 0.8 (its ownerp-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 samesignoff/<module>/<process>.pending.jsonthaterp-compose change proposewrites — instead of changing the signed process under the customer. The signed BPMN drives every change; the code and the rows follow (erp-methodphase/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 raisesCapabilityRefused— both are your bug. A provider that is down is afailed, 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
Transientfor a vendor blip (retried with backoff in the call, thenfailedretryable),Failedfor a permanent failure (stored and replayed),ConnectionRequiredwhen the customer has not connected the tool. Never return a secret in the output. - Put
capabilities.ddl("<brick>")in yourschema.sql— the claims and action-log tables, prefixed with your brick's name.erp-compose checkrefuses a provider without them. - Publish
<brick>:capability:invokein the module spec: it is the permission every capability route checks. - Tests point
BRICK.capability_connectionat a SQLite engine'sbegin(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
urlis 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. Keeplink_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
connto 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 — andreq(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: checkpublic.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
Refusedfrom 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); calllink.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 withauth.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 namedsystem. - Effective permissions:
erp-compose check compose.yaml --roles roles.yamlprints 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'stests/test_probes.pydoes 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_POOLandPUBLIC_BASE_URLin 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.anonymousnobody. 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 (--shufflechecks). - The clock.
s.clock.set("2026-10-25T01:30 Europe/London"),.advance("+3 working days"), thens.tick(). In process it moveswork.now(); live only through a demo clock route —democlock.mount(BRICK)on ONE brick of the composition (<brick>:clock:set, 404 underDODIL_ENV=prod|live). Without one,clockscenarios 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=...)(everyidempotent=Truecommand 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 inexceptionswith 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.