Task — engineering-spec@1

"REW monthly payroll compute + CFO+CHRO co-sign commit gate — orchestrates 3P + deductions + net pay with dual-sign before bank send"

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

§1 — Description (BCP-14 normative)

The REW service MUST ship monthly payroll compute at services/rew/src/payroll/ with 3P orchestration + dual-sign commit + immutable post-commit, 6 memory audit kinds.

  1. MUST validate payroll_status against closed enum per DEC-2191.
  1. MUST compute at compute.rs::compute(tenant, period) per DEC-2190:
  1. MUST require CFO + CHRO dual-sign at dual_sign_gate.rs::can_commit(payroll) per DEC-2192:
  1. MUST make immutable post-committed per DEC-2193 — REVOKE UPDATE on payroll_runs after status=committed (via trigger or app-layer check).
  1. MUST define tables at migration 0005: ```sql CREATE TABLE rew_payroll_runs ( run_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, period_yyyymm CHAR(7) NOT NULL, status TEXT NOT NULL DEFAULT 'drafting' CHECK (status IN ('drafting','computed','cfo_signed','chro_signed','committed','paid','failed')), total_gross_vnd BIGINT, total_net_vnd BIGINT, members_count INT NOT NULL DEFAULT 0, cfo_signed_by UUID, cfo_signed_at TIMESTAMPTZ, chro_signed_by UUID, chro_signed_at TIMESTAMPTZ, committed_at TIMESTAMPTZ, paid_at TIMESTAMPTZ, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant_id, period_yyyymm) ); ALTER TABLE rew_payroll_runs ENABLE ROW LEVEL SECURITY; CREATE POLICY runs_rls ON rew_payroll_runs USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON rew_payroll_runs FROM cyberos_app; GRANT UPDATE (status, total_gross_vnd, total_net_vnd, members_count, cfo_signed_by, cfo_signed_at, chro_signed_by, chro_signed_at, committed_at, paid_at) ON rew_payroll_runs TO cyberos_app;

CREATE TABLE rew_payslip_rows ( payslip_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, run_id UUID NOT NULL REFERENCES rew_payroll_runs(run_id), member_id UUID NOT NULL, gross_vnd BIGINT NOT NULL, deductions_total_vnd BIGINT NOT NULL, net_vnd BIGINT NOT NULL, deductions_jsonb JSONB NOT NULL, income_components_jsonb JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (run_id, member_id) ); ALTER TABLE rew_payslip_rows ENABLE ROW LEVEL SECURITY; CREATE POLICY payslip_rls ON rew_payslip_rows USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON rew_payslip_rows FROM cyberos_app; ```

  1. MUST expose endpoints: ``text POST /v1/rew/payroll/runs (CFO draft new run) POST /v1/rew/payroll/runs/{id}/compute (CFO trigger compute) POST /v1/rew/payroll/runs/{id}/cfo-sign POST /v1/rew/payroll/runs/{id}/chro-sign POST /v1/rew/payroll/runs/{id}/commit (auto if both signed) GET /v1/rew/payroll/runs/{id} (status + summary) ``
  1. MUST emit 6 memory audit kinds per DEC-2194. PII per TASK-MEMORY-111: amounts SHA256.
  1. MUST thread trace_id from draft → compute → sign → commit → audit.
  1. MUST NOT commit without dual-sign per DEC-2192.
  1. MUST NOT mutate committed payroll per DEC-2193.
  1. MUST be deterministic — replay produces byte-identical totals per TASK-REW-002.

§2 — Why this design

Why dual-sign (DEC-2192)? Payroll = largest single financial event monthly; single-signer governance gap.

Why immutable post-commit (DEC-2193)? Bank send happens; mutating after creates reconciliation chaos.

Why prior-period adjustment via new run (DEC-2193)? Corrections create new run with negative entries; preserves history.

Why 7-state lifecycle (DEC-2191)? Captures real workflow: draft → compute → review → sign → commit → bank → paid.


§3 — API contract

Sample payroll run:

{
  "run_id": "uuid",
  "period_yyyymm": "2026-06",
  "status": "computed",
  "total_gross_vnd": 1200000000,
  "total_net_vnd": 925000000,
  "members_count": 30
}

§4 — Acceptance criteria

  1. payroll_status enum cardinality 7. 2. Compute orchestrates 3P + deductions. 3. Net = gross - deductions. 4. CFO + CHRO dual-sign required for commit. 5. Same person rejected. 6. UNIQUE(tenant, period_yyyymm). 7. 6 memory audit kinds emitted. 8. PII scrubbed (amounts SHA256). 9. RLS denies cross-tenant. 10. Trace_id preserved. 11. Append-only via REVOKE except status cols. 12. Immutable post-commit (status can advance only). 13. Bigint VND. 14. Deterministic replay. 15. Per-member payslip row stored. 16. Deductions JSONB + income_components JSONB cached. 17. CFO-only draft + compute. 18. Status workflow enforced. 19. Failed compute → status=failed; reason logged. 20. Correction via new run (prior-period adjustment pattern).

§5 — Verification

#[tokio::test]
async fn dual_sign_required_for_commit() {
    let ctx = TestContext::with_computed_payroll().await;
    let r = ctx.try_commit_without_signs(ctx.run_id).await;
    assert!(r.is_err());
    ctx.cfo_sign(ctx.run_id).await;
    let r2 = ctx.try_commit(ctx.run_id).await;
    assert!(r2.is_err());  // CHRO missing
    ctx.chro_sign(ctx.run_id).await;
    let r3 = ctx.commit(ctx.run_id).await;
    assert!(r3.is_ok());
}

#[tokio::test]
async fn immutable_post_commit() {
    let ctx = TestContext::with_committed_payroll().await;
    let r = ctx.try_mutate_payslip_row(ctx.payslip_id).await;
    assert!(r.is_err());
}

#[tokio::test]
async fn deterministic_replay() {
    let ctx = TestContext::with_member_data().await;
    let r1 = ctx.compute_payroll("2026-06").await;
    let r2 = ctx.compute_payroll("2026-06").await;
    assert_eq!(r1.total_gross_vnd, r2.total_gross_vnd);
    assert_eq!(r1.total_net_vnd, r2.total_net_vnd);
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-REW-001. Cross-module: TASK-REW-002 (versioning), TASK-REW-004 (deductions), TASK-REW-006 (PDF render), TASK-REW-009 (bank send), TASK-AUTH-101 (CFO/CHRO), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Compute fails mid-runrollbackstatus=failedretry
One signer missinggatereject commitwait
Same-person dual-signvalidate403different signer
Duplicate periodUNIQUE409use different period
Post-commit mutationREVOKEDB errorinherent
Cross-tenant queryRLS0 rowsinherent
Decimal precisionbigint VNDinherentinherent
Status workflow violationcheck409follow order
Member missing compsev-2exclude or faildata fix
Decryption failcascadestatus=failedKMS check

§11 — Implementation notes


End of TASK-REW-005 spec.