Skip to content

Phase 5 H1 — Employee Record and Lifecycle Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Give every employee an employee record keyed to their existing employee party (separate from the login member, HD-1), with an append-only lifecycle from which status is derived, a last day that is refused while the person holds an open advance or petty cash float unless an Owner who is not the requester overrides it (HD-6), and the effects of a passed last day: seats end, login ends, pending approvals escalate.

Architecture: A new platform/hr owns three tables — employee (one row per employee party: number, identity document, revision), employee_event (append-only: hired, confirmed, transferred, notice_given, left, rehired) and lifecycle_override (append-only) — and one SQL function, employee_spell(org, employee), which is the only definition of hire date, employment type, last day and status. Recording a last day writes notice_given; an hourly River job writes left once the day has passed and applies its effects through orgstruct (seats close the day after) and a new member.Deactivate (login ends). Pending approvals are not moved by writing anything: the workflow engine resolves approvers at decision time, so a vacated seat is escalated past by the existing on_vacancy: escalate walk. modules/hr is the HTTP surface; web/src/features/hr the screens.

Tech Stack: Go 1.x, pgx v5, Postgres 16 (goose migrations), gin, River, oapi-codegen, React + TanStack Router/Query + i18next (web), vitest + msw.

Spec: docs/superpowers/specs/2026-09-30-hr-layer-design.md (§H1, owner decisions HD-1 and HD-6, the H1 drift-test bullets; §H2 and §H3 for what this lane’s interfaces must serve). Architecture: docs/superpowers/specs/2026-09-20-erp-architecture-design.md §4.7, §19 Phase 5.

  • Migration ranges: H1 00800–00819 (this plan uses 00800–00803). Reserved in Task 1 for the other lanes: H2 00820–00839, H3 00840–00859.
  • A float never touches money. H1 stores no money at all (no salary, no bank details — spec H1 “Personal data”).
  • Every business table has org_id, ENABLE and FORCE ROW LEVEL SECURITY, and a policy named org_isolation. Every tenant foreign key is composite on org_id (TestEveryTenantForeignKeyIsCompositeOnOrgID).
  • Every constraint is named (CONSTRAINT <table>_<rule>); TestEveryConstraintIsNamed fails on a generated name.
  • Every function pins its search path (SET search_path = pg_catalog, public, pg_temp) and schema-qualifies what it reads (public.). No SECURITY DEFINER is added.
  • Retention: employee is amendable (identity corrections; DELETE revoked); employee_event and lifecycle_override are append-only (UPDATE, DELETE revoked). A correction is a later event, never an edit.
  • Vocabulary values are strings: event kinds, statuses, employment types (permanent, contract, probation, part_time), identity document kinds (nric, passport, other).
  • Record text is English or Bahasa Malaysia (spec 11A.3): a new person’s legal name, every reason, an other identity number (recordtext.Check; the same CJK CHECK the other tables carry).
  • Every user-facing name is a translation row — H1 adds no named entity; its labels (statuses, kinds, types) are i18n keys in hr.json (en, ms, zh).
  • A claimant never picks their own approver — here: the member who records a last day is never the Owner who overrode the block (HD-6), enforced in Go and by the employee_event_override_valid trigger.
  • The contract is api/openapi.yaml. Endpoints are declared there first; make generate must leave the tree clean (internal/gen/drift_test.go, web/src/drift/schema.test.ts, internal/kernel/vocab/vocab_test.go).
  • Layering: platform/hr may import platform/orgstruct, platform/identity/member, platform/workflow, platform/advances, platform/pettycash. None of those may import hr. platform/imports imports hr (Task 8). workflow never imports hr (H3’s Unavailable seam is registered by hr, not imported by workflow).
  • Drift tests: presence before absence, each watched failing once (AGENTS.md). While iterating, run only the touched packages; run go test ./... once, in Task 10.
  • Lock order (added to docs/LOCK_ORDER.md in Task 10): organization FOR UPDATE → employee FOR UPDATE → organization_member FOR UPDATE → the target org_position FOR SHARE (transfer) → member_position rows FOR UPDATE in id order → inserts → audit.Write last. No row lock is taken after the first audit.Write.
  • Commits: one per task, the message ending with a blank line and Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. Merging and pushing are the owner’s.
  • H1-1 Status is derived by one SQL function. employee_spell(org, employee) returns hire_date, employment_type, last_day, status from employee_event, ordered by the row’s seq (recording order). The current spell starts at the latest hired/rehired; employment_type is the latest hired/rehired/confirmed in it; last_day is the spell’s left day, else its latest notice_given’s last_day. Status: pending (no hire recorded), active, notice (a last day is recorded, not yet passed), left. Go reads the function through employeeSelect; H3’s SQL may call it. Nothing about status is stored.
  • H1-2 Events. Exactly the spec’s six. rehired is the rehire’s “new hired event” (spec H1 “Rehire”). left is written only by the system when a last day passes (Task 5/6), never by a caller. A later notice_given supersedes an earlier one (that is how a last day is changed). Withdrawing a notice altogether has no event kind in the spec, so it is not in H1 (ROADMAP follow-up).
  • H1-3 Employee number. Supplied by HR, 1–32 characters, trimmed, unique per organization, never changed once set (employee_fixed trigger) and kept on rehire. organization_member.employee_no is read-only from 00801 (a trigger refuses any change of value); an INSERT may still carry one (test fixtures do; no application path writes it) and nothing copies it after 00801. member.Record.EmployeeNo has no readers and is left as it is — a frozen snapshot.
  • H1-4 The copy records no hire. 00801 creates an employee row for each member with a usable number but no hired event: the day a member was invited is not the day they were hired, and a wrong hire date would grant the wrong leave (H3). Such a record reads pending until HR records the hire. A number two members of one organization share is copied for the member who had it first (created_at, then id); DEPLOY_NOTES gives the query for the rest.
  • H1-5 Identity document. Kind and number both or neither. NRIC is stored as its 12 digits (dashes and spaces dropped); a passport as 5–20 upper-case letters and digits; other as 1–40 characters of record text. Unique per (organization, kind, number). Only holders of hr.employee.sensitive see the number or set it (PUT /identity is gated on that key alone: you cannot verify what you cannot see). Audit rows record the kind and id_number_changed: true, never the number — the audit trail is read by more people than hold the key.
  • H1-6 What blocks a last day (HD-6). An open advance is an advance whose holder is the person’s party and any of whose instances — the advance, its top-up or its refund leg — is in a state that is not terminal and whose semantic is not draft or rejected (money is out, or approved to go out, and not yet accounted for). A float is the person’s float that is not closed. The check runs when the last day is recorded; the left event itself is never refused (a passed day is a fact). An advance or float opened after the notice is not blocked by H1 (ROADMAP follow-up). The person’s own documents still awaiting approval are counted as a warning (pending_documents), never a block (H1-11).
  • H1-7 The override is two-person and consumed once. An Owner (holder of hr.employee.lifecycle_override) grants a lifecycle_override for one employee and one last day, with a reason, snapshotting what was open (covers). A different member then records that last day; the notice_given event names the override (employee_event_override_once: once only) and the database refuses it when the override is for another employee or day, or was granted by the member recording (employee_event_override_valid). Something opened after the override is not covered: the recording is refused again. Nobody overrides their own leaving (ErrOwnLeaving). Refusing first and overriding second, not the reverse, because the refusal is what tells HR what is open.
  • H1-8 Sole owner (consistent with Ruling O-3a and spec H3.3). In an organization with one active member, that member may grant the override and then record the last day themselves, with the override’s mandatory reason: the override is stored sole_owner = true, audited so, and at recording the exception is re-checked under the organization lock (workflow.SoleOwnerException) — a second member who joined in between makes it an ordinary refusal again.
  • H1-9 Effects run in an hourly River job, and at once for a backdated day. A last day is usually in the future, so a transition-time hook has nothing to fire on when the day passes; the job (hr_apply_leavings, hourly and on start) finds every notice whose last_day is before today and applies it in its own transaction, idempotently (the status is left afterwards). A last day recorded in the past is applied in the recording’s own transaction. Applying: the left event; every assignment of the member that started by the last day closes on the day after it (valid_to = last_day + 1, half-open); one not yet started is withdrawn; one started after the last day (a late run) closes today; the member is deactivated (member.Deactivate: active = false, permission version bumped, deactivation hooks, audit). Sessions are not revoked: a user’s sessions span every organization they belong to, and the liveness check refuses the inactive membership (its cache holds a positive answer at most 10 s). Claims, advances and payout profiles are not touched, so the person’s open claims can still be settled (spec H1).
  • H1-10 Pending approvals move by resolution, not by writing. Approvers are resolved when a decision is attempted (workflow.Resolve on the decision day), so once the seat’s assignment has ended and the member is inactive, a position grant’s on_vacancy: escalate walks to the next filled seat, and superior/unit-head grants resolve past the vacant seat as they already do. Task 6’s drift test proves it. Delegations need no action: an inactive delegator resolves to nothing, an inactive delegate cannot sign in.
  • H1-11 A leaver’s own pending documents. A superior-relative grant resolves against the claimant’s seat on the decision day; after the last day the leaver has none (no_primary_position). H1 does not change the engine: recording a last day reports the count of the person’s documents still awaiting approval (pending_documents) so they are decided first. ROADMAP follow-up.
  • H1-12 Transfers take effect today or later (a backdated transfer would rewrite who was entitled to approve on past days), need an active login (seats are held by members), withdraw the current primary when it starts that same day and otherwise close it on the effective day, then orgstruct.Assign the new primary from that day. The event records both seats.
  • H1-13 Permissions and module. hr.employee.read, hr.employee.manage, hr.employee.sensitive, hr.employee.lifecycle_override — Owner-only by default (new Owners get every registered key at provisioning; 00802 grants existing Owners). Employee records are personal data; the Owner decides who else sees them. Module key hr, default on, no dependency. No provisioning step is needed, so no step order is taken (Phase 4 O2’s 81 and 83 are untouched).
  • H1-14 One organization lock per lifecycle write. Every write in platform/hr that locks an employee takes the organization row FOR UPDATE first. That is the sole-owner exception’s lock, and it is the row member.BumpPermVersion updates when a member is deactivated, so taking it first gives every lifecycle path one order. The ceiling is that lifecycle writes in one organization serialize (marked ponytail: in code).
  • H1-15 Import employees. A row whose employee_no is new creates a person without a login (party + employee), then records the hire and identity when given. A row whose number matches a pending record (the 00801 copy) completes it (hire, and identity when the record has none). Anything else with that number is refused. Identity numbers need the importer to hold hr.employee.sensitive.
  • H1-16 Rehire does not restore login. A rehire reuses the party, the row and the number (spec H1); giving the person a login again is identity’s invitation flow (ROADMAP follow-up).
  • H1-17 The job reuses ops.org_ids(), the worker’s one allowlisted cross-tenant read, and then works inside each organization’s own tenant transaction, as opsjobs.ReconcileWorker does. It runs whether or not the organization has the hr module on: switching a module off never stops a recorded last day from ending access.

All in github.com/mcom/erp/internal/platform/.... Stable after this plan lands.

// package hr
const (
EventHired = "hired"; EventConfirmed = "confirmed"; EventTransferred = "transferred"
EventNoticeGiven = "notice_given"; EventLeft = "left"; EventRehired = "rehired"
StatusPending = "pending"; StatusActive = "active"; StatusNotice = "notice"; StatusLeft = "left"
TypePermanent = "permanent"; TypeContract = "contract"; TypeProbation = "probation"; TypePartTime = "part_time"
IDNRIC = "nric"; IDPassport = "passport"; IDOther = "other"
EntityEmployee = "employee"; EntityOverride = "lifecycle_override" // audit entity kinds
PermissionRead = "hr.employee.read"; PermissionManage = "hr.employee.manage"
PermissionSensitive = "hr.employee.sensitive"; PermissionLifecycleOverride = "hr.employee.lifecycle_override"
)
var EmploymentTypes []string
type Employee struct {
ID, PartyID uuid.UUID; LegalName, EmployeeNo string
MemberID *uuid.UUID; MemberActive bool // the login, when there is one
IDKind, IDNumber *string // IDNumber always loaded; the HTTP layer withholds it
HireDate *time.Time; EmploymentType *string; LastDay *time.Time; Status string // derived (H1-1)
Revision int; CreatedAt, UpdatedAt time.Time
}
type Event struct {
ID uuid.UUID; Seq int64; EmployeeID uuid.UUID; Kind string; EffectiveOn time.Time
LastDay *time.Time; EmploymentType *string; FromPositionID, ToPositionID, OverrideID *uuid.UUID
Reason string; RecordedBy *uuid.UUID; RecordedAt time.Time
}
type CreateInput struct { MemberID *uuid.UUID; LegalName, EmployeeNo string }
type Filter struct { Status, Query string }
type HireInput struct { EmployeeID uuid.UUID; EffectiveOn time.Time; EmploymentType, Reason string; Actor uuid.UUID }
type ConfirmInput struct { EmployeeID uuid.UUID; EffectiveOn time.Time; EmploymentType, Reason string; Actor uuid.UUID }
type TransferInput struct { EmployeeID, PositionID uuid.UUID; EffectiveOn time.Time; Reason string; Actor uuid.UUID }
type LastDayInput struct { EmployeeID uuid.UUID; NoticeOn, LastDay time.Time; Reason string; Actor uuid.UUID }
type OverrideInput struct { EmployeeID uuid.UUID; LastDay time.Time; Reason string; Actor uuid.UUID }
type OpenAdvance struct { ID uuid.UUID; Reference, State string }
type OpenFloat struct { ID uuid.UUID; Code, Status string }
type Obligations struct { Advances []OpenAdvance; Floats []OpenFloat; PendingDocuments int }
func (o Obligations) Blocking() bool
type Override struct {
ID, EmployeeID uuid.UUID; LastDay time.Time; Reason string; Covers Obligations
GrantedBy uuid.UUID; SoleOwner bool; CreatedAt time.Time
}
func Create(ctx context.Context, tx pgx.Tx, c clock.Clock, in CreateInput) (Employee, error)
func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Employee, error)
func GetByParty(ctx context.Context, tx pgx.Tx, partyID uuid.UUID) (Employee, error)
func GetByMember(ctx context.Context, tx pgx.Tx, memberID uuid.UUID) (Employee, error) // H3: the approver's record
func GetByNumber(ctx context.Context, tx pgx.Tx, employeeNo string) (Employee, error)
func List(ctx context.Context, tx pgx.Tx, f Filter) ([]Employee, error)
func Events(ctx context.Context, tx pgx.Tx, employeeID uuid.UUID) ([]Event, error) // seq order
func SetIdentity(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, kind, number string) (Employee, error)
func Hire(ctx context.Context, tx pgx.Tx, c clock.Clock, in HireInput) (Employee, Event, error) // hired, or rehired after left
func Confirm(ctx context.Context, tx pgx.Tx, c clock.Clock, in ConfirmInput) (Employee, Event, error)
func Transfer(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransferInput) (Employee, Event, error)
func RecordLastDay(ctx context.Context, tx pgx.Tx, c clock.Clock, in LastDayInput) (Employee, Event, error)
func OpenObligations(ctx context.Context, tx pgx.Tx, employeeID uuid.UUID) (Obligations, error)
func GrantLeavingOverride(ctx context.Context, tx pgx.Tx, c clock.Clock, in OverrideInput) (Override, error)
func DueLeavings(ctx context.Context, tx pgx.Tx, c clock.Clock) ([]uuid.UUID, error)
func ApplyLeaving(ctx context.Context, tx pgx.Tx, c clock.Clock, employeeID uuid.UUID) (bool, error)
func ServiceMonths(hireDate, on time.Time) int // whole months completed; 0 before the hire date
// errors: ErrNotFound, ErrNoSuchMember, ErrPartyHasEmployee, ErrPartyNotEmployee, ErrDuplicateEmployeeNo,
// ErrDuplicateIdentity, ErrWrongStatus, ErrNoLogin, ErrNothingToOverride, ErrOverrideByRequester,
// ErrOwnLeaving; *FieldError{Field, Rule, Detail}; *RevisionMismatchError{Current}; *ObligationsError{Obligations}
// River: LeavingsArgs (kind "hr_apply_leavings"), LeavingsPeriodic(), LeavingsWorker{DB, Clock, Logger}
// package hr/hrtest (test fixture)
type Env struct { *advancestest.Env; HR, HRSeat uuid.UUID }
func New(t *testing.T) *Env
func Day(y int, m time.Month, d int) time.Time
func At(d time.Time) clock.Clock // 09:00 in Kuala Lumpur on d
func (e *Env) Hired(t *testing.T, member uuid.UUID, no string) hr.Employee // hired 2026-01-01, permanent, by HR
func (e *Env) LastDay(t *testing.T, id uuid.UUID, noticeOn, lastDay time.Time, by uuid.UUID) hr.Employee
func (e *Env) Override(t *testing.T, id uuid.UUID, lastDay time.Time, by uuid.UUID) hr.Override
func (e *Env) DeactivateByAdmin(t *testing.T, member uuid.UUID)
// package orgstruct
func LockAssignmentsFrom(ctx context.Context, tx pgx.Tx, memberID uuid.UUID, from time.Time) ([]Assignment, error)
// package identity/member
var ErrLastAdministrator error
func IsLastAdministrator(ctx context.Context, tx pgx.Tx, memberID uuid.UUID) (bool, error)
func Deactivate(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID uuid.UUID) error
// package advances
func OpenHeldBy(ctx context.Context, tx pgx.Tx, partyID uuid.UUID) ([]Advance, error)
// package pettycash
func LiveFloatOf(ctx context.Context, tx pgx.Tx, holder uuid.UUID) (Float, error) // ErrNoOpenFloat
// package workflow
func PendingReportedBy(ctx context.Context, tx pgx.Tx, member uuid.UUID) (int, error)

SQL H2/H3 may call: employee_spell(p_org uuid, p_employee uuid) RETURNS TABLE (hire_date date, employment_type text, last_day date, status text).

What each lane does with them:

  • H2 adds vacancy_id to employee_event (its own migration, a composite FK to vacancy) and a VacancyID *uuid.UUID field to HireInput, set through insertEvent — the one writer of employee_event — in Hire. Its headcount trigger on member_position fires inside Transfer (which calls orgstruct.Assign). Transfer pre-locks the target org_position FOR SHARE before any audit row; if H2’s trigger takes that row FOR UPDATE, H2 changes that one line in Transfer to FOR UPDATE so the trigger never waits after audit.Write. position.grade_id is H2’s; an employee’s grade is read from orgstruct.PrimaryPositionOf(member).
  • H3 reads Employee.HireDate, Status and ServiceMonths for grants (only active and notice employees accrue; pending has no hire date), GetByMember to find an approver’s employee record for workflow.Unavailable, and registers that implementation from hr’s side (H3 adds hr → workflow registration; workflow still never imports hr).

Create

Path Responsibility
db/migrations/00800_employee.sql employee, lifecycle_override, employee_event, their guards, employee_spell()
db/migrations/00801_employee_number_move.sql copy member numbers to employee; member column read-only
db/migrations/00802_hr_owner_grants.sql existing Owners get the four hr.employee.* keys
db/migrations/00803_import_employees.sql employees joins import_job_kind_known
internal/platform/hr/{doc,errors,employee,events,obligations,leaving,job}.go the employee record, lifecycle, HD-6 and the leavings job
internal/platform/hr/hrtest/env.go fixture H2/H3 reuse
internal/platform/hr/{schema,migration00801,migration00802,employee,events,leaving,effects}_test.go its tests
internal/platform/orgstruct/lockfrom_test.go LockAssignmentsFrom
internal/platform/identity/member/deactivate.go, deactivate_test.go Deactivate, IsLastAdministrator
internal/platform/advances/openheld.go, openheld_test.go OpenHeldBy (+ workflow.PendingReportedBy’s test)
internal/platform/pettycash/livefloat_test.go LiveFloatOf
internal/platform/workflow/pending.go PendingReportedBy
internal/platform/imports/{employees,employees_test}.go the employees import kind
internal/modules/hr/{permissions,routes,http,errors,handlers}.go HTTP surface
internal/modules/hr/{routes,env,handlers,errors}_test.go its tests
web/src/features/hr/{api.ts,EmployeesPage.tsx,EmployeeDrawer.tsx,NewEmployeeDialog.tsx,LifecycleDialog.tsx,OverrideDialog.tsx,IdentityDialog.tsx,routes.tsx} screens
web/src/features/hr/__tests__/hr.test.tsx screen tests
web/src/lib/i18n/locales/{en,ms,zh}/hr.json screen copy

Modify — internal/kernel/db/{migrationranges,retention}_test.go, internal/platform/orgstruct/assignment.go, internal/platform/pettycash/float.go, internal/platform/imports/imports.go, internal/platform/moduleinstall/moduleinstall.go, internal/app/{app,routes_test}.go, cmd/api/main.go, cmd/worker/{main,exports}.go, cmd/{orgadmin,migrate}/{main,wiring_test}.go, constants.yaml (+ generated internal/kernel/vocab/vocab.gen.go, packages/contracts/vocab.ts), api/openapi.yaml (+ generated internal/gen/api/api.gen.go, web/src/lib/api/schema.d.ts), generated internal/gen/sqlc/models.go, web/src/app/{routes.ts,shell/nav.ts}, locale files modules.json, imports.json (×3), docs/ROADMAP.md, docs/DEPLOY_NOTES.md, docs/LOCK_ORDER.md.


Task 1: The schema (00800) and the one-row-per-employee-party drift test

Section titled “Task 1: The schema (00800) and the one-row-per-employee-party drift test”

Files:

  • Create: db/migrations/00800_employee.sql, internal/platform/hr/doc.go, internal/platform/hr/schema_test.go
  • Modify: internal/kernel/db/migrationranges_test.go, internal/kernel/db/retention_test.go
  • Regenerate: internal/gen/sqlc/models.go (make generate)

Interfaces:

  • Consumes: nothing new.

  • Produces: tables employee, employee_event, lifecycle_override; constraints employee_party_once, employee_no_once, employee_identity_once, employee_event_override_once; trigger constraints employee_party_is_employee, employee_fixed, party_kind_kept_for_employee, employee_event_override_valid; function employee_spell(uuid, uuid); package hr (doc only).

  • Step 1: Declare the migration ranges

In internal/kernel/db/migrationranges_test.go, append to migrationRanges after the {670, 699, ...} entry:

{800, 819, "Phase 5 H1 employee record and lifecycle (2026-09-30-hr-layer-design.md §H1)"},
{820, 839, "Phase 5 H2 grades, bands, headcount, vacancies (2026-09-30-hr-layer-design.md §H2)"},
{840, 859, "Phase 5 H3 leave (2026-09-30-hr-layer-design.md §H3)"},

(Phase 4 O1 Task 1 adds {700, …}–{770, …} at the same place; whichever lands second keeps both blocks, ordered by number.)

  • Step 2: Write the failing schema test

Create internal/platform/hr/doc.go:

// Package hr is the HR layer's employee record (Phase 5 H1,
// docs/superpowers/specs/2026-09-30-hr-layer-design.md): one employee row
// per employee party, separate from the login member (HD-1), with an
// append-only lifecycle (employee_event) from which hire date, employment
// type, last day and status are derived by employee_spell() — never stored
// (Ruling H1-1). Recording a last day is refused while the person holds an
// open advance or petty cash float, unless an Owner who is not the
// requester overrides it (HD-6).
//
// Lock order for every lifecycle write (Ruling H1-14, LOCK_ORDER.md):
// organization FOR UPDATE → employee FOR UPDATE → organization_member FOR
// UPDATE → the target org_position FOR SHARE → member_position rows FOR
// UPDATE in id order → inserts → audit.Write last.
package hr

Create internal/platform/hr/schema_test.go:

