erp-brick-hr
Repo dodil/erp-brick-hr (renamed from dodil/brick-hr on 2026-10-18; the old URL still resolves). The Python
distribution is still brick-hr and the package brick_hr, so a pin reads
brick-hr @ git+https://git.dodil.io/dodil/erp-brick-hr.git@v0.6.0.
People, starters and leavers: who works here, on what terms and since when, and the admin of bringing a new
employee or freelancer in and taking them out again. A brick on erp-platform
(0.9.13 or later, below 0.10: the per-request identity map, snapshot saves, after_response, gate refusals); spec
modules/hr in erp-method, hr v4 (signed by seemo 2026-10-26, PR #21; tests/test_process.py pins the signed
files). Every action is both an API route and an
MCP tool. Core — what SAP HCM, SuccessFactors and Workday ship as standard; a customer's values are configuration
rows.
What it does
| Story | How |
|---|---|
| HR-01 one form for a starter | record_hire: one person (person_id = blake2b of the staff number, else the normalised personal email), its business partner (name + work contact only), engagement 1, assignment and pay slices, one onboarding run from the matching playbook. Twice = the same. 30+ days back refused; a second open engagement refused, naming it; a company freelancer needs the company |
| HR-14/39 history is sliced, never overwritten | a change delimits the slice before it; a correction supersedes (kept, superseded_by, so "what did we believe on day X" is answerable); TC1 (no gap, no overlap) checked after every personnel event |
| HR-06/15/16/17 leave, rehire, conversion, transfer | the seven event kinds: a rehire is engagement n+1 and never offboards; a conversion ends n and starts n+1 as ONE event with one run and no access removal; a transfer's change run carries the access delta |
| HR-12/39 the manager is derived | ManagerOf: the chief position's holder of the person's unit on that date, walking up; an override wins; a reorganisation is a dated unit slice; cycles and second chiefs refused |
| HR-13 sensitive data apart | its own aggregate (hr_sensitive root, own version); HR and finance only — 404, never 403, to anyone else, through both doors |
| HR-02/53 the starter's own form | through the platform's public-link door (token hashed, expiring, reissue revokes); values land in Sensitive and are never shown back; the manager sees "details: 5 of 6 done" |
| HR-03/04/30 runs that do the work | versioned playbooks expanded into runs; steps timed in working days in the org's zone (DST-correct), My tasks (my_hr_tasks, GET /my-tasks), the run board, the tick run_due_steps |
| HR-36/52 actions through capabilities | CapabilitySeam per capability; no provider = a manual task with the exact input and the plan (rule 8); in_progress re-asked, retryable failures retried with backoff then a task for IT, connection_required a task at once |
| HR-07/35 nothing destructive by timer | a destructive step depends on a granted approval; the delete waits for drive.transfer_status = completed |
| HR-08/11/23 reality changes | moving a date re-times only undone steps; a playbook change is a new version (open runs keep theirs); cancelling makes a reversal TASK per done action |
| HR-05/33 AI drafts | the welcome email, handover list, leaver letter via Models (https://api.dodil.io/v1, kimi-k2.6), given only non-sensitive fields; a failure is a task "write it by hand" |
| HR-51 contracts | contracts.draft/send/status/fetch_executed as a consumer (no provider yet → a manual task: attach the signed copy); the IR35 gate blocks a company freelancer's contract until the determination is recorded |
| HR-29 monitoring onboarding | onboarding_monitor (GET /reports/onboarding, hr:run:monitor): run lead time, steps overdue by owner group and team, days from the start date to the first access granted, deviations per playbook version — derived from runs on read, in business_timezone, over the runs the caller may see (APQC 7.3.1.3) |
| HR-42 outplacement on a redundancy | the gb employee offboarding's offer_outplacement step (APQC 7.6.2.4): HR offers it 2 working days after the leave is recorded, only when the leave reason is redundancy (the condition reason_is); completing it records output.outcome = accepted or declined (a step's outcomes, any other answer 422). A task by default; an action with the same outcomes when a provider is wired |
| HR-43 retirement | the gb playbook employee-retirement (rows, APQC 7.6.2.1): the employee offboarding for the leave reason retirement — chosen because it names the reason — plus the notice to the pension scheme (finance), knowledge handover sessions after the handover list (manager) and long-service recognition (manager) |
| HR-54 the ERP login goes too | every gb offboarding's revoke_erp_login step (it, last day 17:30, after the access-removal approval): identity.revoke_app_user disables the composition's pool user and revokes its sessions. The capability is erp-platform's (capabilities.yaml 0.6, erp-platform 0.9.7; it was hr's own capabilities.local.yaml before), under the platform's rule: no provider (CAPABILITY_IDENTITY_REVOKE_APP_USER), or no pool configured, and it is a manual task with the exact input |
| HR-55 when the ERP login goes | the owner's dated setting offboarding.revoke_access_at (POST /dated-settings/offboarding.revoke_access_at, hr:offboarding:policy): last_day_end (the default: the last day at 17:30 in the business timezone, after the access-removal approval) or immediate (as soon as the leaver is recorded and the access removal approved), overridable per leave reason (scope: dismissal). Rows are only ever added — from tomorrow, the first row of a key may start today — so GET /dated-settings/… is the audit trail; a run already made keeps its timing |
| HR-63 organisational assignment (v4) | show_assignment (GET /persons/{person_id}/assignment?on=, machine hr:assignment:read): employed, company_code, org_unit_id, org_unit_code, team_code, the derived manager, work_email and pool_user_sub on a date — derived on read, employed false with nulls when no engagement covers the date, 404 outside the caller's companies. team_code is the assigned unit, org_unit_code the unit above it (hr has no separate unit code) |
| HR-64 the roster (v4) | show_roster (GET /roster?company_code=&on=&org_unit_code=&manager_person_id=&limit=&offset=, machine hr:roster:read): who is employed by a company on a date, ordered by person_id, ≤ 200 a page with next_offset; an org unit includes the units below it; a manager's team is their direct reports on the date; another company's roster is an empty page |
| per-company grants (v4) | every hr grant stops at the companies hr_company_grant names for the caller (principal = their sub, email or service client id); none named = every company. Kept through POST /configuration/company_grant (hr:config:write) by someone whose own grant covers every company, never on their own row. dodil-appid's role rows cannot carry company_codes (a role maps to permission strings only), hence the table — see AGENTS.md |
| HR-18/19 reports, payroll | headcount/FTE/starters/leavers derived from slices for any date; the payroll extract per legal entity and period (proration dates, retro lines after a sent batch, freelancers never in it, rounding once) |
| HR-20/21/22/40 documents and privacy | documents by access class, streamed by the use case (never a link); subject-access export; erasure with legal holds (bp anonymised through the seam); the daily retention sweep |
Your process as BPMN 2.0
show_playbook_bpmn (GET /playbooks/{playbook_id}/bpmn[?version=n], and the MCP tool of the same name; read-only,
hr:config:write like list_playbooks) returns one playbook version as a BPMN 2.0 process model, so you, your
auditor, a BPMN tool or an agent can see exactly what your configured flow does. Over HTTP the answer is the XML
itself (application/xml, ETag = the version); Accept: application/json, the MCP tool and a composition's
/hr/… route give {playbook_id, version, media_type, filename, bpmn}. The same playbook always gives the same
bytes. The playbook rows stay the source: change them with keep_playbook (a new version), not the XML.
| In the playbook | In the BPMN |
|---|---|
| the playbook | a process (name, direction, version) in a pool; the event types and worker types in its documentation |
| responsible groups (hr, it, finance, manager, buddy, starter…) | lanes, labelled from your responsible_group rows |
| a task, an approval, the starter's form | user tasks; a draft is a service task (Models) followed by "Approve: …" |
| an action / document step | a service task named for its capability; its documentation says that with no provider wired it becomes a manual task for the same group carrying the same input; a fan-out is a parallel multi-instance task |
depends_on | sequence flows: start → the steps with no dependency, the steps nobody depends on → end, parallel gateways where a step forks or waits on several |
if conditions (worker_type_is, via_company, rate_outside_band, client_size_is, reason_is) | an exclusive gateway with the condition in words; "no" skips the step |
requires (pack_requires) | an exclusive gateway "… recorded?"; not yet → "Record the …" (HR) first |
| timing rules | a timer before the step: "3 working days before start_date, 09:00 Europe/London" |
| capabilities | message flows to a collapsed pool per outside system, by capability family (Identity, Groups, Mail, Contracts…), never a vendor |
| intake fields, drafts, contracts | data objects, with data input/output associations where a step reads or writes them; each service task's ioSpecification lists its inputs (the step's input template, else the capability's input) and outputs (capabilities.yaml) |
| the step key, APQC, deviations | dodil:step (the key), dodil:pcf (the APQC PCF id of the process and of a step that maps to one) and dodil:deviation (variation / warning) attributes in the namespace https://git.dodil.io/dodil/erp-platform/bpmn/1 — BPMN's anyAttribute, so the document stays XSD-valid and a viewer can colour by them |
A diagram (BPMN DI) is included: columns by dependency depth, rows by lane. To open one, save the response as a
.bpmn file (a sample, the gb pack's employee onboarding, is docs/samples/onboarding.bpmn),
then in Camunda Modeler File → Open; in SAP Signavio Process Manager import it as BPMN 2.0 XML; or drop it
on demo.bpmn.io. The model is descriptive (isExecutable="false"): it shows the flow, and
brick-hr runs it. Validity is tested against the OMG's BPMN 2.0 XSD (tests/fixtures/bpmn20).
Stable step ids
Every playbook step has a key: a slug (send_contract, create_account), unique in its playbook, stored in
hr_playbook_step.step_key and never renumbered. The step number is a position; the key is a name. Every BPMN id
derives from keys — Task_<key>, Timer_<key>, Gateway_<key>__if, Gateway_<key>__fork, Task_<key>__draft,
Task_<key>__record_<record>, Flow_<source>__<target> — so inserting or removing a step changes no other
element's id, and a scenario path, a signed change item or a process-mining join stays valid. The gb pack's
steps have designed keys; a step a customer adds gets a slug of its title (made unique, never a key the previous
version gave another step), and a step saved again keeps its key (matched by title). Rows stored before 0.4.0 are
keyed on read (the pack's key while it is still the pack's step) until scripts/backfill_step_keys.py writes them.
Deviations from the standard
playbook_deviations (GET /playbooks/{playbook_id}/deviations[?version=n], read-only) compares a playbook with
the reference playbook of its localisation pack (the same id, else the same direction and worker types), by
step key: each step added, removed or changed (owner group, timing, condition, an approval no longer waited
for…), and the watched settings (the contract signer, the chase cadence). Each deviation is one of:
| Class | Means | Examples |
|---|---|---|
variation | inside the defaults: shown, signed, built | a step added or removed, re-timed, given to another group |
warning | removes a best-practice safeguard: allowed, flagged, signed knowingly | the rate approval removed; access removal later than the last day; the right-to-work task or the contract step removed; approvals never chased |
foundation | breaks the kept foundation: refused at save, with the reason | see below |
hr's kept foundation: (1) a destructive step that no approval precedes; (2) an account delete that no completed
ownership transfer precedes; (3) a conversion playbook that takes access away; (4) a pack gate dropped from a
step that keeps it — the right-to-work check before the account (IANA 2006 s.15), the status determination before
a company freelancer's contract (ITEPA 2003 Part 2 Ch. 10); (5) two steps with one key, or a key that is not a
slug. show_playbook_bpmn marks each deviating step (dodil:deviation and a documentation note) and lists the
reference steps a playbook does not have in the process documentation.
One change at a time. change_playbook (POST /playbooks/{playbook_id}/changes) is what a chat request or a
diagram edit becomes — add_step (with after / before keys: "a director approves before the contract"),
remove_step, remove_approval, set_owner, set_timing (an anchor may be step:<key>.done) — saved as the next
version through every rule keep_playbook has; its answer carries the new deviations. Who signs a contract for
the company is the setting contract_signer_person_id (a run's own director party still wins); the chase
cadence is the *_chase_every_days / *_chase_max / chase_unit settings — both through keep_configuration,
and both shown in the BPMN and the deviations.
Scenarios as paths
scenario_paths (GET /playbooks/{playbook_id}/scenario-paths, read-only) lists the scenarios of
tests/scenarios/catalogue.py that walk this playbook (through its reference), each as the ordered step keys it
traverses and their element ids (Task_<key>, what a viewer highlights), and whether the path still walks on the
latest version (each step there, each waiting for the one before). The paths are declared once, in
src/brick_hr/scenario_paths.yaml; tests/test_process.py checks each walks the reference and names a real
scenario.
Who
hr_admin (Operations) everything; manager their derived line, never sensitive; it people with an open IT step,
never pay or sensitive; finance everyone, pay and sensitive; assignee only steps of their groups; employee
GET /me; the starter a public link; clock the tick (hr:run:tick); the neighbours' service clients
(expenses_client, time_off_client, …) HR-63/64 through the machine permissions hr:assignment:read and
hr:roster:read (an exact grant only). Every one of them stops at the companies its hr_company_grant rows name.
Permissions: hr:* (tests/scenarios/roles.yaml).
Tables
All hr_, natural keys, schema.sql generated from infrastructure/tables.py (scripts/gen_schema.py). Person
(hr_person + detail, engagement, assignment, pay, pay_component, event, document, compliance_record), Sensitive
(hr_sensitive + hr_person_sensitive), Org (hr_org + org_unit, position), Playbook (hr_playbook +
hr_playbook_step, keyed by step_key since 0.4.0), Run (hr_run + step, party, log), Intake, PayrollExtract (batch + line), PrivacyRequest;
twenty-one catalogue tables (0.6.0: hr_company_grant) and hr_config_version (one token per configuration table, rewritten with every
change: what lets a process cache the configuration); append-only hr_step_claim, hr_retention_log, hr_object_chunk (document bytes).
Run
python -m venv .venv && .venv/bin/pip install -e '.[test]' # plus erp-platform 0.9.6+ and brick-business-partner at the version tests/scenarios/compose.yaml names (0.3.2)
.venv/bin/python -m pytest -q --tb=short # rules, both doors, tier 1, the scenarios
.venv/bin/python scripts/mutation_check.py # each key defect put back must fail its test
BUCKET=erp-platform-test DODIL_ORG=dodil .venv/bin/python scripts/live_check.py # the stories on DataK3
erp-scenarios run tests/scenarios/catalogue.py # SC-HR-01..32 + probes, in process
Live scenarios: against a demo deployment with HR_DEMO_CLOCK=enabled (the clock scenarios need the demo clock
route; it refuses to start with DODIL_ENV=live), erp-scenarios run tests/scenarios/catalogue.py --live --base-url … --public-base-url … --bucket <stamped demo> --org <org>; the fault scenarios are skipped there.
Environment: BUCKET, DODIL_SERVICE_ACCOUNT_ID/_SECRET (needs k3.editor and, for drafts, ignite.model-user),
DODIL_USER_POOL, PUBLIC_LINK_BASE_URL (the intake link), BP_BASE_URL at tiers 2–3, CAPABILITY_<NAME> to
wire a provider, HR_DEMO_CLOCK=enabled on a demo only.
Speed on DataK3
Every statement on DataK3 costs a round trip plus ~50-200 ms on the server, so hr counts them
(scripts/bench_live.py). The configuration (catalogues, playbooks, org trees) is cached per table version; reads
and writes go in pipelined batches; a save writes only what changed since its load; the tick finds what is due,
and who may need a warning or retention, by query. Measured on erp-platform-test, 50 people, 10 open runs, warm
app (p50; 2026-10-17): hire 1.8 s (was 35), show_person 0.6 s (was 9), my_hr_tasks 0.7 s (was 6), steady tick 1.0 s
(was 13.5), first tick acting on 10 runs 12 s, pack install 40 s (was 316). Reads right after a burst of writes run
~2x slower until DataK3 drains its write-log.
Statement budgets (tests/test_statement_budget.py, SQLite, statements per request; none grows with unrelated
people or runs): hire 17 (15 before 0.9.6's diff save added its version check), record leaver 24 (was 31: the run is
expanded from the person as committed), complete step 19 (was 22), steady tick 7, headcount 10 (one read per table).
Not in 0.1
Absence and time (extension points), concurrent employment (the model allows it; v1 refuses a second active
engagement), more than one localisation pack (gb), a screen. Documents are bytes in hr_object_chunk in the bucket
until the platform has an object-store helper. See AGENTS.md for the known gaps.
License: Apache-2.0, see LICENSE