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
§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.
- MUST validate
put_statusagainst closed enum per DEC-2281.
- MUST check eligibility at
eligibility.rs::is_eligible(grant)per DEC-2280:
- grant.vest_start_date + 3 years ≤ now
- grant.status IN (active, fully_vested)
- MUST compute price at
price_calculator.rs::price(shares, year)per DEC-2282:
- Read TASK-ESOP-003 committed price for current calendar year
- amount = shares × committed_price
- MUST enforce cap at
cap_enforcer.rs::check(member, requested_shares, year)per DEC-2283:
- vested_at_year_start = TASK-ESOP-002 accrual at Jan 1
- sum(prior exercised this year) + requested ≤ cap_pct × vested_at_year_start
- default cap_pct = 0.25 (configurable per tenant)
- MUST require CFO approve before wire.
- MUST wire via TASK-INV-005 to member's bank account.
- 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;``
- 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)``
- MUST emit 6 memory audit kinds per DEC-2284. PII per TASK-MEMORY-111: amount + shares SHA256.
- MUST thread trace_id from request → approve → wire → audit.
- MUST NOT approve before Year 3 per DEC-2280.
- 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
- 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
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Pre-Year 3 attempt | eligibility | reject | wait |
| Cap exceeded | enforcer | reject | reduce shares |
| No valuation for year | check | sev-1 | propose valuation |
| Wire fail | catch | status=failed; sev-1 | retry |
| Cross-tenant put | RLS | 403 | inherent |
| Grant cancelled | eligibility | reject | inherent |
| Concurrent put | inherent | each evaluated | inherent |
| Bank invalid acct | wire fail | sev-2 | member update |
| CFO rejection | inherent | status=cfo_rejected | inherent |
| Decimal precision | bigint | inherent | inherent |
§11 — Implementation notes
- §11.1 Cap_pct stored per tenant config; default 0.25.
- §11.2 vested_at_year_start = TASK-ESOP-002 accrual for Jan 1 of current year.
- §11.3 memory audit body: put_id, member_id, grant_id, status; shares + amount SHA256.
- §11.4 Wire via TASK-INV-005 with memo:
ESOP-PUT-{put_id_8}for reconciliation. - §11.5 Future: extend to fractional liquidity rounds (multiple-CFO approval).
End of TASK-ESOP-004 spec.