dodil/erp-brick-time-off

DODIL ERP brick: time off

AGENTS.md

Instructions for agents working on this brick

Built from the time-off module spec (erp-method modules/time-off/module.yaml, version 1, signed 2026-10-25; BPMN in modules/time-off/signoff/time-off/). Read erp-platform's BRICKS.md before changing anything - the rules and why each one exists. The shape is erp-brick-expenses'. Slices A and B are built (0.2.0): the leave_request workflow, the balance, hr's leaver intake, sickness, the year end, closure days, adjustments, family leave, settings / leave types / policies, payroll's absence read and the Odoo migration. See "What is not built" and "Spec gaps".

Where a change goes

You are changing...It goes in
a business rule (the state machine, who decides, the approver's derivation)domain/request.py - and its test in tests/test_invariants.py / test_stories.py
what a request costs, the leave year, the entitlement, the balance arithmeticdomain/calendar.py (plain Decimal arithmetic over hr's days)
who may do something, or which rowsapplication/use_cases.py - the permission on the declaration (or _need), _can_see / _own / _decider; never a role's name
a new action for people and agentsa new use case in application/use_cases.py - a route AND a tool, named <verb>_<module noun>_<object> unless it is a signed transition key
how hr is reachedinfrastructure/seams.py only
a setting the customer editsdomain/values.py (SETTING_DEFS) + SettingsRepository; controls need tof:settings:controls
a column or tableinfrastructure/tables.py AND schema.sql AND a new migrations/NNNNN_<name>/ (append; never drop or rename); tests/test_schema.py keeps them equal
sickness (the spell, fit notes, the facts payroll needs for SSP)domain/sickness.py (state machine, qualifying days, PIW, linking, evidence_ok) + application/sickness.py
the year end (carry cap, lapse, expiry)domain/calendar.py (carry_split, unused_carry, expiry_date) + application/year_end.py
closure days (what a person's allowance pays for)domain/calendar.py closure_deductions (derived on read) + application/closures.py
a manual adjustment (reason, second person)domain/adjustment.py + application/adjustments.py
family leave and its statutory factsapplication/family.py (+ tof_family_leave, tof_claim scope kit_day)
settings, leave types, policies (dated rows, the owner's controls, the statutory minimum)application/settings.py; the defaults in domain/values.py
what payroll and timesheets readlist_tof_absences_for_payroll in application/sickness.py (GET /absences) and get_tof_balance_for_payroll
the Odoo migrationapplication/migration.py, domain/odoo.py, the reader port infrastructure/odoo.py (tests: tests/test_migration.py)
the evidence store (fit notes)infrastructure/objects.py (a port; S3 from the environment; a missing store is a 503)
the processnever here first: erp-compose change propose, a new signature, then application/process.yaml; tests/test_process.py pins the signed BPMN byte for byte (all three processes)

Never add an HTTP route or an MCP tool by hand. Never import SQLAlchemy, FastAPI or MCP in domain/. Don't copy hr's calendar, work pattern or staff list (seams.py; a test fails on a table that holds one). Don't write an upsert by hand - erp_platform.repository.upsert (facts are plain INSERTs). Business time is work.now() counted in business_timezone (_today(repo)); audit stamps are real time.

The rules that must not be undone (each has a test in tests/test_invariants.py / test_stories.py)

  • One claim key per person/day operation. A half day is a tof_day_claim row keyed (person_id, date, part), a plain INSERT: of two racers one wins, the other is a 409 day_free naming the winner. A ledger entry is keyed by its entry_ref (req:<id>:r<rev>, req:<id>:r<rev>:cancel); the request number by the company's yearly series; a decision by (request, revision) in tof_claim (decide, cancel, cancel_ask, cancel_refuse, leaver) via ClaimRepository.once. Approve, reject and withdraw share ONE decide key per revision, so they exclude each other at the database.
  • The ledger entry and the state move in one transaction; nothing leaves before the commit. Approval writes the take entry with the request's new state; cancellation writes the reversal and releases the half days together. 0.2.0 still has NO outbound write apart from the fit note's content-addressed object (hr is read-only for it; payroll and timesheets PULL approved absences, and a change reaches them as revision + changed_at), so there is no outbox yet - it arrives with the reminders (TOF-18). The leaver's balance is read in a SECOND transaction after the cancellations commit (no read-your-writes inside an open transaction).
  • States never move back. domain/request.py MOVES is the only table of arrows. "Taken" is derived from dates, never a state; a cancellation ask is a fact on the approved request. A leaver's cut-back request continues as revision n+1 (n reversed, n+1 taken).
  • Nobody decides their own time off, under either login: Identity.same_as is email OR sub; the check runs before "are you the approver", for HR too. The approver is hr's derived manager on the first day, FIXED at the request; level 1 being the requester (the chief of their own unit) goes to level 2; no manager in hr means HR alone decides.
  • Public holidays, the work pattern, employment and the manager come from hr on every request. Weekends, days off and a public holiday cost nothing and claim nothing. A request costs the halves it takes: 0.5 day each in days, minutes/60 in hours.
  • The balance is the derived entitlement plus a pure sum of the entries, less what is pending (order independent, never a stored number). Full-timers book in DAYS with bank holidays on top; anyone below FTE 1 books in HOURS with the holidays inside the allowance and each one on a day they work deducted (policy.applies_json picks the policy by FTE). Entries are never edited or deleted.
  • Stale results: approval re-reads hr; if the halves or cost differ the request is flagged stale (committed) and approval is refused until the requester runs recompute_leave_request.
  • Business date = the date in business_timezone, never the UTC date (past-day rule, max_days_ahead, the request number's year).
  • Bounds are 422 {gate: bounds}, never a 500 (dates 2000..2100, at most 366 days, part combinations, text lengths, source_ref).
  • Statements stay flat: asking, approving, cancelling, a request, a balance and the lists do not grow with the days or with other people's rows (tests/test_statement_budget.py).
  • A neighbour answer is a reply, never an exception: not installed 409, failed 502, silent 503, shape wrong 502. tests/conftest.py checks every call against hr's signed spec (checked_seams).
  • Machine permissions (tof:leaver:intake - hr's client alone, tof:balance:read_for_payroll and tof:absence:read_for_payroll - payroll's client alone, tof:year_end:run, tof:migration:load and :reconcile - the worker) are exact grants; no wildcard reaches them (tests/test_permission_matrix.py, which also probes every route with a caller who holds nothing). See roles.yaml.

Added in slice B (each has its tests; the mutation that proves the test is named in the commit)

  • Sickness is recorded, never requested; its day claims are the collision guard. A spell claims the half days up to the day before the expected return in the same transaction as the row (a day in leave or other sickness is a 409 day_free). The spell's approver is fixed at the recording (approver_*, like a request's - a test keeps hr's manager out of the tables). Open -> closed | voided only; a closed spell takes evidence (add_late_fit_note) and nothing else. Nobody confirms their own return under either login (_no_self: the person's login, or their person in hr found by sub). The reason is read only by the sick person and tof:sickness:read_detail; the manager sees "sick"; the owner reads the record, not the reason.
  • Facts, not pay. qualifying_days (hr's employed days with contracted minutes, a bank holiday on such a day counts), piw (4+ calendar days), linked_to / linked_group (a PIW within 56 days of an earlier PIW's end) and evidence_ok are refreshed from hr whenever the days or the notes change, and read by payroll in GET /absences with revision and changed_at. time-off never computes statutory pay.
  • A fit note is an object raw/time-off/<absence_id>/<sha256>.<ext> (content-addressed, never overwritten). The put precedes the row's commit - the one outside write that is not from a pending row - because the same bytes are the same object, so a rolled-back row leaves an orphan, never a wrong one.
  • The year end is an idempotent job. carry: / lapse: / expire:<person>:<type>:<year> entries are plain INSERTs, checked first (entries.refs), one transaction per person: a second pass or a second worker writes nothing twice. The carry cap is the one in force on the old year's LAST day (a cap dated for the new year's first day does not touch the year that just ended, TOF-14), the smaller of the setting and the policy's; carried days are used first.
  • Closure days are derived on read, never stored per person: each balance deducts the closure days its person works, less the halves an absence of theirs already holds (shown as covered, never twice). A request on a closure day is not a second cost (_check_balance adds it back).
  • No adjustment alone. propose_time_off_adjustment -> approve_time_off_adjustment by a different holder of tof:balance:adjust, neither the proposer nor the person whose balance it is, under either login. Only an approved one is an entry (adj:<person>:<type>:<year>:<n>), in the decision's transaction.
  • Every settings / leave-type / policy row is dated and a plain INSERT; a key that has a row takes a new one only AFTER today. The controls are tof:settings:controls (the owner's); an annual-leave policy below 5.6 weeks is a 422 statutory_minimum.
  • The migration is read-only on Odoo, plans before it writes, and a signed run is frozen. Keys: request odoo:<db>:hr.leave:<id>, opening open:<person>:<type>:<year>, map tof_migration_map. Discard removes ONLY an unsigned run's own rows - the one place a ledger entry is deleted - and is refused once a migrated request has moved.

What is not built (0.2.0)

  • TOF-04's overlap warning and TOF-10 the team calendar: both need a person's team, and no seam hr lists gives one (overlap_threshold_pct and overlap_mode are settings nothing reads yet).
  • TOF-16 accrual from approved hours (timesheets' GET /approved-hours; a UK-IRREGULAR hours_worked policy; tof:balance:accrue) and TOF-18 reminders (tof_outbox, the approval and fit-note reminders; tof:reminder:tick; there is no notification seam in the spec). Together they are what brings back the outbox: nothing in 0.2.0 leaves this brick except the fit note's content-addressed object.
  • Sickness over approved leave (HR records it, then gives the day back, TOF-12): the day collides with the leave's claim, so today HR cancels or adjusts and records the sickness on the days the leave no longer holds. Not designed: the spec does not say which row keeps the day.
  • Scenarios SC-TOF-01..03 as named end-to-end tests (their stories are tested one by one); the MATB1 document upload (its date is kept).

Spec gaps (recorded here and in the commits; the signed spec was NOT edited)

  1. TOF-01's arithmetic does not add up: entitlement 25.00 + carried 2.00 - taken 17.00 is 10.00, not the 8.00 the story says (the 8.00 subtracts the two carried days a second time: they are inside the 17 taken). The brick follows the ledger: available = entitled + carried + adjustments + lapsed + expired - bank holidays - closures - spent - pending, and shows each figure with its entries. Nothing was changed to make the story's 8.00 appear.
  2. The policy unit is ambiguous (days or hours). Used: the unit is the POLICY's - days for UK-ANNUAL (full-timers, bank holidays on top) and hours for UK-ANNUAL-PT (anyone below FTE 1, holidays inside) - and every quantity of a person (entries, adjustments, carry, lapse) is in THEIR policy's unit. Caps and annual_days are written in DAYS (carry_over_max, the setting carry_over_max_days) and converted at the person's contracted day for hours. Odoo's days are converted the same way in the migration. A person whose unit changes between years is carried in the old year's unit (not handled).
  3. No roster seam. hr offers time-off no list of people, so the year-end pass takes person_ids from the worker (or, without them, the people this brick holds a request or an entry for), and HR's 'who would a closure push below zero' list is each balance's below_zero.
  4. Closure days: the spec's per-person closure:<company>:<date>:<person> entry is not written (derived on read instead, see above); a published closure cannot be withdrawn; HR's permission is not scoped to a company.
  5. Adjustments: the spec only refuses one's own balance; the second-person rule (proposer is not decider) is the build brief's. No deadline is enforced on HR's override of a lapse (before 31 January in TOF-17).
  6. Sickness: retain_until has no rule in the spec (three years after the days covered, HMRC's SSP record rule, is used and flagged in domain/evidence.py); the signed gate of void_sickness 'no day of it already in a calculated pay run' needs a payroll seam the spec does not list, so it is not checked; GET /absences carries an extra sickness block (qualifying days, PIW, linked spell, notes) beside statutory: null; linking looks only backwards (a spell recorded late, out of order, does not relink a later one).
  7. Settings: the spec's test names tof:settings:write, which is not in its permission catalog - non-control settings use tof:policy:write (the owner may set any); the policy's applies_to is FTE-only (fte_min / fte_below): employee subgroups and locations need an hr seam; the statutory check counts 8 bank holidays (the fewest any UK nation has) because this brick keeps no calendar; the carry cap is the smaller of the setting and the policy's, the expiry the earlier.
  8. Odoo migration: hr offers no GET /migration-map, so the person comes through the employee_map door (as expenses does); hours-of-the-day requests are refused by name; number_of_hours is not verified against a live Odoo; an opening is written once per person, type and year (a later allocation is mapped and shown as a reconcile difference, not added); migrated requests carry no requester login, so a person sees them in their balance and payroll's pull, not as 'their request'; discard deletes an unsigned run's ledger rows (see above) and payroll, which pulls, is not told.
  9. Family leave: the person's own login is unknown to HR's recording (same visibility as a migrated request); enhanced pay is payroll's.

Backlog (decided 2026-10-26, pending the spec PR)

seemo decided these on 2026-10-26; they are written into erp-method branch spec/gaps-wave-4. Build them once that PR is signed (the module's processes need a re-sign first), not before; until then the spec on main stands.

  • Migration staging. load and reconcile write staging rows only; sign writes the requests and opening entries (the first ledger write); discard deletes the staging rows and nothing else (today it deletes an unsigned run's ledger rows); a signed run is put right by reversing entries. TOF-23 now says so.
  • The hr roster seam. The year-end pass covers everyone employed through GET /roster?company_code=&on= (HR-64), not person_ids from the worker; the Odoo load matches employees by work email through the roster, not the employee_map door; approver email from HR-63.
  • TOF-04 and TOF-10 via the team seam. The team calendar and the overlap count read GET /roster?manager_person_id= per day, so a team member with no request counts.
  • Spec fixes that change nothing here (already built this way): TOF-01 now adds up to 10.00; the unit is the policy's own (days full-time, hours part-time); tof:settings:write is in the catalog (move the non-control settings onto it from tof:policy:write).

Before you commit

pytest -q --tb=short     # stories, invariants, doors, seams, budget, process, schema, composition, sickness, year end, closures, adjustments, migration, matrix
ruff check src tests
erp-compose check tests/tier1.compose.yaml --strict --roles src/brick_time_off/roles.yaml