# 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.
id: hr
name: People, starters & leavers
short: People
version: 1
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]
# ---------------------------------------------------------------------------------------------
# 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:report:read, 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]
- 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: 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..59.
# ---------------------------------------------------------------------------------------------
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-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
# 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"
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"
- 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)"
- story: HR-37
trigger: per the cadence rows
runs: chasing
where_options: [inside the tick (HR-30)]
idempotent: "<run_id>|<step_no>|chase|<slot>"
- story: HR-38
trigger: when an effective date arrives
runs: business-partner sync of future-dated events
where_options: [inside the tick (HR-30), in the same request when the effective date is today or past]
idempotent: "keyed by the event (person_id|n|seq); bp_synced_at set after success"
- story: HR-39
trigger: on read
runs: nothing stored
where_options: [derived on read]
idempotent: "nothing to repeat"
- story: HR-40
trigger: daily, first tick after 02:00 org timezone
runs: the retention sweep
where_options: [inside the tick (HR-30), external scheduler]
idempotent: "retention log key run_date|person_id|category"
- story: HR-41
trigger: every tick
runs: expiry warnings
where_options: [inside the tick (HR-30)]
idempotent: "the task is keyed by the engagement and the date warned for"
seams:
- story: HR-50
with: business-partner
owner: business-partner
rule: "hr calls bp:person:sync (person partner fields, roles, works_for edge) and bp:partner:promote (a freelancer's company). It never writes business_partner, bp_role or bp_edges itself"
- story: HR-51
with: contracts
owner: contracts
rule: "hr calls contracts.draft / contracts.send / contracts.status (capabilities, through the CapabilitySeam); contracts never reads hr tables"
- story: HR-52
owner: "the capability provider named in compose.yaml"
provider: "google-workspace first; none in the first demo"
rule: "hr calls POST /capabilities/<name> on the mapped provider; it never names a vendor and never holds vendor credentials"
- story: HR-53
owner: "erp-platform (public-link door)"
rule: "the door holds the token (hashed) and calls hr's intake use cases with a service identity scoped to one intake"
screens:
- { id: my_tasks, name: My tasks, stories: [HR-03], actors: [assignee, manager, it, finance, hr_admin] }
- { id: new_starter, name: New starter, stories: [HR-01], actors: [manager, hr_admin] }
- { id: your_details, name: Your details, stories: [HR-02], actors: [starter], surface: public,
served_by: "the platform's public-link door app (no sign-in pool), never the staff app",
note: "PUBLIC: one form, no navigation, grant SUBMIT; see surfaces below" }
- { id: runs, name: Runs, stories: [HR-04], actors: [hr_admin, manager, it] }
- { id: run, name: Run, stories: [HR-05, HR-07, HR-08, HR-10, HR-23], actors: [hr_admin, manager, it] }
- { id: leaver, name: Record a leaver, stories: [HR-06], actors: [manager, hr_admin] }
- { id: action_log, name: Action log, stories: [HR-09], actors: [it, hr_admin] }
- { id: playbooks, name: Playbooks, stories: [HR-11], actors: [hr_admin] }
- { id: org, name: Org, stories: [HR-12], actors: [hr_admin, manager] }
- { id: person, name: Person, stories: [HR-14, HR-15, HR-16, HR-17, HR-20, HR-25, HR-26, HR-27], actors: [hr_admin, manager, finance] }
- { id: sensitive, name: Sensitive data, stories: [HR-13], actors: [hr_admin, finance],
note: "a separate screen and separate routes; a manager has no link to it and gets 404 on its routes" }
- { id: reports, name: Reports, stories: [HR-18], actors: [hr_admin, manager, finance] }
- { id: payroll, name: Payroll extract, stories: [HR-19], actors: [finance] }
- { id: privacy, name: Privacy, stories: [HR-21, HR-22], actors: [hr_admin] }
- { id: my_record, name: My record, stories: [HR-24], actors: [employee] }
- { id: settings, name: Settings, stories: [HR-28], actors: [hr_admin],
note: "every config_row below lives here" }
# ---------------------------------------------------------------------------------------------
# Surfaces: which features face someone with no sign-in. Every screen not listed under public is
# PRIVATE: the staff app, behind the composition's pool.
# ---------------------------------------------------------------------------------------------
surfaces:
private:
app: "the composition's staff app (one app, a router per brick), behind the dodil-appid pool; the gateway injects X-Dodil-User"
documents: >-
the app's service account is the ONLY bucket principal (a bucket-scoped k3.editor, as on crm-demo
and the pilot demo). People never touch DataK3 objects: a document is read only through an hr use case
that checks its document-class permission (personnel: hr:document:read, pay: hr:pay:read,
sensitive: hr:sensitive:read) and STREAMS the bytes. A direct or presigned object link is never
handed out, to a screen, an agent or an email.
screens: [my_tasks, new_starter, runs, run, leaver, action_log, playbooks, org, person, sensitive, reports, payroll, privacy, my_record, settings]
public:
app: >-
the platform's public-link door: a SEPARATE app with no sign-in pool (public_invoke true), its own
image and its own narrow service account. It holds a token reader and calls hr through a seam with a
service identity scoped to one record. It cannot import hr's private use cases, and it holds no
Models key and no connector credential (the precedent is an earlier client CRM's public-surface plan)
screens: [your_details]
stories: [HR-02]
token:
subject: "(hr, intake, <person_id>|<n>)"
recipient: "the starter's personal email: ONE token per person, never one per document"
grants: [SUBMIT] # READ of the empty form is implied; READ of stored values is never granted
stored: "as a hash (the secret never sits in a table); expiring (hr_settings intake validity); revocable as a column with who and when; reissue revokes the previous one"
seen_vs_acted: "first_seen_at / last_seen_at / seen_count are kept apart from used_at (submitted), so the board can say 'opened, not submitted'"
audit: "one row per use (seen, submitted, refused) with time, IP and user agent, kept by the door"
rate_limited: true
what_hr_exposes_to_it: "read_intake_form (the fields to ask, which are still missing; never a stored sensitive value) and submit_intake (the values and the upload); nothing else"
not_public: "everything else, including the starter's first-week plan, which they see in My record once they can sign in"
permissions:
namespace: hr
catalog:
- 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:run:tick
- hr:task:read
- hr:task:complete
- hr:step:approve
- hr:draft:approve
- hr:action:retry
- hr:action_log:read
- hr:config:write
- hr:report:read
- hr:privacy:manage
- hr:pool:invite
- hr:self:read
- hr:clock:set
row_visibility:
rule: >-
hr_admin and finance see every person; a manager sees the people whose derived manager they are
(ManagerOf on today's or a future slice, walking down from every chief position they hold) and
their runs, pay and personnel/pay documents, never sensitive data; IT sees people with an open IT
step; an assignee sees only steps of groups they belong to (a pool role, the derived manager, or
a run party); an employee sees only themselves; sensitive data needs hr:sensitive:read, which
only hr_admin and finance hold
enforced: "inside every use case from `caller` (BRICKS.md); the Sensitive repository has no join path from Person; the same rule serves the API and the MCP door"
not_found_not_forbidden: true
config_rows:
- { table: hr_playbook, changes: "your checklists per worker type, event type, reason and team, versioned" }
- { table: hr_playbook_step, changes: "each step: what, which group, when (anchor, offset in days or working days, time), what it depends on, approvals, conditions" }
- { table: hr_org_unit, changes: "your teams and departments, who leads each (the chief position), dated" }
- { table: hr_unit_access, changes: "which groups, chat spaces and shared folders each team gets" }
- { table: hr_job, changes: "your job catalogue and families" }
- { table: hr_location, changes: "offices, their country, timezone and holiday calendar" }
- { table: hr_work_schedule, changes: "working patterns (37.5 hours Mon–Fri, part-time patterns) and the full-time base" }
- { table: hr_holiday, changes: "public holidays per calendar (the pack loads them; you can add company days)" }
- { table: hr_worker_type, changes: "employee, freelancer (sole trader / via a company), intern…: payroll or supplier, which partner roles" }
- { table: hr_event_type, changes: "your names for the personnel events, each mapped to one of the seven kinds" }
- { table: hr_event_reason, changes: "leave reasons, pay-change reasons, transfer reasons" }
- { table: hr_infogroup, changes: "what each event's form asks for" }
- { table: hr_rate_basis, changes: "hour, day, month, year, fixed fee, and their labels" }
- { table: hr_pay_component_type, changes: "allowances and extras (≈ wage types), and which worker types they apply to" }
- { table: hr_pay_grade, changes: "optional pay bands per job, used by the 'rate outside band' condition" }
- { table: hr_responsible_group, changes: "who does what: manager, IT, finance, buddy, HR, director, and how each is resolved" }
- { table: hr_document_type, changes: "document types, their access class (personnel / pay / sensitive), expiry and retention category" }
- { table: hr_intake_field, changes: "what the starter is asked for, per worker type and country, and which fields are required" }
- { table: hr_draft_instruction, changes: "the instructions for each AI draft (welcome email, handover, leaver letter), and the fields it may use" }
- { table: hr_retention_rule, changes: "how long each kind of data is kept after someone leaves, and whether it is purged or anonymised" }
- { table: hr_settings, changes: "timezone, intake link validity, chasing cadence, max action attempts before IT, warning days, AI model name, rounding mode, and per legal entity the UK client size for IR35 (passed to contracts, which keeps no copy)" }
in_code_not_rows:
- "the seven domain event kinds and what each writes and starts: rehire never offboards; conversion is one run that keeps access"
- "the slice rules: delimit on insert, corrections supersede, TC1 per the entity table, 'current' derived"
- "person_id derivation, run_id derivation and idempotency_key = <run_id>|<step_no>"
- "the step kinds and how each executes; no provider = the same step as a manual task with the exact input (rule 8)"
- "that a destructive step needs an approval, and delete waits for a completed transfer"
- "that the Sensitive aggregate is separate and never loaded by a manager-facing use case"
- "that only name and work contact go to the business partner"
- "that documents are streamed by a use case after its class permission check; no object URL is ever handed out"
- "that a draft is never given a sensitive field, and a failed draft is a manual task"
- "that dry_run writes, claims and sends nothing"
- "that money is exact: rates NUMERIC(19,4), rounded once at the payable line; no float; no stored hourly rate for salaried staff"
- "that freelancer amounts never go to the payroll extract, and a sent batch is frozen"
- "the demo clock: the route exists only when HR_DEMO_CLOCK=enabled, and the app refuses to start with it set in a live deploy (DODIL_ENV=live)"
- "the step condition specifications (worker_type_is, via_company, rate_outside_band, pack_requires): rows choose them, code defines them"
tests:
acceptance: "one per story id above, named for it (test_hr_01_double_submit_is_one_run, …), in tests/test_rules.py and tests/test_doors.py"
access:
- "a manager token gets 404 on EVERY sensitive route (show_sensitive, record_sensitive, the sensitive document download) — including for their own reports — through the API and through MCP"
- "the MCP tool list for a manager-only caller contains no tool that returns sensitive data"
- "a manager opening a person outside their line gets 404, not 403"
- "IT cannot read pay or sensitive data (404); finance cannot record a hire (403 without the permission)"
- "an assignee opening a step of another group gets 404"
- "an employee opening another person's record gets 404"
- "a starter's intake token reaches only its own intake; a reissued or expired token is refused by the door"
- "the demo clock route does not exist when HR_DEMO_CLOCK is unset, and the app refuses to start with it set and DODIL_ENV=live"
- "a request with no gateway identity is rejected; a forged X-Dodil-User header is stripped at the edge"
invariants:
- "a double-submitted hire yields one person, one engagement and one run"
- "a tick run twice invokes each capability once per step"
- "moving a date re-times only steps not done; done steps keep due_at and result"
- "an identity.delete_user step never runs without an approval and before drive.transfer_status = completed"
- "dry_run writes no row, claims no key and sends nothing"
- "no gap and no overlap in a TC1 table after any write (property test over random event sequences)"
- "a correction keeps the superseded slice readable"
- "a rehire creates engagement n+1 and no offboarding run"
- "a conversion creates exactly one run and no access-removal step"
- "an open run keeps its playbook version after the playbook changes"
- "13.3333 an hour for 3 days of 150 minutes is 100.00 (rounded once at the line), never 99.99 (rounded per day)"
- "a payroll batch never contains a freelancer engagement; a sent batch never changes"
- "the business partner never receives a personal email, phone, address, NI number, bank detail or right-to-work field"
- "the draft adapter is never given a field from the Sensitive aggregate"
- "a capability with no provider produces a manual task whose input equals what the provider would have been sent"
live: "scripts/live_check.py: the deployed brick against a dev bucket: a hire, a tick, a manual task, a 404 for a manager on sensitive"
# The scenario catalogue, at the level SAP would test it. Named so the platform scenario harness can run
# each one in-process (rules + doors) and live (against a dev bucket). The org timezone is Europe/London
# unless a scenario says otherwise. "today" is set with the demo clock in-process and never in a live deploy.
scenarios:
- id: SC-HR-01
name: future-dated hire
stories: [HR-01, HR-38, HR-39]
given: today is 2026-10-17; no person with personal email anna@example.org
when: a manager records a hire starting 2026-11-02
then: >-
person, engagement 1 (status future), assignment slice valid_from 2026-11-02, and an onboarding run exist
today; headcount on 2026-10-31 excludes her and on 2026-11-02 includes her; her business partner gets no
employee role until the 2026-11-02 tick, and a second tick that day changes nothing
- id: SC-HR-02
name: a change versus a correction
stories: [HR-14, HR-39]
given: a pay slice 42,000.0000 GBP/year from 2026-03-01
when: (a) a PayChanged to 45,000.0000 from 2026-04-01; (b) then a correction of the 2026-04-01 slice to 45,500.0000
then: >-
(a) the March slice is delimited at 2026-03-31 and a new slice opens 2026-04-01; (b) a new 2026-04-01
slice supersedes the first (superseded_by set, still readable); "what did we believe on 2026-04-15
before the correction" returns 45,000.0000; TC1 holds after both
- id: SC-HR-03
name: retro pay after the batch was sent
stories: [HR-14, HR-19]
given: the November 2026 batch for GH-UK is sent
when: on 2026-12-10 a PayChanged effective 2026-11-01 is recorded
then: the November batch is unchanged; the December batch carries a retro line with for_period 2026-11 and its proration dates; no line appears twice across batches
- id: SC-HR-04
name: mid-month transfer
stories: [HR-17, HR-18, HR-19]
given: a Designer in Studio since 2026-01-05
when: a Transferred event to Campaigns from 2026-11-16 is recorded on 2026-11-10
then: >-
the Studio slice ends 2026-11-15 and the Campaigns slice starts 2026-11-16; headcount counts her in Studio
on 2026-11-15 and in Campaigns on 2026-11-16; the November extract carries both slices with their
proration dates; the change run's access delta is due on 2026-11-16, not on the 10th
- id: SC-HR-05
name: a reorganisation changes the derived manager
stories: [HR-12, HR-39]
given: Studio (chief held by Lena) under Operations (chief held by Omar); Ben assigned to Studio
when: Lena leaves on 2026-11-30 and Studio has no chief from 2026-12-01
then: >-
ManagerOf(Ben, 2026-11-30) is Lena and ManagerOf(Ben, 2026-12-01) is Omar (walking up), with no write to
Ben's record; Omar sees Ben's runs from 2026-12-01 and not before; a manager override on Ben's assignment
would win over both
- id: SC-HR-06
name: leave, then rehire
stories: [HR-06, HR-15]
given: an employee on engagement 1
when: a Left event (last day 2026-11-30) and, the same afternoon, a Rehired event starting 2026-12-01
then: >-
engagement 1 ends 2026-11-30 and engagement 2 starts 2026-12-01; one offboarding run (for the Left) and one
onboarding run (rehire playbook) exist; the rehire creates no offboarding run; a rehire starting on
2026-11-30 instead is refused ("engagement 1 is still active on 30 Nov; start the new one on 1 Dec")
- id: SC-HR-07
name: freelancer converted to employee
stories: [HR-16, HR-50]
given: a sole-trader freelancer on engagement 1 with an account and two groups
when: a Converted event effective 2026-12-01
then: >-
engagement 1 ends 2026-11-30 (reason converted), engagement 2 (employee) starts 2026-12-01, in one
transaction; exactly one conversion run; no suspend/remove/delete step anywhere; the business partner loses
worker and supplier and gains employee on 2026-12-01; the November extract has no line for engagement 1
- id: SC-HR-08
name: two admins change the same person at once
stories: [HR-14]
given: two HR admins open the same person
when: both save a different pay slice at the same moment
then: >-
the repository's version check on save (platform work H2b) lets one save commit; the other is refused
409 "changed since you opened it" and nothing of it is written; TC1 holds; retrying after a reload
succeeds as a normal change. The same holds on the Sensitive root (hr_sensitive.version)
- id: SC-HR-09
name: no provider wired
stories: [HR-31, HR-36, HR-03]
given: a composition mapping no provider to identity.provision_user
when: the account step falls due
then: >-
a manual task for IT carrying capability identity.provision_user, the exact validated input
(given_name, family_name, primary_email, org_unit…) and the plan in words; IT marks it done with
provider_ref and the work email; the email is written back as a person-detail slice; wiring a provider later
and replaying the same playbook on a new starter makes the step automatic with no playbook change
- id: SC-HR-10
name: provider failing
stories: [HR-36]
given: a provider that answers failed retryable=true three times, with max attempts 3
when: three ticks pass
then: three attempts in the action log, backoff between them, then a manual task for IT with the provider's message in words; the step is not done and the run shows "stuck"
- id: SC-HR-11
name: provider needs a connection
stories: [HR-36]
given: a provider that answers connection_required
when: the step falls due
then: a manual task at once, naming what is missing ("no Workspace connection; create the account by hand or connect the provider"); no retries counted; a later tick after the connection exists does not double-act (the manual task, once done, is terminal)
- id: SC-HR-12
name: provider still working
stories: [HR-35, HR-36]
given: drive.transfer_status answers in_progress for two ticks, then completed
when: the delete step is approved and due
then: identity.delete_user is called only after completed; in_progress never counts as a failure
- id: SC-HR-13
name: approvals pending, declined, timed out
stories: [HR-07, HR-37]
given: three runs with an approval before identity.delete_user
when: one is approved, one is declined with a reason, one is never answered
then: >-
approved: delete runs when due; declined: delete is cancelled and the reason is in the run log; unanswered:
the approver group is chased per cadence and after the maximum a task goes to HR ("approval overdue"); the
delete never runs by timeout
- id: SC-HR-14
name: daylight saving
stories: [HR-30, HR-31, HR-34]
given: org timezone Europe/London; British Summer Time ends on 2026-10-25
when: a start on Wednesday 2026-10-28 with the account step at start −3 working days 09:00, and a last day on Friday 2026-10-30 with removal at 17:30
then: the account step is due 2026-10-23T08:00Z (09:00 BST); the removal is due 2026-10-30T17:30Z (17:30 GMT); both stay 09:00 and 17:30 local after the change
- id: SC-HR-15
name: leap day and month end
stories: [HR-14, HR-19]
given: a pay slice starting 2028-02-29, and a leaver whose last day is 2028-02-29
when: the slices are written and the February 2028 extract is produced
then: the previous slice ends 2028-02-28; the leaver's pay is delimited at 2028-03-01; the extract carries prorate_to 2028-02-29; no slice has a gap on 2028-02-29
- id: SC-HR-16
name: proration inputs and rounding once
stories: [HR-19]
given: an hourly rate of 13.3333 GBP and, from the time brick, 3 days of 150 minutes in a period
when: the payable line is computed
then: 13.3333 × 450 / 60 = 99.99975, rounded once half-up to 100.00; rounding per day would give 99.99, and the test asserts 100.00
- id: SC-HR-17
name: retention purge
stories: [HR-40]
given: 'a retention rule "bank details, purge 12 months after the engagemen