package hr_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/expenses/expensestest"
)
func partyOf(t *testing.T, e *expensestest.Env, member uuid.UUID) uuid.UUID {
t.Helper()
var p uuid.UUID
require.NoError(t, e.H.Admin.QueryRow(context.Background(),
`SELECT party_id FROM organization_member WHERE id = $1`, member).Scan(&p))
return p
}
// Spec H1 drift bullet 1: two employee rows for one party, or an employee
// row on a non-employee party, are refused — in the database, whatever the
// Go above it does.
func TestAPartyHasAtMostOneEmployeeRowAndOnlyAnEmployeePartyHasOne(t *testing.T) {
e := expensestest.New(t)
ctx := context.Background()
insert := func(party uuid.UUID, no string) error {
return e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `INSERT INTO employee (org_id, party_id, employee_no, created_at, updated_at)
VALUES ($1, $2, $3, now(), now())`, e.Org, party, no)
return err
})
}
staff := partyOf(t, e, e.Staff)
// PRESENCE: an employee party takes one row.
require.NoError(t, insert(staff, "E-1"))
// ABSENCE: a second row for it is refused, whatever its number.
require.ErrorContains(t, insert(staff, "E-2"), "employee_party_once")
var company uuid.UUID
require.NoError(t, e.H.Admin.QueryRow(ctx,
`INSERT INTO party (org_id, kind, legal_name) VALUES ($1, 'organization', 'Syarikat Contoh Sdn Bhd') RETURNING id`,
e.Org).Scan(&company))
require.ErrorContains(t, insert(company, "E-3"), "employee_party_is_employee")
// A party with no employee row may change kind (PRESENCE); one with a
// row stays an employee party (ABSENCE).
owner := partyOf(t, e, e.Owner)
_, err := e.H.Admin.Exec(ctx, `UPDATE party SET kind = 'individual' WHERE id = $1`, owner)
require.NoError(t, err)
_, err = e.H.Admin.Exec(ctx, `UPDATE party SET kind = 'individual' WHERE id = $1`, staff)
require.ErrorContains(t, err, "party_kind_kept_for_employee")
// The number and the party never change (Ruling H1-3).
_, err = e.H.Admin.Exec(ctx, `UPDATE employee SET employee_no = 'E-9' WHERE party_id = $1`, staff)
require.ErrorContains(t, err, "employee_fixed")
}
// employee_event and lifecycle_override are append-only for the app role.
func TestTheLifecycleIsAppendOnly(t *testing.T) {
e := expensestest.New(t)
staff := partyOf(t, e, e.Staff)
var emp uuid.UUID
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
if err := tx.QueryRow(ctx, `INSERT INTO employee (org_id, party_id, employee_no, created_at, updated_at)
VALUES ($1, $2, 'E-1', now(), now()) RETURNING id`, e.Org, staff).Scan(&emp); err != nil {
return err
}
// PRESENCE: the app role inserts an event.
_, err := tx.Exec(ctx, `INSERT INTO employee_event (org_id, employee_id, kind, effective_on, employment_type,
recorded_by, recorded_at) VALUES ($1, $2, 'hired', DATE '2026-01-01', 'permanent', $3, now())`,
e.Org, emp, e.Owner)
return err
})
// ABSENCE: and cannot change or erase it.
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE employee_event SET effective_on = DATE '2026-02-01' WHERE org_id = $1`, e.Org)
return err
})
require.ErrorContains(t, err, "permission denied")
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `DELETE FROM employee_event WHERE org_id = $1`, e.Org)
return err
})
require.ErrorContains(t, err, "permission denied")
var status string
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
return tx.QueryRow(ctx, `SELECT status FROM employee_spell($1, $2)`, e.Org, emp).Scan(&status)
})
require.Equal(t, "active", status)
}
  • Step 3: Run it to verify it fails

Run: go test ./internal/platform/hr/ -count=1 Expected: FAIL — relation "employee" does not exist.

  • Step 4: Write the migration

Create db/migrations/00800_employee.sql:

-- +goose Up
-- Phase 5 H1 (docs/superpowers/specs/2026-09-30-hr-layer-design.md §H1,
-- HD-1, HD-6): the employee record, keyed to the existing employee party
-- and separate from the login member; its append-only lifecycle, from which
-- status is derived (employee_spell, Ruling H1-1); and the Owner override
-- that lets a last day be recorded while the person still holds an advance
-- or a petty cash float.
CREATE TABLE employee (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
party_id UUID NOT NULL,
-- Supplied by HR and kept for life, across a rehire (Ruling H1-3).
employee_no TEXT NOT NULL CONSTRAINT employee_no_shape
CHECK (employee_no = btrim(employee_no) AND employee_no <> '' AND length(employee_no) <= 32),
-- Personal data (PDPA): shown only to hr.employee.sensitive (Ruling H1-5).
id_kind TEXT CONSTRAINT employee_id_kind_known CHECK (id_kind IN ('nric', 'passport', 'other')),
id_number TEXT,
revision INT NOT NULL DEFAULT 1 CONSTRAINT employee_revision_positive CHECK (revision >= 1),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT employee_tenant_id UNIQUE (org_id, id),
CONSTRAINT employee_party_once UNIQUE (org_id, party_id),
CONSTRAINT employee_no_once UNIQUE (org_id, employee_no),
CONSTRAINT employee_identity_once UNIQUE (org_id, id_kind, id_number),
CONSTRAINT employee_identity_complete CHECK ((id_kind IS NULL) = (id_number IS NULL)),
CONSTRAINT employee_identity_shape CHECK (
id_kind IS NULL
OR (id_kind = 'nric' AND id_number ~ '^[0-9]{12}$')
OR (id_kind = 'passport' AND id_number ~ '^[A-Z0-9]{5,20}$')
OR (id_kind = 'other' AND id_number = btrim(id_number) AND length(id_number) BETWEEN 1 AND 40
AND id_number !~ '[぀-ヿ㐀-䶿一-鿿가-힯]')),
CONSTRAINT employee_party_fk FOREIGN KEY (org_id, party_id) REFERENCES party (org_id, id)
);
ALTER TABLE employee ENABLE ROW LEVEL SECURITY;
ALTER TABLE employee FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON employee
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- Amended (an identity correction), never erased: it is what every
-- lifecycle event and every leave grant (H3) names.
REVOKE DELETE ON employee FROM erp_app;
-- One row per EMPLOYEE party (spec H1), and the party and number never move.
-- +goose StatementBegin
CREATE FUNCTION employee_party_is_employee() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF TG_OP = 'UPDATE' AND (NEW.party_id IS DISTINCT FROM OLD.party_id
OR NEW.employee_no IS DISTINCT FROM OLD.employee_no) THEN
RAISE EXCEPTION 'employee %: the party and the employee number never change', OLD.id
USING ERRCODE = '23514', CONSTRAINT = 'employee_fixed';
END IF;
IF TG_OP = 'INSERT' AND NOT EXISTS (SELECT 1 FROM public.party p
WHERE p.org_id = NEW.org_id AND p.id = NEW.party_id AND p.kind = 'employee') THEN
RAISE EXCEPTION 'party % is not an employee party', NEW.party_id
USING ERRCODE = '23514', CONSTRAINT = 'employee_party_is_employee';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER employee_party_is_employee BEFORE INSERT OR UPDATE ON employee
FOR EACH ROW EXECUTE FUNCTION employee_party_is_employee();
-- +goose StatementBegin
CREATE FUNCTION party_kind_kept_for_employee() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF EXISTS (SELECT 1 FROM public.employee e WHERE e.org_id = NEW.org_id AND e.party_id = NEW.id) THEN
RAISE EXCEPTION 'party % has an employee record, so it stays an employee party', NEW.id
USING ERRCODE = '23514', CONSTRAINT = 'party_kind_kept_for_employee';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER party_kind_kept_for_employee BEFORE UPDATE OF kind ON party
FOR EACH ROW WHEN (OLD.kind = 'employee' AND NEW.kind <> 'employee')
EXECUTE FUNCTION party_kind_kept_for_employee();
-- HD-6: an Owner's leave to record one last day while the person still
-- holds an advance or a float (Ruling H1-7). covers is what was open when
-- it was granted; sole_owner marks the one-member organization's exception
-- (Ruling H1-8).
CREATE TABLE lifecycle_override (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
employee_id UUID NOT NULL,
last_day DATE NOT NULL,
reason TEXT NOT NULL CONSTRAINT lifecycle_override_reason_shape
CHECK (reason = btrim(reason) AND reason <> '' AND length(reason) <= 500
AND reason !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'),
covers JSONB NOT NULL CONSTRAINT lifecycle_override_covers_object CHECK (jsonb_typeof(covers) = 'object'),
granted_by UUID NOT NULL,
sole_owner BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT lifecycle_override_tenant_id UNIQUE (org_id, id),
CONSTRAINT lifecycle_override_employee_fk FOREIGN KEY (org_id, employee_id) REFERENCES employee (org_id, id),
CONSTRAINT lifecycle_override_granted_by_fk FOREIGN KEY (org_id, granted_by) REFERENCES organization_member (org_id, id)
);
ALTER TABLE lifecycle_override ENABLE ROW LEVEL SECURITY;
ALTER TABLE lifecycle_override FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON lifecycle_override
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
REVOKE UPDATE, DELETE ON lifecycle_override FROM erp_app;
-- The lifecycle (spec H1), append-only: status is derived from it and
-- nothing is edited in place. seq is recording order, which is what
-- employee_spell reads "latest" by.
CREATE TABLE employee_event (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
employee_id UUID NOT NULL,
seq BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY,
kind TEXT NOT NULL CONSTRAINT employee_event_kind_known
CHECK (kind IN ('hired', 'confirmed', 'transferred', 'notice_given', 'left', 'rehired')),
-- hired/rehired: the first day of work; confirmed, transferred: the day
-- it takes effect; notice_given: the day notice was given; left: the last day.
effective_on DATE NOT NULL,
last_day DATE,
employment_type TEXT CONSTRAINT employee_event_employment_type_known
CHECK (employment_type IN ('permanent', 'contract', 'probation', 'part_time')),
from_position_id UUID,
to_position_id UUID,
override_id UUID,
reason TEXT NOT NULL DEFAULT '' CONSTRAINT employee_event_reason_shape
CHECK (reason = btrim(reason) AND length(reason) <= 500
AND reason !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'),
-- NULL only on left, which the system writes (Ruling H1-2).
recorded_by UUID,
recorded_at TIMESTAMPTZ NOT NULL,
CONSTRAINT employee_event_tenant_id UNIQUE (org_id, id),
CONSTRAINT employee_event_seq_once UNIQUE (seq),
CONSTRAINT employee_event_override_once UNIQUE (override_id),
CONSTRAINT employee_event_shape CHECK (
(kind IN ('hired', 'rehired', 'confirmed')) = (employment_type IS NOT NULL)
AND (kind = 'notice_given') = (last_day IS NOT NULL)
AND (kind = 'transferred') = (to_position_id IS NOT NULL)
AND (from_position_id IS NULL OR kind = 'transferred')
AND (override_id IS NULL OR kind = 'notice_given')
AND (kind = 'left' OR recorded_by IS NOT NULL)),
CONSTRAINT employee_event_notice_order CHECK (last_day IS NULL OR last_day >= effective_on),
CONSTRAINT employee_event_employee_fk FOREIGN KEY (org_id, employee_id) REFERENCES employee (org_id, id),
CONSTRAINT employee_event_from_position_fk FOREIGN KEY (org_id, from_position_id) REFERENCES org_position (org_id, id),
CONSTRAINT employee_event_to_position_fk FOREIGN KEY (org_id, to_position_id) REFERENCES org_position (org_id, id),
CONSTRAINT employee_event_override_fk FOREIGN KEY (org_id, override_id) REFERENCES lifecycle_override (org_id, id),
CONSTRAINT employee_event_recorded_by_fk FOREIGN KEY (org_id, recorded_by) REFERENCES organization_member (org_id, id)
);
CREATE INDEX employee_event_by_employee ON employee_event (org_id, employee_id, seq);
ALTER TABLE employee_event ENABLE ROW LEVEL SECURITY;
ALTER TABLE employee_event FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON employee_event
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
REVOKE UPDATE, DELETE ON employee_event FROM erp_app;
GRANT USAGE ON SEQUENCE employee_event_seq_seq TO erp_app;
-- HD-6 / Ruling H1-7: an override covers one employee's one last day, once,
-- and never for the member who granted it — except the one-member
-- organization's own exception (Ruling H1-8).
-- +goose StatementBegin
CREATE FUNCTION employee_event_override_guard() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE
o public.lifecycle_override%ROWTYPE;
BEGIN
SELECT * INTO o FROM public.lifecycle_override lo WHERE lo.org_id = NEW.org_id AND lo.id = NEW.override_id;
IF NOT FOUND OR o.employee_id <> NEW.employee_id OR o.last_day <> NEW.last_day
OR (o.granted_by = NEW.recorded_by AND NOT o.sole_owner) THEN
RAISE EXCEPTION 'override % does not cover this last day, or was granted by the member recording it', NEW.override_id
USING ERRCODE = '23514', CONSTRAINT = 'employee_event_override_valid';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER employee_event_override_guard BEFORE INSERT ON employee_event
FOR EACH ROW WHEN (NEW.override_id IS NOT NULL) EXECUTE FUNCTION employee_event_override_guard();
-- Ruling H1-1: the one definition of hire date, employment type, last day
-- and status. The current spell starts at the latest hired/rehired.
-- +goose StatementBegin
CREATE FUNCTION employee_spell(p_org uuid, p_employee uuid)
RETURNS TABLE (hire_date date, employment_type text, last_day date, status text)
LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$
WITH ev AS (
SELECT x.seq, x.kind, x.effective_on, x.last_day, x.employment_type
FROM public.employee_event x
WHERE x.org_id = p_org AND x.employee_id = p_employee
), spell AS (
SELECT max(ev.seq) AS seq FROM ev WHERE ev.kind IN ('hired', 'rehired')
)
SELECT (SELECT ev.effective_on FROM ev WHERE ev.seq = spell.seq),
(SELECT ev.employment_type FROM ev
WHERE ev.seq >= spell.seq AND ev.kind IN ('hired', 'rehired', 'confirmed')
ORDER BY ev.seq DESC LIMIT 1),
coalesce(
(SELECT ev.effective_on FROM ev WHERE ev.seq > spell.seq AND ev.kind = 'left'
ORDER BY ev.seq DESC LIMIT 1),
(SELECT ev.last_day FROM ev WHERE ev.seq > spell.seq AND ev.kind = 'notice_given'
ORDER BY ev.seq DESC LIMIT 1)),
CASE
WHEN spell.seq IS NULL THEN 'pending'
WHEN EXISTS (SELECT 1 FROM ev WHERE ev.seq > spell.seq AND ev.kind = 'left') THEN 'left'
WHEN EXISTS (SELECT 1 FROM ev WHERE ev.seq > spell.seq AND ev.kind = 'notice_given') THEN 'notice'
ELSE 'active'
END
FROM spell
$$;
-- +goose StatementEnd
-- +goose Down
DROP FUNCTION employee_spell(uuid, uuid);
DROP TABLE employee_event;
DROP FUNCTION employee_event_override_guard();
DROP TABLE lifecycle_override;
DROP TRIGGER party_kind_kept_for_employee ON party;
DROP FUNCTION party_kind_kept_for_employee();
DROP TABLE employee;
DROP FUNCTION employee_party_is_employee();
  • Step 5: Declare the retention of the three tables

In internal/kernel/db/retention_test.go, add to appendOnly after "tax_return_line",:

// Phase 5 H1 (00800): the employee lifecycle and the HD-6 overrides
// are the record of who was employed when and who allowed a last day
// over an open advance; a correction is a later event.
"employee_event",
"lifecycle_override",

and to amendable after "bank_statement",:

// Phase 5 H1 (00800): the employee record. Its identity document is
// corrected in place (audited); it is never erased — every event and
// every leave grant names it.
"employee",
  • Step 6: Run the tests

Run: go test ./internal/platform/hr/ ./internal/kernel/db/ -count=1 Expected: PASS (including TestEveryTableIsTenantIsolated, TestEveryTenantForeignKeyIsCompositeOnOrgID, TestEveryConstraintIsNamed, TestEveryFunctionPinsItsSearchPath, TestOnlyOperationalTablesGrantDeleteToTheAppRole, TestEveryMigrationIsNumberedInItsOwnersRange).

  • Step 7: Watch the drift test fail once

In 00800_employee.sql, comment out the line CONSTRAINT employee_party_once UNIQUE (org_id, party_id), and run go test ./internal/platform/hr/ -run TestAPartyHasAtMostOneEmployeeRow -count=1. Expected: FAIL — An error is expected but got nil at the employee_party_once assertion. Restore the line; run again; PASS.

Then in employee_party_is_employee() change AND p.kind = 'employee' to AND true, run the same test. Expected: FAIL — An error is expected but got nil at the employee_party_is_employee assertion. Restore; PASS.

  • Step 8: Regenerate sqlc’s models

Run: make generate Expected: internal/gen/sqlc/models.go gains Employee, EmployeeEvent, LifecycleOverride; nothing else changes. go test ./internal/gen/ -count=1 PASS.

  • Step 9: Commit
Terminal window
git add db/migrations/00800_employee.sql internal/platform/hr internal/kernel/db/migrationranges_test.go \
internal/kernel/db/retention_test.go internal/gen/sqlc
git commit -m "Phase 5 H1: employee, employee_event, lifecycle_override and employee_spell
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Files:

  • Create: db/migrations/00801_employee_number_move.sql, internal/platform/hr/migration00801_test.go

Interfaces:

  • Consumes: Task 1’s employee table.

  • Produces: one employee row (no events, status pending) per member with a usable number; trigger constraint organization_member_employee_no_read_only.

  • Step 1: Write the failing migration test

Create internal/platform/hr/migration00801_test.go:

package hr_test
import (
"context"
"database/sql"
"testing"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db/testdb"
)
// 00801 (spec H1, Rulings H1-3, H1-4): each member's number is copied to an
// employee row for its party, once per number, with no hire; the member
// column becomes read-only; the Down lets it change again.
func TestMigration00801MovesTheEmployeeNumber(t *testing.T) {
h := testdb.StartAt(t, 800)
ctx := context.Background()
org := uuid.New()
_, err := h.Admin.Exec(ctx, `INSERT INTO organization (id, org_id, name) VALUES ($1,$1,'Alpha')`, org)
require.NoError(t, err)
seed := func(name string, no *string, created string) (party, member uuid.UUID) {
require.NoError(t, h.Admin.QueryRow(ctx,
`INSERT INTO party (org_id, kind, legal_name) VALUES ($1,'employee',$2) RETURNING id`, org, name).Scan(&party))
require.NoError(t, h.Admin.QueryRow(ctx, `INSERT INTO organization_member (org_id, user_id, party_id, employee_no, created_at)
VALUES ($1,$2,$3,$4,$5::timestamptz) RETURNING id`, org, uuid.New(), party, no, created).Scan(&member))
return party, member
}
s := func(v string) *string { return &v }
first, firstMember := seed("Aminah", s("E-001"), "2026-01-01T00:00:00Z")
second, _ := seed("Badrul", s("E-001"), "2026-02-01T00:00:00Z") // the same number, later
padded, _ := seed("Chong Wei", s(" E-003 "), "2026-03-01T00:00:00Z")
none, _ := seed("Devi", nil, "2026-04-01T00:00:00Z")
number := func(party uuid.UUID) (string, bool) {
var no string
err := h.Admin.QueryRow(ctx, `SELECT employee_no FROM employee WHERE party_id = $1`, party).Scan(&no)
if err != nil {
return "", false
}
return no, true
}
_, found := number(first)
require.False(t, found, "ABSENCE before the migration")
conn, err := sql.Open("pgx", h.AdminDSN)
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
require.NoError(t, goose.SetDialect("postgres"))
require.NoError(t, goose.UpTo(conn, testdb.MigrationsDir(t), 801, goose.WithAllowMissing()))
no, found := number(first)
require.True(t, found, "PRESENCE: the first holder of E-001 has a record")
require.Equal(t, "E-001", no)
_, found = number(second)
require.False(t, found, "the later duplicate is left for HR")
no, _ = number(padded)
require.Equal(t, "E-003", no, "trimmed")
_, found = number(none)
require.False(t, found, "no number, no record")
var events int
require.NoError(t, h.Admin.QueryRow(ctx, `SELECT count(*) FROM employee_event`).Scan(&events))
require.Zero(t, events, "Ruling H1-4: the copy records no hire")
// The member column: the same value may be written (PRESENCE), a
// different one is refused (ABSENCE).
_, err = h.Admin.Exec(ctx, `UPDATE organization_member SET employee_no = 'E-001' WHERE id = $1`, firstMember)
require.NoError(t, err)
_, err = h.Admin.Exec(ctx, `UPDATE organization_member SET employee_no = 'E-777' WHERE id = $1`, firstMember)
require.ErrorContains(t, err, "organization_member_employee_no_read_only")
require.NoError(t, goose.Down(conn, testdb.MigrationsDir(t)))
_, err = h.Admin.Exec(ctx, `UPDATE organization_member SET employee_no = 'E-777' WHERE id = $1`, firstMember)
require.NoError(t, err, "the Down lifts the read-only rule")
}
  • Step 2: Run it to verify it fails

Run: go test ./internal/platform/hr/ -run TestMigration00801 -count=1 Expected: FAIL — goose reports no migration 801 (UpTo leaves version 800; the PRESENCE assertion fails).

  • Step 3: Write the migration

Create db/migrations/00801_employee_number_move.sql:

-- +goose Up
-- Phase 5 H1 (spec H1 "employee_no moves to employee"; Rulings H1-3, H1-4).
-- Each member's number is copied to an employee row for its party. The copy
-- records no hire: the day a member was invited is not the day they were
-- hired, and a wrong hire date would grant the wrong leave; the record reads
-- "pending" until HR records the hire. A number two members of one
-- organization share is copied once, for the member who had it first; the
-- rest are for HR (DEPLOY_NOTES). Runs as the migration role, which
-- bypasses RLS.
INSERT INTO employee (org_id, party_id, employee_no, created_at, updated_at)
SELECT DISTINCT ON (m.org_id, btrim(m.employee_no))
m.org_id, m.party_id, btrim(m.employee_no), now(), now()
FROM organization_member m
JOIN party p ON p.org_id = m.org_id AND p.id = m.party_id
WHERE p.kind = 'employee'
AND m.employee_no IS NOT NULL AND btrim(m.employee_no) <> '' AND length(btrim(m.employee_no)) <= 32
ORDER BY m.org_id, btrim(m.employee_no), m.created_at, m.id
ON CONFLICT DO NOTHING;
-- The member column stays, read-only, so nothing that reads it breaks. An
-- INSERT may still carry a value (no application path writes one); a change
-- of value is refused.
-- +goose StatementBegin
CREATE FUNCTION organization_member_employee_no_read_only() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
RAISE EXCEPTION 'member %: the employee number is kept on the employee record now; this column is read-only', OLD.id
USING ERRCODE = '23514', CONSTRAINT = 'organization_member_employee_no_read_only';
END $$;
-- +goose StatementEnd
CREATE TRIGGER organization_member_employee_no_read_only BEFORE UPDATE OF employee_no ON organization_member
FOR EACH ROW WHEN (OLD.employee_no IS DISTINCT FROM NEW.employee_no)
EXECUTE FUNCTION organization_member_employee_no_read_only();
-- +goose Down
-- The copied rows go with 00800's Down (DROP TABLE employee).
DROP TRIGGER organization_member_employee_no_read_only ON organization_member;
DROP FUNCTION organization_member_employee_no_read_only();
  • Step 4: Run the tests

Run: go test ./internal/platform/hr/ ./internal/platform/identity/member/ ./internal/kernel/db/ -count=1 Expected: PASS. (member_test.go’s fixtures still INSERT an employee_no, which is allowed; TestSelfMembershipPolicyRefusesWriteEvenWithADifferentCurrentOrgSet’s UPDATE finds no row under RLS, so the trigger never fires.)

  • Step 5: Watch it fail once

Delete the WHEN (OLD.employee_no IS DISTINCT FROM NEW.employee_no) line and run the test. Expected: FAIL at the PRESENCE update (organization_member_employee_no_read_only raised for the same value). Restore. Then change ORDER BY m.org_id, btrim(m.employee_no), m.created_at, m.id to ORDER BY m.org_id, btrim(m.employee_no), m.created_at DESC, m.id. Expected: FAIL — the first holder of E-001 has no record (the later one won). Restore; PASS.

  • Step 6: Commit
Terminal window
git add db/migrations/00801_employee_number_move.sql internal/platform/hr/migration00801_test.go
git commit -m "Phase 5 H1: employee numbers move to employee; the member column is read-only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 3: The seams HR stands on (orgstruct, member, advances, petty cash, workflow)

Section titled “Task 3: The seams HR stands on (orgstruct, member, advances, petty cash, workflow)”

Five small exported reads and one write, each in the package that owns the table, so platform/hr never queries another package’s tables.

Files:

  • Modify: internal/platform/orgstruct/assignment.go — Test: internal/platform/orgstruct/lockfrom_test.go
  • Create: internal/platform/identity/member/deactivate.go, internal/platform/identity/member/deactivate_test.go
  • Create: internal/platform/advances/openheld.go, internal/platform/advances/openheld_test.go
  • Modify: internal/platform/pettycash/float.go — Test: internal/platform/pettycash/livefloat_test.go
  • Create: internal/platform/workflow/pending.go (tested in openheld_test.go)

Interfaces:

  • Consumes: nothing new.

  • Produces:

    • orgstruct.LockAssignmentsFrom(ctx context.Context, tx pgx.Tx, memberID uuid.UUID, from time.Time) ([]orgstruct.Assignment, error)
    • member.ErrLastAdministrator; member.IsLastAdministrator(ctx context.Context, tx pgx.Tx, memberID uuid.UUID) (bool, error); member.Deactivate(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID uuid.UUID) error
    • advances.OpenHeldBy(ctx context.Context, tx pgx.Tx, partyID uuid.UUID) ([]advances.Advance, error)
    • pettycash.LiveFloatOf(ctx context.Context, tx pgx.Tx, holder uuid.UUID) (pettycash.Float, error)
    • workflow.PendingReportedBy(ctx context.Context, tx pgx.Tx, member uuid.UUID) (int, error)
  • Step 1: Write the failing tests

Create internal/platform/orgstruct/lockfrom_test.go:

package orgstruct_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/orgstruct"
)
// Phase 5 H1: the leaving path locks every assignment in force on or after
// the day after the last day, before it writes any audit row.
func TestLockAssignmentsFromReturnsWhatIsInForceOnOrAfterTheDay(t *testing.T) {
f := setup(t)
m := f.newMember(t, "Farah")
past := f.mustAssign(t, orgstruct.AssignmentInput{MemberID: m, PositionID: f.newPosition(t, "P-PAST", "Clerk"),
ValidFrom: jan1, ValidTo: ptr(apr1)})
current := f.mustAssign(t, orgstruct.AssignmentInput{MemberID: m, PositionID: f.newPosition(t, "P-NOW", "Officer"),
IsPrimary: true, ValidFrom: apr1})
later := f.mustAssign(t, orgstruct.AssignmentInput{MemberID: m, PositionID: f.newPosition(t, "P-LATER", "Lead"),
ValidFrom: oct1})
var ids []uuid.UUID
require.NoError(t, f.d.InTx(f.ctx, func(ctx context.Context, tx pgx.Tx) error {
got, err := orgstruct.LockAssignmentsFrom(ctx, tx, m, jul1)
for _, a := range got {
ids = append(ids, a.ID)
}
return err
}))
require.ElementsMatch(t, []uuid.UUID{current.ID, later.ID}, ids,
"PRESENCE: the seat held on the day and the one starting after it")
require.NotContains(t, ids, past.ID, "ABSENCE: an assignment that ended before the day")
}

Create internal/platform/identity/member/deactivate_test.go:

package member_test
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/platform/identity/member"
)
func grantRolesManage(t *testing.T, admin *pgxpool.Pool, org, memberID uuid.UUID) {
t.Helper()
ctx := context.Background()
var role uuid.UUID
require.NoError(t, admin.QueryRow(ctx,
`INSERT INTO role (org_id, name, system) VALUES ($1, 'Admins', false) RETURNING id`, org).Scan(&role))
_, err := admin.Exec(ctx, `INSERT INTO role_permission (org_id, role_id, permission_key) VALUES ($1, $2, 'org.roles.manage')`, org, role)
require.NoError(t, err)
_, err = admin.Exec(ctx, `INSERT INTO member_role (org_id, member_id, role_id) VALUES ($1, $2, $3)`, org, memberID, role)
require.NoError(t, err)
}
// Phase 5 H1 (Ruling H1-9): the one way a member stops being able to act in
// an organization — and never the last member who can manage roles.
func TestDeactivateEndsTheMembershipButNeverTheLastAdministrator(t *testing.T) {
d, h, ctx, org := memberSetup(t)
admin := seedMember(t, h.Admin, org, seedUser(t, h.Admin, "admin@example.com"), "E-1")
clerk := seedMember(t, h.Admin, org, seedUser(t, h.Admin, "clerk@example.com"), "E-2")
grantRolesManage(t, h.Admin, org, admin)
var hooked uuid.UUID
member.OnDeactivate("test.deactivate", func(_ context.Context, _ pgx.Tx, _ clock.Clock, m uuid.UUID) error {
hooked = m
return nil
})
t.Cleanup(func() { member.UnregisterDeactivationHookForTest("test.deactivate") })
c := clock.Fixed(time.Date(2026, 9, 21, 9, 0, 0, 0, time.UTC))
deactivate := func(m uuid.UUID) error {
return d.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { return member.Deactivate(ctx, tx, c, m) })
}
active := func(m uuid.UUID) bool {
var a bool
require.NoError(t, h.Admin.QueryRow(context.Background(),
`SELECT active FROM organization_member WHERE id = $1`, m).Scan(&a))
return a
}
version := func() int64 {
var v int64
require.NoError(t, h.Admin.QueryRow(context.Background(),
`SELECT perm_version FROM organization WHERE id = $1`, org).Scan(&v))
return v
}
// PRESENCE: the clerk's membership ends, the hooks run, tokens are stale.
before := version()
require.NoError(t, deactivate(clerk))
require.False(t, active(clerk))
require.Equal(t, clerk, hooked)
require.Greater(t, version(), before)
require.NoError(t, deactivate(clerk), "already inactive: nothing to do")
// ABSENCE: the last member who can manage roles stays.
require.ErrorIs(t, deactivate(admin), member.ErrLastAdministrator)
require.True(t, active(admin))
}

Create internal/platform/pettycash/livefloat_test.go:

package pettycash_test
import (
"context"
"testing"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/pettycash"
"github.com/mcom/erp/internal/platform/pettycash/pettycashtest"
)
// Phase 5 H1 (HD-6): the float a member holds that is not closed.
func TestLiveFloatOfIsTheHoldersFloatThatIsNotClosed(t *testing.T) {
e := pettycashtest.New(t)
f := e.Float(t, e.Cashier)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
got, err := pettycash.LiveFloatOf(ctx, tx, e.Cashier)
require.NoError(t, err)
require.Equal(t, f.ID, got.ID, "PRESENCE: the cashier's open float")
_, err = pettycash.LiveFloatOf(ctx, tx, e.Staff)
require.ErrorIs(t, err, pettycash.ErrNoOpenFloat, "ABSENCE: the staff member holds none")
return nil
})
}

Create internal/platform/advances/openheld_test.go:

package advances_test
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money"
"github.com/mcom/erp/internal/platform/advances"
"github.com/mcom/erp/internal/platform/advances/advancestest"
"github.com/mcom/erp/internal/platform/workflow"
)
// Phase 5 H1 (HD-6, Ruling H1-6): an advance is open from submission until
// it is accounted for; a draft and a rejection hold nothing. Also
// workflow.PendingReportedBy, which counts the member's own documents
// awaiting approval.
func TestOpenHeldByListsWhatThePartyStillHolds(t *testing.T) {
e := advancestest.New(t)
e.UseAdvancePoster(t)
var party uuid.UUID
require.NoError(t, e.H.Admin.QueryRow(context.Background(),
`SELECT party_id FROM organization_member WHERE id = $1`, e.Staff).Scan(&party))
held := func() []uuid.UUID {
var ids []uuid.UUID
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
as, err := advances.OpenHeldBy(ctx, tx, party)
for _, a := range as {
ids = append(ids, a.ID)
}
return err
})
return ids
}
pending := func() int {
var n int
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
n, err = workflow.PendingReportedBy(ctx, tx, e.Staff)
return err
})
return n
}
var draft advances.Advance
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
draft, err = advances.CreateAdvance(ctx, tx, e.Clock, advances.AdvanceInput{
ClaimantID: e.Staff, Purpose: "Training in Penang", Currency: "MYR",
Amount: money.MustParse("200.00"), DueOn: time.Date(2026, 10, 30, 0, 0, 0, 0, time.UTC)})
return err
})
require.Empty(t, held(), "a draft holds nothing")
require.Zero(t, pending())
e.Move(t, draft.ID, advancestest.Step{From: "draft", To: "pending_review", Actor: e.Staff})
require.Equal(t, []uuid.UUID{draft.ID}, held(), "PRESENCE: submitted, it is open")
require.Equal(t, 1, pending(), "and awaiting approval")
received := e.Received(t, "300.00")
require.ElementsMatch(t, []uuid.UUID{draft.ID, received.ID}, held(), "paid out and received: open")
e.Move(t, draft.ID, advancestest.Step{From: "pending_review", To: "rejected", Actor: e.Owner, Reason: "Not needed"})
require.Equal(t, []uuid.UUID{received.ID}, held(), "ABSENCE: a rejected advance holds nothing")
require.Zero(t, pending(), "received is not awaiting approval")
}
  • Step 2: Run them to verify they fail

Run: go test ./internal/platform/orgstruct/ ./internal/platform/identity/member/ ./internal/platform/pettycash/ ./internal/platform/advances/ -run 'LockAssignmentsFrom|Deactivate|LiveFloatOf|OpenHeldBy' -count=1 Expected: FAIL to compile — undefined: orgstruct.LockAssignmentsFrom, member.Deactivate, pettycash.LiveFloatOf, advances.OpenHeldBy, workflow.PendingReportedBy.

  • Step 3: Write the seams

In internal/platform/orgstruct/assignment.go, add after AssignmentsForMember:

// LockAssignmentsFrom locks, in id order, every assignment of the member
// that is in force on from or starts after it (withdrawn ones excluded),
// and returns them. HR's leaving and transfer paths (Phase 5 H1) call it
// before they write any audit row, so the closes and withdrawals that
// follow re-lock rows this transaction already holds and take no new row
// lock after audit.Write (LOCK_ORDER.md, "Rules for every module").
func LockAssignmentsFrom(ctx context.Context, tx pgx.Tx, memberID uuid.UUID, from time.Time) ([]Assignment, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("orgstruct: %w", tenancy.ErrNoOrg)
}
rows, err := tx.Query(ctx, `
SELECT id, member_id, position_id, is_primary, valid_from, valid_to
FROM member_position
WHERE org_id = $1 AND member_id = $2 AND withdrawn_at IS NULL
AND (valid_to IS NULL OR valid_to > $3)
ORDER BY id
FOR UPDATE`, orgID, memberID, from)
if err != nil {
return nil, fmt.Errorf("orgstruct: lock assignments from: %w", err)
}
defer rows.Close()
var out []Assignment
for rows.Next() {
var a Assignment
if err := rows.Scan(&a.ID, &a.MemberID, &a.PositionID, &a.IsPrimary, &a.ValidFrom, &a.ValidTo); err != nil {
return nil, fmt.Errorf("orgstruct: lock assignments from: %w", err)
}
out = append(out, a)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("orgstruct: lock assignments from: %w", err)
}
return out, nil
}

Create internal/platform/identity/member/deactivate.go:

package member
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/tenancy"
)
// ErrLastAdministrator: deactivating the member would leave the
// organization with no active member who can manage roles, and so nobody
// who could ever grant anything again.
var ErrLastAdministrator = errors.New("member: this is the organization's last active member who can manage roles")
// rolesManage is identity/role.PermissionManage, spelled out: role imports
// this package.
const rolesManage = "org.roles.manage"
// IsLastAdministrator reports whether memberID is the one active member who
// holds org.roles.manage. When nobody holds it, nobody is the last.
func IsLastAdministrator(ctx context.Context, tx pgx.Tx, memberID uuid.UUID) (bool, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return false, fmt.Errorf("member: last administrator: %w", tenancy.ErrNoOrg)
}
rows, err := tx.Query(ctx, `
SELECT DISTINCT m.id
FROM organization_member m
JOIN member_role mr ON mr.org_id = m.org_id AND mr.member_id = m.id
JOIN role_permission rp ON rp.org_id = mr.org_id AND rp.role_id = mr.role_id
WHERE m.org_id = $1 AND m.active AND rp.permission_key = $2`, orgID, rolesManage)
if err != nil {
return false, fmt.Errorf("member: last administrator: %w", err)
}
holders, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
if err != nil {
return false, fmt.Errorf("member: last administrator: %w", err)
}
return len(holders) == 1 && holders[0] == memberID, nil
}
// Deactivate ends memberID's access to the organization (Phase 5 H1, Ruling
// H1-9): active = false, the permission version bumped, the deactivation
// hooks run in this transaction, and an audit row. The user's sessions are
// not revoked — they span every organization the user belongs to — and the
// liveness check refuses this membership from now on. Already inactive:
// nothing to do. The organization's last administrator is refused.
//
// Lock order: the member row FOR UPDATE, then the organization row (the
// version bump), then audit.Write last.
func Deactivate(ctx context.Context, tx pgx.Tx, c clock.Clock, memberID uuid.UUID) error {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return fmt.Errorf("member: deactivate: %w", tenancy.ErrNoOrg)
}
var active bool
err := tx.QueryRow(ctx,
`SELECT active FROM organization_member WHERE org_id = $1 AND id = $2 FOR UPDATE`, orgID, memberID).Scan(&active)
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotAMember
}
if err != nil {
return fmt.Errorf("member: deactivate: %w", err)
}
if !active {
return nil
}
last, err := IsLastAdministrator(ctx, tx, memberID)
if err != nil {
return err
}
if last {
return ErrLastAdministrator
}
if _, err := tx.Exec(ctx,
`UPDATE organization_member SET active = false WHERE org_id = $1 AND id = $2`, orgID, memberID); err != nil {
return fmt.Errorf("member: deactivate: %w", err)
}
if err := BumpPermVersion(ctx, tx); err != nil {
return err
}
if err := RunDeactivationHooks(ctx, tx, c, memberID); err != nil {
return err
}
return audit.Write(ctx, tx, c, audit.Entry{
EntityKind: "organization_member", EntityID: memberID, Action: "deactivate",
Before: map[string]any{"active": true}, After: map[string]any{"active": false},
})
}

Create internal/platform/advances/openheld.go:

package advances
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/tenancy"
)
// OpenHeldBy lists the advances party still holds (Phase 5 H1, HD-6, Ruling
// H1-6): any of whose instances — the advance, its top-up or its refund
// leg — is in a state that is not terminal and is not a draft or a
// rejection. Money is out, or approved to go out, and not yet accounted for.
func OpenHeldBy(ctx context.Context, tx pgx.Tx, partyID uuid.UUID) ([]Advance, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return nil, fmt.Errorf("advances: open held by: %w", tenancy.ErrNoOrg)
}
rows, err := tx.Query(ctx, advanceSelect+`a.holder_party_id = $2 AND EXISTS (
SELECT 1 FROM workflow_instance li
JOIN workflow_state ls ON ls.org_id = li.org_id AND ls.definition_id = li.definition_id
AND ls.state_key = li.state_key
WHERE li.org_id = a.org_id AND li.id IN (a.instance_id, a.topup_instance_id, a.refund_instance_id)
AND NOT ls.is_terminal AND ls.semantic NOT IN ('draft', 'rejected'))
ORDER BY a.reference`, orgID, partyID)
if err != nil {
return nil, fmt.Errorf("advances: open held by: %w", err)
}
defer rows.Close()
var out []Advance
for rows.Next() {
a, err := scanAdvance(rows)
if err != nil {
return nil, fmt.Errorf("advances: open held by: %w", err)
}
out = append(out, a)
}
return out, rows.Err()
}

In internal/platform/pettycash/float.go, add after liveFloatOf:

// LiveFloatOf is the float holder holds that is not closed, read without a
// lock; ErrNoOpenFloat when there is none. HR's last-day check reads it
// (Phase 5 H1, HD-6).
func LiveFloatOf(ctx context.Context, tx pgx.Tx, holder uuid.UUID) (Float, error) {
return liveFloatOf(ctx, tx, holder, false)
}

Create internal/platform/workflow/pending.go:

package workflow
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// PendingReportedBy counts the member's own documents whose instance is in
// a pending-semantic state: awaiting somebody's approval. HR's last-day
// check reports it as a warning (Phase 5 H1, Ruling H1-11): a
// superior-relative grant resolves against the reporter's seat on the
// decision day, and after their last day a leaver has none.
func PendingReportedBy(ctx context.Context, tx pgx.Tx, member uuid.UUID) (int, error) {
org, err := orgOf(ctx)
if err != nil {
return 0, err
}
var n int
if err := tx.QueryRow(ctx, `
SELECT count(*) FROM workflow_instance i
JOIN workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id
AND s.state_key = i.state_key
WHERE i.org_id = $1 AND i.reporter_member_id = $2 AND s.semantic = 'pending'`, org, member).Scan(&n); err != nil {
return 0, fmt.Errorf("workflow: pending reported by: %w", err)
}
return n, nil
}
  • Step 4: Run the tests

Run: go test ./internal/platform/orgstruct/ ./internal/platform/identity/member/ ./internal/platform/pettycash/ ./internal/platform/advances/ ./internal/platform/workflow/ ./internal/kernel/layering/ -count=1 Expected: PASS (TestEveryTenantQueryCarriesAnExplicitOrgPredicate included: every table in the new queries is tied to $1 through org_id equalities).

  • Step 5: Watch the last-administrator guard fail once

In Deactivate, replace if last { with if false && last { and run go test ./internal/platform/identity/member/ -run TestDeactivate -count=1. Expected: FAIL — Target error should be in err chain: expected: "member: this is the organization's last active member..." in chain: nil. Restore; PASS.

  • Step 6: Commit
Terminal window
git add internal/platform/orgstruct internal/platform/identity/member internal/platform/advances \
internal/platform/pettycash internal/platform/workflow/pending.go
git commit -m "Phase 5 H1: seams for HR — assignments from a day, member deactivation, open advances, live float, pending own documents
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 4: The employee record and its lifecycle (platform/hr)

Section titled “Task 4: The employee record and its lifecycle (platform/hr)”

Files:

  • Create: internal/platform/hr/errors.go, internal/platform/hr/employee.go, internal/platform/hr/events.go, internal/platform/hr/hrtest/env.go
  • Test: internal/platform/hr/employee_test.go, internal/platform/hr/events_test.go

Interfaces:

  • Consumes: Task 1’s schema and employee_spell; Task 3’s orgstruct.LockAssignmentsFrom; orgstruct.Assign, orgstruct.CloseAssignment, orgstruct.WithdrawAssignment; audit.Write; recordtext.Check; clock.Today.

  • Produces: the hr constants, Employee, Event, CreateInput, Filter, HireInput, ConfirmInput, TransferInput, Create, Get, GetByParty, GetByMember, GetByNumber, List, Events, SetIdentity, Hire, Confirm, Transfer, ServiceMonths, the errors and *FieldError/*RevisionMismatchError (signatures in “Interfaces H2 and H3 consume”); package-internal orgOf, lockEmployee, insertEvent, finish, checkReason that Task 5 uses; hrtest.New, hrtest.Day, hrtest.At, (*hrtest.Env).Hired, (*hrtest.Env).DeactivateByAdmin.

  • Step 1: Write the fixture

Create internal/platform/hr/hrtest/env.go:

// Package hrtest is Phase 5 H1's test world: advancestest's provisioned
// Malaysian organization (Owner, Staff reporting to the OWNER seat, the
// advance poster available) plus an HR member, so the Owner can be the one
// who is not the requester (HD-6). H2 and H3 build on it.
package hrtest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/platform/advances/advancestest"
"github.com/mcom/erp/internal/platform/hr"
)
type Env struct {
*advancestest.Env
// HR records lifecycle events; the HR seat reports to the OWNER seat.
HR, HRSeat uuid.UUID
}
func New(t *testing.T) *Env {
t.Helper()
a := advancestest.New(t)
member, seat := a.Member(t, "HR", &a.OwnerSeat)
return &Env{Env: a, HR: member, HRSeat: seat}
}
// Day is a calendar day as every DATE column stores it: midnight UTC.
func Day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.UTC) }
// At is a clock at 09:00 in Kuala Lumpur (01:00 UTC) on d, so
// clock.Today(ctx, At(d)) is d.
func At(d time.Time) clock.Clock { return clock.Fixed(d.Add(time.Hour)) }
// Hired creates member's employee record and records a permanent hire on
// 2026-01-01, recorded by HR.
func (e *Env) Hired(t *testing.T, member uuid.UUID, no string) hr.Employee {
t.Helper()
var out hr.Employee
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
emp, err := hr.Create(ctx, tx, e.Clock, hr.CreateInput{MemberID: &member, EmployeeNo: no})
if err != nil {
return err
}
out, _, err = hr.Hire(ctx, tx, e.Clock, hr.HireInput{EmployeeID: emp.ID, EffectiveOn: Day(2026, 1, 1),
EmploymentType: hr.TypePermanent, Actor: e.HR})
return err
})
return out
}
// DeactivateByAdmin marks member inactive through the admin pool: a
// fixture for "this organization has fewer active members", not the
// leaving path.
func (e *Env) DeactivateByAdmin(t *testing.T, member uuid.UUID) {
t.Helper()
_, err := e.H.Admin.Exec(context.Background(),
`UPDATE organization_member SET active = false WHERE org_id = $1 AND id = $2`, e.Org, member)
require.NoError(t, err)
}
  • Step 2: Write the failing tests

Create internal/platform/hr/employee_test.go:

package hr_test
import (
"context"
"testing"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/hr/hrtest"
)
// Spec H1, HD-1: the record is keyed to the person's employee party, one
// per party, and a person with no login gets a party of their own.
func TestAnEmployeeRecordIsKeyedToItsParty(t *testing.T) {
e := hrtest.New(t)
var staff, nurul hr.Employee
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
staff, err = hr.Create(ctx, tx, e.Clock, hr.CreateInput{MemberID: &e.Staff, EmployeeNo: " E-100 "})
return err
})
require.Equal(t, "E-100", staff.EmployeeNo, "trimmed")
require.Equal(t, hr.StatusPending, staff.Status, "no hire recorded yet")
require.Nil(t, staff.HireDate)
require.Equal(t, e.Staff, *staff.MemberID)
require.True(t, staff.MemberActive)
// ABSENCE: a second record for the same person, or a number in use.
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.Create(ctx, tx, e.Clock, hr.CreateInput{MemberID: &e.Staff, EmployeeNo: "E-101"})
return err
})
require.ErrorIs(t, err, hr.ErrPartyHasEmployee)
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.Create(ctx, tx, e.Clock, hr.CreateInput{LegalName: "Nurul Huda", EmployeeNo: "E-100"})
return err
})
require.ErrorIs(t, err, hr.ErrDuplicateEmployeeNo)
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.Create(ctx, tx, e.Clock, hr.CreateInput{LegalName: "陈小明", EmployeeNo: "E-103"})
return err
})
var fe *hr.FieldError
require.ErrorAs(t, err, &fe)
require.Equal(t, "legal_name", fe.Field)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
nurul, err = hr.Create(ctx, tx, e.Clock, hr.CreateInput{LegalName: "Nurul Huda", EmployeeNo: "E-102"})
return err
})
require.Nil(t, nurul.MemberID, "no login")
require.Equal(t, "Nurul Huda", nurul.LegalName)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
byMember, err := hr.GetByMember(ctx, tx, e.Staff)
require.NoError(t, err)
require.Equal(t, staff.ID, byMember.ID)
byNumber, err := hr.GetByNumber(ctx, tx, "E-102")
require.NoError(t, err)
require.Equal(t, nurul.ID, byNumber.ID)
byParty, err := hr.GetByParty(ctx, tx, nurul.PartyID)
require.NoError(t, err)
require.Equal(t, nurul.ID, byParty.ID)
found, err := hr.List(ctx, tx, hr.Filter{Query: "huda"})
require.NoError(t, err)
require.Len(t, found, 1)
pending, err := hr.List(ctx, tx, hr.Filter{Status: hr.StatusPending})
require.NoError(t, err)
require.Len(t, pending, 2)
_, err = hr.GetByMember(ctx, tx, e.Owner)
require.ErrorIs(t, err, hr.ErrNotFound, "the Owner has no record")
return nil
})
}
// Ruling H1-5: the number is normalised, the revision guards it, and the
// audit trail never carries it.
func TestIdentityIsNormalisedAndNeverAudited(t *testing.T) {
e := hrtest.New(t)
emp := e.Hired(t, e.Staff, "E-100")
var got hr.Employee
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
got, err = hr.SetIdentity(ctx, tx, e.Clock, emp.ID, emp.Revision, hr.IDNRIC, "900101-14-5678")
return err
})
require.Equal(t, "900101145678", *got.IDNumber)
require.Equal(t, emp.Revision+1, got.Revision)
var after string
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT after::text FROM audit_log
WHERE entity_kind = 'employee' AND entity_id = $1 AND action = 'identity_set'`, emp.ID).Scan(&after))
require.Contains(t, after, `"id_kind": "nric"`, "PRESENCE: the change is audited")
require.NotContains(t, after, "900101145678", "ABSENCE: the number is not")
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.SetIdentity(ctx, tx, e.Clock, emp.ID, emp.Revision, hr.IDPassport, "A1234567")
return err
})
var stale *hr.RevisionMismatchError
require.ErrorAs(t, err, &stale)
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.SetIdentity(ctx, tx, e.Clock, emp.ID, got.Revision, hr.IDNRIC, "12345")
return err
})
var fe *hr.FieldError
require.ErrorAs(t, err, &fe)
require.Equal(t, "id_number", fe.Field)
}
func TestServiceMonthsCountsWholeMonths(t *testing.T) {
hire := hrtest.Day(2026, 1, 15)
require.Equal(t, 0, hr.ServiceMonths(hire, hrtest.Day(2026, 1, 1)), "before the hire date")
require.Equal(t, 1, hr.ServiceMonths(hire, hrtest.Day(2026, 3, 14)))
require.Equal(t, 2, hr.ServiceMonths(hire, hrtest.Day(2026, 3, 15)))
require.Equal(t, 24, hr.ServiceMonths(hire, hrtest.Day(2028, 1, 15)))
}

