dodil/erp-brick-hr

AGENTS.md

Instructions for agents working on brick-hr

This brick is built on erp-platform (0.9.x: >=0.9.13,<0.10, pyproject.toml). Read its BRICKS.md before changing anything here — it holds the rules (ownership, use cases, data, identity, tests) and why each one exists. The spec is erp-method modules/hr/module.yaml, hr v4 (signed by seemo 2026-10-26, PR #21; receipts in modules/hr/signoff/hr/, pinned by tests/test_process.py): its words are the names here (the glossary is at the top of src/brick_hr/domain/__init__.py). Never edit the signed spec files from here.

Where a change goes

You are changing…It goes in
a rule about people, slices, events, the org, playbooks or runssrc/brick_hr/domain/ — the aggregate that owns it — and a test in tests/test_rules.py named for the story
what a customer configuresa ROW (keep_configuration / keep_playbook / keep_org), never code; a new kind of rule is a catalogue in domain/configuration.py
a country's legal fields, documents, checksa localisation pack in domain/packs/ (seed rows + domain code; it records, it never judges)
what counts as a deviation from the standard, and its classdomain/deviations.py (variation / warning / foundation) and the pack's GATES / SAFEGUARDS; a scenario's path in src/brick_hr/scenario_paths.yaml
who may do something, which rows they seeapplication/support.py (View) and the permission on the declaration
a new action for people and agentsa new @HR.use_case in application/ — it becomes a route AND a tool
what happens after an event commitsapplication/engine.py (eventual: one aggregate per unit of work, idempotent on its own key)
a neighbour (business partner, a capability, contracts, Models)its anti-corruption layer in infrastructure/ — hr speaks capabilities, never vendors
a column or tableinfrastructure/tables.py, then python scripts/gen_schema.py > src/brick_hr/schema.sql (additive: a new column at the END and as an explicit ALTER)

Never add an HTTP route or an MCP tool by hand. Never import SQLAlchemy, FastAPI or MCP in domain/.

Before you commit

.venv/bin/python -m pytest -q --tb=short          # ~2 min: rules, doors, tier 1 (real bp), SC-HR scenarios + probes
.venv/bin/python scripts/mutation_check.py        # after touching an invariant: every mutation must be CAUGHT

A new use case: a test in tests/test_doors.py (both doors, the 404, the 403), and a recipe in tests/scenarios/catalogue.py for the idempotency and mass-assignment probes — a command with no recipe FAILS them. After changing repositories or schema: BUCKET=erp-platform-test DODIL_ORG=dodil python scripts/live_check.py. After changing a repository, a read path or the tick: python scripts/bench_live.py (round trips per use case on SQLite; add BUCKET=… DODIL_ORG=… for the time on DataK3) and compare with the numbers in the README.

Don't

  • Don't overwrite a slice: change (delimit) or correct (supersede). Don't store anything derived — the manager, status, overdue, done, headcount are computed on read.
  • Don't let a manager-facing use case load the Sensitive aggregate, and don't answer 403 on a sensitive route: 404, so nothing confirms the record exists. Don't put a person's private data on their business partner — infrastructure/business_partner.py refuses anything but name and work contact.
  • Don't hand out a document link or store an intake URL: documents are streamed by a use case; a link is returned once (reissue_intake_link) and never stored — which is why that use case opts out of Idempotency-Key.
  • Don't give a draft a sensitive field (configuration.DRAFTABLE), and don't let a destructive step run without a granted approval.
  • Don't mint ids: person_id, bp_id, run ids, idempotency keys are derived from the work.
  • Don't derive a BPMN id from a step's position, and don't rename or reuse a step key: ids, scenario paths and a customer's signed change list join on keys (a renamed concept gets a new key).
  • Don't write a catalogue, playbook or org table except through its repository: the repository rewrites that table's token in hr_config_version in the same transaction, and every process's configuration cache trusts the token (repositories.configuration_snapshot). A row written around it is not seen until the next change.
  • Don't call another brick (the partner sync, an outbound seam) inside the request's transaction: register it with erp_platform.after_response after the commit, and leave a pending row the tick settles (it is safe to lose).
  • Don't let a request's statements grow with unrelated rows: tests/test_statement_budget.py pins hire, leaver, complete step, the tick and headcount (erp_platform.testing.count_statements).
  • Don't add a read that loops over people or runs one by one: a list is one SELECT per table (load_many, infrastructure/batching.select_many), and what a use case reads next can be read ahead in the same round trip (HrRepository.prefetch). On DataK3 every statement costs ~100 ms.
  • Don't send SQL on the raw driver without telling the identity map (erp-platform 0.9.13): batching.execute_many calls repository.note_write for each pipelined statement; a new raw write path must too, or the next load in the same request answers rows from before it (tests/test_identity_map.py).
  • Don't answer a person, a run, a step, a report row, a payroll extract or a seam read outside the caller's companies (View.companies, from hr_company_grant): 404, as for any row outside visibility. A new use case that takes a company or a person checks View.person_in_companies / View.require_company.
  • Don't write another brick's tables — call its seam. Don't write an upsert or DELETE by hand — the repository.
  • Don't commit credentials or .env. Don't change erp-platform or brick-business-partner from here.

Spec gaps and what is not built (hr v4, 0.6.0)

  • Per-company scope lives in the brick, not on the role. The spec says "company_codes on the role row". A dodil-appid pool's role_catalog maps a role name to permission strings only (dodil-appid src/policy.rs, RoleCatalog; read 2026-10-26, v0.15.2, the live service not called), and erp-platform's grants are three [a-z_]+ segments, so neither can carry a company code. hr keeps hr_company_grant (principal = sub, email or service client id, × company_code), a configuration catalogue read with the cached configuration. When appid can carry attributes on a role (or a tenant per company), the rows move there and View.companies reads the claim.
  • None named = every company (the spec's words): a new admin or service client sees every company until a row names them. It is fail-open by design for a single-company customer; a multi-company customer must write the rows.
  • Keeping the grants has no permission of its own in the spec: it is hr:config:write on the company_grant catalogue, allowed only to a caller whose own grant covers every company, never on their own row (email AND sub).
  • Not company-scoped: the org trees, the configuration catalogues and the playbooks (customer-wide rows), and the tick (system). People, runs, steps, reports, sensitive data, privacy, retention log, payroll extracts, hires and HR-63/64 are.
  • HR-63/64 words: hr has no code for a unit separate from its id: team_code is the assigned unit and org_unit_code the unit above it on the date (the unit itself at the root); the roster's org_unit_code filter includes every unit below it.
  • Not built: the v2/v3 read seams HR-56 (employment), HR-57 (manager), HR-58, HR-59 (bank account for payout), HR-60 (the payroll extract for the payroll module's client), HR-61 (payroll identity) and HR-62 (work pattern), and their machine permissions; the spec's header still calls v4 a "DRAFT for sign-off" while the receipts record it signed (erp-method's text, not edited here).