Task — engineering-spec@1

"REW BP (Bonus Points) ledger with ACB-rate interest accrual nightly + per-Member balance + immutable transaction log"

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

§1 — Description (BCP-14 normative)

The REW service MUST ship BP ledger at services/rew/src/bp/ with credit/debit txn log + nightly interest cron + immutable history, 4 memory audit kinds.

  1. MUST validate bp_txn_kind against closed enum per DEC-2211.
  1. MUST define ledger at migration 0007: ``sql CREATE TABLE rew_bp_ledger ( txn_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL, txn_kind TEXT NOT NULL CHECK (txn_kind IN ('credit_p3_accrual','credit_special_award','credit_interest_accrual','debit_p3_distribution','debit_correction')), amount_bp NUMERIC(15,4) NOT NULL, balance_after NUMERIC(15,4) NOT NULL, reason TEXT, correction_of UUID REFERENCES rew_bp_ledger(txn_id), trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX bp_ledger_member_time_idx ON rew_bp_ledger(tenant_id, member_id, created_at DESC); ALTER TABLE rew_bp_ledger ENABLE ROW LEVEL SECURITY; CREATE POLICY bp_rls ON rew_bp_ledger USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON rew_bp_ledger FROM cyberos_app; ``
  1. MUST schedule interest accrual cron nightly 03:30 via TASK-MCP-007 per DEC-2212:
  1. MUST compute balance pure-function at balance_query.rs::balance(member, as_of):
  1. MUST be immutable per DEC-2213 — corrections via new debit_correction txn with reason.
  1. MUST expose endpoints: ``text POST /v1/rew/bp/credits body: {member_id, kind, amount_bp, reason} POST /v1/rew/bp/debits body: {member_id, kind, amount_bp, reason} GET /v1/rew/members/{id}/bp-balance ?as_of=... GET /v1/rew/members/{id}/bp-ledger POST /v1/rew/bp/interest-accrual/trigger (CFO manual) ``
  1. MUST emit 4 memory audit kinds per DEC-2214. PII per TASK-MEMORY-111: amounts SHA256.
  1. MUST thread trace_id from credit/debit/accrual → audit.
  1. MUST NOT mutate prior txn per DEC-2213.
  1. MUST NOT skip nightly interest accrual per DEC-2212.

§2 — Why this design

Why ledger pattern (DEC-2210)? Accounting standard — credit/debit log + balance derivation is auditable.

Why nightly interest (DEC-2212)? Daily granularity = fair accrual; weekly = under-pays late-month credits.

Why immutable (DEC-2213)? BP balances drive P3 distribution; rewrites = financial integrity breach.


§3 — API contract

Sample credit:

POST /v1/rew/bp/credits
{
  "member_id": "uuid",
  "kind": "credit_p3_accrual",
  "amount_bp": 500.0,
  "reason": "Q2 performance bonus accrual"
}

Sample balance:

{
  "member_id": "uuid",
  "balance_bp": 2547.85,
  "as_of": "2026-05-17"
}

§4 — Acceptance criteria

  1. bp_txn_kind enum cardinality 5. 2. Nightly interest cron 03:30. 3. ACB rate from TASK-HR-005. 4. **Daily interest = balance * (rate/365). 5. Immutable txn log. 6. Correction via debit_correction. 7. balance_after stored on each txn. 8. Balance query pure function. 9. 4 memory audit kinds emitted. 10. PII scrubbed (amounts SHA256). 11. RLS denies cross-tenant. 12. CFO-only credit/debit. 13. Trace_id preserved. 14. Append-only via REVOKE. 15. rust_decimal precision (15,4). 16. As_of query. 17. 0-balance members skipped in interest. 18. Cron idempotent per (member, date). 19. Negative balance prevented (debit > balance rejected). 20. Correction reason required**.

§5 — Verification

#[tokio::test]
async fn nightly_interest_accrual() {
    let ctx = TestContext::with_bp_balance_1000().await;
    ctx.run_interest_cron().await;
    let bal = ctx.balance(ctx.member_id, today()).await;
    let expected = dec!(1000) * (acb_rate() / dec!(365));
    assert!((bal - dec!(1000) - expected).abs() < dec!(0.01));
}

#[tokio::test]
async fn immutable_via_correction() {
    let ctx = TestContext::with_bp_txn().await;
    let r = ctx.try_update_txn(ctx.txn_id, dec!(0)).await;
    assert!(r.is_err());
    let corr = ctx.add_correction(ctx.member_id, ctx.txn_id, dec!(-100), "reversal").await;
    assert!(corr.is_ok());
}

#[tokio::test]
async fn debit_exceeds_balance_rejected() {
    let ctx = TestContext::with_bp_balance_100().await;
    let r = ctx.try_debit(ctx.member_id, dec!(200), "test").await;
    assert!(r.is_err());
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-REW-001. Downstream: TASK-REW-008 (P3 distribution debits from this). Cross-module: TASK-HR-005 (ACB rate policy), TASK-MCP-007 (cron), TASK-AUTH-101 (CFO), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
ACB rate missingcatchsev-2; skip accrualseed rate
Cron skippedcatch-upinherentinherent
Negative balance attemptvalidaterejectinherent
Decimal precision driftrust_decimalinherentinherent
Cross-tenant queryRLS0 rowsinherent
Concurrent credit/debitinherent orderinginherentinherent
balance_after mismatchpost-conditionsev-1; rejectbug fix
Correction without reasonvalidate400provide reason
Duplicate cron runUNIQUE on (member, date, kind)skipinherent
Member deactivatedskip interestinherentinherent

§11 — Implementation notes


End of TASK-REW-007 spec.