Create internal/platform/hr/events_test.go:

package hr_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/hr/hrtest"
"github.com/mcom/erp/internal/platform/orgstruct"
)
// Spec H1 / Ruling H1-1: status, hire date and employment type follow the
// events; nothing is edited in place.
func TestStatusIsDerivedFromTheLifecycle(t *testing.T) {
e := hrtest.New(t)
emp := e.Hired(t, e.Staff, "E-100")
require.Equal(t, hr.StatusActive, emp.Status)
require.Equal(t, hrtest.Day(2026, 1, 1), *emp.HireDate)
require.Equal(t, hr.TypePermanent, *emp.EmploymentType)
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, _, err := hr.Hire(ctx, tx, e.Clock, hr.HireInput{EmployeeID: emp.ID, EffectiveOn: hrtest.Day(2026, 2, 1),
EmploymentType: hr.TypePermanent, Actor: e.HR})
return err
})
require.ErrorIs(t, err, hr.ErrWrongStatus, "a second hire while employed")
var got hr.Employee
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
got, _, err = hr.Confirm(ctx, tx, e.Clock, hr.ConfirmInput{EmployeeID: emp.ID, EffectiveOn: hrtest.Day(2026, 4, 1),
EmploymentType: hr.TypeContract, Reason: "Fixed-term contract", Actor: e.HR})
return err
})
require.Equal(t, hr.TypeContract, *got.EmploymentType)
require.Equal(t, hrtest.Day(2026, 1, 1), *got.HireDate, "confirming does not move the hire date")
var kinds []string
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
evs, err := hr.Events(ctx, tx, emp.ID)
for _, ev := range evs {
kinds = append(kinds, ev.Kind)
}
return err
})
require.Equal(t, []string{hr.EventHired, hr.EventConfirmed}, kinds)
}
// Ruling H1-12: a transfer moves the primary seat from today, never before.
func TestATransferMovesThePrimarySeatFromTodayOn(t *testing.T) {
e := hrtest.New(t)
emp := e.Hired(t, e.Staff, "E-100")
var unit uuid.UUID
require.NoError(t, e.H.Admin.QueryRow(context.Background(),
`SELECT org_unit_id FROM org_position WHERE org_id = $1 AND code = 'OWNER'`, e.Org).Scan(&unit))
var seat orgstruct.Position
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
seat, err = orgstruct.CreatePosition(ctx, tx, e.Clock, orgstruct.PositionInput{OrgUnitID: unit,
Code: "OPS-LEAD", Title: "Operations lead", ReportsToPositionID: &e.OwnerSeat})
return err
})
today := hrtest.Day(2026, 9, 21) // expensestest's T0 in Kuala Lumpur
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, _, err := hr.Transfer(ctx, tx, e.Clock, hr.TransferInput{EmployeeID: emp.ID, PositionID: seat.ID,
EffectiveOn: today.AddDate(0, 0, -1), Actor: e.HR})
return err
})
var fe *hr.FieldError
require.ErrorAs(t, err, &fe, "ABSENCE: not backdated")
require.Equal(t, "effective_on", fe.Field)
var ev hr.Event
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
_, ev, err = hr.Transfer(ctx, tx, e.Clock, hr.TransferInput{EmployeeID: emp.ID, PositionID: seat.ID,
EffectiveOn: today, Reason: "New operations team", Actor: e.HR})
return err
})
require.Equal(t, hr.EventTransferred, ev.Kind)
require.Equal(t, e.StaffSeat, *ev.FromPositionID)
require.Equal(t, seat.ID, *ev.ToPositionID)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
now, err := orgstruct.PrimaryPositionOf(ctx, tx, e.Staff, today)
require.NoError(t, err)
require.Equal(t, seat.ID, now, "PRESENCE: the new seat from today")
before, err := orgstruct.PrimaryPositionOf(ctx, tx, e.Staff, today.AddDate(0, 0, -1))
require.NoError(t, err)
require.Equal(t, e.StaffSeat, before, "yesterday is unchanged")
return nil
})
var nurul hr.Employee
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
created, err := hr.Create(ctx, tx, e.Clock, hr.CreateInput{LegalName: "Nurul Huda", EmployeeNo: "E-200"})
if err != nil {
return err
}
nurul, _, err = hr.Hire(ctx, tx, e.Clock, hr.HireInput{EmployeeID: created.ID, EffectiveOn: hrtest.Day(2026, 3, 1),
EmploymentType: hr.TypePartTime, Actor: e.HR})
return err
})
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, _, err := hr.Transfer(ctx, tx, e.Clock, hr.TransferInput{EmployeeID: nurul.ID, PositionID: seat.ID,
EffectiveOn: today, Actor: e.HR})
return err
})
require.ErrorIs(t, err, hr.ErrNoLogin, "a person with no login holds no seat")
}
  • Step 3: Run them to verify they fail

Run: go test ./internal/platform/hr/... -count=1 Expected: FAIL to compile — undefined: hr.Create, hr.Hire, hr.Employee, ….

  • Step 4: Write the errors

Create internal/platform/hr/errors.go:

package hr
import (
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
)
var (
ErrNotFound = errors.New("hr: no such employee in this organization")
ErrNoSuchMember = errors.New("hr: no such member in this organization")
ErrPartyHasEmployee = errors.New("hr: this person already has an employee record")
ErrPartyNotEmployee = errors.New("hr: an employee record needs an employee party")
ErrDuplicateEmployeeNo = errors.New("hr: another employee has this number")
ErrDuplicateIdentity = errors.New("hr: another employee has this identity document")
ErrWrongStatus = errors.New("hr: the employee's status does not allow this")
ErrNoLogin = errors.New("hr: the employee has no active login, so holds no seat")
ErrNothingToOverride = errors.New("hr: the person holds no open advance or float; there is nothing to override")
ErrOverrideByRequester = errors.New("hr: an Owner other than the member recording the last day must authorise it")
ErrOwnLeaving = errors.New("hr: nobody authorises their own leaving")
)
// FieldError is one refused field, in the "field=… rule=…" shape every
// module's 422 carries.
type FieldError struct{ Field, Rule, Detail string }
func (e *FieldError) Error() string {
return fmt.Sprintf("field=%s rule=%s: %s", e.Field, e.Rule, e.Detail)
}
// RevisionMismatchError: the record changed since the caller read it.
type RevisionMismatchError struct{ Current int }
func (e *RevisionMismatchError) Error() string {
return fmt.Sprintf("hr: the employee is at revision %d", e.Current)
}
// mapConstraint names the database's refusals; anything else passes through.
func mapConstraint(err error) error {
var pg *pgconn.PgError
if !errors.As(err, &pg) {
return err
}
switch pg.ConstraintName {
case "employee_party_once":
return ErrPartyHasEmployee
case "employee_no_once":
return ErrDuplicateEmployeeNo
case "employee_identity_once":
return ErrDuplicateIdentity
case "employee_party_is_employee":
return ErrPartyNotEmployee
}
return err
}
  • Step 5: Write the record

Create internal/platform/hr/employee.go:

package hr
import (
"context"
"errors"
"fmt"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/recordtext"
"github.com/mcom/erp/internal/kernel/tenancy"
)
const (
EventHired = "hired"
EventConfirmed = "confirmed"
EventTransferred = "transferred"
EventNoticeGiven = "notice_given"
EventLeft = "left"
EventRehired = "rehired"
StatusPending = "pending" // no hire recorded yet
StatusActive = "active"
StatusNotice = "notice" // a last day is recorded and has not passed
StatusLeft = "left"
TypePermanent = "permanent"
TypeContract = "contract"
TypeProbation = "probation"
TypePartTime = "part_time"
IDNRIC = "nric"
IDPassport = "passport"
IDOther = "other"
// Audit entity kinds.
EntityEmployee = "employee"
EntityOverride = "lifecycle_override"
// The module registers these (internal/modules/hr); they live here
// because platform/imports checks PermissionSensitive (Ruling H1-15).
PermissionRead = "hr.employee.read"
PermissionManage = "hr.employee.manage"
PermissionSensitive = "hr.employee.sensitive"
PermissionLifecycleOverride = "hr.employee.lifecycle_override"
)
// EmploymentTypes is the employment_type vocabulary, in display order.
var EmploymentTypes = []string{TypePermanent, TypeContract, TypeProbation, TypePartTime}
// Employee is one employee record with its derived lifecycle (Ruling H1-1).
// IDNumber is always loaded; the HTTP layer withholds it from callers
// without PermissionSensitive.
type Employee struct {
ID, PartyID uuid.UUID
LegalName string // the party's record-language name
EmployeeNo string
MemberID *uuid.UUID // the login, when the person has one
MemberActive bool
IDKind *string
IDNumber *string
HireDate *time.Time
EmploymentType *string
LastDay *time.Time
Status string
Revision int
CreatedAt time.Time
UpdatedAt time.Time
}
// employeeSelect is the one read of an employee: every getter and List go
// through it, and the lifecycle comes from employee_spell alone.
const employeeSelect = `
SELECT e.id, e.party_id, p.legal_name, e.employee_no,
(SELECT mm.id FROM organization_member mm WHERE mm.org_id = e.org_id AND mm.party_id = e.party_id
ORDER BY mm.active DESC, mm.created_at DESC, mm.id LIMIT 1),
coalesce((SELECT bool_or(ma.active) FROM organization_member ma
WHERE ma.org_id = e.org_id AND ma.party_id = e.party_id), false),
e.id_kind, e.id_number, s.hire_date, s.employment_type, s.last_day, s.status,
e.revision, e.created_at, e.updated_at
FROM employee e
JOIN party p ON p.org_id = e.org_id AND p.id = e.party_id
CROSS JOIN LATERAL employee_spell(e.org_id, e.id) s
WHERE e.org_id = $1 AND `
func scanEmployee(row pgx.Row) (Employee, error) {
var e Employee
if err := row.Scan(&e.ID, &e.PartyID, &e.LegalName, &e.EmployeeNo, &e.MemberID, &e.MemberActive,
&e.IDKind, &e.IDNumber, &e.HireDate, &e.EmploymentType, &e.LastDay, &e.Status,
&e.Revision, &e.CreatedAt, &e.UpdatedAt); err != nil {
return Employee{}, err
}
e.HireDate, e.LastDay = utcDate(e.HireDate), utcDate(e.LastDay)
e.CreatedAt, e.UpdatedAt = e.CreatedAt.UTC(), e.UpdatedAt.UTC()
return e, nil
}
func utcDate(t *time.Time) *time.Time {
if t == nil {
return nil
}
u := t.UTC()
return &u
}
func orgOf(ctx context.Context) (uuid.UUID, error) {
org, ok := tenancy.OrgFromContext(ctx)
if !ok {
return uuid.Nil, fmt.Errorf("hr: %w", tenancy.ErrNoOrg)
}
return org, nil
}
func getWhere(ctx context.Context, tx pgx.Tx, cond string, arg any) (Employee, error) {
org, err := orgOf(ctx)
if err != nil {
return Employee{}, err
}
e, err := scanEmployee(tx.QueryRow(ctx, employeeSelect+cond, org, arg))
if errors.Is(err, pgx.ErrNoRows) {
return Employee{}, ErrNotFound
}
if err != nil {
return Employee{}, fmt.Errorf("hr: read employee: %w", err)
}
return e, nil
}
func Get(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Employee, error) {
return getWhere(ctx, tx, `e.id = $2`, id)
}
func GetByParty(ctx context.Context, tx pgx.Tx, partyID uuid.UUID) (Employee, error) {
return getWhere(ctx, tx, `e.party_id = $2`, partyID)
}
// GetByMember is the record of the login's party; ErrNotFound for a member
// who is not an employee (an outside approver stays valid, spec H1).
func GetByMember(ctx context.Context, tx pgx.Tx, memberID uuid.UUID) (Employee, error) {
return getWhere(ctx, tx, `e.party_id = (SELECT mp.party_id FROM organization_member mp
WHERE mp.org_id = e.org_id AND mp.id = $2)`, memberID)
}
func GetByNumber(ctx context.Context, tx pgx.Tx, employeeNo string) (Employee, error) {
return getWhere(ctx, tx, `e.employee_no = $2`, strings.TrimSpace(employeeNo))
}
// Filter narrows List: Status is a derived status; Query matches part of a
// name or an employee number, ignoring case.
type Filter struct {
Status string
Query string
}
var likeEscaper = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
// List is every employee matching f, by employee number.
func List(ctx context.Context, tx pgx.Tx, f Filter) ([]Employee, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, employeeSelect+`($2 = '' OR s.status = $2)
AND ($3 = '' OR p.legal_name ILIKE '%' || $3 || '%' OR e.employee_no ILIKE '%' || $3 || '%')
ORDER BY e.employee_no`, org, f.Status, likeEscaper.Replace(strings.TrimSpace(f.Query)))
if err != nil {
return nil, fmt.Errorf("hr: list: %w", err)
}
defer rows.Close()
out := []Employee{}
for rows.Next() {
e, err := scanEmployee(rows)
if err != nil {
return nil, fmt.Errorf("hr: list: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// CreateInput names either an existing member (their party becomes the
// employee's) or a new person with no login (a new employee party).
type CreateInput struct {
MemberID *uuid.UUID
LegalName string
EmployeeNo string
}
// Create opens an employee record with no lifecycle yet (status pending).
func Create(ctx context.Context, tx pgx.Tx, c clock.Clock, in CreateInput) (Employee, error) {
org, err := orgOf(ctx)
if err != nil {
return Employee{}, err
}
no := strings.TrimSpace(in.EmployeeNo)
if no == "" || utf8.RuneCountInString(no) > 32 {
return Employee{}, &FieldError{Field: "employee_no", Rule: "shape", Detail: "an employee number is 1 to 32 characters"}
}
name := strings.TrimSpace(in.LegalName)
var partyID uuid.UUID
switch {
case in.MemberID != nil && name != "":
return Employee{}, &FieldError{Field: "legal_name", Rule: "exclusive", Detail: "name a member or a new person, not both"}
case in.MemberID != nil:
err := tx.QueryRow(ctx, `SELECT party_id FROM organization_member WHERE org_id = $1 AND id = $2`,
org, *in.MemberID).Scan(&partyID)
if errors.Is(err, pgx.ErrNoRows) {
return Employee{}, ErrNoSuchMember
}
if err != nil {
return Employee{}, fmt.Errorf("hr: create: %w", err)
}
default:
switch {
case name == "" || utf8.RuneCountInString(name) > 200:
return Employee{}, &FieldError{Field: "legal_name", Rule: "required", Detail: "a legal name of 1 to 200 characters is required"}
case recordtext.Check("legal_name", name) != nil:
return Employee{}, &FieldError{Field: "legal_name", Rule: recordtext.Rule, Detail: recordtext.Detail}
}
if err := tx.QueryRow(ctx, `INSERT INTO party (org_id, kind, legal_name) VALUES ($1, 'employee', $2) RETURNING id`,
org, name).Scan(&partyID); err != nil {
return Employee{}, fmt.Errorf("hr: create party: %w", err)
}
}
now := c.Now()
var id uuid.UUID
if err := tx.QueryRow(ctx, `INSERT INTO employee (org_id, party_id, employee_no, created_at, updated_at)
VALUES ($1, $2, $3, $4, $4) RETURNING id`, org, partyID, no, now).Scan(&id); err != nil {
return Employee{}, fmt.Errorf("hr: create: %w", mapConstraint(err))
}
e, err := Get(ctx, tx, id)
if err != nil {
return Employee{}, err
}
if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityEmployee, EntityID: id, Action: "create",
After: map[string]any{"party_id": partyID, "employee_no": no}}); err != nil {
return Employee{}, err
}
return e, nil
}
var (
nricShape = regexp.MustCompile(`^[0-9]{12}$`)
passportShape = regexp.MustCompile(`^[A-Z0-9]{5,20}$`)
)
// normalizeIdentity is Ruling H1-5's shapes. Both empty clears the document.
func normalizeIdentity(kind, number string) (*string, *string, error) {
kind, number = strings.TrimSpace(kind), strings.TrimSpace(number)
if kind == "" && number == "" {
return nil, nil, nil
}
switch kind {
case IDNRIC:
number = strings.NewReplacer("-", "", " ", "").Replace(number)
if !nricShape.MatchString(number) {
return nil, nil, &FieldError{Field: "id_number", Rule: "nric_shape", Detail: "an NRIC is 12 digits, as 900101-14-5678"}
}
case IDPassport:
number = strings.ToUpper(strings.ReplaceAll(number, " ", ""))
if !passportShape.MatchString(number) {
return nil, nil, &FieldError{Field: "id_number", Rule: "passport_shape", Detail: "a passport number is 5 to 20 letters and digits"}
}
case IDOther:
if number == "" || utf8.RuneCountInString(number) > 40 {
return nil, nil, &FieldError{Field: "id_number", Rule: "other_shape", Detail: "an identity number is 1 to 40 characters"}
}
if recordtext.Check("id_number", number) != nil {
return nil, nil, &FieldError{Field: "id_number", Rule: recordtext.Rule, Detail: recordtext.Detail}
}
default:
return nil, nil, &FieldError{Field: "id_kind", Rule: "known", Detail: "the identity document is nric, passport or other"}
}
return &kind, &number, nil
}
// SetIdentity sets (or, with both empty, clears) the identity document at
// the revision the caller read (Ruling H1-5). The audit row records the kind
// and that the number changed, never the number.
func SetIdentity(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, kind, number string) (Employee, error) {
org, err := orgOf(ctx)
if err != nil {
return Employee{}, err
}
k, n, err := normalizeIdentity(kind, number)
if err != nil {
return Employee{}, err
}
var rev int
var before *string
err = tx.QueryRow(ctx, `SELECT revision, id_kind FROM employee WHERE org_id = $1 AND id = $2 FOR UPDATE`,
org, id).Scan(&rev, &before)
if errors.Is(err, pgx.ErrNoRows) {
return Employee{}, ErrNotFound
}
if err != nil {
return Employee{}, fmt.Errorf("hr: set identity: %w", err)
}
if rev != expectedRevision {
return Employee{}, &RevisionMismatchError{Current: rev}
}
if _, err := tx.Exec(ctx, `UPDATE employee SET id_kind = $3, id_number = $4, revision = revision + 1, updated_at = $5
WHERE org_id = $1 AND id = $2`, org, id, k, n, c.Now()); err != nil {
return Employee{}, fmt.Errorf("hr: set identity: %w", mapConstraint(err))
}
e, err := Get(ctx, tx, id)
if err != nil {
return Employee{}, err
}
if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityEmployee, EntityID: id, Action: "identity_set",
Before: map[string]any{"id_kind": before},
After: map[string]any{"id_kind": k, "id_number_changed": true}}); err != nil {
return Employee{}, err
}
return e, nil
}
// ServiceMonths is the whole months of service completed from hireDate to
// on, for H3's service bands: 2026-01-15 → 2026-03-14 is 1, → 2026-03-15 is
// 2. A month completes on the hire's day of the month (a hire on the 31st
// completes February's month on 1 March). Before the hire date it is 0.
func ServiceMonths(hireDate, on time.Time) int {
m := (on.Year()-hireDate.Year())*12 + int(on.Month()) - int(hireDate.Month())
if on.Day() < hireDate.Day() {
m--
}
return max(m, 0)
}
  • Step 6: Write the lifecycle

Create internal/platform/hr/events.go:

