Instructions for agents working on this brick
Built from the manufacturing module spec (erp-method modules/manufacturing/module.yaml, version 1, signed 2026-10-25). Slices A and B
are built (0.2.0): bills of materials and the production order workflow (release -> issue -> receive -> settle), work centres, routings, the
cost roll-up, MRP-lite, confirmations, scrap, reversals, traceability, the Odoo slice and the explain_variance AI step. Read erp-platform's BRICKS.md
before changing anything. The shape is erp-brick-payments' / erp-brick-product's. application/process.yaml is the signed workflows,
copied; tests/test_process.py holds it equal to the spec AND to the signed BPMN hashes - a process change goes through
erp-compose change propose and a new signature first.
Where a change goes
| You are changing... | It goes in |
|---|---|
| a business rule | domain/ (bom.py, production_order.py, settlement.py, values.py bounds) and its test in tests/test_domain.py / test_stories.py |
| who may do something, or which rows | application/use_cases.py - the permission on the declaration; _see_order for rows; never a role's name |
| a new action for people and agents | a new use case in application/use_cases.py - a route AND a tool (tool=False only for a machine seam) |
| a work centre, rate, routing or cost roll-up | application/engineering.py (+ domain/routing.py, domain/costing.py); staleness is hub.mark_stale in use_cases.py |
| MRP-lite, planned orders, what replenishment reads | application/planning.py (+ domain/mrp.py, the pure netting) |
| time on an operation, scrap, a reversal | application/floor.py |
| a lot trace | application/trace.py (one statement per level) |
| the Odoo slice | application/migration.py + infrastructure/odoo.py (the reader port) |
| the AI step | application/ai_step.py + infrastructure/ai_steps.py (ai.suggest); the contract in domain/ai.py |
| what is sent to inventory or gl, and how an answer is read | application/dispatch.py (+ the payload in the use case that writes the outbox row) |
| how another module is reached | infrastructure/seams.py only (product, inventory, gl) |
| a column or table | infrastructure/tables.py, then python scripts/gen_schema.py (it regenerates schema.sql and migrations/00002_slice_b, and never touches the frozen 00001_init); AFTER release a NEW migrations/NNNNN_<name>/ (append; never drop or rename); tests/test_schema.py keeps them equal |
Never add an HTTP route or an MCP tool by hand. Never import SQLAlchemy, FastAPI or MCP in domain/. Don't write to another brick's tables.
Don't write an upsert by hand for an aggregate (erp_platform.repository); facts are plain INSERTs.
The rules that must not be undone
- Stock and money never move here. A movement row and a PENDING
mfg_outboxrow commit together;dispatch.sendtells inventory / gl AFTER the commit and records the answer in a second transaction. sent and rejected are final (the UPDATE refuses to move them back). A rolled-back movement sends nothing; an order closes only when no row of it is pending. - One claim key per stock or money operation (
mfg_claim, plain INSERT, n = committed claims + 1; a loser is a 409): scopesissue(key<order>:<line>),output(<order>),byproduct(<order>:<line>),approval,settle;mfg_number_seriesfor the order id. The move_ref ismfg:<order>:issue:<line>:<seq>/:output:<seq>/:byproduct:<line>:<seq>: an issue, an output and a by-product never share one. - A released BOM version is never edited. Validity is COMPUTED (a version holds from its valid_from until the next released version's),
so versions never overlap;
valid_frommust be after today and after every released version. An order FREEZES a copy at release. - Normal weight loss is the BOM's planned yield, not a variance. Standard material for the output = planned quantity at the unit cost inventory valued the issue at, scaled to the output reported. The split always sums to the WIP balance (activity takes the rounding).
- State machines only go forward. created -> released -> in_progress -> completed -> closed; cancelled from created/released with no movement.
- Second approver: email AND sub. A variance above
variance_approval_threshold(sum of absolute variance categories) needsapprove_varianceby someone other than whoever closes it; same person under either identity is refused. - 'Today' is the date in
business_timezone(SettingsRepository.timezone, the one port; a zone change counts today in the zone BEFORE it). - Bounded -> 422
{gate: bounds}, never 500: quantities/rates NUMERIC(19,4) (domain/values.py), money NUMERIC(18,2); an answer inventory gives that no column holds is recorded rejected, not stored. A conversion that is not exact is refused by product (never rounded). - Input validation runs before the permission check in the doors, so a date is a plain string parsed INSIDE the use case.
- Rows: an operator sees released / in-progress orders only; anything else is 404, never 403; the shop-floor view carries no cost fields.
- Machine permissions (
mfg:bom:explode,mfg:outbox:dispatch, ...) are exact grants to a service client; no wildcard reaches them. - Slice B rules. Time is costed at the work centre's DATED rates (machine+overhead on machine minutes, labour on labour minutes) and posted to gl
from a pending row; a confirmation gl refuses is VOID (counts for nothing, shown). Scrap is never received: it is expensed at close at its standard
value and counts toward the standard material, so only the rest of the balance is variance; scrap above
scrap_approval_thresholdwaits for a planner who did not report it (email AND sub) and the order cannot close meanwhile.reverse_entryis the operator's OWN entry, the SAME business day, on an open order: an issue returns to stock (a receipt namingreverses_move_ref), a confirmation is mirrored by an opposite journal, scrap is voided; the reversal has its own reference (…:reverse,…:reverse:<n>after a refusal) and an output cannot be reversed. A cost roll-up is a PROPOSAL (computed) until a SECOND cost accountant approves it (= releases it: one pendinginventory.standard_pricerow per product); a BOM, routing, rate or forecast change marks open roll-ups, the last MRP run and unreleased orders stale (flags, never rewrites); released ones keep what they froze. MRP-lite nets by ISO week (Monday), rounds the shortfall UP through product, is deterministic, and a re-run supersedes proposed orders and keeps firmed ones (they count as supply). The explain_variance step only ever writesmfg_ai_finding; the owner's mode isai_explain_variance_mode. - Outbox rows for a roll-up use
mfg_outbox.production_order_id = "rollup:<rollup_id>"(the column is the owning document's id). - tests/conftest.py checks every neighbour call against the callee's signed spec and WORKS AROUND four spec gaps, each held by a test in
tests/test_seams.pythat fails the day the spec closes it (and a fifth, the gl journal id, below).
Backlog (decided 2026-10-26, pending the spec PR)
seemo decided these on 2026-10-26; they are written into erp-method branch spec/gaps-wave-4. Build them once that PR is
signed (the module's processes need a re-sign first), not before; until then the spec on main stands.
- Planning settings as rows.
mfg_planning_ruleper product and site, dated:lot_size_ruledaily | weekly | monthly (default weekly, today hard-coded weekly Monday netting) andproduction_days(default Monday to Friday; a due date on a non-production day moves to the day before); make-or-buy stays product's siteprocurement_type. - The
journal_idform. gl v9 agrees with what is built: an activity journal's source_document is<order>:activity:<op>:<n>[:reverse],journal_id = derived_id(source_document, "activity"); a first settlementderived_id(order, "settle"), a later one<order>:settle:<n>. - The scrap permission. Scrap approval above
scrap_approval_thresholdneedsmfg:scrap:approve(the planner), notmfg:order:release. - Output reversal.
reverse_entryof an output or by-product calls inventory v7'sPOST /goods-issues/from-sourcewithsource_kind production_output_reversalandreverses_move_ref(dropoutput_not_reversibleonce inventory builds it). - Migration stages (load and reconcile stage, sign writes, discard deletes staging only: today discard only marks the run).
explain_variancekeeps the owner's mode plus a role (mfg:order:close); no per-person consent. - Shims to remove once signed: product lists manufacturing for
/products/{id},/convert,/round-proposal; inventory lists it for availability, valuation and the lot trace; gl for cost-centres and periods.
Before you commit
pytest -q --tb=short # domain, stories, invariants, doors, seams, budget, process, schema, composition
ruff check src tests scripts
erp-compose check tests/tier1.compose.yaml --strict --roles src/brick_manufacturing/roles.yaml
Spec gaps found building slice B (the smallest honest thing was built; change requests are the spec owners')
- gl journal id vs its own prose. gl's request_schema says
journal_id = derived_id(source_document, event)witheventonlyactivity | settle; its prose saysderived_id("mfg:<order>", "activity:<op>:<n>"). Taking the schema literally gives EVERY confirmation of an order one id (gl would answer the second as a replay of the first). Activity journals therefore usesource_document = "<order>:activity:<op>:<n>[:reverse]"(the confirmation itself) andderived_id(source_document, "activity"). Settlement staysderived_id(order, "settle"): one per order; a post-closesettle:2adjustment is still unspecified. - No inventory route takes output back out.
reverse_entryrefuses an output/by-product (gateoutput_not_reversible). An issue's reversal isPOST /receiptswithsource_kind production_receipt+reverses_move_ref(inventory's own rule text), sent with the issue's own unit cost (unit_costis required by its schema). - product lists none of manufacturing's calls (
/products/{id},/convert,/round-proposal) withwith: manufacturing-> product is unchecked.GET /product-sitesis listed; its body arrives as{"body": [...]}through the platform seam; paging is unspecified (one call per run). - inventory lists availability and valuation for sales-order only (checked as that caller) and the lot trace only inside a combined
GET /serials/... and GET /lots/.../tracerowwith: crm(not checked). gl listsGET /cost-centresfor inventory and purchasing only. - MRP rules the spec leaves open. Weekly (Monday) netting; lead time = the routing's work-centre minutes for THAT quantity plus waiting hours,
rounded up to whole days, one day before the first need (the spec's "one day before"); open
created/releasedorders count as supply,in_progressones do not (their output is imminent stock); only products whose site row saysprocurement_type makeare planned. The spec's MFG-08 sales lines (due 11-24 to 11-30) straddle two ISO weeks, which weekly netting would split into two orders; the test puts them in one week. The planned-order reference is<warehouse>:<product>:<due>:run <n>(the spec's example, with the space). - Scrap approval has no permission in the catalog: a planner approves it with
mfg:order:release.mfg_scrap_reasonis settable by the owner only (mfg:settings:controls); only ABNORMAL reasons can be reported as scrap (normal loss is the yield). - Staleness from outside is not pushed to us. A sales order, stock or component standard price changing marks nothing (no event seam); only a BOM, routing, work-centre rate or forecast change does. A run's staleness from those is a flag; re-running is the fix.
- Odoo slice. The gate "product and inventory migrations signed" is not checked (no route reads their status). The reader is a PORT
(
infrastructure/odoo.py); no live reader is shipped and product codes arrive already mapped. Work centres getcapacity_unit_code ""(Odoo has none). History keeps Odoo's unit (not converted) and no value.discard_mfg_migrationmarks the run only: carried rows stay (a second load is idempotent on the map), so "discard" is not an undo. - AI step. The consent in the spec is "nothing is posted": there is no per-person consent, only the owner's mode (off | suggest).
ai.suggest's context is scalars, so lists and maps travel as JSON text. The input lots' suppliers are thebp_ids in inventory's trace history of each lot. - Not built (slice B list is otherwise done): MFG-10's capacity-overload warning at release (no load query yet), finite scheduling, quality inspection and co-products (the spec's extension points), the live Odoo reader, a re-release of a standard price inventory rejected.
- Slice A's open question stands:
release_with_shortage/controls are dated rows;valuation_method averageposts a single adjustment, untested live.
Scenarios: SC-MFG-01 (tests/test_floor.py::test_sc_mfg_01_*), SC-MFG-02 (test_mfg_14_a_burnt_batch_*), SC-MFG-03 (tests/test_trace.py),
SC-MFG-05 (tests/test_engineering.py::test_mfg_18_a_new_bom_version_*). SC-MFG-04 (a till sells a latte) is slice A's explode route plus pos.