Task — engineering-spec@1

"LEARN VP score → REW BP fund distribution handoff — quarter-close trigger emits aggregate VP shares per member to REW for fund allocation"

draftTASK-LEARN-007
module learn · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-LEARN-003 · blocks none

§1 — Description (BCP-14 normative)

The LEARN service MUST ship VP → REW handoff at services/learn/src/handoff/ quarterly aggregate + share calc + REW emit, 4 memory audit kinds.

  1. MUST validate handoff_status against closed enum per DEC-2141.
  1. MUST schedule cron Q-end+1d at 04:00 tenant_tz via TASK-MCP-007.
  1. MUST aggregate at aggregator.rs::aggregate(tenant, quarter) per DEC-2140:
  1. MUST emit to REW at rew_emitter.rs::emit(tenant, quarter, shares) per DEC-2140 — call TASK-REW-008 with {member_id, vp_share} list.
  1. MUST be idempotent per DEC-2143: ```sql CREATE TABLE learn_vp_rew_handoffs ( handoff_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, quarter CHAR(7) NOT NULL, -- 'YYYY-Qx' status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','computed','emitted','acknowledged_by_rew','failed')), total_vp NUMERIC(15,4) NOT NULL DEFAULT 0, members_count INT NOT NULL DEFAULT 0, emitted_at TIMESTAMPTZ, acked_at TIMESTAMPTZ, failure_reason TEXT, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant_id, quarter) ); ALTER TABLE learn_vp_rew_handoffs ENABLE ROW LEVEL SECURITY; CREATE POLICY handoffs_rls ON learn_vp_rew_handoffs USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON learn_vp_rew_handoffs FROM cyberos_app; GRANT UPDATE (status, total_vp, members_count, emitted_at, acked_at, failure_reason) ON learn_vp_rew_handoffs TO cyberos_app;

CREATE TABLE learn_vp_rew_member_shares ( handoff_id UUID NOT NULL REFERENCES learn_vp_rew_handoffs(handoff_id), tenant_id UUID NOT NULL, member_id UUID NOT NULL, vp_share NUMERIC(10,9) NOT NULL CHECK (vp_share >= 0 AND vp_share <= 1), vp_absolute NUMERIC(15,4) NOT NULL, PRIMARY KEY (handoff_id, member_id) ); ALTER TABLE learn_vp_rew_member_shares ENABLE ROW LEVEL SECURITY; CREATE POLICY shares_rls ON learn_vp_rew_member_shares USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON learn_vp_rew_member_shares FROM cyberos_app; ```

  1. MUST expose endpoints: ``text POST /v1/learn/vp-rew/trigger (CEO; manual for current quarter) GET /v1/learn/vp-rew/handoffs (list) ``
  1. MUST emit 4 memory audit kinds per DEC-2144. PII per TASK-MEMORY-111: vp values SHA-256; member_id + shares ok.
  1. MUST thread trace_id from cron → aggregate → REW emit → ack → audit.
  1. MUST NOT mutate prior handoff per DEC-2143.
  1. MUST NOT emit absolute amounts per DEC-2142.
  1. MUST verify shares sum to ≈1.0 (tolerance 1e-6) before emit.

§2 — Why this design

Why share not absolute (DEC-2142)? REW determines fund size; LEARN only knows fairness ratios. Decoupling.

Why idempotent (DEC-2143)? Quarter handoff = financial event; double-pay = embarrassment + cost.

Why share sum check (DEC-2144)? Floating-point arithmetic can drift; tolerance check protects against bugs.


§3 — API contract

Sample handoff:

{
  "handoff_id": "uuid",
  "quarter": "2026-Q2",
  "status": "acknowledged_by_rew",
  "total_vp": 12500.5,
  "members_count": 35,
  "shares": [
    {"member_id": "uuid-alice", "vp_share": 0.085, "vp_absolute": 1062.5},
    {"member_id": "uuid-bob", "vp_share": 0.062, "vp_absolute": 775.0}
  ]
}

§4 — Acceptance criteria

  1. handoff_status enum cardinality 5. 2. Quarter-close cron. 3. Aggregate per-member share. 4. Shares sum to 1.0 ±1e-6. 5. Emit to TASK-REW-008. 6. UNIQUE(tenant, quarter) idempotency. 7. 4 memory audit kinds emitted. 8. PII scrubbed (vp values SHA256). 9. RLS denies cross-tenant. 10. CEO-only manual trigger. 11. Trace_id preserved. 12. Append-only via REVOKE. 13. REW ack → status=acknowledged_by_rew. 14. Failure → status=failed + sev-1. 15. 0 active members → skip + sev-3. 16. rust_decimal precision. 17. Share precision (10,9). 18. vp_share CHECK 0-1. 19. history queryable. 20. Quarter format 'YYYY-Qx' enforced.

§5 — Verification

#[tokio::test]
async fn shares_sum_to_1() {
    let ctx = TestContext::with_5_members_vp_each(100).await;
    ctx.run_handoff(this_quarter()).await;
    let shares = ctx.fetch_shares(this_quarter()).await;
    let total: Decimal = shares.iter().map(|s| s.vp_share).sum();
    assert!((total - dec!(1.0)).abs() < dec!(0.000001));
}

#[tokio::test]
async fn idempotent_double_run() {
    let ctx = TestContext::with_member_vp_data().await;
    ctx.run_handoff(this_quarter()).await;
    let r = ctx.run_handoff(this_quarter()).await;
    let handoffs = ctx.fetch_handoffs(this_quarter()).await;
    assert_eq!(handoffs.len(), 1);
}

#[tokio::test]
async fn rew_emit_and_ack() {
    let ctx = TestContext::with_member_vp_data().await;
    ctx.run_handoff(this_quarter()).await;
    let h = ctx.fetch_handoff(this_quarter()).await;
    assert_eq!(h.status, "acknowledged_by_rew");
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-LEARN-003. Cross-module: TASK-REW-008 (BP fund consumer), TASK-MCP-007 (cron), TASK-AUTH-101 (CEO), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Cron skippedcatch-upinherentinherent
Duplicate handoffUNIQUEskipinherent
Shares don't sum to 1tolerance checkreject; sev-1bug fix
REW unreachableretrystatus=failed; sev-1retry
0 membersskipsev-3inherent
Decimal precision driftrust_decimalinherentinherent
Cross-tenant queryRLS0 rowsinherent
Quarter format invalidCHECK400YYYY-Qx
Mid-handoff crashresumepartial → failedretry
REW ack timeoutmark emittedwaitmanual ack

§11 — Implementation notes


End of TASK-LEARN-007 spec.