Phase 5 H2 — Grades, Salary Bands, Headcount and Vacancies 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 the seat tree a job grade and an approved headcount, keep effective-dated salary bands per grade, block a primary assignment beyond a seat’s approved headcount unless an Owner who is not the requester overrides it (HD-4), and add an approved vacancy document whose approval raises headcount for new seats and which hires fill.
Architecture: platform/hr (H1’s package) gains job_grade (names as translation rows, retired not deleted), grade_band (half-open periods, overlaps refused by an exclusion constraint), headcount_override (append-only) and vacancy (a vacancy workflow ending in committed). The seat keeps its own facts: org_position.grade_id and org_position.approved_headcount are added by ALTER, and platform/orgstruct owns every write to them. Over-headcount is refused by a BEFORE trigger on member_position that locks the seat FOR NO KEY UPDATE, counts live primary holders at the busiest day of the new assignment’s period, and — only when that exceeds the approved headcount — consumes one matching unused Owner override, or raises member_position_within_headcount. A hire names the vacancy it fills through H1’s HireInput.VacancyID; the database refuses a hire into a vacancy that is not open. modules/hr gains the HTTP surface; web/src/features/hr gains the grades, headcount and vacancies screens.
Tech Stack: Go 1.x, pgx v5, Postgres 16 (goose migrations), gin, oapi-codegen, React + TanStack Router/Query + i18next (web), vitest + msw.
Spec: docs/superpowers/specs/2026-09-30-hr-layer-design.md (§H2, owner decisions HD-4 and HD-5, the two H2 drift-test bullets). It builds on docs/superpowers/plans/2026-09-30-phase-5-h1-employee-lifecycle.md, whose “Interfaces H2 and H3 consume”, Rulings H1-1–H1-17 and Global Constraints are binding here. Architecture: docs/superpowers/specs/2026-09-20-erp-architecture-design.md §4.7, §5.5 (semantics), §19 Phase 5.
Global Constraints
Section titled “Global Constraints”- Migration range: H2
00820–00839(this plan uses00820–00824; H1 Task 1 already declared the range ininternal/kernel/db/migrationranges_test.go— do not add it again).00820exists only if Task 1 is not dropped. - Depends on H1 only. Every H1 name is used exactly as its plan defines it (
hr.Hire,hr.HireInput,hr.Event,insertEvent,mapConstraint,checkReason,orgOf,hr.ErrOverrideByRequester,hr.ErrNoSuchMember,hr.FieldError,hr.RevisionMismatchError,hrtest.New,hrtest.Day,hrtest.At,(*hrtest.Env).Hired,(*hrtest.Env).DeactivateByAdmin). Nothing here depends on H3. - A float never touches money. Salary bands are
NUMERIC(19,4)in Postgres,money.Amountin Go, a string on the wire. Bands are in the organization’s base currency (Ruling H2-3). - Every business table has
org_id,ENABLEandFORCE ROW LEVEL SECURITY, and a policy namedorg_isolation. Every tenant foreign key is composite onorg_id(TestEveryTenantForeignKeyIsCompositeOnOrgID). - Every constraint is named (
CONSTRAINT <table>_<rule>), including those added byALTER TABLE … ADD COLUMN;TestEveryConstraintIsNamedfails 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.). NoSECURITY DEFINER. - Retention:
job_grade,grade_bandandvacancyare amendable (DELETErevoked): a grade is retired, a band is ended once, a vacancy is closed.headcount_overrideis append-only (UPDATE, DELETErevoked). - Vocabulary values are strings: vacancy kinds (
replacement,new), vacancy statuses, grade codes. - Every user-facing name is a translation row: a grade’s name (
translation,entity_kind = 'job_grade',field = 'name', en and ms required, zh optional).job_gradehas no name column. - Record text is English or Bahasa Malaysia (spec 11A.3): grade names in en and ms, every reason (
recordtext.Check, and the same CJK CHECK the H1 tables carry). - A claimant never picks their own approver. The vacancy’s approver is the OWNER seat by seeded grant; the Owner who overrides headcount is never the requester (
headcount_override_not_requester, and Go’shr.ErrOverrideByRequester). - The contract is
api/openapi.yaml. Endpoints are declared there first;make generatemust leave the tree clean (internal/gen/drift_test.go,web/src/drift/schema.test.ts,internal/kernel/vocab/vocab_test.go). - Layering:
platform/hrmay importplatform/orgstruct,platform/workflow,platform/posting; none of those importhr. Every write toorg_positionandmember_positiongoes throughplatform/orgstruct.modules/hrimports only platform packages. - Drift tests: presence before absence, each watched failing once (AGENTS.md). While iterating run only the touched packages; run
go test ./...once, in Task 11. - Lock order (added to
docs/LOCK_ORDER.mdin Task 11): a seat is lockedFOR NO KEY UPDATE, neverFOR SHAREthen upgraded. Assignment:organization_memberFOR SHARE→org_positionFOR NO KEY UPDATE→ insert (the trigger re-takes the seat, already held) → audit. Override grant:organizationFOR UPDATE→org_positionFOR NO KEY UPDATE→ insert → audit. Vacancy transition:organizationFOR UPDATEin a one-member organization (workflow.LockSoleOwner) →vacancyFOR UPDATE→org_positionFOR NO KEY UPDATE(approving anewvacancy) →workflow.Transition→ headcount update (seat already held) → audit. A hire naming a vacancy: H1’s order, and theemployee_eventtrigger takesvacancyFOR UPDATEbefore the audit row. - 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.
Rulings (where the spec is silent)
Section titled “Rulings (where the spec is silent)”- H2-1
committedtravels droppably. Phase 4 O1 Task 1 owns thecommittedsemantic. Task 1 here carries it (and O1 Task 5’ssoleOwnerEdgeline) only so H2 builds alone; if O1’s00700is already on the integration branch, drop Task 1. The carried migration drops both possible names and createsworkflow_state_semantic_known— O1’s exact name — with the listconstants.yamlholds, so whichever lands second finds the constraint it expects. - H2-2 Grades. Code
^[A-Z0-9][A-Z0-9_-]{0,15}$, upper-cased, unique per organization and never changed; names en and ms required (record text), zh optional, 1–200 characters each; rank 1–999, not unique (two grades may sit level). Retired byis_active = false, never deleted; a retired grade takes no new band and no new seat, and seats that already name it keep it. - H2-3 Bands. Half-open
[effective_from, effective_to), amountsNUMERIC(19,4)in the organization’s base currency (MYR for a Malaysian organization — spec “MYR”; a currency column that could only ever equal the base currency is not added). Adding a band from a date ends the grade’s open band on that date; any other overlap is refused bygrade_band_no_overlap. A band’s amounts and start never change and its end is set once (grade_band_fixed): payroll will read past bands (HD-5), so they are never rewritten. - H2-4 The grade belongs to the seat.
org_position.grade_idis nullable and set throughhr.SetPositionGrade(which refuses a retired grade) →orgstruct.SetGrade. An employee’s grade is their primary seat’s; H2 stores nothing on the employee and adds no employee-facing grade field (payroll joins it when it needs it). - H2-5 Filled. People holding the seat as a primary, live (not withdrawn) assignment in force on the day. A non-primary assignment — acting, secondment, project seat;
member_positionhas no other flag — never counts, and is never refused for headcount. - H2-6 Over-headcount is peak concurrency, checked only on growth. An assignment is over when, on the busiest day of its own period, the seat would hold more live primary holders than its approved headcount (
position_peak_primary, the one definition; a sequence of back-to-back holders is not a crowd). The trigger fires on an insert and on an update that grows occupancy (primary turned on, seat changed, start moved earlier, end moved later or removed) — never on a close or a withdrawal, so ending a seat is never refused. Lowering an approved headcount below what is filled is allowed and audited; the report shows it asover. - H2-7 Backfill.
00822sets every existing seat’s approved headcount togreatest(1, its all-time peak of primary holders): no existing arrangement becomes a violation, at the cost of overstating a seat that was once job-shared (DEPLOY_NOTES tells HR to review them). - H2-8 The seat lock is
FOR NO KEY UPDATE.orgstruct.Assigntakes it before its existingFOR SHAREread, and H1’sTransferchanges its oneFOR SHAREline to it (the H1 plan’s note), so two assignments to one seat queue rather than deadlock on a share-to-update upgrade.FOR NO KEY UPDATEstill lets foreign keys that point at the seat (FOR KEY SHARE) through. - H2-9 The override: refuse, grant, used once, by the database. A refused assignment (
orgstruct.ErrOverHeadcount, HTTPover_headcount) is what tells the requester to ask. An Owner (new keyhr.headcount.override; the spec’s four H2 keys have no Owner key for HD-4) grants aheadcount_overridefor one member, one seat and one first day, naming the member who asked (requested_by) and a mandatory reason; the row snapshots the approved headcount and the peak it was granted over. The trigger consumes it: when an assignment for exactly that member, seat and first day would be over, it stamps the assignment’s newheadcount_override_id(unique: once only). Any path that assigns — the org chart, H1’s transfer — uses it with no new endpoint and no new input. The two-person rule is recorded on the override (headcount_override_not_requester CHECK (granted_by <> requested_by OR sole_owner)), becausemember_positionrecords no actor for the database to compare. Granting when the seat has room ishr.ErrWithinHeadcount. - H2-10 Sole owner (H1-8’s shape). When
requested_byis the granting Owner, the grant is accepted only underworkflow.SoleOwnerException(reason mandatory), storedsole_owner = trueand audited so; at use the trigger re-counts active members and a second one makes it an ordinary refusal again. In practice this covers only an Owner seating themselves: seating anyone else means that person is an active member too. An Owner alone who needs one more seat raises its approved headcount (hr.headcount.manageis not a second-person rule). - H2-11 The vacancy workflow.
draft → pending_approval → approved (committed), approved by the OWNER seat (a headcount decision; an organization may edit the graph), withreject(reason),reviseandwithdraw(reason).approvedis terminal: a vacancy ends by being filled or closed by hand, never cancelled back through the workflow. Opted into the sole-owner exception (workflow.AllowSoleOwnerException(KindVacancy, true), O1-18’s shape).vacancyjoinsworkflow.CommitmentKinds. - H2-12 Raising headcount exactly once. When a
newvacancy’s instance enters acommittedstate (by semantic, never by state name) and the move is not a pending sign-off,vacancy.headcount_raised_atis set where it is null and, only if that update touched the row, the seat’s approved headcount rises by the quantity.vacancy_fixedletsheadcount_raised_atbe written once, so a customised graph that re-enterscommittedraises nothing. Areplacementchanges nothing. Filling or closing never lowers headcount; HR lowers it by hand. - H2-13 Filling. A
hiredorrehiredevent may name a vacancy (employee_event.vacancy_id; spec “a hire links to a vacancy”; a transfer does not fill one). Filled is the count of such events — derived, never stored; a vacancy whose count is met readsfilled(auto-close, derived as O1-5 derives it). A manual close storesclosed_at,close_reason,closed_byand is audited.employee_event_vacancy_guardrefuses a hire into a vacancy that is not approved, is closed, or is already filled, locking the vacancy row. A hire does not seat the person (H1’sHireholds no seat); seating is the org chart or a transfer, under the headcount rule. - H2-14 Vacancy documents are not edited or numbered. A wrong draft is withdrawn and raised again (no update endpoint). The needed-by date is today or later at creation. A vacancy is identified by its seat and dates on screen; it carries no document number (it is not a statutory record).
- H2-15 Status is derived from the workflow semantic and the fill:
draft,pending(also any customer-added custom or dispute step),rejected,withdrawn,open,filled,closed. - H2-16 Reports.
GET /hr/reports/headcount?on=(default today): per unit, its active seats with approved, filled, vacant (max(approved − filled, 0)), over (max(filled − approved, 0)), grade code and open vacancies; a unit’s figures are the sums of its own seats (spec “the sum of its positions’”), not its subtree’s.GET /hr/reports/open-vacancies: open vacancies with remaining count, age in days since approval (the day the instance entered its committed state) andoverduewhen needed-by has passed. - H2-17 Permissions.
hr.grade.manage(grades, bands, a seat’s grade, reading grades),hr.headcount.manage(a seat’s approved headcount),hr.headcount.override(HD-4, Ruling H2-9),hr.vacancy.read(list, view, and the transitions route — the engine decides who may move a vacancy),hr.vacancy.request(raise one),hr.vacancy.close(close by hand),hr.report.read. All Owner-only by default (H1-13’s reasoning);00824grants existing Owners. - H2-18 Web. New screens live in
web/src/features/hrwith their own i18n namespacehrPlanning(en, ms, zh), so H1’s and H3’shr.jsonedits never collide with these.
Interfaces this plan produces
Section titled “Interfaces this plan produces”All in github.com/mcom/erp/internal/platform/....
// package orgstruct (Task 5)type Position struct { /* existing fields */; GradeID *uuid.UUID; ApprovedHeadcount int }type Assignment struct { /* existing fields */; HeadcountOverrideID *uuid.UUID }var ErrOverHeadcount, ErrNegativeHeadcount errortype SeatHeadcount struct { PositionID, UnitID uuid.UUID; Code, Title string; GradeID *uuid.UUID; IsActive bool; Approved, Filled int}func LockSeat(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Position, error) // FOR NO KEY UPDATEfunc SetHeadcount(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, approved int) (Position, error)func SetGrade(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, gradeID *uuid.UUID) (Position, error)func Headcount(ctx context.Context, tx pgx.Tx, on time.Time, only *uuid.UUID) ([]SeatHeadcount, error) // only: one seatfunc PeakPrimary(ctx context.Context, tx pgx.Tx, positionID uuid.UUID, from time.Time, to *time.Time) (int, error)
// package hr (Tasks 3, 5, 6, 7, 8)const ( EntityGrade = "job_grade"; EntityBand = "grade_band"; EntityHeadcountOverride = "headcount_override" EntityVacancy = "vacancy"; KindVacancy = "vacancy" VacancyReplacement = "replacement"; VacancyNew = "new" VacancyDraft = "draft"; VacancyPending = "pending"; VacancyRejected = "rejected"; VacancyWithdrawn = "withdrawn" VacancyOpen = "open"; VacancyFilled = "filled"; VacancyClosed = "closed" PermissionGradeManage = "hr.grade.manage"; PermissionHeadcountManage = "hr.headcount.manage" PermissionHeadcountOverride = "hr.headcount.override"; PermissionVacancyRead = "hr.vacancy.read" PermissionVacancyRequest = "hr.vacancy.request"; PermissionVacancyClose = "hr.vacancy.close" PermissionReportRead = "hr.report.read")var ErrGradeNotFound, ErrDuplicateGradeCode, ErrGradeRetired, ErrBandOverlap, ErrWithinHeadcount, ErrHeadcountOverrideByRequester, ErrVacancyNotFound, ErrVacancyNotOpen errortype Names map[string]stringtype Band struct { ID uuid.UUID; EffectiveFrom time.Time; EffectiveTo *time.Time; MinSalary, MaxSalary money.Amount }type Grade struct { ID uuid.UUID; Code string; Names Names; Rank int; IsActive bool; Revision int; Bands []Band; CreatedAt, UpdatedAt time.Time }func (g Grade) BandOn(day time.Time) *Bandtype GradeInput struct { Code string; Names Names; Rank int }type GradeUpdate struct { Names Names; Rank int; IsActive bool }type BandInput struct { GradeID uuid.UUID; EffectiveFrom time.Time; MinSalary, MaxSalary money.Amount }func CreateGrade(ctx context.Context, tx pgx.Tx, c clock.Clock, in GradeInput) (Grade, error)func UpdateGrade(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in GradeUpdate) (Grade, error)func GetGrade(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Grade, error)func ListGrades(ctx context.Context, tx pgx.Tx, activeOnly bool) ([]Grade, error)func AddBand(ctx context.Context, tx pgx.Tx, c clock.Clock, in BandInput) (Band, error)func SetPositionGrade(ctx context.Context, tx pgx.Tx, c clock.Clock, positionID uuid.UUID, gradeID *uuid.UUID) (orgstruct.Position, error)type HeadcountOverride struct { ID, PositionID, MemberID uuid.UUID; ValidFrom time.Time; RequestedBy uuid.UUID; Reason string Approved, Filled int; GrantedBy uuid.UUID; SoleOwner bool; CreatedAt time.Time}type HeadcountOverrideInput struct { PositionID, MemberID uuid.UUID; ValidFrom time.Time; RequestedBy uuid.UUID; Reason string; Actor uuid.UUID }func GrantHeadcountOverride(ctx context.Context, tx pgx.Tx, c clock.Clock, in HeadcountOverrideInput) (HeadcountOverride, error)type Vacancy struct { ID, PositionID uuid.UUID; Kind string; Quantity, Filled int; NeededBy time.Time; Reason string RequestedBy, InstanceID uuid.UUID; State string; Semantic posting.Semantic; EnteredStateOn time.Time HeadcountRaisedAt, ClosedAt *time.Time; CloseReason *string; ClosedBy *uuid.UUID; CreatedAt time.Time}func (v Vacancy) Status() stringfunc (v Vacancy) Remaining() inttype VacancyInput struct { PositionID uuid.UUID; Kind string; Quantity int; NeededBy time.Time; Reason string; Actor uuid.UUID }type VacancyFilter struct { Status string; PositionID *uuid.UUID }type VacancyTransitionInput struct { ID uuid.UUID; From, To string; Actor uuid.UUID; Reason string }type CloseVacancyInput struct { ID uuid.UUID; Reason string; Actor uuid.UUID }func VacancyGraph(ownerSeat uuid.UUID) workflow.Graphfunc CreateVacancy(ctx context.Context, tx pgx.Tx, c clock.Clock, in VacancyInput) (Vacancy, error)func GetVacancy(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Vacancy, error)func ListVacancies(ctx context.Context, tx pgx.Tx, f VacancyFilter) ([]Vacancy, error)func TransitionVacancy(ctx context.Context, tx pgx.Tx, c clock.Clock, in VacancyTransitionInput) (Vacancy, workflow.Decision, error)func CloseVacancy(ctx context.Context, tx pgx.Tx, c clock.Clock, in CloseVacancyInput) (Vacancy, error)type SeatCount struct { PositionID uuid.UUID; Code, Title string; GradeID *uuid.UUID; GradeCode *string Approved, Filled, Vacant, Over, OpenVacancies int}type UnitCount struct { UnitID uuid.UUID; Code, Name string; Approved, Filled, Vacant, Over int; Positions []SeatCount }type OpenVacancy struct { Vacancy; PositionCode, PositionTitle string; UnitID uuid.UUID; AgeDays int; Overdue bool }func HeadcountReport(ctx context.Context, tx pgx.Tx, on time.Time) ([]UnitCount, error)func OpenVacancies(ctx context.Context, tx pgx.Tx, today time.Time) ([]OpenVacancy, error)// H1 types extended (Task 7): HireInput.VacancyID *uuid.UUID; Event.VacancyID *uuid.UUID
// package hr/hrtest (Task 4)func (e *Env) Newcomer(t *testing.T, name string) uuid.UUID // active member, employee party, no seatfunc (e *Env) Seat(t *testing.T, code string) uuid.UUID // an empty seat under OWNER, approved headcount 1
// package workflow (Task 6)var CommitmentKinds // gains "vacancy"SQL: position_peak_primary(p_org uuid, p_position uuid, p_from date, p_to date, p_exclude uuid) RETURNS int, vacancy_workflow(p_org uuid, p_vacancy uuid) RETURNS TABLE (instance_id uuid, state_key text, semantic text, entered_on date).
File Structure
Section titled “File Structure”Create
| Path | Responsibility |
|---|---|
db/migrations/00820_committed_semantic.sql |
Task 1 only if O1’s 00700 is absent: committed joins workflow_state_semantic_known |
db/migrations/00821_job_grade.sql |
job_grade, grade_band, grade_band_fixed |
db/migrations/00822_headcount.sql |
org_position.grade_id, .approved_headcount (+ backfill), position_peak_primary, headcount_override, member_position.headcount_override_id, the headcount trigger |
db/migrations/00823_vacancy.sql |
vacancy, vacancy_fixed, vacancy_workflow, employee_event.vacancy_id and its guard |
db/migrations/00824_hr_planning_grants.sql |
existing Owners get the seven H2 keys |
internal/platform/hr/planning.go |
H2 constants, permission keys, sentinels |
internal/platform/hr/grade.go |
grades, names, bands, a seat’s grade |
internal/platform/hr/headcount.go |
the HD-4 override |
internal/platform/hr/vacancy.go |
vacancy documents, graph, transitions, close |
internal/platform/hr/report.go |
the two JSON reports |
internal/platform/hr/{gradeschema,grade,migration00822,headcount,vacancy,fill,report,migration00824}_test.go |
tests |
internal/platform/orgstruct/headcount.go, headcount_test.go |
seat grade/headcount writes, lock, counts |
internal/modules/hr/planning.go |
H2 handlers and DTOs |
internal/modules/hr/planning_test.go |
H2 route tests |
web/src/features/hr/{planning.ts,GradesPage.tsx,HeadcountPage.tsx,VacanciesPage.tsx,VacancyDrawer.tsx} |
screens |
web/src/features/hr/__tests__/planning.test.tsx |
screen tests |
web/src/lib/i18n/locales/{en,ms,zh}/hrPlanning.json |
screen copy |
Modify — internal/platform/orgstruct/{position,assignment,assignment_test}.go, internal/platform/workflow/workflowtest/fixture.go, internal/platform/workflow/model.go, internal/platform/hr/{errors,events}.go, internal/platform/hr/hrtest/env.go, internal/kernel/db/retention_test.go, internal/modules/hr/{permissions,routes,errors,handlers,routes_test}.go, internal/modules/orgstructure/handlers.go, internal/app/routes_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/features/hr/{routes.tsx,LifecycleDialog.tsx}, web/src/app/shell/nav.ts, docs/ROADMAP.md, docs/DEPLOY_NOTES.md, docs/LOCK_ORDER.md. Task 1 (if kept) modifies what O1 Task 1 lists.
Task 1: The committed semantic — O1 Task 1, carried over (drop if O1 landed first)
Section titled “Task 1: The committed semantic — O1 Task 1, carried over (drop if O1 landed first)”O1 Task 1 is canonical; if O1 lands first, drop this task. Check with git grep -n 'Committed Semantic = "committed"' internal/platform/posting/: a hit means O1 (or H3’s carried copy) already landed, so skip to Task 2. 00820 is then never created, and nothing below depends on its number.
This task exists only so H2 builds alone (Ruling H2-1). It is O1’s Task 1: execute docs/superpowers/plans/2026-09-30-phase-4-o1-chain-items.md → “Task 1: The committed semantic, end to end”, Steps 2–13, every file, test, code block, run command and web edit exactly as written there, with these substitutions and no others:
| In O1 Task 1 | In H2 Task 1 |
|---|---|
Step 1 (declare the 00700–00789 ranges) |
Skipped: H2 creates no 007xx migration, and H1 declared 00820–00839. |
db/migrations/00700_committed_semantic.sql |
db/migrations/00820_committed_semantic.sql. The header comment’s first line reads -- Phase 4 O1.1's committed semantic, carried by Phase 5 H2 Task 1 because H2 merged first (O1 Task 1 is canonical). |
The Up statement ALTER TABLE workflow_state DROP CONSTRAINT workflow_state_semantic_check, ADD CONSTRAINT workflow_state_semantic_known CHECK (…) |
ALTER TABLE workflow_state DROP CONSTRAINT IF EXISTS workflow_state_semantic_check, DROP CONSTRAINT IF EXISTS workflow_state_semantic_known, ADD CONSTRAINT workflow_state_semantic_known CHECK (semantic IN (…)), where the list is exactly constants.yaml’s workflowSemantics after Step 4 (so 'effect' is in it when O2’s 00720 already landed). |
| The Down | ALTER TABLE workflow_state DROP CONSTRAINT workflow_state_semantic_known, ADD CONSTRAINT workflow_state_semantic_known CHECK (semantic IN (…)) with the same list minus 'committed'. |
In Step 8, “workflow_state_semantic_check” in the expected failure |
The same text — the old unnamed constraint still refuses committed before 00820 exists. |
| Step 10’s run command | go test ./internal/platform/workflow/ ./internal/kernel/db/ -run 'TestTheDatabaseAcceptsTheCommittedSemantic|TestEveryMigrationIsNumberedInItsOwnersRange|TestEveryConstraintIsNamed' -count=1 (unchanged) |
Step 13’s git add of db/migrations/00700_committed_semantic.sql |
db/migrations/00820_committed_semantic.sql, and the commit subject Phase 5 H2 Task 1: the committed semantic (O1 Task 1, canonical) |
It also carries O1 Task 5 Step 6 (O1-18), because the vacancy’s approval rests on it: in internal/platform/workflow/soleowner.go, in soleOwnerEdge, replace
void := from.Semantic == posting.Accrual && to.Semantic == posting.Cancelled && e.ReversesAccrual return (m.void && void) || (m.issue && to.Semantic == posting.Accrual) || (m.payout && to.Semantic == posting.Payout)with
void := from.Semantic == posting.Accrual && to.Semantic == posting.Cancelled && e.ReversesAccrual // Phase 4 O1 (Ruling O1-18): a commitment's approval and its cancel are // a second person's decisions too; the cancel reverses nothing. void = void || (from.Semantic == posting.Committed && to.Semantic == posting.Cancelled) issue := to.Semantic == posting.Accrual || to.Semantic == posting.Committed return (m.void && void) || (m.issue && issue) || (m.payout && to.Semantic == posting.Payout)(if O2’s Task 1 already edited these lines for effect, keep its condition too: issue := to.Semantic == posting.Accrual || to.Semantic == posting.Effect || to.Semantic == posting.Committed). Add internal/platform/workflow/soleowner.go to Step 13’s git add. Task 6’s TestASoleOwnerApprovesTheirOwnVacancyWithAReason is the test that fails without it.
Interfaces:
- Produces, identical to O1 Task 1 and relied on by Task 6:
posting.Committed Semantic = "committed"(PostsToLedger()false);workflow.CommitmentKinds []string(O1’s four kinds; Task 6 appendsvacancy); the validator’s rule 3 codescommitted_count,posting_in_commitment,committed_not_commitment_kind; DB constraintworkflow_state_semantic_knownacceptingcommitted; OpenAPISemantic/WorkflowSemanticenums withcommitted;semanticTone.tscommitted: 'green';soleOwnerEdge’sissuecovering the edge into a committed state.
Task 2: Grades and bands in the database (00821)
Section titled “Task 2: Grades and bands in the database (00821)”Files:
- Create:
db/migrations/00821_job_grade.sql,internal/platform/hr/gradeschema_test.go - Modify:
internal/kernel/db/retention_test.go - Regenerate:
internal/gen/sqlc/models.go(make generate)
Interfaces:
-
Consumes: nothing new (
hrtest.Newfrom H1). -
Produces: tables
job_grade,grade_band; constraintsjob_grade_code_once,job_grade_code_shape,job_grade_rank_range,grade_band_no_overlap,grade_band_min_le_max,grade_band_min_nonnegative,grade_band_period; trigger constraintgrade_band_fixed. -
Step 1: Write the failing schema test
Create internal/platform/hr/gradeschema_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/hrtest")
func insertGrade(t *testing.T, e *hrtest.Env, code string) uuid.UUID { t.Helper() var id uuid.UUID e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, `INSERT INTO job_grade (org_id, code, rank, created_at, updated_at) VALUES ($1, $2, 1, now(), now()) RETURNING id`, e.Org, code).Scan(&id) }) return id}
// Spec H2 "grade_band" (Ruling H2-3): the database refuses overlapping// periods for one grade and a minimum above the maximum, and a band's// amounts and start never change; its end is set once.func TestTheDatabaseGuardsGradeBands(t *testing.T) { e := hrtest.New(t) ctx := context.Background() grade := insertGrade(t, e, "G1") band := func(from string, to *string, min, max string) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `INSERT INTO grade_band (org_id, grade_id, effective_from, effective_to, min_salary, max_salary, created_at) VALUES ($1, $2, $3::date, $4::date, $5::numeric, $6::numeric, now())`, e.Org, grade, from, to, min, max) return err }) } s := func(v string) *string { return &v }
// PRESENCE: bands that meet end to start are both kept. require.NoError(t, band("2026-01-01", s("2027-01-01"), "2500.0000", "4000.0000")) require.NoError(t, band("2027-01-01", nil, "2600.0000", "4200.0000")) // ABSENCE: a minimum above the maximum, and an overlap. require.ErrorContains(t, band("2025-01-01", s("2025-06-01"), "5000.0000", "4000.0000"), "grade_band_min_le_max") require.ErrorContains(t, band("2026-06-01", s("2026-07-01"), "1.0000", "2.0000"), "grade_band_no_overlap")
// PRESENCE: an open band's end may be set. _, err := e.H.Admin.Exec(ctx, `UPDATE grade_band SET effective_to = DATE '2028-01-01' WHERE grade_id = $1 AND effective_from = DATE '2027-01-01'`, grade) require.NoError(t, err) // ABSENCE: then never again, and a band's amounts never change. _, err = e.H.Admin.Exec(ctx, `UPDATE grade_band SET effective_to = DATE '2029-01-01' WHERE grade_id = $1 AND effective_from = DATE '2027-01-01'`, grade) require.ErrorContains(t, err, "grade_band_fixed") _, err = e.H.Admin.Exec(ctx, `UPDATE grade_band SET max_salary = 9999 WHERE grade_id = $1 AND effective_from = DATE '2026-01-01'`, grade) require.ErrorContains(t, err, "grade_band_fixed")}
// Spec H2 "Retired, never deleted": the app role retires a grade and cannot// erase it or a band.func TestGradesAndBandsAreRetiredNeverDeleted(t *testing.T) { e := hrtest.New(t) grade := insertGrade(t, e, "G1") e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { // PRESENCE: the app role retires it. _, err := tx.Exec(ctx, `UPDATE job_grade SET is_active = false WHERE org_id = $1 AND id = $2`, e.Org, grade) return err }) // ABSENCE: and cannot delete it. err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `DELETE FROM job_grade WHERE org_id = $1 AND id = $2`, e.Org, grade) 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 grade_band WHERE org_id = $1`, e.Org) return err }) require.ErrorContains(t, err, "permission denied")}- Step 2: Run it to verify it fails
Run: go test ./internal/platform/hr/ -run 'TestTheDatabaseGuardsGradeBands|TestGradesAndBandsAreRetiredNeverDeleted' -count=1
Expected: FAIL — relation "job_grade" does not exist.
- Step 3: Write the migration
Create db/migrations/00821_job_grade.sql:
-- +goose Up-- Phase 5 H2 (docs/superpowers/specs/2026-09-30-hr-layer-design.md §H2):-- job grades and their salary bands. A grade's name is translation rows-- (entity_kind job_grade, field name; Ruling H2-2) — there is no name-- column. A grade is retired (is_active), never deleted. A band is-- effective-dated, half-open [effective_from, effective_to), in the-- organization's base currency (Ruling H2-3). Payroll will check a salary-- against a band (HD-5); nothing here does.
CREATE TABLE job_grade ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), code TEXT NOT NULL CONSTRAINT job_grade_code_shape CHECK (code ~ '^[A-Z0-9][A-Z0-9_-]{0,15}$'), rank INT NOT NULL CONSTRAINT job_grade_rank_range CHECK (rank BETWEEN 1 AND 999), is_active BOOLEAN NOT NULL DEFAULT true, revision INT NOT NULL DEFAULT 1 CONSTRAINT job_grade_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT job_grade_tenant_id UNIQUE (org_id, id), CONSTRAINT job_grade_code_once UNIQUE (org_id, code));ALTER TABLE job_grade ENABLE ROW LEVEL SECURITY;ALTER TABLE job_grade FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON job_grade USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON job_grade FROM erp_app;
CREATE TABLE grade_band ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), grade_id UUID NOT NULL, effective_from DATE NOT NULL, effective_to DATE, min_salary NUMERIC(19,4) NOT NULL CONSTRAINT grade_band_min_nonnegative CHECK (min_salary >= 0), max_salary NUMERIC(19,4) NOT NULL, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT grade_band_tenant_id UNIQUE (org_id, id), CONSTRAINT grade_band_min_le_max CHECK (min_salary <= max_salary), CONSTRAINT grade_band_period CHECK (effective_to IS NULL OR effective_to > effective_from), CONSTRAINT grade_band_grade_fk FOREIGN KEY (org_id, grade_id) REFERENCES job_grade (org_id, id), -- One band per grade on any day (spec H2). btree_gist is 00018's. CONSTRAINT grade_band_no_overlap EXCLUDE USING gist ( org_id WITH =, grade_id WITH =, daterange(effective_from, effective_to, '[)') WITH &&));ALTER TABLE grade_band ENABLE ROW LEVEL SECURITY;ALTER TABLE grade_band FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON grade_band USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON grade_band FROM erp_app;
-- Ruling H2-3: payroll reads past bands, so a band's grade, start and-- amounts never change; only an open band's end is set, once.-- +goose StatementBeginCREATE FUNCTION grade_band_fixed() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF NEW.grade_id <> OLD.grade_id OR NEW.effective_from <> OLD.effective_from OR NEW.min_salary <> OLD.min_salary OR NEW.max_salary <> OLD.max_salary OR OLD.effective_to IS NOT NULL OR NEW.effective_to IS NULL THEN RAISE EXCEPTION 'grade band %: only an open band''s end may be set, and only once', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'grade_band_fixed'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER grade_band_fixed BEFORE UPDATE ON grade_band FOR EACH ROW EXECUTE FUNCTION grade_band_fixed();
-- +goose DownDROP TABLE grade_band;DROP FUNCTION grade_band_fixed();DROP TABLE job_grade;- Step 4: Declare the retention of the two tables
In internal/kernel/db/retention_test.go, add to amendable after H1’s "employee", entry:
// Phase 5 H2 (00821): grades are retired and bands ended, never // erased — payroll reads a past band (HD-5). "job_grade", "grade_band",- Step 5: Run the tests
Run: go test ./internal/platform/hr/ ./internal/kernel/db/ -count=1
Expected: PASS (including TestEveryTableIsTenantIsolated, TestEveryTenantForeignKeyIsCompositeOnOrgID, TestEveryConstraintIsNamed, TestEveryFunctionPinsItsSearchPath, TestOnlyOperationalTablesGrantDeleteToTheAppRole, TestEveryMigrationIsNumberedInItsOwnersRange).
- Step 6: Watch the drift test fail once
In 00821_job_grade.sql, comment out the four lines of CONSTRAINT grade_band_no_overlap EXCLUDE … (and the comma before them) and run go test ./internal/platform/hr/ -run TestTheDatabaseGuardsGradeBands -count=1. Expected: FAIL — An error is expected but got nil at the grade_band_no_overlap assertion. Restore; PASS.
Then in grade_band_fixed() delete OR OLD.effective_to IS NOT NULL and run the same test. Expected: FAIL at the “then never again” assertion. Restore; PASS.
- Step 7: Regenerate sqlc’s models
Run: make generate
Expected: internal/gen/sqlc/models.go gains JobGrade and GradeBand; nothing else changes. go test ./internal/gen/ -count=1 PASS.
- Step 8: Commit
git add db/migrations/00821_job_grade.sql internal/platform/hr/gradeschema_test.go \ internal/kernel/db/retention_test.go internal/gen/sqlcgit commit -m "Phase 5 H2: job_grade and grade_band, overlaps and min above max refused
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 3: The grade service (platform/hr)
Section titled “Task 3: The grade service (platform/hr)”Files:
- Create:
internal/platform/hr/planning.go,internal/platform/hr/grade.go - Test:
internal/platform/hr/grade_test.go
Interfaces:
-
Consumes: Task 2’s schema; H1’s
orgOf,FieldError,RevisionMismatchError,utcDate;audit.Write,recordtext.Check,money.Amount. -
Produces: every constant, permission key and sentinel of
planning.go(listed in “Interfaces this plan produces”);isConstraint(err error, name string) bool(package-internal);Names,Band,Grade,Grade.BandOn,GradeInput,GradeUpdate,BandInput,CreateGrade,UpdateGrade,GetGrade,ListGrades,AddBand. -
Step 1: Write the failing tests
Create internal/platform/hr/grade_test.go:
package hr_test
import ( "context" "errors" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/hr" "github.com/mcom/erp/internal/platform/hr/hrtest")
func names() hr.Names { return hr.Names{"en": "Executive", "ms": "Eksekutif", "zh": "主管"}}
func createGrade(e *hrtest.Env, in hr.GradeInput) (hr.Grade, error) { var g hr.Grade err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { var err error g, err = hr.CreateGrade(ctx, tx, e.Clock, in) return err }) return g, err}
func fieldRule(err error) string { var fe *hr.FieldError if !errors.As(err, &fe) { return "" } return fe.Field + "/" + fe.Rule}
// Spec H2 "job_grade", Ruling H2-2: named in English and Malay as// translation rows, unique by code, retired rather than deleted.func TestAGradeIsNamedInEnglishAndMalayAndRetiredNotDeleted(t *testing.T) { e := hrtest.New(t) g, err := createGrade(e, hr.GradeInput{Code: " e1 ", Names: names(), Rank: 10}) require.NoError(t, err, "PRESENCE") require.Equal(t, "E1", g.Code, "trimmed and upper-cased") require.Equal(t, names(), g.Names) require.True(t, g.IsActive) require.Equal(t, 3, e.Count(t, `SELECT count(*) FROM translation WHERE org_id = $1 AND entity_kind = 'job_grade' AND entity_id = $2 AND field = 'name'`, e.Org, g.ID))
_, err = createGrade(e, hr.GradeInput{Code: "E1", Names: names(), Rank: 10}) require.ErrorIs(t, err, hr.ErrDuplicateGradeCode) _, err = createGrade(e, hr.GradeInput{Code: "E2", Names: hr.Names{"en": "Executive"}, Rank: 10}) require.Equal(t, "names/required", fieldRule(err)) _, err = createGrade(e, hr.GradeInput{Code: "E2", Names: hr.Names{"en": "Executive", "ms": "主管"}, Rank: 10}) require.Equal(t, "names.ms/record_language", fieldRule(err)) _, err = createGrade(e, hr.GradeInput{Code: "E2", Names: names(), Rank: 0}) require.Equal(t, "rank/range", fieldRule(err))
var retired hr.Grade e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error retired, err = hr.UpdateGrade(ctx, tx, e.Clock, g.ID, g.Revision, hr.GradeUpdate{Names: hr.Names{"en": "Executive", "ms": "Eksekutif"}, Rank: 11, IsActive: false}) return err }) require.False(t, retired.IsActive) require.Equal(t, 11, retired.Rank) require.Equal(t, g.Revision+1, retired.Revision) require.NotContains(t, retired.Names, "zh", "a name left out is removed")
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := hr.UpdateGrade(ctx, tx, e.Clock, g.ID, g.Revision, hr.GradeUpdate{Names: names(), Rank: 10, IsActive: true}) return err }) var stale *hr.RevisionMismatchError require.ErrorAs(t, err, &stale)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { all, err := hr.ListGrades(ctx, tx, false) require.NoError(t, err) require.Len(t, all, 1, "PRESENCE: the retired grade is still listed") active, err := hr.ListGrades(ctx, tx, true) require.NoError(t, err) require.Empty(t, active, "ABSENCE: but not among the active ones") return nil })}
// Ruling H2-3: a new band ends the open one on its first day; any other// overlap is refused; a retired grade takes no band.func TestAddingABandEndsTheOpenOne(t *testing.T) { e := hrtest.New(t) g, err := createGrade(e, hr.GradeInput{Code: "E1", Names: names(), Rank: 10}) require.NoError(t, err) add := func(from string, min, max string) (hr.Band, error) { var b hr.Band err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { var err error b, err = hr.AddBand(ctx, tx, e.Clock, hr.BandInput{GradeID: g.ID, EffectiveFrom: mustDay(from), MinSalary: money.MustParse(min), MaxSalary: money.MustParse(max)}) return err }) return b, err } _, err = add("2026-01-01", "2500.00", "4000.00") require.NoError(t, err) second, err := add("2027-01-01", "2600.00", "4200.00") require.NoError(t, err) require.Nil(t, second.EffectiveTo, "the new band is open")
var got hr.Grade e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error got, err = hr.GetGrade(ctx, tx, g.ID) return err }) require.Len(t, got.Bands, 2) require.Equal(t, mustDay("2027-01-01"), *got.Bands[0].EffectiveTo, "the open band ended where the new one starts") require.Equal(t, "4000.0000", got.BandOn(mustDay("2026-06-01")).MaxSalary.String()) require.Equal(t, "4200.0000", got.BandOn(mustDay("2027-06-01")).MaxSalary.String()) require.Nil(t, got.BandOn(mustDay("2025-06-01")), "no band before the first")
_, err = add("2026-06-01", "1.00", "2.00") require.ErrorIs(t, err, hr.ErrBandOverlap, "inside a closed band") _, err = add("2028-01-01", "5000.00", "4000.00") require.Equal(t, "max_salary/min_le_max", fieldRule(err))
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := hr.UpdateGrade(ctx, tx, e.Clock, got.ID, got.Revision, hr.GradeUpdate{Names: names(), Rank: 10}) return err }) _, err = add("2028-01-01", "2700.00", "4300.00") require.ErrorIs(t, err, hr.ErrGradeRetired)}Append to the same file:
func mustDay(s string) time.Time { d, err := time.Parse("2006-01-02", s) if err != nil { panic(err) } return d}and add "time" to its imports.
- Step 2: Run them to verify they fail
Run: go test ./internal/platform/hr/ -run 'TestAGradeIs|TestAddingABand' -count=1
Expected: FAIL to compile — undefined: hr.CreateGrade, hr.Names, hr.AddBand, ….
- Step 3: Write the constants
Create internal/platform/hr/planning.go:
package hr
import ( "errors"
"github.com/jackc/pgx/v5/pgconn")
// Phase 5 H2 (spec §H2, HD-4, HD-5): grades, bands, headcount and// vacancies. Rulings H2-1–H2-18 are in// docs/superpowers/plans/2026-09-30-phase-5-h2-grades-headcount.md.const ( EntityGrade = "job_grade" // audit and translation entity kind EntityBand = "grade_band" EntityHeadcountOverride = "headcount_override" EntityVacancy = "vacancy"
// KindVacancy is the vacancy's workflow kind. KindVacancy = "vacancy"
VacancyReplacement = "replacement" VacancyNew = "new"
// A vacancy's derived status (Ruling H2-15). VacancyDraft = "draft" VacancyPending = "pending" VacancyRejected = "rejected" VacancyWithdrawn = "withdrawn" VacancyOpen = "open" VacancyFilled = "filled" VacancyClosed = "closed"
// Ruling H2-17: Owner-only by default. PermissionGradeManage = "hr.grade.manage" PermissionHeadcountManage = "hr.headcount.manage" PermissionHeadcountOverride = "hr.headcount.override" PermissionVacancyRead = "hr.vacancy.read" PermissionVacancyRequest = "hr.vacancy.request" PermissionVacancyClose = "hr.vacancy.close" PermissionReportRead = "hr.report.read")
var VacancyKinds = []string{VacancyReplacement, VacancyNew}
var ( ErrGradeNotFound = errors.New("hr: no such grade in this organization") ErrDuplicateGradeCode = errors.New("hr: another grade has this code") ErrGradeRetired = errors.New("hr: the grade is retired") ErrBandOverlap = errors.New("hr: the band overlaps another band of this grade") ErrWithinHeadcount = errors.New("hr: the seat has room for this assignment; no override is needed") ErrVacancyNotFound = errors.New("hr: no such vacancy in this organization") ErrVacancyNotOpen = errors.New("hr: the vacancy is not open: it is not approved, or is filled or closed"))
// isConstraint reports whether err is the database refusing by the named// constraint.func isConstraint(err error, name string) bool { var pg *pgconn.PgError return errors.As(err, &pg) && pg.ConstraintName == name}- Step 4: Write the grade service
Create internal/platform/hr/grade.go:
package hr
import ( "context" "errors" "fmt" "regexp" "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/money" "github.com/mcom/erp/internal/kernel/recordtext")
// Names is a grade's name per locale: en and ms required (either may be the// record language, spec 11A.3), zh optional (Ruling H2-2).type Names map[string]string
var nameLocales = []string{"en", "ms", "zh"}
// Band is one salary band, [EffectiveFrom, EffectiveTo), in the// organization's base currency (Ruling H2-3).type Band struct { ID uuid.UUID `json:"id"` EffectiveFrom time.Time `json:"effective_from"` EffectiveTo *time.Time `json:"effective_to,omitempty"` MinSalary money.Amount `json:"min_salary"` MaxSalary money.Amount `json:"max_salary"`}
// Grade is a job grade with its names and bands (effective_from order).type Grade struct { ID uuid.UUID `json:"id"` Code string `json:"code"` Names Names `json:"names"` Rank int `json:"rank"` IsActive bool `json:"is_active"` Revision int `json:"revision"` Bands []Band `json:"bands"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"`}
// BandOn is the band in force on day, or nil.func (g Grade) BandOn(day time.Time) *Band { for i := range g.Bands { b := &g.Bands[i] if !b.EffectiveFrom.After(day) && (b.EffectiveTo == nil || b.EffectiveTo.After(day)) { return b } } return nil}
type GradeInput struct { Code string Names Names Rank int}
type GradeUpdate struct { Names Names Rank int IsActive bool}
type BandInput struct { GradeID uuid.UUID EffectiveFrom time.Time MinSalary money.Amount MaxSalary money.Amount}
var gradeCode = regexp.MustCompile(`^[A-Z0-9][A-Z0-9_-]{0,15}$`)
// checkNames trims n and applies Ruling H2-2; an empty value is left out.func checkNames(n Names) (Names, error) { out := Names{} for k, v := range n { if !slices.Contains(nameLocales, k) { return nil, &FieldError{Field: "names", Rule: "locale", Detail: "names are given in en, ms and zh"} } v = strings.TrimSpace(v) switch { case v == "": continue case utf8.RuneCountInString(v) > 200: return nil, &FieldError{Field: "names." + k, Rule: "max_length", Detail: "a name is at most 200 characters"} case k != "zh" && recordtext.Check("names."+k, v) != nil: return nil, &FieldError{Field: "names." + k, Rule: recordtext.Rule, Detail: recordtext.Detail} } out[k] = v } if out["en"] == "" || out["ms"] == "" { return nil, &FieldError{Field: "names", Rule: "required", Detail: "a grade is named in English and Bahasa Malaysia"} } return out, nil}
func checkRank(r int) error { if r < 1 || r > 999 { return &FieldError{Field: "rank", Rule: "range", Detail: "a rank is 1 to 999"} } return nil}
// writeGradeNames makes the grade's translation rows exactly n.func writeGradeNames(ctx context.Context, tx pgx.Tx, org, id uuid.UUID, n Names) error { if _, err := tx.Exec(ctx, `DELETE FROM translation WHERE org_id = $1 AND entity_kind = $2 AND entity_id = $3 AND field = 'name'`, org, EntityGrade, id); err != nil { return fmt.Errorf("hr: grade names: %w", err) } for _, loc := range nameLocales { v := n[loc] if v == "" { continue } if _, err := tx.Exec(ctx, `INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value) VALUES ($1, $2, $3, 'name', $4, $5)`, org, EntityGrade, id, loc, v); err != nil { return fmt.Errorf("hr: grade names: %w", err) } } return nil}
// loadGrades reads the grades (just id's when id is set), their names and// their bands, by rank then code.func loadGrades(ctx context.Context, tx pgx.Tx, id *uuid.UUID, activeOnly bool) ([]Grade, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, `SELECT id, code, rank, is_active, revision, created_at, updated_at FROM job_grade WHERE org_id = $1 AND ($2::uuid IS NULL OR id = $2) AND (NOT $3::boolean OR is_active) ORDER BY rank, code`, org, id, activeOnly) if err != nil { return nil, fmt.Errorf("hr: grades: %w", err) } out := []Grade{} at := map[uuid.UUID]int{} var ids []uuid.UUID for rows.Next() { g := Grade{Names: Names{}, Bands: []Band{}} if err := rows.Scan(&g.ID, &g.Code, &g.Rank, &g.IsActive, &g.Revision, &g.CreatedAt, &g.UpdatedAt); err != nil { rows.Close() return nil, fmt.Errorf("hr: grades: %w", err) } g.CreatedAt, g.UpdatedAt = g.CreatedAt.UTC(), g.UpdatedAt.UTC() at[g.ID] = len(out) out = append(out, g) ids = append(ids, g.ID) } rows.Close() if err := rows.Err(); err != nil { return nil, fmt.Errorf("hr: grades: %w", err) } if len(ids) == 0 { return out, nil }
rows, err = tx.Query(ctx, `SELECT entity_id, locale, value FROM translation WHERE org_id = $1 AND entity_kind = $2 AND field = 'name' AND entity_id = ANY($3)`, org, EntityGrade, ids) if err != nil { return nil, fmt.Errorf("hr: grade names: %w", err) } for rows.Next() { var gid uuid.UUID var loc, v string if err := rows.Scan(&gid, &loc, &v); err != nil { rows.Close() return nil, fmt.Errorf("hr: grade names: %w", err) } out[at[gid]].Names[loc] = v } rows.Close() if err := rows.Err(); err != nil { return nil, fmt.Errorf("hr: grade names: %w", err) }
rows, err = tx.Query(ctx, `SELECT id, grade_id, effective_from, effective_to, min_salary::text, max_salary::text FROM grade_band WHERE org_id = $1 AND grade_id = ANY($2) ORDER BY grade_id, effective_from`, org, ids) if err != nil { return nil, fmt.Errorf("hr: grade bands: %w", err) } defer rows.Close() for rows.Next() { var b Band var gid uuid.UUID if err := rows.Scan(&b.ID, &gid, &b.EffectiveFrom, &b.EffectiveTo, &b.MinSalary, &b.MaxSalary); err != nil { return nil, fmt.Errorf("hr: grade bands: %w", err) } b.EffectiveFrom, b.EffectiveTo = b.EffectiveFrom.UTC(), utcDate(b.EffectiveTo) out[at[gid]].Bands = append(out[at[gid]].Bands, b) } return out, rows.Err()}
func GetGrade(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Grade, error) { gs, err := loadGrades(ctx, tx, &id, false) if err != nil { return Grade{}, err } if len(gs) == 0 { return Grade{}, ErrGradeNotFound } return gs[0], nil}
// ListGrades is every grade (activeOnly: the ones not retired), by rank.func ListGrades(ctx context.Context, tx pgx.Tx, activeOnly bool) ([]Grade, error) { return loadGrades(ctx, tx, nil, activeOnly)}
func CreateGrade(ctx context.Context, tx pgx.Tx, c clock.Clock, in GradeInput) (Grade, error) { org, err := orgOf(ctx) if err != nil { return Grade{}, err } code := strings.ToUpper(strings.TrimSpace(in.Code)) if !gradeCode.MatchString(code) { return Grade{}, &FieldError{Field: "code", Rule: "shape", Detail: "a grade code is 1 to 16 letters, digits, - or _"} } if err := checkRank(in.Rank); err != nil { return Grade{}, err } n, err := checkNames(in.Names) if err != nil { return Grade{}, err } var id uuid.UUID err = tx.QueryRow(ctx, `INSERT INTO job_grade (org_id, code, rank, created_at, updated_at) VALUES ($1, $2, $3, $4, $4) RETURNING id`, org, code, in.Rank, c.Now()).Scan(&id) if isConstraint(err, "job_grade_code_once") { return Grade{}, ErrDuplicateGradeCode } if err != nil { return Grade{}, fmt.Errorf("hr: create grade: %w", err) } if err := writeGradeNames(ctx, tx, org, id, n); err != nil { return Grade{}, err } g, err := GetGrade(ctx, tx, id) if err != nil { return Grade{}, err } if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityGrade, EntityID: id, Action: "create", After: g}); err != nil { return Grade{}, err } return g, nil}
// UpdateGrade renames, re-ranks, retires or restores a grade at the// revision the caller read. The code never changes (Ruling H2-2).func UpdateGrade(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in GradeUpdate) (Grade, error) { org, err := orgOf(ctx) if err != nil { return Grade{}, err } if err := checkRank(in.Rank); err != nil { return Grade{}, err } n, err := checkNames(in.Names) if err != nil { return Grade{}, err } var rev int err = tx.QueryRow(ctx, `SELECT revision FROM job_grade WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, id).Scan(&rev) if errors.Is(err, pgx.ErrNoRows) { return Grade{}, ErrGradeNotFound } if err != nil { return Grade{}, fmt.Errorf("hr: update grade: %w", err) } if rev != expectedRevision { return Grade{}, &RevisionMismatchError{Current: rev} } before, err := GetGrade(ctx, tx, id) if err != nil { return Grade{}, err } if _, err := tx.Exec(ctx, `UPDATE job_grade SET rank = $3, is_active = $4, revision = revision + 1, updated_at = $5 WHERE org_id = $1 AND id = $2`, org, id, in.Rank, in.IsActive, c.Now()); err != nil { return Grade{}, fmt.Errorf("hr: update grade: %w", err) } if err := writeGradeNames(ctx, tx, org, id, n); err != nil { return Grade{}, err } after, err := GetGrade(ctx, tx, id) if err != nil { return Grade{}, err } if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityGrade, EntityID: id, Action: "update", Before: before, After: after}); err != nil { return Grade{}, err } return after, nil}
// AddBand adds a band from EffectiveFrom, open-ended, ending the grade's// open band on that day (Ruling H2-3). Any other overlap is ErrBandOverlap.func AddBand(ctx context.Context, tx pgx.Tx, c clock.Clock, in BandInput) (Band, error) { org, err := orgOf(ctx) if err != nil { return Band{}, err } switch { case in.MinSalary.IsNegative(): return Band{}, &FieldError{Field: "min_salary", Rule: "nonnegative", Detail: "a minimum salary is 0 or more"} case in.MinSalary.Cmp(in.MaxSalary) > 0: return Band{}, &FieldError{Field: "max_salary", Rule: "min_le_max", Detail: "the maximum is at least the minimum"} } var active bool err = tx.QueryRow(ctx, `SELECT is_active FROM job_grade WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, in.GradeID).Scan(&active) if errors.Is(err, pgx.ErrNoRows) { return Band{}, ErrGradeNotFound } if err != nil { return Band{}, fmt.Errorf("hr: add band: %w", err) } if !active { return Band{}, ErrGradeRetired } if _, err := tx.Exec(ctx, `UPDATE grade_band SET effective_to = $3 WHERE org_id = $1 AND grade_id = $2 AND effective_to IS NULL AND effective_from < $3`, org, in.GradeID, in.EffectiveFrom); err != nil { return Band{}, fmt.Errorf("hr: add band: %w", err) } var b Band err = tx.QueryRow(ctx, `INSERT INTO grade_band (org_id, grade_id, effective_from, min_salary, max_salary, created_at) VALUES ($1, $2, $3, $4::numeric, $5::numeric, $6) RETURNING id, effective_from, effective_to, min_salary::text, max_salary::text`, org, in.GradeID, in.EffectiveFrom, in.MinSalary.String(), in.MaxSalary.String(), c.Now()). Scan(&b.ID, &b.EffectiveFrom, &b.EffectiveTo, &b.MinSalary, &b.MaxSalary) if isConstraint(err, "grade_band_no_overlap") { return Band{}, ErrBandOverlap } if err != nil { return Band{}, fmt.Errorf("hr: add band: %w", err) } b.EffectiveFrom, b.EffectiveTo = b.EffectiveFrom.UTC(), utcDate(b.EffectiveTo) if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityBand, EntityID: b.ID, Action: "add", After: map[string]any{"grade_id": in.GradeID, "band": b}}); err != nil { return Band{}, err } return b, nil}- Step 5: Run the tests
Run: go test ./internal/platform/hr/ ./internal/kernel/layering/ -count=1
Expected: PASS.
- Step 6: Watch it fail once
In AddBand, delete the UPDATE grade_band SET effective_to … statement (the whole if _, err := tx.Exec(…); err != nil { … } block) and run go test ./internal/platform/hr/ -run TestAddingABandEndsTheOpenOne -count=1. Expected: FAIL — the second add returns hr.ErrBandOverlap instead of a band. Restore; PASS.
- Step 7: Commit
git add internal/platform/hrgit commit -m "Phase 5 H2: grades named as translation rows, retired not deleted; effective-dated bands
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 4: Headcount in the database (00822)
Section titled “Task 4: Headcount in the database (00822)”Files:
- Create:
db/migrations/00822_headcount.sql,internal/platform/hr/migration00822_test.go - Modify:
internal/platform/orgstruct/assignment_test.go(fixturenewPosition),internal/platform/workflow/workflowtest/fixture.go(Seat),internal/kernel/db/retention_test.go - Regenerate:
internal/gen/sqlc/models.go(make generate)
Interfaces:
-
Consumes: Task 2’s
job_grade;00018/00023’sorg_positionandmember_position. -
Produces: columns
org_position.grade_id,org_position.approved_headcount,member_position.headcount_override_id; tableheadcount_override; functionposition_peak_primary(uuid, uuid, date, date, uuid) RETURNS int; constraintsorg_position_approved_headcount_nonnegative,org_position_grade_fk,headcount_override_not_requester,member_position_headcount_override_once; trigger constraintmember_position_within_headcount. -
Step 1: Write the failing migration test
Create internal/platform/hr/migration00822_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")
// 00822 (Rulings H2-5–H2-7): every existing seat gets an approved headcount// no lower than its busiest day of primary holders; from then on one more// is refused, and ending a seat never is; the Down removes the columns.func TestMigration00822BackfillsHeadcountFromWhatSeatsHold(t *testing.T) { h := testdb.StartAt(t, 821) ctx := context.Background() exec := func(q string, args ...any) { t.Helper() _, err := h.Admin.Exec(ctx, q, args...) require.NoError(t, err) } org, unit := uuid.New(), uuid.New() exec(`INSERT INTO organization (id, org_id, name) VALUES ($1,$1,'Alpha')`, org) exec(`INSERT INTO org_unit (id, org_id, code, name, path, depth) VALUES ($1,$2,'HQ','Ibu pejabat',$3,0)`, unit, org, "/"+unit.String()+"/") seat := func(code string) uuid.UUID { id := uuid.New() exec(`INSERT INTO org_position (id, org_id, org_unit_id, code, title, path, depth) VALUES ($1,$2,$3,$4,$4,$5,0)`, id, org, unit, code, "/"+id.String()+"/") return id } member := func(name string) uuid.UUID { var party, m 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) VALUES ($1,$2,$3) RETURNING id`, org, uuid.New(), party).Scan(&m)) return m } sit := func(m, s uuid.UUID, from string, to *string) error { _, err := h.Admin.Exec(ctx, `INSERT INTO member_position (org_id, member_id, position_id, is_primary, valid_from, valid_to) VALUES ($1,$2,$3,true,$4::date,$5::date)`, org, m, s, from, to) return err } until := "2026-01-01" shared, empty := seat("SHARED"), seat("EMPTY") a, b, c := member("Aminah"), member("Badrul"), member("Chong Wei") require.NoError(t, sit(c, shared, "2025-01-01", &until)) // alone, before them require.NoError(t, sit(a, shared, "2026-01-01", nil)) // a job share: two at once require.NoError(t, sit(b, shared, "2026-01-01", nil))
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), 822, goose.WithAllowMissing()))
approved := func(id uuid.UUID) int { var n int require.NoError(t, h.Admin.QueryRow(ctx, `SELECT approved_headcount FROM org_position WHERE id = $1`, id).Scan(&n)) return n } require.Equal(t, 2, approved(shared), "PRESENCE: the job share is kept, and back-to-back holders are not a crowd") require.Equal(t, 1, approved(empty), "an empty seat takes the default")
// ABSENCE: a third holder at once is refused now. require.ErrorContains(t, sit(member("Devi"), shared, "2026-09-01", nil), "member_position_within_headcount")
// Ending a seat is never refused, even below what it holds. exec(`UPDATE org_position SET approved_headcount = 0 WHERE id = $1`, shared) exec(`UPDATE member_position SET valid_to = DATE '2026-10-01' WHERE member_id = $1`, a)
require.NoError(t, goose.Down(conn, testdb.MigrationsDir(t))) var cols int require.NoError(t, h.Admin.QueryRow(ctx, `SELECT count(*) FROM information_schema.columns WHERE table_name = 'org_position' AND column_name IN ('approved_headcount', 'grade_id')`).Scan(&cols)) require.Zero(t, cols, "the Down removes the columns")}- Step 2: Run it to verify it fails
Run: go test ./internal/platform/hr/ -run TestMigration00822 -count=1
Expected: FAIL — goose reports no migration 822 (UpTo stops at 821; column "approved_headcount" does not exist).
- Step 3: Write the migration
Create db/migrations/00822_headcount.sql:
-- +goose Up-- Phase 5 H2 (docs/superpowers/specs/2026-09-30-hr-layer-design.md §H2-- "The grade belongs to the seat", "Headcount"; HD-4; Rulings H2-4–H2-10).
-- The grade belongs to the seat: an employee's grade is their primary-- seat's (Ruling H2-4). approved_headcount is how many may hold the seat as-- their PRIMARY at once (Ruling H2-5).ALTER TABLE org_position ADD COLUMN grade_id UUID, ADD COLUMN approved_headcount INT NOT NULL DEFAULT 1 CONSTRAINT org_position_approved_headcount_nonnegative CHECK (approved_headcount >= 0), ADD CONSTRAINT org_position_grade_fk FOREIGN KEY (org_id, grade_id) REFERENCES job_grade (org_id, id);
-- Ruling H2-6: the most live primary holders the seat has at once on any-- day of [p_from, p_to) (p_to NULL: open-ended), leaving out the row-- p_exclude. The busiest day is always p_from or a day some holder starts,-- so only those days are counted. "In force on d" is orgstruct's-- currentAsOf, written against d.-- +goose StatementBeginCREATE FUNCTION position_peak_primary(p_org uuid, p_position uuid, p_from date, p_to date, p_exclude uuid) RETURNS int LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$ WITH held AS ( SELECT m.valid_from, m.valid_to FROM public.member_position m WHERE m.org_id = p_org AND m.position_id = p_position AND m.is_primary AND m.withdrawn_at IS NULL AND (p_exclude IS NULL OR m.id <> p_exclude) ), days AS ( SELECT p_from AS d UNION SELECT h.valid_from FROM held h WHERE h.valid_from > p_from AND (p_to IS NULL OR h.valid_from < p_to) ) SELECT coalesce(max(x.n), 0)::int FROM ( SELECT (SELECT count(*) FROM held h WHERE h.valid_from <= days.d AND (h.valid_to IS NULL OR h.valid_to > days.d)) AS n FROM days) x$$;-- +goose StatementEnd
-- Ruling H2-7: never below what the seat already held at its busiest, so-- no existing arrangement becomes a violation. Runs as the migration role.UPDATE org_position p SET approved_headcount = greatest(1, position_peak_primary(p.org_id, p.id, DATE '0001-01-01', NULL, NULL));
-- HD-4 / Ruling H2-9: an Owner's leave for one member to hold one seat as-- their primary from one day although the seat is full. requested_by is the-- member who asked; approved_headcount and filled are what it was granted-- over. Used once, by the assignment that names it.CREATE TABLE headcount_override ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), position_id UUID NOT NULL, member_id UUID NOT NULL, valid_from DATE NOT NULL, requested_by UUID NOT NULL, reason TEXT NOT NULL CONSTRAINT headcount_override_reason_shape CHECK (reason = btrim(reason) AND reason <> '' AND length(reason) <= 500 AND reason !~ '[-ヿ㐀-䶿一-鿿가-]'), approved_headcount INT NOT NULL, filled INT NOT NULL, granted_by UUID NOT NULL, sole_owner BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT headcount_override_tenant_id UNIQUE (org_id, id), -- HD-4: an Owner who is not the requester (Ruling H2-10's exception aside). CONSTRAINT headcount_override_not_requester CHECK (granted_by <> requested_by OR sole_owner), CONSTRAINT headcount_override_position_fk FOREIGN KEY (org_id, position_id) REFERENCES org_position (org_id, id), CONSTRAINT headcount_override_member_fk FOREIGN KEY (org_id, member_id) REFERENCES organization_member (org_id, id), CONSTRAINT headcount_override_requested_by_fk FOREIGN KEY (org_id, requested_by) REFERENCES organization_member (org_id, id), CONSTRAINT headcount_override_granted_by_fk FOREIGN KEY (org_id, granted_by) REFERENCES organization_member (org_id, id));ALTER TABLE headcount_override ENABLE ROW LEVEL SECURITY;ALTER TABLE headcount_override FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON headcount_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 headcount_override FROM erp_app;
ALTER TABLE member_position ADD COLUMN headcount_override_id UUID, ADD CONSTRAINT member_position_headcount_override_once UNIQUE (headcount_override_id), ADD CONSTRAINT member_position_headcount_override_fk FOREIGN KEY (org_id, headcount_override_id) REFERENCES headcount_override (org_id, id);
-- HD-4 (Rulings H2-6, H2-9, H2-10): a live primary assignment that would-- give the seat more holders at once than its approved headcount is-- refused, unless an unused override for exactly this member, seat and-- first day exists — which it then consumes. The seat row is locked FOR NO-- KEY UPDATE first, so two assignments to one seat count one after the-- other (Ruling H2-8).-- +goose StatementBeginCREATE FUNCTION member_position_headcount_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE approved int; peak int; o public.headcount_override%ROWTYPE;BEGIN SELECT p.approved_headcount INTO approved FROM public.org_position p WHERE p.org_id = NEW.org_id AND p.id = NEW.position_id FOR NO KEY UPDATE; peak := public.position_peak_primary(NEW.org_id, NEW.position_id, NEW.valid_from, NEW.valid_to, NEW.id) + 1; IF peak <= approved THEN RETURN NEW; END IF; IF NEW.headcount_override_id IS NOT NULL THEN -- an update of an assignment already overridden SELECT * INTO o FROM public.headcount_override x WHERE x.org_id = NEW.org_id AND x.id = NEW.headcount_override_id; IF o.position_id = NEW.position_id AND o.member_id = NEW.member_id AND o.valid_from = NEW.valid_from THEN RETURN NEW; END IF; END IF; SELECT * INTO o FROM public.headcount_override x WHERE x.org_id = NEW.org_id AND x.position_id = NEW.position_id AND x.member_id = NEW.member_id AND x.valid_from = NEW.valid_from AND NOT EXISTS (SELECT 1 FROM public.member_position mp WHERE mp.org_id = x.org_id AND mp.headcount_override_id = x.id) AND (NOT x.sole_owner OR (SELECT count(*) FROM public.organization_member m WHERE m.org_id = x.org_id AND m.active) = 1) ORDER BY x.created_at, x.id LIMIT 1; IF NOT FOUND THEN RAISE EXCEPTION 'seat % would have % primary holders at once; its approved headcount is %', NEW.position_id, peak, approved USING ERRCODE = '23514', CONSTRAINT = 'member_position_within_headcount'; END IF; NEW.headcount_override_id := o.id; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER member_position_headcount_insert BEFORE INSERT ON member_position FOR EACH ROW WHEN (NEW.is_primary AND NEW.withdrawn_at IS NULL) EXECUTE FUNCTION member_position_headcount_guard();-- Only an update that grows occupancy is checked: a close or a withdrawal-- never is (Ruling H2-6).CREATE TRIGGER member_position_headcount_update BEFORE UPDATE OF is_primary, position_id, valid_from, valid_to ON member_position FOR EACH ROW WHEN (NEW.is_primary AND NEW.withdrawn_at IS NULL AND ( NOT OLD.is_primary OR NEW.position_id <> OLD.position_id OR NEW.valid_from < OLD.valid_from OR (NEW.valid_to IS NULL AND OLD.valid_to IS NOT NULL) OR NEW.valid_to > OLD.valid_to)) EXECUTE FUNCTION member_position_headcount_guard();
-- +goose DownDROP TRIGGER member_position_headcount_update ON member_position;DROP TRIGGER member_position_headcount_insert ON member_position;DROP FUNCTION member_position_headcount_guard();ALTER TABLE member_position DROP COLUMN headcount_override_id;DROP TABLE headcount_override;DROP FUNCTION position_peak_primary(uuid, uuid, date, date, uuid);ALTER TABLE org_position DROP COLUMN approved_headcount, DROP COLUMN grade_id;- Step 4: Declare the retention of
headcount_override
In internal/kernel/db/retention_test.go, add to appendOnly after H1’s "lifecycle_override",:
// Phase 5 H2 (00822): the HD-4 overrides are the record of who let a // seat exceed its approved headcount, for whom, and why. "headcount_override",- Step 5: Give the job-sharing test fixtures room
Two fixtures seat several primary holders in one seat on purpose (TestHoldersOfReturnsEveryCurrentHolder, the workflow engine’s position grants). Headcount is not what they test, so their seats take a headcount of 9; the trigger is not weakened.
In internal/platform/orgstruct/assignment_test.go, in newPosition, replace
INSERT INTO org_position (id, org_id, org_unit_id, code, title, path, depth) VALUES ($1, $2, $3, $4, $5, $6, 0)`,with
INSERT INTO org_position (id, org_id, org_unit_id, code, title, path, depth, approved_headcount) VALUES ($1, $2, $3, $4, $5, $6, 0, 9)`, // Phase 5 H2: room for job shares; headcount is tested in platform/hrIn internal/platform/workflow/workflowtest/fixture.go, in Seat, replace
INSERT INTO org_position (id, org_id, org_unit_id, code, title, reports_to_position_id, path, depth) VALUES ($1,$2,$3,$4,$4,$5,$6,$7)`,with
INSERT INTO org_position (id, org_id, org_unit_id, code, title, reports_to_position_id, path, depth, approved_headcount) VALUES ($1,$2,$3,$4,$4,$5,$6,$7,9)`, // Phase 5 H2: room for job shares; headcount is tested in platform/hr- Step 6: Run the tests, including every package that seats people
Run: go test ./internal/platform/hr/ ./internal/kernel/db/ ./internal/platform/orgstruct/ ./internal/platform/workflow/... ./internal/modules/workflow/ ./internal/modules/orgstructure/ ./internal/platform/provisioning/ ./internal/platform/expenses/... ./internal/platform/advances/... ./internal/modules/identity/ ./internal/modules/expenses/ ./internal/app/ -count=1
Expected: PASS. A failure whose message names member_position_within_headcount is another fixture that job-shares a seat: raise that seat’s approved_headcount in the fixture the way Step 5 does (never change the trigger), and run again.
- Step 7: Watch it fail once
In member_position_headcount_guard(), change IF peak <= approved THEN to IF true THEN and run go test ./internal/platform/hr/ -run TestMigration00822 -count=1. Expected: FAIL — An error is expected but got nil at the third-holder assertion. Restore.
Then in position_peak_primary, change h.valid_to > days.d to h.valid_to >= days.d and run the same test. Expected: FAIL — expected: 2 actual: 3 at the PRESENCE assertion (Chong Wei’s last day now overlaps the job share’s first). Restore; PASS.
- Step 8: Regenerate sqlc’s models
Run: make generate
Expected: internal/gen/sqlc/models.go gains HeadcountOverride and the new OrgPosition and MemberPosition fields; nothing else changes.
- Step 9: Commit
git add db/migrations/00822_headcount.sql internal/platform/hr/migration00822_test.go \ internal/platform/orgstruct/assignment_test.go internal/platform/workflow/workflowtest/fixture.go \ internal/kernel/db/retention_test.go internal/gen/sqlcgit commit -m "Phase 5 H2: seat grade and approved headcount; over-headcount refused by trigger (HD-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 5: The headcount rule in Go — seats, the Owner’s override, a seat’s grade
Section titled “Task 5: The headcount rule in Go — seats, the Owner’s override, a seat’s grade”Files:
- Modify:
internal/platform/orgstruct/position.go,internal/platform/orgstruct/assignment.go - Create:
internal/platform/orgstruct/headcount.go— Test:internal/platform/orgstruct/headcount_test.go - Create:
internal/platform/hr/headcount.go; Modify:internal/platform/hr/grade.go(appendSetPositionGrade),internal/platform/hr/planning.go,internal/platform/hr/events.go(Transfer’s seat lock),internal/platform/hr/doc.go,internal/platform/hr/hrtest/env.go - Test:
internal/platform/hr/headcount_test.go - Modify:
internal/modules/orgstructure/handlers.go(mapErr)
Interfaces:
-
Consumes: Task 4’s schema; Task 3’s
GetGrade,ErrGradeRetired,isConstraint; H1’scheckReason,orgOf,ErrNoSuchMember;workflow.SoleOwnerException;orgstruct.ErrPositionInactive. -
Produces:
orgstruct.Position.GradeID,.ApprovedHeadcount;orgstruct.Assignment.HeadcountOverrideID;orgstruct.ErrOverHeadcount,orgstruct.ErrNegativeHeadcount;orgstruct.LockSeat(ctx context.Context, tx pgx.Tx, id uuid.UUID) (orgstruct.Position, error);orgstruct.SetHeadcount(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, approved int) (orgstruct.Position, error);orgstruct.SetGrade(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, gradeID *uuid.UUID) (orgstruct.Position, error);orgstruct.SeatHeadcount;orgstruct.Headcount(ctx context.Context, tx pgx.Tx, on time.Time, only *uuid.UUID) ([]orgstruct.SeatHeadcount, error)(onlynarrows to one seat);orgstruct.PeakPrimary(ctx context.Context, tx pgx.Tx, positionID uuid.UUID, from time.Time, to *time.Time) (int, error);hr.ErrHeadcountOverrideByRequester;hr.HeadcountOverride,hr.HeadcountOverrideInput,hr.GrantHeadcountOverride(ctx context.Context, tx pgx.Tx, c clock.Clock, in hr.HeadcountOverrideInput) (hr.HeadcountOverride, error);hr.SetPositionGrade(ctx context.Context, tx pgx.Tx, c clock.Clock, positionID uuid.UUID, gradeID *uuid.UUID) (orgstruct.Position, error);(*hrtest.Env).Newcomer(t *testing.T, name string) uuid.UUID,(*hrtest.Env).Seat(t *testing.T, code string) uuid.UUID. -
Step 1: Write the fixtures
Append to internal/platform/hr/hrtest/env.go (add "github.com/mcom/erp/internal/platform/orgstruct" to its imports):
// Newcomer is an active member with an employee party and no seat: someone// to put in one (Phase 5 H2).func (e *Env) Newcomer(t *testing.T, name string) uuid.UUID { t.Helper() ctx := context.Background() var party, member uuid.UUID require.NoError(t, e.H.Admin.QueryRow(ctx, `INSERT INTO party (org_id, kind, legal_name) VALUES ($1, 'employee', $2) RETURNING id`, e.Org, name).Scan(&party)) require.NoError(t, e.H.Admin.QueryRow(ctx, `INSERT INTO organization_member (org_id, user_id, party_id) VALUES ($1, $2, $3) RETURNING id`, e.Org, uuid.New(), party).Scan(&member)) return member}
// Seat is an empty seat reporting to the OWNER seat, in its unit, with the// default approved headcount of 1 (Phase 5 H2).func (e *Env) Seat(t *testing.T, code string) uuid.UUID { t.Helper() 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 id = $2`, e.Org, e.OwnerSeat).Scan(&unit)) var id uuid.UUID e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { p, err := orgstruct.CreatePosition(ctx, tx, e.Clock, orgstruct.PositionInput{OrgUnitID: unit, Code: code, Title: code, ReportsToPositionID: &e.OwnerSeat}) id = p.ID return err }) return id}- Step 2: Write the failing tests
Create internal/platform/orgstruct/headcount_test.go:
package orgstruct_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/orgstruct")
// Phase 5 H2 (Rulings H2-5, H2-6): only live primary holders fill a seat;// one more than its approved headcount at once is refused and named by// Assign; back-to-back holders are not a crowd; a close is never refused.func TestASeatRefusesOnePrimaryHolderMoreThanItsHeadcount(t *testing.T) { f := setup(t) seat := f.newPosition(t, "P-HC", "Analyst") aisyah, chandra, devi := f.newMember(t, "Aisyah"), f.newMember(t, "Chandra"), f.newMember(t, "Devi") require.NoError(t, f.d.InTx(f.ctx, func(ctx context.Context, tx pgx.Tx) error { _, err := orgstruct.SetHeadcount(ctx, tx, testClock, seat, -1) require.ErrorIs(t, err, orgstruct.ErrNegativeHeadcount) p, err := orgstruct.SetHeadcount(ctx, tx, testClock, seat, 1) require.Equal(t, 1, p.ApprovedHeadcount) return err }))
first := f.mustAssign(t, orgstruct.AssignmentInput{MemberID: aisyah, PositionID: seat, IsPrimary: true, ValidFrom: jan1, ValidTo: ptr(jul1)}) // PRESENCE: when the first holder's period ends, the next one fits. f.mustAssign(t, orgstruct.AssignmentInput{MemberID: chandra, PositionID: seat, IsPrimary: true, ValidFrom: jul1}) // ABSENCE: alongside either of them, a third primary is refused... _, err := f.assign(t, orgstruct.AssignmentInput{MemberID: devi, PositionID: seat, IsPrimary: true, ValidFrom: apr1}) require.ErrorIs(t, err, orgstruct.ErrOverHeadcount) // ...but an acting (non-primary) holder is not. f.mustAssign(t, orgstruct.AssignmentInput{MemberID: devi, PositionID: seat, ValidFrom: apr1})
require.NoError(t, f.d.InTx(f.ctx, func(ctx context.Context, tx pgx.Tx) error { counts, err := orgstruct.Headcount(ctx, tx, oct1, &seat) require.NoError(t, err) require.Len(t, counts, 1) require.Equal(t, 1, counts[0].Approved) require.Equal(t, 1, counts[0].Filled, "the acting holder does not count") peak, err := orgstruct.PeakPrimary(ctx, tx, seat, jan1, nil) require.NoError(t, err) require.Equal(t, 1, peak, "back to back is not a crowd") // Below what it holds, a close still goes through. if _, err := orgstruct.SetHeadcount(ctx, tx, testClock, seat, 0); err != nil { return err } return orgstruct.CloseAssignment(ctx, tx, testClock, first.ID, apr1) }))}Create internal/platform/hr/headcount_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")
func assignTo(e *hrtest.Env, in orgstruct.AssignmentInput) (orgstruct.Assignment, error) { var a orgstruct.Assignment err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { var err error a, err = orgstruct.Assign(ctx, tx, e.Clock, in) return err }) return a, err}
func grantHeadcount(e *hrtest.Env, in hr.HeadcountOverrideInput) (hr.HeadcountOverride, error) { var o hr.HeadcountOverride err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { var err error o, err = hr.GrantHeadcountOverride(ctx, tx, e.Clock, in) return err }) return o, err}
// Spec H2 drift bullet 1 (HD-4): an assignment beyond approved headcount is// refused and accepted with the override of an Owner who is not the// requester; an acting assignment does not count.func TestAnAssignmentBeyondHeadcountIsRefusedAndAcceptedWithAnOwnerOverride(t *testing.T) { e := hrtest.New(t) seat := e.Seat(t, "ANALYST") aminah, badrul := e.Newcomer(t, "Aminah"), e.Newcomer(t, "Badrul") today := hrtest.Day(2026, 9, 21) // expensestest's T0 in Kuala Lumpur primary := func(m uuid.UUID) orgstruct.AssignmentInput { return orgstruct.AssignmentInput{MemberID: m, PositionID: seat, IsPrimary: true, ValidFrom: today} }
// PRESENCE: a seat with room takes a primary holder. first, err := assignTo(e, primary(aminah)) require.NoError(t, err) require.Nil(t, first.HeadcountOverrideID) // ABSENCE: one more is refused. _, err = assignTo(e, primary(badrul)) require.ErrorIs(t, err, orgstruct.ErrOverHeadcount) // An acting holder does not count: the Staff member acts in the full seat. _, err = assignTo(e, orgstruct.AssignmentInput{MemberID: e.Staff, PositionID: seat, ValidFrom: today}) require.NoError(t, err)
in := hr.HeadcountOverrideInput{PositionID: seat, MemberID: badrul, ValidFrom: today, RequestedBy: e.HR, Reason: "Project backlog until the second seat is approved", Actor: e.Owner} own := in own.RequestedBy = e.Owner _, err = grantHeadcount(e, own) require.ErrorIs(t, err, hr.ErrHeadcountOverrideByRequester, "an Owner never allows their own request")
ov, err := grantHeadcount(e, in) require.NoError(t, err) require.False(t, ov.SoleOwner) require.Equal(t, 1, ov.Approved) require.Equal(t, 1, ov.Filled) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM audit_log WHERE entity_kind = 'headcount_override' AND entity_id = $1 AND action = 'grant'`, ov.ID))
second, err := assignTo(e, primary(badrul)) require.NoError(t, err, "accepted with the override") require.Equal(t, ov.ID, *second.HeadcountOverrideID)
// Used once: withdrawn and assigned again, it is refused again. e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { return orgstruct.WithdrawAssignment(ctx, tx, e.Clock, second.ID) }) _, err = assignTo(e, primary(badrul)) require.ErrorIs(t, err, orgstruct.ErrOverHeadcount)
_, err = grantHeadcount(e, hr.HeadcountOverrideInput{PositionID: e.Seat(t, "SPARE"), MemberID: badrul, ValidFrom: today, RequestedBy: e.HR, Reason: "Not needed", Actor: e.Owner}) require.ErrorIs(t, err, hr.ErrWithinHeadcount)}
// Ruling H2-10: in a one-member organization the Owner allows their own// request, flagged sole_owner; a second active member at use makes it an// ordinary refusal again.func TestTheSoleOwnerMayAllowTheirOwnSeatOverHeadcount(t *testing.T) { e := hrtest.New(t) ctx := context.Background() seat := e.Seat(t, "LEAD") holder := e.Newcomer(t, "Aminah") _, err := assignTo(e, orgstruct.AssignmentInput{MemberID: holder, PositionID: seat, IsPrimary: true, ValidFrom: hrtest.Day(2026, 9, 21)}) require.NoError(t, err) from := hrtest.Day(2026, 10, 1) in := hr.HeadcountOverrideInput{PositionID: seat, MemberID: e.Owner, ValidFrom: from, RequestedBy: e.Owner, Reason: "I lead the team myself from October", Actor: e.Owner}
// PRESENCE of the rule: with other active members it is refused. _, err = grantHeadcount(e, in) require.ErrorIs(t, err, hr.ErrHeadcountOverrideByRequester)
for _, m := range []uuid.UUID{e.Staff, e.HR, holder} { e.DeactivateByAdmin(t, m) } ov, err := grantHeadcount(e, in) require.NoError(t, err) require.True(t, ov.SoleOwner)
// The Owner's own seat ends where the new one starts. var ownerSeat uuid.UUID require.NoError(t, e.H.Admin.QueryRow(ctx, `SELECT id FROM member_position WHERE org_id = $1 AND member_id = $2 AND position_id = $3`, e.Org, e.Owner, e.OwnerSeat).Scan(&ownerSeat)) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { return orgstruct.CloseAssignment(ctx, tx, e.Clock, ownerSeat, from) }) lead := orgstruct.AssignmentInput{MemberID: e.Owner, PositionID: seat, IsPrimary: true, ValidFrom: from}
_, err = e.H.Admin.Exec(ctx, `UPDATE organization_member SET active = true WHERE org_id = $1 AND id = $2`, e.Org, e.Staff) require.NoError(t, err) _, err = assignTo(e, lead) require.ErrorIs(t, err, orgstruct.ErrOverHeadcount, "a second active member: the exception no longer holds")
e.DeactivateByAdmin(t, e.Staff) a, err := assignTo(e, lead) require.NoError(t, err) require.Equal(t, ov.ID, *a.HeadcountOverrideID)}
// Ruling H2-4: a seat takes an active grade, and may be ungraded again.func TestASeatTakesAnActiveGrade(t *testing.T) { e := hrtest.New(t) seat := e.Seat(t, "ANALYST") g, err := createGrade(e, hr.GradeInput{Code: "E1", Names: names(), Rank: 10}) require.NoError(t, err) set := func(grade *uuid.UUID) (orgstruct.Position, error) { var p orgstruct.Position err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { var err error p, err = hr.SetPositionGrade(ctx, tx, e.Clock, seat, grade) return err }) return p, err } p, err := set(&g.ID) require.NoError(t, err) require.Equal(t, g.ID, *p.GradeID) p, err = set(nil) require.NoError(t, err) require.Nil(t, p.GradeID)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := hr.UpdateGrade(ctx, tx, e.Clock, g.ID, g.Revision, hr.GradeUpdate{Names: names(), Rank: 10}) return err }) _, err = set(&g.ID) require.ErrorIs(t, err, hr.ErrGradeRetired)}- Step 3: Run them to verify they fail
Run: go test ./internal/platform/orgstruct/ ./internal/platform/hr/ -run 'Headcount|SoleOwnerMayAllow|ASeatTakes' -count=1
Expected: FAIL to compile — undefined: orgstruct.SetHeadcount, orgstruct.ErrOverHeadcount, hr.GrantHeadcountOverride, ….
- Step 4: Give the seat its two facts
In internal/platform/orgstruct/position.go:
In type Position struct, after the IsActive bool field, add:
// GradeID is the seat's job grade (Phase 5 H2, Ruling H2-4): an // employee's grade is their primary seat's. Nil when ungraded. GradeID *uuid.UUID
// ApprovedHeadcount is how many may hold the seat as their primary at // once (Phase 5 H2, HD-4); 00822's trigger refuses one more. ApprovedHeadcount intReplace
const positionColumns = `id, org_id, org_unit_id, code, title, path, depth, reports_to_position_id, is_active`with
const positionColumns = `id, org_id, org_unit_id, code, title, path, depth, reports_to_position_id, is_active, grade_id, approved_headcount`In scanPosition, replace
&p.Depth, &p.ReportsToPositionID, &p.IsActive)with
&p.Depth, &p.ReportsToPositionID, &p.IsActive, &p.GradeID, &p.ApprovedHeadcount)In CreatePosition’s p := Position{…} literal, after IsActive: true, add ApprovedHeadcount: 1, // 00822's default.
- Step 5: Lock the seat first in
Assign, report the override, name the refusal
In internal/platform/orgstruct/assignment.go:
In type Assignment struct, after the WithdrawnAt *time.Time field, add:
// HeadcountOverrideID names the Owner's override that let this primary // assignment exceed the seat's approved headcount (Phase 5 H2, HD-4). // 00822's trigger sets it; nil for every assignment within headcount. HeadcountOverrideID *uuid.UUIDIn Assign, replace
if err := requireActiveSeat(ctx, tx, orgID, in.PositionID); err != nil { return Assignment{}, err }with
// Phase 5 H2 (Ruling H2-8): 00822's headcount trigger locks this seat // FOR NO KEY UPDATE to count its holders. Taking that lock here, before // requireActiveSeat's FOR SHARE, means two assignments to one seat queue // instead of deadlocking on a share-to-update upgrade. if _, err := tx.Exec(ctx, `SELECT 1 FROM org_position WHERE org_id = $1 AND id = $2 FOR NO KEY UPDATE`, orgID, in.PositionID); err != nil { return Assignment{}, fmt.Errorf("orgstruct: assign: seat: %w", err) } if err := requireActiveSeat(ctx, tx, orgID, in.PositionID); err != nil { return Assignment{}, err }Replace
RETURNING id, valid_from, valid_to`, orgID, in.MemberID, in.PositionID, in.IsPrimary, in.ValidFrom, in.ValidTo). Scan(&a.ID, &a.ValidFrom, &a.ValidTo)with
RETURNING id, valid_from, valid_to, headcount_override_id`, orgID, in.MemberID, in.PositionID, in.IsPrimary, in.ValidFrom, in.ValidTo). Scan(&a.ID, &a.ValidFrom, &a.ValidTo, &a.HeadcountOverrideID)and immediately after the if errors.As(err, &pgErr) && pgErr.Code == "23P01" && … { return Assignment{}, ErrOverlappingPrimary } block, add:
// Phase 5 H2 (HD-4): 00822's trigger, which no override covered. if errors.As(err, &pgErr) && pgErr.ConstraintName == "member_position_within_headcount" { return Assignment{}, ErrOverHeadcount }- Step 6: Write the seat’s headcount functions
Create internal/platform/orgstruct/headcount.go:
package orgstruct
import ( "context" "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/kernel/tenancy")
// ErrOverHeadcount is 00822's refusal (Phase 5 H2, HD-4): the assignment// would give the seat more primary holders at once than its approved// headcount, and no Owner's override covers it.var ErrOverHeadcount = errors.New("orgstruct: the seat is at its approved headcount")
// ErrNegativeHeadcount: an approved headcount is 0 or more.var ErrNegativeHeadcount = errors.New("orgstruct: an approved headcount cannot be negative")
// LockSeat reads the seat FOR NO KEY UPDATE — the lock 00822's headcount// trigger takes — for a caller that will assign to it, or change its// headcount after its own audit row, and so must hold it first (Ruling// H2-8). Foreign keys that point at the seat (FOR KEY SHARE) still pass.func LockSeat(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Position, error) { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return Position{}, fmt.Errorf("orgstruct: lock seat: %w", tenancy.ErrNoOrg) } p, err := scanPosition(tx.QueryRow(ctx, `SELECT `+positionColumns+` FROM org_position WHERE org_id = $1 AND id = $2 FOR NO KEY UPDATE`, orgID, id)) if errors.Is(err, pgx.ErrNoRows) { return Position{}, ErrNotFound } if err != nil { return Position{}, fmt.Errorf("orgstruct: lock seat: %w", err) } return p, nil}
// SetHeadcount sets how many may hold the seat as their primary at once.// Lowering it below what is held is allowed and audited (Ruling H2-6):// nothing held is ended, and the next primary assignment is refused.func SetHeadcount(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, approved int) (Position, error) { if approved < 0 { return Position{}, ErrNegativeHeadcount } before, err := LockSeat(ctx, tx, id) if err != nil { return Position{}, err } if _, err := tx.Exec(ctx, `UPDATE org_position SET approved_headcount = $3, updated_at = $4 WHERE org_id = $1 AND id = $2`, before.OrgID, id, approved, c.Now()); err != nil { return Position{}, fmt.Errorf("orgstruct: set headcount: %w", err) } after := before after.ApprovedHeadcount = approved if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: "org_position", EntityID: id, Action: "headcount", Before: before, After: after}); err != nil { return Position{}, fmt.Errorf("orgstruct: set headcount: %w", err) } return after, nil}
// SetGrade sets (or, with nil, clears) the seat's job grade. The caller// (hr.SetPositionGrade) has checked the grade is active; 00822's composite// foreign key refuses another organization's.func SetGrade(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, gradeID *uuid.UUID) (Position, error) { before, err := LockSeat(ctx, tx, id) if err != nil { return Position{}, err } if _, err := tx.Exec(ctx, `UPDATE org_position SET grade_id = $3, updated_at = $4 WHERE org_id = $1 AND id = $2`, before.OrgID, id, gradeID, c.Now()); err != nil { return Position{}, fmt.Errorf("orgstruct: set grade: %w", err) } after := before after.GradeID = gradeID if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: "org_position", EntityID: id, Action: "grade", Before: before, After: after}); err != nil { return Position{}, fmt.Errorf("orgstruct: set grade: %w", err) } return after, nil}
// SeatHeadcount is one seat's approved and filled headcount on a day.type SeatHeadcount struct { PositionID uuid.UUID UnitID uuid.UUID Code string Title string GradeID *uuid.UUID IsActive bool Approved int // Filled is the live primary holders in force on the day (Ruling H2-5). Filled int}
// Headcount is every seat (only: just that one) with its holders on day on,// in path order. Filled uses currentAsOf, the one definition of "in force".func Headcount(ctx context.Context, tx pgx.Tx, on time.Time, only *uuid.UUID) ([]SeatHeadcount, error) { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return nil, fmt.Errorf("orgstruct: headcount: %w", tenancy.ErrNoOrg) } rows, err := tx.Query(ctx, ` SELECT p.id, p.org_unit_id, p.code, p.title, p.grade_id, p.is_active, p.approved_headcount, (SELECT count(*) FROM member_position WHERE org_id = p.org_id AND position_id = p.id AND is_primary AND `+currentAsOf+`)::int FROM org_position p WHERE p.org_id = $1 AND ($2::uuid IS NULL OR p.id = $2) ORDER BY p.path`, orgID, only, on) if err != nil { return nil, fmt.Errorf("orgstruct: headcount: %w", err) } defer rows.Close() out := []SeatHeadcount{} for rows.Next() { var s SeatHeadcount if err := rows.Scan(&s.PositionID, &s.UnitID, &s.Code, &s.Title, &s.GradeID, &s.IsActive, &s.Approved, &s.Filled); err != nil { return nil, fmt.Errorf("orgstruct: headcount: %w", err) } out = append(out, s) } return out, rows.Err()}
// PeakPrimary is the most live primary holders the seat has at once on any// day of [from, to) (to nil: open-ended) — 00822's position_peak_primary,// the definition the headcount trigger uses too.func PeakPrimary(ctx context.Context, tx pgx.Tx, positionID uuid.UUID, from time.Time, to *time.Time) (int, error) { orgID, ok := tenancy.OrgFromContext(ctx) if !ok { return 0, fmt.Errorf("orgstruct: peak: %w", tenancy.ErrNoOrg) } var n int if err := tx.QueryRow(ctx, `SELECT position_peak_primary($1, $2, $3, $4, NULL)`, orgID, positionID, from, to).Scan(&n); err != nil { return 0, fmt.Errorf("orgstruct: peak: %w", err) } return n, nil}- Step 7: Write the override and a seat’s grade
Append to internal/platform/hr/planning.go’s var (…) block:
// ErrHeadcountOverrideByRequester: HD-4 wants an Owner who is not the // member who asked (Ruling H2-9). ErrHeadcountOverrideByRequester = errors.New("hr: an Owner other than the member who asked must allow the seat over its headcount")Create internal/platform/hr/headcount.go:
package hr
import ( "context" "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/orgstruct" "github.com/mcom/erp/internal/platform/workflow")
// HeadcountOverride is an Owner's leave for MemberID to hold PositionID as// their primary from ValidFrom although the seat is full (HD-4, Ruling// H2-9). Approved and Filled are what it was granted over. 00822's trigger// consumes it once.type HeadcountOverride struct { ID uuid.UUID `json:"id"` PositionID uuid.UUID `json:"position_id"` MemberID uuid.UUID `json:"member_id"` ValidFrom time.Time `json:"valid_from"` RequestedBy uuid.UUID `json:"requested_by"` Reason string `json:"reason"` Approved int `json:"approved_headcount"` Filled int `json:"filled"` GrantedBy uuid.UUID `json:"granted_by"` SoleOwner bool `json:"sole_owner"` CreatedAt time.Time `json:"created_at"`}
// HeadcountOverrideInput: Actor is the granting Owner (the caller has// checked PermissionHeadcountOverride); RequestedBy the member who asked.type HeadcountOverrideInput struct { PositionID uuid.UUID MemberID uuid.UUID ValidFrom time.Time RequestedBy uuid.UUID Reason string Actor uuid.UUID}
// GrantHeadcountOverride records the Owner's leave (HD-4). Refused when the// Owner is the requester (ErrHeadcountOverrideByRequester) unless the// organization has one active member (Ruling H2-10), and when the seat has// room for the assignment (ErrWithinHeadcount).//// Lock order: organization FOR UPDATE (H1-14's lock; also the sole-owner// exception's) → the seat FOR NO KEY UPDATE → insert → audit.func GrantHeadcountOverride(ctx context.Context, tx pgx.Tx, c clock.Clock, in HeadcountOverrideInput) (HeadcountOverride, error) { org, err := orgOf(ctx) if err != nil { return HeadcountOverride{}, err } reason, err := checkReason("reason", in.Reason, true) if err != nil { return HeadcountOverride{}, err } if _, err := tx.Exec(ctx, `SELECT 1 FROM organization WHERE id = $1 AND org_id = $1 FOR UPDATE`, org); err != nil { return HeadcountOverride{}, fmt.Errorf("hr: lock organization: %w", err) } seat, err := orgstruct.LockSeat(ctx, tx, in.PositionID) if err != nil { return HeadcountOverride{}, err } if !seat.IsActive { return HeadcountOverride{}, orgstruct.ErrPositionInactive } for _, m := range []uuid.UUID{in.MemberID, in.RequestedBy} { var n int if err := tx.QueryRow(ctx, `SELECT count(*) FROM organization_member WHERE org_id = $1 AND id = $2`, org, m).Scan(&n); err != nil { return HeadcountOverride{}, fmt.Errorf("hr: headcount override: %w", err) } if n == 0 { return HeadcountOverride{}, ErrNoSuchMember } } sole := false if in.RequestedBy == in.Actor { // Ruling H2-10: only as the one-member organization's exception. if sole, err = workflow.SoleOwnerException(ctx, tx, in.Actor, reason); err != nil { return HeadcountOverride{}, err } if !sole { return HeadcountOverride{}, ErrHeadcountOverrideByRequester } } peak, err := orgstruct.PeakPrimary(ctx, tx, in.PositionID, in.ValidFrom, nil) if err != nil { return HeadcountOverride{}, err } if peak+1 <= seat.ApprovedHeadcount { return HeadcountOverride{}, ErrWithinHeadcount } var o HeadcountOverride err = tx.QueryRow(ctx, `INSERT INTO headcount_override (org_id, position_id, member_id, valid_from, requested_by, reason, approved_headcount, filled, granted_by, sole_owner, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING id, position_id, member_id, valid_from, requested_by, reason, approved_headcount, filled, granted_by, sole_owner, created_at`, org, in.PositionID, in.MemberID, in.ValidFrom, in.RequestedBy, reason, seat.ApprovedHeadcount, peak, in.Actor, sole, c.Now()). Scan(&o.ID, &o.PositionID, &o.MemberID, &o.ValidFrom, &o.RequestedBy, &o.Reason, &o.Approved, &o.Filled, &o.GrantedBy, &o.SoleOwner, &o.CreatedAt) if err != nil { return HeadcountOverride{}, fmt.Errorf("hr: grant headcount override: %w", err) } o.ValidFrom, o.CreatedAt = o.ValidFrom.UTC(), o.CreatedAt.UTC() if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityHeadcountOverride, EntityID: o.ID, Action: "grant", After: o}); err != nil { return HeadcountOverride{}, err } return o, nil}Append to internal/platform/hr/grade.go (add "github.com/mcom/erp/internal/platform/orgstruct" to its imports):
// SetPositionGrade sets (or, with nil, clears) a seat's grade; a retired// grade takes no new seat (Rulings H2-2, H2-4).func SetPositionGrade(ctx context.Context, tx pgx.Tx, c clock.Clock, positionID uuid.UUID, gradeID *uuid.UUID) (orgstruct.Position, error) { if gradeID != nil { g, err := GetGrade(ctx, tx, *gradeID) if err != nil { return orgstruct.Position{}, err } if !g.IsActive { return orgstruct.Position{}, ErrGradeRetired } } return orgstruct.SetGrade(ctx, tx, c, positionID, gradeID)}- Step 8: Make
Transfertake the seat the trigger’s way
In internal/platform/hr/events.go, in Transfer, replace
if _, err := tx.Exec(ctx, `SELECT 1 FROM org_position WHERE org_id = $1 AND id = $2 FOR SHARE`, org, in.PositionID); err != nil {with
// FOR NO KEY UPDATE, the lock 00822's headcount trigger takes (Phase 5 // H2, Ruling H2-8): held here, before the first audit row, the trigger // never waits after one. if _, err := tx.Exec(ctx, `SELECT 1 FROM org_position WHERE org_id = $1 AND id = $2 FOR NO KEY UPDATE`, org, in.PositionID); err != nil {In internal/platform/hr/doc.go, replace UPDATE → the target org_position FOR SHARE → member_position rows FOR with UPDATE → the target org_position FOR NO KEY UPDATE → member_position rows FOR.
- Step 9: Name the refusal on the org chart’s route
In internal/modules/orgstructure/handlers.go, in mapErr, add after the ErrOverlappingPrimary case:
case errors.Is(err, orgstruct.ErrOverHeadcount): // Phase 5 H2 (HD-4): the seat is full; an Owner who is not the // requester can allow one more, or HR can raise its headcount. return apperr.New(apperr.KindConflict, "the position is at its approved headcount; an Owner other than you can allow one more"). With("code", "over_headcount")and add // ErrOverHeadcount 409 the seat is at its approved headcount (Phase 5 H2) to the table in mapErr’s doc comment, after the ErrOverlappingPrimary line. (The mapping is one line like its neighbours; TestASeatRefusesOnePrimaryHolderMoreThanItsHeadcount proves the sentinel it maps.)
- Step 10: Run the tests
Run: go test ./internal/platform/orgstruct/ ./internal/platform/hr/... ./internal/modules/orgstructure/ ./internal/platform/workflow/ ./internal/kernel/layering/ -count=1
Expected: PASS (H1’s TestATransferMovesThePrimarySeatFromTodayOn included: its target seat has room).
- Step 11: Watch the HD-4 drift test fail three times
- In
00822_headcount.sql’s guard, changeIF peak <= approved THENtoIF true THEN; rungo test ./internal/platform/hr/ -run TestAnAssignmentBeyondHeadcount -count=1. Expected: FAIL —Target error should be in err chainat the firstErrOverHeadcount(got nil). Restore. - In
GrantHeadcountOverride, delete the wholeif in.RequestedBy == in.Actor { … }block; run the same test. Expected: FAIL — the error is the database’sheadcount_override_not_requester, nothr.ErrHeadcountOverrideByRequester(the backstop holds; the named refusal is gone). With the block still deleted, comment out theCONSTRAINT headcount_override_not_requester …line in00822; run again. Expected: FAIL —An error is expected but got nil. Restore both. - In
00822, change the insert trigger’sWHEN (NEW.is_primary AND NEW.withdrawn_at IS NULL)toWHEN (NEW.withdrawn_at IS NULL); run the same test. Expected: FAIL at the acting assignment (orgstruct: the seat is at its approved headcount). Restore; PASS.
- Step 12: Commit
git add internal/platform/orgstruct internal/platform/hr internal/modules/orgstructuregit commit -m "Phase 5 H2: seat headcount and grade in orgstruct; the Owner-not-requester headcount override (HD-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 6: The vacancy document and its workflow (00823)
Section titled “Task 6: The vacancy document and its workflow (00823)”Files:
- Create:
db/migrations/00823_vacancy.sql,internal/platform/hr/vacancy.go - Modify:
internal/platform/workflow/model.go(CommitmentKinds),internal/kernel/db/retention_test.go - Test:
internal/platform/hr/vacancy_test.go - Regenerate:
internal/gen/sqlc/models.go(make generate)
Interfaces:
-
Consumes: Task 1 (or O1 Task 1):
posting.Committed,workflow.CommitmentKinds,soleOwnerEdge’s committed branch; Task 5’sorgstruct.LockSeat,orgstruct.SetHeadcount;workflow.RegisterSeededGraph,workflow.AllowSoleOwnerException,workflow.Start,workflow.Get,workflow.Load,workflow.Transition,workflow.LockSoleOwner,workflow.SoleOwnerAudit;orgstruct.GetPosition; H1’scheckReason,orgOf. -
Produces: tables
vacancy; columnemployee_event.vacancy_id(used by Task 7); functionsvacancy_workflow(uuid, uuid); constraintsvacancy_fixed,employee_event_vacancy_on_hire,employee_event_vacancy_open;hr.Vacancy(+Status,Remaining),hr.VacancyInput,hr.VacancyFilter,hr.VacancyTransitionInput,hr.VacancyGraph(ownerSeat uuid.UUID) workflow.Graph,hr.CreateVacancy,hr.GetVacancy,hr.ListVacancies,hr.TransitionVacancy(signatures in “Interfaces this plan produces”); package-internallockVacancy;workflow.CommitmentKindsgains"vacancy". -
Step 1: Write the failing tests
Create internal/platform/hr/vacancy_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/posting" "github.com/mcom/erp/internal/platform/workflow" "github.com/mcom/erp/internal/platform/workflow/workflowtest")
func raiseBy(t *testing.T, e *hrtest.Env, by, seat uuid.UUID, kind string, qty int) hr.Vacancy { t.Helper() var v hr.Vacancy e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error v, err = hr.CreateVacancy(ctx, tx, e.Clock, hr.VacancyInput{PositionID: seat, Kind: kind, Quantity: qty, NeededBy: hrtest.Day(2026, 9, 30), Reason: "Staff for the new branch", Actor: by}) return err }) return v}
func raise(t *testing.T, e *hrtest.Env, seat uuid.UUID, kind string, qty int) hr.Vacancy { t.Helper() return raiseBy(t, e, e.HR, seat, kind, qty)}
func moveErr(e *hrtest.Env, v hr.Vacancy, by uuid.UUID, from, to, reason string) (hr.Vacancy, error) { var out hr.Vacancy err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { var err error out, _, err = hr.TransitionVacancy(ctx, tx, e.Clock, hr.VacancyTransitionInput{ID: v.ID, From: from, To: to, Actor: by, Reason: reason}) return err }) return out, err}
func move(t *testing.T, e *hrtest.Env, v hr.Vacancy, by uuid.UUID, from, to, reason string) hr.Vacancy { t.Helper() out, err := moveErr(e, v, by, from, to, reason) require.NoError(t, err) return out}
// approve submits as the requester and approves as the Owner (the OWNER seat).func approve(t *testing.T, e *hrtest.Env, v hr.Vacancy) hr.Vacancy { t.Helper() v = move(t, e, v, v.RequestedBy, "draft", "pending_approval", "") return move(t, e, v, e.Owner, "pending_approval", "approved", "")}
func approvedHeadcount(t *testing.T, e *hrtest.Env, seat uuid.UUID) int { t.Helper() return e.Count(t, `SELECT approved_headcount FROM org_position WHERE org_id = $1 AND id = $2`, e.Org, seat)}
// publishDisputable publishes a vacancy graph in which an approved vacancy// can be disputed and approved again: re-entering committed, which the// seeded graph never does (Ruling H2-12's "exactly once").func publishDisputable(t *testing.T, e *hrtest.Env) { t.Helper() g := hr.VacancyGraph(e.OwnerSeat) g.States = append(g.States, workflow.StateSpec{Key: "disputed", Semantic: posting.Dispute, DisplayOrder: 6, Labels: workflow.Labels{"en": "Disputed", "ms": "Dipertikaikan", "zh": "有争议"}}) g.Edges = append(g.Edges, workflow.EdgeSpec{From: "approved", To: "disputed", Gate: workflow.GateReporter, ActionKey: "dispute", RequiresReason: true, DisplayOrder: 6, Labels: workflow.Labels{"en": "Dispute", "ms": "Pertikaikan", "zh": "提出争议"}}, workflow.EdgeSpec{From: "disputed", To: "approved", Gate: workflow.GateSource, ActionKey: "dismiss", DisplayOrder: 7, Labels: workflow.Labels{"en": "Dismiss", "ms": "Ketepikan", "zh": "驳回"}}) g.Grants = append(g.Grants, workflow.GrantSpec{StateKey: "disputed", GranteeKind: workflow.GranteePosition, PositionID: &e.OwnerSeat}) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := workflow.PublishGraph(ctx, tx, e.Clock, g.Normalized(), nil) return err })}
func TestTheVacancyGraphIsSeededAndValid(t *testing.T) { workflowtest.RequireSeededGraphValid(t, hr.KindVacancy) require.Contains(t, workflow.CommitmentKinds, hr.KindVacancy)}
// Spec H2 drift bullet 2 (Ruling H2-12): a new vacancy's approval raises// approved headcount once; a replacement's changes nothing.func TestANewVacancysApprovalRaisesHeadcountOnceAndAReplacementChangesNothing(t *testing.T) { e := hrtest.New(t) seat := e.Seat(t, "CLERK") publishDisputable(t, e) require.Equal(t, 1, approvedHeadcount(t, e, seat))
nv := approve(t, e, raise(t, e, seat, hr.VacancyNew, 2)) require.Equal(t, posting.Committed, nv.Semantic) require.Equal(t, hr.VacancyOpen, nv.Status()) require.NotNil(t, nv.HeadcountRaisedAt) require.Equal(t, 3, approvedHeadcount(t, e, seat), "PRESENCE: raised by its quantity")
nv = move(t, e, nv, e.HR, "approved", "disputed", "The budget is under review") nv = move(t, e, nv, e.Owner, "disputed", "approved", "") require.Equal(t, "approved", nv.State) require.Equal(t, 3, approvedHeadcount(t, e, seat), "ABSENCE: approved again, raised nothing more")
rv := approve(t, e, raise(t, e, seat, hr.VacancyReplacement, 1)) require.Equal(t, hr.VacancyOpen, rv.Status()) require.Nil(t, rv.HeadcountRaisedAt) require.Equal(t, 3, approvedHeadcount(t, e, seat), "ABSENCE: a replacement changes nothing")}
// Spec H2 "approver from grants, never chosen by the requester": the OWNER// seat approves; the requester cannot.func TestAVacancyIsApprovedByTheOwnerSeatNeverTheRequester(t *testing.T) { e := hrtest.New(t) seat := e.Seat(t, "CLERK") ok := approve(t, e, raise(t, e, seat, hr.VacancyNew, 1)) require.Equal(t, hr.VacancyOpen, ok.Status(), "PRESENCE: the Owner approves") require.Equal(t, 2, approvedHeadcount(t, e, seat))
v := move(t, e, raise(t, e, seat, hr.VacancyNew, 1), e.HR, "draft", "pending_approval", "") _, err := moveErr(e, v, e.HR, "pending_approval", "approved", "") var refused *workflow.RefusedError require.ErrorAs(t, err, &refused, "ABSENCE: the requester") require.Equal(t, 2, approvedHeadcount(t, e, seat), "a refused approval raises nothing")}
// Ruling H2-11 (O-3a): a one-member organization's Owner approves their own// vacancy with a reason, recorded as the exception; with others active the// claimant rule stands.func TestASoleOwnerApprovesTheirOwnVacancyWithAReason(t *testing.T) { e := hrtest.New(t) seat := e.Seat(t, "CLERK") own := raiseBy(t, e, e.Owner, seat, hr.VacancyNew, 1) _, err := moveErr(e, own, e.Owner, "draft", "pending_approval", "") require.Error(t, err, "PRESENCE of the rule: nobody but the Owner could approve it")
e.DeactivateByAdmin(t, e.Staff) e.DeactivateByAdmin(t, e.HR) v := raiseBy(t, e, e.Owner, seat, hr.VacancyNew, 1) v = move(t, e, v, e.Owner, "draft", "pending_approval", "") _, err = moveErr(e, v, e.Owner, "pending_approval", "approved", " ") require.ErrorIs(t, err, workflow.ErrSoleOwnerReasonRequired) v = move(t, e, v, e.Owner, "pending_approval", "approved", "I run this business alone") require.Equal(t, hr.VacancyOpen, v.Status()) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM workflow_decision WHERE org_id = $1 AND instance_id = $2 AND authority_via = 'sole_owner_exception'`, e.Org, v.InstanceID)) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_kind = 'vacancy' AND entity_id = $2 AND action = 'transition' AND after->>'sole_owner_exception' = 'true'`, e.Org, v.ID))}
// Ruling H2-14: a vacancy names an active seat, a quantity and a reason,// and is needed today or later.func TestAVacancyIsRefusedWithoutItsTerms(t *testing.T) { e := hrtest.New(t) seat := e.Seat(t, "CLERK") create := func(in hr.VacancyInput) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := hr.CreateVacancy(ctx, tx, e.Clock, in) return err }) } in := hr.VacancyInput{PositionID: seat, Kind: hr.VacancyNew, Quantity: 1, NeededBy: hrtest.Day(2026, 9, 21), Reason: "Branch opening", Actor: e.HR} require.NoError(t, create(in), "PRESENCE: needed today") bad := in bad.NeededBy = hrtest.Day(2026, 9, 20) require.Equal(t, "needed_by/not_past", fieldRule(create(bad))) bad = in bad.Quantity = 0 require.Equal(t, "quantity/range", fieldRule(create(bad))) bad = in bad.Kind = "temporary" require.Equal(t, "kind/known", fieldRule(create(bad))) bad = in bad.Reason = " " require.Equal(t, "reason/required", fieldRule(create(bad)))}- Step 2: Run them to verify they fail
Run: go test ./internal/platform/hr/ -run 'Vacanc' -count=1
Expected: FAIL to compile — undefined: hr.CreateVacancy, hr.VacancyGraph, hr.Vacancy, ….
- Step 3: Write the migration
Create db/migrations/00823_vacancy.sql:
-- +goose Up-- Phase 5 H2 (docs/superpowers/specs/2026-09-30-hr-layer-design.md §H2-- "vacancy", "Filling"; Rulings H2-11–H2-15): a request to fill a seat,-- approved through its own workflow (kind vacancy, ending in committed).-- Its status, fill and age are derived; what is stored once is the-- headcount raise and the manual close.CREATE TABLE vacancy ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), position_id UUID NOT NULL, kind TEXT NOT NULL CONSTRAINT vacancy_kind_known CHECK (kind IN ('replacement', 'new')), quantity INT NOT NULL CONSTRAINT vacancy_quantity_range CHECK (quantity BETWEEN 1 AND 999), needed_by DATE NOT NULL, reason TEXT NOT NULL CONSTRAINT vacancy_reason_shape CHECK (reason = btrim(reason) AND reason <> '' AND length(reason) <= 500 AND reason !~ '[-ヿ㐀-䶿一-鿿가-]'), requested_by UUID NOT NULL, headcount_raised_at TIMESTAMPTZ, closed_at TIMESTAMPTZ, close_reason TEXT CONSTRAINT vacancy_close_reason_shape CHECK (close_reason IS NULL OR (close_reason = btrim(close_reason) AND close_reason <> '' AND length(close_reason) <= 500 AND close_reason !~ '[-ヿ㐀-䶿一-鿿가-]')), closed_by UUID, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT vacancy_tenant_id UNIQUE (org_id, id), CONSTRAINT vacancy_close_complete CHECK ((closed_at IS NULL) = (close_reason IS NULL) AND (closed_at IS NULL) = (closed_by IS NULL)), CONSTRAINT vacancy_raise_only_new CHECK (headcount_raised_at IS NULL OR kind = 'new'), CONSTRAINT vacancy_position_fk FOREIGN KEY (org_id, position_id) REFERENCES org_position (org_id, id), CONSTRAINT vacancy_requested_by_fk FOREIGN KEY (org_id, requested_by) REFERENCES organization_member (org_id, id), CONSTRAINT vacancy_closed_by_fk FOREIGN KEY (org_id, closed_by) REFERENCES organization_member (org_id, id));CREATE INDEX vacancy_by_position ON vacancy (org_id, position_id);ALTER TABLE vacancy ENABLE ROW LEVEL SECURITY;ALTER TABLE vacancy FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON vacancy USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON vacancy FROM erp_app;
-- Rulings H2-12, H2-14: a vacancy's terms never change; the headcount raise-- and the close are each written once.-- +goose StatementBeginCREATE FUNCTION vacancy_fixed() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF NEW.position_id <> OLD.position_id OR NEW.kind <> OLD.kind OR NEW.quantity <> OLD.quantity OR NEW.needed_by <> OLD.needed_by OR NEW.reason <> OLD.reason OR NEW.requested_by <> OLD.requested_by OR NEW.created_at <> OLD.created_at OR (OLD.headcount_raised_at IS NOT NULL AND NEW.headcount_raised_at IS DISTINCT FROM OLD.headcount_raised_at) OR (OLD.closed_at IS NOT NULL AND (NEW.closed_at IS DISTINCT FROM OLD.closed_at OR NEW.close_reason IS DISTINCT FROM OLD.close_reason OR NEW.closed_by IS DISTINCT FROM OLD.closed_by)) THEN RAISE EXCEPTION 'vacancy %: its terms never change, and the headcount raise and the close are written once', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'vacancy_fixed'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER vacancy_fixed BEFORE UPDATE ON vacancy FOR EACH ROW EXECUTE FUNCTION vacancy_fixed();
-- The vacancy's workflow instance, state, semantic and the day it entered-- that state (its approval day once committed; Ruling H2-16).-- +goose StatementBeginCREATE FUNCTION vacancy_workflow(p_org uuid, p_vacancy uuid) RETURNS TABLE (instance_id uuid, state_key text, semantic text, entered_on date) LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$ SELECT i.id, i.state_key, s.semantic, i.entered_state_on FROM public.workflow_instance i JOIN public.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 = p_org AND i.kind = 'vacancy' AND i.source_id = p_vacancy$$;-- +goose StatementEnd
-- Spec H2 "Filling" (Ruling H2-13): a hire names the vacancy it fills.ALTER TABLE employee_event ADD COLUMN vacancy_id UUID, ADD CONSTRAINT employee_event_vacancy_on_hire CHECK (vacancy_id IS NULL OR kind IN ('hired', 'rehired')), ADD CONSTRAINT employee_event_vacancy_fk FOREIGN KEY (org_id, vacancy_id) REFERENCES vacancy (org_id, id);CREATE INDEX employee_event_by_vacancy ON employee_event (org_id, vacancy_id) WHERE vacancy_id IS NOT NULL;
-- Only an open vacancy is filled: approved (committed), not closed, and-- short of its quantity. The vacancy row is locked, so two hires into its-- last place serialize.-- +goose StatementBeginCREATE FUNCTION employee_event_vacancy_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE v public.vacancy%ROWTYPE; sem text; filled int;BEGIN SELECT * INTO v FROM public.vacancy x WHERE x.org_id = NEW.org_id AND x.id = NEW.vacancy_id FOR UPDATE; SELECT w.semantic INTO sem FROM public.vacancy_workflow(NEW.org_id, NEW.vacancy_id) w; SELECT count(*) INTO filled FROM public.employee_event ev WHERE ev.org_id = NEW.org_id AND ev.vacancy_id = NEW.vacancy_id; IF v.id IS NULL OR sem IS DISTINCT FROM 'committed' OR v.closed_at IS NOT NULL OR filled >= v.quantity THEN RAISE EXCEPTION 'vacancy % is not open: not approved, closed, or already filled', NEW.vacancy_id USING ERRCODE = '23514', CONSTRAINT = 'employee_event_vacancy_open'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER employee_event_vacancy_guard BEFORE INSERT ON employee_event FOR EACH ROW WHEN (NEW.vacancy_id IS NOT NULL) EXECUTE FUNCTION employee_event_vacancy_guard();
-- +goose DownDROP TRIGGER employee_event_vacancy_guard ON employee_event;DROP FUNCTION employee_event_vacancy_guard();ALTER TABLE employee_event DROP COLUMN vacancy_id;DROP FUNCTION vacancy_workflow(uuid, uuid);DROP TABLE vacancy;DROP FUNCTION vacancy_fixed();In internal/kernel/db/retention_test.go, add to amendable after "grade_band",:
// Phase 5 H2 (00823): a vacancy is closed or filled, never erased; // every hire that filled it names it. "vacancy",- Step 4: Make
vacancya commitment kind
In internal/platform/workflow/model.go, replace
var CommitmentKinds = []string{"purchase_requisition", "purchase_order", "quotation", "sales_order"}with
var CommitmentKinds = []string{"purchase_requisition", "purchase_order", "quotation", "sales_order", "vacancy"} // Phase 5 H2: an approved vacancy is committed to and posts nothingIf internal/platform/trade/graphs_test.go exists (Phase 4 O1 landed), its TestTheCommitmentKindsAreSeededAndValid asserts Len(…, 4) and would now judge vacancy in a binary that does not link hr. Replace its body with:
kinds := []string{trade.KindRequisition, trade.KindOrder, trade.KindQuotation, trade.KindSalesOrder} for _, k := range kinds { require.Contains(t, workflow.CommitmentKinds, k, "PRESENCE: trade's kinds commit") workflowtest.RequireSeededGraphValid(t, k) }and change its _ "github.com/mcom/erp/internal/platform/trade" import to "github.com/mcom/erp/internal/platform/trade". (internal/app’s TestTheCommitmentKindsAreSeeded, which links every module, still checks vacancy is seeded.)
- Step 5: Write the vacancy service
Create internal/platform/hr/vacancy.go:
package hr
import ( "context" "errors" "fmt" "slices" "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/kernel/money" "github.com/mcom/erp/internal/platform/orgstruct" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
func init() { workflow.RegisterSeededGraph(KindVacancy, VacancyGraph) // Ruling H2-11: a one-member organization approves its own vacancy, // with a reason (Ruling O-3a). workflow.AllowSoleOwnerException(KindVacancy, true)}
func wl(en, zh, ms string) workflow.Labels { return workflow.Labels{"en": en, "zh": zh, "ms": ms} }
// VacancyGraph is Ruling H2-11's shape, approved by the OWNER seat://// draft ──submit──▶ pending_approval ──approve──▶ approved (committed, terminal)// │ └──reject──▶ rejected ──revise──▶ draft// └──withdraw──▶ withdrawnfunc VacancyGraph(ownerSeat uuid.UUID) workflow.Graph { st := func(order int, key string, sem posting.Semantic, terminal bool, l workflow.Labels) workflow.StateSpec { return workflow.StateSpec{Key: key, Semantic: sem, DisplayOrder: order, IsTerminal: terminal, Labels: l} } ed := func(order int, from, to string, gate workflow.Gate, action string, reason bool, l workflow.Labels) workflow.EdgeSpec { return workflow.EdgeSpec{From: from, To: to, Gate: gate, ActionKey: action, RequiresReason: reason, DisplayOrder: order, Labels: l} } return workflow.Graph{ Kind: KindVacancy, PostingOrder: workflow.AccrualFirst, // vacuous: nothing posts States: []workflow.StateSpec{ st(1, "draft", posting.Draft, false, wl("Draft", "草稿", "Draf")), st(2, "pending_approval", posting.Pending, false, wl("Pending approval", "待批准", "Menunggu kelulusan")), st(3, "approved", posting.Committed, true, wl("Approved", "已批准", "Diluluskan")), st(4, "rejected", posting.Rejected, false, wl("Rejected", "已拒绝", "Ditolak")), st(5, "withdrawn", posting.Cancelled, true, wl("Withdrawn", "已撤回", "Ditarik balik")), }, Edges: []workflow.EdgeSpec{ ed(1, "draft", "pending_approval", workflow.GateReporter, "submit", false, wl("Submit", "提交", "Hantar")), ed(2, "pending_approval", "approved", workflow.GateTarget, "approve", false, wl("Approve", "批准", "Luluskan")), ed(3, "pending_approval", "rejected", workflow.GateAdvance, "reject", true, wl("Reject", "拒绝", "Tolak")), ed(4, "rejected", "draft", workflow.GateReporter, "revise", false, wl("Revise", "修改", "Semak semula")), ed(5, "draft", "withdrawn", workflow.GateReporter, "withdraw", true, wl("Withdraw", "撤回", "Tarik balik")), }, Grants: []workflow.GrantSpec{{StateKey: "approved", GranteeKind: workflow.GranteePosition, PositionID: &ownerSeat}}, }.Normalized()}
// Vacancy is a request to fill a seat (spec H2). Filled, State, Semantic// and EnteredStateOn are derived; Status and Remaining from them.type Vacancy struct { ID uuid.UUID `json:"id"` PositionID uuid.UUID `json:"position_id"` Kind string `json:"kind"` Quantity int `json:"quantity"` Filled int `json:"filled"` NeededBy time.Time `json:"needed_by"` Reason string `json:"reason"` RequestedBy uuid.UUID `json:"requested_by"` InstanceID uuid.UUID `json:"instance_id"` State string `json:"state"` Semantic posting.Semantic `json:"semantic"` EnteredStateOn time.Time `json:"entered_state_on"` HeadcountRaisedAt *time.Time `json:"headcount_raised_at,omitempty"` ClosedAt *time.Time `json:"closed_at,omitempty"` CloseReason *string `json:"close_reason,omitempty"` ClosedBy *uuid.UUID `json:"closed_by,omitempty"` CreatedAt time.Time `json:"created_at"`}
// Status is Ruling H2-15's derived status.func (v Vacancy) Status() string { switch v.Semantic { case posting.Draft: return VacancyDraft case posting.Rejected: return VacancyRejected case posting.Cancelled: return VacancyWithdrawn case posting.Committed: switch { case v.ClosedAt != nil: return VacancyClosed case v.Filled >= v.Quantity: return VacancyFilled } return VacancyOpen } return VacancyPending // pending, or a customer-added custom or dispute step}
// Remaining is how many hires it still needs.func (v Vacancy) Remaining() int { return max(v.Quantity-v.Filled, 0) }
type VacancyInput struct { PositionID uuid.UUID Kind string Quantity int NeededBy time.Time Reason string Actor uuid.UUID}
// VacancyFilter narrows ListVacancies: Status is a derived status.type VacancyFilter struct { Status string PositionID *uuid.UUID}
type VacancyTransitionInput struct { ID uuid.UUID From, To string Actor uuid.UUID Reason string}
const vacancySelect = `SELECT v.id, v.position_id, v.kind, v.quantity, (SELECT count(*) FROM employee_event ev WHERE ev.org_id = v.org_id AND ev.vacancy_id = v.id)::int, v.needed_by, v.reason, v.requested_by, w.instance_id, w.state_key, w.semantic, w.entered_on, v.headcount_raised_at, v.closed_at, v.close_reason, v.closed_by, v.created_at FROM vacancy v CROSS JOIN LATERAL vacancy_workflow(v.org_id, v.id) w WHERE v.org_id = $1 AND `
func scanVacancy(row pgx.Row) (Vacancy, error) { var v Vacancy var sem string if err := row.Scan(&v.ID, &v.PositionID, &v.Kind, &v.Quantity, &v.Filled, &v.NeededBy, &v.Reason, &v.RequestedBy, &v.InstanceID, &v.State, &sem, &v.EnteredStateOn, &v.HeadcountRaisedAt, &v.ClosedAt, &v.CloseReason, &v.ClosedBy, &v.CreatedAt); err != nil { return Vacancy{}, err } v.Semantic = posting.Semantic(sem) v.NeededBy, v.EnteredStateOn, v.CreatedAt = v.NeededBy.UTC(), v.EnteredStateOn.UTC(), v.CreatedAt.UTC() return v, nil}
func GetVacancy(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Vacancy, error) { org, err := orgOf(ctx) if err != nil { return Vacancy{}, err } v, err := scanVacancy(tx.QueryRow(ctx, vacancySelect+`v.id = $2`, org, id)) if errors.Is(err, pgx.ErrNoRows) { return Vacancy{}, ErrVacancyNotFound } if err != nil { return Vacancy{}, fmt.Errorf("hr: vacancy: %w", err) } return v, nil}
// ListVacancies is every vacancy matching f, newest first.func ListVacancies(ctx context.Context, tx pgx.Tx, f VacancyFilter) ([]Vacancy, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, vacancySelect+`($2::uuid IS NULL OR v.position_id = $2) ORDER BY v.created_at DESC, v.id`, org, f.PositionID) if err != nil { return nil, fmt.Errorf("hr: vacancies: %w", err) } defer rows.Close() out := []Vacancy{} for rows.Next() { v, err := scanVacancy(rows) if err != nil { return nil, fmt.Errorf("hr: vacancies: %w", err) } if f.Status == "" || v.Status() == f.Status { out = append(out, v) } } return out, rows.Err()}
func lockVacancy(ctx context.Context, tx pgx.Tx, org, id uuid.UUID) (Vacancy, error) { var got uuid.UUID err := tx.QueryRow(ctx, `SELECT id FROM vacancy WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, id).Scan(&got) if errors.Is(err, pgx.ErrNoRows) { return Vacancy{}, ErrVacancyNotFound } if err != nil { return Vacancy{}, fmt.Errorf("hr: lock vacancy: %w", err) } return GetVacancy(ctx, tx, id)}
// CreateVacancy raises a draft vacancy for an active seat and starts its// workflow, with in.Actor as the requester (Ruling H2-14).func CreateVacancy(ctx context.Context, tx pgx.Tx, c clock.Clock, in VacancyInput) (Vacancy, error) { org, err := orgOf(ctx) if err != nil { return Vacancy{}, err } switch { case !slices.Contains(VacancyKinds, in.Kind): return Vacancy{}, &FieldError{Field: "kind", Rule: "known", Detail: "a vacancy is a replacement or new"} case in.Quantity < 1 || in.Quantity > 999: return Vacancy{}, &FieldError{Field: "quantity", Rule: "range", Detail: "a vacancy is for 1 to 999 people"} case in.NeededBy.Before(clock.Today(ctx, c)): return Vacancy{}, &FieldError{Field: "needed_by", Rule: "not_past", Detail: "a vacancy is needed today or later"} } reason, err := checkReason("reason", in.Reason, true) if err != nil { return Vacancy{}, err } p, err := orgstruct.GetPosition(ctx, tx, in.PositionID) if err != nil { return Vacancy{}, err } if !p.IsActive { return Vacancy{}, orgstruct.ErrPositionInactive } var id uuid.UUID if err := tx.QueryRow(ctx, `INSERT INTO vacancy (org_id, position_id, kind, quantity, needed_by, reason, requested_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, org, in.PositionID, in.Kind, in.Quantity, in.NeededBy, reason, in.Actor, c.Now()).Scan(&id); err != nil { return Vacancy{}, fmt.Errorf("hr: create vacancy: %w", err) } if _, err := workflow.Start(ctx, tx, c, workflow.StartInput{Kind: KindVacancy, SourceID: id, Reporter: in.Actor, Amount: money.Zero()}); err != nil { return Vacancy{}, err } v, err := GetVacancy(ctx, tx, id) if err != nil { return Vacancy{}, err } if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityVacancy, EntityID: id, Action: "create", After: v}); err != nil { return Vacancy{}, err } return v, nil}
// TransitionVacancy moves a vacancy through its workflow. Entering a// committed state raises a new vacancy's seat by its quantity, exactly once// (Ruling H2-12).//// Lock order: organization FOR UPDATE in a one-member organization// (workflow.LockSoleOwner) → vacancy FOR UPDATE → the seat FOR NO KEY// UPDATE when a raise may follow → workflow.Transition → the raise (rows// already held) → audit.func TransitionVacancy(ctx context.Context, tx pgx.Tx, c clock.Clock, in VacancyTransitionInput) (Vacancy, workflow.Decision, error) { org, err := orgOf(ctx) if err != nil { return Vacancy{}, workflow.Decision{}, err } if err := workflow.LockSoleOwner(ctx, tx); err != nil { return Vacancy{}, workflow.Decision{}, err } v, err := lockVacancy(ctx, tx, org, in.ID) if err != nil { return Vacancy{}, workflow.Decision{}, err } inst, err := workflow.Get(ctx, tx, v.InstanceID) if err != nil { return Vacancy{}, workflow.Decision{}, err } def, err := workflow.Load(ctx, tx, inst.DefinitionID) if err != nil { return Vacancy{}, workflow.Decision{}, err } to, ok := def.State(in.To) if !ok { return Vacancy{}, workflow.Decision{}, workflow.ErrNoSuchEdge } raise := to.Semantic == posting.Committed && v.Kind == VacancyNew && v.HeadcountRaisedAt == nil if raise { if _, err := orgstruct.LockSeat(ctx, tx, v.PositionID); err != nil { return Vacancy{}, workflow.Decision{}, err } } dec, err := workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID: v.InstanceID, From: in.From, To: in.To, Actor: in.Actor, Reason: in.Reason}) if err != nil { return Vacancy{}, workflow.Decision{}, err } raised := false if raise && !dec.Pending { tag, err := tx.Exec(ctx, `UPDATE vacancy SET headcount_raised_at = $3 WHERE org_id = $1 AND id = $2 AND headcount_raised_at IS NULL`, org, v.ID, c.Now()) if err != nil { return Vacancy{}, workflow.Decision{}, fmt.Errorf("hr: raise headcount: %w", err) } if tag.RowsAffected() == 1 { seat, err := orgstruct.LockSeat(ctx, tx, v.PositionID) // held since before the transition if err != nil { return Vacancy{}, workflow.Decision{}, err } if _, err := orgstruct.SetHeadcount(ctx, tx, c, v.PositionID, seat.ApprovedHeadcount+v.Quantity); err != nil { return Vacancy{}, workflow.Decision{}, err } raised = true } } after, err := GetVacancy(ctx, tx, v.ID) if err != nil { return Vacancy{}, workflow.Decision{}, err } auditAfter := workflow.SoleOwnerAudit(after.State, dec) if raised { auditAfter["headcount_raised_by"] = v.Quantity } if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityVacancy, EntityID: v.ID, Action: "transition", Before: map[string]string{"state": v.State}, After: auditAfter}); err != nil { return Vacancy{}, workflow.Decision{}, err } return after, dec, nil}- Step 6: Run the tests
Run: go test ./internal/platform/hr/... ./internal/platform/workflow/ ./internal/kernel/db/ ./internal/kernel/layering/ -count=1
Expected: PASS. (If internal/platform/trade exists, add ./internal/platform/trade/ to the command.)
- Step 7: Watch the drift test fail once
In TransitionVacancy, delete AND headcount_raised_at IS NULL from the UPDATE vacancy … statement and change raise := to.Semantic == posting.Committed && v.Kind == VacancyNew && v.HeadcountRaisedAt == nil to raise := to.Semantic == posting.Committed && v.Kind == VacancyNew; run go test ./internal/platform/hr/ -run TestANewVacancysApproval -count=1. Expected: FAIL — the second approval’s UPDATE is refused by vacancy_fixed (the database backstop). Then in 00823’s vacancy_fixed() delete the OR (OLD.headcount_raised_at IS NOT NULL AND …) line and run again. Expected: FAIL — expected: 3 actual: 5 at “approved again, raised nothing more”. Restore both; PASS.
Then change v.Kind == VacancyNew to true in the raise := line; run again. Expected: FAIL — vacancy_raise_only_new refuses the replacement’s approval. Restore; PASS.
- Step 8: Regenerate sqlc’s models and commit
Run: make generate (models.go gains Vacancy and EmployeeEvent.VacancyID).
git add db/migrations/00823_vacancy.sql internal/platform/hr internal/platform/workflow/model.go \ internal/kernel/db/retention_test.go internal/gen/sqlcgit commit -m "Phase 5 H2: the vacancy document and workflow; a new vacancy's approval raises headcount once
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"(If Step 4 edited internal/platform/trade/graphs_test.go, add it too.)
Task 7: Filling a vacancy by a hire, and closing one by hand
Section titled “Task 7: Filling a vacancy by a hire, and closing one by hand”Files:
- Modify:
internal/platform/hr/events.go(Event,eventColumns,scanEvent,insertEvent,HireInput,Hire),internal/platform/hr/errors.go(mapConstraint),internal/platform/hr/vacancy.go(appendCloseVacancy) - Test:
internal/platform/hr/fill_test.go
Interfaces:
-
Consumes: Task 6’s schema (
employee_event.vacancy_id,employee_event_vacancy_guard),lockVacancy,GetVacancy; H1’sinsertEvent,mapConstraint,checkReason. -
Produces:
hr.HireInput.VacancyID *uuid.UUID;hr.Event.VacancyID *uuid.UUID;mapConstraintmapsemployee_event_vacancy_open→hr.ErrVacancyNotOpen;hr.CloseVacancyInput;hr.CloseVacancy(ctx context.Context, tx pgx.Tx, c clock.Clock, in hr.CloseVacancyInput) (hr.Vacancy, error). -
Step 1: Write the failing tests
Create internal/platform/hr/fill_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")
// newPerson opens a record for someone with no login.func newPerson(t *testing.T, e *hrtest.Env, no string) uuid.UUID { t.Helper() var id uuid.UUID e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { emp, err := hr.Create(ctx, tx, e.Clock, hr.CreateInput{LegalName: "Pekerja " + no, EmployeeNo: no}) id = emp.ID return err }) return id}
func hireInto(e *hrtest.Env, employee, vacancy uuid.UUID) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, _, err := hr.Hire(ctx, tx, e.Clock, hr.HireInput{EmployeeID: employee, EffectiveOn: hrtest.Day(2026, 10, 1), EmploymentType: hr.TypeProbation, VacancyID: &vacancy, Actor: e.HR}) return err })}
func vacancyNow(t *testing.T, e *hrtest.Env, id uuid.UUID) hr.Vacancy { t.Helper() var v hr.Vacancy e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error v, err = hr.GetVacancy(ctx, tx, id) return err }) return v}
// Spec H2 "Filling" (Ruling H2-13): a hire names the vacancy it fills; the// vacancy reads filled once its count is met; one more is refused, as is a// hire into a vacancy not yet approved.func TestAHireFillsAnOpenVacancyUntilItsCountIsMet(t *testing.T) { e := hrtest.New(t) seat := e.Seat(t, "CLERK") draft := raise(t, e, seat, hr.VacancyNew, 2) v := approve(t, e, raise(t, e, seat, hr.VacancyNew, 2))
// PRESENCE: a hire into the open vacancy. require.NoError(t, hireInto(e, newPerson(t, e, "E-201"), v.ID)) got := vacancyNow(t, e, v.ID) require.Equal(t, 1, got.Filled) require.Equal(t, 1, got.Remaining()) require.Equal(t, hr.VacancyOpen, got.Status()) require.NoError(t, hireInto(e, newPerson(t, e, "E-202"), v.ID)) require.Equal(t, hr.VacancyFilled, vacancyNow(t, e, v.ID).Status(), "closed by its count")
// ABSENCE: one more, and a hire into a draft. require.ErrorIs(t, hireInto(e, newPerson(t, e, "E-203"), v.ID), hr.ErrVacancyNotOpen) require.ErrorIs(t, hireInto(e, newPerson(t, e, "E-204"), draft.ID), hr.ErrVacancyNotOpen)
require.Equal(t, 2, e.Count(t, `SELECT count(*) FROM employee_event WHERE org_id = $1 AND vacancy_id = $2 AND kind = 'hired'`, e.Org, v.ID)) var events []hr.Event e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { emp, err := hr.GetByNumber(ctx, tx, "E-201") if err != nil { return err } events, err = hr.Events(ctx, tx, emp.ID) return err }) require.Equal(t, v.ID, *events[0].VacancyID, "the hire names what it filled")}
// Spec H2 "closes … by hand with a reason (audited)".func TestAVacancyIsClosedByHandWithAReason(t *testing.T) { e := hrtest.New(t) seat := e.Seat(t, "CLERK") v := approve(t, e, raise(t, e, seat, hr.VacancyReplacement, 1)) closeIt := func(reason string) (hr.Vacancy, error) { var out hr.Vacancy err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { var err error out, err = hr.CloseVacancy(ctx, tx, e.Clock, hr.CloseVacancyInput{ID: v.ID, Reason: reason, Actor: e.HR}) return err }) return out, err } _, err := closeIt(" ") require.Equal(t, "reason/required", fieldRule(err))
got, err := closeIt("The role is no longer needed") require.NoError(t, err, "PRESENCE") require.Equal(t, hr.VacancyClosed, got.Status()) require.Equal(t, e.HR, *got.ClosedBy) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM audit_log WHERE entity_kind = 'vacancy' AND entity_id = $1 AND action = 'close' AND after->>'reason' = 'The role is no longer needed'`, v.ID))
// ABSENCE: closed once, and no hire fills it now. _, err = closeIt("Again") require.ErrorIs(t, err, hr.ErrVacancyNotOpen) require.ErrorIs(t, hireInto(e, newPerson(t, e, "E-301"), v.ID), hr.ErrVacancyNotOpen)}- Step 2: Run them to verify they fail
Run: go test ./internal/platform/hr/ -run 'TestAHireFills|TestAVacancyIsClosed' -count=1
Expected: FAIL to compile — unknown field VacancyID in struct literal of type hr.HireInput, undefined: hr.CloseVacancy.
- Step 3: Carry the vacancy through H1’s one event writer
In internal/platform/hr/events.go:
In type Event struct, after RecordedAt time.Time \json:“recorded_at”``, add:
// VacancyID is the vacancy a hire filled (Phase 5 H2, Ruling H2-13). VacancyID *uuid.UUID `json:"vacancy_id,omitempty"`Replace
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`with
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, vacancy_id`In scanEvent, replace
&ev.FromPositionID, &ev.ToPositionID, &ev.OverrideID, &ev.Reason, &ev.RecordedBy, &ev.RecordedAt); err != nil {with
&ev.FromPositionID, &ev.ToPositionID, &ev.OverrideID, &ev.Reason, &ev.RecordedBy, &ev.RecordedAt, &ev.VacancyID); err != nil {Replace insertEvent’s body with:
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, vacancy_id) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) 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(), ev.VacancyID)) if err != nil { return Event{}, fmt.Errorf("hr: record %s: %w", ev.Kind, mapConstraint(err)) } return out, nilIn type HireInput struct, after Reason string, add:
// VacancyID links the hire to the approved vacancy it fills (Phase 5 H2, // Ruling H2-13); the database refuses one that is not open. VacancyID *uuid.UUIDIn Hire, replace
EmploymentType: &in.EmploymentType, Reason: reason, RecordedBy: &in.Actor})(the one inside Hire, whose event kind is kind) with
EmploymentType: &in.EmploymentType, Reason: reason, RecordedBy: &in.Actor, VacancyID: in.VacancyID})In internal/platform/hr/errors.go, in mapConstraint’s switch pg.ConstraintName, add:
case "employee_event_vacancy_open": return ErrVacancyNotOpen- Step 4: Write the manual close
Append to internal/platform/hr/vacancy.go:
type CloseVacancyInput struct { ID uuid.UUID Reason string Actor uuid.UUID}
// CloseVacancy closes an open vacancy by hand with a reason, audited (spec// H2 "Filling"). A filled one is already closed by its count; a draft is// withdrawn through its workflow instead.func CloseVacancy(ctx context.Context, tx pgx.Tx, c clock.Clock, in CloseVacancyInput) (Vacancy, error) { org, err := orgOf(ctx) if err != nil { return Vacancy{}, err } reason, err := checkReason("reason", in.Reason, true) if err != nil { return Vacancy{}, err } v, err := lockVacancy(ctx, tx, org, in.ID) if err != nil { return Vacancy{}, err } if v.Status() != VacancyOpen { return Vacancy{}, ErrVacancyNotOpen } if _, err := tx.Exec(ctx, `UPDATE vacancy SET closed_at = $3, close_reason = $4, closed_by = $5 WHERE org_id = $1 AND id = $2`, org, v.ID, c.Now(), reason, in.Actor); err != nil { return Vacancy{}, fmt.Errorf("hr: close vacancy: %w", err) } after, err := GetVacancy(ctx, tx, v.ID) if err != nil { return Vacancy{}, err } if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityVacancy, EntityID: v.ID, Action: "close", Before: map[string]any{"status": VacancyOpen, "filled": v.Filled}, After: map[string]any{"status": VacancyClosed, "reason": reason}}); err != nil { return Vacancy{}, err } return after, nil}- Step 5: Run the tests
Run: go test ./internal/platform/hr/... -count=1
Expected: PASS (H1’s event tests included: vacancy_id is null on every event they write).
- Step 6: Watch the database guard fail once
In 00823’s employee_event_vacancy_guard(), change sem IS DISTINCT FROM 'committed' to false and run go test ./internal/platform/hr/ -run TestAHireFills -count=1. Expected: FAIL — Target error should be in err chain at the draft hire (it was accepted). Restore. Then delete OR filled >= v.quantity and run again. Expected: FAIL at “one more” (E-203 accepted). Restore; PASS.
- Step 7: Commit
git add internal/platform/hrgit commit -m "Phase 5 H2: a hire fills a vacancy until its count is met; manual close with a reason
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 8: The reports
Section titled “Task 8: The reports”Files:
- Create:
internal/platform/hr/report.go - Test:
internal/platform/hr/report_test.go
Interfaces:
-
Consumes: Task 5’s
orgstruct.Headcount,orgstruct.SetHeadcount,hr.SetPositionGrade;orgstruct.ListUnits,orgstruct.ListPositions; Task 3’sListGrades; Task 6’sListVacancies. -
Produces:
hr.SeatCount,hr.UnitCount,hr.OpenVacancy,hr.HeadcountReport(ctx context.Context, tx pgx.Tx, on time.Time) ([]hr.UnitCount, error),hr.OpenVacancies(ctx context.Context, tx pgx.Tx, today time.Time) ([]hr.OpenVacancy, error). -
Step 1: Write the failing tests
Create internal/platform/hr/report_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" "github.com/mcom/erp/internal/platform/orgstruct")
// Spec H2 "Reports", Ruling H2-16: approved, filled and vacant per seat and// per unit (the sum of its own seats'); an acting holder fills nothing.func TestTheHeadcountReportCountsPrimaryHoldersPerSeatAndUnit(t *testing.T) { e := hrtest.New(t) today := hrtest.Day(2026, 9, 21) seat := e.Seat(t, "CLERK") g, err := createGrade(e, hr.GradeInput{Code: "E1", Names: names(), Rank: 10}) require.NoError(t, err) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { if _, err := orgstruct.SetHeadcount(ctx, tx, e.Clock, seat, 2); err != nil { return err } _, err := hr.SetPositionGrade(ctx, tx, e.Clock, seat, &g.ID) return err }) _, err = assignTo(e, orgstruct.AssignmentInput{MemberID: e.Newcomer(t, "Aminah"), PositionID: seat, IsPrimary: true, ValidFrom: today}) require.NoError(t, err) _, err = assignTo(e, orgstruct.AssignmentInput{MemberID: e.Staff, PositionID: seat, ValidFrom: today}) require.NoError(t, err) approve(t, e, raise(t, e, seat, hr.VacancyNew, 1)) // approved 3
report := func() (hr.UnitCount, hr.SeatCount) { var units []hr.UnitCount e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error units, err = hr.HeadcountReport(ctx, tx, today) return err }) for _, u := range units { for _, s := range u.Positions { if s.PositionID == seat { return u, s } } } t.Fatal("the seat is not in the report") return hr.UnitCount{}, hr.SeatCount{} } u, s := report() require.Equal(t, 3, s.Approved) require.Equal(t, 1, s.Filled, "the acting holder does not count") require.Equal(t, 2, s.Vacant) require.Zero(t, s.Over) require.Equal(t, 1, s.OpenVacancies) require.Equal(t, "E1", *s.GradeCode) var approved, filled int for _, p := range u.Positions { approved, filled = approved+p.Approved, filled+p.Filled } require.Equal(t, approved, u.Approved, "a unit's approved headcount is its seats' sum") require.Equal(t, filled, u.Filled)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := orgstruct.SetHeadcount(ctx, tx, e.Clock, seat, 0) return err }) _, s = report() require.Zero(t, s.Vacant) require.Equal(t, 1, s.Over, "lowered below what it holds")}
// Spec H2 "open vacancies and their age".func TestOpenVacanciesCarryTheirAge(t *testing.T) { e := hrtest.New(t) seat := e.Seat(t, "CLERK") open := approve(t, e, raise(t, e, seat, hr.VacancyReplacement, 2)) // approved 2026-09-21, needed by 2026-09-30 raise(t, e, seat, hr.VacancyNew, 1) // a draft: not open var rows []hr.OpenVacancy e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error rows, err = hr.OpenVacancies(ctx, tx, hrtest.Day(2026, 10, 1)) return err }) require.Len(t, rows, 1) require.Equal(t, open.ID, rows[0].ID) require.Equal(t, 10, rows[0].AgeDays) require.True(t, rows[0].Overdue) require.Equal(t, 2, rows[0].Remaining()) require.Equal(t, "CLERK", rows[0].PositionCode)}- Step 2: Run them to verify they fail
Run: go test ./internal/platform/hr/ -run 'TestTheHeadcountReport|TestOpenVacancies' -count=1
Expected: FAIL to compile — undefined: hr.HeadcountReport, hr.OpenVacancies.
- Step 3: Write the reports
Create internal/platform/hr/report.go:
package hr
import ( "context" "sort" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/platform/orgstruct")
// SeatCount is one active seat's headcount on a day (Ruling H2-16).type SeatCount struct { PositionID uuid.UUID Code, Title string GradeID *uuid.UUID GradeCode *string Approved int Filled int Vacant int // max(approved − filled, 0) Over int // max(filled − approved, 0) OpenVacancies int}
// UnitCount is a unit's own seats and their sums (spec H2: "the sum of its// positions'"; not its subtree's).type UnitCount struct { UnitID uuid.UUID Code, Name string Approved, Filled, Vacant, Over int Positions []SeatCount}
// HeadcountReport is every unit that has an active seat, in the unit// tree's order, with its seats on day on.func HeadcountReport(ctx context.Context, tx pgx.Tx, on time.Time) ([]UnitCount, error) { seats, err := orgstruct.Headcount(ctx, tx, on, nil) if err != nil { return nil, err } units, err := orgstruct.ListUnits(ctx, tx) if err != nil { return nil, err } grades, err := ListGrades(ctx, tx, false) if err != nil { return nil, err } open, err := ListVacancies(ctx, tx, VacancyFilter{Status: VacancyOpen}) if err != nil { return nil, err } gradeCode := map[uuid.UUID]string{} for _, g := range grades { gradeCode[g.ID] = g.Code } openAt := map[uuid.UUID]int{} for _, v := range open { openAt[v.PositionID]++ } byUnit := map[uuid.UUID]*UnitCount{} for _, u := range units { byUnit[u.ID] = &UnitCount{UnitID: u.ID, Code: u.Code, Name: u.Name, Positions: []SeatCount{}} } for _, s := range seats { u := byUnit[s.UnitID] if !s.IsActive || u == nil { continue } sc := SeatCount{PositionID: s.PositionID, Code: s.Code, Title: s.Title, GradeID: s.GradeID, Approved: s.Approved, Filled: s.Filled, Vacant: max(s.Approved-s.Filled, 0), Over: max(s.Filled-s.Approved, 0), OpenVacancies: openAt[s.PositionID]} if s.GradeID != nil { code := gradeCode[*s.GradeID] sc.GradeCode = &code } u.Positions = append(u.Positions, sc) u.Approved, u.Filled, u.Vacant, u.Over = u.Approved+sc.Approved, u.Filled+sc.Filled, u.Vacant+sc.Vacant, u.Over+sc.Over } out := []UnitCount{} for _, u := range units { if c := byUnit[u.ID]; len(c.Positions) > 0 { out = append(out, *c) } } return out, nil}
// OpenVacancy is an open vacancy with its seat and its age in days since// approval (Ruling H2-16).type OpenVacancy struct { Vacancy PositionCode string PositionTitle string UnitID uuid.UUID AgeDays int Overdue bool}
// OpenVacancies is every open vacancy, oldest approval first.func OpenVacancies(ctx context.Context, tx pgx.Tx, today time.Time) ([]OpenVacancy, error) { vs, err := ListVacancies(ctx, tx, VacancyFilter{Status: VacancyOpen}) if err != nil { return nil, err } ps, err := orgstruct.ListPositions(ctx, tx) if err != nil { return nil, err } seat := map[uuid.UUID]orgstruct.Position{} for _, p := range ps { seat[p.ID] = p } out := make([]OpenVacancy, 0, len(vs)) for _, v := range vs { p := seat[v.PositionID] out = append(out, OpenVacancy{Vacancy: v, PositionCode: p.Code, PositionTitle: p.Title, UnitID: p.OrgUnitID, AgeDays: int(today.Sub(v.EnteredStateOn).Hours() / 24), Overdue: v.NeededBy.Before(today)}) } sort.SliceStable(out, func(i, j int) bool { return out[i].EnteredStateOn.Before(out[j].EnteredStateOn) }) return out, nil}- Step 4: Run the tests
Run: go test ./internal/platform/hr/ -count=1
Expected: PASS.
- Step 5: Watch it fail once
In HeadcountReport, change Filled: s.Filled to Filled: s.Filled + 1 and run go test ./internal/platform/hr/ -run TestTheHeadcountReport -count=1. Expected: FAIL — expected: 1 actual: 2 at “the acting holder does not count”. Restore; PASS.
- Step 6: Commit
git add internal/platform/hrgit commit -m "Phase 5 H2: headcount per unit and seat, and open vacancies with their age
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 9: The HTTP surface, permissions and Owner grants (00824)
Section titled “Task 9: The HTTP surface, permissions and Owner grants (00824)”Files:
- Modify:
constants.yaml(permissions), thengo run ./tools/vocabgen - Modify:
api/openapi.yaml, thenmake generate - Create:
internal/modules/hr/planning.go; Modify:internal/modules/hr/{permissions,routes,errors,handlers}.go - Test:
internal/modules/hr/planning_test.go,internal/modules/hr/planning_errors_test.go; Modify:internal/modules/hr/routes_test.go - Create:
db/migrations/00824_hr_planning_grants.sql,internal/platform/hr/migration00824_test.go - Modify:
internal/app/routes_test.go
Interfaces:
-
Consumes: every
platformhrfunction of Tasks 3–8;orgstruct.SetHeadcount,orgstruct.ErrOverHeadcount,orgstruct.ErrNegativeHeadcount;workflow.AvailableTransitions,workflow.Available,workflow.RefusedError,workflow.SubmissionRefusedError,workflow.ErrStaleState,workflow.ErrNoSuchEdge,workflow.ErrReasonRequired,workflow.ErrSoleOwnerReasonRequired,workflow.ErrNoDefinition; H1’s module helpersdecodeStrict,parseID,parseDate,dateStr,dateLayout,mustActor,ifMatch,setETag,firstErr,coded,mapErr,handlers,permissionLoader, and its test helpersnewWorld,(*world).member,(*world).do,jsonField. -
Produces: module constants
hr.PermissionGradeManage,hr.PermissionHeadcountManage,hr.PermissionHeadcountOverride,hr.PermissionVacancyRead,hr.PermissionVacancyRequest,hr.PermissionVacancyClose,hr.PermissionReportRead; routesGET|POST /v1/hr/grades,PUT /v1/hr/grades/{id},POST /v1/hr/grades/{id}/bands,PUT /v1/hr/positions/{id}/grade,PUT /v1/hr/positions/{id}/headcount,POST /v1/hr/headcount-overrides,GET|POST /v1/hr/vacancies,GET /v1/hr/vacancies/{id},POST /v1/hr/vacancies/{id}/transitions,POST /v1/hr/vacancies/{id}/close,GET /v1/hr/reports/headcount,GET /v1/hr/reports/open-vacancies; schemasGradeNames,GradeBand,JobGrade,JobGradeCreate,JobGradeUpdate,GradeBandInput,PositionGradeInput,PositionHeadcountInput,PositionHeadcount,HeadcountOverrideInput,HeadcountOverride,VacancyKind,VacancyStatus,Vacancy,VacancyView,VacancyCreate,VacancyTransitionInput,VacancyCloseInput,HeadcountSeat,HeadcountUnit,HeadcountReport,OpenVacancy;EmployeeEvent.vacancy_id,EmployeeEventInput.vacancy_id; refusal codesover_headcount,within_headcount,override_by_requester(headcount),duplicate_grade_code,grade_retired,band_overlap,vacancy_not_open,stale_state,workflow_missing,sole_owner_reason_required,not_routable, and each workflow refusal’s own code. -
Step 1: Write the failing module tests
In internal/modules/hr/routes_test.go, add to the map in TestEveryRouteRequiresItsExactPermission, after the "POST /v1/hr/employees/:id/leaving-overrides" entry:
"GET /v1/hr/grades": hr.PermissionGradeManage, "POST /v1/hr/grades": hr.PermissionGradeManage, "PUT /v1/hr/grades/:id": hr.PermissionGradeManage, "POST /v1/hr/grades/:id/bands": hr.PermissionGradeManage, "PUT /v1/hr/positions/:id/grade": hr.PermissionGradeManage, "PUT /v1/hr/positions/:id/headcount": hr.PermissionHeadcountManage, "POST /v1/hr/headcount-overrides": hr.PermissionHeadcountOverride, "GET /v1/hr/vacancies": hr.PermissionVacancyRead, "POST /v1/hr/vacancies": hr.PermissionVacancyRequest, "GET /v1/hr/vacancies/:id": hr.PermissionVacancyRead, "POST /v1/hr/vacancies/:id/transitions": hr.PermissionVacancyRead, // the engine decides who moves it "POST /v1/hr/vacancies/:id/close": hr.PermissionVacancyClose, "GET /v1/hr/reports/headcount": hr.PermissionReportRead, "GET /v1/hr/reports/open-vacancies": hr.PermissionReportRead,and after its last require.Equal(t, "hr.employee.lifecycle_override", …):
require.Equal(t, "hr.grade.manage", hr.PermissionGradeManage) require.Equal(t, "hr.headcount.manage", hr.PermissionHeadcountManage) require.Equal(t, "hr.headcount.override", hr.PermissionHeadcountOverride) require.Equal(t, "hr.vacancy.read", hr.PermissionVacancyRead) require.Equal(t, "hr.vacancy.request", hr.PermissionVacancyRequest) require.Equal(t, "hr.vacancy.close", hr.PermissionVacancyClose) require.Equal(t, "hr.report.read", hr.PermissionReportRead)Create internal/modules/hr/planning_test.go:
package hr_test
import ( "context" "net/http" "testing"
"github.com/google/uuid" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/authn" "github.com/mcom/erp/internal/modules/hr")
// token mints a token for an existing member, giving them exactly keys more.func (w *world) token(t *testing.T, memberID uuid.UUID, keys ...string) string { t.Helper() ctx, admin := context.Background(), w.e.H.Admin 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 tok}
// Spec H2 over HTTP: grades and bands (money as strings), a seat's grade, a// vacancy from request to the Owner's approval raising headcount, a hire// filling it, and the two reports.func TestTheGradeVacancyAndReportRoutes(t *testing.T) { w := newWorld(t) _, grader := w.member(t, hr.PermissionGradeManage) _, request := w.member(t, hr.PermissionVacancyRead, hr.PermissionVacancyRequest, hr.PermissionReportRead, hr.PermissionManage) _, reader := w.member(t, hr.PermissionVacancyRead) owner := w.token(t, w.e.Owner, hr.PermissionVacancyRead)
res := w.do(t, "POST", "/v1/hr/grades", `{"code":"e1","names":{"en":"Executive","ms":"Eksekutif"},"rank":10}`, grader, "") require.Equal(t, http.StatusCreated, res.Code, res.Body.String()) grade := jsonField(t, res, "id") require.Equal(t, `"1"`, res.Header().Get("ETag")) band := w.do(t, "POST", "/v1/hr/grades/"+grade+"/bands", `{"effective_from":"2026-01-01","min_salary":"2500.00","max_salary":"4000.00"}`, grader, "") require.Equal(t, http.StatusCreated, band.Code, band.Body.String()) require.Contains(t, band.Body.String(), `"max_salary":"4000.0000"`, "money is a string on the wire") require.Equal(t, http.StatusForbidden, w.do(t, "GET", "/v1/hr/grades", "", reader, "").Code) stale := w.do(t, "PUT", "/v1/hr/grades/"+grade, `{"names":{"en":"Executive","ms":"Eksekutif"},"rank":11,"is_active":true}`, grader, `"9"`) require.Equal(t, http.StatusConflict, stale.Code) require.Contains(t, stale.Body.String(), "revision_mismatch")
seat := w.e.StaffSeat.String() set := w.do(t, "PUT", "/v1/hr/positions/"+seat+"/grade", `{"grade_id":"`+grade+`"}`, grader, "") require.Equal(t, http.StatusOK, set.Code, set.Body.String())
v := w.do(t, "POST", "/v1/hr/vacancies", `{"position_id":"`+seat+`","kind":"new","quantity":1,`+ `"needed_by":"2026-12-31","reason":"Second clerk for the branch"}`, request, "") require.Equal(t, http.StatusCreated, v.Code, v.Body.String()) id := jsonField(t, v, "id") require.Contains(t, v.Body.String(), `"status":"draft"`) tr := "/v1/hr/vacancies/" + id + "/transitions" require.Equal(t, http.StatusOK, w.do(t, "POST", tr, `{"from":"draft","to":"pending_approval"}`, request, "").Code) ok := w.do(t, "POST", tr, `{"from":"pending_approval","to":"approved"}`, owner, "") require.Equal(t, http.StatusOK, ok.Code, "PRESENCE: the OWNER seat approves: "+ok.Body.String()) require.Contains(t, ok.Body.String(), `"status":"open"`) require.Contains(t, ok.Body.String(), `"headcount_raised":true`)
report := w.do(t, "GET", "/v1/hr/reports/headcount", "", request, "") require.Equal(t, http.StatusOK, report.Code) require.Contains(t, report.Body.String(), `"grade_code":"E1"`) require.Contains(t, report.Body.String(), `"approved":2`) require.Contains(t, w.do(t, "GET", "/v1/hr/reports/open-vacancies", "", request, "").Body.String(), id)
emp := w.do(t, "POST", "/v1/hr/employees", `{"legal_name":"Siti binti Ahmad","employee_no":"E-900"}`, request, "") require.Equal(t, http.StatusCreated, emp.Code, emp.Body.String()) hire := w.do(t, "POST", "/v1/hr/employees/"+jsonField(t, emp, "id")+"/events", `{"kind":"hired","effective_on":"2026-10-01","employment_type":"probation","vacancy_id":"`+id+`"}`, request, "") require.Equal(t, http.StatusCreated, hire.Code, hire.Body.String()) require.Contains(t, hire.Body.String(), `"vacancy_id":"`+id+`"`) require.Contains(t, w.do(t, "GET", "/v1/hr/vacancies/"+id, "", reader, "").Body.String(), `"status":"filled"`) require.JSONEq(t, `{"vacancies":[]}`, w.do(t, "GET", "/v1/hr/reports/open-vacancies", "", request, "").Body.String())}
// A claimant never approves their own vacancy (the engine's refusal, 403// with its code).func TestTheRequesterCannotApproveTheirVacancy(t *testing.T) { w := newWorld(t) _, request := w.member(t, hr.PermissionVacancyRead, hr.PermissionVacancyRequest) v := w.do(t, "POST", "/v1/hr/vacancies", `{"position_id":"`+w.e.StaffSeat.String()+`","kind":"replacement",`+ `"quantity":1,"needed_by":"2026-12-31","reason":"Replacing a leaver"}`, request, "") require.Equal(t, http.StatusCreated, v.Code, v.Body.String()) tr := "/v1/hr/vacancies/" + jsonField(t, v, "id") + "/transitions" require.Equal(t, http.StatusOK, w.do(t, "POST", tr, `{"from":"draft","to":"pending_approval"}`, request, "").Code) refused := w.do(t, "POST", tr, `{"from":"pending_approval","to":"approved"}`, request, "") require.Equal(t, http.StatusForbidden, refused.Code, refused.Body.String())}Create internal/modules/hr/planning_errors_test.go:
package hr
import ( "encoding/json" "testing"
"github.com/stretchr/testify/require"
apperr "github.com/mcom/erp/internal/kernel/errors" p "github.com/mcom/erp/internal/platform/hr" "github.com/mcom/erp/internal/platform/orgstruct" "github.com/mcom/erp/internal/platform/workflow")
// Every H2 refusal a screen shows is named by a stable code.func TestTheH2RefusalsCarryTheirCodes(t *testing.T) { cases := map[error]string{ orgstruct.ErrOverHeadcount: "over_headcount", p.ErrHeadcountOverrideByRequester: "override_by_requester", p.ErrWithinHeadcount: "within_headcount", p.ErrDuplicateGradeCode: "duplicate_grade_code", p.ErrGradeRetired: "grade_retired", p.ErrBandOverlap: "band_overlap", p.ErrVacancyNotOpen: "vacancy_not_open", workflow.ErrStaleState: "stale_state", workflow.ErrNoDefinition: "workflow_missing", workflow.ErrSoleOwnerReasonRequired: "sole_owner_reason_required", &workflow.RefusedError{Refusal: workflow.Refusal{Code: "claimant_excluded"}}: "claimant_excluded", } 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) }}- Step 2: Run them to verify they fail
Run: go test ./internal/modules/hr/ -count=1
Expected: FAIL to compile — undefined: hr.PermissionGradeManage, ….
- Step 3: Declare the permissions and the vocabulary
In constants.yaml, append to permissions after - hr.employee.lifecycle_override:
- hr.grade.manage - hr.headcount.manage - hr.headcount.override - hr.vacancy.read - hr.vacancy.request - hr.vacancy.close - hr.report.readRun: go run ./tools/vocabgen
Expected: internal/kernel/vocab/vocab.gen.go and packages/contracts/vocab.ts list the seven keys.
In internal/modules/hr/permissions.go, add to the const (…) block:
// Phase 5 H2 (Ruling H2-17): Owner-only by default, like H1's. PermissionGradeManage = platformhr.PermissionGradeManage PermissionHeadcountManage = platformhr.PermissionHeadcountManage PermissionHeadcountOverride = platformhr.PermissionHeadcountOverride PermissionVacancyRead = platformhr.PermissionVacancyRead PermissionVacancyRequest = platformhr.PermissionVacancyRequest PermissionVacancyClose = platformhr.PermissionVacancyClose PermissionReportRead = platformhr.PermissionReportReadand to init():
member.RegisterPermission(PermissionGradeManage, "Keep job grades, their salary bands and each position's grade") member.RegisterPermission(PermissionHeadcountManage, "Set each position's approved headcount") member.RegisterPermission(PermissionHeadcountOverride, "Allow a position to take one person beyond its approved headcount at another member's request, with a reason") member.RegisterPermission(PermissionVacancyRead, "See vacancies and move them through their approval") member.RegisterPermission(PermissionVacancyRequest, "Request a vacancy") member.RegisterPermission(PermissionVacancyClose, "Close an open vacancy by hand, with a reason") member.RegisterPermission(PermissionReportRead, "Read the headcount and open-vacancy reports")- Step 4: Map the H2 refusals
In internal/modules/hr/errors.go, add to mapErr’s var (…) block:
refused *workflow.RefusedError unroutable *workflow.SubmissionRefusedErrorand before the switch’s closing brace (after the orgstruct.ErrNotFound, orgstruct.ErrPositionInactive case):
// Phase 5 H2. case errors.Is(err, orgstruct.ErrOverHeadcount): return coded(apperr.KindConflict, "over_headcount", "the position is at its approved headcount; an Owner other than you can allow one more") case errors.Is(err, orgstruct.ErrNegativeHeadcount): return apperr.New(apperr.KindValidation, "field=approved_headcount rule=nonnegative: an approved headcount is 0 or more"). With("field", "approved_headcount") case errors.Is(err, p.ErrHeadcountOverrideByRequester): return coded(apperr.KindForbidden, "override_by_requester", "an Owner other than the member who asked must allow this") case errors.Is(err, p.ErrWithinHeadcount): return coded(apperr.KindConflict, "within_headcount", "the position has room for this; no override is needed") case errors.Is(err, p.ErrGradeNotFound): return apperr.New(apperr.KindNotFound, "no such grade in this organization") case errors.Is(err, p.ErrDuplicateGradeCode): return coded(apperr.KindConflict, "duplicate_grade_code", "another grade has this code").With("field", "code") case errors.Is(err, p.ErrGradeRetired): return coded(apperr.KindConflict, "grade_retired", "the grade is retired") case errors.Is(err, p.ErrBandOverlap): return coded(apperr.KindConflict, "band_overlap", "the band overlaps another band of this grade"). With("field", "effective_from") case errors.Is(err, p.ErrVacancyNotFound): return apperr.New(apperr.KindNotFound, "no such vacancy in this organization") case errors.Is(err, p.ErrVacancyNotOpen): return coded(apperr.KindConflict, "vacancy_not_open", "the vacancy is not open: not approved, filled or closed"). With("field", "vacancy_id") case errors.Is(err, workflow.ErrStaleState): return coded(apperr.KindConflict, "stale_state", "the vacancy is no longer in the state you sent as from; reload it and retry") case errors.Is(err, workflow.ErrNoSuchEdge): return apperr.New(apperr.KindValidation, "to names no transition out of the current state") case errors.Is(err, workflow.ErrSoleOwnerReasonRequired): return apperr.New(apperr.KindValidation, "field=reason rule=required: as the organization's only member you may do this yourself, with a reason"). With("code", "sole_owner_reason_required") case errors.Is(err, workflow.ErrReasonRequired): return apperr.New(apperr.KindValidation, "field=reason rule=required: this transition requires a reason"). With("field", "reason") case errors.As(err, &refused): return coded(apperr.KindForbidden, refused.Refusal.Code, "refused: "+refused.Refusal.Code+": "+refused.Refusal.Detail) case errors.As(err, &unroutable): return coded(apperr.KindValidation, "not_routable", err.Error()) case errors.Is(err, workflow.ErrNoDefinition): return coded(apperr.KindConflict, "workflow_missing", "vacancies are not set up in this organization yet; an administrator re-runs provisioning")- Step 5: Carry
vacancy_idthrough H1’s lifecycle endpoint
In internal/modules/hr/handlers.go:
In type eventDTO struct, after RecordedAt time.Time \json:“recorded_at”`, add VacancyID *uuid.UUID `json:“vacancy_id”`; in toEventDTO, add VacancyID: ev.VacancyID,` to the literal.
In recordEvent’s var body struct, after Reason string \json:“reason”`, add VacancyID *uuid.UUID `json:“vacancy_id”`. After the on, err := parseDate(…)` block, add:
if body.VacancyID != nil && body.Kind != platformhr.EventHired { _ = c.Error(apperr.New(apperr.KindValidation, "field=vacancy_id rule=hire_only: only a hire fills a vacancy"). With("field", "vacancy_id")) return }and in the case platformhr.EventHired: literal, change EmploymentType: body.EmploymentType, Reason: body.Reason, Actor: who}) to EmploymentType: body.EmploymentType, Reason: body.Reason, VacancyID: body.VacancyID, Actor: who}).
- Step 6: Write the H2 handlers
In internal/modules/hr/routes.go, add at the end of Register:
// Phase 5 H2 (Ruling H2-17). httpx.Protected(g, pl, "GET", "/hr/grades", PermissionGradeManage, h.listGrades) httpx.Protected(g, pl, "POST", "/hr/grades", PermissionGradeManage, h.createGrade) httpx.Protected(g, pl, "PUT", "/hr/grades/:id", PermissionGradeManage, h.updateGrade) httpx.Protected(g, pl, "POST", "/hr/grades/:id/bands", PermissionGradeManage, h.addBand) httpx.Protected(g, pl, "PUT", "/hr/positions/:id/grade", PermissionGradeManage, h.setPositionGrade) httpx.Protected(g, pl, "PUT", "/hr/positions/:id/headcount", PermissionHeadcountManage, h.setPositionHeadcount) httpx.Protected(g, pl, "POST", "/hr/headcount-overrides", PermissionHeadcountOverride, h.grantHeadcountOverride) httpx.Protected(g, pl, "GET", "/hr/vacancies", PermissionVacancyRead, h.listVacancies) httpx.Protected(g, pl, "POST", "/hr/vacancies", PermissionVacancyRequest, h.createVacancy) httpx.Protected(g, pl, "GET", "/hr/vacancies/:id", PermissionVacancyRead, h.getVacancy) // The engine decides who may move a vacancy (grants, the reporter, the // sole-owner exception); the route only admits the vacancy's readers. httpx.Protected(g, pl, "POST", "/hr/vacancies/:id/transitions", PermissionVacancyRead, h.transitionVacancy) httpx.Protected(g, pl, "POST", "/hr/vacancies/:id/close", PermissionVacancyClose, h.closeVacancy) httpx.Protected(g, pl, "GET", "/hr/reports/headcount", PermissionReportRead, h.headcountReport) httpx.Protected(g, pl, "GET", "/hr/reports/open-vacancies", PermissionReportRead, h.openVacanciesReport)Create internal/modules/hr/planning.go:
package hr
import ( "context" "errors" "fmt" "net/http" "time"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/money" platformhr "github.com/mcom/erp/internal/platform/hr" "github.com/mcom/erp/internal/platform/orgstruct" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
// Phase 5 H2: grades, headcount, vacancies and the reports. Every rule and// audit row is platform/hr's (or orgstruct's); this file parses, runs one// transaction, and shapes the answer.
type bandDTO struct { ID uuid.UUID `json:"id"` EffectiveFrom string `json:"effective_from"` EffectiveTo *string `json:"effective_to"` MinSalary string `json:"min_salary"` MaxSalary string `json:"max_salary"`}
type gradeDTO struct { ID uuid.UUID `json:"id"` Code string `json:"code"` Names map[string]string `json:"names"` Rank int `json:"rank"` IsActive bool `json:"is_active"` Revision int `json:"revision"` Bands []bandDTO `json:"bands"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"`}
func toGradeDTO(g platformhr.Grade) gradeDTO { d := gradeDTO{ID: g.ID, Code: g.Code, Names: g.Names, Rank: g.Rank, IsActive: g.IsActive, Revision: g.Revision, Bands: make([]bandDTO, 0, len(g.Bands)), CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt} for _, b := range g.Bands { d.Bands = append(d.Bands, bandDTO{ID: b.ID, EffectiveFrom: b.EffectiveFrom.Format(dateLayout), EffectiveTo: dateStr(b.EffectiveTo), MinSalary: b.MinSalary.String(), MaxSalary: b.MaxSalary.String()}) } return d}
type seatDTO struct { ID uuid.UUID `json:"id"` Code string `json:"code"` Title string `json:"title"` OrgUnitID uuid.UUID `json:"org_unit_id"` GradeID *uuid.UUID `json:"grade_id"` ApprovedHeadcount int `json:"approved_headcount"` IsActive bool `json:"is_active"`}
func toSeatDTO(p orgstruct.Position) seatDTO { return seatDTO{ID: p.ID, Code: p.Code, Title: p.Title, OrgUnitID: p.OrgUnitID, GradeID: p.GradeID, ApprovedHeadcount: p.ApprovedHeadcount, IsActive: p.IsActive}}
type headcountOverrideDTO struct { ID uuid.UUID `json:"id"` PositionID uuid.UUID `json:"position_id"` MemberID uuid.UUID `json:"member_id"` ValidFrom string `json:"valid_from"` RequestedBy uuid.UUID `json:"requested_by"` Reason string `json:"reason"` Approved int `json:"approved_headcount"` Filled int `json:"filled"` GrantedBy uuid.UUID `json:"granted_by"` SoleOwner bool `json:"sole_owner"` CreatedAt time.Time `json:"created_at"`}
type vacancyDTO struct { ID uuid.UUID `json:"id"` PositionID uuid.UUID `json:"position_id"` Kind string `json:"kind"` Quantity int `json:"quantity"` Filled int `json:"filled"` Remaining int `json:"remaining"` NeededBy string `json:"needed_by"` Reason string `json:"reason"` RequestedBy uuid.UUID `json:"requested_by"` State string `json:"state"` Semantic string `json:"semantic"` Status string `json:"status"` ApprovedOn *string `json:"approved_on"` HeadcountRaised bool `json:"headcount_raised"` ClosedAt *time.Time `json:"closed_at"` CloseReason *string `json:"close_reason"` ClosedBy *uuid.UUID `json:"closed_by"` CreatedAt time.Time `json:"created_at"`}
func toVacancyDTO(v platformhr.Vacancy) vacancyDTO { d := vacancyDTO{ID: v.ID, PositionID: v.PositionID, Kind: v.Kind, Quantity: v.Quantity, Filled: v.Filled, Remaining: v.Remaining(), NeededBy: v.NeededBy.Format(dateLayout), Reason: v.Reason, RequestedBy: v.RequestedBy, State: v.State, Semantic: string(v.Semantic), Status: v.Status(), HeadcountRaised: v.HeadcountRaisedAt != nil, ClosedAt: v.ClosedAt, CloseReason: v.CloseReason, ClosedBy: v.ClosedBy, CreatedAt: v.CreatedAt} if v.Semantic == posting.Committed { on := v.EnteredStateOn.Format(dateLayout) d.ApprovedOn = &on } return d}
type vacancyViewDTO struct { vacancyDTO AvailableTransitions []workflow.Available `json:"available_transitions"`}
type seatCountDTO struct { PositionID uuid.UUID `json:"position_id"` Code string `json:"code"` Title string `json:"title"` GradeID *uuid.UUID `json:"grade_id"` GradeCode *string `json:"grade_code"` Approved int `json:"approved"` Filled int `json:"filled"` Vacant int `json:"vacant"` Over int `json:"over"` OpenVacancies int `json:"open_vacancies"`}
type unitCountDTO struct { UnitID uuid.UUID `json:"unit_id"` Code string `json:"code"` Name string `json:"name"` Approved int `json:"approved"` Filled int `json:"filled"` Vacant int `json:"vacant"` Over int `json:"over"` Positions []seatCountDTO `json:"positions"`}
type openVacancyDTO struct { vacancyDTO PositionCode string `json:"position_code"` PositionTitle string `json:"position_title"` UnitID uuid.UUID `json:"unit_id"` AgeDays int `json:"age_days"` Overdue bool `json:"overdue"`}
// inTx runs fn in one transaction as the calling member; false when it// recorded an error for the response.func (h handlers) inTx(c *gin.Context, fn func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error) bool { who, err := mustActor(c.Request.Context()) if err != nil { _ = c.Error(err) return false } if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { return fn(ctx, tx, who) }); err != nil { var rev *platformhr.RevisionMismatchError if errors.As(err, &rev) { // a grade's revision; H1's wording names the employee _ = c.Error(coded(apperr.KindConflict, "revision_mismatch", fmt.Sprintf("the record is at revision %d; reload and retry", rev.Current))) return false } _ = c.Error(mapErr(err)) return false } return true}
func parseAmount(field, s string) (money.Amount, error) { a, err := money.Parse(s) if err != nil { return money.Amount{}, apperr.New(apperr.KindValidation, "field="+field+" rule=amount: an amount is a decimal string, as 2500.00"). With("field", field) } return a, nil}
func (h handlers) listGrades(c *gin.Context) { out := []gradeDTO{} if h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { gs, err := platformhr.ListGrades(ctx, tx, c.Query("active") == "true") for _, g := range gs { out = append(out, toGradeDTO(g)) } return err }) { c.JSON(http.StatusOK, gin.H{"grades": out}) }}
func (h handlers) createGrade(c *gin.Context) { var body struct { Code string `json:"code"` Names map[string]string `json:"names"` Rank int `json:"rank"` } if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } var g platformhr.Grade if h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { var err error g, err = platformhr.CreateGrade(ctx, tx, h.deps.Clock, platformhr.GradeInput{Code: body.Code, Names: platformhr.Names(body.Names), Rank: body.Rank}) return err }) { setETag(c, g.Revision) c.JSON(http.StatusCreated, toGradeDTO(g)) }}
func (h handlers) updateGrade(c *gin.Context) { id, err := parseID(c, "id") rev, err2 := ifMatch(c) var body struct { Names map[string]string `json:"names"` Rank int `json:"rank"` IsActive bool `json:"is_active"` } if err := firstErr(err, err2, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } var g platformhr.Grade if h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { var err error g, err = platformhr.UpdateGrade(ctx, tx, h.deps.Clock, id, rev, platformhr.GradeUpdate{ Names: platformhr.Names(body.Names), Rank: body.Rank, IsActive: body.IsActive}) return err }) { setETag(c, g.Revision) c.JSON(http.StatusOK, toGradeDTO(g)) }}
func (h handlers) addBand(c *gin.Context) { id, err := parseID(c, "id") var body struct { EffectiveFrom string `json:"effective_from"` MinSalary string `json:"min_salary"` MaxSalary string `json:"max_salary"` } if err := firstErr(err, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } from, err := parseDate("effective_from", body.EffectiveFrom, true) minS, err2 := parseAmount("min_salary", body.MinSalary) maxS, err3 := parseAmount("max_salary", body.MaxSalary) if err := firstErr(err, err2, err3); err != nil { _ = c.Error(err) return } var g platformhr.Grade if h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { if _, err := platformhr.AddBand(ctx, tx, h.deps.Clock, platformhr.BandInput{GradeID: id, EffectiveFrom: *from, MinSalary: minS, MaxSalary: maxS}); err != nil { return err } var err error g, err = platformhr.GetGrade(ctx, tx, id) return err }) { setETag(c, g.Revision) c.JSON(http.StatusCreated, toGradeDTO(g)) }}
func (h handlers) setPositionGrade(c *gin.Context) { id, err := parseID(c, "id") var body struct { GradeID *uuid.UUID `json:"grade_id"` } if err := firstErr(err, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } var p orgstruct.Position if h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { var err error p, err = platformhr.SetPositionGrade(ctx, tx, h.deps.Clock, id, body.GradeID) return err }) { c.JSON(http.StatusOK, toSeatDTO(p)) }}
func (h handlers) setPositionHeadcount(c *gin.Context) { id, err := parseID(c, "id") var body struct { ApprovedHeadcount *int `json:"approved_headcount"` } if err := firstErr(err, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } if body.ApprovedHeadcount == nil { _ = c.Error(apperr.New(apperr.KindValidation, "field=approved_headcount rule=required: give the approved headcount"). With("field", "approved_headcount")) return } var p orgstruct.Position if h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { var err error p, err = orgstruct.SetHeadcount(ctx, tx, h.deps.Clock, id, *body.ApprovedHeadcount) return err }) { c.JSON(http.StatusOK, toSeatDTO(p)) }}
func (h handlers) grantHeadcountOverride(c *gin.Context) { var body struct { PositionID uuid.UUID `json:"position_id"` MemberID uuid.UUID `json:"member_id"` ValidFrom string `json:"valid_from"` RequestedBy uuid.UUID `json:"requested_by"` Reason string `json:"reason"` } if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } from, err := parseDate("valid_from", body.ValidFrom, true) if err != nil { _ = c.Error(err) return } var o platformhr.HeadcountOverride if h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { var err error o, err = platformhr.GrantHeadcountOverride(ctx, tx, h.deps.Clock, platformhr.HeadcountOverrideInput{ PositionID: body.PositionID, MemberID: body.MemberID, ValidFrom: *from, RequestedBy: body.RequestedBy, Reason: body.Reason, Actor: who}) return err }) { c.JSON(http.StatusCreated, headcountOverrideDTO{ID: o.ID, PositionID: o.PositionID, MemberID: o.MemberID, ValidFrom: o.ValidFrom.Format(dateLayout), RequestedBy: o.RequestedBy, Reason: o.Reason, Approved: o.Approved, Filled: o.Filled, GrantedBy: o.GrantedBy, SoleOwner: o.SoleOwner, CreatedAt: o.CreatedAt}) }}
func (h handlers) listVacancies(c *gin.Context) { f := platformhr.VacancyFilter{Status: c.Query("status")} if s := c.Query("position_id"); s != "" { id, err := uuid.Parse(s) if err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "position_id must be a UUID")) return } f.PositionID = &id } out := []vacancyDTO{} if h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { vs, err := platformhr.ListVacancies(ctx, tx, f) for _, v := range vs { out = append(out, toVacancyDTO(v)) } return err }) { c.JSON(http.StatusOK, gin.H{"vacancies": out}) }}
// vacancyView answers the vacancy fn returns with the moves the caller may// make on it.func (h handlers) vacancyView(c *gin.Context, status int, fn func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (platformhr.Vacancy, error)) { var out vacancyViewDTO if h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { v, err := fn(ctx, tx, who) if err != nil { return err } av, err := workflow.AvailableTransitions(ctx, tx, h.deps.Clock, v.InstanceID, who) if err != nil { return err } if av == nil { av = []workflow.Available{} } out = vacancyViewDTO{vacancyDTO: toVacancyDTO(v), AvailableTransitions: av} return nil }) { c.JSON(status, out) }}
func (h handlers) createVacancy(c *gin.Context) { var body struct { PositionID uuid.UUID `json:"position_id"` Kind string `json:"kind"` Quantity int `json:"quantity"` NeededBy string `json:"needed_by"` Reason string `json:"reason"` } if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } needed, err := parseDate("needed_by", body.NeededBy, true) if err != nil { _ = c.Error(err) return } h.vacancyView(c, http.StatusCreated, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (platformhr.Vacancy, error) { return platformhr.CreateVacancy(ctx, tx, h.deps.Clock, platformhr.VacancyInput{PositionID: body.PositionID, Kind: body.Kind, Quantity: body.Quantity, NeededBy: *needed, Reason: body.Reason, Actor: who}) })}
func (h handlers) getVacancy(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } h.vacancyView(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) (platformhr.Vacancy, error) { return platformhr.GetVacancy(ctx, tx, id) })}
func (h handlers) transitionVacancy(c *gin.Context) { id, err := parseID(c, "id") var body struct { From string `json:"from"` To string `json:"to"` Reason string `json:"reason"` } if err := firstErr(err, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } h.vacancyView(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (platformhr.Vacancy, error) { v, _, err := platformhr.TransitionVacancy(ctx, tx, h.deps.Clock, platformhr.VacancyTransitionInput{ID: id, From: body.From, To: body.To, Actor: who, Reason: body.Reason}) return v, err })}
func (h handlers) closeVacancy(c *gin.Context) { id, err := parseID(c, "id") var body struct { Reason string `json:"reason"` } if err := firstErr(err, decodeStrict(c, &body)); err != nil { _ = c.Error(err) return } h.vacancyView(c, http.StatusOK, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) (platformhr.Vacancy, error) { return platformhr.CloseVacancy(ctx, tx, h.deps.Clock, platformhr.CloseVacancyInput{ID: id, Reason: body.Reason, Actor: who}) })}
func (h handlers) headcountReport(c *gin.Context) { on, err := parseDate("on", c.Query("on"), false) if err != nil { _ = c.Error(err) return } var day time.Time units := []unitCountDTO{} if h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { day = clock.Today(ctx, h.deps.Clock) if on != nil { day = *on } us, err := platformhr.HeadcountReport(ctx, tx, day) for _, u := range us { d := unitCountDTO{UnitID: u.UnitID, Code: u.Code, Name: u.Name, Approved: u.Approved, Filled: u.Filled, Vacant: u.Vacant, Over: u.Over, Positions: make([]seatCountDTO, 0, len(u.Positions))} for _, s := range u.Positions { d.Positions = append(d.Positions, seatCountDTO{PositionID: s.PositionID, Code: s.Code, Title: s.Title, GradeID: s.GradeID, GradeCode: s.GradeCode, Approved: s.Approved, Filled: s.Filled, Vacant: s.Vacant, Over: s.Over, OpenVacancies: s.OpenVacancies}) } units = append(units, d) } return err }) { c.JSON(http.StatusOK, gin.H{"on": day.Format(dateLayout), "units": units}) }}
func (h handlers) openVacanciesReport(c *gin.Context) { out := []openVacancyDTO{} if h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { vs, err := platformhr.OpenVacancies(ctx, tx, clock.Today(ctx, h.deps.Clock)) for _, v := range vs { out = append(out, openVacancyDTO{vacancyDTO: toVacancyDTO(v.Vacancy), PositionCode: v.PositionCode, PositionTitle: v.PositionTitle, UnitID: v.UnitID, AgeDays: v.AgeDays, Overdue: v.Overdue}) } return err }) { c.JSON(http.StatusOK, gin.H{"vacancies": out}) }}- Step 7: Declare the routes in the contract
In api/openapi.yaml, insert immediately above the top-level components: line (after H1’s /hr/employees/{id}/leaving-overrides path):
/hr/grades: get: operationId: listJobGrades tags: [HR] summary: Job grades and their salary bands description: > Every grade by rank, with its names and its bands (amounts are strings, in the organization's base currency). active=true leaves out retired grades. Requires hr.grade.manage. parameters: - { name: active, in: query, required: false, schema: { type: boolean } } responses: '200': description: The grades content: application/json: schema: type: object required: [grades] properties: grades: { type: array, items: { $ref: '#/components/schemas/JobGrade' } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/HRForbidden' } post: operationId: createJobGrade tags: [HR] summary: Create a job grade description: > Named in English and Bahasa Malaysia (Chinese optional); the code is fixed once created. 409 duplicate_grade_code. Requires hr.grade.manage. requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/JobGradeCreate' } } } responses: '201': description: The grade headers: { ETag: { $ref: '#/components/headers/HRRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/JobGrade' } } } '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/grades/{id}: put: operationId: updateJobGrade tags: [HR] summary: Rename, re-rank, retire or restore a grade description: > A retired grade is never deleted; it takes no new band or position. 409 revision_mismatch. Requires hr.grade.manage. 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 grade being changed. requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/JobGradeUpdate' } } } responses: '200': description: The grade headers: { ETag: { $ref: '#/components/headers/HRRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/JobGrade' } } } '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/grades/{id}/bands: post: operationId: addGradeBand tags: [HR] summary: Add a salary band from a date description: > The band is open-ended and ends the grade's open band on its first day; any other overlap is 409 band_overlap. A retired grade is 409 grade_retired. Amounts are decimal strings. Requires hr.grade.manage. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/GradeBandInput' } } } responses: '201': description: The grade with its bands headers: { ETag: { $ref: '#/components/headers/HRRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/JobGrade' } } } '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/positions/{id}/grade: put: operationId: setPositionGrade tags: [HR] summary: Set or clear a position's grade description: > An employee's grade is their primary position's. A retired grade is 409 grade_retired. Requires hr.grade.manage. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/PositionGradeInput' } } } responses: '200': description: The position content: { application/json: { schema: { $ref: '#/components/schemas/PositionHeadcount' } } } '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/positions/{id}/headcount: put: operationId: setPositionHeadcount tags: [HR] summary: Set a position's approved headcount description: > How many may hold the position as their primary at once. Lowering it below what is held ends nothing; the next primary assignment is refused. Requires hr.headcount.manage. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/PositionHeadcountInput' } } } responses: '200': description: The position content: { application/json: { schema: { $ref: '#/components/schemas/PositionHeadcount' } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/HRForbidden' } '422': { $ref: '#/components/responses/HRInvalid' } /hr/headcount-overrides: post: operationId: grantHeadcountOverride tags: [HR] summary: Allow one person into a full position description: > HD-4: lets member_id hold position_id as their primary from valid_from although it is at its approved headcount, at the request of requested_by, who must not be the caller (403 override_by_requester) except in a one-member organization. The assignment that matches uses it once. The reason is mandatory and audited. 409 within_headcount when the position has room. Requires hr.headcount.override. requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/HeadcountOverrideInput' } } } responses: '201': description: The override content: { application/json: { schema: { $ref: '#/components/schemas/HeadcountOverride' } } } '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/vacancies: get: operationId: listVacancies tags: [HR] summary: Vacancies description: Newest first. Requires hr.vacancy.read. parameters: - name: status in: query required: false schema: { $ref: '#/components/schemas/VacancyStatus' } - { name: position_id, in: query, required: false, schema: { type: string, format: uuid } } responses: '200': description: The vacancies content: application/json: schema: type: object required: [vacancies] properties: vacancies: { type: array, items: { $ref: '#/components/schemas/Vacancy' } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/HRForbidden' } '422': { $ref: '#/components/responses/HRInvalid' } post: operationId: createVacancy tags: [HR] summary: Request a vacancy description: > A draft for an active position, needed today or later, with the caller as requester. Its approver comes from the vacancy workflow's grants, never the requester. 409 workflow_missing when the organization has not been provisioned for vacancies. Requires hr.vacancy.request. requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/VacancyCreate' } } } responses: '201': description: The vacancy content: { application/json: { schema: { $ref: '#/components/schemas/VacancyView' } } } '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/vacancies/{id}: get: operationId: getVacancy tags: [HR] summary: One vacancy and the moves the caller may make description: Requires hr.vacancy.read. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } responses: '200': description: The vacancy content: { application/json: { schema: { $ref: '#/components/schemas/VacancyView' } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/HRForbidden' } '404': { $ref: '#/components/responses/HRNotFound' } '422': { $ref: '#/components/responses/HRInvalid' } /hr/vacancies/{id}/transitions: post: operationId: transitionVacancy tags: [HR] summary: Move a vacancy through its approval description: > The workflow engine decides who may: 403 with the refusal's code otherwise (the requester never approves). Approving a new vacancy raises its position's approved headcount by its quantity, once. In a one-member organization the Owner may approve their own with a reason (422 sole_owner_reason_required without one). 409 stale_state. Requires hr.vacancy.read. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/VacancyTransitionInput' } } } responses: '200': description: The vacancy content: { application/json: { schema: { $ref: '#/components/schemas/VacancyView' } } } '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/vacancies/{id}/close: post: operationId: closeVacancy tags: [HR] summary: Close an open vacancy by hand description: > With a mandatory reason, audited. A filled vacancy is closed by its count already; a draft is withdrawn through its workflow. 409 vacancy_not_open. Requires hr.vacancy.close. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } requestBody: required: true content: { application/json: { schema: { $ref: '#/components/schemas/VacancyCloseInput' } } } responses: '200': description: The vacancy content: { application/json: { schema: { $ref: '#/components/schemas/VacancyView' } } } '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/reports/headcount: get: operationId: getHeadcountReport tags: [HR] summary: Approved, filled and vacant per unit and position description: > On a day (default today): each unit's active positions and their sums. Filled counts primary holders only. Requires hr.report.read. parameters: - { name: on, in: query, required: false, schema: { type: string, format: date } } responses: '200': description: The report content: { application/json: { schema: { $ref: '#/components/schemas/HeadcountReport' } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/HRForbidden' } '422': { $ref: '#/components/responses/HRInvalid' } /hr/reports/open-vacancies: get: operationId: getOpenVacanciesReport tags: [HR] summary: Open vacancies and their age description: Oldest approval first; age in days since approval. Requires hr.report.read. responses: '200': description: The report content: application/json: schema: type: object required: [vacancies] properties: vacancies: { type: array, items: { $ref: '#/components/schemas/OpenVacancy' } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/HRForbidden' }In HRConflict’s description, replace overlapping_primary, revision_mismatch. with overlapping_primary, revision_mismatch; and (Phase 5 H2) over_headcount, within_headcount, duplicate_grade_code, grade_retired, band_overlap, vacancy_not_open, stale_state, workflow_missing. In HRForbidden’s description, replace own_leaving_override). with own_leaving_override), or the workflow engine refuses the move (the refusal's code). In HRNotFound’s description, replace No such employee in this organization. with No such employee, grade or vacancy in this organization.
In /org/members/{id}/positions’s '409' description, replace position for part of that period. with position for part of that period; or (code over_headcount) the position is at its approved headcount and no Owner's override covers this assignment.
In the EmployeeEvent schema, add vacancy_id to required (after recorded_at) and to properties:
vacancy_id: { type: string, format: uuid, nullable: true, description: The vacancy a hire filled. }In EmployeeEventInput’s properties, add:
vacancy_id: { type: string, format: uuid, description: hired only — the approved, open vacancy this hire fills (409 vacancy_not_open otherwise). }Append to the end of the file (the schemas section):
GradeNames: type: object required: [en, ms] additionalProperties: false properties: en: { type: string, maxLength: 200 } ms: { type: string, maxLength: 200 } zh: { type: string, maxLength: 200 } GradeBand: type: object required: [id, effective_from, effective_to, min_salary, max_salary] properties: id: { type: string, format: uuid } effective_from: { type: string, format: date } effective_to: { type: string, format: date, nullable: true, description: The first day it no longer applies. } min_salary: { type: string, description: 'NUMERIC(19,4) as a string, in the base currency.' } max_salary: { type: string, description: 'NUMERIC(19,4) as a string, in the base currency.' } JobGrade: type: object required: [id, code, names, rank, is_active, revision, bands, created_at, updated_at] properties: id: { type: string, format: uuid } code: { type: string } names: { $ref: '#/components/schemas/GradeNames' } rank: { type: integer } is_active: { type: boolean } revision: { type: integer } bands: { type: array, items: { $ref: '#/components/schemas/GradeBand' } } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } JobGradeCreate: type: object required: [code, names, rank] additionalProperties: false properties: code: { type: string, maxLength: 16 } names: { $ref: '#/components/schemas/GradeNames' } rank: { type: integer, minimum: 1, maximum: 999 } JobGradeUpdate: type: object required: [names, rank, is_active] additionalProperties: false properties: names: { $ref: '#/components/schemas/GradeNames' } rank: { type: integer, minimum: 1, maximum: 999 } is_active: { type: boolean } GradeBandInput: type: object required: [effective_from, min_salary, max_salary] additionalProperties: false properties: effective_from: { type: string, format: date } min_salary: { type: string } max_salary: { type: string } PositionGradeInput: type: object required: [grade_id] additionalProperties: false properties: grade_id: { type: string, format: uuid, nullable: true } PositionHeadcountInput: type: object required: [approved_headcount] additionalProperties: false properties: approved_headcount: { type: integer, minimum: 0 } PositionHeadcount: type: object required: [id, code, title, org_unit_id, grade_id, approved_headcount, is_active] properties: id: { type: string, format: uuid } code: { type: string } title: { type: string } org_unit_id: { type: string, format: uuid } grade_id: { type: string, format: uuid, nullable: true } approved_headcount: { type: integer } is_active: { type: boolean } HeadcountOverrideInput: type: object required: [position_id, member_id, valid_from, requested_by, reason] additionalProperties: false properties: position_id: { type: string, format: uuid } member_id: { type: string, format: uuid } valid_from: { type: string, format: date } requested_by: { type: string, format: uuid, description: The member who asked; not the caller. } reason: { type: string, maxLength: 500 } HeadcountOverride: type: object required: [id, position_id, member_id, valid_from, requested_by, reason, approved_headcount, filled, granted_by, sole_owner, created_at] properties: id: { type: string, format: uuid } position_id: { type: string, format: uuid } member_id: { type: string, format: uuid } valid_from: { type: string, format: date } requested_by: { type: string, format: uuid } reason: { type: string } approved_headcount: { type: integer, description: The approved headcount it was granted over. } filled: { type: integer, description: The most primary holders at once it was granted over. } granted_by: { type: string, format: uuid } sole_owner: { type: boolean } created_at: { type: string, format: date-time } VacancyKind: type: string enum: [replacement, new] VacancyStatus: type: string enum: [draft, pending, rejected, withdrawn, open, filled, closed] description: Derived. filled — its count of hires is met; closed — closed by hand. Vacancy: type: object required: [id, position_id, kind, quantity, filled, remaining, needed_by, reason, requested_by, state, semantic, status, approved_on, headcount_raised, closed_at, close_reason, closed_by, created_at] properties: id: { type: string, format: uuid } position_id: { type: string, format: uuid } kind: { $ref: '#/components/schemas/VacancyKind' } quantity: { type: integer } filled: { type: integer } remaining: { type: integer } needed_by: { type: string, format: date } reason: { type: string } requested_by: { type: string, format: uuid } state: { type: string } semantic: { $ref: '#/components/schemas/WorkflowSemantic' } status: { $ref: '#/components/schemas/VacancyStatus' } approved_on: { type: string, format: date, nullable: true } headcount_raised: { type: boolean } closed_at: { type: string, format: date-time, nullable: true } close_reason: { type: string, nullable: true } closed_by: { type: string, format: uuid, nullable: true } created_at: { type: string, format: date-time } VacancyView: allOf: - $ref: '#/components/schemas/Vacancy' - type: object required: [available_transitions] properties: available_transitions: { type: array, items: { $ref: '#/components/schemas/WorkflowAvailableTransition' } } VacancyCreate: type: object required: [position_id, kind, quantity, needed_by, reason] additionalProperties: false properties: position_id: { type: string, format: uuid } kind: { $ref: '#/components/schemas/VacancyKind' } quantity: { type: integer, minimum: 1, maximum: 999 } needed_by: { type: string, format: date } reason: { type: string, maxLength: 500 } VacancyTransitionInput: type: object required: [from, to] additionalProperties: false properties: from: { type: string } to: { type: string } reason: { type: string, maxLength: 500 } VacancyCloseInput: type: object required: [reason] additionalProperties: false properties: reason: { type: string, maxLength: 500 } HeadcountSeat: type: object required: [position_id, code, title, grade_id, grade_code, approved, filled, vacant, over, open_vacancies] properties: position_id: { type: string, format: uuid } code: { type: string } title: { type: string } grade_id: { type: string, format: uuid, nullable: true } grade_code: { type: string, nullable: true } approved: { type: integer } filled: { type: integer, description: Primary holders only. } vacant: { type: integer } over: { type: integer } open_vacancies: { type: integer } HeadcountUnit: type: object required: [unit_id, code, name, approved, filled, vacant, over, positions] properties: unit_id: { type: string, format: uuid } code: { type: string } name: { type: string } approved: { type: integer } filled: { type: integer } vacant: { type: integer } over: { type: integer } positions: { type: array, items: { $ref: '#/components/schemas/HeadcountSeat' } } HeadcountReport: type: object required: [on, units] properties: on: { type: string, format: date } units: { type: array, items: { $ref: '#/components/schemas/HeadcountUnit' } } OpenVacancy: allOf: - $ref: '#/components/schemas/Vacancy' - type: object required: [position_code, position_title, unit_id, age_days, overdue] properties: position_code: { type: string } position_title: { type: string } unit_id: { type: string, format: uuid } age_days: { type: integer } overdue: { type: boolean }Run: make generate
Expected: internal/gen/api/api.gen.go and web/src/lib/api/schema.d.ts gain the operations and schemas.
- Step 8: Grant existing Owners (
00824)
Create internal/platform/hr/migration00824_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")
// 00824 (Ruling H2-17): an existing Owner gains the seven H2 keys; Finance// does not; the Down takes them back.func TestMigration00824GrantsTheH2KeysToOwnersOnly(t *testing.T) { h := testdb.StartAt(t, 823) 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 IN ('hr.grade.manage', 'hr.headcount.manage', 'hr.headcount.override', 'hr.vacancy.read', 'hr.vacancy.request', 'hr.vacancy.close', 'hr.report.read')`, id).Scan(&n)) return n } require.Zero(t, count(owner), "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), 824, goose.WithAllowMissing())) require.Equal(t, 7, count(owner), "PRESENCE: the Owner holds all seven") require.Zero(t, count(finance), "Finance holds none")
require.NoError(t, goose.Down(conn, testdb.MigrationsDir(t))) require.Zero(t, count(owner), "the Down removes them")}Create db/migrations/00824_hr_planning_grants.sql:
-- +goose Up-- Phase 5 H2 (Ruling H2-17): the seven H2 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.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.grade.manage'), ('hr.headcount.manage'), ('hr.headcount.override'), ('hr.vacancy.read'), ('hr.vacancy.request'), ('hr.vacancy.close'), ('hr.report.read')) 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 DownDELETE 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.grade.manage', 'hr.headcount.manage', 'hr.headcount.override', 'hr.vacancy.read', 'hr.vacancy.request', 'hr.vacancy.close', 'hr.report.read');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 9: Wire the composed-app test
In internal/app/routes_test.go, replace H1’s composedModules entry
"hr": {hr.PermissionRead, hr.PermissionManage, hr.PermissionSensitive, hr.PermissionLifecycleOverride},with
"hr": {hr.PermissionRead, hr.PermissionManage, hr.PermissionSensitive, hr.PermissionLifecycleOverride, hr.PermissionGradeManage, hr.PermissionHeadcountManage, hr.PermissionHeadcountOverride, hr.PermissionVacancyRead, hr.PermissionVacancyRequest, hr.PermissionVacancyClose, hr.PermissionReportRead},- Step 10: Run the tests
Run: go test ./internal/modules/hr/ ./internal/modules/orgstructure/ ./internal/platform/hr/ ./internal/app/ ./cmd/... ./internal/gen/ ./internal/kernel/vocab/ -count=1 && pnpm -C web typecheck && pnpm -C web test -- schema
Expected: PASS (TestEveryRouteIsAuthorized, TestEveryModuleContributesRoutesAndPermissions, TestTheOpenAPIDocumentSaysWhatTheRouterEnforces, TestEverySeededGraphPassesTheValidator, TestEverySeededGraphShipsAllThreeLocales included).
- Step 11: Watch the route guard fail once
In routes.go, change the POST /hr/vacancies/:id/close line’s PermissionVacancyClose to PermissionVacancyRead and run go test ./internal/modules/hr/ -run TestEveryRouteRequiresItsExactPermission -count=1. Expected: FAIL — the maps differ at POST /v1/hr/vacancies/:id/close. Restore.
Then in TransitionVacancy (platform), change raise := to.Semantic == posting.Committed && … to raise := false && to.Semantic == posting.Committed && … and run go test ./internal/modules/hr/ -run TestTheGradeVacancyAndReportRoutes -count=1. Expected: FAIL — the body lacks "headcount_raised":true. Restore; PASS.
- Step 12: Commit
git add constants.yaml internal/kernel/vocab packages/contracts/vocab.ts api/openapi.yaml internal/gen \ internal/modules/hr internal/platform/hr db/migrations/00824_hr_planning_grants.sql internal/app web/src/lib/apigit commit -m "Phase 5 H2: grade, headcount, vacancy and report routes; Owner grants
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 10: The screens (grades and bands, headcount by unit, vacancies and their drawer)
Section titled “Task 10: The screens (grades and bands, headcount by unit, vacancies and their drawer)”Files:
- Create:
web/src/features/hr/{planning.ts,GradesPage.tsx,HeadcountPage.tsx,VacanciesPage.tsx,VacancyDrawer.tsx} - Create:
web/src/lib/i18n/locales/{en,ms,zh}/hrPlanning.json - Modify:
web/src/features/hr/routes.tsx,web/src/features/hr/LifecycleDialog.tsx,web/src/app/shell/nav.ts - Test:
web/src/features/hr/__tests__/planning.test.tsx
Interfaces:
-
Consumes: Task 9’s routes and generated
Schemas['JobGrade' | 'GradeBand' | 'JobGradeCreate' | 'JobGradeUpdate' | 'GradeBandInput' | 'PositionHeadcountInput' | 'PositionGradeInput' | 'HeadcountOverrideInput' | 'HeadcountReport' | 'HeadcountUnit' | 'HeadcountSeat' | 'Vacancy' | 'VacancyView' | 'VacancyStatus' | 'VacancyKind' | 'VacancyCreate' | 'VacancyTransitionInput' | 'VacancyCloseInput' | 'OpenVacancy' | 'WorkflowAvailableTransition']; H1’sroutes.tsx(employeesRoute),LifecycleDialog.tsx;useMemberDirectory,useChart,RecordLanguageHint,useCan,RequirePermission,problemMessage/ProblemError/unwrap,formatDate/todayISO,formatMoney/isMoneyInput,pickLabel,useLocale. -
Produces: routes
/hr/grades,/hr/headcount,/hr/vacancies(searchvacancy); hooksuseGrades,useCreateGrade,useUpdateGrade,useAddBand,useHeadcount,useSetHeadcount,useSetPositionGrade,useGrantHeadcountOverride,useVacancies,useVacancy,useCreateVacancy,useTransitionVacancy,useCloseVacancy;bandOn,planningMessage,VacancyStatusTag; a vacancy picker in H1’s hire dialog. -
Step 1: Write the failing screen tests
Create web/src/features/hr/__tests__/planning.test.tsx:
import { describe, expect, it } from 'vitest';import { screen, within } from '@testing-library/react';import { http, HttpResponse } from 'msw';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';
const grade = { id: 'g1', code: 'E1', names: { en: 'Executive', ms: 'Eksekutif' }, rank: 10, is_active: true, revision: 1, bands: [ { id: 'b1', effective_from: '2020-01-01', effective_to: null, min_salary: '2500.0000', max_salary: '4000.0000' }, ], created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',};const report = { on: '2026-09-21', units: [ { unit_id: 'u1', code: 'HQ', name: 'Ibu pejabat', approved: 1, filled: 1, vacant: 0, over: 0, positions: [ { position_id: 'p1', code: 'CLERK', title: 'Clerk', grade_id: null, grade_code: null, approved: 1, filled: 1, vacant: 0, over: 0, open_vacancies: 0, }, ], }, ],};const open = { id: 'v1', position_id: 'p1', kind: 'new', quantity: 2, filled: 1, remaining: 1, needed_by: '2026-12-31', reason: 'Branch opening', requested_by: 'm1', state: 'approved', semantic: 'committed', status: 'open', approved_on: '2026-09-21', headcount_raised: true, closed_at: null, close_reason: null, closed_by: null, created_at: '2026-09-21T01:00:00Z', available_transitions: [],};const pending = { ...open, state: 'pending_approval', semantic: 'pending', status: 'pending', approved_on: null, headcount_raised: false, available_transitions: [ { to: 'approved', action_key: 'approve', gate: 'target', requires_reason: false, allowed: true, labels: { en: 'Approve', ms: 'Luluskan', zh: '批准' }, }, ],};
function mockVacancy(v: typeof open | typeof pending) { server.use( http.get('*/v1/hr/vacancies', () => HttpResponse.json({ vacancies: [v] })), http.get('*/v1/hr/vacancies/v1', () => HttpResponse.json(v)), );}
describe('GradesPage', () => { it('lists a grade with its current band', async () => { server.use(http.get('*/v1/hr/grades', () => HttpResponse.json({ grades: [grade] }))); await renderWithApp({ path: '/hr/grades', permissions: ['hr.grade.manage'] }); const row = (await screen.findByText('Executive')).closest('tr')!; expect(within(row).getByText(/4,000\.00/)).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'New grade' })).toBeInTheDocument(); });});
describe('HeadcountPage', () => { it('offers the override on a full position to its holder (presence first)', async () => { server.use(http.get('*/v1/hr/reports/headcount', () => HttpResponse.json(report))); await renderWithApp({ path: '/hr/headcount', permissions: ['hr.report.read', 'hr.headcount.override'] }); expect(await screen.findByText('CLERK · Clerk')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Allow over headcount' })).toBeInTheDocument(); });
it('does not offer it without hr.headcount.override', async () => { server.use(http.get('*/v1/hr/reports/headcount', () => HttpResponse.json(report))); await renderWithApp({ path: '/hr/headcount', permissions: ['hr.report.read'] }); expect(await screen.findByText('CLERK · Clerk')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Allow over headcount' })).not.toBeInTheDocument(); });});
describe('VacancyDrawer', () => { it('offers the moves the engine allows, and no close to a reader', async () => { mockVacancy(pending); await renderWithApp({ path: '/hr/vacancies?vacancy=v1', permissions: ['hr.vacancy.read'] }); expect(await screen.findByRole('button', { name: 'Approve' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Close vacancy' })).not.toBeInTheDocument(); });
it('lets a holder of hr.vacancy.close close an open vacancy', async () => { mockVacancy(open); await renderWithApp({ path: '/hr/vacancies?vacancy=v1', permissions: ['hr.vacancy.read', 'hr.vacancy.close'] }); expect(await screen.findByText('1 of 2 filled')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Close vacancy' })).toBeInTheDocument(); });});- Step 2: Run them to verify they fail
Run: pnpm -C web test -- features/hr/__tests__/planning
Expected: FAIL — /hr/grades, /hr/headcount and /hr/vacancies render the not-found page.
- Step 3: Write the hooks
Create web/src/features/hr/planning.ts:
// The HR layer's planning screens (Phase 5 H2): grades and bands, headcount, vacancies.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 Grade = Schemas['JobGrade'];export type Band = Schemas['GradeBand'];export type Vacancy = Schemas['Vacancy'];export type VacancyView = Schemas['VacancyView'];export type VacancyStatus = Schemas['VacancyStatus'];export type VacancyKind = Schemas['VacancyKind'];export type HeadcountSeat = Schemas['HeadcountSeat'];export type Available = Schemas['WorkflowAvailableTransition'];
export const VACANCY_STATUSES: VacancyStatus[] = [ 'draft', 'pending', 'rejected', 'withdrawn', 'open', 'filled', 'closed',];export const VACANCY_KINDS: VacancyKind[] = ['new', 'replacement'];
const etag = (rev: number) => `"${rev}"`;
export const planningKeys = { grades: ['hr', 'grades'] as const, headcount: ['hr', 'headcount'] as const, vacancies: (f: { status?: VacancyStatus }) => ['hr', 'vacancies', f] as const, vacancy: (id: string) => ['hr', 'vacancy', id] as const,};
/** The band in force on day (YYYY-MM-DD), if any. */export function bandOn(g: Grade, day: string): Band | undefined { return g.bands.find((b) => b.effective_from <= day && (!b.effective_to || b.effective_to > day));}
/** A grade's name in the UI's locale: zh when given, else ms or en. */export function gradeName(g: Grade, locale: string): string { if (locale === 'zh' && g.names.zh) return g.names.zh; return locale === 'ms' ? g.names.ms : g.names.en;}
export function useGrades(enabled = true) { return useQuery({ queryKey: planningKeys.grades, enabled, queryFn: async ({ signal }) => unwrap(await api.GET('/hr/grades', { signal })).grades, });}
export function useCreateGrade() { const qc = useQueryClient(); return useMutation({ mutationFn: async (body: Schemas['JobGradeCreate']) => unwrap(await api.POST('/hr/grades', { body })), onSuccess: () => void qc.invalidateQueries({ queryKey: planningKeys.grades }), });}
export function useUpdateGrade(id: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (v: { revision: number; body: Schemas['JobGradeUpdate'] }) => unwrap( await api.PUT('/hr/grades/{id}', { params: { path: { id }, header: { 'If-Match': etag(v.revision) } }, body: v.body, }), ), onSuccess: () => void qc.invalidateQueries({ queryKey: planningKeys.grades }), });}
export function useAddBand(id: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (body: Schemas['GradeBandInput']) => unwrap(await api.POST('/hr/grades/{id}/bands', { params: { path: { id } }, body })), onSuccess: () => void qc.invalidateQueries({ queryKey: planningKeys.grades }), });}
export function useHeadcount() { return useQuery({ queryKey: planningKeys.headcount, queryFn: async ({ signal }) => unwrap(await api.GET('/hr/reports/headcount', { signal })), });}
function useAfterSeat() { const qc = useQueryClient(); return () => void qc.invalidateQueries({ queryKey: planningKeys.headcount });}
export function useSetHeadcount(id: string) { const after = useAfterSeat(); return useMutation({ mutationFn: async (body: Schemas['PositionHeadcountInput']) => unwrap(await api.PUT('/hr/positions/{id}/headcount', { params: { path: { id } }, body })), onSuccess: after, });}
export function useSetPositionGrade(id: string) { const after = useAfterSeat(); return useMutation({ mutationFn: async (body: Schemas['PositionGradeInput']) => unwrap(await api.PUT('/hr/positions/{id}/grade', { params: { path: { id } }, body })), onSuccess: after, });}
export function useGrantHeadcountOverride() { return useMutation({ mutationFn: async (body: Schemas['HeadcountOverrideInput']) => unwrap(await api.POST('/hr/headcount-overrides', { body })), });}
export function useVacancies(f: { status?: VacancyStatus }, enabled = true) { return useQuery({ queryKey: planningKeys.vacancies(f), enabled, queryFn: async ({ signal }) => unwrap(await api.GET('/hr/vacancies', { params: { query: f }, signal })).vacancies, });}
export function useVacancy(id: string) { return useQuery({ queryKey: planningKeys.vacancy(id), queryFn: async ({ signal }) => unwrap(await api.GET('/hr/vacancies/{id}', { params: { path: { id } }, signal })), });}
function useAfterVacancy() { const qc = useQueryClient(); return (v: VacancyView) => { qc.setQueryData(planningKeys.vacancy(v.id), v); void qc.invalidateQueries({ queryKey: ['hr', 'vacancies'] }); void qc.invalidateQueries({ queryKey: planningKeys.headcount }); };}
export function useCreateVacancy() { const after = useAfterVacancy(); return useMutation({ mutationFn: async (body: Schemas['VacancyCreate']) => unwrap(await api.POST('/hr/vacancies', { body })), onSuccess: after, });}
export function useTransitionVacancy(id: string) { const after = useAfterVacancy(); return useMutation({ mutationFn: async (body: Schemas['VacancyTransitionInput']) => unwrap(await api.POST('/hr/vacancies/{id}/transitions', { params: { path: { id } }, body })), onSuccess: after, });}
export function useCloseVacancy(id: string) { const after = useAfterVacancy(); return useMutation({ mutationFn: async (body: Schemas['VacancyCloseInput']) => unwrap(await api.POST('/hr/vacancies/{id}/close', { params: { path: { id } }, body })), onSuccess: after, });}
/** A refusal in these screens' words when its code is one they know, else the common text. */export function planningMessage(e: unknown, t: TFunction, tc: TFunction): string { if (e instanceof ProblemError && e.code && i18n.exists(`hrPlanning:refusals.${e.code}`)) { return t(`refusals.${e.code}`); } return problemMessage(e, tc);}- Step 4: Write the grades screen
Create web/src/features/hr/GradesPage.tsx:
import { useMemo, useState } from 'react';import { useTranslation } from 'react-i18next';import { Plus } from 'lucide-react';import { Button } from '@/components/ui/Button';import { Checkbox } from '@/components/ui/Checkbox';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { DatePicker } from '@/components/ui/DatePicker';import { Dialog } from '@/components/ui/Dialog';import { EmptyState } from '@/components/ui/EmptyState';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { PageHeader } from '@/components/ui/PageHeader';import { Tag } from '@/components/ui/Tag';import { toast } from '@/components/ui/Toaster';import { RecordLanguageHint } from '@/features/orgstructure/RecordLanguageHint';import { todayISO } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { formatMoney, isMoneyInput } from '@/lib/money';import { bandOn, gradeName, planningMessage, useAddBand, useCreateGrade, useGrades, useUpdateGrade, type Grade,} from './planning';
/** Job grades and their salary bands (Phase 5 H2); bands are in ringgit (spec H2). */export function GradesPage() { const { t } = useTranslation('hrPlanning'); const [locale] = useLocale(); const grades = useGrades(); const [editing, setEditing] = useState<Grade | 'new' | null>(null); const [banding, setBanding] = useState<Grade | null>(null); const today = todayISO(); const columns = useMemo<ColumnDef<Grade>[]>( () => [ { id: 'code', meta: { required: true }, header: t('grades.code'), cell: ({ row }) => <span className="font-mono text-caption">{row.original.code}</span>, }, { id: 'name', meta: { required: true }, header: t('grades.name'), cell: ({ row }) => gradeName(row.original, locale), }, { id: 'rank', header: t('grades.rank'), cell: ({ row }) => row.original.rank }, { id: 'band', header: t('grades.band'), cell: ({ row }) => { const b = bandOn(row.original, today); return b ? `${formatMoney(b.min_salary, 'MYR', locale)} – ${formatMoney(b.max_salary, 'MYR', locale)}` : '—'; }, }, { id: 'status', header: t('grades.status'), cell: ({ row }) => row.original.is_active ? ( <Tag tone="green">{t('grades.active')}</Tag> ) : ( <Tag tone="muted">{t('grades.retired')}</Tag> ), }, { id: 'actions', header: '', cell: ({ row }) => row.original.is_active && ( <Button variant="outline" onClick={(e) => { e.stopPropagation(); setBanding(row.original); }} > {t('grades.addBand')} </Button> ), }, ], [t, locale, today], ); return ( <div className="flex flex-col gap-4"> <PageHeader title={t('grades.title')} subtitle={t('grades.subtitle')} actions={ <Button onClick={() => setEditing('new')}> <Plus size={16} aria-hidden /> {t('grades.new')} </Button> } /> <DataTable tableId="hr.grades" columns={columns} data={grades.data ?? []} loading={grades.isLoading} getRowId={(g: Grade) => g.id} onRowClick={(g: Grade) => setEditing(g)} empty={<EmptyState title={t('grades.empty')} />} /> {editing && ( <GradeDialog grade={editing === 'new' ? undefined : editing} onClose={() => setEditing(null)} /> )} {banding && <BandDialog grade={banding} onClose={() => setBanding(null)} />} </div> );}
function GradeDialog({ grade, onClose }: { grade?: Grade; onClose(): void }) { const { t } = useTranslation('hrPlanning'); const { t: tc } = useTranslation(); const create = useCreateGrade(); const update = useUpdateGrade(grade?.id ?? ''); const [code, setCode] = useState(''); const [en, setEn] = useState(grade?.names.en ?? ''); const [ms, setMs] = useState(grade?.names.ms ?? ''); const [zh, setZh] = useState(grade?.names.zh ?? ''); const [rank, setRank] = useState(String(grade?.rank ?? 10)); const [active, setActive] = useState(grade?.is_active ?? true); const [errors, setErrors] = useState<Record<string, string>>({}); const [formError, setFormError] = useState<string | null>(null);
async function onSubmit() { const e: Record<string, string> = {}; if (!grade && !code.trim()) e.code = t('errors.required'); if (!en.trim()) e.en = t('errors.required'); if (!ms.trim()) e.ms = t('errors.required'); if (!/^\d{1,3}$/.test(rank) || Number(rank) < 1) e.rank = t('errors.count'); setErrors(e); if (Object.keys(e).length) return; setFormError(null); const names = { en: en.trim(), ms: ms.trim(), ...(zh.trim() ? { zh: zh.trim() } : {}) }; try { if (grade) { await update.mutateAsync({ revision: grade.revision, body: { names, rank: Number(rank), is_active: active } }); } else { await create.mutateAsync({ code: code.trim(), names, rank: Number(rank) }); } toast.success(t('grades.saved')); onClose(); } catch (err) { setFormError(planningMessage(err, t, tc)); } }
return ( <Dialog open onOpenChange={(o) => !o && onClose()} title={grade ? t('grades.edit') : t('grades.new')} footer={ <> <Button variant="outline" onClick={onClose}> {t('cancel')} </Button> <Button onClick={() => void onSubmit()} loading={create.isPending || update.isPending}> {t('save')} </Button> </> } > <div className="grid gap-4"> {!grade && ( <Field label={t('grades.code')} error={errors.code}> <Input id="grade-code" value={code} onChange={(e) => setCode(e.target.value)} /> </Field> )} <Field label={t('grades.nameEn')} error={errors.en}> <Input id="grade-en" value={en} onChange={(e) => setEn(e.target.value)} /> </Field> <Field label={t('grades.nameMs')} error={errors.ms}> <Input id="grade-ms" value={ms} onChange={(e) => setMs(e.target.value)} /> </Field> <Field label={t('grades.nameZh')} optional> <Input id="grade-zh" value={zh} onChange={(e) => setZh(e.target.value)} /> </Field> <Field label={t('grades.rank')} error={errors.rank}> <Input id="grade-rank" inputMode="numeric" value={rank} onChange={(e) => setRank(e.target.value)} /> </Field> {grade && <Checkbox checked={active} onCheckedChange={setActive} label={t('grades.activeLabel')} />} <RecordLanguageHint /> {formError && ( <p role="alert" className="text-caption text-tone-red-fg"> {formError} </p> )} </div> </Dialog> );}
function BandDialog({ grade, onClose }: { grade: Grade; onClose(): void }) { const { t } = useTranslation('hrPlanning'); const { t: tc } = useTranslation(); const add = useAddBand(grade.id); const [from, setFrom] = useState<string | undefined>(todayISO()); const [min, setMin] = useState(''); const [max, setMax] = useState(''); const [errors, setErrors] = useState<Record<string, string>>({}); const [formError, setFormError] = useState<string | null>(null);
async function onSubmit() { const e: Record<string, string> = {}; if (!from) e.from = t('errors.date'); if (!isMoneyInput(min)) e.min = t('errors.amount'); if (!isMoneyInput(max)) e.max = t('errors.amount'); setErrors(e); if (!from || Object.keys(e).length) return; setFormError(null); try { await add.mutateAsync({ effective_from: from, min_salary: min, max_salary: max }); toast.success(t('grades.bandAdded')); onClose(); } catch (err) { setFormError(planningMessage(err, t, tc)); } }
return ( <Dialog open onOpenChange={(o) => !o && onClose()} title={t('grades.bandTitle')} description={t('grades.bandDescription')} footer={ <> <Button variant="outline" onClick={onClose}> {t('cancel')} </Button> <Button onClick={() => void onSubmit()} loading={add.isPending}> {t('save')} </Button> </> } > <div className="grid gap-4"> <Field label={t('grades.from')} error={errors.from}> <DatePicker id="band-from" value={from} onChange={setFrom} /> </Field> <Field label={t('grades.min')} error={errors.min}> <Input id="band-min" inputMode="decimal" value={min} onChange={(e) => setMin(e.target.value)} /> </Field> <Field label={t('grades.max')} error={errors.max}> <Input id="band-max" inputMode="decimal" value={max} onChange={(e) => setMax(e.target.value)} /> </Field> {formError && ( <p role="alert" className="text-caption text-tone-red-fg"> {formError} </p> )} </div> </Dialog> );}- Step 5: Write the headcount screen
Create web/src/features/hr/HeadcountPage.tsx:
import { useMemo, useState, type ChangeEvent } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Combobox } from '@/components/ui/Combobox';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { DatePicker } from '@/components/ui/DatePicker';import { Dialog } from '@/components/ui/Dialog';import { EmptyState } from '@/components/ui/EmptyState';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { PageHeader } from '@/components/ui/PageHeader';import { Select } from '@/components/ui/Select';import { Tag } from '@/components/ui/Tag';import { Textarea } from '@/components/ui/Textarea';import { toast } from '@/components/ui/Toaster';import { useCan } from '@/features/auth/session';import { useMemberDirectory } from '@/features/members/useMemberNames';import { RecordLanguageHint } from '@/features/orgstructure/RecordLanguageHint';import { todayISO } from '@/lib/date';import { planningMessage, useGrades, useGrantHeadcountOverride, useHeadcount, useSetHeadcount, useSetPositionGrade, type HeadcountSeat,} from './planning';
type Row = HeadcountSeat & { unit: string };const NONE = '__none';
/** Approved and filled per unit and position (spec H2 "Reports"); its actions follow the caller's keys. */export function HeadcountPage() { const { t } = useTranslation('hrPlanning'); const report = useHeadcount(); const canHeadcount = useCan('hr.headcount.manage'); const canGrade = useCan('hr.grade.manage'); const canOverride = useCan('hr.headcount.override'); const [editing, setEditing] = useState<Row | null>(null); const [allowing, setAllowing] = useState<Row | null>(null); const units = useMemo(() => report.data?.units ?? [], [report.data]); const rows = useMemo<Row[]>( () => units.flatMap((u) => u.positions.map((p) => ({ ...p, unit: u.name }))), [units], ); const columns = useMemo<ColumnDef<Row>[]>( () => [ { id: 'unit', header: t('headcount.unit'), cell: ({ row }) => row.original.unit }, { id: 'position', meta: { required: true }, header: t('headcount.position'), cell: ({ row }) => `${row.original.code} · ${row.original.title}`, }, { id: 'grade', header: t('headcount.grade'), cell: ({ row }) => row.original.grade_code ?? '—' }, { id: 'approved', header: t('headcount.approved'), cell: ({ row }) => row.original.approved }, { id: 'filled', header: t('headcount.filled'), cell: ({ row }) => row.original.filled }, { id: 'vacant', header: t('headcount.vacant'), cell: ({ row }) => row.original.over > 0 ? ( <Tag tone="red">{t('headcount.overBy', { count: row.original.over })}</Tag> ) : ( row.original.vacant ), }, { id: 'open', header: t('headcount.openVacancies'), cell: ({ row }) => row.original.open_vacancies }, { id: 'actions', header: '', cell: ({ row }) => ( <div className="flex gap-2"> {(canHeadcount || canGrade) && ( <Button variant="outline" onClick={() => setEditing(row.original)}> {t('headcount.edit')} </Button> )} {canOverride && row.original.vacant === 0 && ( <Button variant="outline" onClick={() => setAllowing(row.original)}> {t('headcount.allowOver')} </Button> )} </div> ), }, ], [t, canHeadcount, canGrade, canOverride], ); return ( <div className="flex flex-col gap-4"> <PageHeader title={t('headcount.title')} subtitle={t('headcount.subtitle')} /> <ul className="flex flex-wrap gap-3 text-caption"> {units.map((u) => ( <li key={u.unit_id} className="rounded-control border border-border px-3 py-1"> {t('headcount.unitSummary', { name: u.name, approved: u.approved, filled: u.filled, vacant: u.vacant })} </li> ))} </ul> <DataTable tableId="hr.headcount" columns={columns} data={rows} loading={report.isLoading} getRowId={(r: Row) => r.position_id} empty={<EmptyState title={t('headcount.empty')} />} /> {editing && ( <SeatDialog seat={editing} canHeadcount={canHeadcount} canGrade={canGrade} onClose={() => setEditing(null)} /> )} {allowing && <AllowOverDialog seat={allowing} onClose={() => setAllowing(null)} />} </div> );}
function SeatDialog({ seat, canHeadcount, canGrade, onClose,}: { seat: Row; canHeadcount: boolean; canGrade: boolean; onClose(): void;}) { const { t } = useTranslation('hrPlanning'); const { t: tc } = useTranslation(); const grades = useGrades(canGrade); const setHeadcount = useSetHeadcount(seat.position_id); const setGrade = useSetPositionGrade(seat.position_id); const [approved, setApproved] = useState(String(seat.approved)); const [grade, setGradeId] = useState<string>(seat.grade_id ?? NONE); const [error, setError] = useState<string | undefined>(); const [formError, setFormError] = useState<string | null>(null);
async function onSubmit() { if (canHeadcount && !/^\d{1,4}$/.test(approved)) { setError(t('errors.count')); return; } setError(undefined); setFormError(null); const gradeId = grade === NONE ? null : grade; try { if (canHeadcount && Number(approved) !== seat.approved) { await setHeadcount.mutateAsync({ approved_headcount: Number(approved) }); } if (canGrade && gradeId !== seat.grade_id) { await setGrade.mutateAsync({ grade_id: gradeId }); } toast.success(t('headcount.saved')); onClose(); } catch (err) { setFormError(planningMessage(err, t, tc)); } }
return ( <Dialog open onOpenChange={(o) => !o && onClose()} title={t('headcount.editTitle')} description={`${seat.code} · ${seat.title}`} footer={ <> <Button variant="outline" onClick={onClose}> {t('cancel')} </Button> <Button onClick={() => void onSubmit()} loading={setHeadcount.isPending || setGrade.isPending}> {t('save')} </Button> </> } > <div className="grid gap-4"> {canHeadcount && ( <Field label={t('headcount.approvedField')} error={error}> <Input id="seat-approved" inputMode="numeric" value={approved} onChange={(e) => setApproved(e.target.value)} /> </Field> )} {canGrade && ( <Field label={t('headcount.grade')}> <Select id="seat-grade" aria-label={t('headcount.grade')} value={grade} onValueChange={setGradeId} options={[ { value: NONE, label: t('headcount.noGrade') }, ...(grades.data ?? []).filter((g) => g.is_active).map((g) => ({ value: g.id, label: g.code })), ]} /> </Field> )} {formError && ( <p role="alert" className="text-caption text-tone-red-fg"> {formError} </p> )} </div> </Dialog> );}
/** HD-4: an Owner lets another member seat one more person, at a named member's request. */function AllowOverDialog({ seat, onClose }: { seat: Row; onClose(): void }) { const { t } = useTranslation('hrPlanning'); const { t: tc } = useTranslation(); const directory = useMemberDirectory(); const grant = useGrantHeadcountOverride(); const [member, setMember] = useState<string | undefined>(); const [requester, setRequester] = useState<string | undefined>(); const [from, setFrom] = useState<string | undefined>(todayISO()); 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 (!member) e.member = t('errors.required'); if (!requester) e.requester = t('errors.required'); if (!from) e.from = t('errors.date'); if (!reason.trim()) e.reason = t('errors.reason'); setErrors(e); if (!member || !requester || !from || Object.keys(e).length) return; setFormError(null); try { await grant.mutateAsync({ position_id: seat.position_id, member_id: member, valid_from: from, requested_by: requester, reason: reason.trim(), }); toast.success(t('headcount.allowed')); onClose(); } catch (err) { setFormError(planningMessage(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('headcount.overTitle')} description={t('headcount.overDescription')} footer={ <> <Button variant="outline" onClick={onClose}> {t('cancel')} </Button> <Button onClick={() => void onSubmit()} loading={grant.isPending}> {t('headcount.allowOver')} </Button> </> } > <div className="grid gap-4"> <Field label={t('headcount.member')} error={errors.member}> <Combobox id="over-member" aria-label={t('headcount.member')} value={member} onChange={setMember} options={people} placeholder={t('headcount.pickMember')} /> </Field> <Field label={t('headcount.requester')} hint={t('headcount.requesterHint')} error={errors.requester}> <Combobox id="over-requester" aria-label={t('headcount.requester')} value={requester} onChange={setRequester} options={people} placeholder={t('headcount.pickMember')} /> </Field> <Field label={t('headcount.from')} error={errors.from}> <DatePicker id="over-from" value={from} onChange={setFrom} /> </Field> <Field label={t('headcount.reason')} error={errors.reason}> <Textarea id="over-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> );}- Step 6: Write the vacancies screen and drawer
Create web/src/features/hr/VacanciesPage.tsx:
import { useMemo, useState, type ChangeEvent } 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 { DatePicker } from '@/components/ui/DatePicker';import { Dialog } from '@/components/ui/Dialog';import { EmptyState } from '@/components/ui/EmptyState';import { Field } from '@/components/ui/Field';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 { Textarea } from '@/components/ui/Textarea';import { toast } from '@/components/ui/Toaster';import { useCan } from '@/features/auth/session';import { useChart } from '@/features/orgstructure/api';import { RecordLanguageHint } from '@/features/orgstructure/RecordLanguageHint';import { formatDate, todayISO } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { VACANCY_KINDS, VACANCY_STATUSES, planningMessage, useCreateVacancy, useVacancies, type Vacancy, type VacancyKind, type VacancyStatus,} from './planning';
const ALL = '__all';const TONE: Record<VacancyStatus, TagTone> = { draft: 'neutral', pending: 'amber', rejected: 'red', withdrawn: 'muted', open: 'blue', filled: 'green', closed: 'muted',};
export function VacancyStatusTag({ status }: { status: VacancyStatus }) { const { t } = useTranslation('hrPlanning'); return <Tag tone={TONE[status]}>{t(`status.${status}`)}</Tag>;}
/** A position's code and title from the org chart, or the end of its id without org structure access. */export function usePositionLabel() { const chart = useChart(); return (id: string) => { const p = chart.data?.positions.find((x) => x.id === id); return p ? `${p.code} · ${p.title}` : `…${id.slice(-4)}`; };}
export function VacanciesPage({ onOpen }: { onOpen(id: string): void }) { const { t } = useTranslation('hrPlanning'); const [locale] = useLocale(); const canRequest = useCan('hr.vacancy.request'); const label = usePositionLabel(); const [status, setStatus] = useState<string>(ALL); const [creating, setCreating] = useState(false); const list = useVacancies(status === ALL ? {} : { status: status as VacancyStatus }); const columns = useMemo<ColumnDef<Vacancy>[]>( () => [ { id: 'position', meta: { required: true }, header: t('vacancies.position'), cell: ({ row }) => label(row.original.position_id), }, { id: 'kind', header: t('vacancies.kind'), cell: ({ row }) => t(`kinds.${row.original.kind}`) }, { id: 'quantity', header: t('vacancies.quantity'), cell: ({ row }) => t('vacancies.filledOf', { filled: row.original.filled, quantity: row.original.quantity }), }, { id: 'needed_by', header: t('vacancies.neededBy'), cell: ({ row }) => formatDate(row.original.needed_by, locale), }, { id: 'status', header: t('vacancies.status'), cell: ({ row }) => <VacancyStatusTag status={row.original.status} /> }, ], [t, locale, label], ); return ( <div className="flex flex-col gap-4"> <PageHeader title={t('vacancies.title')} subtitle={t('vacancies.subtitle')} actions={ canRequest && ( <Button onClick={() => setCreating(true)}> <Plus size={16} aria-hidden /> {t('vacancies.new')} </Button> ) } /> <Select aria-label={t('vacancies.status')} value={status} onValueChange={setStatus} options={[ { value: ALL, label: t('vacancies.allStatuses') }, ...VACANCY_STATUSES.map((s) => ({ value: s, label: t(`status.${s}`) })), ]} /> <DataTable tableId="hr.vacancies" columns={columns} data={list.data ?? []} loading={list.isLoading} getRowId={(v: Vacancy) => v.id} onRowClick={(v: Vacancy) => onOpen(v.id)} empty={<EmptyState title={t('vacancies.empty')} />} /> {creating && <NewVacancyDialog onClose={() => setCreating(false)} onCreated={onOpen} />} </div> );}
function NewVacancyDialog({ onClose, onCreated }: { onClose(): void; onCreated(id: string): void }) { const { t } = useTranslation('hrPlanning'); const { t: tc } = useTranslation(); const chart = useChart(); const create = useCreateVacancy(); const [position, setPosition] = useState<string | undefined>(); const [kind, setKind] = useState<VacancyKind>('new'); const [quantity, setQuantity] = useState('1'); const [neededBy, setNeededBy] = useState<string | undefined>(todayISO()); 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 (!position) e.position = t('errors.required'); if (!/^\d{1,3}$/.test(quantity) || Number(quantity) < 1) e.quantity = t('errors.count'); if (!neededBy) e.neededBy = t('errors.date'); if (!reason.trim()) e.reason = t('errors.reason'); setErrors(e); if (!position || !neededBy || Object.keys(e).length) return; setFormError(null); try { const v = await create.mutateAsync({ position_id: position, kind, quantity: Number(quantity), needed_by: neededBy, reason: reason.trim(), }); toast.success(t('vacancies.created')); onClose(); onCreated(v.id); } catch (err) { setFormError(planningMessage(err, t, tc)); } }
const positions = (chart.data?.positions ?? []) .filter((p) => p.active) .map((p) => ({ value: p.id, label: `${p.code} · ${p.title}` }));
return ( <Dialog open onOpenChange={(o) => !o && onClose()} title={t('vacancies.new')} footer={ <> <Button variant="outline" onClick={onClose}> {t('cancel')} </Button> <Button onClick={() => void onSubmit()} loading={create.isPending}> {t('vacancies.create')} </Button> </> } > <div className="grid gap-4"> <Field label={t('vacancies.position')} hint={t('vacancies.positionHint')} error={errors.position}> <Select id="vacancy-position" aria-label={t('vacancies.position')} value={position} onValueChange={setPosition} options={positions} placeholder={t('vacancies.pickPosition')} /> </Field> <Field label={t('vacancies.kind')}> <Select id="vacancy-kind" aria-label={t('vacancies.kind')} value={kind} onValueChange={(v) => setKind(v as VacancyKind)} options={VACANCY_KINDS.map((k) => ({ value: k, label: t(`kinds.${k}`) }))} /> </Field> <Field label={t('vacancies.quantity')} error={errors.quantity}> <Input id="vacancy-quantity" inputMode="numeric" value={quantity} onChange={(e) => setQuantity(e.target.value)} /> </Field> <Field label={t('vacancies.neededBy')} error={errors.neededBy}> <DatePicker id="vacancy-needed-by" value={neededBy} onChange={setNeededBy} min={todayISO()} /> </Field> <Field label={t('vacancies.reason')} error={errors.reason}> <Textarea id="vacancy-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/VacancyDrawer.tsx:
import { useState, type ChangeEvent, type ReactNode } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Drawer } from '@/components/ui/Drawer';import { Field } from '@/components/ui/Field';import { Textarea } from '@/components/ui/Textarea';import { toast } from '@/components/ui/Toaster';import { useCan } from '@/features/auth/session';import { RecordLanguageHint } from '@/features/orgstructure/RecordLanguageHint';import { formatDate } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { pickLabel } from '@/lib/i18n/pickLabel';import { VacancyStatusTag, usePositionLabel } from './VacanciesPage';import { planningMessage, useCloseVacancy, useTransitionVacancy, useVacancy, type Available,} from './planning';
function Item({ label, children }: { label: string; children: ReactNode }) { return ( <div> <dt className="text-caption text-fg-muted">{label}</dt> <dd>{children}</dd> </div> );}
/** One vacancy: its fill, the moves the engine allows the caller, and the manual close. */export function VacancyDrawer({ id, onClose }: { id: string; onClose(): void }) { const { t } = useTranslation('hrPlanning'); const { t: tc } = useTranslation(); const [locale] = useLocale(); const { data: v } = useVacancy(id); const move = useTransitionVacancy(id); const close = useCloseVacancy(id); const canClose = useCan('hr.vacancy.close'); const label = usePositionLabel(); const [pending, setPending] = useState<Available | 'close' | null>(null); const [reason, setReason] = useState(''); const [formError, setFormError] = useState<string | null>(null);
async function run(target: Available | 'close', why: string) { if (!v) return; setFormError(null); try { if (target === 'close') { await close.mutateAsync({ reason: why.trim() }); toast.success(t('vacancies.closed')); } else { await move.mutateAsync({ from: v.state, to: target.to, ...(why.trim() ? { reason: why.trim() } : {}) }); toast.success(t('vacancies.moved')); } setPending(null); setReason(''); } catch (err) { setFormError(planningMessage(err, t, tc)); } }
if (!v) { return ( <Drawer open onOpenChange={(o) => !o && onClose()} title={t('vacancies.loading')}> <p>{t('vacancies.loading')}</p> </Drawer> ); } const allowed = v.available_transitions.filter((a) => a.allowed); const needsReason = pending === 'close' || (pending !== null && (pending.requires_reason || !!pending.sole_owner_exception));
return ( <Drawer open onOpenChange={(o) => !o && onClose()} title={label(v.position_id)} description={t(`kinds.${v.kind}`)} footer={ pending ? ( <> <Button variant="outline" onClick={() => setPending(null)}> {t('cancel')} </Button> <Button onClick={() => void run(pending, reason)} loading={move.isPending || close.isPending} disabled={needsReason && !reason.trim()} > {t('vacancies.confirm')} </Button> </> ) : ( <> {allowed.map((a) => ( <Button key={a.to} onClick={() => (a.requires_reason || a.sole_owner_exception ? setPending(a) : void run(a, ''))} > {pickLabel(a.labels, locale)} </Button> ))} {canClose && v.status === 'open' && ( <Button variant="outline" onClick={() => setPending('close')}> {t('vacancies.close')} </Button> )} </> ) } > <dl className="grid grid-cols-2 gap-3"> <Item label={t('vacancies.status')}> <VacancyStatusTag status={v.status} /> </Item> <Item label={t('vacancies.quantity')}>{t('vacancies.filledOf', { filled: v.filled, quantity: v.quantity })}</Item> <Item label={t('vacancies.remaining')}>{v.remaining}</Item> <Item label={t('vacancies.neededBy')}>{formatDate(v.needed_by, locale)}</Item> <Item label={t('vacancies.approvedOn')}>{v.approved_on ? formatDate(v.approved_on, locale) : '—'}</Item> <Item label={t('vacancies.reason')}>{v.reason}</Item> {v.close_reason && <Item label={t('vacancies.closeReason')}>{v.close_reason}</Item>} </dl> {needsReason && ( <div className="mt-4 grid gap-2"> <Field label={t('vacancies.reasonField')}> <Textarea id="vacancy-move-reason" rows={2} value={reason} onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setReason(e.target.value)} /> </Field> <RecordLanguageHint /> </div> )} {formError && ( <p role="alert" className="mt-2 text-caption text-tone-red-fg"> {formError} </p> )} </Drawer> );}- Step 7: Let a hire name the vacancy it fills
In web/src/features/hr/LifecycleDialog.tsx:
Add to the imports import { useCan } from '@/features/auth/session'; and import { useVacancies } from './planning';, and after the imports:
const NO_VACANCY = '__none';After const [reason, setReason] = useState(''); add:
const { t: tp } = useTranslation('hrPlanning'); const canVacancies = useCan('hr.vacancy.read'); const vacancies = useVacancies({ status: 'open' }, kind === 'hired' && canVacancies); const [vacancy, setVacancy] = useState<string>(NO_VACANCY);After the line ...(kind === 'notice_given' && lastDay ? { last_day: lastDay } : {}), add:
...(kind === 'hired' && vacancy !== NO_VACANCY ? { vacancy_id: vacancy } : {}),After the {(kind === 'hired' || kind === 'confirmed') && ( … )} employment-type block, add:
{kind === 'hired' && canVacancies && ( <Field label={tp('vacancies.fills')} optional> <Select id="hr-vacancy" aria-label={tp('vacancies.fills')} value={vacancy} onValueChange={setVacancy} options={[ { value: NO_VACANCY, label: tp('vacancies.none') }, ...(vacancies.data ?? []).map((v) => ({ value: v.id, label: tp('vacancies.option', { position: chart.data?.positions.find((p) => p.id === v.position_id)?.code ?? `…${v.position_id.slice(-4)}`, remaining: v.remaining, }), })), ]} /> </Field> )}- Step 8: Route, navigate and word it
In web/src/features/hr/routes.tsx, add the imports:
import { GradesPage } from './GradesPage';import { HeadcountPage } from './HeadcountPage';import { VacanciesPage } from './VacanciesPage';import { VacancyDrawer } from './VacancyDrawer';and replace export const routes = [employeesRoute] as const; with:
const gradesRoute = createRoute({ getParentRoute: () => appRoute, path: 'hr/grades', component: () => ( <RequirePermission permission="hr.grade.manage"> <GradesPage /> </RequirePermission> ),});
const headcountRoute = createRoute({ getParentRoute: () => appRoute, path: 'hr/headcount', component: () => ( <RequirePermission permission="hr.report.read"> <HeadcountPage /> </RequirePermission> ),});
const vacancySearch = z.object({ vacancy: z.string().optional().catch(undefined) });type VacanciesSearch = z.infer<typeof vacancySearch>;
const vacanciesRoute = createRoute({ getParentRoute: () => appRoute, path: 'hr/vacancies', validateSearch: (s: Record<string, unknown>) => vacancySearch.parse(s), component: function R() { const s = vacanciesRoute.useSearch(); const navigate = vacanciesRoute.useNavigate(); return ( <RequirePermission permission="hr.vacancy.read"> <VacanciesPage onOpen={(id) => void navigate({ search: (p: VacanciesSearch) => ({ ...p, vacancy: id }) })} /> {s.vacancy && ( <VacancyDrawer id={s.vacancy} onClose={() => void navigate({ search: (p: VacanciesSearch) => ({ ...p, vacancy: undefined }) })} /> )} </RequirePermission> ); },});
export const routes = [employeesRoute, gradesRoute, headcountRoute, vacanciesRoute] as const;In web/src/app/shell/nav.ts, add Award, and UserPlus, to the lucide-react import (alphabetical), and after the /hr/employees item:
{ group: 'manage', to: '/hr/vacancies', labelKey: 'hrPlanning:nav.vacancies', icon: UserPlus, permission: 'hr.vacancy.read', }, { group: 'manage', to: '/hr/headcount', labelKey: 'hrPlanning:nav.headcount', icon: Users, permission: 'hr.report.read', }, { group: 'manage', to: '/hr/grades', labelKey: 'hrPlanning:nav.grades', icon: Award, permission: 'hr.grade.manage', },Create web/src/lib/i18n/locales/en/hrPlanning.json:
{ "nav": { "grades": "Job grades", "headcount": "Headcount", "vacancies": "Vacancies" }, "cancel": "Cancel", "save": "Save", "kinds": { "new": "New position", "replacement": "Replacement" }, "status": { "draft": "Draft", "pending": "Awaiting approval", "rejected": "Rejected", "withdrawn": "Withdrawn", "open": "Open", "filled": "Filled", "closed": "Closed" }, "grades": { "title": "Job grades", "subtitle": "Grades, their salary bands in ringgit, and the grade each position carries.", "new": "New grade", "edit": "Edit grade", "empty": "No grades yet.", "code": "Code", "name": "Name", "nameEn": "Name (English)", "nameMs": "Name (Bahasa Malaysia)", "nameZh": "Name (Chinese)", "rank": "Rank", "band": "Current band", "status": "Status", "active": "Active", "retired": "Retired", "activeLabel": "Active (clear to retire the grade; it is never deleted)", "addBand": "Add band", "saved": "Grade saved", "bandAdded": "Band added", "bandTitle": "New salary band", "bandDescription": "The band applies from this day and ends the current one.", "from": "From", "min": "Minimum (RM)", "max": "Maximum (RM)" }, "headcount": { "title": "Headcount", "subtitle": "Approved and filled positions per unit. Filled counts each person's primary position only.", "unit": "Unit", "position": "Position", "grade": "Grade", "approved": "Approved", "filled": "Filled", "vacant": "Vacant", "openVacancies": "Open vacancies", "overBy_one": "{{count}} over", "overBy_other": "{{count}} over", "edit": "Edit", "allowOver": "Allow over headcount", "saved": "Position saved", "noGrade": "No grade", "unitSummary": "{{name}}: {{approved}} approved · {{filled}} filled · {{vacant}} vacant", "editTitle": "Edit position", "approvedField": "Approved headcount", "overTitle": "Allow over headcount", "overDescription": "Another member may then seat this person although the position is full. Your reason is kept in the audit trail.", "member": "Person to seat", "requester": "Requested by", "requesterHint": "The member who asked. It cannot be you.", "pickMember": "Choose a member", "from": "From", "reason": "Reason", "allowed": "Allowed; the assignment can be made now", "empty": "No positions yet." }, "vacancies": { "title": "Vacancies", "subtitle": "Requests to fill positions, their approval, and how many are filled.", "new": "New vacancy", "create": "Request", "empty": "No vacancies.", "allStatuses": "All statuses", "position": "Position", "positionHint": "Positions come from the org chart; you need org structure access to see them.", "pickPosition": "Choose a position", "kind": "Kind", "quantity": "Quantity", "neededBy": "Needed by", "status": "Status", "filledOf": "{{filled}} of {{quantity}} filled", "remaining": "Still needed", "approvedOn": "Approved on", "reason": "Reason", "closeReason": "Closed because", "reasonField": "Reason", "confirm": "Confirm", "close": "Close vacancy", "moved": "Vacancy updated", "closed": "Vacancy closed", "created": "Vacancy requested", "loading": "Loading…", "fills": "Fills vacancy", "none": "No vacancy", "option": "{{position}} — {{remaining}} still needed" }, "errors": { "required": "Required.", "amount": "Enter an amount such as 2500.00.", "date": "Choose a date.", "count": "Enter a whole number.", "reason": "Give a reason." }, "refusals": { "over_headcount": "The position is at its approved headcount. An Owner other than you can allow one more.", "within_headcount": "The position has room; no override is needed.", "override_by_requester": "An Owner other than the member who asked must allow this.", "duplicate_grade_code": "Another grade has this code.", "grade_retired": "The grade is retired.", "band_overlap": "The band overlaps another band of this grade.", "vacancy_not_open": "The vacancy is not open.", "stale_state": "Someone else moved this vacancy; reload it.", "workflow_missing": "Vacancies are not set up in this organization yet.", "revision_mismatch": "Someone else changed this; reload it." }}Create web/src/lib/i18n/locales/ms/hrPlanning.json:
{ "nav": { "grades": "Gred jawatan", "headcount": "Bilangan perjawatan", "vacancies": "Kekosongan" }, "cancel": "Batal", "save": "Simpan", "kinds": { "new": "Jawatan baharu", "replacement": "Pengganti" }, "status": { "draft": "Draf", "pending": "Menunggu kelulusan", "rejected": "Ditolak", "withdrawn": "Ditarik balik", "open": "Dibuka", "filled": "Diisi", "closed": "Ditutup" }, "grades": { "title": "Gred jawatan", "subtitle": "Gred, julat gaji dalam ringgit, dan gred bagi setiap jawatan.", "new": "Gred baharu", "edit": "Sunting gred", "empty": "Belum ada gred.", "code": "Kod", "name": "Nama", "nameEn": "Nama (Bahasa Inggeris)", "nameMs": "Nama (Bahasa Malaysia)", "nameZh": "Nama (Bahasa Cina)", "rank": "Kedudukan", "band": "Julat semasa", "status": "Status", "active": "Aktif", "retired": "Dihentikan", "activeLabel": "Aktif (kosongkan untuk menghentikan gred; ia tidak pernah dipadam)", "addBand": "Tambah julat", "saved": "Gred disimpan", "bandAdded": "Julat ditambah", "bandTitle": "Julat gaji baharu", "bandDescription": "Julat ini terpakai mulai hari ini dan menamatkan julat semasa.", "from": "Mulai", "min": "Minimum (RM)", "max": "Maksimum (RM)" }, "headcount": { "title": "Bilangan perjawatan", "subtitle": "Jawatan yang diluluskan dan diisi bagi setiap unit. Diisi mengira jawatan utama setiap orang sahaja.", "unit": "Unit", "position": "Jawatan", "grade": "Gred", "approved": "Diluluskan", "filled": "Diisi", "vacant": "Kosong", "openVacancies": "Kekosongan dibuka", "overBy_one": "Lebih {{count}}", "overBy_other": "Lebih {{count}}", "edit": "Sunting", "allowOver": "Benarkan melebihi perjawatan", "saved": "Jawatan disimpan", "noGrade": "Tiada gred", "unitSummary": "{{name}}: {{approved}} diluluskan · {{filled}} diisi · {{vacant}} kosong", "editTitle": "Sunting jawatan", "approvedField": "Bilangan perjawatan diluluskan", "overTitle": "Benarkan melebihi perjawatan", "overDescription": "Ahli lain kemudian boleh meletakkan orang ini walaupun jawatan sudah penuh. Sebab anda disimpan dalam jejak audit.", "member": "Orang yang diletakkan", "requester": "Dipohon oleh", "requesterHint": "Ahli yang memohon. Ia tidak boleh anda sendiri.", "pickMember": "Pilih ahli", "from": "Mulai", "reason": "Sebab", "allowed": "Dibenarkan; penempatan boleh dibuat sekarang", "empty": "Belum ada jawatan." }, "vacancies": { "title": "Kekosongan", "subtitle": "Permohonan mengisi jawatan, kelulusannya, dan berapa yang telah diisi.", "new": "Kekosongan baharu", "create": "Mohon", "empty": "Tiada kekosongan.", "allStatuses": "Semua status", "position": "Jawatan", "positionHint": "Jawatan diambil daripada carta organisasi; anda memerlukan akses struktur organisasi untuk melihatnya.", "pickPosition": "Pilih jawatan", "kind": "Jenis", "quantity": "Bilangan", "neededBy": "Diperlukan sebelum", "status": "Status", "filledOf": "{{filled}} daripada {{quantity}} diisi", "remaining": "Masih diperlukan", "approvedOn": "Diluluskan pada", "reason": "Sebab", "closeReason": "Ditutup kerana", "reasonField": "Sebab", "confirm": "Sahkan", "close": "Tutup kekosongan", "moved": "Kekosongan dikemas kini", "closed": "Kekosongan ditutup", "created": "Kekosongan dimohon", "loading": "Memuatkan…", "fills": "Mengisi kekosongan", "none": "Tiada kekosongan", "option": "{{position}} — {{remaining}} masih diperlukan" }, "errors": { "required": "Wajib.", "amount": "Masukkan jumlah seperti 2500.00.", "date": "Pilih tarikh.", "count": "Masukkan nombor bulat.", "reason": "Berikan sebab." }, "refusals": { "over_headcount": "Jawatan ini telah mencapai bilangan perjawatan yang diluluskan. Pemilik selain anda boleh membenarkan seorang lagi.", "within_headcount": "Jawatan ini masih ada ruang; tiada kebenaran diperlukan.", "override_by_requester": "Pemilik selain ahli yang memohon mesti membenarkannya.", "duplicate_grade_code": "Gred lain mempunyai kod ini.", "grade_retired": "Gred ini telah dihentikan.", "band_overlap": "Julat ini bertindih dengan julat lain bagi gred ini.", "vacancy_not_open": "Kekosongan ini tidak dibuka.", "stale_state": "Orang lain telah mengubah kekosongan ini; muat semula.", "workflow_missing": "Kekosongan belum disediakan dalam organisasi ini.", "revision_mismatch": "Orang lain telah mengubah rekod ini; muat semula." }}Create web/src/lib/i18n/locales/zh/hrPlanning.json:
{ "nav": { "grades": "职级", "headcount": "编制", "vacancies": "职位空缺" }, "cancel": "取消", "save": "保存", "kinds": { "new": "新增职位", "replacement": "补缺" }, "status": { "draft": "草稿", "pending": "待批准", "rejected": "已拒绝", "withdrawn": "已撤回", "open": "招聘中", "filled": "已补满", "closed": "已关闭" }, "grades": { "title": "职级", "subtitle": "职级、以令吉计的薪资范围,以及每个职位所属的职级。", "new": "新建职级", "edit": "编辑职级", "empty": "暂无职级。", "code": "代码", "name": "名称", "nameEn": "名称(英文)", "nameMs": "名称(马来文)", "nameZh": "名称(中文)", "rank": "等级", "band": "当前薪资范围", "status": "状态", "active": "启用", "retired": "已停用", "activeLabel": "启用(取消勾选即停用该职级;职级不会被删除)", "addBand": "添加薪资范围", "saved": "职级已保存", "bandAdded": "薪资范围已添加", "bandTitle": "新薪资范围", "bandDescription": "该范围自此日起适用,并结束当前范围。", "from": "起始日期", "min": "最低(令吉)", "max": "最高(令吉)" }, "headcount": { "title": "编制", "subtitle": "各单位的核定编制与在岗人数。在岗只计算每人的主要职位。", "unit": "单位", "position": "职位", "grade": "职级", "approved": "核定", "filled": "在岗", "vacant": "空缺", "openVacancies": "招聘中的空缺", "overBy_one": "超出 {{count}}", "overBy_other": "超出 {{count}}", "edit": "编辑", "allowOver": "允许超编", "saved": "职位已保存", "noGrade": "无职级", "unitSummary": "{{name}}:核定 {{approved}} · 在岗 {{filled}} · 空缺 {{vacant}}", "editTitle": "编辑职位", "approvedField": "核定编制", "overTitle": "允许超编", "overDescription": "其他成员随后可在职位已满时安排此人任职。您的原因会记入审计记录。", "member": "拟任职人员", "requester": "申请人", "requesterHint": "提出申请的成员,不能是您本人。", "pickMember": "选择成员", "from": "起始日期", "reason": "原因", "allowed": "已允许;现在可以安排任职", "empty": "暂无职位。" }, "vacancies": { "title": "职位空缺", "subtitle": "补充职位的申请、审批及已补人数。", "new": "新建空缺", "create": "提交申请", "empty": "暂无空缺。", "allStatuses": "全部状态", "position": "职位", "positionHint": "职位来自组织架构图;需要组织架构的访问权限才能看到。", "pickPosition": "选择职位", "kind": "类型", "quantity": "人数", "neededBy": "需到岗日期", "status": "状态", "filledOf": "已补 {{filled}} / {{quantity}}", "remaining": "尚需人数", "approvedOn": "批准日期", "reason": "原因", "closeReason": "关闭原因", "reasonField": "原因", "confirm": "确认", "close": "关闭空缺", "moved": "空缺已更新", "closed": "空缺已关闭", "created": "空缺已申请", "loading": "加载中…", "fills": "补充空缺", "none": "不关联空缺", "option": "{{position}} — 尚需 {{remaining}} 人" }, "errors": { "required": "必填。", "amount": "请输入金额,如 2500.00。", "date": "请选择日期。", "count": "请输入整数。", "reason": "请填写原因。" }, "refusals": { "over_headcount": "该职位已达核定编制。除您以外的所有者可以允许再增加一人。", "within_headcount": "该职位仍有编制,无需允许。", "override_by_requester": "须由申请人以外的所有者允许。", "duplicate_grade_code": "其他职级已使用此代码。", "grade_retired": "该职级已停用。", "band_overlap": "该薪资范围与此职级的其他范围重叠。", "vacancy_not_open": "该空缺未在招聘中。", "stale_state": "他人已变更此空缺,请刷新。", "workflow_missing": "本组织尚未设置职位空缺流程。", "revision_mismatch": "他人已修改此记录,请刷新。" }}- Step 9: Run the web checks
Run: pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test -- features/hr nav i18n
Expected: PASS (H1’s hr.test.tsx included; its fixtures hold no hr.vacancy.read, so the hire dialog there shows no vacancy picker).
- Step 10: Watch the override guard fail once
In HeadcountPage.tsx, change {canOverride && row.original.vacant === 0 && ( to {row.original.vacant === 0 && ( and run pnpm -C web test -- features/hr/__tests__/planning. Expected: FAIL — does not offer it without hr.headcount.override: the button is found. Restore; PASS.
- Step 11: Look at it
Start the stack (make dev or the project’s run skill), sign in as the Owner, and re-run provisioning for the organization if it predates this release (POST /v1/org/provision) so the vacancy workflow exists. Open Job grades: create a grade, add a band. Open Headcount: give a position a grade and a headcount of 1; with it full, assign a second primary holder on the org chart and read the over_headcount refusal; allow it from Headcount naming the member who asked, and assign again as that member. Open Vacancies: request a new vacancy as another member, approve it as the Owner, and see the position’s approved headcount rise by its quantity; record a hire from Employees that fills it, and see it read Filled. Check en, ms and zh, and the phone width (drawer full screen, no horizontal scroll).
- Step 12: Commit
git add web/srcgit commit -m "Phase 5 H2: grades, headcount and vacancy screens; a hire names the vacancy it fills
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"Task 11: Documentation and the full suite
Section titled “Task 11: Documentation and the full suite”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 rows.
-
Step 1: Tick the roadmap
In docs/ROADMAP.md, under ## Phase 5 — People, change - [ ] HR layer deferred from Phase 1: job grades, salary bands, headcount, vacancies to - [x] HR layer deferred from Phase 1: job grades, salary bands, headcount, vacancies, and replace H1’s line
- [ ] H2 grades, salary bands, headcount, vacancies (`00820`–`00839`)with:
- [x] H2 grades, salary bands, headcount, vacancies (`platform/hr`, `00820`–`00824`) Plan: [`2026-09-30-phase-5-h2-grades-headcount.md`](superpowers/plans/2026-09-30-phase-5-h2-grades-headcount.md) (11 tasks, Rulings H2-1–H2-18) Deploy: [`DEPLOY_NOTES.md`](DEPLOY_NOTES.md) — headcount backfilled from what seats hold; re-provision for the vacancy workflow - Job grades named as translation rows, retired not deleted; effective-dated bands, overlaps refused - The grade and the approved headcount belong to the seat (`org_position`); filled = primary holders - Over-headcount refused by trigger; an Owner who is not the requester overrides, used once (HD-4) - `vacancy` workflow ending in `committed`; a new vacancy's approval raises headcount once; hires fill it - Reports: headcount per unit and position; open vacancies and their age - Drift tests: over-headcount refused and overridden, acting not counted; raise once, replacement none (`internal/platform/hr`) - [ ] H2 follow-ups: vacancies in the approvals inbox; a transfer filling a vacancy; applicant tracking, offer letters, succession, a headcount budget in RM, promotions (spec "Out of H2")- Step 2: Write the deploy notes
In docs/DEPLOY_NOTES.md, insert after the first --- line (before the newest existing entry):
## Phase 5 H2 — grades, salary bands, headcount, vacancies
Migrations `00820`–`00824` (`00820` only if Phase 4 O1's `00700` had not landed first); permissionkeys `hr.grade.manage`, `hr.headcount.manage`, `hr.headcount.override`, `hr.vacancy.read`,`hr.vacancy.request`, `hr.vacancy.close`, `hr.report.read` (the Owner's; `00824` grants existingOwners and bumps `perm_version`); new workflow kind `vacancy`. No environment variable, no job.
- **Every existing position gets an approved headcount** (`00822`): the most people who ever held it as their primary at once, and at least 1. Nothing held today becomes a violation. A seat that was once job-shared keeps the larger number; HR should review them (Headcount screen) and lower any that are now too high — lowering ends no assignment.- **From `00822` a primary assignment beyond a position's approved headcount is refused** (`member_position_within_headcount`, HTTP 409 `over_headcount`), on the org chart and on an HR transfer alike. Scripts that seat people directly in SQL are refused too. The fix is to raise the headcount (`hr.headcount.manage`) or to have an Owner who is not the requester allow the one assignment (`hr.headcount.override`). Non-primary (acting) assignments are never refused.- **Existing organizations get the vacancy workflow by re-running provisioning** (step `workflow_definitions`; `POST /v1/org/provision` or `migrate provision --org … --owner-member …`). Until then requesting a vacancy answers 409 `workflow_missing`.- **The `committed` semantic and Phase 4 O1:** if this lane carried it (`00820`), O1's `00700` must use the `DROP CONSTRAINT IF EXISTS … workflow_state_semantic_check, … workflow_state_semantic_known` form before it lands on a database that ran `00820`; both create `workflow_state_semantic_known` with the list `constants.yaml` holds. `workflow.CommitmentKinds` gains `vacancy` (and H3's `leave_request`): whichever lane merges second keeps every kind.- **The Down-migrations lose records once H2 has been used:** `00823`'s drops the vacancies and every hire's link to one; `00822`'s drops every override and every headcount — roll the code back, not these migrations.- Step 3: Record the lock order
In docs/LOCK_ORDER.md, in H1’s “HR lifecycle (Phase 5 H1)” row, replace a transfer's target \org_position` `FOR SHARE`witha transfer’s target `org_position` `FOR NO KEY UPDATE` (Phase 5 H2)`. Add as the last rows of the “Per module” table:
| Seat assignment (Phase 5 H2) | `organization_member` `FOR SHARE` → `org_position` `FOR NO KEY UPDATE` (taken before `requireActiveSeat`'s `FOR SHARE`, so two assignments to one seat queue instead of deadlocking on an upgrade) → insert, where `00822`'s headcount trigger re-takes the seat (already held) → audit | `orgstruct/assignment.go` `Assign`; `db/migrations/00822_headcount.sql` || HR headcount and vacancies (Phase 5 H2) | override grant: `organization` `FOR UPDATE` → `org_position` `FOR NO KEY UPDATE` (`orgstruct.LockSeat`) → insert → audit. Vacancy transition: `organization` `FOR UPDATE` in a one-member organization (`workflow.LockSoleOwner`) → `vacancy` `FOR UPDATE` → `org_position` `FOR NO KEY UPDATE` when approving a new vacancy → `workflow.Transition` → `vacancy.headcount_raised_at` and `orgstruct.SetHeadcount` (rows already held) → audit. Close: `vacancy` `FOR UPDATE` → audit. A hire naming a vacancy: H1's order, and `employee_event_vacancy_guard` takes `vacancy` `FOR UPDATE` before the audit row | `hr/headcount.go` `GrantHeadcountOverride`; `hr/vacancy.go` `TransitionVacancy`, `CloseVacancy`; `db/migrations/00823_vacancy.sql` |- 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 beyond this task’s docs); every Go and web test passes, including TestEveryTableIsTenantIsolated, TestEveryTenantForeignKeyIsCompositeOnOrgID, TestEveryTenantQueryCarriesAnExplicitOrgPredicate, TestEveryConstraintIsNamed, TestEveryFunctionPinsItsSearchPath, TestOnlyOperationalTablesGrantDeleteToTheAppRole, TestEveryRouteIsAuthorized, TestTheOpenAPIDocumentSaysWhatTheRouterEnforces, TestEverySeededGraphPassesTheValidator, TestEverySeededGraphShipsAllThreeLocales, TestEverySecurityDefinerIsAllowlistedAndNotPublic, the H1 drift tests and the H2 drift tests. A failure naming member_position_within_headcount in a package Task 4 Step 6 did not run is a job-sharing fixture: raise its seat’s headcount as Task 4 Step 5 does.
- Step 5: Commit
git add docs/ROADMAP.md docs/DEPLOY_NOTES.md docs/LOCK_ORDER.mdgit commit -m "Phase 5 H2: 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 (§H2, HD-4, HD-5, the H2 drift bullets):
| Spec | Where |
|---|---|
job_grade: code, name as translation rows, numeric rank, active; retired never deleted |
Task 2 (table, DELETE revoked), Task 3 (CreateGrade, UpdateGrade, names in translation), Ruling H2-2 |
grade_band: NUMERIC(19,4), effective-dated; overlap and min > max refused by the database |
Task 2 (grade_band_no_overlap, grade_band_min_le_max, grade_band_fixed), Task 3 (AddBand), Ruling H2-3 |
The grade belongs to the seat (position.grade_id, nullable); nothing checks salary yet (HD-5) |
Task 4 (column), Task 5 (orgstruct.SetGrade, hr.SetPositionGrade), Ruling H2-4 |
approved_headcount ≥ 0, default 1; unit’s = sum of its positions’; filled = primary only |
Task 4 (column, backfill), Task 5 (orgstruct.Headcount), Task 8 (report), Rulings H2-5, H2-7, H2-16 |
| Over-headcount blocked by a trigger that locks the position row; Owner-not-requester override, append-only row + audit (HD-4) | Task 4 (trigger, headcount_override), Task 5 (GrantHeadcountOverride, Assign’s lock and mapping, Transfer’s lock), Rulings H2-6, H2-8–H2-10 |
vacancy (position, kind, quantity, needed-by, reason); own workflow, approver from grants; ends in committed |
Task 1 (committed), Task 6 (table, graph, CommitmentKinds, sole-owner opt-in), Rulings H2-1, H2-11 |
Approving a new vacancy raises approved headcount by its quantity; replacement changes nothing |
Task 6 (TransitionVacancy, vacancy_fixed, vacancy_raise_only_new), Ruling H2-12 |
| Filling: a hire links to a vacancy; tracks remaining; closes when met or by hand with a reason (audited) | Task 6 (employee_event.vacancy_id, guard), Task 7 (HireInput.VacancyID, CloseVacancy), Ruling H2-13 |
| Reports (JSON): approved/filled/vacant per unit and position; open vacancies and age | Task 8, Task 9 (/hr/reports/*), Ruling H2-16 |
Permissions hr.grade.manage, hr.headcount.manage, hr.vacancy.*, hr.report.read |
Task 9 (plus hr.headcount.override, Ruling H2-17; 00824 grants) |
| Drift: beyond headcount refused and accepted with the override; acting not counted | Task 5 TestAnAssignmentBeyondHeadcountIsRefusedAndAcceptedWithAnOwnerOverride, watched failing three ways in Step 11; Task 4’s migration test |
Drift: a new vacancy’s approval raises headcount once; a replacement changes nothing |
Task 6 TestANewVacancysApprovalRaisesHeadcountOnceAndAReplacementChangesNothing, watched failing in Step 7 |
| Screens (grades and bands, headcount by unit, vacancies list and drawer), en/ms/zh | Task 10 |
| ROADMAP, DEPLOY_NOTES, LOCK_ORDER; full suite | Task 11 |
Placeholder scan: no TBD/TODO; every code step carries its code; every “watch it fail” step names the edit and the expected failure. Task 1 executes O1 Task 1 by reference with an exact substitution table, as O4 Task 1 does for O2 — the carried task is O1’s, not a second design.
Type consistency: hr.Names, hr.Grade, hr.Band, hr.HeadcountOverride, hr.Vacancy and their inputs are defined once (Tasks 3, 5, 6, 7) and used with the same field names in Tasks 8–9’s DTOs; orgstruct.Headcount takes (ctx, tx, on, only) everywhere (Tasks 5, 8); orgstruct.LockSeat returns Position and is used so in Tasks 5 and 6; hr.ErrHeadcountOverrideByRequester (not H1’s ErrOverrideByRequester, whose text is about a last day) is the headcount refusal in Tasks 5 and 9. The web types come from Schemas[...] generated in Task 9; Available is Schemas['WorkflowAvailableTransition']. Test helpers shared across hr_test files (createGrade, names, fieldRule, mustDay, assignTo, grantHeadcount, raise, raiseBy, move, moveErr, approve, approvedHeadcount, newPerson, hireInto, vacancyNow) are each defined once.
Known limits, deliberately: the two-person rule for an override is recorded on the override (requested_by) because member_position records no actor (Ruling H2-9); the sole-owner override in practice covers only an Owner seating themselves (H2-10); vacancies do not appear in the approvals inbox yet, and a transfer does not fill a vacancy (ROADMAP follow-ups).