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 arithmetic | domain/calendar.py (plain Decimal arithmetic over hr's days) |
| who may do something, or which rows | application/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 agents | a 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 reached | infrastructure/seams.py only |
| a setting the customer edits | domain/values.py (SETTING_DEFS) + SettingsRepository; controls need tof:settings:controls |
| a column or table | infrastructure/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 facts | application/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 read | list_tof_absences_for_payroll in application/sickness.py (GET /absences) and get_tof_balance_for_payroll |
| the Odoo migration | application/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 process | never 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_claimrow keyed (person_id, date, part), a plain INSERT: of two racers one wins, the other is a 409day_freenaming the winner. A ledger entry is keyed by itsentry_ref(req:<id>:r<rev>,req:<id>:r<rev>:cancel); the request number by the company's yearly series; a decision by (request, revision) intof_claim(decide,cancel,cancel_ask,cancel_refuse,leaver) viaClaimRepository.once. Approve, reject and withdraw share ONEdecidekey 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
takeentry 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 asrevision+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 MOVESis 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_asis 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_jsonpicks 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 runsrecompute_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.pychecks every call against hr's signed spec (checked_seams). - Machine permissions (
tof:leaver:intake- hr's client alone,tof:balance:read_for_payrollandtof:absence:read_for_payroll- payroll's client alone,tof:year_end:run,tof:migration:loadand: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). Seeroles.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 andtof: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) andevidence_okare refreshed from hr whenever the days or the notes change, and read by payroll in GET /absences withrevisionandchanged_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_balanceadds it back). - No adjustment alone.
propose_time_off_adjustment->approve_time_off_adjustmentby a different holder oftof: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 422statutory_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>, openingopen:<person>:<type>:<year>, maptof_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_pctandoverlap_modeare settings nothing reads yet). - TOF-16 accrual from approved hours (timesheets' GET /approved-hours; a UK-IRREGULAR
hours_workedpolicy;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)
- 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.
- The policy unit is ambiguous (days or hours). Used: the unit is the POLICY's -
daysfor UK-ANNUAL (full-timers, bank holidays on top) andhoursfor 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 andannual_daysare written in DAYS (carry_over_max, the settingcarry_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). - No roster seam. hr offers time-off no list of people, so the year-end pass takes
person_idsfrom 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'sbelow_zero. - 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. - 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).
- Sickness:
retain_untilhas no rule in the spec (three years after the days covered, HMRC's SSP record rule, is used and flagged indomain/evidence.py); the signed gate ofvoid_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 extrasicknessblock (qualifying days, PIW, linked spell, notes) besidestatutory: null; linking looks only backwards (a spell recorded late, out of order, does not relink a later one). - Settings: the spec's test names
tof:settings:write, which is not in its permission catalog - non-control settings usetof:policy:write(the owner may set any); the policy'sapplies_tois 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. - Odoo migration: hr offers no GET /migration-map, so the person comes through the
employee_mapdoor (as expenses does); hours-of-the-day requests are refused by name;number_of_hoursis 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. - 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), notperson_idsfrom the worker; the Odoo load matches employees by work email through the roster, not theemployee_mapdoor; 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:writeis in the catalog (move the non-control settings onto it fromtof: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