Task — engineering-spec@1

"HR annual leave accrual nightly batch — Decree 145 formula (1d/month + 1d/5yr seniority bonus) with immutable accrual ledger"

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

§1 — Description (BCP-14 normative)

The HR service MUST ship leave accrual at services/hr/src/accrual/ running nightly via TASK-MCP-007 cron, computing base + seniority per Decree 145, immutable ledger, 4 memory audit kinds.

  1. MUST schedule batch at 02:00 tenant_tz per DEC-1850.
  1. MUST validate accrual_kind against closed enum per DEC-1852.
  1. MUST compute per-month accrual at monthly_batch.rs::accrue(member, year_month):
  1. MUST be idempotent per DEC-1854 — UNIQUE on (member_id, year_month, kind); ON CONFLICT DO NOTHING.
  1. MUST support correction at correction_handler.rs::add_correction(member, year_month, days, reason) per DEC-1853 — new row with kind='correction' + sign.
  1. MUST define table at migration 0006: ``sql CREATE TABLE hr_leave_accrual_ledger ( ledger_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL, year_month CHAR(7) NOT NULL, -- 'YYYY-MM' kind TEXT NOT NULL CHECK (kind IN ('monthly_base','seniority_bonus','correction','carryover')), days_added NUMERIC(5,2) NOT NULL, reason TEXT, applied_by UUID, -- system or CHRO uuid trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant_id, member_id, year_month, kind) ); CREATE INDEX accrual_member_year_idx ON hr_leave_accrual_ledger(tenant_id, member_id, year_month DESC); ALTER TABLE hr_leave_accrual_ledger ENABLE ROW LEVEL SECURITY; CREATE POLICY accrual_rls ON hr_leave_accrual_ledger 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_accrual_ledger FROM cyberos_app; -- Append-only per DEC-1853 ``
  1. MUST expose endpoints: ``text POST /v1/hr/accrual/run-batch (manual trigger, CHRO) POST /v1/hr/accrual/corrections (CHRO; new correction row) GET /v1/hr/members/{id}/accrual-ledger (history) ``
  1. MUST emit 4 memory audit kinds per DEC-1855. PII per TASK-MEMORY-111: reason SHA-256 hashed.
  1. MUST thread trace_id from cron → batch → ledger insert → audit.
  1. MUST NOT mutate prior accrual per DEC-1853.
  1. MUST NOT double-credit per DEC-1854 (UNIQUE).

§2 — Why this design

Why nightly cron (DEC-1850)? Daily run catches month-end transitions; weekly delay = members short on leave for 7d.

Why immutable ledger (DEC-1853)? Audit lineage; corrections via new row preserve "who changed what when".

Why idempotent (DEC-1854)? Cron may retry on failure; double-run must not double-credit.

Why seniority bonus (DEC-1851)? Decree 145 Art. 66 mandates for hazardous/heavy industries; default 0 for office, configurable per tenant.


§3 — API contract

Sample accrual ledger row:

{
  "ledger_id": "uuid",
  "member_id": "uuid",
  "year_month": "2026-06",
  "kind": "monthly_base",
  "days_added": 1.00,
  "applied_by": "system",
  "created_at": "2026-07-01T02:00:00Z"
}

Sample correction:

POST /v1/hr/accrual/corrections
{
  "member_id": "uuid",
  "year_month": "2026-05",
  "days_added": 0.5,
  "reason": "Late hire mid-May; pro-rate adjustment"
}

§4 — Acceptance criteria

  1. Nightly batch 02:00 tenant_tz. 2. 1d/month base accrual. 3. Seniority bonus configurable per tenant (default 0). 4. Pro-rate respects TASK-HR-002 contract type. 5. Inactive members skipped. 6. Idempotent via UNIQUE. 7. Correction kind allows manual adj. 8. kind enum cardinality 4. 9. 4 memory audit kinds emitted. 10. PII scrubbed (reason SHA256). 11. RLS denies cross-tenant. 12. Trace_id preserved. 13. Append-only via REVOKE. 14. History query desc time. 15. CHRO-only manual trigger. 16. CHRO-only correction. 17. Year_month format 'YYYY-MM' enforced. 18. Cron skip if 0 active members. 19. Days_added precision 2 decimal (rust_decimal). 20. Carryover kind for year-end roll.

§5 — Verification

#[tokio::test]
async fn monthly_base_accrual() {
    let ctx = TestContext::with_active_indefinite_member().await;
    ctx.run_accrual_batch(ctx.tenant_id, "2026-06").await;
    let ledger = ctx.fetch_ledger(ctx.member_id).await;
    assert!(ledger.iter().any(|r| r.kind == "monthly_base" && r.year_month == "2026-06" && r.days_added == dec!(1.0)));
}

#[tokio::test]
async fn idempotent_double_run() {
    let ctx = TestContext::with_active_member().await;
    ctx.run_accrual_batch(ctx.tenant_id, "2026-06").await;
    ctx.run_accrual_batch(ctx.tenant_id, "2026-06").await;
    let ledger = ctx.fetch_ledger(ctx.member_id).await;
    let count = ledger.iter().filter(|r| r.kind == "monthly_base" && r.year_month == "2026-06").count();
    assert_eq!(count, 1);
}

#[tokio::test]
async fn correction_creates_new_row() {
    let ctx = TestContext::with_accrual_for_member("2026-05").await;
    ctx.add_correction(ctx.member_id, "2026-05", dec!(0.5), "late hire").await;
    let ledger = ctx.fetch_ledger(ctx.member_id).await;
    assert!(ledger.iter().any(|r| r.kind == "correction" && r.days_added == dec!(0.5)));
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-HR-004. Cross-module: TASK-HR-002 (contract pro-rate), TASK-MCP-007 (cron), TASK-AUTH-101 (CHRO), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Batch fails mid-runpartial insertssev-2; resume from last successfulre-run safe
Duplicate cron instanceUNIQUEsecond skippedinherent
Member inactive mid-monthskipinherentdata integrity
Year_month format wrongvalidatereject 400use YYYY-MM
Correction without reasonwarn but allowinherentaudit visible
Cross-tenant correctionRLS403inherent
Days_added negativeallow (correction reduces)inherentinherent
Pro-rate computation wrongunit testsinherentcode fix
Cron skipped (system down)catch-up next bootinherentinherent
Seniority bonus misconfigureddefault 0inherenttenant config fix

§11 — Implementation notes


End of TASK-HR-006 spec.