The anatomy of a module
How a DODIL business module is built, so that the fifth one looks like the first and a customer can change any of it later. The lifecycle says what to do in what order; this says what the thing you build looks like.
Every rule here comes from a module that runs, and each one says where it was learned. Since
2026-10-16 the modules that have code ship it as bricks — public repos on erp-platform
(BRICKS.md there is the shape of one, and wins where the two differ in detail). Some rules
post-date the older GL/AR/ITSM packages, which were built before the pattern existed — those
say so too, because a reference that quietly contradicts its own rules is worse than one that admits it.
Where a rule is currently blocked by an open platform fault, that is stated rather than glossed.
1 · Three artifacts, no more
A module is three things. Anything else is drift.
| Artifact | Audience | Lives | Answers |
|---|---|---|---|
The spec — /modules/<id>/spec.yaml | agents | this site | who uses it, what it must do, what it keeps, what runs on its own |
The page — /modules/<id>/ | people | this site | the same content, readable, in the customer's words |
The code — the bricks in spec.yaml → reference.repos | both | public git repos (git.dodil.io/dodil/brick-*), one release tag each | the running module: use cases, tables, tests, scenarios. A module may be several bricks (CRM is five); a customer depends on them at their tags. A module with no bricks yet is built by generating a new brick in the brick shape on erp-platform |
The platform layer is not part of any of them. It is a dependency, erp-platform, pinned
at its tag like a brick: the engine and pool (db), identity (auth), the doors that serve every
use case as a route and an MCP tool, the Aggregate repository, seams, migrations, the public-link
door, the scenario harness. Nothing of it is copied into a customer's repo. Copying it was retired because copied files drift. erp-platform is the foundation and is never
copied: the same four, duplicated across two dozen
packages, had become three versions of auth.py and three of db.py, and a repo that copied the
wrong one inherited the difference silently. A pinned dependency cannot drift; it upgrades.
The spec and the page are generated from one source (modules/<id>/module.yaml), because two
hand-written copies of the same truth diverge — ours did, repeatedly, before they shared a source.
A customer's own system adds a fourth, in their repo: ERP_<MODULE>.md, the design file
(template). The reference spec is the starting vocabulary; their design
file is the decision record.
2 · One composition, one app
A customer's system is a composition: the bricks it needs, each pinned to its tag, served as one app. The repo holds what is the customer's, and nothing that is a brick's:
customer-repo/
ERP_CRM.md ERP_HR.md the design files — one per module, read by every agent
requirements.txt erp-platform + each brick @ its release tag
compose.yaml the bricks; tier 1 = one app, one bucket
roles.yaml role -> permissions; the pool's catalog is seeded from it
scenarios/catalogue.py the cross-brick stories, SC-<MODULE>-nn
web/ Vite + React (erp-ui components); screens named after stories
Dockerfile .dodil/deploy.yaml .github/workflows/ci.yml
erp-compose serve mounts every brick under /<brick> with one /mcp for the whole
composition. A module no brick covers is a new brick in the shape below, in its own repo,
joining compose.yaml like the published ones.
The shape of a brick (erp-platform/BRICKS.md, template in templates/brick-example/):
src/brick_<name>/
domain/ the rules — plain Python; no SQLAlchemy, FastAPI, MCP or identity
application/ use cases (@BRICK.use_case): who may, which rows. Each is a route AND a tool
infrastructure/ prefixed tables + Aggregate repositories
schema.sql inside the package, so an installed brick migrates itself
tests/ rules without a database; both doors over the real app; scenarios/
There is no api/ or mcp/ folder: both doors are generated from the use-case declarations, so
they cannot drift apart. (A module with no brick yet gets this same shape: generate a new brick on erp-platform.)
The staff app: --user-pool <pool> with --allow-unauthenticated. The gateway logs in optionally, strips forged identity headers and injects them when a session or
bearer is present, and the app's sign-in gate (erp_platform _SignInGate) refuses every route
without identity except /healthz and /.well-known/oauth-protected-resource — the RFC 9728
metadata that Claude.ai/ChatGPT connector discovery and the browser sign-in redirect need
anonymously (crm-demo v4–v7, 2026-10-16/17: an anonymous /lead/leads is a 401 from the app, the
metadata a 200). Gateway-level public_invoke=false is right only for an app that serves no MCP
and no browser sign-in redirect.
The public app is the one standing exception. A brick that issues links — a counterparty
signing, a starter's intake form, open/click tracking — has them served by a second app
(erp-compose serve --public): no sign-in pool, public_invoke=true, its own narrower service
account (k3.editor only, no Models role, no connector credential), only /p/, /o/, /c/,
/healthz and /readyz, access log off (the path carries the token), and it never migrates.
The staff app never serves a link. (erp-platform 0.5, from the contracts and hr bricks.)
Split further only for a stated reason: independent scaling, or a distinct trust boundary (tiers 2–3 — configuration, not code). Two apps on two hosts do not share a gateway login session — the cookie is host-only — so splitting for tidiness costs the customer a second sign-in.
One bucket per customer, one sign-in pool per customer. That is what makes a sales opportunity JOIN a finance invoice with no ETL, which is the whole reason the system is on DataK3.
3 · The data layer
Naming
- Module-owned tables carry the module prefix:
crm_quotes,gl_journal_lines. In a shared bucket an unprefixedquotestable is a collision waiting for the second module. - Shared master data is unprefixed and has exactly one writer:
business_partner,product.
Keys
- Natural primary keys, always. DataK3 has no sequences, and
RETURNINGdoes not return a staged write. If you cannot name the key in the design phase, the entity is not understood yet. - Derive an id when one is needed:
bp_id = int.from_bytes(blake2b(bp_key, digest_size=7))(a 56-bit BIGINT),line_id = f"{quote_id}|{line_no}",task_id = f"followup:{quote_id}". A derived id makes a re-import or a double-clicked button idempotent for free. - A counter is a row, not a sequence — a high-water mark, with the primary key as the
uniqueness guard and a retry on conflict. Claim the next number with a plain
INSERT: under concurrency exactly one writer is acknowledged and every other is rejected with SQLSTATE23505, so the losers retry with the number after it. Never claim with an upsert (ON CONFLICT … DO UPDATE/data_table_upsert), which lets every writer "win" the same number. (Measured live 2026-10-17 on tables 0.2.1: 10 and 20 concurrent inserts of one key → 1 acknowledged, the rest23505, one row. Earlier versions acknowledged all of them; this note said "blocked" until then.)
Writes
- Every write is idempotent, by one of two means: a derived id plus a status guard (the
common case — re-marking a sent quote as sent returns early, and the follow-up task keyed
followup:<quote_id>cannot double), or an upsert where a row may legitimately be rewritten — through the repository (erp_platform.repository), never hand-written.DO UPDATE SETaccepts onlyEXCLUDED.<col>— put constants inVALUES. - An upsert overwrites every column in
VALUES, whatever theSETlist says (DataK3 tables#417, fixed in 0.2.2; production still runs 0.1.60.x, 2026-10-17). Acreated_ator an owner inVALUESis rewritten by every later save: leave insert-only columns out of it. The repository's upsert works around it and stays correct after the fix. - A claim is never an upsert (§3 Keys): a number, a one-time action, an
Idempotency-Key, a single-use link — each is a plainINSERTthat exactly one writer wins. - No lost updates. A record two people may edit carries an integer
version(Aggregate(..., version="version")). A screen or agent sends back the version it loaded (If-Match: "3", orexpected_version); a stale one is 409 "changed since you opened it" and nothing is written. Without it the last writer silently wins (hr SC-HR-08; the CRM audit, 2026-10-16). A caller who sends no version is re-run on the latest; one who sent a version is never retried — the person reloads. - Retries are safe for the client too. Every non-idempotent command accepts an
Idempotency-Keyheader (idempotency_keythrough MCP), claimed by plainINSERTscoped to the caller: a client that lost the reply sends the same key and gets the first answer. The doors add it; nothing to write. (erp-platform 0.5.3 — 0.5.2's repeat answered 409 on DataK3.) - 64-bit ids leave as JSON strings (
Brick(ids_as_strings=True)). A JavaScript client or an agent reads a JSON number as a double, and past 2^53 it is another record: 45665172796086509 rounds to a different customer's id (the CRM audit, 2026-10-16). DELETErequires aWHERE(WHERE TRUEfor a deliberate clear).- No read-your-writes inside an open transaction. Commit, then re-derive in a second
transaction — and re-derive with
SUM(...), never+=, so concurrent re-runs land once. (A second, open fault goes further today: rows committed seconds earlier are intermittently invisible to a later request. Where a decision depends on a row you just wrote, re-read it and fail loudly rather than acting on an empty result.) CREATE GRAPHsnapshots its edges — commit the edge rows first, thenDROP+CREATE.- Money is
Numeric(18,2), never float. Never add across currencies: totals are per currency. atis reserved — name timestamp columnsevent_at,created_at,sent_at.
Derive, don't store
State that a date already decides is computed on read: a quote is expired because
valid_until < today, not because a nightly job set a flag. Every derived state is one less job,
one less way to be wrong, and one less thing to migrate. Store what a person decided; derive what
follows from it.
4 · Identity and visibility
- No auth code in the app. The gateway does PKCE and injects
X-Dodil-User;erp_platform.authtrusts that header and gates by permission. A brick never verifies tokens. Forged headers are stripped at the edge — verify that once, per deploy. (On the older package pathauth.pyis copied and extended with the customer's visibility helpers.) - Visibility is decided inside the use case, from
caller— its first parameter. That is why an agent sees exactly what its person sees, and why a new door cannot bypass it. - Agents connect two ways — never mix them. A machine (a job, Claude Code, the Claude API)
uses a dodil-appid service client (
client_credentials). A person connecting Claude.ai or ChatGPT signs in (authorization_code+ PKCE): a service-client token carries no person, so row scoping has nothing to bind to and every user's agent would see every row. SetDODIL_USER_POOLandPUBLIC_BASE_URLin every deploy — the first tells an agent where to sign in. Claude.ai registers itself (DCR) and starts pending until approved (appid 0.14, 2026-10-16). - Permissions are named
<namespace>:<object>:<verb>(crm:quote:approve,bp:partner:write) — the namespace is the module's declared short form, which may be shorter than its id. The CRM package predates this and still shipsquotes:approve; generated systems use the namespaced form. A module joining an existing system merges its roles into the pool catalog:roles get→ merge →roles set.roles setreplaces the whole catalog, so writing only your own roles silently strips every other module's access. - Roles map to permissions in one place (
common.py), never scattered through routes. - Visibility is a decision, and it is recorded. "Everyone sees everything" is a legitimate
answer for a five-person team and a wrong one for a sales floor — ask, and write the answer in
the design file. Where rows are owned, scope every owned read by the caller's identity and
return not found rather than forbidden: 403 confirms the record exists. The packages ship the
helper (
owner_scope) but not the wiring, so this is a build step, not a freebie. - The UI hides what the role cannot do, and the route refuses it anyway. The screen is convenience; the route is the control.
5 · Use cases — each one a route and a tool
- Declare a use case once; it is served as an API route and an MCP tool, with one permission
check, one identity parser and one refusal shape. Tools are use cases (
accept_quote), never table CRUD (update_quote_row); the docstring is what an agent reads to decide whether to call it. A route only another brick calls istool=False, behind a permission no person role holds. - Workflow operations, named for what the business does:
POST /quotes/{id}/send,POST /partners/promote, notPATCH /quotes/{id}with a magic status field. - The module that owns a table is its only writer, across module boundaries. Another module
calls the owner's use case — in process inside one app, as the caller (
SEAM.get(path, caller=caller)), with no service client; over HTTP with a service client only between separate apps; it never writes into the owner's tables. (Inside one module's own app, a sibling router writing a task row is fine — the rule is about the seams.) On DataK3 a foreign key is accepted at DDL and never enforced, so the sole-writer path is the integrity mechanism — and note it only holds for callers coming through the app: any principal withk3.editorcan write the table directly. - A refusal explains itself in the customer's words — "Q-1042 is sent and can no longer be edited — use Revise to make a new revision". The refusal text is part of the story's acceptance criteria, not an afterthought.
- Guards live on the write path, because column constraints are limited. A closed period or a frozen quote is enforced by the route, which is exactly why every write must go through it.
- Every generated app ships
POST /as a self-check that opens the bucket with its own credential and reports row counts.ignite invokecalls it; a public-URL 401 proves only that the gateway is up. The older packages expose onlyGET /healthz— add the self-check as you generate. GET /healthzdoes no database round trip — the pod must report healthy before credentials resolve.
6 · Workflows are data, not code
States and transitions belong in the design file; the tuning belongs in rows:
| In code | In rows (the customer changes it without us) |
|---|---|
| the transitions that exist, and who may make them | stage names, order, probability, forecast category |
| that a discount cap exists and is enforced on every edit and on send | the cap: 10%, 20% for VIP, per customer |
| that a quote freezes when sent | validity days, follow-up days, expiry warning days |
| that a lost deal needs a reason | the list of reasons |
The test: can the customer's office manager change it on a Settings screen? If yes it is a row.
If changing it would change what the tests assert, it is code. Rows are the first depth, not the
only one: what rows cannot say is a capability in the customer's own brick (depth 2), or their own
copy of the brick (depth 3), recorded and signed (lifecycle.md, three depths).
Stable ids are designed, not derived. Every process, step, transition and gateway carries a
key (a slug, right_to_work_check), unique in its process and frozen once published, and every
BPMN id is derived from keys alone, so inserting or removing a step changes no other element's id.
The scheme brick-hr 0.4.0's show_playbook_bpmn ships (one process per document, so the process
is not in the id):
| Element | Id | Example |
|---|---|---|
| a step's task | Task_<key> (plus dodil:step="<key>") | Task_send_contract |
| a sequence flow | Flow_<source id>__<target id> | Flow_Gateway_approve_rate__if_end__Task_send_contract |
| a step's condition | Gateway_<key>__if … Gateway_<key>__if_end | Gateway_approve_rate__if |
| a record a step requires | Gateway_<key>__requires_<record> | Gateway_create_account__requires_right_to_work_check |
| a fork and join around a step | Gateway_<key>__fork, Gateway_<key>__join; Gateway_start, Gateway_end | Gateway_create_account__fork |
| a step's timing rule | Timer_<key> | Timer_right_to_work_check |
| a step's helpers | Task_<key>__draft, Task_<key>__record_<record> | Task_welcome_email__draft |
| the process, its version | Process_<playbook>_v<n> (the only versioned id) | Process_employee-onboarding_v2 |
A step that differs from the pack's reference carries dodil:deviation="variation|warning", and
the process names the APQC PCF id it implements (dodil:pcf="10469"). A renamed concept gets a new
key; the old one is deprecated, never reused. Positional ids (Task_3) shift when a step is
inserted and silently break scenario paths, change items, mining joins and the upgrade re-apply at
once (brick-hr before 0.4.0, 2026-10-18). The signed hash and erp-compose signoff verify's diff
both work on these ids.
7 · Jobs — there is no scheduler
Ignite is request-invoked. A system story ("every 6 hours…", "every night at 02:00…") must say where it runs:
- Derive it instead (§3) — the cheapest job is the one that does not exist.
- In the same request that caused it — a follow-up task created when the quote is sent, keyed
followup:<quote_id>so a double click makes one task. - An always-on worker pinned warm (
--reserved 1 --max-replicas 1) running its own loop. - An external clock calling a route.
Every job is idempotent: "running it twice changes nothing" belongs in the story's acceptance criteria, not in a comment. A job that can fail half-way records what it processed, so the retry knows where to resume.
8 · Screens come from stories
Each person story names an actor and a screen; the screens are the list of those, not an invention of the build phase. Each screen names the stories it serves — usually several — and the design file carries that table, so a change request can name the screens it touches before any code is written.
What the screens must do is fixed; how they are rendered is a judgement:
- The default is Vite + React + TypeScript, talking only to the routes and never to DataK3, with a client generated from the OpenAPI schema rather than hand-written fetch calls. Reach for it when there are many screens, live updating, or a team using it all day.
- Server-rendered templates are the right answer for a small internal tool — two users, a handful of screens, no offline behaviour. One less build, one less thing to deploy. State the choice in the design file; both are the pattern, and a needless React app is its own cost.
- Either way the route is the control. The screen hides what the role cannot do; the route refuses it regardless.
- The rule is visible where it bites: the discount bar reads "8.00% of 10%", the banner says a machine quote needs a site visit first. A rule the user cannot see is a rule they will fight.
9 · Tests are the stories
A brick ships three layers (BRICKS.md "Tests"): rules without a database (one test per sentence a business person would agree to), both doors over the real app (the same outcome through the API and through MCP, the 404 for another person's row, the 403 without the permission), and a scenario catalogue. What they assert, against a dev bucket where they need one:
- Acceptance — one test per story id, named for it (
test_s06_send_freezes_sets_validity_…), asserting its Given/When/Then. A story with no test is a story nobody verified. - Access — per role: sees the right rows, others' rows are not found, excluded actions refused, no gateway identity is rejected.
- Invariants — the trial balance nets to zero, a re-import changes nothing, a frozen record refuses edits, totals are computed not typed.
Scenarios prove a business promise end to end, through the doors only — never by writing a
table. Each has an id SC-<MODULE>-nn, names the stories it proves, and runs in process (the
whole composition, each brick on SQLite, real ASGI and MCP, clock and faults under control) and
live against a deployment as real people. The harness also generates the negatives a
hand-written suite forgets: the permission matrix (every persona × use case × both doors), an
idempotent replay of every command marked so, and mass assignment. A command with no recipe fails
its probe. The crm-demo composition ran 486/486 security probes and 28/28 stories live on
2026-10-17 — the bar for "validated together".
Coverage is checkable because both sides carry the story id. A customer CRM built this way carried 31 tests at first release and 36 after three change requests; in every run its failures were the same three platform faults rather than app faults — which is only knowable because each test names the requirement it verifies.
10 · Scale and failure
- The tables reader serves 16 concurrent reads. Above that:
ConfigurationLimitExceeded. Hold a client-side semaphore below it and retry transient failures. That pair took a 60-way write storm from lossy to clean (2026-09-03).erp_platform.dbholds it as the pool size (DB_CONCURRENCY, default 8); a package on the older path must add it itself. - Both wires are TLS:
sslmode=requireandbolt+s://, neverprefer(its silent plaintext fallback discloses a service-account secret) orneo4j+s://(refused: routing is not supported).verify-fullis the production posture; libpq-based drivers need an explicitsslrootcert. (Verified 2026-09-09.) - Retry the transient, fail the deliberate. Match on SQLSTATE (
40001write conflict,08006,58030,XX000) and, because object-store blips are sometimes misclassified as syntax errors, on the message marker too. - One pinned-warm replica for anything holding a loop or a counter.
data table compactis a performance step, never a correctness step. There is no freshness flag.- When the platform misbehaves, reproduce it, write a brief with a script that exits non-zero while the fault is live, and stop. Do not turn a workaround into a design rule, and do not document the bug as a convention.
11 · Change, migration, versioning
- A change request is a story change first — the design file moves before the code, and the CR records who asked, who approved, the impact with real counts, and how to undo it.
- Versioned migrations per brick (seemo, 2026-10-18; design
bpmn-driven-lifecycle.md§9):src/brick_<name>/migrations/NNNNN_<name>/up.sql+down.sql(+meta.yaml: kind, reason, the CR or deviation it serves,requires,drop_after,destructive_down), numbered monotonic per brick; a fork's own from9001. One journal per brick, named with its prefix (hr_schema_migrations,ct_…,bp_…), because every brick shares one bucket.erp-migrate up [--to N]/down --to Nmove a stage forward and back, verifying the schema after each step;erp-compose migrateruns bricks in dependency order going up (business-partner before hr) and in reverse going down.schema.sqlbecomes a generated snapshot. A changed checksum on an applied migration is a hard failure; a destructive down needs--confirm-destructiveand a backup. Not built yet: today each brick ships one additiveschema.sqlapplied byerp-migrate(erp-platformmigrate.py); write additive changes there, by hand, under the rules below. ALTER TABLE … ADD COLUMNis the only cheap schema change — there are no views — but it currently destabilises reads on that table for minutes afterwards (an open fault: the new column is intermittently invisible, and files written before it fail a schema match). Add it at the END of itsCREATEand as an explicitALTER(CREATE TABLE IF NOT EXISTSskips an existing table), migrate the test copy first, and expect a noisy few minutes.- A rename or type change is: add → backfill → switch reads → drop later. Nothing is dropped in the release that stops using it.
ignite version rollbackreturns the app to an earlier deployed version; the data is not versioned with it — which is why the go-live step exports the affected tables first.- Dev bucket, then live, and live only on an explicit "go live".
12 · Anti-patterns
| Smell | Why it hurts | Instead |
|---|---|---|
A CRM-only customers table when invoicing is on the roadmap | two customer lists, reconciled by hand forever | reference the shared partner from module one |
SERIAL / autoincrement | no sequences here; the ORM's RETURNING path returns nothing | natural or derived keys |
| A nightly job to set a status a date already implies | a job, a race, and a wrong answer between runs | derive it on read |
| Writing another module's table "just this once" | the sole-writer rule is the only integrity there is | call the owner's route |
roles set with only your module's roles | silently strips every other module's access | get → merge → set |
| Hard-coded stage names, thresholds, terms | every customer needs code changed for a setting | rows the customer can edit |
| 403 for a record the caller may not see | confirms the record exists | 404 |
| Tests named after functions | nobody can tell which requirement is unverified | name them after story ids |
| Changing a brick's code with no recorded intent | no upgrade path: nobody knows what to re-apply | fork through dodil git repo fork, record the change items, let the agent re-apply them on each tag |
| A deviation from a best practice built without its warning signed | the customer finds out at audit | show the warning and its source, record the acceptance, then build |
| A change that breaks the foundation (a renamed key, a weaker permission, a copied platform) | intent, mining and security stop joining up | refuse it, with the reason; re-time or re-lane instead where the declaration allows |
| An upsert as a claim (a number, a one-time action) | every concurrent writer "wins" | a plain INSERT; the losers get 23505 |
| A person's agent given a service-client token | no person to scope rows by: it sees every row | the person signs in (authorization_code + PKCE) |
| Verifying a deploy by loading the public URL | proves the gateway is alive, not the app | POST / self-check + pod logs |
13 · Composing modules into one system
One module is a project; four modules is an ERP. What makes the fourth one cheap:
Install in master-data order. A module that owns a shared record comes before the modules that reference it. For a typical business:
business-partner the customer/supplier record (owns it)
└── crm sells to them (references)
└── ar invoices them (references; CRM hands it the accepted quote)
└── gl posts the invoice to the books (owns the ledger; AR calls it)
Getting this order wrong is the one mistake that cannot be fixed cheaply later: two customer lists are a migration, not a refactor.
One app, one entry each. Adding a module to a running system is one more brick in
compose.yaml (erp-compose check refuses a table or tool collision and version drift), its
prefixed tables, and its roles merged into the existing pool catalog. Not a new app, not a new
bucket, not a second sign-in — unless one of the split reasons in §2 applies.
Cross-module reads are joins; cross-module writes are calls. The point of one bucket is that the collections list can join invoices to the partner and to the sales owner in a single query, with no ETL. The moment a module writes another's tables, that guarantee is gone, because the owner's guards no longer run.
Verified, 2026-09-18 — four modules in one bucket. business-partner, crm, ar and gl
installed in the order above into one DataK3 bucket: 71 tables, no name collisions, and a
fifth package (itsm) adds 23 more with none either. A deal accepted in the CRM promotes the
party, raises the invoice and posts the journal — one query joins all four
(code/crm-suite-app/v1/tests/test_suite.py, 10 tests). Two things the exercise found, both
worth knowing before you repeat it:
- The join needed a
CAST.business_partner.bp_idis a BIGINT (a graph walk needs an integer start node) andar_invoices.bp_idis a VARCHAR. The un-cast join does not return wrong rows — it fails outright. Agree the type of a shared key across modules before the second one stores it, because afterwards it is a data migration. - The prefix rule above is followed by 21 of 68 module-owned tables.
quotes,products,tasks,ledger,periodsandjournalsall sit unprefixed, which by this document's own convention claims they are shared master data. Nothing collides today; the convention that would keep it that way is not actually being applied.
Shared settings stay where they are owned. Tax codes belong to the ledger once the ledger exists; payment terms belong to invoicing; a customer's currency belongs to the partner record. Two modules writing the same setting is the same bug as two customer lists, one level down.
Test the seams, not just the modules. A suite gets one test per integration story that runs both sides — an accepted quote produces exactly one draft invoice, and posting it twice changes the ledger once. Those are the tests that fail when someone "just adds a column" months later.
Each module keeps its own design file. ERP_CRM.md, ERP_INVOICING.md, ERP_FINANCE.md. A new
module reads them all before designing anything, which is how it learns what a customer is, who
writes it, and which stories already cross into its territory.
14 · Maturity — what a module claims about itself
A module's maturity is a promise to whoever picks it up, so it has criteria:
| Level | Means | Earned by |
|---|---|---|
| planned | a spec exists; no code | the spec passes check:modules and names its gaps |
| reference | code exists and runs against a bucket | the package installs, its schema applies, and its routes answer |
| tested | every story has a test, and they pass | the acceptance, access and invariant suites run green on a dev bucket |
| deployed | it has run behind the gateway, for a real person | the self-check passes through ignite invoke, forged identity headers are stripped, and someone signed in per role |
Two rules keep the ladder honest: a module never claims a level it has not been through, and
known gaps are published with it (reference.known_gaps), because a reference that hides what
it does not do costs the next person a day.
A new module starts from the skeleton at
/modules/module.template.yaml, not from a blank file — the
sections are the pattern in question form.
15 · Processes as BPMN
Every module's workflows are published as BPMN 2.0 at /modules/<id>/process.bpmn (linked as
process_bpmn in /modules/index.json and the spec): one pool per entity, a lane per actor, a task
per transition. It is generated from module.yaml; never edit the .bpmn, change the workflow
and it follows. It describes the software; the bricks do not execute BPMN. The customer's own
configurable process is exported from their rows the same way: brick-hr's show_playbook_bpmn.
A module with no brick is drawn the same way from its design: erp-compose bpmn --from ERP_<MODULE>.md (erp-platform 0.8; the Workflows format in template.md), and every brick serves
its process as show_process_bpmn (erp-platform BRICKS.md, "The process view").
16 · The shape of a good module, in one paragraph
One bucket, one sign-in, one app composing pinned bricks, one /mcp; the public app only for
links. The customer's business in rows where rows can say it; every deviation drawn, signed and
re-appliable. Stable keys on every process element. Tables prefixed unless they are shared
master data with a single writer. Natural keys, idempotent writes, money as exact decimals, derived
state over stored state. Every rule enforced on a write path the UI merely mirrors. Every scheduled
thing either derived away or given a named home. Every story carrying its own test, and every
decision carrying its date and its author in ERP_<MODULE>.md. Everything a customer is likely to
argue about — stages, thresholds, terms, reasons — living in rows they can edit, and what rows
cannot say in their own brick or their own signed fork.