dodil/erp-brick-product

AGENTS.md

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 ruledomain/ — and its test in tests/test_rules.py (one test per story, named for it)
how a price is resolveddomain/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 seeapplication/use_cases.py — the permission, or _sees_*(); never a role's name
a new action for people and agentsa new use case in application/use_cases.py — a route AND a tool
how another module is reachedinfrastructure/seams.py only
a setting the customer editsdomain/values.py (Settings, check_setting) and SETTINGS_SEED in infrastructure/tables.py
an AI stepinfrastructure/ai_steps.py (the capability, the fallback) and the use case that calls ask()
a column or tableinfrastructure/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_from and an assignment must start AFTER today (409 otherwise, a late publish included): discard/discard_rules and re-date it. The default on a date is the latest default_from on or before it; two lists never share one. A retired list answers every date up to retired_on.
  • Every conversion is an exact Fraction (numerator / denominator, domain/units.py); rounding to quantity_decimals happens 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)): never date.today() or UTC.
  • A move starts tomorrow. A product's category and a category's parent are append-only prod_category_membership rows 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) are product:settings:controls and refused to product:settings:write (set_pricing_control vs set_product_setting).
  • No price is stored as "current". find_price derives 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_price has 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