Changelog
Earlier releases are described where they landed: the module docstrings and README.md name the version that
introduced each piece (0.9.6: …).
0.9.13 (2026-10-25)
Fewer statements per request, the same answers (measured on the Lead-to-Cash composition, SQLite, a one-line sale).
- A per-request identity map for aggregates (
erp_platform.repository.request_scope, opened by both doors around each use-case attempt). An aggregate is read once per request tree per key per CALLER: a second load answers a deep copy of the first one's rows and snapshot. A tier-1 seam call carries the map into its neighbour's request in the same process (inventory asking sales-order for an order's fulfilment and then recording the delivery on it now reads the order's 13 tables once, not twice); every entry is keyed by the full identity of the caller that read it, so a seam call on a person's behalf and a brick's own grant never answer each other's reads, nor do two people. Dropped when the request saves or removes the aggregate, when any statement writes one of its tables (text SQL included), on the rollback of a transaction that wrote, and after a seam command to a neighbour in another process; a read made by a connection that has written in its open transaction is never kept (SQLite and Postgres show a transaction its own writes). Never shared between two outer requests; a retried attempt starts empty; outside a scope nothing is cached.repository.memogives a brick's own reference reads the same lifetime. Tests: tests/test_identity_map.py (two requests never share rows; two callers in one tree never do; a user-scoped read through a seam is still refused another user's order; a concurrent save still loses with 409; a retried attempt through the door starts empty; a tier-2 command forgets the map). - A versioned save with a snapshot checks the committed root in the same round trip as its claim. The stale case
is refused against the snapshot first (no statement); the version SELECT is then QUEUED in the psycopg pipeline with
the claim and the writes and decided before COMMIT — a missing root or another version is a Conflict. One round trip
on the pg wire (measured through the counting proxy on Postgres 2026-10-25: a versioned one-line save is 4 statements,
1 round trip; it was 2 round trips). The SELECT stays because a concurrent
remove()deletes the claims with the rows: the claim alone let a save from an older snapshot resurrect a partial aggregate (review of 70fcf3e; tests: a save after a concurrent remove, and after a remove + re-create below the snapshot's version, are both 409). Not done, and why: a conditional upsert (… ON CONFLICT DO UPDATE … WHERE version = :v) cannot be the decision on the DataK3 pg wire — a statement's rowcount there is no verdict (every racer was told rowcount 1 before COMMIT, measured 2026-10-17; data_pg reports row_count 0 for any DML). A failed check rolls the caller's transaction back before raising, so code that catches the Conflict inside its unit of work and commits persists neither the claim nor the writes (test). Residual, stated (re-review 2026-10-25, pre-existing since 0.9.12): a remove that has not committed yet, racing a save, can still let the save re-insert the root — the save's check reads the committed root, which is still there. The durable fix is for remove() to claim (k, v+1) itself (or leave a tombstone) instead of deleting the claims; not done in this release. upsert(..., new=True): the caller knows the keys are not stored yet: a plain INSERT with no key lookup. A key that is stored after all is a Conflict (409), never a silent overwrite of its creation columns.- Writes the map cannot see:
repository.note_write(sql, conn)/forget_all()for SQL sent on the raw driver (BRICKS.md, Data; brick-hr's batching.py is the one raw write path found in the bricks). The write detector skips leading comments and knows COPY and SELECT … INTO; a table is matched bare, quoted or schema-qualified. repository.memo(conn, key, tables, read): keyed by the engine too (two bricks with the same caller and key never answer each other), and never kept whenconnhas written in its open transaction.- An after-response job runs detached from the request's map (
after.start→repository.detach). - The statement counter reports wire round trips on SQLite too: an aggregate's per-table reads count as the ONE round trip they cost on psycopg 3 (pipeline), so a budget test can pin round trips as well as statements.
0.9.12 (2026-10-25)
Ship a signed composition the proper way, and take it all back.
erp-compose deploy compose.yaml --org ORG --name APP [--repo R] [--warm] [--demo-clock](erp_platform.deploy). The composition gets its own private Dodil Git repo;git pushruns the forge's CI (build/buildbuilds the image into the org registry;ci/signoffrunserp-compose signoff verify --all) and the forge's CD deploys from.dodil/deploy.yamlonce both are green (when.require_checks). Neverdodil ignite app deploy --code(throwaway only, capped near 1 MB). Steps, each printed, the first refusal stopping the run with its fix, a re-run resuming: the sign-off gate; provisioning through the dodil CLI with--orgon every call (bucket; pool with email + password, sign-up off, CIMD on, the app's callback ADDED to the redirect URIs; roles MERGED into the pool's catalogue — roles.yaml ormanager: [*:*:*], pluserp_machine= the bricks' machine permissions; a service account withk3.editoron this bucket ONLY; a machine service client for the checks; the repo's CI secrets);migrate+ each starter'sseedas the app's own service account; the files (Dockerfile on python:3.12-slim from the pinned requirements, app.py, the two workflows — build.yml is the onedodil git ci initwrites — deploy.yaml, ignore files; a file without the marker is the customer's and is kept); repo,dodil git ci verify, commit, push, then the checks and the CD deploy followed (a check not yet listed is waited for; a failed one prints its log's tail);--warmsets reserved 1 on the app (deploy.yaml has no such key); GET /healthz and MCP initialize + tools/list with the service client's token;.dodil/inventory.json(ids + exact delete commands, no secret, git-ignored). Secrets never reach argv (every argv is checked), stdout or the repo (every committed file is scanned): they go to~/.dodil-secrets/<app>/(0600),git secret seton stdin, and migrate's environment.erp-compose teardown compose.yaml --org ORG [--keep-repo]deletes in reverse from the inventory, including what the platform made (the app CD created, its app secrets, the registry repos the build pushed, the bucket's ownk3-tables-sa-…), then lists each kind and refuses if anything remains.- A master-data brick has nothing to sign. init (0.9.11, erp-method v0.1.35) lists business-partner in
modules:although its brick saysprocess_view: "none: …", sosignoff record --alldied on "no workflow has transitions" and the gate held it UNSIGNED: the gate now leaves out a module whose brick declaresprocess_view: none.
0.9.11 (2026-10-25)
Three fixes from the 2026-10-25 small-ERP lab run and its live deploy, and repeatable starters.
serve --localruns the composition's scheduled work (erp_platform.local.Ticker). On the local tier nothing ran the bricks' passes, so an issued invoice never reached the ledger until the agent calledpost_to_ledgerby hand. Every 5 s (--ticks-every N;--no-ticksturns it off) each pass runs through the routes as the dev owner (suberp-compose.clock), with one line per pass that did something (an unchanged answer is not logged again; the ticker's own request and demo-clock lines are quieted). A brick names its passes withBrick(..., ticks=(…)); otherwise they are the idempotent, input-free commands behind a machine permission named like a pass (tick/pending/due/retry/finish): arraise_due,finish_schedules,post_pending; glpost_pending_reversal; paymentscollections_tick,retry_payout_notices; inventoryretry_postings. Local only.erp-compose migrateon a bucket inserts each brick's starting rows (migrate.seed): the brick's ownRepository.seed()(payment terms, units, product types, order types, payment settings) after its schema, asmigrate --localalways did. Each seed inserts only what is missing, so a re-run adds nothing. Retires smallerp-demo'sscripts/live/seed_bricks.py.- Sign-off for a composition of many modules. Each module's spec lives in
processes/<module>/module.yamlbesidesignoff/<module>/; a receipt's source is looked up there first, then at the root, and recorded relative to the module's folder ("module.yaml") — exactly as erp-method's baseline receipts are, so they verify UNEDITED (the deploy had copied each spec and hand-edited every receipt's path; those edited receipts still verify).erp-compose initwrites the layout: the method's spec, and the method's receipts (with history) byte for byte intoprocesses/<module>/baseline/— REFERENCE ONLY: the customer's signature is the contract and never comes from erp-method, sosignoff/<module>/stays empty andmigrateon a bucket refuses until the customer signs (the local tier warns); a signed process that matches the baseline says "identical to the standard v<n>".erp-compose signoff record --all --approver … --reason …records the customer's one approval of every workflow of every composed module, still one receipt per process. - The pg-wire engine never probes for hstore (
pool.pooled_engine,use_native_hstore=False): psycopg's first-connection hstore lookup is a pg_catalog query, and on DataK3 every catalog query costs O(tables) today (dodilio/tables#494, ~178 s at 288 tables); the small-ERP deploy's first connection went from 6 min to 2 s. migrate/serve/check reflect nothing; migrate's drift check is still ONE information_schema query per schema file. init --starterrepeats (--starter uk-wholesale --starter uk-wholesale-sample): each starter is written toconfig/<name>.calls.yaml(a second one used to overwrite the first inconfig/starter.calls.yaml) and the seed commands are printed in the order given. A composition that already hasconfig/starter.calls.yamlkeeps seeding it —erp-compose seedreads whatever calls file it is given.
0.9.10 (2026-10-25)
A fresh agent's small-ERP build in fewer steps. The timed lab run of 2026-10-25 (8 bricks, a UK wholesaler) took
74 turns, most of them hand-written setup: an MCP client, a shell wrapper, setup scripts, and a hand-collected
DEV_USER_PERMISSIONS. From an empty folder it is now four commands: init --starter, migrate --local,
seed --local, serve --local.
serve --local: the dev owner holds every declared permission (erp_platform.local.dev_owner). A header-less request isowner(owner@localhost, role manager) holding every permission the composed bricks declare — each use case's and eachBrick(machine_permissions=…), machine ones included, since a wildcard never reaches those — and serve prints them (person by namespace, machine by name). A SET env var wins (DEV_USER_PERMISSIONSnarrows the owner,DEV_ALLOW_ANON=0turns it off); the platform'serp:seam:act_foris never granted. Local tier only.erp-compose seed [--local [DIR]] compose.yaml CALLS.yaml(erp_platform.seed). Replays a list of{use_case, args}through the routes as the dev owner — the replayerp-compose processuses, now shared (process_export.send/resolve). A call may save its answer (as: acct) for a later argument ("$acct.account_id");$today/$tomorrow/$yearare the business day. Re-runnable: each command carries an Idempotency-Key from its use case and arguments, so a repeat answers its first answer (Idempotent-Replayed), a keyed upsert rewrites the same row, and a 409 is skipped with the brick's reason; the first other refusal stops it, naming the call. Both tiers boot the composition in process:--localon the SQLite files, otherwise on the bucket named byBUCKETwith the app's env (likemigrate) — not through the deployed URL, where a caller is a pool user and holds no machine permission. Output is one line per run of the same use case and outcome.- Day one after a seed.
seed --localrecords the business day in.erp-local/erp-local.json;serve --localwith no--clockon that day starts the business clock at +1d, so rows a brick only takes from tomorrow (a starter dates them$tomorrow) are in force for the first sale.--clockoverrides. erp-compose init --starter <name>readsstarters/<name>.calls.yamlfrom the same erp-method ref (same reader) intoconfig/starter.calls.yaml; with no modules it composes the starter'smodules:, and a module list missing one is refused. It prints the next commands.- Fix — an Idempotency-Key on the local tier / in process answered 500. The claim store fell back to
erp_platform.db(which needs BUCKET);scenarios.inprocess.stagenow puts the claims with the link tables (_platform), andmigrate --local/serve --localcreateerp_idempotencythere. - Fix — the demo clock on a brick named by its module id.
democlock.mountonsales-orderdeclaredsales-order:clock:set, which is not a concrete permission (a hyphen), so no one could move the clock (the local tier mounts it on the first brick). An unconcrete default now uses the brick's namespace, hyphens as underscores (sales_order:clock:set).
0.9.9 (2026-10-25)
Four papercuts found by a dry run that composed a small ERP end to end.
erp-compose <verb> --help.check,plan,migrateandserveread--help/-has the compose.yaml path and crashed; every verb now prints its own usage and exits 0 (bareerp-compose --helpprints them all).erp-compose initwritesmodules: [...]into compose.yaml — the modules it composed — so the sign-off gate (erp_platform.gate.modules) names what must be signed instead of reporting "(none)" before an ERP_<MODULE>.md exists.- A local dev tier:
erp-compose migrate|serve --local [DIR](erp_platform.local). SQLite files in DIR (default.erp-local/beside compose.yaml, self-ignored by git), tables from each brick's own MetaData +seed()— the path the in-process scenario runner stands on, not schema.sql. Explicit only: refused whenBUCKETis set and underDODIL_ENV=prod|live; every line says LOCAL DEV TIER. The sign-off gate warns here (migrating a bucket still refuses an unsigned design, unchanged).serve --localneedsmigrate --localfirst, listens on 127.0.0.1 only (X-Dodil-User is trusted with no gateway in front) and has no--public. - Day-one selling on the local tier. Bricks take settings, price lists and maps in force from a date after
today — a brick rule (a correction is a new dated row), deliberately unchanged here.
serve --localmounts the demo clock (on the brick that already mounts it, else the first) and says so, and--clock +1d|INSTANTstarts the business clock there. Production date semantics are untouched: the clock stays refused under prod|live.
0.9.8 (2026-10-25)
- bricks —
Answer(body, status=200). A use case that recognises a repeat by the caller's own reference (abatch_ref, an order number) can answer 200 for the repeat while its route declaresstatus=201for a create, as the signed specs say (payments'POST /deposit-batches/expected, PAY-49). Only the HTTP door reads the status; the MCP tool returns the body either way. 2xx with a body only. A replay of anIdempotency-Keystill answers the route's declared status. Import it fromerp_platform.bricks. - bricks/capabilities —
Brick(name, namespace=…). A brick named by its module id keeps its spec's permission namespace (contracts →contract, business-partner →bp). A provided capability's door now asks for<namespace>:capability:invoke, and in-process consumers present the same; it used the brick's name, which made the contracts rename change a signed permission. Withoutnamespacenothing changes. - contract — a seam shared by several callers.
with: expenses, pos(or a YAML list) now names each caller;check(..., caller=...)compared the whole string, so hr's employment read was flagged for both. - scaffold — the business partner is a member named
business-partner. The generatedcompose.yamllisted it asbp, which business-partner 0.4.0 (named by its module id, erp-method v0.1.31) refuses as a wrong name.
0.9.7 (2026-10-25)
- repository — an unchanged root's version bump on a NOT NULL table. In 0.9.6 a
save(snapshot=…)of a versioned aggregate whose root did not change wrote the root's new version with a row of the key and the version only. Postgres and SQLite check NOT NULL on the proposed INSERT row before ON CONFLICT turns it into an update, so the save failed on any root with a NOT NULL non-key column. The bump now sends the whole root row as it was read (the caller's values on top): still 3 statements for an unchanged versioned save (check, claim, version), the same upsert a changed root uses, correct on the DataK3 pg wire. Bricks that worked around it (always writing the whole root) can drop the workaround. - bricks —
aliases=on@BRICK.use_case.aliases={"date_from": "from", "date_to": "to"}serves the inputdate_fromas?from=on a GET (or"from"in a body) and asfromin the MCP tool's schema, the name the spec uses, which Python cannot take as a parameter. The use case still receivesdate_from. The Python name is not a second spelling on either door. Path parameters cannot be aliased. The scenario harness'scall()sends either name as the alias. - capabilities.yaml 0.6 —
identity.revoke_app_user. Disable a person's pool user and revoke their sessions; moved from brick-hr'scapabilities.local.yamlunder the same name and shape. With no provider it is a manual task carrying the exact input (rule 8). - scaffold + templates/brick-example — the commit-time unique race is a 409. Every generated brick's
_repository_defaultopens its transaction through_on_engine(engine), which answers a duplicate key that DataK3 reports at COMMIT (_lost_a_unique_race) as the retry-safe 409 instead of a 500 (as erp-brick-ar does by hand). A version claim is left to the doors' "changed since you opened it". The generatedtest_doors.pytests it. - bricks —
status=on@BRICK.use_case.status=201makes the route answer 201 (and OpenAPI declare it); any 2xx with a body (not 204). A refusal keeps its own status; the MCP tool is unchanged. - seam — a brick's own service-client call is a service, in process too. At tier 1 a seam call without
caller=now carriesauth_source "service"and the calling brick's name (identity["service"], the Seam'sowner, by default the package that declares it:brick_inventory…→inventory). Before, the callee saw a person ("pool"), so a rule such as sales-order's "only inventory's service client records a delivery with source inventory" could never pass in one process. Only a seam can say it: the gateway mints and strips the source header and the JWT. - seam/auth — an onward read on a person's behalf runs on the calling brick's own grant. A seam call with
caller=is authorised at the callee's door by the calling brick's service grant (the permissions its Seam declares, i.e. the machine grants its spec declares) or the person's own (auth.may_run), and scoped to the person's rows:owner_scope/has_permissionstill read the person's permissions alone, so a service grant holding aread_allnever widens what the person sees. In process the grant rides in the seam-minted JWT (service_permissions); across apps an acting-for identity carries the machine token's permissions the same way (before, it carried none, so every permissioned onward read was refused). The doors checkauth.may_run.