# People: starters, leavers and who works here. The module source.
#
# PROVENANCE. Derived from a pilot engagement, 2026-10-17 (a creative agency with two UK offices, the
# first customer for this module, and deliberately NOT its shape). Sources, and how far each one is
# signed off:
# - SIGNED OFF BY THE CUSTOMER: nothing yet. The need is the customer's own written request, in their
# words. Their stories G-01 to G-17 are requirements written FROM that request; the customer has not
# reviewed them yet.
# - AGREED IN A BOUNDARY REVIEW (30 rows, 2026-10-17): what is core and what is a customer adjustment.
# Every row marked core is in scope here: the UK localisation pack, documents on a person, headcount
# reporting, retention and erasure, and the audit trail. Absence is out of scope for now and is an
# extension point.
# - DESIGNED (2026-10-17). These decisions are DODIL's, made on HCM research (SAP HCM, SuccessFactors
# and Workday): everything else. That covers the aggregates, effective dating, personnel events, the
# business-partner link, pay precision, the onboarding/offboarding engine and the extension points. A
# customer still has to correct this in phase 2. Use it as the vocabulary to start that conversation
# from, not as an answer.
#
# What a generic HR spec misses, and this one does not:
# - history is SLICED, never overwritten: "who managed whom in March", retro pay and the audit trail all
# depend on it (SAP's time constraints; pitfall 1 in the research)
# - the manager is DERIVED from the org tree, never stored on the person
# - onboarding and offboarding DO things (accounts, access, contracts, mail). With no connector installed,
# each action is a precise manual task, and wiring a connector later makes the same step automatic
# - sensitive personal data is a separate aggregate. A manager gets 404 on it, not a hidden field.
#
# The extra top-level sections (brick, domain_model, localisation_packs, extension_points) go out verbatim
# in /modules/hr/spec.yaml for the builders. The human page renders only the standard sections.
start_here: >-
START HERE, before reading on: dodil://erp/build is the map (read it first; this spec is a vocabulary,
not the method). Then, in the project folder, two commands: (1) `uvx --from git+https://git.dodil.io/dodil/erp-platform.git@v0.8.2 erp-compose init hr`;
(2) SHOW the customer the diagram: `uvx --from git+https://git.dodil.io/dodil/erp-platform.git@v0.8.2 erp-compose process --module hr --playbook employee-onboarding
--calls config/hr.calls.yaml` (config/hr.calls.yaml: `- { use_case: install_localisation_pack, args: { pack: gb,
with_playbooks: true } }`), then paste its step names in order, with who does each, into your message
(`grep -o 'Task_[a-z_]*" name="[^"]*"' processes/employee-onboarding.bpmn`). The signed artifact is
that BPMN: never prose, never "open bpmn.io". Cards: dodil://erp/phase/show, dodil://erp/phase/signoff.
# Version 4 (2026-10-26, DRAFT for sign-off, spec gaps wave 4; version 3 stays the signed one until it is signed):
# - seams only, no process change: HR-56, HR-57, HR-58 and HR-62 get one entry per caller, named in with: (what
# erp_platform.contract reads), in place of the callers: lists; HR-54's two leaver routes carry the route alone,
# the body under request
# - blog checker: HR-54's identity revoke names its provider under provider: (dodil-appid is a platform
# capability, not a module); the task reassignment names crm (the activity brick) in with: in place of free text
# - seemo's decided answer 2 (SAP's organisational assignment plus communication): HR-63 GET
# /persons/{person_id}/assignment (company, org unit, team, derived manager, work email by person id) and HR-64
# GET /roster (who is employed in a company, org unit or a manager's team on a date, paged); one seam row per
# caller (time-off, expenses, timesheets, payroll); permissions hr:assignment:read and hr:roster:read for those
# clients
# - every hr grant is scoped per company (SAP's structural authorisation by company code): a role row names its
# company_codes, none = every company; hr_admin, finance and the service clients see only those companies'
# people
# - HR-62: contracted_minutes > 0 while employed is a working day for a caller counting days; minutes are for a
# caller costing hours
id: hr
name: People, starters & leavers
short: People
version: 4 # v3 signed by seemo 2026-10-25 with wave 4 (signoff/hr/ re-recorded; seams only, no process change): HR-56..59 written as stories, HR-60 payroll extract for the payroll module, HR-61 payroll identity, HR-62 work pattern, HR-54 reaches time-off; time-off, timesheets and payroll added as callers. v2, signed by seemo 2026-10-25 (, erp-roadmap #54): HR-29 onboarding monitor, HR-42 outplacement (a PROCESS change: signoff/hr/employee-offboarding.bpmn), HR-43 retirement playbook, HR-54 the Left event reaching other bricks, HR-55 when the ERP sign-in goes (a dated setting, per leave reason), SC-HR-32 documents
maturity: tested # built and tested, not deployed (pattern §14): rules, both doors, tier 1 and the scenarios pass; live_check ran on DataK3; nothing has run behind the gateway for a real person
summary: >-
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. That covers their details, the contract, accounts and
access, first-week plans, everyone's tasks, and access removed at the right time. The history is
kept, so nothing is ever overwritten.
facets:
domain: people
industries: [any]
customer_words:
- "onboarding and offboarding"
- "bring a new employee or freelancer into the business"
- "collect their details and trigger an onboarding checklist"
- "remove access at the right time"
- "who works here"
- "starters and leavers"
- "a new starter on Monday"
- "handover when someone leaves"
replaces: [spreadsheets, a checklist in a shared doc, BambooHR, HiBob, Personio, Workday]
pillars: [sql, graph]
first_module_fit: medium
depends_on:
- module: business-partner
why: >-
every person gets ONE business partner (one per natural person, S/4's model), so contracts,
finance and purchasing address the same person with one key, and a freelancer's company is
the supplier finance pays
required: true
- module: contracts
why: the contract step in a run (draft from an approved template, sign, file the signed copy on the person)
required: recommended # without it, the contract step is a manual task with the exact input it would have sent
feeds: [contracts, expenses, itsm, helpdesk]
owns_master_data: [person, org_unit, position, job]
references_master_data: [business_partner, cost_centre]
# ---------------------------------------------------------------------------------------------
# The brick: the same shape as brick-pipeline, brick-quote, brick-business-partner and brick-activity
# (erp/erp-platform/BRICKS.md). Builders copy this layout; they don't invent one.
# ---------------------------------------------------------------------------------------------
brick:
package: brick_hr
short_name: hr # the permission namespace and the table prefix (hr_)
layout:
- "src/brick_hr/domain/ rules in plain Python: aggregates, value objects, domain events, domain services, policies. No SQLAlchemy, FastAPI, MCP or identity."
- "src/brick_hr/application/ use cases declared ONCE with @HR.use_case (each one is an API route AND an MCP tool), with `caller` as the first parameter; row visibility is decided here"
- "src/brick_hr/infrastructure/ tables + one repository per aggregate (erp_platform.repository Aggregate/Part); the CapabilitySeam and the Models adapter live here (the anti-corruption layers)"
- "src/brick_hr/app.py app = HR.app()"
- "src/brick_hr/schema.sql inside the package, so an installed brick can migrate itself; additive only"
- "tests/test_rules.py rules with no database: one test per Given/When/Then sentence"
- "tests/test_doors.py both doors (API and MCP) over the real app with storage on SQLite / the in-memory repository: same outcome both ways, the 404 for another person's row, the 403 without the permission"
- "tests/test_tier1.py a tier-1 suite with the real neighbour bricks (business-partner, contracts when it exists) in one bucket"
- "scripts/live_check.py the deployed brick against a dev bucket (erp_platform.livecheck)"
- "README.md · AGENTS.md · CLAUDE.md, like brick-pipeline's: where a change goes, what not to do, how to test"
refusals: "Refused(status, reason) with the rule's own words; a broken domain rule is 409, invalid input 422, a row the caller may not see 404 (never 403)"
no_hand_written_doors: "no api/ or mcp/ folder: both doors are generated from the use-case declarations"
uses_platform:
- "erp_platform.repository: Aggregate/Part, one aggregate per unit of work, no session.merge, no hand-written upsert"
- "erp_platform.money: exact Money. NEEDS a 4-decimal Rate type (see domain_model.value_objects.Rate); money.py is 2dp today"
- "erp_platform.seam: the business-partner seam (bp:person:sync, bp:partner:promote)"
- "CapabilitySeam (platform, to be built per capabilities.yaml 0.3): every action step, contracts.* included"
- "the public-link door (platform, to be built): the starter's intake link"
- "the tracked-work LIBRARY (platform, not a brick): timing rules with working-day offsets and the chase unit, due/overdue/today/this-week, the board, chasing cadence. hr passes it a WorkCalendar built from its schedule and holiday rows; delivery will use it too"
- "erp_platform.requestlog: one request id through every door and seam (logged, not used as a key)"
- "append-only ids are (aggregate key | seq), deterministic, never UUID(): hr_event person_id|n|seq, hr_run_log run_id|seq"
use_cases: # named in the ubiquitous language below; each is a route and an MCP tool
commands: [record_hire, record_leave, record_transfer, record_pay_change, record_contract_extension,
record_rehire, record_conversion, correct_slice, move_event_date, submit_intake, reissue_intake_link,
complete_step, approve_step, approve_draft, assign_run_party, retry_step, cancel_run,
file_document, record_compliance, invite_to_sign_in, keep_org, keep_configuration,
keep_playbook, change_playbook, record_sensitive, produce_payroll_extract, mark_extract_sent,
record_privacy_request, run_due_steps, set_demo_clock]
queries: [my_hr_tasks, run_board, show_run, preview_run, show_person, show_sensitive, list_documents,
read_action_log, headcount, starters_and_leavers, org_chart, export_subject_access, show_my_record,
show_playbook_bpmn, playbook_deviations, scenario_paths, onboarding_monitor]
# ---------------------------------------------------------------------------------------------
# Domain model (DDD). Builders implement this; stories and entities use exactly these words.
# ---------------------------------------------------------------------------------------------
domain_model:
ubiquitous_language:
person: "a natural person we employ or engage. One per human, however many times they come back. Key person_id."
engagement: "one period of working for us under one worker type (employee, freelancer, contractor, intern). A rehire is a new engagement; so is a conversion."
worker_type: "what the engagement is (a row): employee full-time, freelancer (sole trader), freelancer (via a company)… It decides the playbook, whether payroll applies, and the business-partner roles."
assignment: "where an engagement sits on a date: org unit, job, optional position, location, work schedule, FTE, cost centre ref. Sliced."
org_unit: "a team or department in the org tree. Sliced, so a reorganisation is dated."
position: "one seat in an org unit that describes a job. It can be vacant. Optional, except the chief position."
chief_position: "the position that manages an org unit. Its holder is the manager of everyone assigned to the unit, and of units below it that have no chief of their own."
manager: "DERIVED: the holder of the chief position of the assignment's org unit, walking up the tree. Never stored. A manager override on the assignment is the rare exception."
job: "a catalogue entry (Motion Designer). A position describes one; an assignment names one."
slice: "one dated version of a record, valid_from to valid_to (open = 9999-12-31)."
delimit: "to end the previous slice the day before a new one starts (valid_to = new valid_from − 1). Nothing is overwritten."
correction: "a new slice with the SAME valid_from that supersedes the old one (superseded_by). We keep what we believed before."
personnel_event: "something that happened to an engagement on an effective date: Hired, Transferred, PayChanged, ContractExtended, Left, Rehired, Converted. It writes slices and may start a run."
reason: "why a personnel event happened (a row per event type: resignation, end of project…)."
infogroup: "the list of records one event type captures (a row set). The event wizard is generated from it."
playbook: "the customer's checklist for one kind of run (worker type × event type × reason × org unit), versioned."
run: "a playbook expanded for one person and one personnel event. Its id is derived, so a double click is one run."
step: "one item of a run, of a kind: task | action | document | draft | intake | approval."
responsible_group: "WHO a step goes to: manager, IT, finance, buddy, HR, the starter. Not a named person; resolved when the step falls due."
capability: "a named thing a provider brick can do in the outside world (identity.provision_user). hr asks for capabilities, never vendors."
manual_task: "the first-class path when no provider is wired: a task carrying the capability, the exact validated input and the plan in words."
sensitive_data: "NI/tax id, bank, home address, date of birth, emergency contact, right-to-work evidence. Its own aggregate, HR and finance only."
localisation_pack: "a country's legal fields, document types, steps and checks (UK first), swappable per country."
bounded_context:
inside: >-
people, engagements, org structure (units, positions, jobs, locations, schedules), pay RATES (not
payroll), personnel events, onboarding/offboarding/change/conversion runs and their steps, the
starter intake, documents on a person, sensitive data, retention, headcount reporting
outside: >-
payroll calculation (tax, NI, pensions, RTI), contract wording and signing (contracts), the party
record itself (business-partner), the vendor's systems (provider bricks), sign-in (the pool), time
and absence (future bricks), cost centres (finance)
context_map:
- neighbour: business-partner
relationship: "hr is UPSTREAM for person fields: hr is the single writer of a person's partner (name, work contact, roles) through a PUBLISHED write path (bp:person:sync). bp is the shared record everyone reads."
- neighbour: contracts
relationship: "customer/supplier: hr is the customer and asks through the contracts.* capabilities (to be added to capabilities.yaml). contracts never reads hr; hr passes the counterparty bp_id, the context ref and the variables."
- neighbour: connector providers (google-workspace first; none in the first demo)
relationship: "an ANTI-CORRUPTION LAYER: hr speaks capabilities (capabilities.yaml 0.3) through the CapabilitySeam, never vendor terms. No provider means the manual task path."
- neighbour: the payroll provider (outside the platform)
relationship: "an ANTI-CORRUPTION LAYER through the period extract: hr publishes rates, slices and proration dates per legal entity; it never models tax or payslips."
- neighbour: the identity pool (dodil-appid)
relationship: "conformist: hr stores pool_user_sub when a person is invited, and resolves callers through it. It never verifies tokens (the gateway does)."
- neighbour: Models (api.dodil.io, OpenAI-compatible)
relationship: "a small adapter for draft steps, under the billed ignite.model-user role, granted deliberately. It is given no sensitive field."
aggregates:
- name: Person
root: "hr_person (person_id)"
parts: [hr_person_detail, hr_engagement, hr_assignment, hr_pay, hr_pay_component, hr_event, hr_document, hr_compliance_record]
invariants:
- "TC1: hr_person_detail has exactly one slice on every date from the person's first; no gap, no overlap"
- "TC1 per engagement: hr_assignment covers the engagement from its start date to its end, no gaps, no overlaps"
- "TC1 per engagement from the FIRST pay slice: once pay is recorded it has no gaps or overlaps to the engagement's end (a hire may be registered before pay is known; the playbook's 'record pay' task closes that)"
- "at most ONE active engagement at a time in v1 (concurrent employment is an extension point; the model already allows n)"
- "personnel events are append-only, and each one writes at least one slice in the same transaction"
- "a freelancer engagement via a company names a supplier_bp_id; an employee engagement never does"
- "a correction keeps the old slice (superseded_by) and is itself a slice, so 'what we believed on date X' is answerable"
- "a manager override names a person assigned within the SAME org (org_id) as the assignment; across org trees it is refused in v1"
enforced_in: domain (Person.apply(event) and the slice rules), never the database
- name: Org
root: "hr_org (org_id): one per org tree"
parts: [hr_org_unit, hr_position]
invariants:
- "the unit tree is acyclic on every date"
- "a unit has at most one chief position at a time"
- "every unit and position is TC1 over its own lifetime"
- "a position moving units is a new slice, not a new key (keys are org_id|position_id|valid_from)"
enforced_in: domain
- name: Sensitive
root: "hr_sensitive (person_id PK, version): a thin root row, so it is a proper Aggregate with its own version for concurrency"
parts: [hr_sensitive, hr_person_sensitive]
invariants:
- "a separate root: no manager-facing use case loads it, and the Person repository never joins it"
- "read and written only through use cases requiring hr:sensitive:read / hr:sensitive:write"
enforced_in: application (permission) + repository (no join path) + an access test on every route
- name: Configuration
root: "one small aggregate per catalogue, rooted at its natural key (job_code, location_code, schedule_code, event_type, …)"
invariants:
- "an event reason belongs to exactly one event type; an infogroup names only records the event's kind can write"
- "a pay component is valid for the worker types it names"
enforced_in: domain
- name: Playbook
root: "hr_playbook (playbook_id|version)"
parts: [hr_playbook_step]
invariants:
- "an active version is immutable once a run has used it; a change is a new version"
- "a destructive capability step depends on an approval step"
- "identity.delete_user depends on a drive.transfer_ownership step in the same playbook (the engine then waits on drive.transfer_status = completed)"
- "depends_on forms no cycle; step:<n>.done anchors name an existing step"
enforced_in: domain (refused on save, 409, in words)
- name: Run
root: "hr_run (run_id)"
parts: [hr_run_step, hr_run_party, hr_run_log]
invariants:
- "the playbook version is frozen at expansion: later playbook edits never change an open run"
- "a destructive step never fires without a granted approval"
- "a done step is never re-timed and never acts again"
- "each action/document step carries idempotency_key = <run_id>|<step_no>"
enforced_in: domain
- name: Intake
root: "hr_intake (intake_id = <person_id>|<n>)"
invariants: ["one open link at a time; reissuing revokes the previous one (through the door)"]
- name: PayrollExtract
root: "hr_payroll_batch (batch_id = <legal_entity_ref>|<period>)"
parts: [hr_payroll_line]
invariants: ["a sent batch is frozen; a later change to a sent period goes into the next open batch as a retro line, so a row goes out exactly once"]
- name: PrivacyRequest
root: "hr_privacy_request (request_id)"
parts: [hr_retention_log]
transactions: >-
One aggregate per transaction. Cross-aggregate consistency is EVENTUAL: a personnel event commits
on the Person, and the run engine (run_due_steps) then expands the run, syncs the business
partner and calls capabilities, each step idempotent on its own key. A step that reads what an
earlier transaction wrote re-reads after commit (no read-your-writes inside an open transaction).
value_objects:
EffectivePeriod: "valid_from / valid_to (9999-12-31 = open). delimit(new_from) → valid_to = new_from − 1 day; covers(date); overlaps(other)"
PersonKey: "the customer's staff number ('staff:<no>'), else 'email:<lower(trim(personal_email))>'. Fixed at creation, never re-keyed"
PersonId: "int.from_bytes(blake2b(person_key, digest_size=7)): a 56-bit BIGINT, like bp_id"
EngagementRef: "person_id|n"
RunId: "<direction>:<person_id>:<n>:<event seq>"
Rate: "amount NUMERIC(19,4) + basis (hour | day | month | year | fixed) + currency (ISO 4217). Never converted between bases at write time"
Minutes: "an integer count of minutes (time is never a float)"
Days: "NUMERIC(5,2)"
PayableAmount: "rate × quantity (minutes/60 or days) in exact decimal, rounded ONCE at the payable line to the currency's minor unit with the configured rounding mode"
TimingRule: "anchor (start_date | last_day | event_date | step:<n>.done | run.created) + offset (signed integer) in calendar_days or working_days + time of day + the org's timezone (IANA, a row)"
CapabilityCall: "capability name + validated input + idempotency_key + dry_run. The result is done | planned | in_progress | connection_required | failed (capabilities.yaml 0.3)"
ResponsibleGroup: "a group code resolved on the due date (derived manager, a pool role, or a run party)"
domain_events: # these ARE the hr_event rows (the event's kind); past tense
Hired: "a new engagement starts (first ever, for a new person). Writes engagement, first assignment, optional pay, detail when new. Starts an onboarding run"
Transferred: "a new assignment slice (unit, job, position, location, schedule). Starts a change run (the access delta)"
PayChanged: "a new pay or component slice; reason required. No run (it appears in the payroll extract)"
ContractExtended: "planned_end moves. Re-times the offboarding run's undone steps if one exists"
Left: "actual_end set; assignment and pay delimited at last day + 1. Starts an offboarding run, unless the reason is 'converted'"
Rehired: "a NEW engagement n+1 for an existing person; history kept. Starts an onboarding run (rehire playbook). NEVER starts an offboarding run"
Converted: "Left (reason converted) on n plus a new engagement n+1 on the same date, as ONE event. Starts one conversion run; access is kept"
domain_services:
ManagerOf: "(person, date) → the derived manager: the assignment's org unit chief, walking up; the override wins when set"
FteOf: "(assignment, date) → schedule hours ÷ the schedule's full-time base × the assignment's employment %"
RunExpansion: "(personnel event, playbook version, person, org) → a Run with steps whose due_at comes from each TimingRule; a capability with no provider becomes a manual task here (rule 8)"
Retimer: "(run, changed anchor) → new due_at for every step not done; done steps untouched"
Headcount: "(date or period, grouping) → headcount, FTE, starters, leavers, derived from slices"
WorkingDayCalendar: "(location, date) → working day or not, from the work schedule and the public-holiday calendar rows; handed to the tracked-work library as its WorkCalendar"
policies: # rows the customer edits, evaluated by domain code (specifications)
- "event types (each mapped to one domain event kind), reasons and infogroups"
- "playbook selection: worker type × event type × reason × org unit (most specific wins; a tie is refused on save)"
- "step conditions: a named specification from a fixed set in code (worker_type_is, via_company, rate_outside_band, pack_requires) chosen by a row"
- "chasing cadence (the tracked-work library), the max attempts before an action step goes to IT"
- "the rounding mode at the payable line (default half-up)"
- "the retention schedule per data category"
- "which document types are personnel, pay-bearing or sensitive"
factories:
PersonFactory: "from a Hired event: Person + detail slice + engagement 1 + first assignment (+ pay), person_id derived from PersonKey. A second submit finds the same person_id and the same engagement, so it is a no-op"
RunFactory: "from a committed personnel event + the selected playbook version → Run (RunExpansion)"
repositories: >-
One per aggregate (PersonRepository, OrgRepository, SensitiveRepository, ConfigurationRepository,
PlaybookRepository, RunRepository, IntakeRepository, PayrollExtractRepository,
PrivacyRepository), each an erp_platform.repository Aggregate of Parts whose keys all start with
the root key. Each one loads the whole aggregate, with no lazy loading. Nothing loads two aggregates
in one unit of work.
# ---------------------------------------------------------------------------------------------
actors:
- id: hr_admin
name: Operations / HR
does: runs every starter and leaver, keeps the org, playbooks and catalogues, handles personal data and privacy requests
sees: everything, including sensitive data
permissions: [hr:person:read, hr:person:write, hr:hire:record, hr:leave:record, hr:event:record, hr:pay:read, hr:pay:write,
hr:sensitive:read, hr:sensitive:write, hr:document:read, hr:document:write, hr:org:read, hr:org:write,
hr:run:read, hr:run:preview, hr:run:reschedule, hr:run:cancel, hr:task:read, hr:task:complete,
hr:step:approve, hr:draft:approve, hr:action:retry, hr:action_log:read, hr:config:write, hr:offboarding:policy,
hr:report:read, hr:run:monitor, hr:privacy:manage, hr:pool:invite, hr:clock:set]
- id: manager
name: Hiring manager / team lead
does: asks for a starter, approves the welcome email, records a leaver, takes over their work, moves a start or last day
sees: the people in their line (derived from the org tree) and their runs and pay, never sensitive data
permissions: [hr:person:read, hr:hire:record, hr:leave:record, hr:event:record, hr:pay:read, hr:document:read, hr:org:read,
hr:run:read, hr:run:preview, hr:run:reschedule, hr:task:read, hr:task:complete, hr:draft:approve, hr:report:read,
hr:run:monitor]
- id: it
name: IT / ops
does: does the account and access steps by hand until a connector is wired, approves account deletion, retries failed actions
sees: people with an open IT step, and the runs and action log for them, never sensitive data or pay
permissions: [hr:person:read, hr:run:read, hr:task:read, hr:task:complete, hr:step:approve, hr:action:retry, hr:action_log:read]
- id: finance
name: Finance
does: payroll hand-off for employees, supplier set-up for freelancers, bank and tax details
sees: every person, their pay and their sensitive data
permissions: [hr:person:read, hr:pay:read, hr:pay:write, hr:sensitive:read, hr:sensitive:write, hr:task:read, hr:task:complete, hr:report:read]
- id: assignee
name: Anyone a step is assigned to (a buddy, a takeover, a director)
does: sees and completes the steps of the responsible groups they belong to
sees: only their own steps, and the person's name and start date on them
permissions: [hr:task:read, hr:task:complete]
- id: employee
name: The person themselves, once they can sign in
does: sees their own record, their own engagement and documents, and their first-week plan
sees: only themselves, and not their own sensitive data (that goes through HR, customer_varies)
permissions: [hr:self:read]
- id: starter
name: The starter, before they have a sign-in
does: fills in their own details and uploads right-to-work evidence through a link
sees: one intake form, bound to their engagement, through the public-link door. It never echoes back stored sensitive values
permissions: [] # holds a public-link token, not a pool role
- id: clock
name: The clock (a service client, or the always-on worker)
does: "calls run_due_steps(now). ONE clock worker (the clock service client) calls every brick's tick route, listed in the composition"
sees: nothing through people's doors; the tick runs as the brick's service identity
permissions: [hr:run:tick]
- id: expenses_client
name: Expenses (a service client)
does: asks who a person is on a date, their employment and their derived manager, to fix an approval chain
sees: the employment slice and the derived manager on a date; no pay, no sensitive data
permissions: [hr:employment:read, hr:manager:read, hr:assignment:read, hr:roster:read]
- id: pos_client
name: Point of sale (a service client)
does: asks whether a person is employed on a date (staff discount, till login)
sees: the employment slice on a date; no pay, no sensitive data
permissions: [hr:employment:read]
- id: time_off_client
name: Time off (a service client, v3)
does: asks whether a person is employed, their work pattern and holidays, and their derived manager, to cost and route a request
sees: the employment slice, the work pattern and the derived manager; no pay, no sensitive data
permissions: [hr:employment:read, hr:manager:read, hr:work_pattern:read, hr:assignment:read, hr:roster:read]
- id: timesheets_client
name: Timesheets (a service client, v3)
does: asks a person's employment, expected hours and manager, to build and route a week
sees: the employment slice, the work pattern and the derived manager; no pay, no sensitive data
permissions: [hr:employment:read, hr:manager:read, hr:work_pattern:read, hr:assignment:read, hr:roster:read]
- id: payroll_client
name: Payroll (a service client, v3)
does: produces and reads the payroll extract, marks it sent when a run is approved, reads the identity HMRC needs and the work pattern for proration
sees: the extract, the employment slice, the work pattern and the payroll identity (audited); never bank details
permissions: [hr:employment:read, hr:work_pattern:read, hr:payroll_extract:read, hr:payroll_extract:mark_sent, hr:payroll_identity:read, hr:assignment:read, hr:roster:read]
- id: payments_client
name: Payments (a service client)
does: reads one person's bank account on the payment date to pay an approved expense report
sees: one bank slice of person_sensitive per call, every read audited; nothing else of the sensitive aggregate
permissions: [hr:bank_account:read_for_payout]
- id: system
name: The system
does: expands runs, times steps, calls capabilities, syncs the business partner, drafts, chases, applies retention
sees: everything, through its own service account
permissions: []
# ---------------------------------------------------------------------------------------------
# Stories. Person HR-01..29, system HR-30..49, integration HR-50..69.
# ---------------------------------------------------------------------------------------------
stories:
# person
- id: HR-01
kind: person
actor: manager
story: As a manager, I want to ask for a starter with one form, so that the onboarding starts without me chasing HR.
given: >-
the form: legal name, preferred name, personal email, worker type (employee / freelancer sole trader /
freelancer via a company / intern…), job, org unit, start date, and for a freelancer a day rate or a
fixed fee (and the company, when via a company)
when: I submit it (record_hire, a Hired event)
then: >-
there is one person (person_id derived from the staff number, or from the normalised personal email)
with its business partner created AT HIRE (kind person, name and work contact only, HR-50),
one engagement, one assignment slice from the start date, a pay slice if a rate was given, and one
onboarding run from the playbook for that worker type × event type × org unit, which Operations sees. Submitting
twice gives the same person, engagement and run. A start date in the past beyond the setting is refused
("start dates more than 30 days back are corrections, ask HR"). A second open engagement for the same
person is refused, naming the one that is open. A company freelancer with no company is refused.
screen: New starter
reference: not built
- id: HR-02
kind: person
actor: starter
story: As a starter, I want to fill in my own details from a link, so that nobody re-types my bank details from an email.
given: an intake link sent to my personal email, valid for the configured days, asking for the fields for my worker type and country
when: I fill in address, emergency contact, bank details (employee) or company and VAT details (freelancer), NI number, and upload my right-to-work document
then: >-
the values go to the Sensitive aggregate and the upload to a DataK3 object filed as a sensitive document;
the form shows which fields are still missing and never shows a stored sensitive value back; the link
works for this engagement only, expires, can be reissued (the old one stops working), and every use is
audited by the door. The manager sees "details: 5 of 6 done", never the values.
screen: Your details
reference: not built
- id: HR-03
kind: person
actor: assignee
story: As anyone with steps to do (IT, finance, a buddy, a manager), I want my steps as overdue, today and this week, so that I start the day knowing what is late.
given: steps assigned to the responsible groups I belong to
when: I open My tasks and complete one
then: >-
I see counts and lists by overdue / today / this week (derived on read through the tracked-work
library); completing a step records who and when and makes the steps that depend on it due;
a step of another group is not found. A manual task (an action with no provider) shows the capability,
the exact input and the plan in words, and asks for the provider_ref (optional) when I mark it done.
screen: My tasks
reference: not built
- id: HR-04
kind: person
actor: hr_admin
story: As Operations, I want one view of every run, so that I see what is done, what is stuck and who it is waiting on.
given: runs at different stages
when: I open the run board
then: >-
each run shows the person, direction, start or last day, steps done of total, the next due step,
"waiting on <group>" and what is stuck (overdue, failed, waiting for approval or signature),
all derived on read; filters by direction, org unit, worker type
screen: Runs
reference: not built
- id: HR-05
kind: person
actor: manager
story: As a manager, I want an AI-drafted welcome email I can edit and approve, so that the starter gets a warm, specific welcome without me writing it from scratch.
given: a draft step whose draft was made from the job, org unit, buddy (a run party), start date and first project brief (no sensitive field)
when: I edit and approve it (approve_draft)
then: >-
the approved text becomes the input of the dependent mail.send step (from my work mailbox, to the
starter's personal email). With no mail provider, that step is a manual task carrying the exact message,
and I copy and send it and mark it sent. A failed draft is a manual task "write the welcome email" and never blocks the run.
screen: Run
reference: not built
- id: HR-06
kind: person
actor: manager
story: As a manager, I want to record a leaver with their last day and reason, so that offboarding is planned backwards from that day.
given: a person with an active engagement in my line
when: I record the leave (record_leave, a Left event) with last day and a reason from the list
then: >-
actual_end is set, the assignment and pay are delimited at last day + 1, and one offboarding run starts
from the playbook for that worker type × reason (a freelancer's has no payroll steps). A draft step
produces the handover list from what hr knows they look after (their open steps, people in their line
via a chief position, org units they lead, the groups and spaces of their unit), and I edit it. Each item becomes
a task for the takeover group (a run party I name). With the UK pack, a last day giving less than the statutory
minimum notice is warned, not refused (the contract may say more, and the employee may waive).
screen: Record a leaver
reference: not built
- id: HR-07
kind: person
actor: it
story: As the approver for a destructive step, I want to approve or refuse it explicitly, so that an account is never deleted by a timer.
given: an identity.delete_user step due at last_day + 30 days behind an approval step
when: I approve it (approve_step), or refuse it with a reason
then: >-
approved: the delete step runs when due, and only after drive.transfer_status says completed;
refused: the delete step is cancelled with my reason; nobody without hr:step:approve can approve;
the approval is in the run log with who and when
screen: Run
reference: not built
- id: HR-08
kind: person
actor: manager
story: As a manager, I want to move a start date or a last day, so that the plan follows reality without rebuilding it.
given: an onboarding run with 3 steps done and 9 not done, start date Monday 3rd
when: I move the start date to Monday 10th (move_event_date)
then: >-
the 9 not-done steps get a new due_at from their timing rules (working days and the org's timezone
respected); the 3 done steps keep their due_at and result; the run log says who moved it, from what to what,
and when; the engagement and the assignment slice move with it. A contract extension (ContractExtended) re-times an open offboarding run the same way.
screen: Run
reference: not built
- id: HR-09
kind: person
actor: it
story: As IT, I want to read every action's request, result and actor, so that I can answer "what did the system do to this account".
given: action steps that ran automatically, failed, or were done by hand as manual tasks
when: I open the action log for a person or a run
then: >-
each attempt shows the capability, the input (secrets redacted), the result status, the provider_ref,
the message, who or what acted and when. The automatic attempts come from the provider's log (capabilities rule 5)
through the seam. Manual ones come from hr's own step record. Another person's log, for someone with no
IT step, is not found.
screen: Action log
reference: not built
- id: HR-10
kind: person
actor: hr_admin
story: As Operations, I want to preview a run before it fires, so that I see every step, its date and every payload before going live.
given: a hire about to be recorded, or an existing run
when: I preview it (preview_run, dry_run=true)
then: >-
I get every step with its computed due_at, responsible group, and for each action the exact
capability input and the provider's plan (or "manual: no provider" and the plan in words). Nothing is
written, claimed or sent. A go-live shadow is a dry run beside one real starter.
screen: Run
reference: not built
- id: HR-11
kind: person
actor: hr_admin
story: As Operations, I want to keep our checklists as playbooks, so that a new step never needs a developer.
given: the employee onboarding playbook, version 3, used by two open runs
when: I add a step "buddy coffee, start_date +1 working day 10:00, group buddy" and save
then: >-
version 4 is created and new runs use it; the two open runs keep version 3. Saving a destructive action
with no approval before it is refused in words. So is a delete with no transfer before it, or a dependency cycle.
screen: Playbooks
reference: not built
- id: HR-12
kind: person
actor: hr_admin
story: As Operations, I want to keep the org (units, chief positions, jobs, locations, schedules, and which groups and spaces each unit has), so that managers, access and reports follow it.
given: Studio under Operations, with a chief position held by the Studio lead
when: I move Studio under Campaigns from the 1st of next month
then: >-
a new Studio slice from that date delimits the old one; until then the old tree is current; the derived
manager changes on that date for everyone in Studio without touching their records; a move that would make
a cycle is refused; a second chief for one unit on one date is refused
screen: Org
reference: not built
- id: HR-13
kind: person
actor: finance
story: As finance or HR, I want to read and correct a person's sensitive data, so that pay goes to the right account and nobody else sees it.
given: a person whose bank details were entered at intake
when: I open their sensitive data, and correct the sort code
then: >-
I see the current values per kind; the correction is a new slice (valid_from today, or a correction of
the same date) and the old one is kept; a manager or IT user asking for the same route gets 404 (the
route exists, the record does not, for them), through the API and through MCP alike
screen: Sensitive data
reference: not built
- id: HR-14
kind: person
actor: hr_admin
story: As HR, I want to record pay and pay components with effective dates, so that a raise next month is entered today and history is never overwritten.
given: a salaried employee on 42,000.0000 GBP per year since 1 March
when: I record a PayChanged event with reason "annual review", 45,000.0000 from 1 April
then: >-
a pay slice from 1 April delimits the March slice at 31 March; the reason is required; a component
(car allowance) is its own sliced row; a rate basis of hour/day/month/year/fixed comes from the rows and
is never converted at write time; no derived hourly rate is stored for a salaried person
screen: Person
reference: not built
- id: HR-15
kind: person
actor: hr_admin
story: As HR, I want to rehire someone who has worked here before, so that their history is kept and offboarding never fires by mistake.
given: a freelancer whose engagement 1 ended last year
when: I record a Rehired event
then: >-
the same person gets engagement 2; one onboarding run from the rehire playbook starts; no offboarding run
is created by anything in the rehire; their old engagement, documents and events stay readable
screen: Person
reference: not built
- id: HR-16
kind: person
actor: hr_admin
story: As HR, I want to convert a freelancer to an employee in one step, so that they don't lose access between two contracts.
given: a freelancer on engagement 1 with an account and team access
when: I record a Converted event effective 1 December
then: >-
engagement 1 ends 30 November (reason converted), engagement 2 (employee) starts 1 December, as one
event in one transaction; ONE conversion run starts (employment contract, payroll set-up, business-partner
roles worker → employee, and supplier removed when they were a sole trader); no offboarding or access-removal step runs
screen: Person
reference: not built
- id: HR-17
kind: person
actor: manager
story: As a manager, I want to record a transfer to another team or job, so that their access changes on the day and the history shows both.
given: a Designer in Studio
when: I record a Transferred event to Campaigns, Campaign Producer, from the 15th
then: >-
a new assignment slice from the 15th delimits the Studio one; a change run is created with the access delta from
the unit access rows (add the new unit's groups and spaces, remove the old ones) timed on the event date;
a transfer into a unit outside my line is refused unless I hold the destination's chief position too
screen: Person
reference: not built
- id: HR-18
kind: person
actor: hr_admin
story: As Operations, I want starters, leavers, headcount and FTE by team and worker type over time, so that I can answer "how many people do we have" for any date.
given: slices covering this year
when: I open the report for this month, by org unit and worker type
then: >-
starters (engagements starting in the period), leavers (actual_end in the period), headcount and FTE at
the period end, all derived from slices on read (no snapshot table); a past month gives the answer as the
org was then, not as it is now; a manager sees their own line only
screen: Reports
reference: not built
- id: HR-19
kind: person
actor: finance
story: As finance, I want a payroll extract per legal entity and period, so that the payroll provider gets every change once and I re-key nothing.
given: November, with two starters, one leaver, a pay change and a bank change at the legal entity GH-UK
when: I produce the extract and mark it sent
then: >-
the batch lists every pay and component slice touching the period with its proration dates, starters
and leavers, and bank changes; freelancer engagements (paid as suppliers) are never in it; once sent the
batch is frozen, and a later change to November lands in the next open batch as a retro line, so no row goes out twice
screen: Payroll extract
reference: not built
- id: HR-20
kind: person
actor: hr_admin
story: As HR, I want documents filed on the person with a type, an expiry and an access class, so that the right-to-work copy and the signed contract are where they belong.
given: a right-to-work document uploaded at intake, and a signed employment contract from contracts
when: I open the person's documents
then: >-
the contract is a pay-bearing document (readable with hr:pay:read); the right-to-work copy is sensitive
(filed in the Sensitive aggregate, readable with hr:sensitive:read only); downloads are streamed by the
use case, never an object link; each has its DataK3 object key,
sha256, uploaded by and when, and an expiry where the type has one. A time-limited right to work expiring within the
configured days shows on the board and creates a follow-up check step (the UK pack).
screen: Person
reference: not built
- id: HR-21
kind: person
actor: hr_admin
story: As HR, I want to produce everything we hold on a person, so that a subject-access request is answered on time.
given: a subject-access request from a former freelancer, recorded with its received date
when: I export it (export_subject_access)
then: >-
one archive (a DataK3 object) with the person, every slice including superseded ones, events, documents,
sensitive data, run steps naming them, and their business-partner fields. The request shows its due date
(one month from receipt, UK GDPR Art. 12(3)), and the export is logged with who and when.
screen: Privacy
reference: not built
- id: HR-22
kind: person
actor: hr_admin
story: As HR, I want to handle an erasure request, so that we delete what we may and can say why we keep the rest.
given: an erasure request from a leaver of three years ago
when: I apply it
then: >-
each data category is erased or anonymised unless a retention rule with a legal-obligation basis still holds it,
in which case it is kept and the reason is recorded; the business partner's person fields are anonymised
through the bp seam; everything done is in the retention log
screen: Privacy
reference: not built
- id: HR-23
kind: person
actor: hr_admin
story: As HR, I want to cancel a run when a starter withdraws, so that nothing more fires and what already happened is undone deliberately.
given: an onboarding run where the account was created and a group added
when: I cancel it with a reason
then: >-
every not-done step is cancelled; for each done action a reversal task is created (suspend the account,
remove from the group), named after the capability. Nothing is reversed silently. The cancellation is in the run log.
screen: Run
reference: not built
- id: HR-24
kind: person
actor: employee
story: As an employee, I want to see my own record and my first week, so that I know what is planned and what HR holds.
given: I have signed in (pool_user_sub on my person)
when: I open My record
then: I see my person detail, engagement, assignment, documents of the personnel class and my first-week steps; anybody else's record is not found
screen: My record
reference: not built
- id: HR-25
kind: person
actor: hr_admin
story: As HR, I want to invite a person to sign in, so that they can see their record and their tasks.
given: a person with a work email and no pool_user_sub
when: I invite them (invite_to_sign_in)
then: >-
an action step calls the capability identity.invite_app_user (proposed; a future appid provider brick
provides it) with the work email and the pool; its output sub is stored on hr_person.pool_user_sub;
inviting twice reuses the same user (idempotency_key invite:<person_id>). With no provider it is a manual
task (rule 8): an admin creates the pool user with the exact input shown and records the sub on completion.
Until then they are a person with no login, which is normal.
screen: Person
reference: "not built — identity.invite_app_user is proposed, not yet in capabilities.yaml"
- id: HR-26
kind: person
actor: hr_admin
story: As HR, I want to record the right-to-work check with its evidence, so that we can show the check was done before day one.
given: an employee starting Monday with right-to-work evidence uploaded at intake (UK pack)
when: I record the check, giving its type (manual document check, check via an identity service provider, or Home Office online check), the date, who did it and any follow-up date
then: >-
a compliance record on the engagement points at the sensitive evidence; the playbook's "right to work
checked" gate before day-one steps is met. A start date before the check date is flagged. The record says
what was done, and the module makes no legal judgement.
screen: Person
reference: not built
- id: HR-27
kind: person
actor: hr_admin
story: As HR, I want to record the IR35 status determination for a freelancer, so that the contract cannot go out before it is decided.
given: a freelancer via a limited company, at a client the customer has set as medium or large (UK pack)
when: I record the determination (inside or outside, the reasons, the date, given to the worker and to whoever we contract with)
then: >-
a compliance record exists, and the contract step's condition is met. Without it the contract step stays
blocked with "record the status determination first". The module records the determination and does not make it.
screen: Person
reference: not built
- id: HR-28
kind: person
actor: hr_admin
story: As Operations, I want to keep the catalogues (worker types, event types and reasons, infogroups, jobs, rate bases, pay components, document types, retention rules, settings), so that our vocabulary is ours and not the software's.
given: the default catalogues from the UK pack
when: I add a leave reason "end of project" to Left, and a worker type "intern"
then: new events and hires can use them at once; a reason in use cannot be deleted, only retired; a worker type must say whether payroll applies and which business-partner roles it gives
screen: Settings
reference: not built
# system
- id: HR-29
kind: person
actor: hr_admin
story: As Operations, I want to see how our onboarding is actually going — how long runs take, where steps go late and how soon a starter can work — so that I fix the programme, not just the run in front of me.
given: onboarding runs over time, some done and some open, from one or more versions of the playbooks
when: I open the onboarding monitor (onboarding_monitor, GET /reports/onboarding), optionally from a date
then: >-
I see the run lead time (median and longest, in days, from the run's creation to its last step done)
and the age of the runs still open; the steps overdue now, counted by owner group and by team (the
person's org unit); the days from the start date to the first access granted (an account made, a group
or chat space joined; negative is before day one, which is the aim); and, per playbook version the runs
used, how many runs used it and its deviations from the pack's standard by class. Every number is
derived from the runs on read, nothing stored; every date is the business date in business_timezone. A
manager sees the same numbers over the runs of their own line (the run board's rows). APQC 7.3.1.3
Monitor and update employee onboarding program (11243): monitor here, update through versioned playbooks
(HR-11) and playbook_deviations.
screen: Reports
reference: "brick-hr 0.6 (unreleased): domain/monitoring.py, application/reports.onboarding_monitor; tests test_hr_29_*"
- id: HR-30
kind: system
actor: system
trigger: "run_due_steps(now), called by the clock every minute or so"
story: The system moves every run on. Steps that are due and whose dependencies are done get done, so nothing depends on someone remembering.
given: steps with due_at ≤ now, their depends_on done, state pending
when: the tick runs
then: >-
each step is executed by kind (task: shown as due; action/document: a CapabilitySeam call with
idempotency_key <run_id>|<step_no>; draft: the Models adapter; intake: the door issues the link;
approval: waits for a person); a step claimed by a concurrent tick is skipped; running the tick twice
acts once per step. It also applies due future-dated changes (HR-38) and chases (HR-36).
reference: not built
- id: HR-31
kind: system
actor: system
trigger: "at the account step's timing (typically start_date −3 working days 09:00, org timezone)"
story: The system sets the starter up in our tools (work account, team groups, chat spaces, shared folders), and does it automatically once a provider is wired.
given: an onboarding run past its contract-signed gate, the org unit's access rows (groups, spaces, folders)
when: the steps fall due
then: >-
identity.provision_user, then one groups.add_member / chat.add_to_space / drive.create_folder step per
access row; with a provider: done, and the work email is written back as a new person-detail slice and
synced to the business partner. With no provider: each is a manual task with the exact input ("create
anna.berg@… in OU /Staff/London") and the plan in words, and marking it done with the created address
writes it back the same way. Wiring a provider later changes no playbook.
reference: not built
- id: HR-32
kind: system
actor: system
trigger: at each calendar step's timing
story: The system puts the first week in the calendar (day-one welcome, IT set-up, buddy coffee, week-one review).
given: playbook calendar steps with titles, times (working-day offsets from start_date) and attendee groups
when: they fall due
then: calendar.create_event with the times resolved in the org's timezone; with no provider, a manual task with the times and attendees filled in
reference: not built
- id: HR-33
kind: system
actor: system
trigger: when a draft step falls due, or when a manager asks for it early
story: The system drafts the welcome email, the handover list and the leaver letter with AI, so that the manager edits rather than writes.
given: a draft step with its draft instructions row and the allowed fields (job, unit, buddy, dates, first project; never a sensitive field)
when: it runs
then: >-
a chat completion at https://api.dodil.io/v1 with the brick's service-account token (ignite.model-user)
produces the draft, stored on the step, waiting for approval by its group; the model name is a settings
row; on any failure or timeout the step becomes a manual task "write it by hand" and the run goes on;
running it twice keeps the first draft unless the approver asks for a new one
reference: not built
- id: HR-34
kind: system
actor: system
trigger: "at last_day 17:30 (org timezone), or at the event date for a reason the playbook treats as immediate"
story: The system removes access at the right time.
given: an offboarding run
when: the removal steps fall due
then: >-
identity.suspend_user, drive.transfer_ownership to the derived manager on the last day, mail.set_autoreply,
groups.remove_member and chat.remove_from_space per access row; with no provider each is a manual task due
at exactly that time, which the board shows as a countdown
reference: not built
- id: HR-35
kind: system
actor: system
trigger: "at last_day +30 days 09:00, after its approval"
story: The system deletes the account and frees the licence only when it is safe.
given: an approved identity.delete_user step and a drive.transfer_ownership step done earlier
when: it falls due
then: >-
it first asks drive.transfer_status; if not completed the step stays waiting (in_progress) and is asked
again on the next tick; then identity.delete_user; without an approval it never runs; with no provider it is a manual task
reference: not built
- id: HR-36
kind: system
actor: system
trigger: on every tick
story: The system handles every outcome of an action, so that a failure becomes work for a person instead of a silent gap.
given: action steps with results
when: the result is in_progress / failed retryable / failed not retryable / connection_required, or the capability has no provider
then: >-
in_progress: asked again next tick, not counted as a failure; failed retryable: retried with backoff
and after N attempts (a row) a manual task for IT with the provider's message in words; failed not
retryable or connection_required: a manual task at once, naming what is missing; no provider: a manual
task from the start (rule 8), with the same input and plan
reference: not built
- id: HR-37
kind: system
actor: system
trigger: on every tick, per the chasing cadence rows
story: The system chases what is late (an incomplete intake, an overdue task), so that Operations is not the reminder service.
given: an intake missing two fields for 2 working days, and a task overdue since yesterday
when: the cadence says it is time
then: >-
a reminder through mail.send (or a manual task to send it) to the starter or to the task's group, at most
once per cadence slot and up to the configured maximum; the chase is in the run log. Running twice in one
slot sends once (idempotency_key <run_id>|<step_no>|chase|<slot>).
reference: not built
- id: HR-38
kind: system
actor: system
trigger: on every tick, for slices and events whose effective date has arrived
story: The system makes future-dated changes take effect outside hr too, so that the business partner is right on the day.
given: a Hired event recorded today for a start date in two weeks, and a Left event for Friday
when: the effective date arrives
then: >-
the business partner gets role employee (or worker) from the start date and loses employee after the
last day, through the bp seam; each sync is keyed by the event, so a second tick changes nothing. This is pitfall 5 in the research, avoided.
reference: not built
- id: HR-39
kind: system
actor: system
trigger: on read (derived)
story: The system derives every state that a date decides, so that no job sets a flag and nothing is stale.
given: slices, events and steps
when: anything is read
then: >-
the current slice is valid_from ≤ today < valid_to; the manager is ManagerOf(person, date); FTE,
engagement status (future / active / ending / ended), overdue, "waiting on", run done, and headcount are
computed; reading twice gives the same answer
reference: not built
- id: HR-40
kind: system
actor: system
trigger: once a day, in the first tick after 02:00 (org timezone)
story: The system applies the retention schedule, so that leavers' data does not pile up.
given: "retention rules per data category (e.g. sensitive bank details, N months after the engagement ends, purged)"
when: the sweep runs
then: >-
each category past its period is purged or anonymised per its rule; each action is a row in the
retention log (date, person, category, action); the business partner is anonymised through the seam when
the person category goes; running twice on the same day changes nothing
reference: not built
- id: HR-41
kind: system
actor: system
trigger: on every tick
story: The system warns when a fixed-term engagement or a time-limited right to work is about to run out, so that someone decides in time.
given: an engagement with planned_end in 14 days and no Left or ContractExtended event, or a right-to-work follow-up date in 28 days
when: the configured warning days are reached
then: one task to the manager ("extend or record a leaver") or to HR ("follow-up right-to-work check"), keyed by the engagement and the date, so it is created once
reference: not built
- id: HR-42
kind: system
actor: system
trigger: when a Left event whose reason is redundancy expands its offboarding run
story: The system adds an outplacement offer to a redundancy's offboarding, so that the person made redundant is offered help to find their next job and the answer is on record.
given: the employee offboarding playbook with the step offer_outplacement (hr, 2 working days after the run is created, condition reason_is [redundancy], outcomes [accepted, declined])
when: a leaver is recorded with the reason redundancy (any other reason skips the step, recorded with its condition)
then: >-
HR has a task "Offer outplacement support"; completing it MUST give the outcome, accepted or declined
(output.outcome; any other answer is refused 422 naming both), which is kept on the step and shown with
it to whoever sees the step. A done step never changes its answer. With an outplacement provider
connected the step can be an action with the same two outcomes; with none it is this task. The step is
a playbook row, so a customer removes it, re-times it or gives it another group like any other step
(a variation in playbook_deviations). APQC 7.6.2.4 Develop and implement employee outplacement (10516).
This changes the offboarding PROCESS: the signed artifact is signoff/hr/employee-offboarding.bpmn.
reference: "brick-hr 0.6 (unreleased): gb pack offboarding step 14, playbook condition reason_is, step outcomes; tests test_hr_42_*"
- id: HR-43
kind: system
actor: system
trigger: when a Left event whose reason is retirement expands its offboarding run
story: The system runs a retirement as its own checklist, so that the pension scheme is told, the knowledge is handed over and the long service is recognised, not only the access removed.
given: the playbook employee-retirement (event type leave, reason retirement, employee worker types) — rows, chosen because it names the reason (the most specific playbook wins)
when: a leaver is recorded with the reason retirement
then: >-
the run comes from employee-retirement: every access step and approval of the employee offboarding (no
outplacement offer), plus "Tell the pension scheme they are retiring" (finance, last day −20 working
days), "Knowledge handover sessions held" (manager, last day −5 working days, after the handover list)
and "Long-service recognition" (manager, last day −2 working days). No new code path: the playbook
mechanism (HR-11). APQC 7.6.2.1 Manage retirement (10514).
reference: "brick-hr 0.6 (unreleased): gb pack retirement(); test test_hr_43_*"
# integration
- id: HR-50
kind: integration
actor: system
trigger: when a person is created, a name or work contact changes, or a role-bearing event takes effect
story: hr keeps the person's business partner right, as its single writer, so that every module addresses this person with one key and reads nothing private.
given: business-partner installed, with person partners, the bp:person:sync write path, and person partners readable only with bp:person:read
when: hr syncs
then: >-
a partner of kind person with bp_key 'person:<person_id>' (so hr derives the bp_id without a round trip)
carries ONLY the legal name, preferred name and work email / work phone; roles employee (while an
employee engagement is active), worker (freelancer), supplier (a sole-trader freelancer, paid outside
payroll). A freelancer's company is its own partner (organisation, supplier role, created or reused through
promote), and bp holds the edge person works_for company. Personal email, phone, address, NI, bank and
right to work never go to bp. The partner is created at the hire (HR-01), not at the start date; person
partners are readable only with bp:person:read (hr, contracts and finance roles), never by every
signed-in user. Syncing twice changes nothing.
owner: business-partner
reference: "not built — needs the business-partner changes listed in reference.known_gaps"
- id: HR-51
kind: integration
actor: system
trigger: when a document step falls due
story: hr asks contracts to draft, send and track the right contract, so that the run waits for "signed" and the signed copy lands on the person.
given: an approved template for the worker type, the person's business partner, the engagement and pay
when: the step runs
then: >-
contracts.draft (counterparty bp_id; context person_id|n; template kind; the variables, including the UK
client_size_regime from hr's settings for the engagement's legal entity; signers), then contracts.send. Then
contracts.status on each tick until signed, declined or expired. The contract_id produced is stored on the
step. When signed, contracts.status gives hr, as the ORIGINATOR, the executed copy's object key and sha256;
hr streams it with contracts.fetch_executed (no broad contract:employment:read) and files it on the person
as a pay-bearing document, and the steps that depend on it become due. With contracts not
installed, it is a manual task with the same input ("send the employment contract and attach the signed copy").
owner: contracts
reference: "not built — the contracts.* capabilities are proposed, not yet in capabilities.yaml"
- id: HR-52
kind: integration
actor: system
trigger: when an action step runs
story: hr asks a provider brick to act in the customer's tools through capabilities, never vendor terms.
given: compose.yaml mapping each capability to a provider brick, or to none
when: an action step runs
then: >-
a POST /capabilities/<name> through the CapabilitySeam with idempotency_key, dry_run and the validated
input; the RESULT shape is handled as in HR-36; the provider owns its claim table and action log
owner: "the capability provider (google-workspace first; none in the first demo)"
reference: not built
- id: HR-53
kind: integration
actor: system
trigger: when an intake step runs, or HR reissues the link
story: The starter reaches their intake through the platform's public-link door, never through the staff app.
given: the public-link door (a separate app, no pool)
when: hr asks for an intake link
then: >-
the door issues a token bound to (hr, intake_id) and the actions read_intake / submit_intake, stored as a
hash, expiring, single purpose, rate-limited, revocable, with an audit row per use; the door calls hr's
intake use cases through a seam with a service identity scoped to that one intake
owner: "erp-platform (the public-link door)"
reference: "not built — platform piece"
- id: HR-54
kind: integration
actor: system
trigger: when a Left event commits, and at the last day 17:30 for the sign-in
story: hr tells the other modules that a person has left, so that nothing they own outlives the person — no ERP sign-in, no open task, no open expense report.
given: a committed Left event (actual_end set) and its offboarding run; the outbound seams below
when: the event commits (never inside its transaction), and the tick consumes what is pending
then: >-
(1) the ERP sign-in: the offboarding step revoke_erp_login (it, last day 17:30, after the access-removal
approval) asks identity.revoke_app_user {pool, email, sub, reason} — the composition's dodil-appid pool
user disabled and every session revoked — claimed once on <run_id>|<step_no>; with no connector (or no pool
configured) it is a manual task with that exact input (rule 8). (2) open tasks: each module holding tasks
assigned to the person hands them to the DERIVED manager on the last day (hr answers the manager; it
stores none). (3) expenses settles the person's open expense reports before final pay. (4) v3: time-off
cancels leave after the last day and fixes the leaver's balance for payroll (POST /employees/{person_id}/leaver
on time-off). (2), (3) and (4) are consumed after commit from pending rows (the Left event, until each consumer has acknowledged it); a
consumer that is down delays its part and nothing else. Only (1) is built.
owner: "dodil-appid (through a provider brick) for (1); the task-owning modules for (2); expenses for (3); time-off for (4)"
reference: "brick-hr 0.6 (unreleased): (1) only — identity.revoke_app_user (erp-platform capabilities.yaml 0.6, from 0.9.7; hr's own capabilities.local.yaml before), gb offboarding step 13, its timing from HR-55; tests test_hr_54_*. (2) and (3) are named seams, not built"
- id: HR-55
kind: person
actor: hr_admin
story: As the owner of offboarding, I want to choose when a leaver loses their sign-in to this ERP — at the end of their last day, or as soon as they are recorded — with a different choice per leave reason, so that a dismissed person loses access at the meeting while someone who resigned keeps it to their last day.
given: >-
the dated setting offboarding.revoke_access_at, default last_day_end (the last day at the business close, 17:30
in business_timezone, after the access-removal approval); the other value is immediate (as soon as the leaver
is recorded and the access removal approved); I hold hr:offboarding:policy
when: I set dismissal to immediate, then a dismissal and a resignation are recorded today
then: >-
the dismissal's revoke_erp_login step is due when the leaver is recorded and runs once the access-removal
approval is given, months before the last day; the resignation's is due on the last day at 17:30. The setting
is a new dated row each time, never an edit: from tomorrow by default, only the first row of a key and reason
may start today, never earlier, and a different value for a day that has a row is refused (409). Every row
— who, when, from which day, the note — is readable, and the one in force is the reason's own, else the
default's, else last_day_end, on the business date the leaver is recorded. A run already made keeps its
timing. Without hr:offboarding:policy the change is refused (403).
screen: Settings
reference: "brick-hr 0.6 (unreleased): POST/GET /dated-settings/{setting_key}, hr_setting_row; tests test_hr_55_*"
# ── v3 (2026-10-25, wave 4): the read routes other modules call on hr, as stories (seams HR-56..62) ──
- id: HR-56
kind: integration
actor: system
trigger: when another module needs to know whether a person is employed on a date (expenses, pos, time-off, timesheets, payroll)
story: Other modules ask hr whether a person is employed on a date and where, so that nobody keeps a second staff list.
given: 'person 4410 employed by UK01 from 2024-03-01 in org unit SALES, location LON, cost centre SALES-N'
when: 'time-off calls GET /persons/4410/employment?on=2026-12-21, and GET /persons/4410/employment?on=2024-02-01'
then: '{employed: true, engagement_id, worker_type, company_code UK01, org_unit_id, location_code LON, cost_centre SALES-N, bp_id}; for 2024-02-01 employed false with nulls, never a 404; no pay and no sensitive data'
owner: hr
reference: "named seam (v2 draft)"
- id: HR-57
kind: integration
actor: system
trigger: when another module fixes an approver (expenses, time-off, timesheets)
story: Other modules ask hr who a person's manager is on a date, so that approvals follow the org tree and nobody stores a manager.
given: 'person 4410 in org unit SALES whose chief position is held by 2280 on 2026-12-21; 2280''s own manager is 1904'
when: 'GET /persons/4410/manager?on=2026-12-21, and GET /persons/2280/manager?on=2026-12-21&level=1'
then: '{manager: {person_id 2280, bp_id, pool_user_sub, email}, source org_tree} and, for 2280, 1904; derived by ManagerOf on read, never stored'
owner: hr
reference: "named seam (v2 draft)"
- id: HR-58
kind: integration
actor: system
trigger: when a person signs in to another module's screen (expenses, time-off, timesheets, payroll self-service)
story: Other modules turn the signed-in user's sub into a person_id here, so that "my" means the same person everywhere.
given: 'person 4410 with hr_person.pool_user_sub s-4410'
when: 'GET /persons?pool_user_sub=s-4410, and with a sub nobody carries'
then: '{person_id 4410, bp_id, company_code UK01}; 404 for the unknown sub'
owner: hr
reference: "named seam (v2 draft)"
- id: HR-59
kind: integration
actor: system
trigger: when payments exports a payout run to a person (an expense reimbursement, a salary)
story: Payments reads one person's bank account on the payment date, so that the account is known only to the payment file.
given: 'person 4410 with a bank slice valid from 2025-06-01'
when: "payments' client calls GET /persons/4410/bank-account?on=2026-11-30"
then: '{account_holder, sort_code, account_number, valid_from 2025-06-01}; every read audited (who, when, which person, which payout); any other client is refused; 404 when none is held on the date'
owner: hr
reference: "named seam (v2 draft)"
- id: HR-60
kind: integration
actor: system
trigger: when the payroll module prepares a pay run (instead of a payroll provider reading the extract)
story: The payroll module reads hr's payroll extract for a legal entity and period, so that pay, starters, leavers and changes reach the payslip with nobody re-keying them.
given: 'company UK01, monthly period 2026-11: starter 4450 (2026-11-02, salary 30,000.00 a year), leaver 4495 (last day 2026-11-13), a pay change for 4410 from 2026-11-16 (36,000.00 to 38,400.00), a retro line for October; weekly period 2026-W47 for the weekly-paid'
when: "payroll calls POST /payroll-extracts {legal_entity_ref UK01, period 2026-11}, GET /payroll-extracts/UK01|2026-11, and after its run is approved POST /payroll-extracts/UK01|2026-11/sent {sent_ref: the pay run id}"
then: >-
the produce call answers the draft batch (re-produced while it is a draft, so a later change before the run is
approved is picked up); the GET lists every line (person_id, n, component, for_period, rate_basis, rate_amount
NUMERIC 19,4, prorate_from, prorate_to, change_kind) with starters and leavers named, 4410's pay as two slices
split on 2026-11-16, and October's retro line; freelancers never appear; sent freezes the batch exactly as
mark_extract_sent does (HR-19), a repeat with the same sent_ref answers the same, a different sent_ref 409; a
weekly period is an ISO week (YYYY-Www) for a weekly pay schedule (v3)
owner: hr
reference: not built
- id: HR-61
kind: integration
actor: system
trigger: when payroll builds a full payment submission or a P45/P60 for a person
story: Payroll reads the identity HMRC needs (legal name, date of birth, NI number, home address) from hr, so that it is kept once, as sensitive data, and read only by payroll.
given: 'person 4410 with ni_number, date_of_birth and home_address slices in person_sensitive'
when: "payroll's client calls GET /persons/4410/payroll-identity?on=2026-11-30"
then: >-
{person_id, legal_first_names, legal_last_name, date_of_birth, ni_number (or null), home_address (the address
shape), valid_from}; every read audited like a bank read; refused to every client but payroll's and to every
person role; no bank data on this route
owner: hr
reference: not built
- id: HR-62
kind: integration
actor: system
trigger: when time-off costs a request, timesheets builds a week's expected hours, or payroll prorates a part period
story: Other modules read a person's working pattern and public holidays day by day, so that a bank holiday, a part-time day and a mid-month start mean the same in every module.
given: 'person 5120 at location EDI (holiday_calendar GB-SCT), schedule Monday to Wednesday 450 minutes, FTE 0.6000'
when: 'GET /persons/5120/work-pattern?from=2026-11-30&to=2026-12-06'
then: '7 days: 2026-11-30 contracted 450 with public_holiday "St Andrew''s Day", 2026-12-01 and 12-02 450, Thursday to Sunday 0; employed per day; at most 400 days per call (422 bounds); derived from the assignment, work schedule and hr_holiday rows on read. v4: contracted_minutes is the day''s contracted time (0 on a non-working day and on a public holiday the schedule does not work); a caller counting working days counts a day with contracted_minutes > 0 while employed, whatever its length, and a caller costing hours uses the minutes'
owner: hr
reference: not built
- id: HR-63
kind: integration
actor: system
trigger: when another module needs a person's company, org unit, team, manager or work email on a date (time-off, expenses, timesheets, payroll)
story: Other modules ask hr for a person's organisational assignment and work email by person id, so that no module types an email or keeps its own copy of who belongs where.
given: 'person 4410 employed by UK01 in org unit SALES, team SALES-N, derived manager 2280, work email ana.lee@example.com on 2026-12-21'
when: 'expenses calls GET /persons/4410/assignment?on=2026-12-21'
then: >-
{person_id 4410, on 2026-12-21, employed true, company_code UK01, org_unit_id, org_unit_code SALES, team_code SALES-N,
manager_person_id 2280, work_email ana.lee@example.com, pool_user_sub (null until the person signs in)}; a date with
no engagement answers employed false with nulls, never a 404; derived on read from the assignment slice, the
ManagerOf walk and the communication record in force on the date (SAP's organisational assignment plus
communication), nothing stored by the caller; no pay and no sensitive data; a caller sees only the companies its
grant is scoped to
owner: hr
reference: "v4 (2026-10-26, seemo's decided answer 2): not built"
- id: HR-64
kind: integration
actor: system
trigger: when another module works over everyone of a company, an org unit or a manager's team (a year-end pass, a team calendar, a reminder run, a migration matching people)
story: Other modules ask hr for the roster of a company, org unit or team on a date, so that a year-end pass or a team calendar covers everyone and nobody keeps a staff list.
given: 'UK01 on 2026-12-31: 212 people employed, 5 of them in the team whose derived manager is 2280'
when: 'time-off calls GET /roster?company_code=UK01&on=2026-12-31&limit=200, then the next page; and GET /roster?company_code=UK01&manager_person_id=2280&on=2026-12-21'
then: >-
a page of [{person_id, company_code, org_unit_code, team_code, manager_person_id, work_email, fte, employed_from,
employed_to}] ordered by person_id, at most 200 a page with next_offset; 212 people in two pages; the team query
answers the 5 whose derived manager is 2280 on that date (direct reports, level 1); people who left before `on`
are not listed; a company outside the caller's scope answers an empty page, never another company's people
owner: hr
reference: "v4 (2026-10-26, seemo's decided answer 2): not built"
workflows:
- entity: run
states: [open, done, cancelled]
transitions:
- { from: none, to: open, by: system, gate: "a committed personnel event whose kind starts a run, and a playbook matching it", effect: "steps expanded from the frozen playbook version; due_at computed" }
- { from: open, to: cancelled, by: hr_admin, requires: reason, effect: "not-done steps cancelled; a reversal task per done action" }
note: "done is DERIVED (every step done, skipped or cancelled). 'stuck' and 'waiting on <group>' are derived too"
- entity: step
states: [pending, waiting, done, skipped, cancelled]
transitions:
- { from: pending, to: waiting, by: system, gate: "due and its dependencies done; for an approval, draft, document or in_progress action, waiting on someone or something", effect: "shown on My tasks for its group" }
- { from: [pending, waiting], to: done, by: [assignee, system], gate: "task completed / capability done / approval granted / contract signed / draft approved", effect: "result and provider_ref stored; dependants become due" }
- { from: [pending, waiting], to: skipped, by: system, gate: "its condition is false for this person", effect: "recorded with the condition" }
- { from: [pending, waiting], to: cancelled, by: [hr_admin, it], gate: "run cancelled, or an approval refused" }
note: "overdue = due_at < now and not done, derived; a done step is never re-timed and never acts again. An action with no provider is a manual task from expansion, with the same kind and the same input. v2: a step with outcomes (the outplacement offer: accepted / declined) reaches done only WITH one of them, kept on the step"
- entity: offboarding_playbook
states: [standard, redundancy, retirement]
transitions:
- { from: none, to: standard, by: system, gate: "a Left event: the employee offboarding playbook", effect: "the gb steps 1–13: handover, approvals, access removal at last day 17:30, the ERP sign-in revoked (HR-54), deletion after approval" }
- { from: none, to: redundancy, by: system, gate: "the reason is redundancy (condition reason_is)", effect: "the same run plus offer_outplacement (HR-42, outcome accepted or declined)" }
- { from: none, to: retirement, by: system, gate: "the reason is retirement: the playbook employee-retirement, which names the reason", effect: "the standard steps plus pension_notice, knowledge_handover, long_service_recognition (HR-43)" }
note: "v2 PROCESS CHANGE for sign-off: not a state machine on a row but which checklist a leaver gets. The source is the playbook rows (gb pack); the signed artifacts are signoff/hr/employee-offboarding.bpmn and signoff/hr/employee-retirement.bpmn (show_playbook_bpmn). Signed by seemo 2026-10-25"
- entity: engagement
states: [future, active, ending, ended]
transitions:
- { from: none, to: future, by: [manager, hr_admin], gate: "a Hired, Rehired or Converted event" }
- { from: [future, active], to: ending, by: [manager, hr_admin], gate: "a Left event (actual_end set)" }
note: "every state is DERIVED from start date, planned_end, actual_end and today; nothing stores a status"
- entity: intake
states: [issued, submitted, expired, revoked]
transitions:
- { from: none, to: issued, by: system, gate: "the intake step falls due" }
- { from: issued, to: submitted, by: starter, gate: "every required field for the worker type and pack" }
- { from: [issued, expired], to: revoked, by: hr_admin, effect: "a reissue makes a new link" }
note: "expired is derived from the door's expiry; 'complete' is derived from the required fields"
entities:
# Person aggregate
- id: person
meaning: a natural person we employ or engage, once, however many times they come back
natural_key: "person_id = blake2b(person_key, 7); person_key = 'staff:<no>' else 'email:<normalised personal email>'"
key_attributes: [person_key, staff_no, bp_id, pool_user_sub, created_at]
relationships: ["1..n engagement", "-> business_partner (bp_id)", "0..1 pool user"]
master_data: "SHARED — hr is the sole writer (person_id in shared.yaml)"
reference_table: hr_person
note: "identity only, not sliced. Date of birth is NOT here: it is sensitive (see person_sensitive)"
- id: person_detail
meaning: the person's names and contact on a date
natural_key: "person_id|valid_from (TC1)"
key_attributes: [legal_given_name, legal_family_name, preferred_name, personal_email, personal_phone, work_email, work_phone, valid_to, superseded_by, created_by]
master_data: module-owned (Person aggregate)
reference_table: hr_person_detail
- id: engagement
meaning: one period of working for us under one worker type
natural_key: "person_id|n"
key_attributes: [worker_type, legal_entity_ref, start_date, planned_end, actual_end, supplier_bp_id]
relationships: ["1..n assignment", "0..n pay", "1..n event", "-> business_partner (supplier_bp_id, a company freelancer)"]
master_data: module-owned (Person aggregate)
reference_table: hr_engagement
note: "header, not sliced; status derived. legal_entity_ref from day 1 (multiple legal entities is an extension point). supplier_bp_id is a bp_id (BIGINT)"
- id: assignment
meaning: where an engagement sits on a date
natural_key: "person_id|n|valid_from (TC1 per engagement)"
key_attributes: [org_id, org_unit_id, position_id, job_code, location_code, schedule_code, employment_pct, employee_subgroup, cost_centre, manager_override_person_id, valid_to, superseded_by]
master_data: module-owned (Person aggregate)
reference_table: hr_assignment
note: "position optional; cost_centre is a reference to finance's code (shared.yaml), inherited from the position or unit when empty; manager override is the rare exception"
- id: pay
meaning: the rate an engagement is paid at on a date
natural_key: "person_id|n|valid_from (TC1 per engagement from its first slice)"
key_attributes: [rate_basis, rate_amount (NUMERIC(19,4)), currency, frequency, pay_grade, valid_to, superseded_by]
master_data: module-owned (Person aggregate)
reference_table: hr_pay
- id: pay_component
meaning: an allowance or recurring extra
natural_key: "person_id|n|component|valid_from (TC3)"
key_attributes: [rate_basis, rate_amount (NUMERIC(19,4)), currency, valid_to, superseded_by]
master_data: module-owned (Person aggregate)
reference_table: hr_pay_component
- id: event
meaning: a personnel event on an engagement (Hired, Transferred, PayChanged, ContractExtended, Left, Rehired, Converted)
natural_key: "person_id|n|seq (append-only)"
key_attributes: [event_type, kind, reason, effective_date, actor, recorded_at, slices_written, run_id, bp_synced_at]
master_data: module-owned (Person aggregate)
reference_table: hr_event
note: "seq is the next integer for the engagement; a concurrent second write of one key is rejected (23505) and retried with seq+1"
- id: document
meaning: a non-sensitive document on a person (signed contract, letters)
natural_key: "person_id|doc_id (doc_id derived: 'contract:<contract_id>', 'letter:<run_id>|<step_no>')"
key_attributes: [document_type, object_key, sha256, size_bytes, expires_on, contract_id, uploaded_by, uploaded_at]
master_data: module-owned (Person aggregate)
reference_table: hr_document
note: "the file is a DataK3 object under hr/<person_id>/…; access class comes from the document type (personnel | pay)"
- id: compliance_record
meaning: a record a localisation pack requires (right-to-work check, status determination, written statement given)
natural_key: "person_id|n|kind|valid_from"
key_attributes: [pack, outcome, checked_on, checked_by, follow_up_on, evidence_ref, reasons]
master_data: module-owned (Person aggregate)
reference_table: hr_compliance_record
note: "evidence_ref points into the Sensitive aggregate; the record says what was done, never a legal judgement"
# Sensitive aggregate
- id: sensitive
meaning: the root of one person's sensitive data
natural_key: person_id
key_attributes: [version, erased_at, anonymised_at]
master_data: module-owned (Sensitive aggregate root)
reference_table: hr_sensitive
note: "carries the aggregate's version, checked on save (two finance users correcting bank details at once: one gets 409)"
- id: person_sensitive
meaning: the personal data only HR and finance may see
natural_key: "person_id|kind|valid_from (TC3, under the hr_sensitive root)"
key_attributes: [kind (ni_number | tax_id | bank | home_address | date_of_birth | emergency_contact | right_to_work_evidence | vat_number), value_json, object_key, valid_to, superseded_by, created_by]
master_data: module-owned (its OWN aggregate)
reference_table: hr_person_sensitive
# Org aggregate
- id: org
meaning: one org tree
natural_key: org_id
key_attributes: [name, legal_entity_ref]
master_data: module-owned (Org aggregate root)
reference_table: hr_org
- id: org_unit
meaning: a team or department on a date
natural_key: "org_id|org_unit_id|valid_from (TC1 over its lifetime)"
key_attributes: [name, parent_org_unit_id, org_unit_type, cost_centre, chief_position_id, location_code, valid_to]
master_data: "SHARED — hr is the sole writer (other modules may group by it)"
reference_table: hr_org_unit
note: "the walk up for the manager is a recursive CTE over current slices: trees of hundreds of units"
- id: position
meaning: one seat in an org unit that describes a job
natural_key: "org_id|position_id|valid_from (TC1 over its lifetime)"
key_attributes: [org_unit_id, job_code, fte_pct, cost_centre, vacant_policy, valid_to]
master_data: module-owned (Org aggregate)
reference_table: hr_position
note: "optional per unit, EXCEPT the chief position, which is how the manager is derived; a holder is the assignment that names it"
# Configuration
- id: job
meaning: a job in the catalogue
natural_key: job_code
key_attributes: [title, family, default_subgroup, default_pay_grade, active]
master_data: "SHARED — hr is the sole writer"
reference_table: hr_job
- id: location
meaning: an office or place of work
natural_key: location_code
key_attributes: [address, country, timezone, holiday_calendar]
master_data: module-owned (configuration)
reference_table: hr_location
- id: work_schedule
meaning: a weekly pattern of working time
natural_key: schedule_code
key_attributes: [weekly_minutes, daily_pattern, full_time_weekly_minutes]
master_data: module-owned (configuration)
reference_table: hr_work_schedule
- id: holiday
meaning: a public holiday in a calendar
natural_key: "holiday_calendar|holiday_date"
key_attributes: [name]
master_data: module-owned (configuration; a localisation pack loads its rows)
reference_table: hr_holiday
- id: catalogue
meaning: the small catalogues (worker types, employee subgroups, org unit types, event types → kind, reasons, infogroups, rate bases, pay component types, pay grades, responsible groups, document types, intake fields, draft instructions, retention rules, settings)
natural_key: "each by its own code (worker_type, event_type, event_type|reason, event_type|record, component, group_code, document_type, pack|field, category, key)"
key_attributes: [label, active, and per catalogue its rule fields]
master_data: module-owned (configuration)
reference_table: [hr_worker_type, hr_employee_subgroup, hr_org_unit_type, hr_event_type, hr_event_reason, hr_infogroup, hr_rate_basis, hr_pay_component_type, hr_pay_grade, hr_responsible_group, hr_document_type, hr_intake_field, hr_draft_instruction, hr_retention_rule, hr_settings]
- id: unit_access
meaning: the groups, chat spaces and folders that come with an org unit
natural_key: "org_unit_id|kind|target"
key_attributes: [kind (group | chat_space | drive_folder), target, role]
master_data: module-owned (configuration)
reference_table: hr_unit_access
# Playbook aggregate
- id: playbook
meaning: the customer's checklist for one kind of run, versioned
natural_key: "playbook_id|version"
key_attributes: [name, event_types, worker_types, reasons, org_unit_id, status, created_by]
master_data: module-owned (configuration with invariants)
reference_table: hr_playbook
- id: playbook_step
meaning: one step of a playbook
natural_key: "playbook_id|version|step_no"
key_attributes: [kind, title, responsible_group, capability, input_template, timing_rule, depends_on, condition, approval_group, draft_instruction, template_kind, fan_out]
master_data: module-owned (Playbook aggregate)
reference_table: hr_playbook_step
# Run aggregate
- id: run
meaning: a playbook expanded for one person and one personnel event
natural_key: "run_id = <direction>:<person_id>:<n>:<event seq>"
key_attributes: [direction, person_id, n, event_seq, playbook_id, playbook_version, created_at, cancelled_at, cancel_reason]
master_data: module-owned (Run aggregate root)
reference_table: hr_run
- id: run_step
meaning: one step of one run
natural_key: "run_id|step_no (step_no = the playbook step_no, plus '/<target>' when fanned out per access row)"
key_attributes: [kind, responsible_group, capability, input_json, plan, manual, timing_rule, due_at, state, attempts, last_status, provider_ref, result_json, contract_id, draft_text, done_by, done_at]
master_data: module-owned (Run aggregate)
reference_table: hr_run_step
note: "idempotency_key = <run_id>|<step_no>; contract_id (shared.yaml) for a document step"
- id: run_party
meaning: who a run-scoped group is for this run (the buddy, the takeover)
natural_key: "run_id|group_code"
key_attributes: [person_id, set_by]
master_data: module-owned (Run aggregate)
reference_table: hr_run_party
- id: run_log
meaning: the run's history (reschedules, approvals, completions, chases, cancellations)
natural_key: "run_id|seq (append-only; seq is the next integer for the run, a concurrent duplicate is rejected 23505 and retried)"
key_attributes: [what, from_value, to_value, actor, event_at]
master_data: module-owned (Run aggregate)
reference_table: hr_run_log
# others
- id: intake
meaning: the starter's self-service form for one engagement
natural_key: "intake_id = person_id|n"
key_attributes: [link_ref (the door's id, never the token), issued_at, expires_at, submitted_at, missing_fields, chases]
master_data: module-owned (Intake aggregate)
reference_table: hr_intake
- id: payroll_batch
meaning: one period's hand-off to payroll for one legal entity
natural_key: "batch_id = <legal_entity_ref>|<period>"
key_attributes: [state (draft | sent), produced_at, sent_at, sent_by]
master_data: module-owned (PayrollExtract aggregate)
reference_table: hr_payroll_batch
- id: payroll_line
meaning: one line of a payroll extract
natural_key: "batch_id|person_id|n|component|for_period"
key_attributes: [rate_basis, rate_amount (NUMERIC(19,4)), currency, prorate_from, prorate_to, quantity_minutes, quantity_days (NUMERIC(5,2)), payable_amount (NUMERIC(18,2)), change_kind]
master_data: module-owned (PayrollExtract aggregate)
reference_table: hr_payroll_line
note: "payable_amount only where a quantity exists (from the future time brick) or a fixed amount is due; rounded once here"
- id: privacy_request
meaning: a subject-access or erasure request
natural_key: request_id (derived from the person and the received date)
key_attributes: [person_id, kind (access | erasure), received_on, due_on, done_at, export_object_key, outcome]
master_data: module-owned (PrivacyRequest aggregate)
reference_table: hr_privacy_request
- id: retention_log
meaning: what the retention sweep or an erasure did
natural_key: "run_date|person_id|category"
key_attributes: [action (purged | anonymised | kept), basis, actor]
master_data: module-owned (append-only)
reference_table: hr_retention_log
master_data:
owns: [hr_person, hr_org_unit, hr_job]
references:
- entity: business_partner
owner: business-partner
how: "hr_person.bp_id = business_partner.bp_id (bp_key 'person:<person_id>'); hr_engagement.supplier_bp_id for a company freelancer"
if_absent: "not optional: contracts and finance address people by bp_id; without it a freelancer is paid from a second, unlinked record (S/4's old-model failure)"
- entity: cost_centre
owner: "gl (finance/controlling); hr stores the code only"
how: hr_assignment.cost_centre, hr_position.cost_centre, hr_org_unit.cost_centre
if_absent: "a free code typed by HR, validated against nothing until finance owns cost centres"
warning: >-
Before this module, the sign-in pool WAS the staff list: other modules hold the pool user's sub or
email in their owner columns. From now on, person_id is the key for people. hr_person.pool_user_sub
maps the two, and a module that needs more than "who is signed in" references person_id, never
a copy of the staff list. And never put a person's private data on their business partner: it is
the record other modules share. Even with person partners restricted to bp:person:read, a partner carries
a name and a work contact, nothing more.
jobs: # Ignite has no scheduler: everything below runs inside one tick, or is derived
- story: HR-30
trigger: "every minute or so"
runs: "POST /run-due-steps (run_due_steps(now)), as the brick's service identity"
where_options: ["the ONE clock worker (the clock service client) that calls every brick's tick route listed in the composition, pinned warm (--reserved 1 --max-replicas 1)", external scheduler calling the route with a service client holding hr:run:tick]
idempotent: "each step's state guard plus idempotency_key <run_id>|<step_no>; a tick may run twice"
- story: HR-31
trigger: at each step's due_at
runs: the account and access action steps
where_options: [inside the tick (HR-30)]
idempotent: "<run_id>|<step_no>; the provider claims it with a plain INSERT"
- story: HR-32
trigger: at each step's due_at
runs: calendar action steps
where_options: [inside the tick (HR-30)]
idempotent: "<run_id>|<step_no>"
- story: HR-33
trigger: at the draft step's due_at, or on request
runs: the Models adapter
where_options: [inside the tick (HR-30), in the same request when a manager asks early]
idempotent: "the first draft is kept; a new one only on the approver's request"
- story: HR-34
trigger: last_day 17:30 org timezone
runs: access removal steps
where_options: [inside the tick (HR-30)]
idempotent: "<run_id>|<step_no>"
- story: HR-35
trigger: last_day +30 days, after approval
runs: transfer status check, then delete
where_options: [inside the tick (HR-30)]
idempotent: "<run_id>|<step_no>; drive.transfer_status is a read"
- story: HR-36
trigger: every tick
runs: result handling and retries
where_options: [inside the tick (HR-30)]
idempotent: "attempts counted on the step; a manual task is created once (its key is the step)"
- st