package hr
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/recordtext"
"github.com/mcom/erp/internal/platform/orgstruct"
)
// Event is one row of the append-only lifecycle (spec H1).
type Event struct {
ID uuid.UUID `json:"id"`
Seq int64 `json:"seq"`
EmployeeID uuid.UUID `json:"employee_id"`
Kind string `json:"kind"`
EffectiveOn time.Time `json:"effective_on"`
LastDay *time.Time `json:"last_day,omitempty"`
EmploymentType *string `json:"employment_type,omitempty"`
FromPositionID *uuid.UUID `json:"from_position_id,omitempty"`
ToPositionID *uuid.UUID `json:"to_position_id,omitempty"`
OverrideID *uuid.UUID `json:"override_id,omitempty"`
Reason string `json:"reason,omitempty"`
RecordedBy *uuid.UUID `json:"recorded_by,omitempty"`
RecordedAt time.Time `json:"recorded_at"`
}
const eventColumns = `id, seq, employee_id, kind, effective_on, last_day, employment_type, from_position_id,
to_position_id, override_id, reason, recorded_by, recorded_at`
func scanEvent(row pgx.Row) (Event, error) {
var ev Event
if err := row.Scan(&ev.ID, &ev.Seq, &ev.EmployeeID, &ev.Kind, &ev.EffectiveOn, &ev.LastDay, &ev.EmploymentType,
&ev.FromPositionID, &ev.ToPositionID, &ev.OverrideID, &ev.Reason, &ev.RecordedBy, &ev.RecordedAt); err != nil {
return Event{}, err
}
ev.EffectiveOn, ev.LastDay, ev.RecordedAt = ev.EffectiveOn.UTC(), utcDate(ev.LastDay), ev.RecordedAt.UTC()
return ev, nil
}
// Events is the employee's lifecycle in recording order.
func Events(ctx context.Context, tx pgx.Tx, employeeID uuid.UUID) ([]Event, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `SELECT `+eventColumns+` FROM employee_event
WHERE org_id = $1 AND employee_id = $2 ORDER BY seq`, org, employeeID)
if err != nil {
return nil, fmt.Errorf("hr: events: %w", err)
}
defer rows.Close()
out := []Event{}
for rows.Next() {
ev, err := scanEvent(rows)
if err != nil {
return nil, fmt.Errorf("hr: events: %w", err)
}
out = append(out, ev)
}
return out, rows.Err()
}
// insertEvent is the one writer of employee_event (H2 adds its vacancy
// column here).
func insertEvent(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, ev Event) (Event, error) {
out, err := scanEvent(tx.QueryRow(ctx, `INSERT INTO employee_event (org_id, employee_id, kind, effective_on,
last_day, employment_type, from_position_id, to_position_id, override_id, reason, recorded_by, recorded_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING `+eventColumns,
org, ev.EmployeeID, ev.Kind, ev.EffectiveOn, ev.LastDay, ev.EmploymentType, ev.FromPositionID,
ev.ToPositionID, ev.OverrideID, ev.Reason, ev.RecordedBy, c.Now()))
if err != nil {
return Event{}, fmt.Errorf("hr: record %s: %w", ev.Kind, err)
}
return out, nil
}
// lockEmployee takes the package's first two locks (Ruling H1-14): the
// organization row, then the employee row, and reads the employee.
func lockEmployee(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Employee, error) {
org, err := orgOf(ctx)
if err != nil {
return Employee{}, err
}
// ponytail: one organization lock serializes every lifecycle write in
// the organization; a per-employee order is the upgrade if HR traffic
// ever contends on it.
if _, err := tx.Exec(ctx, `SELECT 1 FROM organization WHERE id = $1 AND org_id = $1 FOR UPDATE`, org); err != nil {
return Employee{}, fmt.Errorf("hr: lock organization: %w", err)
}
var got uuid.UUID
err = tx.QueryRow(ctx, `SELECT id FROM employee WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, id).Scan(&got)
if errors.Is(err, pgx.ErrNoRows) {
return Employee{}, ErrNotFound
}
if err != nil {
return Employee{}, fmt.Errorf("hr: lock employee: %w", err)
}
return Get(ctx, tx, id)
}
// finish rereads the employee and writes the event's audit row, last.
func finish(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, ev Event) (Employee, Event, error) {
e, err := Get(ctx, tx, id)
if err != nil {
return Employee{}, Event{}, err
}
if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityEmployee, EntityID: id, Action: ev.Kind, After: ev}); err != nil {
return Employee{}, Event{}, err
}
return e, ev, nil
}
// checkReason is record text of at most 500 characters, required or not.
func checkReason(field, reason string, required bool) (string, error) {
r := strings.TrimSpace(reason)
switch {
case r == "" && required:
return "", &FieldError{Field: field, Rule: "required", Detail: "a reason is required"}
case utf8.RuneCountInString(r) > 500:
return "", &FieldError{Field: field, Rule: "max_length", Detail: "a reason is at most 500 characters"}
case r != "" && recordtext.Check(field, r) != nil:
return "", &FieldError{Field: field, Rule: recordtext.Rule, Detail: recordtext.Detail}
}
return r, nil
}
func checkType(t string) error {
if !slices.Contains(EmploymentTypes, t) {
return &FieldError{Field: "employment_type", Rule: "known", Detail: "permanent, contract, probation or part_time"}
}
return nil
}
// HireInput starts an employment spell on EffectiveOn, its first day of work.
type HireInput struct {
EmployeeID uuid.UUID
EffectiveOn time.Time
EmploymentType string
Reason string
Actor uuid.UUID
}
// Hire records hired the first time and rehired after a left (spec H1: the
// party, the row and the number are reused). Refused while a spell is open.
func Hire(ctx context.Context, tx pgx.Tx, c clock.Clock, in HireInput) (Employee, Event, error) {
org, err := orgOf(ctx)
if err != nil {
return Employee{}, Event{}, err
}
if err := checkType(in.EmploymentType); err != nil {
return Employee{}, Event{}, err
}
reason, err := checkReason("reason", in.Reason, false)
if err != nil {
return Employee{}, Event{}, err
}
e, err := lockEmployee(ctx, tx, in.EmployeeID)
if err != nil {
return Employee{}, Event{}, err
}
kind := EventHired
switch e.Status {
case StatusPending:
case StatusLeft:
kind = EventRehired
if !in.EffectiveOn.After(*e.LastDay) {
return Employee{}, Event{}, &FieldError{Field: "effective_on", Rule: "after_last_day",
Detail: "a rehire starts after the last day of the previous employment"}
}
default:
return Employee{}, Event{}, ErrWrongStatus
}
ev, err := insertEvent(ctx, tx, c, org, Event{EmployeeID: e.ID, Kind: kind, EffectiveOn: in.EffectiveOn,
EmploymentType: &in.EmploymentType, Reason: reason, RecordedBy: &in.Actor})
if err != nil {
return Employee{}, Event{}, err
}
return finish(ctx, tx, c, e.ID, ev)
}
// ConfirmInput records a confirmation (typically the end of probation) and
// the employment type from EffectiveOn.
type ConfirmInput struct {
EmployeeID uuid.UUID
EffectiveOn time.Time
EmploymentType string
Reason string
Actor uuid.UUID
}
func Confirm(ctx context.Context, tx pgx.Tx, c clock.Clock, in ConfirmInput) (Employee, Event, error) {
org, err := orgOf(ctx)
if err != nil {
return Employee{}, Event{}, err
}
if err := checkType(in.EmploymentType); err != nil {
return Employee{}, Event{}, err
}
reason, err := checkReason("reason", in.Reason, false)
if err != nil {
return Employee{}, Event{}, err
}
e, err := lockEmployee(ctx, tx, in.EmployeeID)
if err != nil {
return Employee{}, Event{}, err
}
if e.Status != StatusActive {
return Employee{}, Event{}, ErrWrongStatus
}
if in.EffectiveOn.Before(*e.HireDate) {
return Employee{}, Event{}, &FieldError{Field: "effective_on", Rule: "after_hire", Detail: "a confirmation is on or after the hire date"}
}
ev, err := insertEvent(ctx, tx, c, org, Event{EmployeeID: e.ID, Kind: EventConfirmed, EffectiveOn: in.EffectiveOn,
EmploymentType: &in.EmploymentType, Reason: reason, RecordedBy: &in.Actor})
if err != nil {
return Employee{}, Event{}, err
}
return finish(ctx, tx, c, e.ID, ev)
}
// TransferInput moves the employee's primary seat to PositionID from
// EffectiveOn (Ruling H1-12).
type TransferInput struct {
EmployeeID uuid.UUID
PositionID uuid.UUID
EffectiveOn time.Time
Reason string
Actor uuid.UUID
}
// Transfer closes (or, when it starts that same day, withdraws) the current
// primary assignment and assigns the new one, through orgstruct, then
// records transferred with both seats. Today or later only; the person
// needs an active login.
func Transfer(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransferInput) (Employee, Event, error) {
org, err := orgOf(ctx)
if err != nil {
return Employee{}, Event{}, err
}
reason, err := checkReason("reason", in.Reason, false)
if err != nil {
return Employee{}, Event{}, err
}
e, err := lockEmployee(ctx, tx, in.EmployeeID)
if err != nil {
return Employee{}, Event{}, err
}
if e.Status != StatusActive && e.Status != StatusNotice {
return Employee{}, Event{}, ErrWrongStatus
}
if e.MemberID == nil || !e.MemberActive {
return Employee{}, Event{}, ErrNoLogin
}
switch {
case in.EffectiveOn.Before(clock.Today(ctx, c)):
return Employee{}, Event{}, &FieldError{Field: "effective_on", Rule: "not_past", Detail: "a transfer takes effect today or later"}
case e.LastDay != nil && in.EffectiveOn.After(*e.LastDay):
return Employee{}, Event{}, &FieldError{Field: "effective_on", Rule: "after_last_day", Detail: "a transfer takes effect by the last day"}
}
// Every row the closes and the assignment touch is locked before the
// first audit row (Ruling H1-14): Assign's own FOR SHARE reads are then
// already this transaction's.
if _, err := tx.Exec(ctx, `SELECT 1 FROM organization_member WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, *e.MemberID); err != nil {
return Employee{}, Event{}, fmt.Errorf("hr: transfer: %w", err)
}
if _, err := tx.Exec(ctx, `SELECT 1 FROM org_position WHERE org_id = $1 AND id = $2 FOR SHARE`, org, in.PositionID); err != nil {
return Employee{}, Event{}, fmt.Errorf("hr: transfer: %w", err)
}
held, err := orgstruct.LockAssignmentsFrom(ctx, tx, *e.MemberID, in.EffectiveOn)
if err != nil {
return Employee{}, Event{}, err
}
var from *uuid.UUID
for _, a := range held {
if !a.IsPrimary || a.ValidFrom.After(in.EffectiveOn) {
continue
}
if a.PositionID == in.PositionID {
return Employee{}, Event{}, &FieldError{Field: "position_id", Rule: "same_seat", Detail: "this is already the primary seat"}
}
p := a.PositionID
from = &p
if a.ValidFrom.Equal(in.EffectiveOn) {
err = orgstruct.WithdrawAssignment(ctx, tx, c, a.ID)
} else {
err = orgstruct.CloseAssignment(ctx, tx, c, a.ID, in.EffectiveOn)
}
if err != nil {
return Employee{}, Event{}, err
}
}
if _, err := orgstruct.Assign(ctx, tx, c, orgstruct.AssignmentInput{MemberID: *e.MemberID,
PositionID: in.PositionID, IsPrimary: true, ValidFrom: in.EffectiveOn}); err != nil {
return Employee{}, Event{}, err
}
to := in.PositionID
ev, err := insertEvent(ctx, tx, c, org, Event{EmployeeID: e.ID, Kind: EventTransferred, EffectiveOn: in.EffectiveOn,
FromPositionID: from, ToPositionID: &to, Reason: reason, RecordedBy: &in.Actor})
if err != nil {
return Employee{}, Event{}, err
}
return finish(ctx, tx, c, e.ID, ev)
}
  • Step 7: Run the tests

Run: go test ./internal/platform/hr/... ./internal/kernel/layering/ -count=1 Expected: PASS.

  • Step 8: Watch the derivation fail once

In 00800_employee.sql’s employee_spell, change WHEN spell.seq IS NULL THEN 'pending' to WHEN spell.seq IS NULL THEN 'active' and run go test ./internal/platform/hr/ -run TestAnEmployeeRecordIsKeyedToItsParty -count=1. Expected: FAIL — expected: "pending" actual: "active". Restore; PASS.

  • Step 9: Commit
Terminal window
git add internal/platform/hr
git commit -m "Phase 5 H1: the employee record, hire, confirm, transfer and identity
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 5: The last day, what blocks it, and the Owner’s override (HD-6)

Section titled “Task 5: The last day, what blocks it, and the Owner’s override (HD-6)”

Files:

  • Create: internal/platform/hr/obligations.go, internal/platform/hr/leaving.go
  • Modify: internal/platform/hr/hrtest/env.go (add LastDay, Override)
  • Test: internal/platform/hr/leaving_test.go

Interfaces:

  • Consumes: Task 3’s advances.OpenHeldBy, pettycash.LiveFloatOf, workflow.PendingReportedBy, member.IsLastAdministrator, member.Deactivate, orgstruct.LockAssignmentsFrom; workflow.SoleOwnerException; Task 4’s lockEmployee, insertEvent, finish, checkReason, orgOf.

  • Produces: LastDayInput, RecordLastDay, OpenAdvance, OpenFloat, Obligations (+ Blocking), ObligationsError, OpenObligations, OverrideInput, Override, GrantLeavingOverride; package-internal lockLeaving(ctx, tx, e Employee, lastDay time.Time) (*leaving, error) and (*leaving).apply(ctx, tx, c) error that Task 6’s ApplyLeaving reuses; (*hrtest.Env).LastDay, (*hrtest.Env).Override.

  • Step 1: Write the failing tests

Append to internal/platform/hr/hrtest/env.go:

// LastDay records a notice given on noticeOn with lastDay, by the member by.
func (e *Env) LastDay(t *testing.T, id uuid.UUID, noticeOn, lastDay time.Time, by uuid.UUID) hr.Employee {
t.Helper()
var out hr.Employee
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
out, _, err = hr.RecordLastDay(ctx, tx, e.Clock, hr.LastDayInput{EmployeeID: id, NoticeOn: noticeOn,
LastDay: lastDay, Reason: "Resigned", Actor: by})
return err
})
return out
}
// Override grants an HD-6 override for id's lastDay, by the member by.
func (e *Env) Override(t *testing.T, id uuid.UUID, lastDay time.Time, by uuid.UUID) hr.Override {
t.Helper()
var out hr.Override
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
out, err = hr.GrantLeavingOverride(ctx, tx, e.Clock, hr.OverrideInput{EmployeeID: id, LastDay: lastDay,
Reason: "The advance will be recovered from the final pay", Actor: by})
return err
})
return out
}

Create internal/platform/hr/leaving_test.go:

package hr_test
import (
"context"
"errors"
"testing"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/hr/hrtest"
"github.com/mcom/erp/internal/platform/pettycash"
)
var errRollback = errors.New("rolled back on purpose")
func recordLastDay(e *hrtest.Env, in hr.LastDayInput) error {
return e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, _, err := hr.RecordLastDay(ctx, tx, e.Clock, in)
return err
})
}
// Spec H1 drift bullet 2 (HD-6): a last day is refused while an open
// advance or float exists, and accepted with an Owner override who is not
// the requester.
func TestALastDayIsRefusedWhileAnAdvanceIsOpenAndAcceptedWithAnOwnerOverride(t *testing.T) {
e := hrtest.New(t)
e.UseAdvancePoster(t)
staff := e.Hired(t, e.Staff, "E-100")
in := hr.LastDayInput{EmployeeID: staff.ID, NoticeOn: hrtest.Day(2026, 9, 21), LastDay: hrtest.Day(2026, 10, 31),
Reason: "Resigned", Actor: e.HR}
// PRESENCE: with nothing open the same request is accepted (rolled back).
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
if _, _, err := hr.RecordLastDay(ctx, tx, e.Clock, in); err != nil {
return err
}
return errRollback
})
require.ErrorIs(t, err, errRollback)
advance := e.Received(t, "500.00")
// ABSENCE: refused, naming what is open.
var oe *hr.ObligationsError
require.ErrorAs(t, recordLastDay(e, in), &oe)
require.Len(t, oe.Obligations.Advances, 1)
require.Equal(t, advance.ID, oe.Obligations.Advances[0].ID)
// The Owner overrides; the Owner cannot then be the one who records it.
ov := e.Override(t, staff.ID, in.LastDay, e.Owner)
require.False(t, ov.SoleOwner)
owner := in
owner.Actor = e.Owner
require.ErrorIs(t, recordLastDay(e, owner), hr.ErrOverrideByRequester)
// An override for another day covers nothing.
otherDay := in
otherDay.LastDay = hrtest.Day(2026, 11, 30)
require.ErrorAs(t, recordLastDay(e, otherDay), &oe)
// HR, who is not the Owner who overrode, records it: accepted, once.
got := e.LastDay(t, staff.ID, in.NoticeOn, in.LastDay, e.HR)
require.Equal(t, hr.StatusNotice, got.Status)
require.Equal(t, in.LastDay, *got.LastDay)
var used int
require.NoError(t, e.H.Admin.QueryRow(context.Background(),
`SELECT count(*) FROM employee_event WHERE override_id = $1`, ov.ID).Scan(&used))
require.Equal(t, 1, used)
require.ErrorAs(t, recordLastDay(e, in), &oe, "a spent override covers nothing again")
}
// Ruling H1-7: a float opened after the override is not covered by it.
func TestAFloatOpenedAfterTheOverrideIsNotCovered(t *testing.T) {
e := hrtest.New(t)
e.UseAdvancePoster(t)
staff := e.Hired(t, e.Staff, "E-100")
e.Received(t, "500.00")
in := hr.LastDayInput{EmployeeID: staff.ID, NoticeOn: hrtest.Day(2026, 9, 21), LastDay: hrtest.Day(2026, 10, 31),
Actor: e.HR}
e.Override(t, staff.ID, in.LastDay, e.Owner)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := pettycash.OpenFloat(ctx, tx, e.Clock, pettycash.OpenInput{HolderMemberID: e.Staff, Actor: e.Owner})
return err
})
var oe *hr.ObligationsError
require.ErrorAs(t, recordLastDay(e, in), &oe)
require.Len(t, oe.Obligations.Floats, 1)
}
// Ruling H1-8: in a one-member organization the Owner overrides and records
// it themselves, with the override's reason, flagged sole_owner.
func TestTheSoleOwnerMayOverrideTheirOwnRequest(t *testing.T) {
e := hrtest.New(t)
e.UseAdvancePoster(t)
staff := e.Hired(t, e.Staff, "E-100")
e.Received(t, "500.00")
lastDay := hrtest.Day(2026, 10, 31)
// PRESENCE of the rule: with other active members the flag is off and
// the Owner may not record their own override.
first := e.Override(t, staff.ID, lastDay, e.Owner)
require.False(t, first.SoleOwner)
in := hr.LastDayInput{EmployeeID: staff.ID, NoticeOn: hrtest.Day(2026, 9, 21), LastDay: lastDay, Actor: e.Owner}
require.ErrorIs(t, recordLastDay(e, in), hr.ErrOverrideByRequester)
e.DeactivateByAdmin(t, e.Staff)
e.DeactivateByAdmin(t, e.HR)
second := e.Override(t, staff.ID, lastDay, e.Owner)
require.True(t, second.SoleOwner)
got := e.LastDay(t, staff.ID, in.NoticeOn, lastDay, e.Owner)
require.Equal(t, hr.StatusNotice, got.Status)
var after string
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT after::text FROM audit_log
WHERE entity_kind = 'lifecycle_override' AND entity_id = $1`, second.ID).Scan(&after))
require.Contains(t, after, `"SoleOwner": true`)
}
// Rulings H1-7 and H1-9: nobody overrides their own leaving; an override
// needs something to override; a last day already past applies at once.
func TestOverrideRefusalsAndABackdatedLastDay(t *testing.T) {
e := hrtest.New(t)
hrRecord := e.Hired(t, e.HR, "E-001")
staff := e.Hired(t, e.Staff, "E-100")
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.GrantLeavingOverride(ctx, tx, e.Clock, hr.OverrideInput{EmployeeID: hrRecord.ID,
LastDay: hrtest.Day(2026, 10, 31), Reason: "Own leaving", Actor: e.HR})
return err
})
require.ErrorIs(t, err, hr.ErrOwnLeaving)
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.GrantLeavingOverride(ctx, tx, e.Clock, hr.OverrideInput{EmployeeID: staff.ID,
LastDay: hrtest.Day(2026, 10, 31), Reason: "Nothing open", Actor: e.Owner})
return err
})
require.ErrorIs(t, err, hr.ErrNothingToOverride)
// Backdated: the last day was yesterday, so it applies in the same
// transaction — status left, login ended, primary seat closed today.
got := e.LastDay(t, staff.ID, hrtest.Day(2026, 9, 1), hrtest.Day(2026, 9, 20), e.HR)
require.Equal(t, hr.StatusLeft, got.Status)
require.False(t, got.MemberActive)
var validTo string
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT valid_to::text FROM member_position
WHERE member_id = $1 AND position_id = $2`, e.Staff, e.StaffSeat).Scan(&validTo))
require.Equal(t, "2026-09-21", validTo, "the seat ends the day after the last day")
}
  • Step 2: Run them to verify they fail

Run: go test ./internal/platform/hr/... -count=1 Expected: FAIL to compile — undefined: hr.RecordLastDay, hr.GrantLeavingOverride, hr.ObligationsError, ….

  • Step 3: Write the obligations

Create internal/platform/hr/obligations.go:

package hr
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/platform/advances"
"github.com/mcom/erp/internal/platform/pettycash"
"github.com/mcom/erp/internal/platform/workflow"
)
type OpenAdvance struct {
ID uuid.UUID `json:"id"`
Reference string `json:"reference"`
State string `json:"state"`
}
type OpenFloat struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Status string `json:"status"`
}
// Obligations is what the person still holds (Ruling H1-6). The JSON shape
// is also lifecycle_override.covers.
type Obligations struct {
Advances []OpenAdvance `json:"advances"`
Floats []OpenFloat `json:"floats"`
// PendingDocuments counts the person's own documents awaiting approval:
// a warning, never a block (Ruling H1-11).
PendingDocuments int `json:"pending_documents"`
}
// Blocking reports whether HD-6 refuses a last day.
func (o Obligations) Blocking() bool { return len(o.Advances) > 0 || len(o.Floats) > 0 }
// coveredBy reports whether every blocking item of o is in the snapshot c.
func (o Obligations) coveredBy(c Obligations) bool {
ids := map[uuid.UUID]bool{}
for _, a := range c.Advances {
ids[a.ID] = true
}
for _, f := range c.Floats {
ids[f.ID] = true
}
for _, a := range o.Advances {
if !ids[a.ID] {
return false
}
}
for _, f := range o.Floats {
if !ids[f.ID] {
return false
}
}
return true
}
// ObligationsError refuses a last day (HD-6) and says what is open.
type ObligationsError struct{ Obligations Obligations }
func (e *ObligationsError) Error() string {
return "hr: the person still holds an open advance or petty cash float"
}
// OpenObligations is what the employee holds now.
func OpenObligations(ctx context.Context, tx pgx.Tx, employeeID uuid.UUID) (Obligations, error) {
e, err := Get(ctx, tx, employeeID)
if err != nil {
return Obligations{}, err
}
return obligationsOf(ctx, tx, e)
}
func obligationsOf(ctx context.Context, tx pgx.Tx, e Employee) (Obligations, error) {
out := Obligations{Advances: []OpenAdvance{}, Floats: []OpenFloat{}}
held, err := advances.OpenHeldBy(ctx, tx, e.PartyID)
if err != nil {
return Obligations{}, err
}
for _, a := range held {
out.Advances = append(out.Advances, OpenAdvance{ID: a.ID, Reference: a.Reference, State: a.State})
}
if e.MemberID == nil {
return out, nil
}
f, err := pettycash.LiveFloatOf(ctx, tx, *e.MemberID)
switch {
case err == nil:
out.Floats = append(out.Floats, OpenFloat{ID: f.ID, Code: f.Code, Status: f.Status})
case !errors.Is(err, pettycash.ErrNoOpenFloat):
return Obligations{}, err
}
if out.PendingDocuments, err = workflow.PendingReportedBy(ctx, tx, *e.MemberID); err != nil {
return Obligations{}, err
}
return out, nil
}
  • Step 4: Write the last day, the override and the leaving

Create internal/platform/hr/leaving.go:

