Task — engineering-spec@1

"REW quarterly P3 distribution from BP fund — CEO+CFO sign-off + LEARN-007 VP share splits + debit BP balances"

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

§1 — Description (BCP-14 normative)

The REW service MUST ship quarterly P3 distribution at services/rew/src/p3/ consuming TASK-LEARN-007 VP shares + dual-sign + BP debit + P3 add to next payroll, 5 memory audit kinds.

  1. MUST validate distribution_status against closed enum per DEC-2221.
  1. MUST calculate at calculator.rs::calc(tenant, quarter, fund_vnd) per DEC-2220:
  1. MUST require CEO + CFO dual-sign at dual_sign_gate.rs::can_execute(distribution) per DEC-2222 — same-person rejected.
  1. MUST be idempotent per DEC-2223 — UNIQUE(tenant, quarter).
  1. MUST on execute:
  1. MUST define tables at migration 0008: ```sql CREATE TABLE rew_p3_distributions ( distribution_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, quarter CHAR(7) NOT NULL, fund_vnd BIGINT NOT NULL CHECK (fund_vnd > 0), status TEXT NOT NULL DEFAULT 'drafted' CHECK (status IN ('drafted','ceo_signed','cfo_signed','executed','paid','dismissed')), ceo_signed_by UUID, ceo_signed_at TIMESTAMPTZ, cfo_signed_by UUID, cfo_signed_at TIMESTAMPTZ, executed_at TIMESTAMPTZ, paid_at TIMESTAMPTZ, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant_id, quarter) ); ALTER TABLE rew_p3_distributions ENABLE ROW LEVEL SECURITY; CREATE POLICY p3_dist_rls ON rew_p3_distributions USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON rew_p3_distributions FROM cyberos_app; GRANT UPDATE (status, ceo_signed_by, ceo_signed_at, cfo_signed_by, cfo_signed_at, executed_at, paid_at) ON rew_p3_distributions TO cyberos_app;

CREATE TABLE rew_p3_member_payouts ( payout_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, distribution_id UUID NOT NULL REFERENCES rew_p3_distributions(distribution_id), member_id UUID NOT NULL, vp_share NUMERIC(10,9) NOT NULL, payout_vnd BIGINT NOT NULL, PRIMARY KEY_ALT UNIQUE (distribution_id, member_id) ); ALTER TABLE rew_p3_member_payouts ENABLE ROW LEVEL SECURITY; CREATE POLICY payouts_rls ON rew_p3_member_payouts USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON rew_p3_member_payouts FROM cyberos_app; ```

  1. MUST expose endpoints: ``text POST /v1/rew/p3-distributions (CEO drafts; provides fund_vnd) POST /v1/rew/p3-distributions/{id}/ceo-sign POST /v1/rew/p3-distributions/{id}/cfo-sign POST /v1/rew/p3-distributions/{id}/execute (auto if both signed) GET /v1/rew/p3-distributions/{id} (status + payouts) ``
  1. MUST emit 5 memory audit kinds per DEC-2224. PII per TASK-MEMORY-111: payout amounts SHA256.
  1. MUST thread trace_id from draft → sign → execute → audit.
  1. MUST NOT execute without dual-sign per DEC-2222.
  1. MUST NOT duplicate quarter per DEC-2223.

§2 — Why this design

Why VP share input (DEC-2220)? Fairness — distribution proportional to contribution per TASK-LEARN-007.

Why CEO + CFO dual-sign (DEC-2222)? CEO sets strategic fund; CFO confirms financial OK.

Why idempotent (DEC-2223)? Quarter is single financial event; double-pay = disaster.


§3 — API contract

Sample distribution draft:

POST /v1/rew/p3-distributions
{
  "quarter": "2026-Q2",
  "fund_vnd": 500000000
}

Sample executed status:

{
  "distribution_id": "uuid",
  "quarter": "2026-Q2",
  "fund_vnd": 500000000,
  "status": "executed",
  "payouts": [
    {"member_id": "uuid", "vp_share": 0.085, "payout_vnd": 42500000},
    {"member_id": "uuid", "vp_share": 0.062, "payout_vnd": 31000000}
  ]
}

§4 — Acceptance criteria

  1. distribution_status enum cardinality 6. 2. fund_vnd > 0 CHECK. 3. VP shares from TASK-LEARN-007. 4. CEO+CFO dual-sign required. 5. Same-person rejected. 6. UNIQUE(tenant, quarter). 7. 5 memory audit kinds emitted. 8. PII scrubbed (amounts SHA256). 9. RLS denies cross-tenant. 10. CEO-only draft. 11. Trace_id preserved. 12. Append-only via REVOKE except status cols. 13. bigint VND. 14. vp_share NUMERIC(10,9). 15. Per-member payout row. 16. Execute debits BP ledger. 17. P3 added to next payroll. 18. Status workflow enforced. 19. Dismiss allowed pre-execute. 20. Sum of payouts ≈ fund_vnd (±1 VND tolerance).

§5 — Verification

#[tokio::test]
async fn dual_sign_required_for_execute() {
    let ctx = TestContext::with_drafted_distribution().await;
    ctx.ceo_sign(ctx.dist_id).await;
    let r = ctx.try_execute(ctx.dist_id).await;
    assert!(r.is_err());  // CFO missing
    ctx.cfo_sign(ctx.dist_id).await;
    let r2 = ctx.execute(ctx.dist_id).await;
    assert!(r2.is_ok());
}

#[tokio::test]
async fn payouts_sum_matches_fund() {
    let ctx = TestContext::with_executed_distribution(dec!(500_000_000)).await;
    let payouts = ctx.fetch_payouts(ctx.dist_id).await;
    let total: i64 = payouts.iter().map(|p| p.payout_vnd).sum();
    assert!((total - 500_000_000).abs() <= 1);
}

#[tokio::test]
async fn idempotent_quarter() {
    let ctx = TestContext::with_q1_distribution().await;
    let r = ctx.try_draft_distribution("2026-Q1").await;
    assert!(r.is_err());  // UNIQUE
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-REW-007. Cross-module: TASK-LEARN-007 (VP shares), TASK-REW-005 (payroll injection), TASK-AUTH-101 (CEO/CFO), TASK-MCP-007 (trigger cron), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
TASK-LEARN-007 not emittedcatchsev-2; awaitmanual trigger
One signer missinggatereject executewait
Same-person dual-signvalidate403different signer
Sum of payouts != fundpost-conditionsev-1; rejectbug fix
Duplicate quarterUNIQUE409use different
BP debit failrollbacksev-1retry
Payroll injection failsev-1manual fixinherent
Cross-tenant queryRLS0 rowsinherent
Decimal precisionbigint VND + rust_decimal shareinherentinherent
Concurrent executeUPDATE WHERE pendingfirst winsinherent

§11 — Implementation notes


End of TASK-REW-008 spec.