Instructions for agents working on this brick
Built from the product module spec (erp-method modules/product), v1 plus v4 slice A (PRD-27..33; slice B, PRD-34..39, is not built yet). The customer signed the PROCESS (product
228d5f88, price_list c6745ab1, 2026-10-18); tests/test_process.py fails if application/process.yaml drifts from
it. A process change goes through erp-compose change propose, the customer's new signature, then the code. Read
erp-platform's BRICKS.md before changing anything — the rules and why each one exists.
Where a change goes
| You are changing… | It goes in |
|---|---|
| a business rule | domain/ — and its test in tests/test_rules.py (one test per story, named for it) |
| how a price is resolved | domain/pricing.py — and tests/test_pricing.py; the answer's shape is find_price in application/use_cases.py |
| who may do something, or which rows they see | application/use_cases.py — the permission, or _sees_*(); 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 |
| how another module is reached | infrastructure/seams.py only |
| a setting the customer edits | domain/values.py (Settings, check_setting) and SETTINGS_SEED in infrastructure/tables.py |
| an AI step | infrastructure/ai_steps.py (the capability, the fallback) and the use case that calls ask() |
| a column or table | infrastructure/tables.py AND schema.sql (append; never drop or rename) and a new migrations/NNNNN_<name>/ |
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 — call its seam. Don't write an upsert by hand — use the repository (erp_platform.repository).
Business time is work.now(); audit stamps are real time.
The rules that must not be undone
- A price row is never edited. A different price needs a later
valid_from; a published list only takes rows and rules dated AFTER today (plan_changes). Only a DRAFT row, which never priced anything, may be discarded. - Nothing published or assigned changes the answer for a date already past. A pending row or rule, a
default_fromand an assignment must start AFTER today (409 otherwise, a late publish included):discard/discard_rulesand re-date it. The default on a date is the latestdefault_fromon or before it; two lists never share one. A retired list answers every date up toretired_on. - Every conversion is an exact
Fraction(numerator / denominator,domain/units.py); rounding toquantity_decimalshappens only at the end (round_half_up), never a float. A conversion a document or stock has used (mark_unit_used) never changes: a changed pack size is a new unit code. A price row is keyed [list, product, unit, valid_from]; breaks compare in the base unit. - A rule keeps the unit it was written in (
price_rule.unit_code, never re-read from the product); a used conversion, its level, decimals and e-invoice code are frozen, and so is the template row its products inherit (_store_units). - A price, a document line and a stock row name a PRODUCT (a SKU), never a template. A GTIN is unique across everything (
prod_gtin). - 'Today' is the date in the setting
business_timezone(_today(repo)): neverdate.today()or UTC. - A move starts tomorrow. A product's category and a category's parent are append-only
prod_category_membershiprows read as of a date (_subject,_move); nothing edits the tree for a date already past. - The import is a machine (
product:catalogue:import): drafts only, never publishes, never reads cost. - The controls on pricing authors (
publish_needs_second_person,unusual_price_pct) areproduct:settings:controlsand refused toproduct:settings:write(set_pricing_controlvsset_product_setting). - No price is stored as "current".
find_pricederives it on read, and its answer names the list, the rule and the row. The same question must always get the same answer (test_the_same_question_is_the_same_answer). - A price is never converted between currencies. A list is in one currency; a base list is in the same one.
- Cost never leaves in an answer to a caller without
product:product:write(_product_view);find_pricehas no cost at all. - A product carries a tax CATEGORY, never a tax code or a rate. Its code is fixed once set; nothing deletes it.
- No read-your-writes inside an open transaction (DataK3): a use case that writes rows then reports them works the
counts out (
_write_changes,publish_price_list) instead of querying them back. - AI steps only suggest. Nothing is merged, filled in or repriced by a model; with no provider wired the step leaves a
ManualTask in
prod_task. Tests never call a model.
Before you commit
pytest -q --tb=short # rules, pricing, both doors, the probes, scenarios, process signature, schema, seams
ruff check src tests
erp-compose check tests/tier1.compose.yaml --strict --roles src/brick_product/roles.yaml