package hr
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/platform/identity/member"
"github.com/mcom/erp/internal/platform/orgstruct"
"github.com/mcom/erp/internal/platform/workflow"
)
// LastDayInput records notice given on NoticeOn, ending on LastDay (the
// last day worked; login ends after it).
type LastDayInput struct {
EmployeeID uuid.UUID
NoticeOn time.Time
LastDay time.Time
Reason string
Actor uuid.UUID
}
// Override is an Owner's leave to record one last day over what was open
// (HD-6, Ruling H1-7).
type Override struct {
ID uuid.UUID
EmployeeID uuid.UUID
LastDay time.Time
Reason string
Covers Obligations
GrantedBy uuid.UUID
SoleOwner bool
CreatedAt time.Time
}
type OverrideInput struct {
EmployeeID uuid.UUID
LastDay time.Time
Reason string
Actor uuid.UUID
}
const overrideReturning = `id, employee_id, last_day, reason, covers, granted_by, sole_owner, created_at`
func scanOverride(row pgx.Row) (Override, error) {
var o Override
var covers []byte
if err := row.Scan(&o.ID, &o.EmployeeID, &o.LastDay, &o.Reason, &covers, &o.GrantedBy, &o.SoleOwner, &o.CreatedAt); err != nil {
return Override{}, err
}
if err := json.Unmarshal(covers, &o.Covers); err != nil {
return Override{}, fmt.Errorf("hr: override covers: %w", err)
}
o.LastDay, o.CreatedAt = o.LastDay.UTC(), o.CreatedAt.UTC()
return o, nil
}
var errNoOverride = errors.New("hr: no unused override")
// usableOverride is the unused override for this employee and day that
// actor may rely on: one granted by somebody else first, then the actor's
// own sole-owner one, then any (which RecordLastDay then refuses as the
// requester's own). Several can exist: an Owner may grant again after the
// organization's membership changed.
func usableOverride(ctx context.Context, tx pgx.Tx, org, employeeID uuid.UUID, lastDay time.Time, actor uuid.UUID) (Override, error) {
rows, err := tx.Query(ctx, `SELECT o.id, o.employee_id, o.last_day, o.reason, o.covers, o.granted_by,
o.sole_owner, o.created_at
FROM lifecycle_override o
WHERE o.org_id = $1 AND o.employee_id = $2 AND o.last_day = $3
AND NOT EXISTS (SELECT 1 FROM employee_event ev WHERE ev.org_id = o.org_id AND ev.override_id = o.id)
ORDER BY o.created_at DESC, o.id`, org, employeeID, lastDay)
if err != nil {
return Override{}, fmt.Errorf("hr: override: %w", err)
}
defer rows.Close()
var all []Override
for rows.Next() {
o, err := scanOverride(rows)
if err != nil {
return Override{}, fmt.Errorf("hr: override: %w", err)
}
all = append(all, o)
}
if err := rows.Err(); err != nil {
return Override{}, fmt.Errorf("hr: override: %w", err)
}
if len(all) == 0 {
return Override{}, errNoOverride
}
for _, o := range all {
if o.GrantedBy != actor {
return o, nil
}
}
for _, o := range all {
if o.SoleOwner {
return o, nil
}
}
return all[0], nil
}
// GrantLeavingOverride lets a different member record LastDay while what is
// open now stays open (HD-6, Rulings H1-7, H1-8). The caller has checked
// that Actor holds PermissionLifecycleOverride.
func GrantLeavingOverride(ctx context.Context, tx pgx.Tx, c clock.Clock, in OverrideInput) (Override, error) {
org, err := orgOf(ctx)
if err != nil {
return Override{}, err
}
reason, err := checkReason("reason", in.Reason, true)
if err != nil {
return Override{}, err
}
e, err := lockEmployee(ctx, tx, in.EmployeeID)
if err != nil {
return Override{}, err
}
if e.Status != StatusActive && e.Status != StatusNotice {
return Override{}, ErrWrongStatus
}
if e.MemberID != nil && *e.MemberID == in.Actor {
return Override{}, ErrOwnLeaving
}
ob, err := obligationsOf(ctx, tx, e)
if err != nil {
return Override{}, err
}
if !ob.Blocking() {
return Override{}, ErrNothingToOverride
}
// The organization row is already this transaction's (lockEmployee),
// so the exception's own NOWAIT lock succeeds and its count is final.
sole, err := workflow.SoleOwnerException(ctx, tx, in.Actor, reason)
if err != nil {
return Override{}, err
}
covers, err := json.Marshal(ob)
if err != nil {
return Override{}, fmt.Errorf("hr: override covers: %w", err)
}
o, err := scanOverride(tx.QueryRow(ctx, `INSERT INTO lifecycle_override (org_id, employee_id, last_day, reason,
covers, granted_by, sole_owner, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING `+overrideReturning,
org, e.ID, in.LastDay, reason, covers, in.Actor, sole, c.Now()))
if err != nil {
return Override{}, fmt.Errorf("hr: grant override: %w", err)
}
if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityOverride, EntityID: o.ID, Action: "grant", After: o}); err != nil {
return Override{}, err
}
return o, nil
}
// RecordLastDay writes notice_given (spec H1, HD-6). Refused while the
// person holds an open advance or float, unless an unused override for this
// day, granted by another member (or under the sole-owner exception), covers
// all of it. A last day already past applies at once (Ruling H1-9).
func RecordLastDay(ctx context.Context, tx pgx.Tx, c clock.Clock, in LastDayInput) (Employee, Event, error) {
org, err := orgOf(ctx)
if err != nil {
return Employee{}, Event{}, err
}
reason, err := checkReason("reason", in.Reason, false)
if err != nil {
return Employee{}, Event{}, err
}
e, err := lockEmployee(ctx, tx, in.EmployeeID)
if err != nil {
return Employee{}, Event{}, err
}
if e.Status != StatusActive && e.Status != StatusNotice {
return Employee{}, Event{}, ErrWrongStatus
}
today := clock.Today(ctx, c)
switch {
case in.NoticeOn.After(today):
return Employee{}, Event{}, &FieldError{Field: "effective_on", Rule: "not_future", Detail: "notice is recorded on or after the day it was given"}
case in.LastDay.Before(in.NoticeOn):
return Employee{}, Event{}, &FieldError{Field: "last_day", Rule: "after_notice", Detail: "the last day is on or after the notice"}
case in.LastDay.Before(*e.HireDate):
return Employee{}, Event{}, &FieldError{Field: "last_day", Rule: "after_hire", Detail: "the last day is on or after the hire date"}
}
if e.MemberID != nil && e.MemberActive {
last, err := member.IsLastAdministrator(ctx, tx, *e.MemberID)
if err != nil {
return Employee{}, Event{}, err
}
if last {
return Employee{}, Event{}, member.ErrLastAdministrator
}
}
ob, err := obligationsOf(ctx, tx, e)
if err != nil {
return Employee{}, Event{}, err
}
var overrideID *uuid.UUID
if ob.Blocking() {
ov, err := usableOverride(ctx, tx, org, e.ID, in.LastDay, in.Actor)
if errors.Is(err, errNoOverride) || (err == nil && !ob.coveredBy(ov.Covers)) {
return Employee{}, Event{}, &ObligationsError{Obligations: ob}
}
if err != nil {
return Employee{}, Event{}, err
}
if ov.GrantedBy == in.Actor {
ok := ov.SoleOwner
if ok {
// Re-checked under the organization lock (Ruling H1-8).
if ok, err = workflow.SoleOwnerException(ctx, tx, in.Actor, ov.Reason); err != nil {
return Employee{}, Event{}, err
}
}
if !ok {
return Employee{}, Event{}, ErrOverrideByRequester
}
}
overrideID = &ov.ID
}
var lv *leaving
if in.LastDay.Before(today) {
// Before any audit row (Ruling H1-14).
if lv, err = lockLeaving(ctx, tx, e, in.LastDay); err != nil {
return Employee{}, Event{}, err
}
}
lastDay := in.LastDay
ev, err := insertEvent(ctx, tx, c, org, Event{EmployeeID: e.ID, Kind: EventNoticeGiven, EffectiveOn: in.NoticeOn,
LastDay: &lastDay, OverrideID: overrideID, Reason: reason, RecordedBy: &in.Actor})
if err != nil {
return Employee{}, Event{}, err
}
if lv != nil {
if err := lv.apply(ctx, tx, c); err != nil {
return Employee{}, Event{}, err
}
}
return finish(ctx, tx, c, e.ID, ev)
}
// leaving is one passed last day with every row applying it will change
// already locked.
type leaving struct {
org uuid.UUID
employee Employee
lastDay time.Time
held []orgstruct.Assignment
}
// lockLeaving takes the member row and the member's assignments from the
// day after the last day, before the caller writes any audit row. The
// organization and employee rows are the caller's (lockEmployee).
func lockLeaving(ctx context.Context, tx pgx.Tx, e Employee, lastDay time.Time) (*leaving, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
lv := &leaving{org: org, employee: e, lastDay: lastDay}
if e.MemberID == nil {
return lv, nil
}
if _, err := tx.Exec(ctx, `SELECT 1 FROM organization_member WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, *e.MemberID); err != nil {
return nil, fmt.Errorf("hr: lock member: %w", err)
}
if lv.held, err = orgstruct.LockAssignmentsFrom(ctx, tx, *e.MemberID, lastDay.AddDate(0, 0, 1)); err != nil {
return nil, err
}
return lv, nil
}
// apply writes left and its effects (spec H1 "On the last day", Ruling
// H1-9): the seats end the day after the last day, so they are vacant from
// then and on_vacancy: escalate resolves past them; the login ends. Claims,
// advances and payout profiles are not touched: open claims still settle.
func (lv *leaving) apply(ctx context.Context, tx pgx.Tx, c clock.Clock) error {
ev, err := insertEvent(ctx, tx, c, lv.org, Event{EmployeeID: lv.employee.ID, Kind: EventLeft, EffectiveOn: lv.lastDay})
if err != nil {
return err
}
if m := lv.employee.MemberID; m != nil {
if lv.employee.MemberActive {
if err := member.Deactivate(ctx, tx, c, *m); err != nil {
return err
}
}
end, today := lv.lastDay.AddDate(0, 0, 1), clock.Today(ctx, c)
for _, a := range lv.held {
switch {
case !a.ValidFrom.After(lv.lastDay): // held on the last day: ends the day after
err = orgstruct.CloseAssignment(ctx, tx, c, a.ID, end)
case !a.ValidFrom.Before(today): // not started: never in force
err = orgstruct.WithdrawAssignment(ctx, tx, c, a.ID)
default: // started after the last day, before a late run: ends today
err = orgstruct.CloseAssignment(ctx, tx, c, a.ID, today)
}
if err != nil {
return fmt.Errorf("hr: end assignment %s: %w", a.ID, err)
}
}
}
return audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityEmployee, EntityID: lv.employee.ID, Action: EventLeft, After: ev})
}
  • Step 5: Run the tests

Run: go test ./internal/platform/hr/... ./internal/kernel/layering/ -count=1 Expected: PASS.

  • Step 6: Watch the HD-6 drift test fail twice
  1. In RecordLastDay, change if ob.Blocking() { to if false && ob.Blocking() { and run go test ./internal/platform/hr/ -run TestALastDayIsRefusedWhileAnAdvanceIsOpen -count=1. Expected: FAIL — Should be in error chain: expected: *hr.ObligationsError. Restore.
  2. In RecordLastDay, delete the whole if ov.GrantedBy == in.Actor { … } block and run the same test. Expected: FAIL at the ErrOverrideByRequester assertion — the error is now the database’s employee_event_override_valid (the backstop holds, but the named refusal is gone). Then, with the Go block still deleted, comment out OR (o.granted_by = NEW.recorded_by AND NOT o.sole_owner) in 00800_employee.sql’s guard and run again. Expected: FAIL — An error is expected but got nil. Restore both; PASS.
  • Step 7: Commit
Terminal window
git add internal/platform/hr
git commit -m "Phase 5 H1: last day refused over an open advance or float; Owner-not-requester override (HD-6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 6: The hourly leavings job, and approvals moving to the next filled seat

Section titled “Task 6: The hourly leavings job, and approvals moving to the next filled seat”

Files:

  • Create: internal/platform/hr/job.go
  • Test: internal/platform/hr/effects_test.go
  • Modify: cmd/worker/exports.go, cmd/worker/main.go

Interfaces:

  • Consumes: Task 5’s lockLeaving, (*leaving).apply; Task 4’s lockEmployee; ops.org_ids() (00233); workflow.Resolve; jobs.QueueMaintenance.

  • Produces: DueLeavings(ctx context.Context, tx pgx.Tx, c clock.Clock) ([]uuid.UUID, error), ApplyLeaving(ctx context.Context, tx pgx.Tx, c clock.Clock, employeeID uuid.UUID) (bool, error), LeavingsArgs (River kind hr_apply_leavings, queue maintenance), LeavingsPeriodic() *river.PeriodicJob, LeavingsWorker{DB *db.DB; Clock clock.Clock; Logger *slog.Logger}.

  • Step 1: Write the failing tests

Create internal/platform/hr/effects_test.go:

package hr_test
import (
"context"
"log/slog"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/hr/hrtest"
"github.com/mcom/erp/internal/platform/workflow"
)
type discard struct{}
func (discard) Write(p []byte) (int, error) { return len(p), nil }
// Spec H1 "On the last day" and Ruling H1-10: once the last day has passed
// and the job has run, the seat is vacant, the login has ended, and an
// approval that named the seat resolves to the next filled seat above it
// through on_vacancy: escalate — nothing about the approval was rewritten.
func TestPassingTheLastDayEndsTheSeatAndTheLoginAndApprovalsMoveUp(t *testing.T) {
e := hrtest.New(t)
mgr, mgrSeat := e.Member(t, "Manager", &e.OwnerSeat)
clerk, _ := e.Member(t, "Clerk", &mgrSeat)
manager := e.Hired(t, mgr, "E-200")
e.LastDay(t, manager.ID, hrtest.Day(2026, 9, 21), hrtest.Day(2026, 9, 25), e.HR)
dayAfter := hrtest.Day(2026, 9, 26)
later := hrtest.At(dayAfter)
grant := workflow.Grant{GrantSpec: workflow.GrantSpec{StateKey: "approved", GranteeKind: workflow.GranteePosition,
PositionID: &mgrSeat, OnVacancy: workflow.VacancyEscalate, MaxEscalationLevels: 3}}
resolve := func() workflow.Resolved {
var r []workflow.Resolved
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
r, err = workflow.Resolve(ctx, tx, []workflow.Grant{grant}, clerk, dayAfter)
return err
})
return r[0]
}
// PRESENCE: before the job has run, the seat still resolves to the manager.
r := resolve()
require.Equal(t, []uuid.UUID{mgr}, r.Members)
require.False(t, r.Escalated)
var dueToday, dueLater []uuid.UUID
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
if dueToday, err = hr.DueLeavings(ctx, tx, e.Clock); err != nil {
return err
}
dueLater, err = hr.DueLeavings(ctx, tx, later)
return err
})
require.Empty(t, dueToday, "on the 21st the last day has not passed")
require.Equal(t, []uuid.UUID{manager.ID}, dueLater)
require.NoError(t, (&hr.LeavingsWorker{DB: e.DB, Clock: later, Logger: slog.New(slog.NewTextHandler(discard{}, nil))}).
Work(context.Background(), &river.Job[hr.LeavingsArgs]{}))
// ABSENCE of the manager: the approval moves up to the Owner's seat.
r = resolve()
require.Equal(t, []uuid.UUID{e.Owner}, r.Members)
require.True(t, r.Escalated)
var active bool
var validTo time.Time
require.NoError(t, e.H.Admin.QueryRow(context.Background(),
`SELECT active FROM organization_member WHERE id = $1`, mgr).Scan(&active))
require.False(t, active, "the login ended")
require.NoError(t, e.H.Admin.QueryRow(context.Background(),
`SELECT valid_to FROM member_position WHERE member_id = $1 AND position_id = $2`, mgr, mgrSeat).Scan(&validTo))
require.Equal(t, dayAfter, validTo.UTC(), "the seat ends the day after the last day")
var got hr.Employee
var applied bool
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
if got, err = hr.Get(ctx, tx, manager.ID); err != nil {
return err
}
applied, err = hr.ApplyLeaving(ctx, tx, later, manager.ID)
return err
})
require.Equal(t, hr.StatusLeft, got.Status)
require.Equal(t, hrtest.Day(2026, 9, 25), *got.LastDay)
require.False(t, applied, "a second run applies nothing")
}
// Spec H1 "Rehire": the party, the row and the number are reused, with a
// rehired event, and the new spell starts clean.
func TestARehireReusesTheRecord(t *testing.T) {
e := hrtest.New(t)
staff := e.Hired(t, e.Staff, "E-100")
e.LastDay(t, staff.ID, hrtest.Day(2026, 9, 1), hrtest.Day(2026, 9, 20), e.HR) // applied at once
var got hr.Employee
var ev hr.Event
c := hrtest.At(hrtest.Day(2026, 11, 2))
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
got, ev, err = hr.Hire(ctx, tx, c, hr.HireInput{EmployeeID: staff.ID, EffectiveOn: hrtest.Day(2026, 11, 2),
EmploymentType: hr.TypeContract, Actor: e.HR})
return err
})
require.Equal(t, hr.EventRehired, ev.Kind)
require.Equal(t, staff.ID, got.ID)
require.Equal(t, "E-100", got.EmployeeNo, "kept, not reissued")
require.Equal(t, hr.StatusActive, got.Status)
require.Equal(t, hrtest.Day(2026, 11, 2), *got.HireDate, "service counts from the rehire (Ruling H1-1)")
require.Nil(t, got.LastDay)
require.False(t, got.MemberActive, "Ruling H1-16: rehire does not restore login")
}
  • Step 2: Run them to verify they fail

Run: go test ./internal/platform/hr/ -run 'TestPassingTheLastDay|TestARehire' -count=1 Expected: FAIL to compile — undefined: hr.DueLeavings, hr.LeavingsWorker, hr.LeavingsArgs, hr.ApplyLeaving.

  • Step 3: Write the job

Create internal/platform/hr/job.go:

package hr
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/db"
"github.com/mcom/erp/internal/kernel/jobs"
"github.com/mcom/erp/internal/kernel/tenancy"
)
// DueLeavings is every employee in the context organization whose recorded
// last day has passed and whose leaving is not applied yet (Ruling H1-9).
func DueLeavings(ctx context.Context, tx pgx.Tx, c clock.Clock) ([]uuid.UUID, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `SELECT e.id FROM employee e CROSS JOIN LATERAL employee_spell(e.org_id, e.id) s
WHERE e.org_id = $1 AND s.status = 'notice' AND s.last_day < $2 ORDER BY e.id`, org, clock.Today(ctx, c))
if err != nil {
return nil, fmt.Errorf("hr: due leavings: %w", err)
}
ids, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
if err != nil {
return nil, fmt.Errorf("hr: due leavings: %w", err)
}
return ids, nil
}
// ApplyLeaving applies one passed last day, idempotently: false when the
// employee is no longer due (applied, superseded, or not passed).
func ApplyLeaving(ctx context.Context, tx pgx.Tx, c clock.Clock, employeeID uuid.UUID) (bool, error) {
e, err := lockEmployee(ctx, tx, employeeID)
if err != nil {
return false, err
}
if e.Status != StatusNotice || !e.LastDay.Before(clock.Today(ctx, c)) {
return false, nil
}
lv, err := lockLeaving(ctx, tx, e, *e.LastDay)
if err != nil {
return false, err
}
return true, lv.apply(ctx, tx, c)
}
// LeavingsArgs is the tick that applies passed last days.
type LeavingsArgs struct{}
func (LeavingsArgs) Kind() string { return "hr_apply_leavings" }
func (LeavingsArgs) InsertOpts() river.InsertOpts {
return river.InsertOpts{Queue: jobs.QueueMaintenance}
}
// LeavingsPeriodic runs hourly and on start, so access ends within the hour
// after a last day whenever the worker restarted (Ruling H1-9).
func LeavingsPeriodic() *river.PeriodicJob {
return river.NewPeriodicJob(river.PeriodicInterval(time.Hour),
func() (river.JobArgs, *river.InsertOpts) { return LeavingsArgs{}, nil },
&river.PeriodicJobOpts{RunOnStart: true})
}
// LeavingsWorker applies every organization's passed last days, each
// leaving in its own tenant transaction (Ruling H1-17). It runs whether or
// not the organization has the hr module on. One failure — the
// organization's last administrator, say — is logged and retried next hour;
// the others still apply, and the job itself succeeds.
type LeavingsWorker struct {
river.WorkerDefaults[LeavingsArgs]
DB *db.DB
Clock clock.Clock
Logger *slog.Logger
}
func (w *LeavingsWorker) Work(ctx context.Context, _ *river.Job[LeavingsArgs]) error {
// ponytail: every organization every hour, one indexed read each; an
// hr_due_orgs() function (einvoice_due_orgs' shape) is the upgrade when
// the organization count makes this slow.
rows, err := w.DB.Pool().Query(ctx, `SELECT org_id FROM ops.org_ids()`)
if err != nil {
return fmt.Errorf("hr_apply_leavings: org ids: %w", err)
}
orgs, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
if err != nil {
return fmt.Errorf("hr_apply_leavings: org ids: %w", err)
}
for _, org := range orgs {
octx := tenancy.WithOrg(ctx, org)
var due []uuid.UUID
if err := w.DB.InTx(octx, func(ctx context.Context, tx pgx.Tx) error {
var err error
due, err = DueLeavings(ctx, tx, w.Clock)
return err
}); err != nil {
w.Logger.Error("hr_apply_leavings: due", "org_id", org, "err", err)
continue
}
for _, id := range due {
if err := w.DB.InTx(octx, func(ctx context.Context, tx pgx.Tx) error {
_, err := ApplyLeaving(ctx, tx, w.Clock, id)
return err
}); err != nil {
w.Logger.Error("hr_apply_leavings: employee", "org_id", org, "employee_id", id, "err", err)
}
}
}
return nil
}
  • Step 4: Register the job in the worker

In cmd/worker/exports.go, add the import "github.com/mcom/erp/internal/platform/hr" and, after the opsjobs.ReconcileWorker line:

river.AddWorker(workers, &hr.LeavingsWorker{DB: database, Clock: clock.System(), Logger: cfg.Logger})

In cmd/worker/main.go, add the import "github.com/mcom/erp/internal/platform/hr" and change the PeriodicJobs line to:

PeriodicJobs: []*river.PeriodicJob{notifications.PrunePeriodic(), einvoice.SweepPeriodic(),
opsjobs.ReconcilePeriodic(), opsjobs.DispatchPeriodic(), hr.LeavingsPeriodic()}},
  • Step 5: Run the tests

Run: go test ./internal/platform/hr/... ./cmd/worker/ -count=1 && go build ./cmd/... Expected: PASS; the build succeeds.

  • Step 6: Watch the routing test fail once

In leaving.go’s apply, comment out the member.Deactivate call (the seat still closes) and run go test ./internal/platform/hr/ -run TestPassingTheLastDay -count=1. Expected: FAIL — the login ended: Should be false. Restore. Then comment out the for _, a := range lv.held { … } loop instead (the login ends, the seat stays assigned). Expected: FAIL at the valid_to read — cannot scan NULL into *time.Time (the approval still escalates, because activeOnly drops the inactive holder; the seat’s end is what this half guards). Restore; PASS.

  • Step 7: Commit
Terminal window
git add internal/platform/hr cmd/worker
git commit -m "Phase 5 H1: hourly leavings job — seats end, login ends, approvals escalate
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 7: The HTTP surface, permissions, Owner grants (00802) and module install

Section titled “Task 7: The HTTP surface, permissions, Owner grants (00802) and module install”

Files:

  • Modify: constants.yaml (permissions), then go run ./tools/vocabgen
  • Modify: api/openapi.yaml (paths, responses, header, schemas; OrgModule.key), then make generate
  • Create: internal/modules/hr/{permissions,routes,http,errors,handlers}.go
  • Test: internal/modules/hr/{routes,env,handlers,errors}_test.go
  • Create: db/migrations/00802_hr_owner_grants.sql, internal/platform/hr/migration00802_test.go
  • Modify: internal/platform/moduleinstall/moduleinstall.go
  • Modify: internal/app/app.go, internal/app/routes_test.go, cmd/api/main.go
  • Modify: cmd/orgadmin/main.go, cmd/orgadmin/wiring_test.go, cmd/migrate/main.go, cmd/migrate/wiring_test.go
  • Modify: web/src/lib/i18n/locales/{en,ms,zh}/modules.json

Interfaces:

  • Consumes: every platformhr function of Tasks 4–5; member.Can, member.Permissions, member.ErrLastAdministrator; orgstruct.ErrOverlappingPrimary, orgstruct.ErrNotFound, orgstruct.ErrPositionInactive; workflow.ReasonError; httpx.Protected, httpx.RequireModule (through app.Router’s optional).

  • Produces: hr.PermissionRead, hr.PermissionManage, hr.PermissionSensitive, hr.PermissionLifecycleOverride (module re-exports), hr.Deps{DB *db.DB; Clock clock.Clock}, hr.Register(g *gin.RouterGroup, deps Deps); routes GET|POST /v1/hr/employees, GET /v1/hr/employees/{id}, PUT /v1/hr/employees/{id}/identity, POST /v1/hr/employees/{id}/events, GET /v1/hr/employees/{id}/obligations, POST /v1/hr/employees/{id}/leaving-overrides; schemas Employee, EmployeeView, EmployeeEvent, EmployeeStatus, EmploymentType, EmployeeCreate, EmployeeIdentityInput, EmployeeEventInput, EmployeeObligations, LeavingOverrideInput, LeavingOverride; refusal codes open_obligations, override_by_requester, own_leaving_override, nothing_to_override, last_administrator, employee_status, employee_exists, duplicate_employee_no, duplicate_identity, employee_no_login, no_such_member, party_not_employee, overlapping_primary, position_unavailable, revision_mismatch; module key hr.

  • Step 1: Write the failing module tests

Create internal/modules/hr/routes_test.go:

package hr_test
import (
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/httpx"
"github.com/mcom/erp/internal/modules/hr"
)
// Each route's exact permission (Ruling H1-13): a write re-registered under
// the read key would hand every reader of a record the power to end a job.
func TestEveryRouteRequiresItsExactPermission(t *testing.T) {
gin.SetMode(gin.TestMode)
httpx.ResetAuthorizedRoutes()
_, api := httpx.NewRouter(httpx.Deps{})
hr.Register(api, hr.Deps{})
require.Equal(t, map[string]string{
"GET /v1/hr/employees": hr.PermissionRead,
"POST /v1/hr/employees": hr.PermissionManage,
"GET /v1/hr/employees/:id": hr.PermissionRead,
"PUT /v1/hr/employees/:id/identity": hr.PermissionSensitive,
"POST /v1/hr/employees/:id/events": hr.PermissionManage,
"GET /v1/hr/employees/:id/obligations": hr.PermissionRead,
"POST /v1/hr/employees/:id/leaving-overrides": hr.PermissionLifecycleOverride,
}, httpx.AuthorizedRoutes())
require.Equal(t, "hr.employee.read", hr.PermissionRead)
require.Equal(t, "hr.employee.manage", hr.PermissionManage)
require.Equal(t, "hr.employee.sensitive", hr.PermissionSensitive)
require.Equal(t, "hr.employee.lifecycle_override", hr.PermissionLifecycleOverride)
}

Create internal/modules/hr/env_test.go:

package hr_test
import (
"context"
"encoding/json"
"log/slog"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/authn"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/httpx"
"github.com/mcom/erp/internal/modules/hr"
"github.com/mcom/erp/internal/platform/expenses/expensestest"
"github.com/mcom/erp/internal/platform/identity/token"
)
type alwaysLive struct{}
func (alwaysLive) Check(context.Context, authn.Claims) error { return nil }
type discard struct{}
func (discard) Write(p []byte) (int, error) { return len(p), nil }
// world is expensestest's provisioned organization with THIS module mounted
// alone on a test router.
type world struct {
e *expensestest.Env
r *gin.Engine
signer *token.Signer
}
func newWorld(t *testing.T) *world {
t.Helper()
gin.SetMode(gin.TestMode)
e := expensestest.New(t)
signer, err := token.NewSigner(map[string][]byte{"k1": []byte("01234567890123456789012345678901")},
"k1", clock.Fixed(e.Clock.Now()))
require.NoError(t, err)
r, api := httpx.NewRouter(httpx.Deps{DB: e.DB, Logger: slog.New(slog.NewTextHandler(discard{}, nil)),
Version: "test", TokenVerifier: signer, Liveness: alwaysLive{}})
hr.Register(api, hr.Deps{DB: e.DB, Clock: e.Clock})
return &world{e: e, r: r, signer: signer}
}
// member mints a token for a fresh member holding exactly keys.
func (w *world) member(t *testing.T, keys ...string) (uuid.UUID, string) {
t.Helper()
ctx, admin := context.Background(), w.e.H.Admin
memberID, _ := w.e.Member(t, "M-"+uuid.NewString()[:8], &w.e.OwnerSeat)
var roleID uuid.UUID
require.NoError(t, admin.QueryRow(ctx, `INSERT INTO role (org_id, name, system) VALUES ($1,$2,true) RETURNING id`,
w.e.Org, "R-"+uuid.NewString()).Scan(&roleID))
for _, k := range keys {
_, err := admin.Exec(ctx, `INSERT INTO role_permission (org_id, role_id, permission_key) VALUES ($1,$2,$3)`, w.e.Org, roleID, k)
require.NoError(t, err)
}
_, err := admin.Exec(ctx, `INSERT INTO member_role (org_id, member_id, role_id) VALUES ($1,$2,$3)`, w.e.Org, memberID, roleID)
require.NoError(t, err)
tok, err := w.signer.Mint(authn.Claims{UserID: uuid.New(), OrgID: w.e.Org, MemberID: memberID, SessionID: uuid.New()})
require.NoError(t, err)
return memberID, tok
}
func (w *world) do(t *testing.T, method, path, body, tok, ifMatch string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
if ifMatch != "" {
req.Header.Set("If-Match", ifMatch)
}
rec := httptest.NewRecorder()
w.r.ServeHTTP(rec, req)
return rec
}
func jsonField(t *testing.T, rec *httptest.ResponseRecorder, field string) string {
t.Helper()
var body map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
v, ok := body[field].(string)
require.True(t, ok, "field %q not found or not a string in %s", field, rec.Body.String())
return v
}

Create internal/modules/hr/handlers_test.go:

package hr_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/modules/hr"
)
// Spec H1 "Personal data" (Ruling H1-5): the identity number is shown to and
// set by hr.employee.sensitive holders only.
func TestTheIdentityNumberIsForSensitiveHoldersOnly(t *testing.T) {
w := newWorld(t)
_, manager := w.member(t, hr.PermissionRead, hr.PermissionManage)
_, sensitive := w.member(t, hr.PermissionRead, hr.PermissionSensitive)
_, reader := w.member(t, hr.PermissionRead)
res := w.do(t, "POST", "/v1/hr/employees", `{"legal_name":"Siti binti Ahmad","employee_no":"E-900"}`, manager, "")
require.Equal(t, http.StatusCreated, res.Code, res.Body.String())
id := jsonField(t, res, "id")
require.Contains(t, res.Body.String(), `"status":"pending"`)
require.Equal(t, http.StatusForbidden,
w.do(t, "POST", "/v1/hr/employees", `{"legal_name":"Ali","employee_no":"E-901"}`, reader, "").Code)
dup := w.do(t, "POST", "/v1/hr/employees", `{"legal_name":"Ali","employee_no":"E-900"}`, manager, "")
require.Equal(t, http.StatusConflict, dup.Code)
require.Contains(t, dup.Body.String(), "duplicate_employee_no")
etag := w.do(t, "GET", "/v1/hr/employees/"+id, "", sensitive, "").Header().Get("ETag")
set := w.do(t, "PUT", "/v1/hr/employees/"+id+"/identity", `{"id_kind":"nric","id_number":"900101-14-5678"}`, sensitive, etag)
require.Equal(t, http.StatusOK, set.Code, set.Body.String())
require.Contains(t, set.Body.String(), `"id_number":"900101145678"`, "PRESENCE: the holder sees it")
got := w.do(t, "GET", "/v1/hr/employees/"+id, "", reader, "")
require.Equal(t, http.StatusOK, got.Code)
require.NotContains(t, got.Body.String(), "900101145678", "ABSENCE: a reader does not")
require.Contains(t, got.Body.String(), `"identity_hidden":true`)
require.Contains(t, got.Body.String(), `"id_kind":"nric"`)
require.NotContains(t, w.do(t, "GET", "/v1/hr/employees?q=siti", "", reader, "").Body.String(), "900101145678")
require.Equal(t, http.StatusForbidden,
w.do(t, "PUT", "/v1/hr/employees/"+id+"/identity", `{"id_kind":null,"id_number":null}`, manager, etag).Code)
}
func TestTheLifecycleRoutes(t *testing.T) {
w := newWorld(t)
_, manager := w.member(t, hr.PermissionRead, hr.PermissionManage)
_, owner := w.member(t, hr.PermissionRead, hr.PermissionLifecycleOverride)
res := w.do(t, "POST", "/v1/hr/employees", `{"legal_name":"Siti binti Ahmad","employee_no":"E-900"}`, manager, "")
require.Equal(t, http.StatusCreated, res.Code, res.Body.String())
id := jsonField(t, res, "id")
events := "/v1/hr/employees/" + id + "/events"
hire := w.do(t, "POST", events, `{"kind":"hired","effective_on":"2026-01-05","employment_type":"probation"}`, manager, "")
require.Equal(t, http.StatusCreated, hire.Code, hire.Body.String())
require.Contains(t, hire.Body.String(), `"status":"active"`)
require.Contains(t, hire.Body.String(), `"kind":"hired"`)
again := w.do(t, "POST", events, `{"kind":"hired","effective_on":"2026-02-05","employment_type":"permanent"}`, manager, "")
require.Equal(t, http.StatusConflict, again.Code)
require.Contains(t, again.Body.String(), "employee_status")
require.Equal(t, http.StatusUnprocessableEntity,
w.do(t, "POST", events, `{"kind":"left","effective_on":"2026-02-05"}`, manager, "").Code, "left is the system's")
confirm := w.do(t, "POST", events, `{"kind":"confirmed","effective_on":"2026-04-05","employment_type":"permanent"}`, manager, "")
require.Equal(t, http.StatusCreated, confirm.Code, confirm.Body.String())
require.Contains(t, confirm.Body.String(), `"employment_type":"permanent"`)
ob := w.do(t, "GET", "/v1/hr/employees/"+id+"/obligations", "", manager, "")
require.Equal(t, http.StatusOK, ob.Code)
require.JSONEq(t, `{"advances":[],"floats":[],"pending_documents":0,"blocking":false}`, ob.Body.String())
override := "/v1/hr/employees/" + id + "/leaving-overrides"
body := `{"last_day":"2026-10-31","reason":"Final pay covers it"}`
require.Equal(t, http.StatusForbidden, w.do(t, "POST", override, body, manager, "").Code)
nothing := w.do(t, "POST", override, body, owner, "")
require.Equal(t, http.StatusConflict, nothing.Code, "PRESENCE of the route for its holder")
require.Contains(t, nothing.Body.String(), "nothing_to_override")
notice := w.do(t, "POST", events, `{"kind":"notice_given","effective_on":"2026-09-21","last_day":"2026-10-31","reason":"Resigned"}`, manager, "")
require.Equal(t, http.StatusCreated, notice.Code, notice.Body.String())
require.Contains(t, notice.Body.String(), `"status":"notice"`)
require.Contains(t, notice.Body.String(), `"last_day":"2026-10-31"`)
}

Create internal/modules/hr/errors_test.go:

package hr
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
apperr "github.com/mcom/erp/internal/kernel/errors"
"github.com/mcom/erp/internal/platform/identity/member"
p "github.com/mcom/erp/internal/platform/hr"
)
// Every HR refusal a screen shows is named by a stable code, and the HD-6
// refusal carries what is still open.
func TestTheRefusalsCarryTheirCodes(t *testing.T) {
cases := map[error]string{
&p.ObligationsError{Obligations: p.Obligations{Advances: []p.OpenAdvance{{Reference: "ADV-2026-0001"}}}}: "open_obligations",
p.ErrOverrideByRequester: "override_by_requester",
p.ErrOwnLeaving: "own_leaving_override",
p.ErrNothingToOverride: "nothing_to_override",
member.ErrLastAdministrator: "last_administrator",
p.ErrWrongStatus: "employee_status",
p.ErrPartyHasEmployee: "employee_exists",
p.ErrDuplicateEmployeeNo: "duplicate_employee_no",
p.ErrNoLogin: "employee_no_login",
&p.RevisionMismatchError{Current: 2}: "revision_mismatch",
}
for err, code := range cases {
prob, ok := mapErr(err).(*apperr.Problem)
require.True(t, ok, "%v is mapped", err)
b, jerr := json.Marshal(prob)
require.NoError(t, jerr)
require.Contains(t, string(b), `"code":"`+code+`"`, "%v", err)
}
b, _ := json.Marshal(mapErr(&p.ObligationsError{Obligations: p.Obligations{
Advances: []p.OpenAdvance{{Reference: "ADV-2026-0001"}}}}))
require.Contains(t, string(b), "ADV-2026-0001", "the refusal names the advance")
}
  • Step 2: Run them to verify they fail

Run: go test ./internal/modules/hr/ -count=1 Expected: FAIL to compile — package internal/modules/hr has no Go files / undefined: hr.Register.

  • Step 3: Declare the permissions and the vocabulary

In constants.yaml, append to permissions after - einvoice.settings.manage:

- hr.employee.read
- hr.employee.manage
- hr.employee.sensitive
- hr.employee.lifecycle_override

Run: go run ./tools/vocabgen Expected: internal/kernel/vocab/vocab.gen.go and packages/contracts/vocab.ts list the four keys.

Create internal/modules/hr/permissions.go:

package hr
import (
platformhr "github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/identity/member"
)
// The four keys (Ruling H1-13). They are Owner-only by default: a new
// Owner is seeded with every registered key, and 00802 grants existing
// Owners. Employee records are personal data, so no other role gets them
// until the Owner decides.
const (
PermissionRead = platformhr.PermissionRead
PermissionManage = platformhr.PermissionManage
PermissionSensitive = platformhr.PermissionSensitive
PermissionLifecycleOverride = platformhr.PermissionLifecycleOverride
)
func init() {
member.RegisterPermission(PermissionRead, "Read employee records and their lifecycle")
member.RegisterPermission(PermissionManage, "Open employee records and record hires, confirmations, transfers and last days")
member.RegisterPermission(PermissionSensitive, "See and change an employee's identity document number (personal data)")
member.RegisterPermission(PermissionLifecycleOverride,
"Allow another member to record a last day while the person still holds an advance or petty cash float, with a reason")
}
  • Step 4: Write the HTTP surface

Create internal/modules/hr/http.go:

// Package hr is the HTTP surface of the HR layer's employee record (Phase 5
// H1): internal/platform/hr owns every rule and writes every audit row;
// this package parses requests, calls it inside one transaction, and maps
// its refusals onto HTTP statuses. Errors are recorded (c.Error), never
// written; every request, reads included, runs in a transaction.
package hr
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/mcom/erp/internal/kernel/audit"
apperr "github.com/mcom/erp/internal/kernel/errors"
)
// dateLayout is the wire form of a DATE.
const dateLayout = "2006-01-02"
// decodeStrict reads exactly one JSON object into v and refuses a field v
// does not declare (a copy of the other modules' rule, not an import: a
// module does not import another module).
func decodeStrict(c *gin.Context, v any) error {
if c.Request.Body == nil {
return apperr.New(apperr.KindBadRequest, "a JSON request body is required")
}
raw, err := io.ReadAll(c.Request.Body)
if err != nil {
return apperr.New(apperr.KindBadRequest, "the request body could not be read")
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
if errors.Is(err, io.EOF) {
return apperr.New(apperr.KindBadRequest, "a JSON request body is required")
}
const unknown = "json: unknown field "
if msg := err.Error(); strings.HasPrefix(msg, unknown) {
return apperr.New(apperr.KindBadRequest, "field "+strings.TrimPrefix(msg, unknown)+" is not accepted by this endpoint")
}
return apperr.New(apperr.KindBadRequest, "the request body is not this endpoint's JSON shape")
}
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
return apperr.New(apperr.KindBadRequest, "the request body must be a single JSON object")
}
return nil
}
func parseID(c *gin.Context, name string) (uuid.UUID, error) {
id, err := uuid.Parse(c.Param(name))
if err != nil {
return uuid.Nil, apperr.New(apperr.KindValidation, "the "+name+" in the path must be a UUID")
}
return id, nil
}
// parseDate reads a YYYY-MM-DD field as midnight UTC, the shape every DATE
// column stores.
func parseDate(field, s string, required bool) (*time.Time, error) {
if s == "" {
if required {
return nil, apperr.New(apperr.KindValidation, "field="+field+" rule=required: a date is required").With("field", field)
}
return nil, nil
}
t, err := time.Parse(dateLayout, s)
if err != nil {
return nil, apperr.New(apperr.KindValidation, "field="+field+" rule=date: a date is YYYY-MM-DD").With("field", field)
}
return &t, nil
}
func dateStr(t *time.Time) *string {
if t == nil {
return nil
}
s := t.Format(dateLayout)
return &s
}
// mustActor is the calling member, from the verified token.
func mustActor(ctx context.Context) (uuid.UUID, error) {
a, ok := audit.ActorFromContext(ctx)
if !ok {
return uuid.Nil, apperr.New(apperr.KindUnauthorized, "authentication required")
}
return a.MemberID, nil
}
// ifMatch is the revision the caller read the record at; required.
func ifMatch(c *gin.Context) (int, error) {
raw := strings.Trim(strings.TrimPrefix(strings.TrimSpace(c.GetHeader("If-Match")), "W/"), `"`)
if raw == "" {
return 0, apperr.New(apperr.KindBadRequest, "If-Match is required: send the ETag of the record you are editing")
}
rev, err := strconv.Atoi(raw)
if err != nil || rev < 1 {
return 0, apperr.New(apperr.KindBadRequest, "If-Match must be the ETag of the record you are editing")
}
return rev, nil
}
func setETag(c *gin.Context, rev int) { c.Header("ETag", strconv.Quote(strconv.Itoa(rev))) }
func firstErr(errs ...error) error {
for _, err := range errs {
if err != nil {
return err
}
}
return nil
}

Create internal/modules/hr/routes.go:

package hr
import (
"context"
"fmt"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/db"
apperr "github.com/mcom/erp/internal/kernel/errors"
"github.com/mcom/erp/internal/kernel/httpx"
"github.com/mcom/erp/internal/platform/identity/member"
)
type Deps struct {
DB *db.DB
Clock clock.Clock
}
// Register hangs the HR routes off the module's group (app.Router gives it
// the hr install switch). Every route is behind httpx.Protected; the
// sensitive and override keys gate their own routes (Ruling H1-13).
func Register(g *gin.RouterGroup, deps Deps) {
h := handlers{deps: deps}
pl := permissionLoader{db: deps.DB}
httpx.Protected(g, pl, "GET", "/hr/employees", PermissionRead, h.list)
httpx.Protected(g, pl, "POST", "/hr/employees", PermissionManage, h.create)
httpx.Protected(g, pl, "GET", "/hr/employees/:id", PermissionRead, h.get)
httpx.Protected(g, pl, "PUT", "/hr/employees/:id/identity", PermissionSensitive, h.setIdentity)
httpx.Protected(g, pl, "POST", "/hr/employees/:id/events", PermissionManage, h.recordEvent)
httpx.Protected(g, pl, "GET", "/hr/employees/:id/obligations", PermissionRead, h.obligations)
httpx.Protected(g, pl, "POST", "/hr/employees/:id/leaving-overrides", PermissionLifecycleOverride, h.grantOverride)
}
type handlers struct{ deps Deps }
// permissionLoader adapts member.Permissions to httpx.PermissionLoader (the
// other modules' few lines, copied).
type permissionLoader struct{ db *db.DB }
var _ httpx.PermissionLoader = permissionLoader{}
func (p permissionLoader) Permissions(ctx context.Context, memberID uuid.UUID) (map[string]struct{}, error) {
if p.db == nil {
return nil, apperr.New(apperr.KindInternal, "authorization is not configured")
}
var out map[string]struct{}
err := p.db.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var err error
out, err = member.Permissions(ctx, tx, memberID)
return err
})
if err != nil {
return nil, fmt.Errorf("hr: load permissions: %w", err)
}
return out, nil
}

Create internal/modules/hr/errors.go:

