Task — engineering-spec@1

"ESOP put-option exec flow — Year 3+ eligibility + per-Member annual cap + CFO approve + bank wire via TASK-INV-005"

draftTASK-ESOP-004
module esop · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-ESOP-003, TASK-INV-005 · blocks none

§1 — Description (BCP-14 normative)

The ESOP service MUST ship put-option exec at services/esop/src/put/ with Year 3 eligibility + annual cap + CFO approve + TASK-INV-005 wire, 6 memory audit kinds.

  1. MUST validate put_status against closed enum per DEC-2281.
  1. MUST check eligibility at eligibility.rs::is_eligible(grant) per DEC-2280:
  1. MUST compute price at price_calculator.rs::price(shares, year) per DEC-2282:
  1. MUST enforce cap at cap_enforcer.rs::check(member, requested_shares, year) per DEC-2283:
  1. MUST require CFO approve before wire.
  1. MUST wire via TASK-INV-005 to member's bank account.
  1. MUST define table at migration 0004: ``sql CREATE TABLE esop_put_requests ( put_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL, grant_id UUID NOT NULL REFERENCES esop_sp_grants(grant_id), shares_requested BIGINT NOT NULL CHECK (shares_requested > 0), valuation_id UUID NOT NULL REFERENCES esop_annual_valuations(valuation_id), amount_vnd BIGINT NOT NULL, status TEXT NOT NULL DEFAULT 'requested' CHECK (status IN ('requested','cfo_pending','cfo_approved','cfo_rejected','wire_initiated','paid','failed')), cfo_approved_by UUID, cfo_approved_at TIMESTAMPTZ, cfo_rejected_reason TEXT, wire_initiated_at TIMESTAMPTZ, paid_at TIMESTAMPTZ, inv_payment_id UUID, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX put_member_year_idx ON esop_put_requests(tenant_id, member_id, EXTRACT(YEAR FROM created_at)); ALTER TABLE esop_put_requests ENABLE ROW LEVEL SECURITY; CREATE POLICY puts_rls ON esop_put_requests USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON esop_put_requests FROM cyberos_app; GRANT UPDATE (status, cfo_approved_by, cfo_approved_at, cfo_rejected_reason, wire_initiated_at, paid_at, inv_payment_id) ON esop_put_requests TO cyberos_app; ``
  1. MUST expose endpoints: ``text POST /v1/esop/puts (member-self requests) POST /v1/esop/puts/{id}/approve (CFO) POST /v1/esop/puts/{id}/reject body: {reason} GET /v1/esop/puts/{id} (status) GET /v1/esop/members/{id}/puts (history; member-self or CFO) ``
  1. MUST emit 6 memory audit kinds per DEC-2284. PII per TASK-MEMORY-111: amount + shares SHA256.
  1. MUST thread trace_id from request → approve → wire → audit.
  1. MUST NOT approve before Year 3 per DEC-2280.
  1. MUST NOT exceed annual cap per DEC-2283.

§2 — Why this design

Why Year 3 (DEC-2280)? Industry standard — gives incentive period for retention before liquidity option.

Why annual cap (DEC-2283)? Without cap, single executor could drain liquidity; tenant-controlled.

Why CFO approve (DEC-2280)? Cash outflow event; CFO controls treasury.


§3 — API contract

Sample put request:

POST /v1/esop/puts
{
  "grant_id": "uuid",
  "shares_requested": 500
}

Sample response:

{
  "put_id": "uuid",
  "shares_requested": 500,
  "amount_vnd": 25000000,
  "status": "cfo_pending"
}

§4 — Acceptance criteria

  1. put_status enum cardinality 7. 2. Year 3+ eligibility enforced. 3. Cap 25% annual default. 4. Cap configurable per tenant. 5. Price from TASK-ESOP-003 committed. 6. CFO approve required. 7. TASK-INV-005 wire integration. 8. 6 memory audit kinds emitted. 9. PII scrubbed (shares + amount SHA256). 10. RLS denies cross-tenant. 11. Member-self request only. 12. CFO-only approve/reject. 13. Trace_id preserved. 14. Append-only via REVOKE except status cols. 15. bigint VND + shares. 16. shares_requested > 0. 17. Rejection reason logged. 18. Cap considers prior YTD exercises. 19. vested_at_year_start from TASK-ESOP-002 Jan 1 accrual. 20. Wire failure → status=failed + sev-1.

§5 — Verification

#[tokio::test]
async fn year_3_eligibility_blocks_early() {
    let g = ctx.grant_active("2026-01-01", 48, 12, 10000).await;
    let r = ctx.try_request_put(g.id, 100).await;
    assert!(r.is_err());  // <3 years
}

#[tokio::test]
async fn annual_cap_enforced() {
    let ctx = TestContext::with_grant_3y_old_vested_4000().await;  // 25% = 1000
    let r1 = ctx.request_put(ctx.grant_id, 600).await;
    ctx.cfo_approve(r1.id).await;
    let r2 = ctx.try_request_put(ctx.grant_id, 500).await;  // 600+500=1100 > 1000
    assert!(r2.is_err());
}

#[tokio::test]
async fn wire_initiated_via_inv_005() {
    let ctx = TestContext::with_approved_put().await;
    ctx.initiate_wire(ctx.put_id).await;
    let p = ctx.fetch_put(ctx.put_id).await;
    assert!(p.inv_payment_id.is_some());
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-ESOP-003, TASK-INV-005. Cross-module: TASK-ESOP-002 (vested at Jan 1), TASK-AUTH-101 (CFO role), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Pre-Year 3 attempteligibilityrejectwait
Cap exceededenforcerrejectreduce shares
No valuation for yearchecksev-1propose valuation
Wire failcatchstatus=failed; sev-1retry
Cross-tenant putRLS403inherent
Grant cancelledeligibilityrejectinherent
Concurrent putinherenteach evaluatedinherent
Bank invalid acctwire failsev-2member update
CFO rejectioninherentstatus=cfo_rejectedinherent
Decimal precisionbigintinherentinherent

§11 — Implementation notes


End of TASK-ESOP-004 spec.