Task — engineering-spec@1

"REW 3P income schema — P1 Base + P2 Allowance + P3 Performance with separate encrypted comp keyspace isolated from HR"

draftTASK-REW-001
module rew · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-HR-001, TASK-AUTH-101 · blocks TASK-REW-002, TASK-REW-003, TASK-REW-005, TASK-REW-007, TASK-REW-010

§1 — Description (BCP-14 normative)

The REW service MUST ship 3P income schema at services/rew/src/comp/ with 8-kind enum + separate KMS keyspace + CFO-only decrypt + immutable rows, 4 memory audit kinds.

  1. MUST validate income_kind against closed enum per DEC-2151.
  1. MUST define tables at migration 0001: ```sql CREATE TABLE rew_comp_records ( comp_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL, income_kind TEXT NOT NULL CHECK (income_kind IN ('p1_base','p2_allowance_housing','p2_allowance_transport','p2_allowance_meal','p2_allowance_other','p3_bonus_quarterly','p3_commission','p3_spot_award')), encrypted_amount_vnd BYTEA NOT NULL, -- encrypted via REW keyspace KMS encryption_kms_key_arn TEXT NOT NULL, currency CHAR(3) NOT NULL DEFAULT 'VND', valid_from DATE NOT NULL, valid_to DATE, set_by UUID NOT NULL, correction_of UUID REFERENCES rew_comp_records(comp_id), trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX comp_member_idx ON rew_comp_records(tenant_id, member_id, income_kind, valid_from DESC); ALTER TABLE rew_comp_records ENABLE ROW LEVEL SECURITY; CREATE POLICY comp_rls ON rew_comp_records USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON rew_comp_records FROM cyberos_app;

CREATE TABLE rew_comp_access_log ( log_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, accessor_id UUID NOT NULL, comp_id UUID, accessor_role TEXT NOT NULL, access_kind TEXT NOT NULL, succeeded BOOLEAN NOT NULL, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE rew_comp_access_log ENABLE ROW LEVEL SECURITY; CREATE POLICY access_log_rls ON rew_comp_access_log USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON rew_comp_access_log FROM cyberos_app; ```

  1. MUST use separate KMS keyspace per DEC-2152 — rew-{tenant_id} alias, distinct from hr-{tenant_id} and hr-cccd-{tenant_id}.
  1. MUST gate decrypt to ROOT-CFO only via access_gate.rs::check(user) per DEC-2152 — other roles → 403 + sev-1 audit.
  1. MUST correct via new row per DEC-2153 — correction_of points to prior; prior's valid_to set.
  1. MUST expose endpoints: ``text POST /v1/rew/comp (CFO sets/corrects) GET /v1/rew/comp/{id}/decrypt (ROOT-CFO only) GET /v1/rew/members/{id}/comp-history (CFO sees encrypted refs) ``
  1. MUST emit 4 memory audit kinds per DEC-2154. PII per TASK-MEMORY-111: encrypted_amount never in memory chain; income_kind enum ok; member_id (uuid) ok.
  1. MUST thread trace_id from set / decrypt → audit.
  1. MUST NOT decrypt for non-CFO per DEC-2152.
  1. MUST NOT mutate prior comp row per DEC-2153.
  1. MUST NOT share KMS keyspace with HR per DEC-2152.

§2 — Why this design

Why 8-kind enum (DEC-2151)? Captures real 3P composition; bounded prevents add-hoc additions.

Why separate keyspace (DEC-2152)? Defense in depth — HR breach doesn't expose comp; comp breach doesn't expose HR PII.

Why CFO-only decrypt (DEC-2152)? Principle of least privilege — even CHRO doesn't need decrypt access.

Why immutable (DEC-2153)? Payroll replay (TASK-REW-002) requires deterministic history; retroactive changes break audit.


§3 — API contract

Sample comp set:

POST /v1/rew/comp
{
  "member_id": "uuid",
  "income_kind": "p1_base",
  "amount_vnd": 30000000,
  "valid_from": "2026-06-01"
}

Sample decrypt response (CFO-only):

{
  "comp_id": "uuid",
  "income_kind": "p1_base",
  "amount_vnd": 30000000,
  "valid_from": "2026-06-01"
}

§4 — Acceptance criteria

  1. income_kind enum cardinality 8. 2. CFO-only decrypt. 3. Non-CFO 403 + sev-1 audit. 4. Separate KMS keyspace (rew-{tenant}). 5. Immutable rows. 6. Correction via correction_of. 7. 4 memory audit kinds emitted. 8. encrypted_amount never in memory chain. 9. RLS denies cross-tenant. 10. Trace_id preserved. 11. Access log append-only. 12. Member-week comp query indexable. 13. valid_from + valid_to range. 14. Currency CHAR(3) default VND. 15. set_by audit-traceable. 16. Tenant comp KMS key created at provisioning. 17. Cross-tenant KMS rejection. 18. CFO email notification on decrypt (sev-1). 19. Append-only via REVOKE UPDATE/DELETE. 20. 3P composition: P1 + P2 + P3 = total comp.

§5 — Verification

#[tokio::test]
async fn non_cfo_decrypt_denied() {
    let ctx = TestContext::with_encrypted_comp().await;
    let r = ctx.try_decrypt_as(ctx.am_user, ctx.comp_id).await;
    assert_eq!(r.status_code, 403);
    let logs = ctx.fetch_access_log(ctx.comp_id).await;
    assert!(logs.iter().any(|l| !l.succeeded));
}

#[tokio::test]
async fn separate_keyspace() {
    let ctx = TestContext::with_comp_and_hr_records().await;
    let comp_arn = ctx.fetch_comp_kms_arn(ctx.comp_id).await;
    let hr_arn = ctx.fetch_hr_kms_arn(ctx.member_id).await;
    assert_ne!(comp_arn, hr_arn);
    assert!(comp_arn.contains("rew-"));
    assert!(hr_arn.contains("hr-"));
}

#[tokio::test]
async fn immutable_append_only() {
    let ctx = TestContext::with_comp_record().await;
    let r = ctx.try_update_comp(ctx.comp_id, 50000000).await;
    assert!(r.is_err());
    let r2 = ctx.set_comp_correction(ctx.comp_id, 50000000).await;
    assert!(r2.is_ok());
    let row = ctx.fetch_comp_row(r2.new_comp_id).await;
    assert_eq!(row.correction_of, Some(ctx.comp_id));
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-HR-001. Downstream: TASK-REW-002 (versioning), TASK-REW-003 (P1 invariant), TASK-REW-005 (payroll), TASK-REW-007 (BP), TASK-REW-010 (memory exclusion). Cross-module: TASK-AUTH-105 (KMS), TASK-AUTH-101 (CFO role), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
KMS decrypt failKMS errsev-1retry; check IAM
Non-CFO bypassrole check403 + sev-1investigate
Cross-tenant KMSRLS + arn check403 + sev-1inherent
Income kind invalidCHECK400use valid
Currency invalidvalidate400use ISO 4217
Decimal precision lossrust_decimalinherentinherent
Concurrent comp setinherentboth appendinherent
Correction chain too deepsanity warnsev-3inherent
Cross-tenant comp viewRLS0 rowsinherent
KMS key disabledsev-1inherentCISO action

§11 — Implementation notes


End of TASK-REW-001 spec.