dodil/erp-method

node_modules/.cache/erp-method/v0.1.21/modules/hr/module.yaml

# People: starters, leavers and who works here. The module source.
#
# PROVENANCE. Derived from a pilot engagement, 2026-10-17 (a creative agency with two UK offices, the
# first customer for this module, and deliberately NOT its shape). Sources, and how far each one is
# signed off:
#   - SIGNED OFF BY THE CUSTOMER: nothing yet. The need is the customer's own written request, in their
#     words. Their stories G-01 to G-17 are requirements written FROM that request; the customer has not
#     reviewed them yet.
#   - AGREED IN A BOUNDARY REVIEW (30 rows, 2026-10-17): what is core and what is a customer adjustment.
#     Every row marked core is in scope here: the UK localisation pack, documents on a person, headcount
#     reporting, retention and erasure, and the audit trail. Absence is out of scope for now and is an
#     extension point.
#   - DESIGNED (2026-10-17). These decisions are DODIL's, made on HCM research (SAP HCM, SuccessFactors
#     and Workday): everything else. That covers the aggregates, effective dating, personnel events, the
#     business-partner link, pay precision, the onboarding/offboarding engine and the extension points. A
#     customer still has to correct this in phase 2. Use it as the vocabulary to start that conversation
#     from, not as an answer.
#
# What a generic HR spec misses, and this one does not:
#   - history is SLICED, never overwritten: "who managed whom in March", retro pay and the audit trail all
#     depend on it (SAP's time constraints; pitfall 1 in the research)
#   - the manager is DERIVED from the org tree, never stored on the person
#   - onboarding and offboarding DO things (accounts, access, contracts, mail). With no connector installed,
#     each action is a precise manual task, and wiring a connector later makes the same step automatic
#   - sensitive personal data is a separate aggregate. A manager gets 404 on it, not a hidden field.
#
# The extra top-level sections (brick, domain_model, localisation_packs, extension_points) go out verbatim
# in /modules/hr/spec.yaml for the builders. The human page renders only the standard sections.

start_here: >-
  START HERE, before reading on: dodil://erp/build is the map (read it first; this spec is a vocabulary,
  not the method). Then, in the project folder, two commands: (1) `uvx --from git+https://git.dodil.io/dodil/erp-platform.git@v0.8.2 erp-compose init hr`;
  (2) SHOW the customer the diagram: `uvx --from git+https://git.dodil.io/dodil/erp-platform.git@v0.8.2 erp-compose process --module hr --playbook employee-onboarding
  --calls config/hr.calls.yaml` (config/hr.calls.yaml: `- { use_case: install_localisation_pack, args: { pack: gb,
  with_playbooks: true } }`), then paste its step names in order, with who does each, into your message
  (`grep -o 'Task_[a-z_]*" name="[^"]*"' processes/employee-onboarding.bpmn`). The signed artifact is
  that BPMN: never prose, never "open bpmn.io". Cards: dodil://erp/phase/show, dodil://erp/phase/signoff.
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: [H