Task — engineering-spec@1

"REW parameter versioning — immutable versioned formula parameters with 100% replay-equivalence on prior payslips"

draftTASK-REW-002
module rew · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-REW-001 · blocks none

§1 — Description (BCP-14 normative)

The REW service MUST ship parameter versioning at services/rew/src/params/ with immutable snapshots + version-pinned payslip computation + monthly replay-equivalence CI, 4 memory audit kinds.

  1. MUST validate param_kind against closed enum per DEC-2161.
  1. MUST define tables at migration 0002: ``sql CREATE TABLE rew_param_versions ( version_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('tax_bracket','si_rate','overtime_multiplier','allowance_cap','bonus_formula')), value_jsonb JSONB NOT NULL, effective_from DATE NOT NULL, effective_to DATE, source_law_reference TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_by UUID NOT NULL ); CREATE INDEX param_kind_effective_idx ON rew_param_versions(tenant_id, kind, effective_from DESC); ALTER TABLE rew_param_versions ENABLE ROW LEVEL SECURITY; CREATE POLICY param_rls ON rew_param_versions USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON rew_param_versions FROM cyberos_app; ``
  1. MUST lookup at loader.rs::get(tenant_id, kind, effective_at) per DEC-2162:
  1. MUST run monthly replay-equivalence test via TASK-MCP-007 per DEC-2163:
  1. MUST expose endpoints: ``text POST /v1/rew/params (CFO; new version) GET /v1/rew/params/{kind}?at=... (lookup at date) POST /v1/rew/replay-test/trigger (CFO manual) ``
  1. MUST emit 4 memory audit kinds per DEC-2164. PII per TASK-MEMORY-111: param values (mostly rates/brackets) ok in memory as they're public; member-specific compute hashes only.
  1. MUST thread trace_id from lookup → audit.
  1. MUST NOT mutate prior version per DEC-2162 (REVOKE UPDATE/DELETE).
  1. MUST NOT use now() or random in compute path per DEC-2163.

§2 — Why this design

Why versioning (DEC-2160)? TASK-REW-005 payroll must reproduce historical payslips exactly; mutable params break replay.

Why replay test (DEC-2163)? Without automated check, drift creeps in unnoticed; monthly CI catches early.

Why immutable (DEC-2162)? Audit lineage requires unmutable history; corrections via new version with new effective_from.


§3 — API contract

Sample param lookup:

GET /v1/rew/params/tax_bracket?at=2026-06-01

{
  "version_id": "uuid",
  "kind": "tax_bracket",
  "value_jsonb": [
    {"min_vnd": 0, "max_vnd": 5000000, "rate": 0.05},
    {"min_vnd": 5000000, "max_vnd": 10000000, "rate": 0.10}
  ],
  "effective_from": "2025-01-01",
  "source_law_reference": "Decree 152/2020 Art. 7"
}

§4 — Acceptance criteria

  1. param_kind enum cardinality 5. 2. Immutable rows (REVOKE). 3. Lookup by effective_at. 4. Replay test monthly via cron. 5. 100% match for prior periods. 6. Failure → sev-1 + CI block. 7. 4 memory audit kinds emitted. 8. PII: param values public; compute hashed. 9. RLS denies cross-tenant. 10. CFO-only write. 11. Trace_id preserved. 12. Append-only via REVOKE. 13. JSONB schema validated per kind. 14. Source law reference recommended. 15. Effective_to NULL = current. 16. Index on (kind, effective_from). 17. Lookup performance < 5ms. 18. Annual refresh runbook. 19. Replay test produces diff report on failure. 20. Deterministic (no now/random).

§5 — Verification

#[tokio::test]
async fn version_lookup_at_date() {
    let ctx = TestContext::with_two_tax_bracket_versions("2024-01", "2025-01").await;
    let v_2024 = ctx.get_param("tax_bracket", "2024-06-01").await;
    let v_2025 = ctx.get_param("tax_bracket", "2025-06-01").await;
    assert_ne!(v_2024.version_id, v_2025.version_id);
}

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

#[tokio::test]
async fn replay_equivalence_100pct() {
    let ctx = TestContext::with_12_months_payslips().await;
    let result = ctx.run_replay_test().await;
    assert_eq!(result.match_pct, dec!(100.0));
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-REW-001. Downstream: TASK-REW-005 (payroll compute uses versioned params). Cross-module: TASK-HR-005 (compares versioning approach), TASK-MCP-007 (replay cron), TASK-MEMORY-111 (audit).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Replay test failsev-1 alertCI blockinvestigate diff
Lookup at date with no versionerror404seed gap
Two versions same dateUNIQUE on (kind, effective_from)rejectuse later date
JSONB schema invalidvalidator400fix shape
Cross-tenant queryRLS0 rowsinherent
Non-CFO writerole check403request CFO
Decimal precision driftrust_decimalinherentinherent
Cron skippedcatch-upinherentinherent
Test data corruptsev-1manual reviewdata fix
Param value range invalidrange check400fix

§11 — Implementation notes


End of TASK-REW-002 spec.