dodil/erp-brick-hr

README.md

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

StoryHow
HR-01 one form for a starterrecord_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 overwrittena 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, transferthe 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 derivedManagerOf: 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 apartits 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 formthrough 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 workversioned 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 capabilitiesCapabilitySeam 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 timera destructive step depends on a granted approval; the delete waits for drive.transfer_status = completed
HR-08/11/23 reality changesmoving 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 draftsthe 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 contractscontracts.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 onboardingonboarding_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 redundancythe 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 retirementthe 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 tooevery 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 goesthe 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, payrollheadcount/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 privacydocuments 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 playbookIn the BPMN
the playbooka 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 formuser tasks; a draft is a service task (Models) followed by "Approve: …"
an action / document stepa 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_onsequence 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 rulesa timer before the step: "3 working days before start_date, 09:00 Europe/London"
capabilitiesmessage flows to a collapsed pool per outside system, by capability family (Identity, Groups, Mail, Contracts…), never a vendor
intake fields, drafts, contractsdata 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, deviationsdodil: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:

ClassMeansExamples
variationinside the defaults: shown, signed, builta step added or removed, re-timed, given to another group
warningremoves a best-practice safeguard: allowed, flagged, signed knowinglythe rate approval removed; access removal later than the last day; the right-to-work task or the contract step removed; approvals never chased
foundationbreaks the kept foundation: refused at save, with the reasonsee 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