package hr
import (
"errors"
"fmt"
apperr "github.com/mcom/erp/internal/kernel/errors"
p "github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/identity/member"
"github.com/mcom/erp/internal/platform/orgstruct"
"github.com/mcom/erp/internal/platform/workflow"
)
func coded(kind apperr.Kind, code, detail string) *apperr.Problem {
return apperr.New(kind, detail).With("code", code)
}
// mapErr turns platform refusals into RFC 7807 problems: 409 the same
// request would succeed against the records in another state; 422 the
// request itself is wrong; 403 a rule reserved for another member.
func mapErr(err error) error {
var (
field *p.FieldError
rev *p.RevisionMismatchError
ob *p.ObligationsError
reason *workflow.ReasonError
)
switch {
case errors.Is(err, p.ErrNotFound):
return apperr.New(apperr.KindNotFound, "no such employee in this organization")
case errors.As(err, &field):
return apperr.New(apperr.KindValidation, field.Error()).With("field", field.Field)
case errors.As(err, &reason):
return apperr.New(apperr.KindValidation, reason.Error()).With("field", "reason")
case errors.As(err, &rev):
return coded(apperr.KindConflict, "revision_mismatch",
fmt.Sprintf("the employee is at revision %d; reload and retry", rev.Current))
case errors.As(err, &ob):
return coded(apperr.KindConflict, "open_obligations",
"the person still holds an open advance or petty cash float; an Owner other than you can allow this last day").
With("obligations", obligationsDTO{Obligations: ob.Obligations, Blocking: true})
case errors.Is(err, p.ErrOverrideByRequester):
return coded(apperr.KindForbidden, "override_by_requester", "an Owner other than you must allow this last day")
case errors.Is(err, p.ErrOwnLeaving):
return coded(apperr.KindForbidden, "own_leaving_override", "nobody allows their own leaving")
case errors.Is(err, p.ErrNothingToOverride):
return coded(apperr.KindConflict, "nothing_to_override", "the person holds no open advance or float")
case errors.Is(err, member.ErrLastAdministrator):
return coded(apperr.KindConflict, "last_administrator",
"this is the organization's last member who can manage roles; give another member that role first")
case errors.Is(err, p.ErrWrongStatus):
return coded(apperr.KindConflict, "employee_status", "the employee's status does not allow this")
case errors.Is(err, p.ErrPartyHasEmployee):
return coded(apperr.KindConflict, "employee_exists", "this person already has an employee record")
case errors.Is(err, p.ErrDuplicateEmployeeNo):
return coded(apperr.KindConflict, "duplicate_employee_no", "another employee has this number").With("field", "employee_no")
case errors.Is(err, p.ErrDuplicateIdentity):
return coded(apperr.KindConflict, "duplicate_identity", "another employee has this identity document").With("field", "id_number")
case errors.Is(err, p.ErrNoLogin):
return coded(apperr.KindConflict, "employee_no_login", "the employee has no active login, so holds no seat")
case errors.Is(err, p.ErrNoSuchMember):
return coded(apperr.KindValidation, "no_such_member", "no such member in this organization").With("field", "member_id")
case errors.Is(err, p.ErrPartyNotEmployee):
return coded(apperr.KindValidation, "party_not_employee", "an employee record needs an employee party")
case errors.Is(err, orgstruct.ErrOverlappingPrimary):
return coded(apperr.KindConflict, "overlapping_primary", "the member already has a primary seat from that day")
case errors.Is(err, orgstruct.ErrNotFound), errors.Is(err, orgstruct.ErrPositionInactive):
return coded(apperr.KindValidation, "position_unavailable", "no such active position").With("field", "position_id")
}
return err
}

Create internal/modules/hr/handlers.go:

