Instructions for agents working on erp-platform
Read README.md (what this is) and BRICKS.md (the rules every brick follows) first. This file is what
is specific to changing the platform itself.
Ground rules
-
This is shared infrastructure. Every brick depends on it. A change that is convenient for one brick and wrong for another is a regression. Keep the public surface —
Brick,Refused,encode,Aggregate,Part,upsert,Seam,auth.*,erp-migrate— backwards compatible inside a minor version. -
Run the suite before committing:
.venv/bin/python -m pytest -q --tb=short. It includes the starter brick's own tests (tests/test_template.py); if the template breaks, fix the template in the same change. -
A security property needs a test that fails when it is broken — check it by putting the defect back. The existing ones: an agent cannot skip a permission, identity never leaks between concurrent calls, a customer Host is not refused 421, a mistyped key cannot make a save delete what it wrote.
-
A log line is a fixed set of fields, never "whatever helps".
requestlog.FIELDSis the request line; bodies, tool arguments, header values (other than the request id), exception messages and email addresses stay out — they carry customer data and credentials. Log a route TEMPLATE, an exception's TYPE, a pseudonymisedsub.tests/test_requestlog.pyasserts this; extend it when you add a field. -
Test against the real thing. The doors are tested on a real uvicorn server with a real MCP client; repository SQL is checked on a real bucket by
scripts/live_repository_check.py(dodil auth use-org dodil, bucketerp-platform-test). Mocks have hidden real defects here twice. -
Verify a library's API before writing against it. The MCP SDK is 2.x (
MCPServer, notFastMCP; its client useshttpx2). Probe it in the venv rather than writing from memory. -
Seams are found anywhere in a brick's package (
compose.Member.modules): a submodule that cannot import without a database is skipped, anappmodule is never imported. Keep checking a composition free of a bucket. -
Fetch before reading another repo (dodil-appid, tables, the forge). Stale checkouts have produced wrong claims here.
-
capabilities.yamlis a contract other teams build against (a provider brick is built in parallel with its consumer). Change it additively; a new rule or a changed field is a version bump in the file and a note in its header. It lives insrc/erp_platform/(package data); the root file is a symlink — edit the one insrc/. -
The capability claim is a plain INSERT, and must stay one. An upsert lets concurrent callers all "win" and act;
test_concurrent_calls_with_one_key_act_exactly_oncefails when one is put back. -
The optimistic version claim is a plain INSERT into
<root>_versions, in the save's own transaction, and must stay one. NotUPDATE … WHERE version = :v(DataK3 reports rowcount 1 to every racer before COMMIT) and not a claim in a separate transaction (a failed write would burn the version). A lost claim can surface at COMMIT, outside save():repository.as_conflictrecognises it by the_versionstable name, so keep the suffix.tests/test_versions.pyfails when the claim or the committed check is removed;scripts/live_version_check.pyis the race on DataK3. -
The Idempotency-Key claim is a plain INSERT into
erp_idempotency, scoped by the caller'ssub, and must stay one (0.5).test_the_insert_alone_lets_one_racer_through_when_none_saw_the_othersfails when it is made to always win; only a success is stored, a refusal releases the claim. Its table is in platform.sql, soerp-compose migratecreates it. -
X-Dodil-Acting-Foris honoured only from a principal holdingerp:seam:act_for, and carries roles, never permissions (0.5, auth.py). Grant that permission to service clients only — a person holding it could read as anyone.test_across_apps_acting_for_is_honoured_only_from_a_service_that_mayfails when either rule goes. -
A use case's annotations are its input contract —
get_type_hints(include_extras=True)keeps anAnnotatedbound, and Path()/Query() go inside the Annotated (as a default FastAPI drops the metadata).tests/test_hardening.pyfails when either is undone. -
The public door's rules are security properties, each with a test that fails when it is broken (tests/test_public.py): the table never holds the token (only
hash_token); every failure kind is the same 404 byte for byte, headers included; a single-use act is a plain-INSERT claim of the event(link_id, "used"); the token is in no log line (route templates;requestlogredacts secret-length segments of an unmatched path;serve --publicturns uvicorn's access log off); the public app's routes are exactlypublic.ROUTES. Adding a route to the public app is a change to that list and to its review, never a convenience. A new response type must still pass through_SecurityHeaders. -
The scenario harness's assertions are what every brick's scenarios stand on.
not_found()is 404 and nothing else,same_as()compares the body as well as the status, the matrix wants the exact "missing permission: <perm>", money compares to the minor unit — and each has a defect test in tests/test_scenarios.py (a defect put back into the throwaway orders brick must FAIL its scenario) that fails when the assertion is weakened. A live runner never injects faults, never runs against a bucket not stampeddemowithout--allow, and never prints or reports a credential (report.redact).work.set_clockand the demo clock route refuse underDODIL_ENV=prod|live; keep both refusals. -
erp_andplatform_metaare the platform's table names. A platform table goes inplatform.sql, applied bymigrate_platform(); compose check refuses a brick table with those prefixes. -
Money's two-place behaviour is a promise to every brick for 2dp currencies; a change to rounding runs every brick's suite, not just this one.
Platform facts this code depends on
- DataK3
ON CONFLICT DO UPDATEwrites every column in VALUES (dodilio/tables#417, fixed in 0.2.2; production on 0.1.60.x). Keeprepository.upsert's workaround until production runs 0.2.2. - No read-your-writes inside an open transaction.
- The per-request identity map (0.9.13, repository.request_scope) is invalidated by SQLAlchemy cursor events; raw
driver writes must call
repository.note_write(BRICKS.md, Data). A versioned save checks the committed root in the same pipeline as its claim (a remove deletes the claims, so the claim alone cannot refuse a save after it). - Concurrent plain INSERTs of one primary key: exactly one is acknowledged, the rest fail 23505 (DataK3 0.2.1, measured 2026-10-17). The capability claim, the optimistic version and the single-use link depend on it. Inside open transactions the duplicate can be refused only at COMMIT (measured the same day: both INSERTs succeed, the second COMMIT fails 23505), and the loser's other writes roll back with it.
UPDATE … WHERE version = :vunder a race: one commits, the rest 40001 at COMMIT, and every racer was told rowcount 1 before it (measured 2026-10-17).- The pg wire returns TIMESTAMP columns as aware datetimes; SQLite returns naive ones (public.py normalises).
dodil data connect/data pgwith DODIL_TOKEN in the environment resolve a different database name ("not authorized for database …--dodil--…"); livecheck strips it from the CLI's environment.- The dodil-appid issuer is
{APPID_PUBLIC_URL}/{org}/{pool},APPID_PUBLIC_URL=https://appid.dodil.io. - Ignite scales to zero: MCP is served stateless.
Don't
- Don't commit credentials, tokens or
.envfiles. Never print a secret's value. - Don't push to
mainwithout being asked; the repo is private and stays private. - Don't add a dependency for something a few lines of code do.