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 runs | src/brick_hr/domain/ — the aggregate that owns it — and a test in tests/test_rules.py named for the story |
| what a customer configures | a 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, checks | a localisation pack in domain/packs/ (seed rows + domain code; it records, it never judges) |
| what counts as a deviation from the standard, and its class | domain/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 see | application/support.py (View) and the permission on the declaration |
| a new action for people and agents | a new @HR.use_case in application/ — it becomes a route AND a tool |
| what happens after an event commits | application/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 table | infrastructure/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.pyrefuses 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_versionin 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_responseafter 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.pypins 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_manycallsrepository.note_writefor 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, fromhr_company_grant): 404, as for any row outside visibility. A new use case that takes a company or a person checksView.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_catalogmaps a role name to permission strings only (dodil-appidsrc/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 keepshr_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 andView.companiesreads 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:writeon thecompany_grantcatalogue, 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_codeis the assigned unit andorg_unit_codethe unit above it on the date (the unit itself at the root); the roster'sorg_unit_codefilter 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).