package hr
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
apperr "github.com/mcom/erp/internal/kernel/errors"
platformhr "github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/identity/member"
)
type employeeDTO struct {
ID uuid.UUID `json:"id"`
PartyID uuid.UUID `json:"party_id"`
LegalName string `json:"legal_name"`
EmployeeNo string `json:"employee_no"`
MemberID *uuid.UUID `json:"member_id"`
MemberActive bool `json:"member_active"`
IDKind *string `json:"id_kind"`
IDNumber *string `json:"id_number"`
IdentityHidden bool `json:"identity_hidden"`
HireDate *string `json:"hire_date"`
EmploymentType *string `json:"employment_type"`
LastDay *string `json:"last_day"`
Status string `json:"status"`
Revision int `json:"revision"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type eventDTO struct {
ID uuid.UUID `json:"id"`
Seq int64 `json:"seq"`
Kind string `json:"kind"`
EffectiveOn string `json:"effective_on"`
LastDay *string `json:"last_day"`
EmploymentType *string `json:"employment_type"`
FromPositionID *uuid.UUID `json:"from_position_id"`
ToPositionID *uuid.UUID `json:"to_position_id"`
OverrideID *uuid.UUID `json:"override_id"`
Reason string `json:"reason"`
RecordedBy *uuid.UUID `json:"recorded_by"`
RecordedAt time.Time `json:"recorded_at"`
}
type employeeView struct {
employeeDTO
Events []eventDTO `json:"events"`
}
type obligationsDTO struct {
platformhr.Obligations
Blocking bool `json:"blocking"`
}
type overrideDTO struct {
ID uuid.UUID `json:"id"`
EmployeeID uuid.UUID `json:"employee_id"`
LastDay string `json:"last_day"`
Reason string `json:"reason"`
Covers obligationsDTO `json:"covers"`
GrantedBy uuid.UUID `json:"granted_by"`
SoleOwner bool `json:"sole_owner"`
CreatedAt time.Time `json:"created_at"`
}
// toEmployeeDTO withholds the identity number from a caller without
// hr.employee.sensitive (Ruling H1-5).
func toEmployeeDTO(e platformhr.Employee, sensitive bool) employeeDTO {
d := employeeDTO{ID: e.ID, PartyID: e.PartyID, LegalName: e.LegalName, EmployeeNo: e.EmployeeNo,
MemberID: e.MemberID, MemberActive: e.MemberActive, IDKind: e.IDKind, IDNumber: e.IDNumber,
HireDate: dateStr(e.HireDate), EmploymentType: e.EmploymentType, LastDay: dateStr(e.LastDay),
Status: e.Status, Revision: e.Revision, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt}
if !sensitive && e.IDNumber != nil {
d.IDNumber, d.IdentityHidden = nil, true
}
return d
}
func toEventDTO(ev platformhr.Event) eventDTO {
return eventDTO{ID: ev.ID, Seq: ev.Seq, Kind: ev.Kind, EffectiveOn: ev.EffectiveOn.Format(dateLayout),
LastDay: dateStr(ev.LastDay), EmploymentType: ev.EmploymentType, FromPositionID: ev.FromPositionID,
ToPositionID: ev.ToPositionID, OverrideID: ev.OverrideID, Reason: ev.Reason, RecordedBy: ev.RecordedBy,
RecordedAt: ev.RecordedAt}
}
func (h handlers) list(c *gin.Context) {
who, err := mustActor(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
out := []employeeDTO{}
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
es, err := platformhr.List(ctx, tx, platformhr.Filter{Status: c.Query("status"), Query: c.Query("q")})
if err != nil {
return err
}
sensitive, err := member.Can(ctx, tx, who, PermissionSensitive)
if err != nil {
return err
}
for _, e := range es {
out = append(out, toEmployeeDTO(e, sensitive))
}
return nil
}); err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusOK, gin.H{"employees": out})
}
// view runs fn in a transaction and answers the employee it returns, with
// its lifecycle, as the caller may see it.
func (h handlers) view(c *gin.Context, status int, fn func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (platformhr.Employee, error)) {
who, err := mustActor(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
var out employeeView
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
e, err := fn(ctx, tx, who)
if err != nil {
return err
}
sensitive, err := member.Can(ctx, tx, who, PermissionSensitive)
if err != nil {
return err
}
evs, err := platformhr.Events(ctx, tx, e.ID)
if err != nil {
return err
}
out = employeeView{employeeDTO: toEmployeeDTO(e, sensitive), Events: make([]eventDTO, 0, len(evs))}
for _, ev := range evs {
out.Events = append(out.Events, toEventDTO(ev))
}
return nil
}); err != nil {
_ = c.Error(mapErr(err))
return
}
setETag(c, out.Revision)
c.JSON(status, out)
}
func (h handlers) create(c *gin.Context) {
var body struct {
MemberID *uuid.UUID `json:"member_id"`
LegalName string `json:"legal_name"`
EmployeeNo string `json:"employee_no"`
}
if err := decodeStrict(c, &body); err != nil {
_ = c.Error(err)
return
}
h.view(c, http.StatusCreated, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) (platformhr.Employee, error) {
return platformhr.Create(ctx, tx, h.deps.Clock, platformhr.CreateInput{MemberID: body.MemberID,
LegalName: body.LegalName, EmployeeNo: body.EmployeeNo})
})
}
func (h handlers) get(c *gin.Context) {
id, err := parseID(c, "id")
if err != nil {
_ = c.Error(err)
return
}
h.view(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) (platformhr.Employee, error) {
return platformhr.Get(ctx, tx, id)
})
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
func (h handlers) setIdentity(c *gin.Context) {
id, err := parseID(c, "id")
rev, err2 := ifMatch(c)
var body struct {
IDKind *string `json:"id_kind"`
IDNumber *string `json:"id_number"`
}
if err := firstErr(err, err2, decodeStrict(c, &body)); err != nil {
_ = c.Error(err)
return
}
h.view(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) (platformhr.Employee, error) {
return platformhr.SetIdentity(ctx, tx, h.deps.Clock, id, rev, deref(body.IDKind), deref(body.IDNumber))
})
}
// recordEvent is the one lifecycle endpoint: hired (rehired after a left),
// confirmed, transferred or notice_given. left is the system's (Ruling H1-2).
func (h handlers) recordEvent(c *gin.Context) {
id, err := parseID(c, "id")
var body struct {
Kind string `json:"kind"`
EffectiveOn string `json:"effective_on"`
EmploymentType string `json:"employment_type"`
PositionID *uuid.UUID `json:"position_id"`
LastDay string `json:"last_day"`
Reason string `json:"reason"`
}
if err := firstErr(err, decodeStrict(c, &body)); err != nil {
_ = c.Error(err)
return
}
on, err := parseDate("effective_on", body.EffectiveOn, true)
if err != nil {
_ = c.Error(err)
return
}
var record func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (platformhr.Employee, error)
switch body.Kind {
case platformhr.EventHired:
record = func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (platformhr.Employee, error) {
e, _, err := platformhr.Hire(ctx, tx, h.deps.Clock, platformhr.HireInput{EmployeeID: id, EffectiveOn: *on,
EmploymentType: body.EmploymentType, Reason: body.Reason, Actor: who})
return e, err
}
case platformhr.EventConfirmed:
record = func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (platformhr.Employee, error) {
e, _, err := platformhr.Confirm(ctx, tx, h.deps.Clock, platformhr.ConfirmInput{EmployeeID: id, EffectiveOn: *on,
EmploymentType: body.EmploymentType, Reason: body.Reason, Actor: who})
return e, err
}
case platformhr.EventTransferred:
if body.PositionID == nil {
_ = c.Error(apperr.New(apperr.KindValidation, "field=position_id rule=required: a transfer names the new seat").
With("field", "position_id"))
return
}
record = func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (platformhr.Employee, error) {
e, _, err := platformhr.Transfer(ctx, tx, h.deps.Clock, platformhr.TransferInput{EmployeeID: id,
PositionID: *body.PositionID, EffectiveOn: *on, Reason: body.Reason, Actor: who})
return e, err
}
case platformhr.EventNoticeGiven:
last, err := parseDate("last_day", body.LastDay, true)
if err != nil {
_ = c.Error(err)
return
}
record = func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (platformhr.Employee, error) {
e, _, err := platformhr.RecordLastDay(ctx, tx, h.deps.Clock, platformhr.LastDayInput{EmployeeID: id,
NoticeOn: *on, LastDay: *last, Reason: body.Reason, Actor: who})
return e, err
}
default:
_ = c.Error(apperr.New(apperr.KindValidation, "field=kind rule=known: hired, confirmed, transferred or notice_given").
With("field", "kind"))
return
}
h.view(c, http.StatusCreated, record)
}
func (h handlers) obligations(c *gin.Context) {
id, err := parseID(c, "id")
if err != nil {
_ = c.Error(err)
return
}
var out obligationsDTO
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
o, err := platformhr.OpenObligations(ctx, tx, id)
out = obligationsDTO{Obligations: o, Blocking: o.Blocking()}
return err
}); err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusOK, out)
}
func (h handlers) grantOverride(c *gin.Context) {
id, err := parseID(c, "id")
var body struct {
LastDay string `json:"last_day"`
Reason string `json:"reason"`
}
if err := firstErr(err, decodeStrict(c, &body)); err != nil {
_ = c.Error(err)
return
}
last, err := parseDate("last_day", body.LastDay, true)
if err != nil {
_ = c.Error(err)
return
}
who, err := mustActor(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
var out overrideDTO
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
o, err := platformhr.GrantLeavingOverride(ctx, tx, h.deps.Clock, platformhr.OverrideInput{EmployeeID: id,
LastDay: *last, Reason: body.Reason, Actor: who})
if err != nil {
return err
}
out = overrideDTO{ID: o.ID, EmployeeID: o.EmployeeID, LastDay: o.LastDay.Format(dateLayout), Reason: o.Reason,
Covers: obligationsDTO{Obligations: o.Covers, Blocking: o.Covers.Blocking()}, GrantedBy: o.GrantedBy,
SoleOwner: o.SoleOwner, CreatedAt: o.CreatedAt}
return nil
}); err != nil {
_ = c.Error(mapErr(err))
return
}
c.JSON(http.StatusCreated, out)
}
  • Step 5: Declare the routes in the contract

In api/openapi.yaml, insert immediately above the top-level components: line:

/hr/employees:
get:
operationId: listEmployees
tags: [HR]
summary: Employee records
description: >
Every employee record by employee number, with hire date,
employment type, last day and status derived from its lifecycle.
id_number is null and identity_hidden true unless the caller holds
hr.employee.sensitive. Requires hr.employee.read.
parameters:
- name: status
in: query
required: false
schema: { $ref: '#/components/schemas/EmployeeStatus' }
- name: q
in: query
required: false
schema: { type: string }
description: Part of a legal name or an employee number, ignoring case.
responses:
'200':
description: The employees
content:
application/json:
schema:
type: object
required: [employees]
properties:
employees: { type: array, items: { $ref: '#/components/schemas/Employee' } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/HRForbidden' }
post:
operationId: createEmployee
tags: [HR]
summary: Open an employee record
description: >
For an existing member (member_id: their party becomes the
employee's) or a person with no login (legal_name: a new employee
party), never both. The record is pending until a hire is recorded.
409 employee_exists or duplicate_employee_no; 422 no_such_member.
Requires hr.employee.manage.
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/EmployeeCreate' } } }
responses:
'201':
description: The record and its (empty) lifecycle
headers: { ETag: { $ref: '#/components/headers/HRRevision' } }
content: { application/json: { schema: { $ref: '#/components/schemas/EmployeeView' } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/HRForbidden' }
'409': { $ref: '#/components/responses/HRConflict' }
'422': { $ref: '#/components/responses/HRInvalid' }
/hr/employees/{id}:
get:
operationId: getEmployee
tags: [HR]
summary: One employee record and its lifecycle
description: Requires hr.employee.read. id_number as for listEmployees.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
description: The record and its lifecycle, in recording order
headers: { ETag: { $ref: '#/components/headers/HRRevision' } }
content: { application/json: { schema: { $ref: '#/components/schemas/EmployeeView' } } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/HRForbidden' }
'404': { $ref: '#/components/responses/HRNotFound' }
'422': { $ref: '#/components/responses/HRInvalid' }
/hr/employees/{id}/identity:
put:
operationId: setEmployeeIdentity
tags: [HR]
summary: Set or clear the identity document
description: >
Personal data: requires hr.employee.sensitive. An NRIC is stored as
its 12 digits; a passport as 5 to 20 letters and digits; other as
1 to 40 characters. Both null clears it. The audit trail records
the change, never the number. 409 duplicate_identity or
revision_mismatch.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
- name: If-Match
in: header
required: true
schema: { type: string }
description: The ETag of the record being changed.
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/EmployeeIdentityInput' } } }
responses:
'200':
description: The record
headers: { ETag: { $ref: '#/components/headers/HRRevision' } }
content: { application/json: { schema: { $ref: '#/components/schemas/EmployeeView' } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/HRForbidden' }
'404': { $ref: '#/components/responses/HRNotFound' }
'409': { $ref: '#/components/responses/HRConflict' }
'422': { $ref: '#/components/responses/HRInvalid' }
/hr/employees/{id}/events:
post:
operationId: recordEmployeeEvent
tags: [HR]
summary: Record a lifecycle event
description: >
hired (recorded as rehired after a left; the number is kept),
confirmed, transferred (today or later; needs a login; moves the
primary seat) or notice_given (effective_on is the day notice was
given, last_day the last day worked; login ends after it). left is
written by the system when a last day has passed. notice_given is
refused 409 open_obligations, carrying obligations, while the person
holds an open advance or petty cash float, unless an Owner who is
not the caller has granted a leaving override for that last day;
403 override_by_requester when the caller granted it themselves;
409 last_administrator. Requires hr.employee.manage.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/EmployeeEventInput' } } }
responses:
'201':
description: The record and its lifecycle
headers: { ETag: { $ref: '#/components/headers/HRRevision' } }
content: { application/json: { schema: { $ref: '#/components/schemas/EmployeeView' } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/HRForbidden' }
'404': { $ref: '#/components/responses/HRNotFound' }
'409': { $ref: '#/components/responses/HRConflict' }
'422': { $ref: '#/components/responses/HRInvalid' }
/hr/employees/{id}/obligations:
get:
operationId: getEmployeeObligations
tags: [HR]
summary: What the person still holds
description: >
Open advances and the petty cash float that block a last day (HD-6),
and the count of the person's own documents still awaiting approval
(a warning). Requires hr.employee.read.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
description: What is open
content: { application/json: { schema: { $ref: '#/components/schemas/EmployeeObligations' } } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/HRForbidden' }
'404': { $ref: '#/components/responses/HRNotFound' }
/hr/employees/{id}/leaving-overrides:
post:
operationId: grantLeavingOverride
tags: [HR]
summary: Allow a last day over an open advance or float
description: >
HD-6: lets a member other than the caller record this last day while
what is open now stays open; the reason is mandatory and audited.
Something opened afterwards is not covered. In a one-member
organization the caller may then record it themselves (sole_owner).
403 own_leaving_override for one's own leaving; 409
nothing_to_override. Requires hr.employee.lifecycle_override.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/LeavingOverrideInput' } } }
responses:
'201':
description: The override
content: { application/json: { schema: { $ref: '#/components/schemas/LeavingOverride' } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/HRForbidden' }
'404': { $ref: '#/components/responses/HRNotFound' }
'409': { $ref: '#/components/responses/HRConflict' }
'422': { $ref: '#/components/responses/HRInvalid' }

Under components: responses:, insert immediately above the parameters: line (after TaxReturnsInvalid):

HRForbidden:
description: >
The caller does not hold the permission this operation requires, or
the rule reserves it for another member (override_by_requester,
own_leaving_override).
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
HRNotFound:
description: No such employee in this organization.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
HRConflict:
description: >
The same request would succeed against the records in another state.
code: open_obligations (with obligations), employee_status,
employee_exists, duplicate_employee_no, duplicate_identity,
employee_no_login, nothing_to_override, last_administrator,
overlapping_primary, revision_mismatch.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
HRInvalid:
description: The request is well-formed but refused (field=… rule=…; no_such_member, party_not_employee, position_unavailable).
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }

Under components: headers:, add after TaxReturnsRevision’s two lines:

HRRevision:
description: The employee record's revision, quoted.
schema: { type: string }

In the OrgModule schema, change the key enum to [expenses, advances, pettycash, payables, receivables, banking, einvoice, taxreturns, hr].

Append to the end of the file (the schemas section):

EmployeeStatus:
type: string
enum: [pending, active, notice, left]
description: Derived from the lifecycle. pending — no hire recorded yet; notice — a last day is recorded and has not passed.
EmploymentType:
type: string
enum: [permanent, contract, probation, part_time]
Employee:
type: object
required: [id, party_id, legal_name, employee_no, member_id, member_active, id_kind, id_number, identity_hidden,
hire_date, employment_type, last_day, status, revision, created_at, updated_at]
properties:
id: { type: string, format: uuid }
party_id: { type: string, format: uuid }
legal_name: { type: string }
employee_no: { type: string }
member_id: { type: string, format: uuid, nullable: true, description: The person's login, when they have one. }
member_active: { type: boolean }
id_kind: { type: string, enum: [nric, passport, other], nullable: true }
id_number: { type: string, nullable: true, description: Null unless the caller holds hr.employee.sensitive. }
identity_hidden: { type: boolean, description: True when id_number was withheld from this caller. }
hire_date: { type: string, format: date, nullable: true, description: The current spell's first day. }
employment_type: { type: string, enum: [permanent, contract, probation, part_time], nullable: true }
last_day: { type: string, format: date, nullable: true }
status: { $ref: '#/components/schemas/EmployeeStatus' }
revision: { type: integer }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
EmployeeEvent:
type: object
required: [id, seq, kind, effective_on, last_day, employment_type, from_position_id, to_position_id,
override_id, reason, recorded_by, recorded_at]
properties:
id: { type: string, format: uuid }
seq: { type: integer, description: Recording order. }
kind: { type: string, enum: [hired, confirmed, transferred, notice_given, left, rehired] }
effective_on: { type: string, format: date }
last_day: { type: string, format: date, nullable: true }
employment_type: { type: string, enum: [permanent, contract, probation, part_time], nullable: true }
from_position_id: { type: string, format: uuid, nullable: true }
to_position_id: { type: string, format: uuid, nullable: true }
override_id: { type: string, format: uuid, nullable: true }
reason: { type: string }
recorded_by: { type: string, format: uuid, nullable: true, description: Null on left, which the system writes. }
recorded_at: { type: string, format: date-time }
EmployeeView:
allOf:
- $ref: '#/components/schemas/Employee'
- type: object
required: [events]
properties:
events: { type: array, items: { $ref: '#/components/schemas/EmployeeEvent' } }
EmployeeCreate:
type: object
required: [employee_no]
additionalProperties: false
properties:
member_id: { type: string, format: uuid, nullable: true }
legal_name: { type: string, description: For a person with no login; English or Bahasa Malaysia. }
employee_no: { type: string, maxLength: 32 }
EmployeeIdentityInput:
type: object
required: [id_kind, id_number]
additionalProperties: false
properties:
id_kind: { type: string, enum: [nric, passport, other], nullable: true }
id_number: { type: string, nullable: true }
EmployeeEventInput:
type: object
required: [kind, effective_on]
additionalProperties: false
properties:
kind: { type: string, enum: [hired, confirmed, transferred, notice_given] }
effective_on: { type: string, format: date }
employment_type: { $ref: '#/components/schemas/EmploymentType' }
position_id: { type: string, format: uuid }
last_day: { type: string, format: date }
reason: { type: string, maxLength: 500 }
EmployeeObligations:
type: object
required: [advances, floats, pending_documents, blocking]
properties:
advances:
type: array
items:
type: object
required: [id, reference, state]
properties:
id: { type: string, format: uuid }
reference: { type: string }
state: { type: string }
floats:
type: array
items:
type: object
required: [id, code, status]
properties:
id: { type: string, format: uuid }
code: { type: string }
status: { type: string }
pending_documents: { type: integer, description: The person's own documents still awaiting approval; a warning, not a block. }
blocking: { type: boolean, description: True when a last day is refused (HD-6). }
LeavingOverrideInput:
type: object
required: [last_day, reason]
additionalProperties: false
properties:
last_day: { type: string, format: date }
reason: { type: string, maxLength: 500 }
LeavingOverride:
type: object
required: [id, employee_id, last_day, reason, covers, granted_by, sole_owner, created_at]
properties:
id: { type: string, format: uuid }
employee_id: { type: string, format: uuid }
last_day: { type: string, format: date }
reason: { type: string }
covers: { $ref: '#/components/schemas/EmployeeObligations' }
granted_by: { type: string, format: uuid }
sole_owner: { type: boolean }
created_at: { type: string, format: date-time }

Run: make generate Expected: internal/gen/api/api.gen.go and web/src/lib/api/schema.d.ts gain the HR operations and schemas.

  • Step 6: Grant existing Owners (00802)

Create internal/platform/hr/migration00802_test.go:

package hr_test
import (
"context"
"database/sql"
"testing"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db/testdb"
)
// 00802 (Ruling H1-13): an existing Owner gains the four hr keys; Finance
// does not; the Down takes them back.
func TestMigration00802GrantsTheHRKeysToOwnersOnly(t *testing.T) {
h := testdb.StartAt(t, 801)
ctx := context.Background()
org := uuid.New()
_, err := h.Admin.Exec(ctx, `INSERT INTO organization (id, org_id, name) VALUES ($1,$1,'Alpha')`, org)
require.NoError(t, err)
role := func(name string) uuid.UUID {
var id uuid.UUID
require.NoError(t, h.Admin.QueryRow(ctx,
`INSERT INTO role (org_id, name, system) VALUES ($1,$2,true) RETURNING id`, org, name).Scan(&id))
return id
}
owner, finance := role("Owner"), role("Finance")
count := func(id uuid.UUID) int {
var n int
require.NoError(t, h.Admin.QueryRow(ctx, `SELECT count(*) FROM role_permission
WHERE role_id = $1 AND permission_key LIKE 'hr.employee.%'`, id).Scan(&n))
return n
}
version := func() int {
var v int
require.NoError(t, h.Admin.QueryRow(ctx, `SELECT perm_version FROM organization WHERE id = $1`, org).Scan(&v))
return v
}
require.Zero(t, count(owner), "ABSENCE before the migration")
before := version()
conn, err := sql.Open("pgx", h.AdminDSN)
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
require.NoError(t, goose.SetDialect("postgres"))
require.NoError(t, goose.UpTo(conn, testdb.MigrationsDir(t), 802, goose.WithAllowMissing()))
require.Equal(t, 4, count(owner), "PRESENCE: the Owner holds all four")
require.Zero(t, count(finance), "Finance holds none")
require.Greater(t, version(), before, "perm_version bumped")
require.NoError(t, goose.Down(conn, testdb.MigrationsDir(t)))
require.Zero(t, count(owner), "the Down removes them")
}

Create db/migrations/00802_hr_owner_grants.sql:

-- +goose Up
-- Phase 5 H1 (Ruling H1-13): the four hr.employee.* keys are the Owner's by
-- default. A new Owner is seeded with every registered key by provisioning;
-- an existing organization's Owner gets them here. Runs as the migration
-- role, which bypasses RLS.
INSERT INTO role_permission (org_id, role_id, permission_key)
SELECT r.org_id, r.id, k.key
FROM role r
CROSS JOIN (VALUES ('hr.employee.read'), ('hr.employee.manage'), ('hr.employee.sensitive'),
('hr.employee.lifecycle_override')) AS k(key)
WHERE r.system AND r.name = 'Owner'
ON CONFLICT (role_id, permission_key) DO NOTHING;
-- member.BumpPermVersion's contract (see 00170).
UPDATE organization o SET perm_version = o.perm_version + 1
WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name = 'Owner');
-- +goose Down
DELETE FROM role_permission rp USING role r
WHERE r.org_id = rp.org_id AND r.id = rp.role_id AND r.system AND r.name = 'Owner'
AND rp.permission_key IN ('hr.employee.read', 'hr.employee.manage', 'hr.employee.sensitive',
'hr.employee.lifecycle_override');
UPDATE organization o SET perm_version = o.perm_version + 1
WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name = 'Owner');
  • Step 7: Install the module and wire it

In internal/platform/moduleinstall/moduleinstall.go, append to Catalog after {Key: "taxreturns", Requires: []string{"receivables"}},:

{Key: "hr"}, // Phase 5 H1; leave (H3) will require it

(Phase 4 O1 appends inventory, procurement, sales at the same place; whichever lands second keeps both.)

In internal/app/app.go: add the import "github.com/mcom/erp/internal/modules/hr"; add the field HR hr.Deps after Imports imports.Deps in Deps; add after imports.Register(api, deps.Imports):

hr.Register(optional("hr"), deps.HR)

In internal/app/routes_test.go: add the import "github.com/mcom/erp/internal/modules/hr" and to composedModules:

"hr": {hr.PermissionRead, hr.PermissionManage, hr.PermissionSensitive, hr.PermissionLifecycleOverride},

In cmd/api/main.go: add the import "github.com/mcom/erp/internal/modules/hr" and, after the Imports: imports.Deps{…}, block:

HR: hr.Deps{
DB: database,
Clock: clock.System(),
},

In cmd/orgadmin/main.go and cmd/migrate/main.go, add _ "github.com/mcom/erp/internal/modules/hr" to the blank-import block. In cmd/orgadmin/wiring_test.go and cmd/migrate/wiring_test.go, add "github.com/mcom/erp/internal/modules/hr", to wantModules and after the taxreturns keys in wantPermissions:

"hr.employee.read", // modules/hr
"hr.employee.lifecycle_override", // modules/hr

In web/src/lib/i18n/locales/en/modules.json add "hr": "HR" to "names" and "hr": "Employee records and their lifecycle." to "descriptions"; in ms/modules.json "hr": "Sumber manusia" and "hr": "Rekod pekerja dan kitaran hayatnya."; in zh/modules.json "hr": "人力资源" and "hr": "员工档案及其生命周期。".

  • Step 8: Run the tests

Run: go test ./internal/modules/hr/ ./internal/platform/hr/ ./internal/app/ ./internal/platform/moduleinstall/ ./cmd/... ./internal/gen/ ./internal/kernel/vocab/ -count=1 && pnpm -C web typecheck && pnpm -C web test -- modules i18n schema Expected: PASS (TestEveryRouteIsAuthorized, TestEveryModuleContributesRoutesAndPermissions, TestTheOpenAPIDocumentSaysWhatTheRouterEnforces included).

  • Step 9: Watch the sensitive guard fail once

In toEmployeeDTO, change if !sensitive && e.IDNumber != nil { to if false && !sensitive && e.IDNumber != nil { and run go test ./internal/modules/hr/ -run TestTheIdentityNumberIsForSensitiveHoldersOnly -count=1. Expected: FAIL — ABSENCE: a reader does not: the body contains 900101145678. Restore; PASS.

  • Step 10: Commit
Terminal window
git add constants.yaml internal/kernel/vocab packages/contracts/vocab.ts api/openapi.yaml internal/gen \
internal/modules/hr internal/platform/hr db/migrations/00802_hr_owner_grants.sql internal/platform/moduleinstall \
internal/app cmd web/src/lib/i18n/locales web/src/lib/api
git commit -m "Phase 5 H1: HR routes, permissions, Owner grants and the hr module
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Files:

  • Create: internal/platform/imports/employees.go, internal/platform/imports/employees_test.go
  • Create: db/migrations/00803_import_employees.sql
  • Modify: internal/platform/imports/imports.go (kinds)
  • Modify: api/openapi.yaml (ImportRequest.kind enum, its mapping description, ImportRunResult.counts description), then make generate; web/src/lib/i18n/locales/{en,ms,zh}/imports.json

Interfaces:

  • Consumes: hr.GetByNumber, hr.Create, hr.Hire, hr.SetIdentity, hr.FieldError, hr.ErrNotFound, hr.ErrDuplicateIdentity, hr.PermissionSensitive, hr.TypePermanent, hr.StatusPending; member.Can; the package’s own lineBad, inSavepoint, jobRef.

  • Produces: imports.KindEmployees = "employees" with fields employee_no, legal_name, hire_date, employment_type, id_kind, id_number (required employee_no); counts {"created", "completed", "unchanged"} (Ruling H1-15).

  • Step 1: Write the failing tests

Create internal/platform/imports/employees_test.go:

package imports_test
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/imports"
)
// Ruling H1-15: new numbers create a person with no login; a number that
// matches a pending record (00801's copy) completes it.
func TestEmployeesArriveThroughTheHRRecord(t *testing.T) {
w := newPartyWorld(t)
w.e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.Create(ctx, tx, w.e.Clock, hr.CreateInput{MemberID: &w.e.Staff, EmployeeNo: "E-100"})
return err
})
res, err := w.run(t, imports.KindEmployees, "employee_no,legal_name,hire_date,employment_type\n"+
"E-500,Siti binti Ahmad,2026-03-01,probation\n"+
"E-501,Tan Ah Kow,,\n"+
"E-100,,2025-06-01,permanent\n")
require.NoError(t, err)
require.Equal(t, map[string]int{"created": 2, "completed": 1, "unchanged": 0}, res.Counts)
w.e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
siti, err := hr.GetByNumber(ctx, tx, "E-500")
require.NoError(t, err)
require.Equal(t, hr.StatusActive, siti.Status)
require.Equal(t, time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), *siti.HireDate)
require.Equal(t, hr.TypeProbation, *siti.EmploymentType)
require.Nil(t, siti.MemberID)
tan, err := hr.GetByNumber(ctx, tx, "E-501")
require.NoError(t, err)
require.Equal(t, hr.StatusPending, tan.Status, "no hire date, no hire")
staff, err := hr.GetByNumber(ctx, tx, "E-100")
require.NoError(t, err)
require.Equal(t, time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), *staff.HireDate)
return nil
})
}
// A row the screen would refuse is reported on its own line and column.
func TestAnEmployeeRowTheScreenWouldRefuseIsReportedOnItsLine(t *testing.T) {
w := newPartyWorld(t)
w.e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
e, err := hr.Create(ctx, tx, w.e.Clock, hr.CreateInput{MemberID: &w.e.Staff, EmployeeNo: "E-100"})
if err != nil {
return err
}
_, _, err = hr.Hire(ctx, tx, w.e.Clock, hr.HireInput{EmployeeID: e.ID,
EffectiveOn: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), EmploymentType: hr.TypePermanent, Actor: w.e.Owner})
return err
})
_, err := w.run(t, imports.KindEmployees, "employee_no,legal_name,hire_date,id_kind,id_number\n"+
"E-600,Aminah,2026-01-01,,\n"+
"E-601,,2026-01-01,,\n"+
"E-602,Badrul,01/02/2026,,\n"+
"E-603,Chong Wei,2026-01-01,nric,900101145678\n"+
"E-100,,2026-02-01,,\n")
var pe *imports.ProblemsError
require.ErrorAs(t, err, &pe)
require.Equal(t, []imports.Problem{
{Line: 3, Column: "legal_name", Reason: "a legal name of 1 to 200 characters is required"},
{Line: 4, Column: "hire_date", Reason: "a date is YYYY-MM-DD"},
{Line: 5, Column: "id_number", Reason: "importing identity numbers needs hr.employee.sensitive"},
{Line: 6, Column: "employee_no", Reason: "employee E-100 already has a hire recorded"},
}, pe.Problems, "PRESENCE: line 2 is valid and is not reported")
}
// Ruling H1-15: an importer holding hr.employee.sensitive brings identity
// numbers in, normalised as the screen does.
func TestIdentityNumbersImportForASensitiveHolder(t *testing.T) {
w := newPartyWorld(t)
_, err := w.e.H.Admin.Exec(context.Background(), `INSERT INTO role_permission (org_id, role_id, permission_key)
SELECT org_id, role_id, 'hr.employee.sensitive' FROM member_role WHERE member_id = $1 LIMIT 1`, w.e.Owner)
require.NoError(t, err)
res, err := w.run(t, imports.KindEmployees, "employee_no,legal_name,id_kind,id_number\nE-700,Devi,nric,900101-14-5678\n")
require.NoError(t, err)
require.Equal(t, 1, res.Counts["created"])
w.e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
devi, err := hr.GetByNumber(ctx, tx, "E-700")
require.NoError(t, err)
require.Equal(t, "900101145678", *devi.IDNumber)
return nil
})
}
  • Step 2: Run them to verify they fail

Run: go test ./internal/platform/imports/ -run 'Employee|IdentityNumbersImport' -count=1 Expected: FAIL to compile — undefined: imports.KindEmployees.

  • Step 3: Write the kind

Create internal/platform/imports/employees.go:

package imports
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/tenancy"
"github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/identity/member"
)
// Employees (Phase 5 H1, Ruling H1-15). Every row is saved through the hr
// package, so the screen's rules refuse the same row here. A new number
// creates a person with no login (hr.Create), then records the hire and the
// identity when given; a number matching a pending record completes it. An
// identity number needs the importer to hold hr.employee.sensitive.
const KindEmployees = "employees"
var employeesKind = kind{
fields: []string{"employee_no", "legal_name", "hire_date", "employment_type", "id_kind", "id_number"},
required: []string{"employee_no"},
save: saveEmployees,
}
var errAlreadyHired = errors.New("imports: the employee already has a hire recorded")
// employeeColumns maps an hr.FieldError's field to the import's column.
var employeeColumns = map[string]string{"effective_on": "hire_date"}
func saveEmployees(ctx context.Context, tx pgx.Tx, c clock.Clock, job jobRef, rows []row) (outcome, error) {
orgID, ok := tenancy.OrgFromContext(ctx)
if !ok {
return outcome{}, fmt.Errorf("imports: employees: %w", tenancy.ErrNoOrg)
}
// hr's lifecycle writes lock the organization row first (Ruling H1-14).
// Taking it before the first row means no later line of this import
// waits for it after an earlier line's audit row.
if _, err := tx.Exec(ctx, `SELECT 1 FROM organization WHERE id = $1 AND org_id = $1 FOR UPDATE`, orgID); err != nil {
return outcome{}, fmt.Errorf("imports: employees: %w", err)
}
sensitive, err := member.Can(ctx, tx, job.by, hr.PermissionSensitive)
if err != nil {
return outcome{}, err
}
var out outcome
counts := map[string]int{"created": 0, "completed": 0, "unchanged": 0}
for _, r := range rows {
bad := lineBad(&out.problems, r.line)
cell := func(f string) string { return strings.TrimSpace(r.cells[f]) }
lineOK := true
var hire *time.Time
if v := cell("hire_date"); v != "" {
d, err := time.Parse("2006-01-02", v)
if err != nil {
bad("hire_date", "a date is YYYY-MM-DD")
lineOK = false
} else {
hire = &d
}
}
employmentType := strings.ToLower(cell("employment_type"))
if employmentType == "" {
employmentType = hr.TypePermanent
}
if cell("id_number") != "" && !sensitive {
bad("id_number", "importing identity numbers needs %s", hr.PermissionSensitive)
lineOK = false
}
if !lineOK {
continue
}
outcomeKey := "unchanged"
err := inSavepoint(ctx, tx, func(sp pgx.Tx) error {
e, err := hr.GetByNumber(ctx, sp, cell("employee_no"))
switch {
case errors.Is(err, hr.ErrNotFound):
if e, err = hr.Create(ctx, sp, c, hr.CreateInput{LegalName: cell("legal_name"), EmployeeNo: cell("employee_no")}); err != nil {
return err
}
outcomeKey = "created"
case err != nil:
return err
case e.Status != hr.StatusPending && hire != nil:
return errAlreadyHired
}
if hire != nil {
if e, _, err = hr.Hire(ctx, sp, c, hr.HireInput{EmployeeID: e.ID, EffectiveOn: *hire,
EmploymentType: employmentType, Actor: job.by}); err != nil {
return err
}
if outcomeKey == "unchanged" {
outcomeKey = "completed"
}
}
if cell("id_number") != "" && e.IDNumber == nil {
if _, err = hr.SetIdentity(ctx, sp, c, e.ID, e.Revision, strings.ToLower(cell("id_kind")), cell("id_number")); err != nil {
return err
}
if outcomeKey == "unchanged" {
outcomeKey = "completed"
}
}
return nil
})
var fe *hr.FieldError
switch {
case err == nil:
counts[outcomeKey]++
case errors.As(err, &fe):
col := fe.Field
if mapped, found := employeeColumns[col]; found {
col = mapped
}
bad(col, "%s", fe.Detail)
case errors.Is(err, errAlreadyHired):
bad("employee_no", "employee %s already has a hire recorded", cell("employee_no"))
case errors.Is(err, hr.ErrDuplicateIdentity):
bad("id_number", "another employee has this identity document")
default:
return outcome{}, fmt.Errorf("imports: employees: line %d: %w", r.line, err)
}
}
out.counts = counts
return out, nil
}

In internal/platform/imports/imports.go, add to kinds after KindArchivePurchaseDocuments: archivePurchaseKind,:

KindEmployees: employeesKind,

Create db/migrations/00803_import_employees.sql:

-- +goose Up
-- Phase 5 H1 (Ruling H1-15): employees join the import kinds. 'items' is
-- Phase 4 O1's (00714) and is listed so this list is already the union
-- whichever of the two lands second (DEPLOY_NOTES).
ALTER TABLE import_job DROP CONSTRAINT import_job_kind_known;
ALTER TABLE import_job ADD CONSTRAINT import_job_kind_known
CHECK (kind IN ('chart_of_accounts', 'customers', 'suppliers', 'opening_trial_balance', 'period_movements',
'opening_sales_documents', 'opening_purchase_documents',
'archive_sales_documents', 'archive_purchase_documents', 'items', 'employees'));
-- +goose Down
-- Fails while an employees import is recorded (import_job is append-only).
ALTER TABLE import_job DROP CONSTRAINT import_job_kind_known;
ALTER TABLE import_job ADD CONSTRAINT import_job_kind_known
CHECK (kind IN ('chart_of_accounts', 'customers', 'suppliers', 'opening_trial_balance', 'period_movements',
'opening_sales_documents', 'opening_purchase_documents',
'archive_sales_documents', 'archive_purchase_documents', 'items'));
  • Step 4: Run the import tests

Run: go test ./internal/platform/imports/ ./internal/kernel/db/ -count=1 Expected: PASS.

  • Step 5: Watch the sensitive check fail once

In saveEmployees, change if cell("id_number") != "" && !sensitive { to if false { and run go test ./internal/platform/imports/ -run TestAnEmployeeRowTheScreenWouldRefuse -count=1. Expected: FAIL — the problems list lacks line 5’s id_number entry. Restore; PASS.

  • Step 6: Name the kind in the contract and the web

In api/openapi.yaml, in ImportRequest.properties.kind’s enum append , employees inside the brackets; at the end of its mapping description (after Kept read-only, never posted.) add the lines:

employees: employee_no, legal_name (for a new person, who gets no
login), hire_date, employment_type (permanent when blank),
id_kind and id_number (needs hr.employee.sensitive). A number that
matches a record with no hire yet completes that record.

and in ImportRunResult.properties.counts.description append employees: created, completed, unchanged. before the closing quote.

Run: make generate Expected: internal/gen/api/api.gen.go and web/src/lib/api/schema.d.ts gain employees.

In web/src/lib/i18n/locales/{en,ms,zh}/imports.json, add to "kinds" after "archive_purchase_documents": en "employees": "Employees", ms "employees": "Pekerja", zh "employees": "员工".

Run: pnpm -C web typecheck && pnpm -C web test -- imports i18n schema Expected: PASS.

  • Step 7: Commit
Terminal window
git add internal/platform/imports db/migrations/00803_import_employees.sql api/openapi.yaml internal/gen/api web/src
git commit -m "Phase 5 H1: employees import kind
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 9: The screens (employee list, drawer, lifecycle actions, override dialog)

Section titled “Task 9: The screens (employee list, drawer, lifecycle actions, override dialog)”

Files:

  • Create: web/src/features/hr/{api.ts,EmployeesPage.tsx,EmployeeDrawer.tsx,NewEmployeeDialog.tsx,LifecycleDialog.tsx,OverrideDialog.tsx,IdentityDialog.tsx,routes.tsx}
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/hr.json
  • Modify: web/src/app/routes.ts, web/src/app/shell/nav.ts
  • Test: web/src/features/hr/__tests__/hr.test.tsx

Interfaces:

  • Consumes: Task 7’s routes and generated Schemas['Employee' | 'EmployeeView' | 'EmployeeEvent' | 'EmployeeStatus' | 'EmploymentType' | 'EmployeeCreate' | 'EmployeeIdentityInput' | 'EmployeeEventInput' | 'EmployeeObligations' | 'LeavingOverrideInput']; useMemberDirectory (@/features/members/useMemberNames), useChart (@/features/orgstructure/api), RecordLanguageHint (@/features/orgstructure/RecordLanguageHint), useCan/useMe/RequirePermission (@/features/auth/session), problemMessage/ProblemError/unwrap, formatDate/todayISO (@/lib/date), useLocale (@/lib/i18n).

  • Produces: route /hr/employees (search employee); hooks useEmployees, useEmployee, useObligations, useCreateEmployee, useRecordEvent, useSetIdentity, useGrantOverride; StatusTag; hrMessage (H2/H3 screens reuse the hooks and the tag).

  • Step 1: Write the failing screen tests

Create web/src/features/hr/__tests__/hr.test.tsx:

import { describe, expect, it } from 'vitest';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@/test/msw/server';
import { renderWithApp } from '@/test/renderWithApp';
const employee = {
id: 'e1',
party_id: 'p1',
legal_name: 'Siti binti Ahmad',
employee_no: 'E-900',
member_id: null,
member_active: false,
id_kind: 'nric',
id_number: null,
identity_hidden: true,
hire_date: '2026-01-05',
employment_type: 'permanent',
last_day: null,
status: 'active',
revision: 1,
created_at: '2026-01-05T01:00:00Z',
updated_at: '2026-01-05T01:00:00Z',
};
const view = {
...employee,
events: [
{
id: 'v1',
seq: 1,
kind: 'hired',
effective_on: '2026-01-05',
last_day: null,
employment_type: 'permanent',
from_position_id: null,
to_position_id: null,
override_id: null,
reason: '',
recorded_by: 'm1',
recorded_at: '2026-01-05T01:00:00Z',
},
],
};
const blocking = {
advances: [{ id: 'a1', reference: 'ADV-2026-0001', state: 'received' }],
floats: [],
pending_documents: 0,
blocking: true,
};
const nothingOpen = { advances: [], floats: [], pending_documents: 0, blocking: false };
function mockApi(obligations: typeof blocking | typeof nothingOpen = blocking) {
server.use(
http.get('*/v1/hr/employees', () => HttpResponse.json({ employees: [employee] })),
http.get('*/v1/hr/employees/e1', () => HttpResponse.json(view)),
http.get('*/v1/hr/employees/e1/obligations', () => HttpResponse.json(obligations)),
);
}
describe('EmployeesPage', () => {
it('lists employees with their status and offers a new one to a manager (presence first)', async () => {
mockApi();
await renderWithApp({
path: '/hr/employees',
permissions: ['hr.employee.read', 'hr.employee.manage'],
});
const row = (await screen.findByText('Siti binti Ahmad')).closest('tr')!;
expect(within(row).getByText('Active')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'New employee' })).toBeInTheDocument();
});
it('is read-only without hr.employee.manage', async () => {
mockApi();
await renderWithApp({ path: '/hr/employees', permissions: ['hr.employee.read'] });
expect(await screen.findByText('Siti binti Ahmad')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'New employee' })).not.toBeInTheDocument();
});
});
describe('EmployeeDrawer', () => {
it('hides the identity number, and offers the override to its holder while something is open', async () => {
mockApi(blocking);
await renderWithApp({
path: '/hr/employees?employee=e1',
permissions: ['hr.employee.read', 'hr.employee.lifecycle_override'],
});
expect(await screen.findByText(/Hidden/)).toBeInTheDocument();
expect(await screen.findByText('Advance ADV-2026-0001')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Allow last day' })).toBeInTheDocument();
});
it('does not offer the override when nothing is open', async () => {
mockApi(nothingOpen);
await renderWithApp({
path: '/hr/employees?employee=e1',
permissions: ['hr.employee.read', 'hr.employee.lifecycle_override'],
});
expect(await screen.findByText('No open advance or float')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Allow last day' })).not.toBeInTheDocument();
});
it('says in the last-day dialog what blocks it', async () => {
mockApi(blocking);
const user = userEvent.setup();
await renderWithApp({
path: '/hr/employees?employee=e1',
permissions: ['hr.employee.read', 'hr.employee.manage'],
});
await user.click(await screen.findByRole('button', { name: 'Record last day' }));
expect(await screen.findByText('Advance ADV-2026-0001')).toBeInTheDocument();
expect(screen.getByText('An Owner other than you can allow it with a reason.')).toBeInTheDocument();
});
});
  • Step 2: Run them to verify they fail

Run: pnpm -C web test -- features/hr Expected: FAIL — /hr/employees renders the not-found page.

  • Step 3: Write the API hooks

Create web/src/features/hr/api.ts:

// The HR layer's employee record (Phase 5 H1): records, lifecycle, HD-6.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import i18n, { type TFunction } from 'i18next';
import { api } from '@/lib/api/client';
import { ProblemError, problemMessage, unwrap } from '@/lib/api/problem';
import type { Schemas } from '@/lib/api/types';
export type Employee = Schemas['Employee'];
export type EmployeeView = Schemas['EmployeeView'];
export type EmployeeStatus = Schemas['EmployeeStatus'];
export type EmploymentType = Schemas['EmploymentType'];
export type Obligations = Schemas['EmployeeObligations'];
export type EventInput = Schemas['EmployeeEventInput'];
export type EventKind = EventInput['kind'];
export const STATUSES: EmployeeStatus[] = ['pending', 'active', 'notice', 'left'];
export const EMPLOYMENT_TYPES: EmploymentType[] = ['permanent', 'contract', 'probation', 'part_time'];
const etag = (rev: number) => `"${rev}"`;
export const hrKeys = {
employees: (f: { status?: EmployeeStatus; q?: string }) => ['hr', 'employees', f] as const,
employee: (id: string) => ['hr', 'employee', id] as const,
obligations: (id: string) => ['hr', 'obligations', id] as const,
};
export function useEmployees(f: { status?: EmployeeStatus; q?: string }) {
return useQuery({
queryKey: hrKeys.employees(f),
queryFn: async ({ signal }) =>
unwrap(await api.GET('/hr/employees', { params: { query: f }, signal })).employees,
});
}
export function useEmployee(id: string) {
return useQuery({
queryKey: hrKeys.employee(id),
queryFn: async ({ signal }) =>
unwrap(await api.GET('/hr/employees/{id}', { params: { path: { id } }, signal })),
});
}
export function useObligations(id: string, enabled = true) {
return useQuery({
queryKey: hrKeys.obligations(id),
enabled,
queryFn: async ({ signal }) =>
unwrap(
await api.GET('/hr/employees/{id}/obligations', { params: { path: { id } }, signal }),
),
});
}
function useAfterEmployee() {
const qc = useQueryClient();
return (e: EmployeeView) => {
qc.setQueryData(hrKeys.employee(e.id), e);
void qc.invalidateQueries({ queryKey: ['hr', 'employees'] });
void qc.invalidateQueries({ queryKey: hrKeys.obligations(e.id) });
};
}
export function useCreateEmployee() {
const after = useAfterEmployee();
return useMutation({
mutationFn: async (body: Schemas['EmployeeCreate']) =>
unwrap(await api.POST('/hr/employees', { body })),
onSuccess: after,
});
}
export function useRecordEvent(id: string) {
const after = useAfterEmployee();
return useMutation({
mutationFn: async (body: EventInput) =>
unwrap(await api.POST('/hr/employees/{id}/events', { params: { path: { id } }, body })),
onSuccess: after,
});
}
export function useSetIdentity(id: string) {
const after = useAfterEmployee();
return useMutation({
mutationFn: async (v: { revision: number; body: Schemas['EmployeeIdentityInput'] }) =>
unwrap(
await api.PUT('/hr/employees/{id}/identity', {
params: { path: { id }, header: { 'If-Match': etag(v.revision) } },
body: v.body,
}),
),
onSuccess: after,
});
}
export function useGrantOverride(id: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: Schemas['LeavingOverrideInput']) =>
unwrap(
await api.POST('/hr/employees/{id}/leaving-overrides', { params: { path: { id } }, body }),
),
onSuccess: () => void qc.invalidateQueries({ queryKey: hrKeys.obligations(id) }),
});
}
/** A refusal in HR's own words when its code is one this screen knows, else the common text. */
export function hrMessage(e: unknown, t: TFunction, tc: TFunction): string {
if (e instanceof ProblemError && e.code && i18n.exists(`hr:refusals.${e.code}`)) {
return t(`refusals.${e.code}`);
}
return problemMessage(e, tc);
}
  • Step 4: Write the list and the new-employee dialog

Create web/src/features/hr/EmployeesPage.tsx:

import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { DataTable, type ColumnDef } from '@/components/ui/DataTable';
import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input';
import { PageHeader } from '@/components/ui/PageHeader';
import { Select } from '@/components/ui/Select';
import { Tag, type TagTone } from '@/components/ui/Tag';
import { useCan } from '@/features/auth/session';
import { formatDate } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { NewEmployeeDialog } from './NewEmployeeDialog';
import { STATUSES, useEmployees, type Employee, type EmployeeStatus } from './api';
const ALL = '__all';
const TONE: Record<EmployeeStatus, TagTone> = {
pending: 'amber',
active: 'green',
notice: 'purple',
left: 'muted',
};
export function StatusTag({ status }: { status: EmployeeStatus }) {
const { t } = useTranslation('hr');
return <Tag tone={TONE[status]}>{t(`status.${status}`)}</Tag>;
}
export function EmployeesPage({ onOpen }: { onOpen(id: string): void }) {
const { t } = useTranslation('hr');
const [locale] = useLocale();
const canManage = useCan('hr.employee.manage');
const [status, setStatus] = useState<string>(ALL);
const [q, setQ] = useState('');
const [creating, setCreating] = useState(false);
const list = useEmployees({
...(status === ALL ? {} : { status: status as EmployeeStatus }),
...(q.trim() ? { q: q.trim() } : {}),
});
const columns = useMemo<ColumnDef<Employee>[]>(
() => [
{
id: 'employee_no',
meta: { required: true },
header: t('fields.employeeNo'),
cell: ({ row }) => <span className="font-mono text-caption">{row.original.employee_no}</span>,
},
{
id: 'name',
meta: { required: true },
header: t('fields.name'),
cell: ({ row }) => row.original.legal_name,
},
{
id: 'status',
header: t('fields.status'),
cell: ({ row }) => <StatusTag status={row.original.status} />,
},
{
id: 'hire_date',
header: t('fields.hireDate'),
cell: ({ row }) => (row.original.hire_date ? formatDate(row.original.hire_date, locale) : '—'),
},
{
id: 'last_day',
header: t('fields.lastDay'),
cell: ({ row }) => (row.original.last_day ? formatDate(row.original.last_day, locale) : '—'),
},
],
[t, locale],
);
return (
<div className="flex flex-col gap-4">
<PageHeader
title={t('title')}
subtitle={t('subtitle')}
actions={
canManage && (
<Button onClick={() => setCreating(true)}>
<Plus size={16} aria-hidden />
{t('new')}
</Button>
)
}
/>
<div className="flex flex-wrap gap-3">
<Input
aria-label={t('search')}
placeholder={t('search')}
value={q}
onChange={(e) => setQ(e.target.value)}
className="max-w-xs"
/>
<Select
aria-label={t('fields.status')}
value={status}
onValueChange={setStatus}
options={[
{ value: ALL, label: t('allStatuses') },
...STATUSES.map((s) => ({ value: s, label: t(`status.${s}`) })),
]}
/>
</div>
<DataTable
tableId="hr.employees"
columns={columns}
data={list.data ?? []}
loading={list.isLoading}
getRowId={(e: Employee) => e.id}
onRowClick={(e: Employee) => onOpen(e.id)}
empty={<EmptyState title={t('empty')} />}
/>
{creating && <NewEmployeeDialog onClose={() => setCreating(false)} onCreated={onOpen} />}
</div>
);
}

Create web/src/features/hr/NewEmployeeDialog.tsx:

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Combobox } from '@/components/ui/Combobox';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Input } from '@/components/ui/Input';
import { toast } from '@/components/ui/Toaster';
import { useMemberDirectory } from '@/features/members/useMemberNames';
import { RecordLanguageHint } from '@/features/orgstructure/RecordLanguageHint';
import { hrMessage, useCreateEmployee } from './api';
export function NewEmployeeDialog({
onClose,
onCreated,
}: {
onClose(): void;
onCreated(id: string): void;
}) {
const { t } = useTranslation('hr');
const { t: tc } = useTranslation();
const directory = useMemberDirectory();
const create = useCreateEmployee();
const [memberId, setMemberId] = useState<string | undefined>();
const [name, setName] = useState('');
const [no, setNo] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
async function onSubmit() {
const e: Record<string, string> = {};
if (!no.trim()) e.employee_no = t('errors.employeeNo');
if (!memberId && !name.trim()) e.legal_name = t('errors.legalName');
setErrors(e);
if (Object.keys(e).length) return;
setFormError(null);
try {
const created = await create.mutateAsync(
memberId
? { member_id: memberId, employee_no: no.trim() }
: { legal_name: name.trim(), employee_no: no.trim() },
);
toast.success(t('created'));
onClose();
onCreated(created.id);
} catch (err) {
setFormError(hrMessage(err, t, tc));
}
}
const people = (directory.data ?? [])
.filter((m) => m.active)
.map((m) => ({ value: m.member_id, label: m.name ?? `…${m.member_id.slice(-4)}` }));
return (
<Dialog
open
onOpenChange={(o) => !o && onClose()}
title={t('new')}
footer={
<>
<Button variant="outline" onClick={onClose}>
{t('cancel')}
</Button>
<Button onClick={() => void onSubmit()} loading={create.isPending}>
{t('create')}
</Button>
</>
}
>
<div className="grid gap-4">
<Field label={t('fields.member')} hint={t('fields.memberHint')} optional>
<Combobox
id="hr-member"
aria-label={t('fields.member')}
value={memberId}
onChange={setMemberId}
options={people}
placeholder={t('fields.pickMember')}
/>
</Field>
{!memberId && (
<Field label={t('fields.name')} error={errors.legal_name}>
<Input id="hr-name" value={name} onChange={(e) => setName(e.target.value)} />
</Field>
)}
<Field label={t('fields.employeeNo')} error={errors.employee_no}>
<Input id="hr-no" value={no} onChange={(e) => setNo(e.target.value)} />
</Field>
{!memberId && <RecordLanguageHint />}
{formError && (
<p role="alert" className="text-caption text-tone-red-fg">
{formError}
</p>
)}
</div>
</Dialog>
);
}
  • Step 5: Write the drawer and its dialogs

Create web/src/features/hr/EmployeeDrawer.tsx:

import { useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Drawer } from '@/components/ui/Drawer';
import { useCan, useMe } from '@/features/auth/session';
import { formatDate } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { StatusTag } from './EmployeesPage';
import { IdentityDialog } from './IdentityDialog';
import { LifecycleDialog } from './LifecycleDialog';
import { OverrideDialog } from './OverrideDialog';
import { useEmployee, useObligations, type EventKind } from './api';
function Item({ label, children }: { label: string; children: ReactNode }) {
return (
<div>
<dt className="text-caption text-fg-muted">{label}</dt>
<dd>{children}</dd>
</div>
);
}
export function EmployeeDrawer({ id, onClose }: { id: string; onClose(): void }) {
const { t } = useTranslation('hr');
const [locale] = useLocale();
const { data: me } = useMe();
const canManage = useCan('hr.employee.manage');
const canSensitive = useCan('hr.employee.sensitive');
const canOverride = useCan('hr.employee.lifecycle_override');
const { data: e } = useEmployee(id);
const open = e?.status === 'active' || e?.status === 'notice';
const obligations = useObligations(id, canOverride && open);
const [action, setAction] = useState<EventKind | 'override' | 'identity' | null>(null);
const own = e?.member_id != null && e.member_id === me?.member_id;
const ob = obligations.data;
const date = (d: string | null) => (d ? formatDate(d, locale) : '—');
const login = !e?.member_id ? 'none' : e.member_active ? 'active' : 'inactive';
return (
<Drawer
open
onOpenChange={(o) => !o && onClose()}
title={e ? `${e.legal_name} · ${e.employee_no}` : t('loading')}
>
{e && (
<div className="flex flex-col gap-5 p-4">
<dl className="grid grid-cols-2 gap-3 text-body">
<Item label={t('fields.status')}>
<StatusTag status={e.status} />
</Item>
<Item label={t('fields.employmentType')}>
{e.employment_type ? t(`types.${e.employment_type}`) : '—'}
</Item>
<Item label={t('fields.hireDate')}>{date(e.hire_date)}</Item>
<Item label={t('fields.lastDay')}>{date(e.last_day)}</Item>
<Item label={t('fields.member')}>{t(`login.${login}`)}</Item>
<Item label={t('fields.identity')}>
{e.id_kind
? `${t(`idKinds.${e.id_kind}`)} ${e.identity_hidden ? t('identityHidden') : (e.id_number ?? '')}`
: t('none')}
</Item>
</dl>
<div className="flex flex-wrap gap-2">
{canManage && (e.status === 'pending' || e.status === 'left') && (
<Button size="sm" onClick={() => setAction('hired')}>
{t(e.status === 'left' ? 'actions.rehire' : 'actions.hire')}
</Button>
)}
{canManage && e.status === 'active' && (
<Button size="sm" variant="outline" onClick={() => setAction('confirmed')}>
{t('actions.confirm')}
</Button>
)}
{canManage && open && e.member_active && (
<Button size="sm" variant="outline" onClick={() => setAction('transferred')}>
{t('actions.transfer')}
</Button>
)}
{canManage && open && (
<Button size="sm" variant="danger-outline" onClick={() => setAction('notice_given')}>
{t('actions.lastDay')}
</Button>
)}
{canSensitive && (
<Button size="sm" variant="ghost" onClick={() => setAction('identity')}>
{t('actions.identity')}
</Button>
)}
</div>
{canOverride && open && ob && (
<section>
<h3 className="text-body font-bold">{t('obligations.title')}</h3>
{ob.blocking ? (
<ul className="mt-2 list-disc pl-5 text-body">
{ob.advances.map((a) => (
<li key={a.id}>{t('obligations.advance', { reference: a.reference })}</li>
))}
{ob.floats.map((f) => (
<li key={f.id}>{t('obligations.float', { code: f.code })}</li>
))}
</ul>
) : (
<p className="mt-2 text-body text-fg-muted">{t('obligations.none')}</p>
)}
{ob.blocking && !own && (
<Button className="mt-3" size="sm" variant="secondary" onClick={() => setAction('override')}>
{t('actions.override')}
</Button>
)}
</section>
)}
<section>
<h3 className="text-body font-bold">{t('history')}</h3>
<ol className="mt-2 flex flex-col gap-2">
{[...e.events].reverse().map((ev) => (
<li key={ev.id} className="text-body">
<span className="font-medium">{t(`events.${ev.kind}`)}</span>{' '}
<span className="text-fg-muted">
{formatDate(ev.effective_on, locale)}
{ev.last_day ? ` → ${formatDate(ev.last_day, locale)}` : ''}
</span>
{ev.reason && <p className="text-caption text-fg-muted">{ev.reason}</p>}
</li>
))}
</ol>
</section>
</div>
)}
{e && action && action !== 'override' && action !== 'identity' && (
<LifecycleDialog employee={e} kind={action} onClose={() => setAction(null)} />
)}
{e && action === 'override' && <OverrideDialog employee={e} onClose={() => setAction(null)} />}
{e && action === 'identity' && <IdentityDialog employee={e} onClose={() => setAction(null)} />}
</Drawer>
);
}

Create web/src/features/hr/LifecycleDialog.tsx:

import { useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { DatePicker } from '@/components/ui/DatePicker';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Select } from '@/components/ui/Select';
import { Textarea } from '@/components/ui/Textarea';
import { toast } from '@/components/ui/Toaster';
import { useChart } from '@/features/orgstructure/api';
import { RecordLanguageHint } from '@/features/orgstructure/RecordLanguageHint';
import { todayISO } from '@/lib/date';
import {
EMPLOYMENT_TYPES,
hrMessage,
useObligations,
useRecordEvent,
type EmployeeView,
type EmploymentType,
type EventKind,
} from './api';
/** Hire (rehire), confirm, transfer or record a last day (spec H1). */
export function LifecycleDialog({
employee,
kind,
onClose,
}: {
employee: EmployeeView;
kind: EventKind;
onClose(): void;
}) {
const { t } = useTranslation('hr');
const { t: tc } = useTranslation();
const record = useRecordEvent(employee.id);
const chart = useChart();
const obligations = useObligations(employee.id, kind === 'notice_given');
const [effectiveOn, setEffectiveOn] = useState(todayISO());
const [lastDay, setLastDay] = useState<string | undefined>();
const [type, setType] = useState<EmploymentType>(kind === 'hired' ? 'probation' : 'permanent');
const [position, setPosition] = useState<string | undefined>();
const [reason, setReason] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
async function onSubmit() {
const e: Record<string, string> = {};
if (kind === 'transferred' && !position) e.position_id = t('errors.position');
if (kind === 'notice_given' && !lastDay) e.last_day = t('errors.lastDay');
setErrors(e);
if (Object.keys(e).length) return;
setFormError(null);
try {
await record.mutateAsync({
kind,
effective_on: effectiveOn,
...(kind === 'hired' || kind === 'confirmed' ? { employment_type: type } : {}),
...(kind === 'transferred' && position ? { position_id: position } : {}),
...(kind === 'notice_given' && lastDay ? { last_day: lastDay } : {}),
...(reason.trim() ? { reason: reason.trim() } : {}),
});
toast.success(t(`recorded.${kind}`));
onClose();
} catch (err) {
setFormError(hrMessage(err, t, tc));
}
}
const positions = (chart.data?.positions ?? [])
.filter((p) => p.active)
.map((p) => ({ value: p.id, label: `${p.code} · ${p.title}` }));
const ob = obligations.data;
return (
<Dialog
open
onOpenChange={(o) => !o && onClose()}
title={t(`dialog.${kind}.title`)}
description={t(`dialog.${kind}.description`)}
footer={
<>
<Button variant="outline" onClick={onClose}>
{t('cancel')}
</Button>
<Button onClick={() => void onSubmit()} loading={record.isPending}>
{t('record')}
</Button>
</>
}
>
<div className="grid gap-4">
<Field label={t(`dialog.${kind}.date`)}>
<DatePicker
id="hr-effective"
value={effectiveOn}
onChange={(v) => setEffectiveOn(v ?? todayISO())}
/>
</Field>
{(kind === 'hired' || kind === 'confirmed') && (
<Field label={t('fields.employmentType')}>
<Select
id="hr-type"
aria-label={t('fields.employmentType')}
value={type}
onValueChange={(v) => setType(v as EmploymentType)}
options={EMPLOYMENT_TYPES.map((v) => ({ value: v, label: t(`types.${v}`) }))}
/>
</Field>
)}
{kind === 'transferred' && (
<Field label={t('fields.position')} hint={t('fields.positionHint')} error={errors.position_id}>
<Select
id="hr-position"
aria-label={t('fields.position')}
value={position}
onValueChange={setPosition}
options={positions}
placeholder={t('fields.pickPosition')}
/>
</Field>
)}
{kind === 'notice_given' && (
<Field label={t('fields.lastDay')} error={errors.last_day}>
<DatePicker id="hr-last-day" value={lastDay} onChange={setLastDay} min={effectiveOn} />
</Field>
)}
{kind === 'notice_given' && ob?.blocking && (
<div role="alert" className="rounded-control border border-tone-red-accent bg-tone-red-soft p-3 text-body">
<p className="font-medium">{t('obligations.blocking')}</p>
<ul className="mt-1 list-disc pl-5">
{ob.advances.map((a) => (
<li key={a.id}>{t('obligations.advance', { reference: a.reference })}</li>
))}
{ob.floats.map((f) => (
<li key={f.id}>{t('obligations.float', { code: f.code })}</li>
))}
</ul>
<p className="mt-1 text-caption">{t('obligations.overrideHint')}</p>
</div>
)}
{kind === 'notice_given' && ob && ob.pending_documents > 0 && (
<p className="text-caption text-tone-amber-fg">
{t('obligations.pending', { count: ob.pending_documents })}
</p>
)}
<Field label={t('fields.reason')} optional>
<Textarea
id="hr-reason"
rows={2}
value={reason}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setReason(e.target.value)}
/>
</Field>
<RecordLanguageHint />
{formError && (
<p role="alert" className="text-caption text-tone-red-fg">
{formError}
</p>
)}
</div>
</Dialog>
);
}

Create web/src/features/hr/OverrideDialog.tsx:

import { useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { DatePicker } from '@/components/ui/DatePicker';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Textarea } from '@/components/ui/Textarea';
import { toast } from '@/components/ui/Toaster';
import { RecordLanguageHint } from '@/features/orgstructure/RecordLanguageHint';
import { hrMessage, useGrantOverride, useObligations, type EmployeeView } from './api';
/** HD-6: an Owner allows another member to record this last day while these stay open. */
export function OverrideDialog({ employee, onClose }: { employee: EmployeeView; onClose(): void }) {
const { t } = useTranslation('hr');
const { t: tc } = useTranslation();
const obligations = useObligations(employee.id);
const grant = useGrantOverride(employee.id);
const [lastDay, setLastDay] = useState<string | undefined>(employee.last_day ?? undefined);
const [reason, setReason] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [formError, setFormError] = useState<string | null>(null);
async function onSubmit() {
const e: Record<string, string> = {};
if (!lastDay) e.last_day = t('errors.lastDay');
if (!reason.trim()) e.reason = t('errors.reason');
setErrors(e);
if (Object.keys(e).length || !lastDay) return;
setFormError(null);
try {
await grant.mutateAsync({ last_day: lastDay, reason: reason.trim() });
toast.success(t('overrideGranted'));
onClose();
} catch (err) {
setFormError(hrMessage(err, t, tc));
}
}
const ob = obligations.data;
return (
<Dialog
open
onOpenChange={(o) => !o && onClose()}
title={t('dialog.override.title')}
description={t('dialog.override.description')}
footer={
<>
<Button variant="outline" onClick={onClose}>
{t('cancel')}
</Button>
<Button onClick={() => void onSubmit()} loading={grant.isPending}>
{t('actions.override')}
</Button>
</>
}
>
<div className="grid gap-4">
{ob && (
<ul className="list-disc pl-5 text-body">
{ob.advances.map((a) => (
<li key={a.id}>{t('obligations.advance', { reference: a.reference })}</li>
))}
{ob.floats.map((f) => (
<li key={f.id}>{t('obligations.float', { code: f.code })}</li>
))}
</ul>
)}
<Field label={t('fields.lastDay')} error={errors.last_day}>
<DatePicker id="hr-override-day" value={lastDay} onChange={setLastDay} />
</Field>
<Field label={t('fields.reason')} error={errors.reason}>
<Textarea
id="hr-override-reason"
rows={3}
value={reason}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setReason(e.target.value)}
/>
</Field>
<RecordLanguageHint />
{formError && (
<p role="alert" className="text-caption text-tone-red-fg">
{formError}
</p>
)}
</div>
</Dialog>
);
}

Create web/src/features/hr/IdentityDialog.tsx:

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { toast } from '@/components/ui/Toaster';
import type { Schemas } from '@/lib/api/types';
import { hrMessage, useSetIdentity, type EmployeeView } from './api';
type IdKind = NonNullable<Schemas['EmployeeIdentityInput']['id_kind']>;
const KINDS: IdKind[] = ['nric', 'passport', 'other'];
const NONE = '__none';
/** Personal data (Ruling H1-5): only hr.employee.sensitive holders open this. */
export function IdentityDialog({ employee, onClose }: { employee: EmployeeView; onClose(): void }) {
const { t } = useTranslation('hr');
const { t: tc } = useTranslation();
const save = useSetIdentity(employee.id);
const [kind, setKind] = useState<string>(employee.id_kind ?? NONE);
const [number, setNumber] = useState(employee.id_number ?? '');
const [formError, setFormError] = useState<string | null>(null);
async function onSubmit() {
setFormError(null);
try {
await save.mutateAsync({
revision: employee.revision,
body:
kind === NONE
? { id_kind: null, id_number: null }
: { id_kind: kind as IdKind, id_number: number.trim() },
});
toast.success(t('identitySaved'));
onClose();
} catch (err) {
setFormError(hrMessage(err, t, tc));
}
}
return (
<Dialog
open
onOpenChange={(o) => !o && onClose()}
title={t('dialog.identity.title')}
description={t('dialog.identity.description')}
footer={
<>
<Button variant="outline" onClick={onClose}>
{t('cancel')}
</Button>
<Button onClick={() => void onSubmit()} loading={save.isPending}>
{t('record')}
</Button>
</>
}
>
<div className="grid gap-4">
<Field label={t('fields.idKind')}>
<Select
id="hr-id-kind"
aria-label={t('fields.idKind')}
value={kind}
onValueChange={setKind}
options={[
{ value: NONE, label: t('none') },
...KINDS.map((k) => ({ value: k, label: t(`idKinds.${k}`) })),
]}
/>
</Field>
{kind !== NONE && (
<Field label={t('fields.idNumber')}>
<Input id="hr-id-number" value={number} onChange={(e) => setNumber(e.target.value)} />
</Field>
)}
{formError && (
<p role="alert" className="text-caption text-tone-red-fg">
{formError}
</p>
)}
</div>
</Dialog>
);
}
  • Step 6: Route, navigate and word it

Create web/src/features/hr/routes.tsx:

import { createRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/features/auth/session';
import { EmployeeDrawer } from './EmployeeDrawer';
import { EmployeesPage } from './EmployeesPage';
const search = z.object({ employee: z.string().optional().catch(undefined) });
type EmployeesSearch = z.infer<typeof search>;
const employeesRoute = createRoute({
getParentRoute: () => appRoute,
path: 'hr/employees',
validateSearch: (s: Record<string, unknown>) => search.parse(s),
component: function R() {
const s = employeesRoute.useSearch();
const navigate = employeesRoute.useNavigate();
return (
<RequirePermission permission="hr.employee.read">
<EmployeesPage
onOpen={(id) => void navigate({ search: (p: EmployeesSearch) => ({ ...p, employee: id }) })}
/>
{s.employee && (
<EmployeeDrawer
id={s.employee}
onClose={() =>
void navigate({ search: (p: EmployeesSearch) => ({ ...p, employee: undefined }) })
}
/>
)}
</RequirePermission>
);
},
});
export const routes = [employeesRoute] as const;

In web/src/app/routes.ts, add import { routes as hrRoutes } from '@/features/hr/routes'; after the homeRoutes import, and ...hrRoutes, after ...printingRoutes, in appRoutes.

In web/src/app/shell/nav.ts, add after the /org-structure item:

{
group: 'manage',
to: '/hr/employees',
labelKey: 'hr:nav',
icon: UsersRound,
permission: 'hr.employee.read',
},

Create web/src/lib/i18n/locales/en/hr.json:

{
"nav": "Employees",
"title": "Employees",
"subtitle": "Employee records and their lifecycle: hire, confirmation, transfer and last day.",
"new": "New employee",
"search": "Search name or number",
"allStatuses": "All statuses",
"empty": "No employees yet.",
"loading": "Loading…",
"cancel": "Cancel",
"create": "Create",
"record": "Save",
"created": "Employee record created",
"none": "None",
"history": "History",
"identityHidden": "Hidden",
"identitySaved": "Identity document saved",
"overrideGranted": "Last day allowed; another member can record it now",
"login": { "active": "Active login", "inactive": "Login ended", "none": "No login" },
"status": { "pending": "Not hired yet", "active": "Active", "notice": "Serving notice", "left": "Left" },
"types": { "permanent": "Permanent", "contract": "Contract", "probation": "Probation", "part_time": "Part-time" },
"idKinds": { "nric": "NRIC", "passport": "Passport", "other": "Other" },
"events": {
"hired": "Hired",
"confirmed": "Confirmed",
"transferred": "Transferred",
"notice_given": "Notice given",
"left": "Left",
"rehired": "Rehired"
},
"fields": {
"employeeNo": "Employee no.",
"name": "Name",
"status": "Status",
"hireDate": "Hire date",
"lastDay": "Last day",
"employmentType": "Employment type",
"member": "Login",
"memberHint": "Choose the member this record belongs to, or leave it empty for someone with no login.",
"pickMember": "Choose a member",
"position": "New seat",
"positionHint": "Seats come from the org chart; you need org structure access to see them.",
"pickPosition": "Choose a seat",
"reason": "Reason",
"identity": "Identity document",
"idKind": "Document",
"idNumber": "Number"
},
"actions": {
"hire": "Record hire",
"rehire": "Record rehire",
"confirm": "Confirm",
"transfer": "Transfer",
"lastDay": "Record last day",
"override": "Allow last day",
"identity": "Identity document"
},
"dialog": {
"hired": { "title": "Record hire", "description": "The first day of work. A rehire keeps the employee number.", "date": "First day" },
"confirmed": { "title": "Confirm", "description": "Record the confirmation and the employment type from that day.", "date": "Confirmed on" },
"transferred": { "title": "Transfer", "description": "Move the primary seat from today or a later day.", "date": "From" },
"notice_given": { "title": "Record last day", "description": "Login ends after the last day; their seats become vacant and approvals move up.", "date": "Notice given on" },
"override": { "title": "Allow last day", "description": "Another member may then record this last day while these stay open. Your reason is kept in the audit trail." },
"identity": { "title": "Identity document", "description": "Personal data: shown only to members allowed to see it." }
},
"recorded": {
"hired": "Hire recorded",
"confirmed": "Confirmation recorded",
"transferred": "Transfer recorded",
"notice_given": "Last day recorded"
},
"obligations": {
"title": "Open items",
"none": "No open advance or float",
"blocking": "The last day cannot be recorded while these are open:",
"advance": "Advance {{reference}}",
"float": "Petty cash float {{code}}",
"overrideHint": "An Owner other than you can allow it with a reason.",
"pending_one": "{{count}} of their own documents is still awaiting approval; decide it before the last day.",
"pending_other": "{{count}} of their own documents are still awaiting approval; decide them before the last day."
},
"errors": {
"employeeNo": "Enter the employee number.",
"legalName": "Enter the name, or choose a member.",
"position": "Choose the new seat.",
"lastDay": "Choose the last day.",
"reason": "Give a reason."
},
"refusals": {
"open_obligations": "The person still holds an advance or petty cash float. An Owner other than you can allow the last day.",
"override_by_requester": "You allowed this last day yourself; another member must record it.",
"own_leaving_override": "Nobody allows their own leaving.",
"nothing_to_override": "Nothing is open, so nothing needs allowing.",
"last_administrator": "This is the last member who can manage roles. Give another member that role first.",
"employee_status": "The employee's status does not allow this.",
"employee_exists": "This person already has an employee record.",
"duplicate_employee_no": "Another employee has this number.",
"duplicate_identity": "Another employee has this identity document.",
"employee_no_login": "This person has no login, so holds no seat.",
"overlapping_primary": "They already have a primary seat from that day."
}
}

Create web/src/lib/i18n/locales/ms/hr.json:

{
"nav": "Pekerja",
"title": "Pekerja",
"subtitle": "Rekod pekerja dan kitaran hayatnya: pengambilan, pengesahan, pertukaran dan hari terakhir.",
"new": "Pekerja baharu",
"search": "Cari nama atau nombor",
"allStatuses": "Semua status",
"empty": "Belum ada pekerja.",
"loading": "Memuatkan…",
"cancel": "Batal",
"create": "Cipta",
"record": "Simpan",
"created": "Rekod pekerja dicipta",
"none": "Tiada",
"history": "Sejarah",
"identityHidden": "Tersembunyi",
"identitySaved": "Dokumen pengenalan disimpan",
"overrideGranted": "Hari terakhir dibenarkan; ahli lain boleh merekodkannya sekarang",
"login": { "active": "Log masuk aktif", "inactive": "Log masuk tamat", "none": "Tiada log masuk" },
"status": { "pending": "Belum diambil bekerja", "active": "Aktif", "notice": "Dalam tempoh notis", "left": "Telah berhenti" },
"types": { "permanent": "Tetap", "contract": "Kontrak", "probation": "Percubaan", "part_time": "Separuh masa" },
"idKinds": { "nric": "MyKad", "passport": "Pasport", "other": "Lain-lain" },
"events": {
"hired": "Diambil bekerja",
"confirmed": "Disahkan",
"transferred": "Dipindahkan",
"notice_given": "Notis diberi",
"left": "Berhenti",
"rehired": "Diambil semula"
},
"fields": {
"employeeNo": "No. pekerja",
"name": "Nama",
"status": "Status",
"hireDate": "Tarikh mula bekerja",
"lastDay": "Hari terakhir",
"employmentType": "Jenis pekerjaan",
"member": "Log masuk",
"memberHint": "Pilih ahli yang memiliki rekod ini, atau biarkan kosong bagi seseorang tanpa log masuk.",
"pickMember": "Pilih ahli",
"position": "Jawatan baharu",
"positionHint": "Jawatan diambil daripada carta organisasi; anda memerlukan akses struktur organisasi untuk melihatnya.",
"pickPosition": "Pilih jawatan",
"reason": "Sebab",
"identity": "Dokumen pengenalan",
"idKind": "Dokumen",
"idNumber": "Nombor"
},
"actions": {
"hire": "Rekod pengambilan",
"rehire": "Rekod pengambilan semula",
"confirm": "Sahkan",
"transfer": "Pindahkan",
"lastDay": "Rekod hari terakhir",
"override": "Benarkan hari terakhir",
"identity": "Dokumen pengenalan"
},
"dialog": {
"hired": { "title": "Rekod pengambilan", "description": "Hari pertama bekerja. Pengambilan semula mengekalkan nombor pekerja.", "date": "Hari pertama" },
"confirmed": { "title": "Sahkan", "description": "Rekod pengesahan dan jenis pekerjaan mulai hari itu.", "date": "Disahkan pada" },
"transferred": { "title": "Pindahkan", "description": "Pindahkan jawatan utama mulai hari ini atau hari kemudian.", "date": "Mulai" },
"notice_given": { "title": "Rekod hari terakhir", "description": "Log masuk tamat selepas hari terakhir; jawatannya menjadi kosong dan kelulusan beralih ke atas.", "date": "Notis diberi pada" },
"override": { "title": "Benarkan hari terakhir", "description": "Ahli lain kemudian boleh merekodkan hari terakhir ini walaupun perkara ini masih terbuka. Sebab anda disimpan dalam jejak audit." },
"identity": { "title": "Dokumen pengenalan", "description": "Data peribadi: hanya ditunjukkan kepada ahli yang dibenarkan melihatnya." }
},
"recorded": {
"hired": "Pengambilan direkodkan",
"confirmed": "Pengesahan direkodkan",
"transferred": "Pertukaran direkodkan",
"notice_given": "Hari terakhir direkodkan"
},
"obligations": {
"title": "Perkara terbuka",
"none": "Tiada pendahuluan atau apungan terbuka",
"blocking": "Hari terakhir tidak boleh direkodkan selagi perkara ini terbuka:",
"advance": "Pendahuluan {{reference}}",
"float": "Apungan tunai runcit {{code}}",
"overrideHint": "Pemilik selain anda boleh membenarkannya dengan sebab.",
"pending_one": "{{count}} dokumen miliknya masih menunggu kelulusan; putuskannya sebelum hari terakhir.",
"pending_other": "{{count}} dokumen miliknya masih menunggu kelulusan; putuskannya sebelum hari terakhir."
},
"errors": {
"employeeNo": "Masukkan nombor pekerja.",
"legalName": "Masukkan nama, atau pilih ahli.",
"position": "Pilih jawatan baharu.",
"lastDay": "Pilih hari terakhir.",
"reason": "Berikan sebab."
},
"refusals": {
"open_obligations": "Orang ini masih memegang pendahuluan atau apungan tunai runcit. Pemilik selain anda boleh membenarkan hari terakhir.",
"override_by_requester": "Anda sendiri membenarkan hari terakhir ini; ahli lain mesti merekodkannya.",
"own_leaving_override": "Tiada sesiapa boleh membenarkan pemberhentiannya sendiri.",
"nothing_to_override": "Tiada perkara terbuka, jadi tiada apa yang perlu dibenarkan.",
"last_administrator": "Ini ahli terakhir yang boleh mengurus peranan. Berikan peranan itu kepada ahli lain dahulu.",
"employee_status": "Status pekerja tidak membenarkan tindakan ini.",
"employee_exists": "Orang ini sudah mempunyai rekod pekerja.",
"duplicate_employee_no": "Pekerja lain mempunyai nombor ini.",
"duplicate_identity": "Pekerja lain mempunyai dokumen pengenalan ini.",
"employee_no_login": "Orang ini tiada log masuk, jadi tidak memegang jawatan.",
"overlapping_primary": "Mereka sudah mempunyai jawatan utama mulai hari itu."
}
}

Create web/src/lib/i18n/locales/zh/hr.json:

{
"nav": "员工",
"title": "员工",
"subtitle": "员工档案及其生命周期:入职、转正、调动和最后工作日。",
"new": "新建员工",
"search": "搜索姓名或工号",
"allStatuses": "全部状态",
"empty": "暂无员工。",
"loading": "加载中…",
"cancel": "取消",
"create": "创建",
"record": "保存",
"created": "已创建员工档案",
"none": "无",
"history": "记录",
"identityHidden": "已隐藏",
"identitySaved": "身份证件已保存",
"overrideGranted": "已允许最后工作日;其他成员现在可以登记",
"login": { "active": "登录有效", "inactive": "登录已结束", "none": "无登录" },
"status": { "pending": "尚未入职", "active": "在职", "notice": "离职通知期", "left": "已离职" },
"types": { "permanent": "正式", "contract": "合同", "probation": "试用", "part_time": "兼职" },
"idKinds": { "nric": "身份证(MyKad)", "passport": "护照", "other": "其他" },
"events": {
"hired": "入职",
"confirmed": "转正",
"transferred": "调动",
"notice_given": "提交离职通知",
"left": "离职",
"rehired": "重新入职"
},
"fields": {
"employeeNo": "工号",
"name": "姓名",
"status": "状态",
"hireDate": "入职日期",
"lastDay": "最后工作日",
"employmentType": "雇佣类型",
"member": "登录",
"memberHint": "选择此档案所属的成员;没有登录的人请留空。",
"pickMember": "选择成员",
"position": "新职位",
"positionHint": "职位来自组织架构图;需要组织架构的访问权限才能看到。",
"pickPosition": "选择职位",
"reason": "原因",
"identity": "身份证件",
"idKind": "证件",
"idNumber": "号码"
},
"actions": {
"hire": "登记入职",
"rehire": "登记重新入职",
"confirm": "转正",
"transfer": "调动",
"lastDay": "登记最后工作日",
"override": "允许最后工作日",
"identity": "身份证件"
},
"dialog": {
"hired": { "title": "登记入职", "description": "第一个工作日。重新入职沿用原工号。", "date": "第一个工作日" },
"confirmed": { "title": "转正", "description": "登记转正及自当日起的雇佣类型。", "date": "转正日期" },
"transferred": { "title": "调动", "description": "自今天或之后的某一天起调整主要职位。", "date": "生效日期" },
"notice_given": { "title": "登记最后工作日", "description": "最后工作日之后登录结束;其职位空缺,审批上移。", "date": "提交通知日期" },
"override": { "title": "允许最后工作日", "description": "其他成员随后可在这些事项仍未结清时登记此最后工作日。您的原因会记入审计记录。" },
"identity": { "title": "身份证件", "description": "个人资料:仅向获准查看的成员显示。" }
},
"recorded": {
"hired": "已登记入职",
"confirmed": "已登记转正",
"transferred": "已登记调动",
"notice_given": "已登记最后工作日"
},
"obligations": {
"title": "未结事项",
"none": "没有未结清的预支款或备用金",
"blocking": "以下事项未结清前,无法登记最后工作日:",
"advance": "预支款 {{reference}}",
"float": "备用金 {{code}}",
"overrideHint": "除您以外的所有者可以填写原因后允许。",
"pending_one": "其本人有 {{count}} 份单据仍在等待审批;请在最后工作日前处理。",
"pending_other": "其本人有 {{count}} 份单据仍在等待审批;请在最后工作日前处理。"
},
"errors": {
"employeeNo": "请输入工号。",
"legalName": "请输入姓名,或选择成员。",
"position": "请选择新职位。",
"lastDay": "请选择最后工作日。",
"reason": "请填写原因。"
},
"refusals": {
"open_obligations": "此人仍持有预支款或备用金。除您以外的所有者可以允许最后工作日。",
"override_by_requester": "此最后工作日由您本人允许,须由其他成员登记。",
"own_leaving_override": "任何人都不能允许自己的离职。",
"nothing_to_override": "没有未结事项,无需允许。",
"last_administrator": "这是最后一位可管理角色的成员。请先将该角色授予其他成员。",
"employee_status": "员工当前状态不允许此操作。",
"employee_exists": "此人已有员工档案。",
"duplicate_employee_no": "其他员工已使用此工号。",
"duplicate_identity": "其他员工已使用此身份证件。",
"employee_no_login": "此人没有登录,因此不担任职位。",
"overlapping_primary": "其自该日起已有主要职位。"
}
}
  • Step 7: Run the web checks

Run: pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test -- features/hr nav i18n Expected: PASS.

  • Step 8: Watch the override guard fail once

In EmployeeDrawer.tsx, change {ob.blocking && !own && ( to {!own && ( and run pnpm -C web test -- features/hr. Expected: FAIL — does not offer the override when nothing is open: the button is found. Restore; PASS.

  • Step 9: Look at it

Start the stack (make dev or the project’s run skill), sign in as the Owner, open Employees: create a record for a member, record a hire, open the drawer, record a last day for someone holding an advance (the dialog lists it and the save is refused with the open-obligations message), allow it from the Owner’s drawer, and record it as another member. Check en, ms and zh, and the phone width (drawer full screen, no horizontal scroll).

  • Step 10: Commit
Terminal window
git add web/src
git commit -m "Phase 5 H1: employee screens — list, drawer, lifecycle actions, override dialog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Files:

  • Modify: docs/ROADMAP.md (Phase 5), docs/DEPLOY_NOTES.md (new entry, newest first), docs/LOCK_ORDER.md (per-module table)

Interfaces:

  • Consumes: everything above.

  • Produces: the roadmap tick, the operator notes, the lock-order row.

  • Step 1: Tick the roadmap

In docs/ROADMAP.md, under ## Phase 5 — People, replace the line - [ ] HR layer deferred from Phase 1: job grades, salary bands, headcount, vacancies with:

- [ ] HR layer deferred from Phase 1: job grades, salary bands, headcount, vacancies
Design: [`2026-09-30-hr-layer-design.md`](superpowers/specs/2026-09-30-hr-layer-design.md) (HD-1–HD-8), lanes H1–H3
- [x] H1 employee record and lifecycle (`platform/hr`, `00800`–`00803`)
Plan: [`2026-09-30-phase-5-h1-employee-lifecycle.md`](superpowers/plans/2026-09-30-phase-5-h1-employee-lifecycle.md) (10 tasks, Rulings H1-1–H1-17)
Deploy: [`DEPLOY_NOTES.md`](DEPLOY_NOTES.md) — member numbers copied as pending records; the hourly leavings job
- Employee keyed to the employee party (HD-1); `employee_no` moved from `organization_member` (read-only there)
- Append-only lifecycle; status derived by `employee_spell`; identity number behind `hr.employee.sensitive`
- A last day is refused while an advance or float is open; an Owner who is not the requester overrides (HD-6)
- Hourly job: seats end the day after the last day, login ends, approvals escalate past the vacant seat
- Drift tests: one employee row per employee party; HD-6 refusal and override (`internal/platform/hr`)
- [ ] H1 follow-ups: withdrawing a notice (no event kind yet); refusing a new advance or float
after notice; a leaver's own documents on a superior-relative step lose their reporting
line (`no_primary_position`, Ruling H1-11); rehire restoring login (identity's invitation)
- [ ] H2 grades, salary bands, headcount, vacancies (`00820`–`00839`)

and under - [ ] Leave and claims interplay; lifecycle effects on approval routing add:

- [x] Lifecycle effects on approval routing: a leaver's seat ends and `on_vacancy: escalate`
resolves past it (H1, Ruling H1-10)
- [ ] H3 leave (`00840`–`00859`)
  • Step 2: Write the deploy notes

In docs/DEPLOY_NOTES.md, insert after the first --- line (before the newest existing entry):

## Phase 5 H1 — employee record and lifecycle
Migrations `00800`–`00803`; permission keys `hr.employee.read`, `hr.employee.manage`,
`hr.employee.sensitive`, `hr.employee.lifecycle_override` (the Owner's; `00802` grants existing
Owners and bumps `perm_version`); optional module `hr`, on by default; River periodic job
`hr_apply_leavings` (hourly, on the `maintenance` queue). No environment variable.
- **Every member with an employee number gets a pending employee record** (`00801`). It has no
hire date: HR records each hire (the Employees screen, or the `employees` import, which
completes a pending record matched by its number). Until then H3 grants such a person no leave.
- **Numbers two members shared are copied once**, for the member who had it first. List the rest
(as a superuser or a role with BYPASSRLS — every table has FORCE ROW LEVEL SECURITY):
```sql
SELECT m.org_id, m.id AS member_id, m.employee_no
FROM organization_member m
WHERE btrim(coalesce(m.employee_no, '')) <> ''
AND NOT EXISTS (SELECT 1 FROM employee e WHERE e.org_id = m.org_id AND e.party_id = m.party_id);
  • organization_member.employee_no is read-only from 00801: a script that updates it now fails with organization_member_employee_no_read_only. Change numbers on the employee record.
  • The worker must run hr_apply_leavings (it is registered in cmd/worker): it is what ends a leaver’s login and seats after their last day. A worker that is down leaves leavers signed in; the job catches up on start.
  • Import kinds and Phase 4 O1: 00803 lists items as well as employees in import_job_kind_known. If O1’s 00714 runs after 00803 on a database (a branch merged in the other order), it drops employees again: whichever merges second makes its migration’s list the union of both.
  • Down-migrations refuse once H1 has been used: 00803 while an employees import is recorded (import_job is append-only); 00800’s Down drops the lifecycle with the tables — roll the code back, not these migrations.
- [ ] **Step 3: Record the lock order**
In `docs/LOCK_ORDER.md`, add as the last row of the "Per module" table:
```markdown
| HR lifecycle (Phase 5 H1) | `organization` `FOR UPDATE` (always, Ruling H1-14: it is also the sole-owner exception's lock and the row `member.BumpPermVersion` updates) → `employee` `FOR UPDATE` → `organization_member` `FOR UPDATE` → a transfer's target `org_position` `FOR SHARE` → `member_position` rows `FOR UPDATE` in `id` order (`orgstruct.LockAssignmentsFrom`) → `employee_event`/`lifecycle_override` inserts → `member.Deactivate` (the member row again, the organization row's version) → assignment closes and withdrawals (rows already held) → audit. The `employees` import takes `organization` `FOR UPDATE` before its first row | `hr/events.go` `lockEmployee`, `Transfer`; `hr/leaving.go` `RecordLastDay`, `lockLeaving`, `apply`; `hr/job.go` `ApplyLeaving`; `imports/employees.go` |
  • Step 4: Run the full suite once

Run: make generate && git status --porcelain && go vet ./... && go test ./... -count=1 && pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test Expected: make generate leaves nothing to commit (the status prints nothing new beyond this task’s docs); every Go and web test passes, including TestEveryTableIsTenantIsolated, TestEveryTenantQueryCarriesAnExplicitOrgPredicate, TestEveryRouteIsAuthorized, TestTheOpenAPIDocumentSaysWhatTheRouterEnforces, TestOnlyOperationalTablesGrantDeleteToTheAppRole, TestEverySecurityDefinerIsAllowlistedAndNotPublic and the H1 drift tests.

  • Step 5: Commit
Terminal window
git add docs/ROADMAP.md docs/DEPLOY_NOTES.md docs/LOCK_ORDER.md
git commit -m "Phase 5 H1: roadmap, deploy notes and lock order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Self-review (done while writing; re-run before executing)

Section titled “Self-review (done while writing; re-run before executing)”

Spec coverage (§H1, HD-1, HD-6, the H1 drift bullets):

Spec Where
employee per employee party, unique, employee kind only; number, identity kind/number, hire date, employment type, last day, status Task 1 (table, employee_party_once, employee_party_is_employee, party_kind_kept_for_employee, employee_spell), Task 4
Link to login via organization_member.party_id; outside approver valid without a record Task 4 (GetByMember → ErrNotFound, MemberID nullable)
employee_no moves; member column kept read-only Task 2
Identity number only for hr.employee.sensitive; no salary, no bank details Tasks 4, 7 (DTO withholding; PUT gated), Ruling H1-5
employee_event append-only with the six kinds; status derived Tasks 1, 4, 5, 6
HD-6 refusal; Owner-not-requester override; append-only lifecycle_override + audit; sole owner Task 5, Rulings H1-6–H1-8
On the last day: seats end, login ends after the day, claims still settle, approvals move to the next filled seat Tasks 5–6, Rulings H1-9–H1-11
Rehire reuses party, row and number Task 4 (Hire → rehired), Task 6 test
employees import kind Task 8
Module install hr Task 7
Drift: two rows for one party / non-employee party refused Task 1, watched failing in Step 7
Drift: last day refused with an open advance or float; accepted with an Owner override who is not the requester Task 5, watched failing in Step 6
Migration ranges H1/H2/H3 reserved Task 1 Step 1
ROADMAP, DEPLOY_NOTES Task 10

Placeholder scan: no TBD/TODO; every code step carries its code, and every “watch it fail” step names the edit and the expected failure.

Type consistency: hr.Employee, hr.Event, hr.Override, hr.Obligations and every input type are defined once (Tasks 4–5) and used with the same field names in Tasks 6–8 and the module’s DTOs (Task 7). usableOverride takes the actor (Task 5). hrtest.Env embeds *advancestest.Env (which embeds *expensestest.Env), so e.Staff, e.Owner, e.OwnerSeat, e.StaffSeat, e.Member, e.Tx, e.TxErr, e.H, e.Org, e.Clock, e.DB, e.Received, e.UseAdvancePoster resolve. Web types come from Schemas[...] generated in Task 7.

Known limits, deliberately: Rulings H1-2 (no notice withdrawal), H1-6 (new advances after notice not blocked), H1-11 (a leaver’s superior-relative pending steps), H1-16 (rehire and login) are follow-ups on the roadmap, not gaps in this plan.