Task — engineering-spec@1

"HR 8 leave types — annual/sick/maternity/paternity/sabbatical/unpaid/bereavement/public_holiday with per-type accrual + approval rules"

draftTASK-HR-004
module hr · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-HR-001 · blocks TASK-HR-006

§1 — Description (BCP-14 normative)

The HR service MUST ship leave types at services/hr/src/leave/ with 8 types + per-type entitlement + per-type approval routing + balance tracking, 5 memory audit kinds.

  1. MUST validate leave_type against closed enum per DEC-1831.
  1. MUST validate leave_status against closed enum per DEC-1832.
  1. MUST compute entitlement per DEC-1834 at entitlement_calc.rs::compute(member, leave_type, year):
  1. MUST route approval per DEC-1833 at approval_router.rs::route(request):
  1. MUST define table at migration 0004: ``sql CREATE TABLE hr_leave_requests ( request_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL, leave_type TEXT NOT NULL CHECK (leave_type IN ('annual','sick','maternity','paternity','sabbatical','unpaid','bereavement','public_holiday')), start_date DATE NOT NULL, end_date DATE NOT NULL CHECK (end_date >= start_date), days_count NUMERIC(5,2) NOT NULL CHECK (days_count > 0), reason TEXT, status TEXT NOT NULL DEFAULT 'requested' CHECK (status IN ('requested','approved','rejected','taken','cancelled')), approver_id UUID, approved_at TIMESTAMPTZ, rejection_reason TEXT, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX leave_member_year_idx ON hr_leave_requests(tenant_id, member_id, EXTRACT(YEAR FROM start_date)); ALTER TABLE hr_leave_requests ENABLE ROW LEVEL SECURITY; CREATE POLICY leave_rls ON hr_leave_requests USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON hr_leave_requests FROM cyberos_app; GRANT UPDATE (status, approver_id, approved_at, rejection_reason) ON hr_leave_requests TO cyberos_app; ``
  1. MUST prevent over-entitlement per DEC-1834 — at approval, check member's running balance for the type+year; reject if exceeds.
  1. MUST emit 5 memory audit kinds per DEC-1835. PII per TASK-MEMORY-111: reason text SHA-256 hashed; member_id (uuid) + dates ok.
  1. MUST thread trace_id from request → router → approver → status update → audit.
  1. MUST NOT bypass approval gate per DEC-1833 (manager+CHRO required for >5d unpaid).
  1. MUST NOT exceed type entitlement per DEC-1834.

§2 — Why this design

Why 8 types (DEC-1830)? Covers VN Labour Code + business need (sabbatical for senior retention). Closed enum prevents type sprawl.

Why per-type approval (DEC-1833)? Sabbatical/unpaid have business impact (replacement cost); CHRO oversight. Bereavement should be friction-free.

Why statutory entitlements (DEC-1834)? Decree 145/2020 mandates specific days; auto-enforce to avoid VN Labour inspector findings.

Why per-type balance (DEC-1834)? Sick days don't deduct from annual; separate accrual prevents under-utilization (members feeling they "spend" days when sick).


§3 — API contract

POST   /v1/hr/leave-requests                  body: {leave_type, start_date, end_date, reason?}
POST   /v1/hr/leave-requests/{id}/approve     (manager/CHRO)
POST   /v1/hr/leave-requests/{id}/reject      body: {reason}
POST   /v1/hr/leave-requests/{id}/cancel      (member-self before approval)
GET    /v1/hr/members/{id}/leave-balance      (per-type balance for current year)

Sample balance:

{
  "year": 2026,
  "balances": [
    {"leave_type": "annual", "entitled": 12, "taken": 5, "pending": 2, "remaining": 5},
    {"leave_type": "sick", "entitled": 30, "taken": 3, "pending": 0, "remaining": 27}
  ]
}

§4 — Acceptance criteria

  1. leave_type enum cardinality 8. 2. leave_status enum cardinality 5. 3. Per-type entitlement computed. 4. Per-type approval routing. 5. Balance deduction on status=taken. 6. Over-entitlement rejected at approve. 7. Manager+CHRO co-sign for unpaid >5d. 8. Auto-approve bereavement ≤3d. 9. Auto-apply public_holiday. 10. 5 memory audit kinds emitted. 11. PII scrubbed (reason SHA256). 12. RLS denies cross-tenant. 13. Trace_id preserved. 14. Cancel before approve allowed. 15. Cancel after approve requires CHRO. 16. Append-only via REVOKE except 4 status cols. 17. Contractor → 0 annual entitlement (TASK-HR-002 override). 18. Part_time → pro-rated (TASK-HR-002 override). 19. Sick SI-funded note in memory audit. 20. Year boundary handled (Dec 31 → Jan 1 transition).

§5 — Verification

#[tokio::test]
async fn annual_entitlement_12d() {
    let ctx = TestContext::with_indefinite_member().await;
    let bal = ctx.fetch_balance(ctx.member_id, "annual", 2026).await;
    assert_eq!(bal.entitled, 12);
}

#[tokio::test]
async fn unpaid_over_5d_requires_chro() {
    let ctx = TestContext::with_member_and_manager().await;
    let req = ctx.request_leave("unpaid", 10).await;
    let r = ctx.try_approve_as_manager(req.id).await;
    assert!(r.is_err());  // Manager alone insufficient
    let r2 = ctx.approve_as_chro(req.id).await;
    assert!(r2.is_ok());
}

#[tokio::test]
async fn over_entitlement_rejected() {
    let ctx = TestContext::with_member_used_12_annual().await;
    let req = ctx.request_leave("annual", 5).await;
    let r = ctx.try_approve(req.id).await;
    assert!(r.is_err());
}

#[tokio::test]
async fn contractor_zero_annual() {
    let ctx = TestContext::with_contractor_member().await;
    let bal = ctx.fetch_balance(ctx.member_id, "annual", 2026).await;
    assert_eq!(bal.entitled, 0);
}

// 5.5..5.10

§7 — Dependencies

Upstream: TASK-HR-001. Cross-module: TASK-HR-002 (contract type override), TASK-HR-006 (accrual cron), TASK-AUTH-101 (CHRO role), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Over-entitlementbalance check400reduce request
Type not in enumCHECK constraint400use valid
Date range invalidvalidate400fix
Manager not setroute fallback to CHROinherentdata fix
Approve already approvedstatus check409inherent
Cancel after takenreject400inherent
Year boundary leavesplit into 2 requestsmanualinherent
Sabbatical at probationreject per HR-002inherentpost-probation
Maternity claim without recordreject400data setup
Cross-tenant requestRLS404inherent

§11 — Implementation notes


End of TASK-HR-004 spec.