erp-platform
The substrate every DODIL brick is built on. A brick is one bounded piece of business software — quotes, parties, a ledger — that owns the tables it writes. Bricks are composed into products (a CRM is a composition of bricks, not a repo), and the same bricks run as one app or as separate services without a code change.
This package gives a brick these things, so no brick re-implements them:
| Module | What it does |
|---|---|
bricks | Declare a use case once → it is served as an API route and an MCP tool for agents (Claude, ChatGPT), with one permission check, one identity parser and one refusal shape. Agents with no person signed in are sent to the brick's user pool (RFC 9728). 0.5: Brick(ids_as_strings=True) returns every integer *_id as a string; use_case(if_match="version") lets an If-Match header carry the optimistic version, etag="version" sends it back on a query; use_case(tool=False) is a route only (a seam another brick calls); control characters are refused in every string; a body past ERP_MAX_BODY_BYTES (5 MiB) is 413; a crash through MCP says 500: internal error (request id …). |
repository | Load an aggregate whole; save it whole and idempotently — stale rows deleted at every depth, creation columns kept. Opt-in optimistic versions (Aggregate(..., version="version")): save(expected_version=v) writes v+1 or raises Conflict — 409 "changed since you opened it" through both doors — race-safe on DataK3 by a plain-INSERT claim. retry_on_conflict(fn) (0.5) re-runs a unit of work whose caller sent NO version, so two concurrent edits both land instead of one being lost. 0.9.5: claim_tables(...) — a brick declares the tables it claims with a plain INSERT; a duplicate key on one (DataK3 reports a racer's at COMMIT) is a 409 Conflict, never a 500. 0.9.6: a load is ONE round trip on psycopg 3 (the part SELECTs pipelined) and returns .snapshot; load_many(conn, keys) reads N roots with one query per part; save(conn, rows, snapshot=loaded, ...) writes only the DIFF — new/changed rows, one DELETE per table — so an unchanged save is 0 statements (3 when versioned: check, claim, version), measured on DataK3 for a 14-part aggregate: load 1,340 → 440 ms, unchanged save 3.7 s → 190 ms. Without snapshot= a save is unchanged. ERP_PIPELINE=0 / ERP_PIPELINE_WRITES=0 turn pipelining off. |
seam | Call a neighbouring brick — in-process at tier 1, over HTTP at tiers 2–3 — with the same reply either way. caller= (0.5) asks as the person, with their row scope: in process the neighbour sees exactly the caller; across apps the machine token plus X-Dodil-Acting-For, honoured only from a service holding erp:seam:act_for. |
types | (0.5) Bounded input types for use-case parameters — BigId (a 64-bit id: digits or a number in, type: string, format: int64 in both schemas), Name/Email/Note/text(n), IsoDate/IsoTime (1900–2199), Amount (< 10^15, ≤ 3 places), Quantity, Percent, Limit. A caller's mistake is a 422 naming the field, never the engine's 500. |
idempotency | (0.5) Idempotency-Key (header) / idempotency_key (MCP argument) on every command that makes something new (idempotent=False): a repeat of the key by the same caller replays the first answer (Idempotent-Replayed: true), the same key with another request is 422, one still running is 409. A plain-INSERT claim in erp_idempotency (platform.sql), the capability claim's measured mechanics. |
requestlog | One id per request (X-Request-Id: the caller's if sane, minted otherwise), returned on every response — 401s and 500s included — and sent on by every seam; one JSON line per request on erp_platform.request (use case, door, pseudonymous sub, retries, outcome) that never carries bodies, arguments, tokens or addresses. A use case reads the id with requestlog.request_id(). |
auth | The identity the gateway mints (X-Dodil-User), row scoping (owner_scope), permissions. 0.9.2: module:entity:action with segment-wise wildcards in a grant (pj:*:read, pj:item:*, *:*:*; a malformed grant is ignored and logged once), machine permissions (erp:seam:act_for + Brick(machine_permissions=…)) passing on an exact grant only, the role-name fallback removed, owner_scope(see_all="<ns>:<root>:read_all") deciding by permission; erp-compose check [--roles FILE] [--strict] prints each role's effective permissions per brick and warns on a wildcard reaching an approval or destructive command, and on role-name decisions in a brick (see Permissions). |
money | One money type: Decimal to the currency's ISO 4217 minor unit (JPY 0, EUR 2, KWD 3; 2 for a code not listed), HALF_UP, never mixes currencies, splits so parts sum back to the whole. Rate (4 places, per hour/day/month/year/fixed) computes EXACT amounts that are rounded once per payable line (round_once), with the rounding mode a parameter. Floats are refused. |
work | Tracked work as pure domain code: TimingRule (anchor ± days or working days, at a local time in an IANA zone → aware UTC, DST-correct), recompute, board (overdue/today/this week in the person's zone), chasing cadences, and now() — the business clock a demo may move (ERP_DEMO_CLOCK_OFFSET), never in prod. |
capabilities | What a brick may ask another to DO outside (capabilities.yaml, shipped in the package). @BRICK.capability(name) makes a provider — route, MCP tool, claim/lease/replay, dry run, action log; CapabilitySeam(name) is the consumer's side, returning a ManualTask when nothing provides it. |
public | The public-link door: what outsiders reach with no login (a signer, a starter's intake, a client approving, mail opens and clicks). One platform-owned table (erp_public_link); BRICK.links.issue(...) returns a link once and stores only its sha256; @BRICK.public(grant, subject_kind=...) registers what it shows and does; single use by a plain-INSERT claim; the same 404 for every failure. Served by the composition's separate public app. |
compose | Bricks into one product from a compose.yaml: refuses table/tool collisions and version drift, wires seams (found anywhere in a brick's package) and capabilities (capabilities: map; no provider = manual), serves tier 1 as ONE app with ONE /mcp — and, with --public, the public app; erp-compose check|plan|migrate|serve [--public]. 0.6: erp-compose init <module>… builds a working composition in one command — reads dodil/erp-method (the module's reference.repos and brick_entries), writes requirements.txt, compose.yaml and tests/test_composition.py, installs into .venv, and runs the check and the test; idempotent, never overwrites an edited file without --force. 0.8: the customer's repo holds only what is theirs — each brick and erp-platform is PINNED (brick-hr @ git+https://git.dodil.io/dodil/erp-brick-hr.git@v0.5.0), never cloned in; --read-copy clones them to bricks/ to read (git-ignored); 0.8.2: a master-data brick's process_view: "none: <reason>" in erp-method's brick_entries is written into compose.yaml, so check passes with bp; erp-compose own <brick> --reason "…" copies one into owned/<repo>/, pins it there and records owned: {path, upstream, reason} in compose.yaml. Run from outside the folder's .venv (uvx at a newer erp-platform), every command finds the bricks in .venv, owned/ and bricks/. |
signoff | (0.7; 0.8: record --from ERP_<MODULE>.md generates and records the source the gate regenerates from, --scenarios/--deviations optional, numbered versions; verify --all for CI; 0.8.1: one sign-off PER PROCESS — signoff/<module>/<process>.json (process = --process, else the playbook id, else the BPMN's single process), numbered history per process in signoff/<module>/history/, record adds or supersedes ONE process and never touches the module's others, verify --all and the gate check every signed process; the 0.8.0 single-file layout is moved in on first read; 0.8.2: verify --all and the gate regenerate a playbook from uvx with the bricks of the folder's .venv, quietly; 0.9.1: canonical v2 ignores the generator's provenance phrase "generated from <file>" in documentation — a brick's process view drawn from process.yaml matches a spec signed from module.yaml; record writes "canonical": "v2", verify accepts v2 or the legacy form and says which matched, so every signature recorded before keeps verifying) The customer signs the process, not the drawing. erp-compose signoff record --module hr --bpmn process.bpmn --scenarios scenarios.json --deviations deviations.json --approver "<name>" [--file ERP_HR.md] writes signoff/<module>/<process>.json (bpmn_sha256, scenario ids, the accepted deviations, approver, signed_at), a copy of the signed BPMN, and the Processes / Scenarios / Deviations sections of ERP_<MODULE>.md; it refuses a foundation deviation and a scenario that does not walk. The hash is over a canonical form: diagram layout (BPMNDI) and the exporter stamp removed, children sorted by id, C14N 2.0 with prefixes rewritten — moving a shape keeps it, any process change breaks it. erp-compose signoff verify --module hr --bpmn current.bpmn exits non-zero on a mismatch with an element diff by stable id (tasks, flows, gateways, timers: added / removed / changed, and what changed). Standard library only. |
bpmn | (0.8) A module's workflows as BPMN 2.0, with no brick needed. erp-compose bpmn --from module.yaml|ERP_<MODULE>.md [--workflow ENTITY] --out process.bpmn: the stable ids of pattern.md (Task_<key>, Flow_<a>__<b>, Gateway_<key>__if, Timer_<key>, Process_<id>_<entity>_v<n>), lanes per actor, DI so viewers draw it, XSD-valid, byte-stable. The ERP_<MODULE>.md form is the ## Workflows table erp-method's template.md documents (self-steps in row order, optional when gated, external actors as waited-on receive tasks with message flows, Terminal: states); a malformed table fails with its line. Brick.declare_process("process.yaml") serves the same as show_process_bpmn (BRICKS.md, "The process view"), and erp-compose check asks every brick for one. |
gate | (0.8) Nothing is built from an unsigned design. erp-compose plan / migrate (and init where ERP_<MODULE>.md exists) REFUSE a module with no signed process (signoff/<module>/<process>.json), a pending unsigned change, or a signed BPMN that differs from the one regenerated now — naming which way: DRIFT (changed without a new sign-off) or NOT IMPLEMENTED (signed, not built). --unsigned "<reason>" goes on under a banner and records the reason in signoff/<module>.unsigned.json; serve warns. CI: erp-compose signoff verify --all. |
change | (0.8) The signed BPMN drives every change; the code follows. erp-compose change propose --module M --bpmn proposed.bpmn|--from ERP_M.md writes the change view (signoff/<module>/<process>.change.bpmn, elements marked dodil:change="added|removed|changed") and a plain-words summary (what changes, scenarios that no longer walk, deviations that appear or go, foundation refusals) as signoff/<module>/<process>.pending.json; change withdraw drops it. Signing it makes a new NUMBERED version: signoff record is append-only version control (signoff/<module>/history/<process>.v<n>.json + .bpmn, a reason from v2 on, reverts_to when a new version equals an old one, implemented_by: [NNNNN_<migration>]). |
democlock | The demo clock as a route a brick mounts (democlock.mount(BRICK)): GET/POST /<brick>/demo-clock, permission <brick>:clock:set, so a DEMO deployment can be walked through time; 404 — and work.set_clock refuses — under DODIL_ENV=prod|live. |
scenarios | The end-to-end scenario harness: SAP-grade scenarios (SC-<MODULE>-nn, mapped to stories) written ONCE as Given/When/Then over the composition's doors, run in process (the whole composition, each brick on SQLite, real ASGI + MCP, the clock and failures under control — seams down or slow, no / failing / unconnected providers, transient SQLSTATEs, a crash between steps, N at once) and live (a deployment, real tokens, bucket stamped demo). Generated probes: the permission matrix (persona × use case × both doors, 401/403/404), idempotency replay, mass assignment. Console, JUnit and Markdown reports, secrets redacted. erp-scenarios list|run. |
livecheck | Run a brick's real use cases on a real DataK3 bucket with only a CLI login — the check SQLite cannot make. |
scaffold | (0.9) A module spec → a brick skeleton, deterministically (no model). erp-compose scaffold modules/<id>/module.yaml --out <dir> [--entity E] [--dist erp-brick-<id>] [--force] writes a brick in templates/brick-example's shape that installs, passes its own tests and composes: one domain module per aggregate root (an entity whose natural key extends another's is a part of it) with typed dataclasses, value objects for states and enumerated attributes, one event per transition and check_<story> invariant stubs; one use case per workflow transition (route + MCP tool, the spec's permission strings, if_match on commands, idempotent per pattern.md), get/list per root, keep_<root> for a root no transition creates, declare_process over a process.yaml that is the spec's workflows (the BPMN equals erp-compose bpmn --from module.yaml); prefixed tables with a PRIMARY KEY each, NUMERIC(18,2) money and the <root>_versions claim table, one repository per aggregate, schema.sql and migrations/00001_init/{up,down}.sql (journal <prefix>_schema_migrations; no migrator for that layout yet — erp-migrate --schema applies schema.sql) from the same column model; config_rows → a seeded <prefix>settings table; ai_steps → capability seams in mode suggest with the manual fallback; tests: one strict xfail per story, both doors, schema, composition (with business-partner), a scenario catalogue skeleton. Byte-stable; never overwrites a file that differs without --force (and then writes nothing); prints entities, use cases, stories → xfails and the TODO count by kind (rule, visibility, permission, test, scenario, ai) — the agent's work list. scaffold --update is out of scope: once filled in, the code is the brick's own. 0.9.1 (what the first real scaffolded brick, erp-brick-projects, found): settings are ONE TYPED ROW PER SETTING (int / bool / date / str / an AI step's mode; from a settings: list on the config row, else one per clause of the settings table's prose) in the spec's own table (pj_setting); the optimistic column is always version and a spec version attribute is reused, never shadowed; an unquoted off in modes_allowed/mode is normalised to "off" and on/true is refused naming its line; a configuration entity (master_data: … (configuration)) is a keyed table edited under the settings permission and an append-only history entity is appended on every aggregate save — neither is a writable aggregate; a permission the catalog lacks is TODO(permission):<what>, held by no one; ai_steps wire to ai.suggest / ai.draft_message / ai.flag_risk with propose() and the audit fields; generated probes (tests/test_probes.py): the role × command matrix, another owner → 404, every command in every state its workflow forbids → 409, under a terminal parent, separation of duties, seams only on the transitions that name them, replay, mass assignment — what the skeleton enforces is a green test, what needs the brick's rule is xfail(strict); the scenario catalogue runs in process with CAT.permission_matrix(). |
contract | (0.9.3) Every seam call in a brick's tests is checked against the CALLEE's signed spec. check(callee, method, path, body, caller=) lists violations from the callee's module.yaml seams: (route and method, required fields, shared key types from shared.yaml, accepted values, derived_id(...) journal ids). **0.9.4: where the seam carries a structured request_schema: (erp-method pattern.md section 13) the call is checked against it EXACTLY: required, type (shared key or basic), values, array items, derived, `in: path |
lint | (0.9.3) erp-lint <brick-dir> [--method-dir DIR]: the bug classes audits kept proving, caught statically. L1 a business date from the UTC/naive clock; L2 an approval comparing one identity (email or sub, not both); L3 a broad except that carries on in the same transaction; L4 int() on a VARCHAR shared key; L5 an unbounded money × quantity product; L6 SERIAL/nextval/max(..)+1 numbering; L7 a neighbour brick's fake with no contract check. file:line: id name — why, exit 1 on findings; # erp-lint: allow <id> <reason> keeps one line. |
card | (0.9.3) erp-card <module.yaml> [--shared shared.yaml] [--stories FA-16..FA-26,FA-30] [-o out]: the build card a builder reads instead of the whole spec — stories one line each, routes, the seams it owns and calls (request lines verbatim: they are the contract), entities and keys, permissions, config_rows, jobs, the sap_parity rows that are not covered, and the bug-class checklist. **0.9.4: `--stories ID[,ID |
db, migrate | The DataK3 engine over the Postgres wire (TLS), and erp-migrate: plan, apply, verify, record versions; --platform applies the platform's own tables (platform.sql) too. |
after, testing, pool | (0.9.6) erp_platform.after_response(fn, *args, conn=conn) — work after the use case's transaction committed and its answer was sent, on both doors; never after a rollback or a refusal; a failure is logged, never answered (join_after_response() in tests). Refused(409, reason, gate=, fix=) answers {detail, gate, reason, fix}. erp_platform.testing.count_statements(engine) — a statement-budget test's counter (statements and round trips). pool.pooled_engine — the db/livecheck engine: LIFO, pinged only after DB_PING_AFTER idle seconds, recycled after DB_POOL_RECYCLE. |
Permissions
A permission is module:entity:action: three segments, each [a-z_]+ (pj:item:accept). A pool role (dodil-appid)
expands to a list of them in the token's permissions; auth.has_permission(user, perm) is the one rule, used by
both doors and by the probes.
- Wildcards are segment-wise, in grants only.
pj:item:*,pj:*:read,pj:*:*,*:*:*. No partial glob (it*), no other segment count, no capitals or digits: a malformed grant is ignored and logged once — never a crash, never a pass. A use case's REQUIRED permission is concrete:permission="pj:item:*"is refused when the use case is registered (and byrequire_permission). A required permission that is not concrete (the scaffold'sTODO(permission):…) passes no one. - Machine permissions never come from a wildcard.
auth.MACHINE_PERMISSIONS(erp:seam:act_for) plus everyBrick(..., machine_permissions=("pipeline:opportunity:link_source",)): what only a service client holds (a seam another brick calls, a scheduler's tick). They pass on an EXACT grant only —*:*:*runs every person command and none of these. A system-only step is a machine permission held by the scheduler's service client, never a role namedsystem. - Roles are the customer's; a brick decides by permission. There is no role-name fallback (until 0.9.1 a role
NAMED like the permission passed; nothing relied on it). Seeing every owner's rows is a permission too:
auth.owner_scope(caller, see_all="pj:project:read_all"). A brick ships default roles (the scaffold'stests/scenarios/roles.yaml); a composition maps them to its own. - Effective permissions.
erp-compose check compose.yaml [--roles FILE] [--strict]expands each role's grants against the composition's permission catalog (every use case's permission + the machine ones) and prints them per brick. It WARNS when a wildcard reaches a destructive or approval command the role does not name, when a person role holds a machine permission, when a seam-only command's permission is not a machine one, and on every role-name decision in a brick's source (caller["roles"],is_manager,owner_scopewithoutsee_all=…; exempt a line with# role-check: <reason>).--strictmakes the role-name findings problems. The roles come from--roles(a roles.yaml, or the pool's catalog:dodil appid roles get <pool> --org <org> -o json), elseroles.yamlbeside compose.yaml, elsetests/scenarios/roles.yaml; the deployed truth is the pool. - dodil-appid (0.15.1) accepts
*in a role's permissions ([a-z0-9:_.*-/], ≤128 chars) and copies them into the token verbatim; it does not check the segment shape, so a grant appid accepts can still be malformed here.
Tiers
| Tier | Shape | When |
|---|---|---|
| 1 | One app, all bricks in-process, one bucket | Most customers. Start here. |
| 2 | One app per brick, one shared user pool, one bucket | A brick needs its own scaling or exposure. |
| 3 | One app and one bucket per brick | Separate trust boundaries. Loses the cross-brick JOIN. |
The tier is configuration (<NAME>_BASE_URL set or not, a registration or not), never code.
Two apps per composition
| App | Serves | Sign-in | Service account | Migrates |
|---|---|---|---|---|
staff (erp-compose serve) | every brick's routes under /<brick>, ONE /mcp, /composition | the composition's dodil-appid pool | the app's, bucket-scoped k3.editor (+ ignite.model-user if a brick calls Models) | erp-compose migrate, once per release |
public (erp-compose serve --public) | /p/<token> (pages and acts), /o/<token> (open pixel), /c/<token> (click), /healthz, /readyz — nothing else | none (public_invoke) | its OWN, narrower: bucket-scoped k3.editor only, no Models role, no connector credential | never |
The public app runs with the access log off (its line is the raw path, which carries the token); put rate
limits for /p /o /c at the gateway. erp-compose plan prints both apps' environment.
Processes as BPMN 2.0
Each module's spec generates BPMN 2.0 for its lifecycles — one pool per entity, a lane per actor, a task
per transition — published beside the spec as /modules/<id>/process.bpmn on the module catalog, so the
diagram always matches the software and opens in Signavio, Camunda Modeler or any BPMN tool. Bricks with
configurable flows will expose their customer's own process as BPMN, generated from its configuration
rows: brick-hr's playbooks first, being built now as GET /playbooks/{id}/bpmn. The bricks describe
their processes in BPMN; they do not execute it.
Sign-off in CI
The customer's repo fails its build when the process and its signature disagree, either way: DRIFT (the design or code changed the process with no new sign-off) and NOT IMPLEMENTED (a signed version the build does not draw yet), and also when a module is unsigned or a proposed change is pending. One job:
# .github/workflows/ci.yml (the forge runs it; the check is named <workflow>/<job>: ci/signoff)
name: ci
on: { push: { branches: [main] } }
jobs:
signoff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
# the bricks, so a playbook's process (brick-hr) can be regenerated; a design-only module needs none
- run: python -m venv .venv && .venv/bin/pip install -q -r requirements.txt
- run: |
python -m venv /tmp/erp && /tmp/erp/bin/pip install -q "erp-platform @ git+https://git.dodil.io/dodil/erp-platform.git@v0.9.5"
/tmp/erp/bin/erp-compose signoff verify --all # non-zero on unsigned / pending / drifted / not implemented
# .dodil/deploy.yaml — CD waits for it. FULL check names (<workflow>/<job>), never `all`: a skipped job satisfies `all`.
when:
branches: [main]
require_checks: [ci/signoff, ci/test]
Run dodil git ci verify before pushing either file.
Start a brick
From a module spec: erp-compose scaffold modules/<id>/module.yaml --out brick-<id> (0.9) writes the skeleton in the
template's shape, so the agent writes only the business rules (grep -rn "TODO(" src tests). By hand: copy
templates/brick-example/ — a small working brick (quotes) with each layer and its rule
written down. Either way, read BRICKS.md. The template's tests run as part of this repo's, so it
always works against the current platform.
Install
Not on a package index yet. Pin a commit until releases are tagged:
pip install "erp-platform @ git+https://git.dodil.io/dodil/erp-platform.git@<commit>"
Configuration
| Variable | Used by | Meaning |
|---|---|---|
BUCKET | db | The DataK3 bucket. Required — no default, because a default writes to someone else's bucket. |
DODIL_SERVICE_ACCOUNT_ID / _SECRET | db | Deployed: the app's service account, exchanged for a pg-wire token. |
DODIL_TOKEN | db | Local only: your login token as the pg-wire password. |
DODIL_ENV | db, work | dev / test / prod; checked against the bucket's own stamp on first query. prod or live also makes the demo clock refuse to apply. |
ERP_DEMO_CLOCK_OFFSET | work | Demos only: moves work.now() (+3d, -2h30m, or an ISO time it starts at). Logs a WARNING on every read; raises under DODIL_ENV=prod|live. |
CAPABILITY_<NAME> | capabilities | Tiers 2–3: the brick that provides a capability (CAPABILITY_IDENTITY_PROVISION_USER=gws), found at its <BRICK>_BASE_URL. Unset or manual = no provider. erp-compose plan prints them. |
DODIL_USER_POOL | bricks | <org>/<pool> people sign in to; advertised to agents. (AUTH_ISSUER overrides.) Never on the public app. |
PUBLIC_LINK_BASE_URL | public | The public app's address, on a host the composition's mail is signed by (SPF/DKIM alignment). Both apps set it: the staff app to issue links, the public app to serve them. |
PUBLIC_REDIRECT_HOSTS | public | Hosts a TRACK click link may redirect to (co.example,*.shop.example), https only; checked at issue and again at click. |
PUBLIC_NOT_FOUND_TEXT, PUBLIC_MAX_FORM_BYTES, PUBLIC_MAX_UPLOAD_BYTES | public | The one "link no longer works" sentence (per composition, never per link — no oracle), and body limits (64 KiB forms, 10 MiB uploads; uploads need python-multipart). |
PUBLIC_BASE_URL | bricks | The address clients use; also the only Host the agent door accepts (plus MCP_ALLOWED_HOSTS). |
ERP_SCENARIO_SECRETS | scenarios | Live runs: the directory (files mode 600, refused otherwise) whose personas.json holds each persona's pool credentials (email + password, the appid password grant), service clients (client_id + client_secret), or a local issuer's key. Never printed or reported. |
ERP_MAX_BODY_BYTES | bricks | (0.5) The largest request body either door accepts (default 5 MiB); past it, 413. |
ERP_PIPELINE, ERP_PIPELINE_WRITES | repository | (0.9.6) 0 turns psycopg 3 pipeline mode off for loads / for a diff save's writes (default on). |
DB_PING_AFTER, DB_POOL_RECYCLE | pool | (0.9.6) Ping a pooled connection on checkout after this many idle seconds (default 10; 0 = every checkout); recycle after (default 1800). |
LOG_LEVEL | requestlog | Level for erp_platform.* (default INFO). With no logging configured, lines go to stdout as JSON. |
DEV_ALLOW_ANON, DEV_USER_* | auth | Local development without the gateway. Never set in a deploy. |
<NAME>_BASE_URL, SEAM_CLIENT_ID / _SECRET / _TOKEN_URL | seam | Where a neighbour brick lives at tiers 2–3, and the machine identity used to call it. |
Develop
python -m venv .venv && .venv/bin/pip install -e '.[test]'
.venv/bin/python -m pytest -q --tb=short
dodil auth use-org dodil && BUCKET=erp-platform-test python scripts/live_repository_check.py
BUCKET=erp-platform-test DODIL_ORG=dodil python scripts/live_version_check.py # optimistic versions
BUCKET=erp-platform-test DODIL_ORG=dodil python scripts/live_public_check.py # the public-link door
BUCKET=erp-platform-test DODIL_ORG=dodil python scripts/live_capability_check.py # the capability claim
BUCKET=erp-platform-test DODIL_ORG=dodil python scripts/live_idempotency_check.py # the Idempotency-Key claim
Scenarios (BRICKS.md, "How to write scenarios"):
erp-scenarios list tests/scenarios/catalogue.py # ids, stories, tags, which modes
erp-scenarios run tests/scenarios/catalogue.py [-k SC-SHOP-03 -k clock] [--shuffle 7] \
[--junit out/scenarios.xml] [--markdown out/scenarios.md] # in process
ERP_SCENARIO_SECRETS=~/.dodil-secrets/<composition>/scenarios \
erp-scenarios run <composition>/scenarios/catalogue.py --live --base-url https://… \
--public-base-url https://… --bucket <stamped demo> --org <org> # a deployment
BUCKET=erp-platform-test DODIL_ORG=dodil python scripts/live_scenario_check.py # the live runner on DataK3
The unit suite needs no bucket. The live checks run the real code on a real DataK3 bucket — run them after
changing repository.py, public.py, capabilities.py or scenarios/, and after any DataK3 release. The version and
public checks use a real engine (livecheck.engine(), your CLI login, never printed) because what they test
is decided at COMMIT.
Money columns
| What | Column | Why |
|---|---|---|
| A payable amount | NUMERIC(18,2); NUMERIC(18,3) where a three-place currency (BHD, KWD, …) can occur | Every currency's minor unit stores exactly in it. |
| A rate | NUMERIC(19,4), with its basis and currency beside it | A rate rounded to cents pays the wrong amount on every unit. |
| An exact (unrounded) amount | never stored | Round once with to_money / round_once, then store the Money. |
Known platform behaviour
- DataK3
ON CONFLICT DO UPDATEwrites every column inVALUESand ignores the SET list (dodilio/tables#417; fixed in DataK3 0.2.2, production is on 0.1.60.x).repository.upsertworks around it and stays correct after the fix — use it, not a hand-written upsert. - No read-your-writes inside an open transaction. Read what you need before writing; re-derive in a second transaction.
- A duplicate primary key can be refused at COMMIT, not at the INSERT. Two open transactions inserting one key both succeed at the statement; the second COMMIT fails 23505 (measured 2026-10-17). Optimistic versions and single-use links rely on exactly this, and recognise the error wherever it surfaces.
UPDATE … WHERE version = :vreports rowcount 1 to every racer before COMMIT; the losers fail 40001 at COMMIT. Never decide on that rowcount.- The pg wire returns a TIMESTAMP as an aware datetime (SQLite: naive). Compare times as naive UTC.
License: Apache-2.0, see LICENSE