dodil/erp-platform

AGENTS.md

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.FIELDS is 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 pseudonymised sub. tests/test_requestlog.py asserts 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, bucket erp-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, not FastMCP; its client uses httpx2). 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, an app module 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.yaml is 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 in src/erp_platform/ (package data); the root file is a symlink — edit the one in src/.

  • 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_once fails 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. Not UPDATE … 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_conflict recognises it by the _versions table name, so keep the suffix. tests/test_versions.py fails when the claim or the committed check is removed; scripts/live_version_check.py is the race on DataK3.

  • The Idempotency-Key claim is a plain INSERT into erp_idempotency, scoped by the caller's sub, and must stay one (0.5). test_the_insert_alone_lets_one_racer_through_when_none_saw_the_others fails when it is made to always win; only a success is stored, a refusal releases the claim. Its table is in platform.sql, so erp-compose migrate creates it.

  • X-Dodil-Acting-For is honoured only from a principal holding erp: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_may fails when either rule goes.

  • A use case's annotations are its input contract — get_type_hints(include_extras=True) keeps an Annotated bound, and Path()/Query() go inside the Annotated (as a default FastAPI drops the metadata). tests/test_hardening.py fails 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; requestlog redacts secret-length segments of an unmatched path; serve --public turns uvicorn's access log off); the public app's routes are exactly public.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 stamped demo without --allow, and never prints or reports a credential (report.redact). work.set_clock and the demo clock route refuse under DODIL_ENV=prod|live; keep both refusals.

  • erp_ and platform_meta are the platform's table names. A platform table goes in platform.sql, applied by migrate_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 UPDATE writes every column in VALUES (dodilio/tables#417, fixed in 0.2.2; production on 0.1.60.x). Keep repository.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 = :v under 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 pg with 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 .env files. Never print a secret's value.
  • Don't push to main without being asked; the repo is private and stays private.
  • Don't add a dependency for something a few lines of code do.