Task — engineering-spec@1

"ESOP M&A acceleration trigger — Board declares M&A event + 5-business-day Member notice + full vesting acceleration for all active grants"

draftTASK-ESOP-006
module esop · class product · priority p1 · created 2026-05-17 · shipped null
depends on TASK-ESOP-001 · blocks none

§1 — Description (BCP-14 normative)

The ESOP service MUST ship M&A acceleration at services/esop/src/ma/ with Board declaration + acceleration cron + 5-business-day member notice, 5 memory audit kinds.

  1. MUST validate ma_event_status against closed enum per DEC-2301.
  1. MUST require board threshold per DEC-2302 (reuse TASK-ESOP-003 board threshold logic).
  1. MUST run acceleration at acceleration_runner.rs::accelerate(ma_event) per DEC-2303:
  1. MUST notify members within 5 business days at member_notifier.rs::notify(ma_event) per DEC-2300:
  1. MUST define tables at migration 0006: ```sql CREATE TABLE esop_ma_events ( ma_event_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, event_description TEXT NOT NULL, declared_by UUID NOT NULL, declared_at TIMESTAMPTZ NOT NULL DEFAULT now(), status TEXT NOT NULL DEFAULT 'declared' CHECK (status IN ('declared','accelerating','members_notified','completed','dismissed')), acceleration_started_at TIMESTAMPTZ, all_notified_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, trace_id CHAR(32) ); ALTER TABLE esop_ma_events ENABLE ROW LEVEL SECURITY; CREATE POLICY ma_rls ON esop_ma_events USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON esop_ma_events FROM cyberos_app; GRANT UPDATE (status, acceleration_started_at, all_notified_at, completed_at) ON esop_ma_events TO cyberos_app;

CREATE TABLE esop_ma_board_signs ( sign_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, ma_event_id UUID NOT NULL REFERENCES esop_ma_events(ma_event_id), board_member_id UUID NOT NULL, signed_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (ma_event_id, board_member_id) ); ALTER TABLE esop_ma_board_signs ENABLE ROW LEVEL SECURITY; CREATE POLICY ma_signs_rls ON esop_ma_board_signs USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON esop_ma_board_signs FROM cyberos_app;

CREATE TABLE esop_ma_member_notices ( notice_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, ma_event_id UUID NOT NULL REFERENCES esop_ma_events(ma_event_id), member_id UUID NOT NULL, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','sent','failed')), sent_at TIMESTAMPTZ, email_message_id UUID, UNIQUE (ma_event_id, member_id) ); ALTER TABLE esop_ma_member_notices ENABLE ROW LEVEL SECURITY; CREATE POLICY notices_rls ON esop_ma_member_notices USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON esop_ma_member_notices FROM cyberos_app; GRANT UPDATE (status, sent_at, email_message_id) ON esop_ma_member_notices TO cyberos_app; ```

  1. MUST expose endpoints: ``text POST /v1/esop/ma-events (CEO declares) POST /v1/esop/ma-events/{id}/board-sign (board member) POST /v1/esop/ma-events/{id}/accelerate (auto on threshold) GET /v1/esop/ma-events/{id} (status) ``
  1. MUST emit 5 memory audit kinds per DEC-2304. PII per TASK-MEMORY-111: descriptions SHA256.
  1. MUST thread trace_id from declare → sign → accelerate → notify → audit.
  1. MUST NOT accelerate without board threshold per DEC-2302.
  1. MUST NOT skip member notice per DEC-2300 (5-business-day deadline tracked).

§2 — Why this design

Why board threshold (DEC-2302)? M&A acceleration = major equity event; requires governance.

Why 5-business-day notice (DEC-2300)? Industry standard for change-of-control disclosure; allows members to plan.

Why per-member notice tracking (DEC-2300)? Audit trail proves notification delivered.


§3 — API contract

Sample M&A event:

POST /v1/esop/ma-events
{
  "event_description": "Acquisition by AcmeCorp, closing 2026-Q3"
}

Sample status:

{
  "ma_event_id": "uuid",
  "status": "completed",
  "board_signs_count": 3,
  "members_notified_count": 30,
  "completed_at": "2026-06-01T10:00:00Z"
}

§4 — Acceptance criteria

  1. ma_event_status enum cardinality 5. 2. Board threshold (≥3 default). 3. All active grants accelerated. 4. vested = total_shares set. 5. 5-business-day notice tracked. 6. Per-member notice via TASK-EMAIL-009. 7. 5 memory audit kinds emitted. 8. PII scrubbed (desc SHA256). 9. RLS denies cross-tenant. 10. CEO-only declare. 11. Board member sign-only. 12. Trace_id preserved. 13. Append-only via REVOKE except status cols. 14. UNIQUE on (ma_event_id, board_member_id). 15. UNIQUE on (ma_event_id, member_id) for notices. 16. status workflow enforced. 17. Per-member notice failure isolated. 18. Cancelled grants excluded. 19. bigint shares. 20. Dismiss allowed pre-acceleration.

§5 — Verification

#[tokio::test]
async fn board_threshold_required() {
    let ctx = TestContext::with_declared_ma().await;
    ctx.board_sign(ctx.b1).await;
    ctx.board_sign(ctx.b2).await;
    let m = ctx.fetch_ma(ctx.ma_id).await;
    assert_eq!(m.status, "declared");  // still
    ctx.board_sign(ctx.b3).await;
    let m2 = ctx.fetch_ma(ctx.ma_id).await;
    assert_eq!(m2.status, "accelerating");
}

#[tokio::test]
async fn all_grants_accelerated() {
    let ctx = TestContext::with_5_active_grants_and_ma_signed().await;
    ctx.run_acceleration(ctx.ma_id).await;
    for g_id in ctx.grant_ids() {
        let g = ctx.fetch_grant(g_id).await;
        assert_eq!(g.status, "accelerated");
        let a = ctx.fetch_latest_accrual(g_id).await;
        assert_eq!(a.vested_cumulative, g.total_shares);
    }
}

#[tokio::test]
async fn members_notified_in_5_bd() {
    let ctx = TestContext::with_accelerated_ma().await;
    ctx.run_notifier(ctx.ma_id).await;
    let notices = ctx.fetch_notices(ctx.ma_id).await;
    let sent = notices.iter().filter(|n| n.status == "sent").count();
    assert_eq!(sent, ctx.member_count());
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-ESOP-001. Cross-module: TASK-ESOP-002 (accrual rows), TASK-EMAIL-009 (notice), TASK-MCP-007 (deadline cron), TASK-AUTH-101 (CEO + board), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Board threshold not reachedinherentstays declaredget signs
Acceleration mid-run crashresumepartialretry
Notice send fail per-memberper-rowisolate; retryinherent
5-day deadline missedcron alertsev-1 + escalatemanual notify
Cancelled grant in active listfilterskipinherent
Cross-tenant declareRLS403inherent
Duplicate signUNIQUEsecond skipinherent
Member has no emailsev-2skip + manual notifydata fix
Concurrent accelerationUPDATE WHEREfirst winsinherent
Dismiss post-accelerationreject409inherent

§11 — Implementation notes


End of TASK-ESOP-006 spec.