"LEARN VP score → REW BP fund distribution handoff — quarter-close trigger emits aggregate VP shares per member to REW for fund allocation"
§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.
- MUST validate
handoff_statusagainst closed enum per DEC-2141.
- MUST schedule cron Q-end+1d at 04:00 tenant_tz via TASK-MCP-007.
- MUST aggregate at
aggregator.rs::aggregate(tenant, quarter)per DEC-2140:
- Sum TASK-LEARN-003 snapshots for each member over quarter's weeks
- Compute total_vp = sum across all members
- Per-member share = member_vp / total_vp (sums to 1.0)
- 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.
- 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; ```
- MUST expose endpoints: ``
text POST /v1/learn/vp-rew/trigger (CEO; manual for current quarter) GET /v1/learn/vp-rew/handoffs (list)``
- MUST emit 4 memory audit kinds per DEC-2144. PII per TASK-MEMORY-111: vp values SHA-256; member_id + shares ok.
- MUST thread trace_id from cron → aggregate → REW emit → ack → audit.
- MUST NOT mutate prior handoff per DEC-2143.
- MUST NOT emit absolute amounts per DEC-2142.
- 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
- 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
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Cron skipped | catch-up | inherent | inherent |
| Duplicate handoff | UNIQUE | skip | inherent |
| Shares don't sum to 1 | tolerance check | reject; sev-1 | bug fix |
| REW unreachable | retry | status=failed; sev-1 | retry |
| 0 members | skip | sev-3 | inherent |
| Decimal precision drift | rust_decimal | inherent | inherent |
| Cross-tenant query | RLS | 0 rows | inherent |
| Quarter format invalid | CHECK | 400 | YYYY-Qx |
| Mid-handoff crash | resume | partial → failed | retry |
| REW ack timeout | mark emitted | wait | manual ack |
§11 — Implementation notes
- §11.1 Cron via TASK-MCP-007
kind: 'learn.vp_rew_handoff', Q-end+1d at 04:00. - §11.2 Aggregator pure function:
(member_vp_snapshots, total_vp) → shares. - §11.3 REW emit via HTTP POST to TASK-REW-008 endpoint with retry+ack.
- §11.4 memory audit body: handoff_id, quarter, members_count; total_vp SHA256.
- §11.5 Member with 0 VP for quarter → vp_share=0 (still included in shares list for transparency).
End of TASK-LEARN-007 spec.