dodil/erp-platform

README.md

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:

ModuleWhat it does
bricksDeclare 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 …).
repositoryLoad 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.
seamCall 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.
requestlogOne 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().
authThe 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).
moneyOne 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.
workTracked 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.
capabilitiesWhat 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.
publicThe 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.
composeBricks 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>]).
democlockThe 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.
scenariosThe 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.
livecheckRun 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, migrateThe 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 by require_permission). A required permission that is not concrete (the scaffold's TODO(permission):…) passes no one.
  • Machine permissions never come from a wildcard. auth.MACHINE_PERMISSIONS (erp:seam:act_for) plus every Brick(..., 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 named system.
  • 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's tests/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_scope without see_all= …; exempt a line with # role-check: <reason>). --strict makes 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), else roles.yaml beside compose.yaml, else tests/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

TierShapeWhen
1One app, all bricks in-process, one bucketMost customers. Start here.
2One app per brick, one shared user pool, one bucketA brick needs its own scaling or exposure.
3One app and one bucket per brickSeparate 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

AppServesSign-inService accountMigrates
staff (erp-compose serve)every brick's routes under /<brick>, ONE /mcp, /compositionthe composition's dodil-appid poolthe 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 elsenone (public_invoke)its OWN, narrower: bucket-scoped k3.editor only, no Models role, no connector credentialnever

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

VariableUsed byMeaning
BUCKETdbThe DataK3 bucket. Required — no default, because a default writes to someone else's bucket.
DODIL_SERVICE_ACCOUNT_ID / _SECRETdbDeployed: the app's service account, exchanged for a pg-wire token.
DODIL_TOKENdbLocal only: your login token as the pg-wire password.
DODIL_ENVdb, workdev / 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_OFFSETworkDemos 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>capabilitiesTiers 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_POOLbricks<org>/<pool> people sign in to; advertised to agents. (AUTH_ISSUER overrides.) Never on the public app.
PUBLIC_LINK_BASE_URLpublicThe 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_HOSTSpublicHosts 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_BYTESpublicThe 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_URLbricksThe address clients use; also the only Host the agent door accepts (plus MCP_ALLOWED_HOSTS).
ERP_SCENARIO_SECRETSscenariosLive 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_BYTESbricks(0.5) The largest request body either door accepts (default 5 MiB); past it, 413.
ERP_PIPELINE, ERP_PIPELINE_WRITESrepository(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_RECYCLEpool(0.9.6) Ping a pooled connection on checkout after this many idle seconds (default 10; 0 = every checkout); recycle after (default 1800).
LOG_LEVELrequestlogLevel for erp_platform.* (default INFO). With no logging configured, lines go to stdout as JSON.
DEV_ALLOW_ANON, DEV_USER_*authLocal development without the gateway. Never set in a deploy.
<NAME>_BASE_URL, SEAM_CLIENT_ID / _SECRET / _TOKEN_URLseamWhere 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

WhatColumnWhy
A payable amountNUMERIC(18,2); NUMERIC(18,3) where a three-place currency (BHD, KWD, …) can occurEvery currency's minor unit stores exactly in it.
A rateNUMERIC(19,4), with its basis and currency beside itA rate rounded to cents pays the wrong amount on every unit.
An exact (unrounded) amountnever storedRound once with to_money / round_once, then store the Money.

Known platform behaviour

  • DataK3 ON CONFLICT DO UPDATE writes every column in VALUES and ignores the SET list (dodilio/tables#417; fixed in DataK3 0.2.2, production is on 0.1.60.x). repository.upsert works 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 = :v reports 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