Task — engineering-spec@1

"HR Decree 145/2020 working-hour caps + Decree 152/2020 SI rates — version-pinned policy constants with annual refresh + tenant override"

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

§1 — Description (BCP-14 normative)

The HR service MUST ship policy constants at services/hr/src/policy/ with version-pinned snapshots (Decree 145 + 152), per-version immutability, tenant override for non-statutory, 3 memory audit kinds.

  1. MUST validate policy_kind against closed enum per DEC-1841.
  1. MUST seed initial Decree 145 + 152 values at seed_decree_145_152.rs::seed() — Phase 1 migration.
  1. MUST lookup at loader.rs::get(tenant_id, kind, effective_at):
  1. MUST define tables at migration 0005: ```sql CREATE TABLE hr_policy_versions ( version_id UUID PRIMARY KEY, kind TEXT NOT NULL CHECK (kind IN ('working_hour_cap','si_rate_bhxh','si_rate_bhyt','si_rate_bhtn','pit_bracket','minimum_wage')), value_jsonb JSONB NOT NULL, effective_from DATE NOT NULL, effective_to DATE, source_law_reference TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID NOT NULL ); CREATE INDEX policy_kind_effective_idx ON hr_policy_versions(kind, effective_from DESC, effective_to); REVOKE UPDATE, DELETE ON hr_policy_versions FROM cyberos_app; -- Append-only immutable; per DEC-1842

CREATE TABLE hr_tenant_policy_overrides ( tenant_id UUID NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('working_hour_cap','minimum_wage')), -- only non-statutory override_value_jsonb JSONB NOT NULL, effective_from DATE NOT NULL, set_by UUID NOT NULL, PRIMARY KEY (tenant_id, kind, effective_from) ); ALTER TABLE hr_tenant_policy_overrides ENABLE ROW LEVEL SECURITY; CREATE POLICY tenant_override_rls ON hr_tenant_policy_overrides USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON hr_tenant_policy_overrides FROM cyberos_app; ```

  1. MUST allow tenant override only for non-statutory per DEC-1843 — CHECK constraint enforces.
  1. MUST expose endpoints: ``text POST /v1/hr/policy-versions (sys-admin only) GET /v1/hr/policy?kind=X&at=DATE PUT /v1/hr/tenant-policy-override (CHRO only; non-statutory only) ``
  1. MUST emit 3 memory audit kinds per DEC-1844. PII: none (policy values public).
  1. MUST thread trace_id from lookup → loader → audit.
  1. MUST NOT mutate prior version per DEC-1842 (REVOKE UPDATE/DELETE).
  1. MUST NOT allow tenant override of SI/PIT/BHXH per DEC-1843 (CHECK constraint).

§2 — Why this design

Why version-pinned (DEC-1842)? TASK-REW-004 must reproduce historical payslips deterministically; mutable rates break replay.

Why per-kind enum (DEC-1841)? Closed set of statutory/business policy types; prevents ad-hoc additions.

Why tenant override gate (DEC-1843)? Tenants can offer better-than-minimum (e.g. shorter working hours), but cannot lower SI obligations.

Why annual refresh (DEC-1840)? VN policy law updates annually (esp. minimum wage); refresh adds new version row, doesn't mutate.


§3 — API contract

Sample policy lookup:

GET /v1/hr/policy?kind=working_hour_cap&at=2026-06-01

Response:

{
  "kind": "working_hour_cap",
  "value_jsonb": {"regular_per_week": 48, "ot_per_week_max": 12, "ot_per_year_max": 200},
  "effective_from": "2021-01-01",
  "effective_to": null,
  "source_law_reference": "Decree 145/2020 Art. 107"
}

§4 — Acceptance criteria

  1. 6-kind enum + cardinality test. 2. Initial seed populated. 3. Version pinning correct (effective_at lookup). 4. Immutability enforced (REVOKE UPDATE/DELETE). 5. Tenant override gated to non-statutory (CHECK). 6. Statutory override attempt rejected (400). 7. 3 memory audit kinds emitted. 8. Replay determinism. 9. Annual refresh adds new version row. 10. Source law reference required. 11. RLS on tenant_overrides. 12. Sys-admin only for global. 13. CHRO only for tenant override. 14. Trace_id preserved. 15. Lookup performance < 5ms (index). 16. TASK-TIME-007 reads OT caps from this. 17. TASK-REW-004 reads SI rates from this. 18. JSONB schema validated per kind. 19. Effective_to NULL means current. 20. Annual seed runbook documented.

§5 — Verification

#[tokio::test]
async fn working_hour_cap_seeded() {
    let ctx = TestContext::with_seed().await;
    let v = ctx.policy_get("working_hour_cap", "2026-01-01").await;
    assert_eq!(v.value_jsonb["regular_per_week"], 48);
}

#[tokio::test]
async fn version_pinning_replay() {
    let ctx = TestContext::with_two_versions("minimum_wage", "2024-01-01", "2025-01-01").await;
    let v1 = ctx.policy_get("minimum_wage", "2024-06-01").await;
    let v2 = ctx.policy_get("minimum_wage", "2025-06-01").await;
    assert_ne!(v1.value_jsonb, v2.value_jsonb);
}

#[tokio::test]
async fn immutability_enforced() {
    let ctx = TestContext::with_seed().await;
    let r = ctx.try_update_policy(ctx.version_id).await;
    assert!(r.is_err());
}

#[tokio::test]
async fn statutory_override_rejected() {
    let ctx = TestContext::with_chro().await;
    let r = ctx.try_set_override("si_rate_bhxh", 10.0).await;
    assert_eq!(r.status_code, 400);
}

// 5.5..5.10

§7 — Dependencies

Upstream: TASK-HR-001. Downstream: TASK-TIME-007 (OT caps), TASK-REW-004 (SI rates). Cross-module: TASK-AUTH-101 (CHRO + sys-admin roles), TASK-MEMORY-111.

§10 — Failure modes

FailureDetectionOutcomeRecovery
Kind not in enumCHECKreject 400use valid
Effective_from in pastwarnallow (backdated)inherent
Two versions same effective_fromUNIQUEreject duplicateuse later date
JSONB schema invalidvalidatorreject 400fix structure
Lookup at date with no versionerror404seed gap
Statutory override attemptCHECK400use non-statutory
Cross-tenant override lookupRLS0 rowsinherent
Annual seed missedsev-2 alertuse prior versionrunbook fix
Policy law deprecatednew versioninherentmaintenance
Tenant override conflictper-tenant indexinherentinherent

§11 — Implementation notes


End of TASK-HR-005 spec.