dodil/erp-method

node_modules/.cache/erp-method/v0.1.15/modules/pattern.md

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.

ArtifactAudienceLivesAnswers
The spec — /modules/<id>/spec.yamlagentsthis sitewho uses it, what it must do, what it keeps, what runs on its own
The page — /modules/<id>/peoplethis sitethe same content, readable, in the customer's words
The code — the bricks in spec.yaml → reference.reposbothpublic git repos (git.dodil.io/dodil/brick-*), one release tag eachthe 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 unprefixed quotes table 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 RETURNING does 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 SQLSTATE 23505, 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 rest 23505, 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 SET accepts only EXCLUDED.<col> — put constants in VALUES.
  • An upsert overwrites every column in VALUES, whatever the SET list says (DataK3 tables#417, fixed in 0.2.2; production still runs 0.1.60.x, 2026-10-17). A created_at or an owner in VALUES is 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 plain INSERT that 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", or expected_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-Key header (idempotency_key through MCP), claimed by plain INSERT scoped 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).
  • DELETE requires a WHERE (WHERE TRUE for 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 GRAPH snapshots its edges — commit the edge rows first, then DROP + CREATE.
  • Money is Numeric(18,2), never float. Never add across currencies: totals are per currency.
  • at is reserved — name timestamp columns event_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.auth trusts 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 path auth.py is 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. Set DODIL_USER_POOL and PUBLIC_BASE_URL in 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 ships quotes: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 set replaces 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 is tool=False, behind a permission no person role holds.
  • Workflow operations, named for what the business does: POST /quotes/{id}/send, POST /partners/promote, not PATCH /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 with k3.editor can 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 invoke calls it; a public-URL 401 proves only that the gateway is up. The older packages expose only GET /healthz — add the self-check as you generate.
  • GET /healthz does 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 codeIn rows (the customer changes it without us)
the transitions that exist, and who may make themstage names, order, probability, forecast category
that a discount cap exists and is enforced on every edit and on sendthe cap: 10%, 20% for VIP, per customer
that a quote freezes when sentvalidity days, follow-up days, expiry warning days
that a lost deal needs a reasonthe 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):

ElementIdExample
a step's taskTask_<key> (plus dodil:step="<key>")Task_send_contract
a sequence flowFlow_<source id>__<target id>Flow_Gateway_approve_rate__if_end__Task_send_contract
a step's conditionGateway_<key>__if … Gateway_<key>__if_endGateway_approve_rate__if
a record a step requiresGateway_<key>__requires_<record>Gateway_create_account__requires_right_to_work_check
a fork and join around a stepGateway_<key>__fork, Gateway_<key>__join; Gateway_start, Gateway_endGateway_create_account__fork
a step's timing ruleTimer_<key>Timer_right_to_work_check
a step's helpersTask_<key>__draft, Task_<key>__record_<record>Task_welcome_email__draft
the process, its versionProcess_<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:

  1. Derive it instead (§3) — the cheapest job is the one that does not exist.
  2. 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.
  3. An always-on worker pinned warm (--reserved 1 --max-replicas 1) running its own loop.
  4. 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.db holds it as the pool size (DB_CONCURRENCY, default 8); a package on the older path must add it itself.
  • Both wires are TLS: sslmode=require and bolt+s://, never prefer (its silent plaintext fallback discloses a service-account secret) or neo4j+s:// (refused: routing is not supported). verify-full is the production posture; libpq-based drivers need an explicit sslrootcert. (Verified 2026-09-09.)
  • Retry the transient, fail the deliberate. Match on SQLSTATE (40001 write 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 compact is 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 from 9001. 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 N move a stage forward and back, verifying the schema after each step; erp-compose migrate runs bricks in dependency order going up (business-partner before hr) and in reverse going down. schema.sql becomes a generated snapshot. A changed checksum on an applied migration is a hard failure; a destructive down needs --confirm-destructive and a backup. Not built yet: today each brick ships one additive schema.sql applied by erp-migrate (erp-platform migrate.py); write additive changes there, by hand, under the rules below.
  • ALTER TABLE … ADD COLUMN is 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 its CREATE and as an explicit ALTER (CREATE TABLE IF NOT EXISTS skips 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 rollback returns 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

SmellWhy it hurtsInstead
A CRM-only customers table when invoicing is on the roadmaptwo customer lists, reconciled by hand foreverreference the shared partner from module one
SERIAL / autoincrementno sequences here; the ORM's RETURNING path returns nothingnatural or derived keys
A nightly job to set a status a date already impliesa job, a race, and a wrong answer between runsderive it on read
Writing another module's table "just this once"the sole-writer rule is the only integrity there iscall the owner's route
roles set with only your module's rolessilently strips every other module's accessget → merge → set
Hard-coded stage names, thresholds, termsevery customer needs code changed for a settingrows the customer can edit
403 for a record the caller may not seeconfirms the record exists404
Tests named after functionsnobody can tell which requirement is unverifiedname them after story ids
Changing a brick's code with no recorded intentno upgrade path: nobody knows what to re-applyfork 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 signedthe customer finds out at auditshow 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 uprefuse 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 tokenno person to scope rows by: it sees every rowthe person signs in (authorization_code + PKCE)
Verifying a deploy by loading the public URLproves the gateway is alive, not the appPOST / 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_id is a BIGINT (a graph walk needs an integer start node) and ar_invoices.bp_id is 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, periods and journals all 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:

LevelMeansEarned by
planneda spec exists; no codethe spec passes check:modules and names its gaps
referencecode exists and runs against a bucketthe package installs, its schema applies, and its routes answer
testedevery story has a test, and they passthe acceptance, access and invariant suites run green on a dev bucket
deployedit has run behind the gateway, for a real personthe 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.