Task — engineering-spec@1

"ESOP SP grant schema — Stock Plan grant with 4-year vesting + 12-month cliff default + per-grant immutable params"

draftTASK-ESOP-001
module esop · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-HR-001 · blocks TASK-ESOP-002, TASK-ESOP-003, TASK-ESOP-006, TASK-ESOP-007, TASK-TEN-201

§1 — Description (BCP-14 normative)

The ESOP service MUST ship SP grant schema at services/esop/src/grant/ with 5-kind enum + immutable params + status lifecycle, 5 memory audit kinds.

  1. MUST validate grant_kind against closed enum per DEC-2251, grant_status per DEC-2252.
  1. MUST define table at migration 0001: ``sql CREATE TABLE esop_sp_grants ( grant_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL, kind TEXT NOT NULL CHECK (kind IN ('founder','employee_initial','employee_refresher','advisor','board')), total_shares BIGINT NOT NULL CHECK (total_shares > 0), vest_months INT NOT NULL DEFAULT 48 CHECK (vest_months > 0), cliff_months INT NOT NULL DEFAULT 12 CHECK (cliff_months >= 0 AND cliff_months <= vest_months), strike_price_vnd BIGINT NOT NULL CHECK (strike_price_vnd >= 0), grant_date DATE NOT NULL, vest_start_date DATE NOT NULL, status TEXT NOT NULL DEFAULT 'pending_signing' CHECK (status IN ('pending_signing','active','fully_vested','cancelled_unvested','accelerated')), granted_by UUID NOT NULL, ceo_signed_at TIMESTAMPTZ, member_signed_at TIMESTAMPTZ, activated_at TIMESTAMPTZ, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX grants_member_idx ON esop_sp_grants(tenant_id, member_id, created_at DESC); ALTER TABLE esop_sp_grants ENABLE ROW LEVEL SECURITY; CREATE POLICY grants_rls ON esop_sp_grants USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON esop_sp_grants FROM cyberos_app; GRANT UPDATE (status, ceo_signed_at, member_signed_at, activated_at) ON esop_sp_grants TO cyberos_app; ``
  1. MUST require CEO sign + member sign before status=active per DEC-2253.
  1. MUST expose endpoints: ``text POST /v1/esop/grants (CEO creates) POST /v1/esop/grants/{id}/ceo-sign POST /v1/esop/grants/{id}/member-sign (member self) POST /v1/esop/grants/{id}/cancel (CEO; pre-cliff only) GET /v1/esop/grants/{id} (member-self or CFO/CEO) ``
  1. MUST emit 5 memory audit kinds per DEC-2254. PII per TASK-MEMORY-111: total_shares SHA256; member_id (uuid) ok.
  1. MUST thread trace_id from create → sign → activate → audit.
  1. MUST NOT mutate prior grant params per DEC-2250 (REVOKE UPDATE except 4 status cols).
  1. MUST NOT activate without both CEO + member signs.

§2 — Why this design

Why 5 kinds (DEC-2251)? Covers founders, regular employees (initial + refresh), advisors, board — bounded.

Why default 4y + 12mo cliff (DEC-2250)? Industry standard for early-stage equity.

Why vest_start_date != grant_date (DEC-2253)? Late-signing grants back-date to employment start for fairness.

Why immutable (DEC-2250)? Equity grants = legal commitment; mutation = fraud risk.


§3 — API contract

Sample grant:

POST /v1/esop/grants
{
  "member_id": "uuid",
  "kind": "employee_initial",
  "total_shares": 10000,
  "vest_months": 48,
  "cliff_months": 12,
  "strike_price_vnd": 1000,
  "grant_date": "2026-05-17",
  "vest_start_date": "2026-01-15"
}

§4 — Acceptance criteria

  1. grant_kind enum cardinality 5. 2. grant_status enum cardinality 5. 3. Total_shares > 0 CHECK. 4. Vest_months > 0 CHECK. 5. Cliff_months ≤ vest_months CHECK. 6. Strike_price_vnd ≥ 0. 7. Defaults vest=48, cliff=12. 8. CEO + member sign required for activate. 9. 5 memory audit kinds emitted. 10. PII scrubbed (total_shares SHA256). 11. RLS denies cross-tenant. 12. CEO-only create + cancel. 13. Trace_id preserved. 14. Append-only via REVOKE except 4 status cols. 15. Cancel allowed pre-cliff only. 16. bigint shares. 17. vest_start_date locked at creation. 18. status workflow: pending_signing → active → fully_vested | cancelled | accelerated. 19. Member-self can view own. 20. Cross-member view requires CFO audit (TASK-ESOP-007).

§5 — Verification

#[tokio::test]
async fn defaults_4y_12mo_cliff() {
    let g = ctx.create_grant_minimal(ctx.member_id, 10000).await;
    assert_eq!(g.vest_months, 48);
    assert_eq!(g.cliff_months, 12);
}

#[tokio::test]
async fn dual_sign_to_activate() {
    let g = ctx.create_grant(ctx.member_id, 10000).await;
    ctx.ceo_sign(g.id).await;
    assert_eq!(ctx.fetch_grant(g.id).await.status, "pending_signing");
    ctx.member_sign(g.id).await;
    assert_eq!(ctx.fetch_grant(g.id).await.status, "active");
}

#[tokio::test]
async fn immutable_post_create() {
    let g = ctx.create_grant(...).await;
    let r = ctx.try_update_total_shares(g.id, 20000).await;
    assert!(r.is_err());
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-HR-001. Downstream: TASK-ESOP-002 (vesting), TASK-ESOP-003 (valuation), TASK-ESOP-004 (put-option), TASK-ESOP-005 (GL/BL). Cross-module: TASK-AUTH-101 (CEO role), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Kind invalidCHECK400use valid
Total_shares ≤ 0CHECK400use positive
Cliff > vestCHECK400reduce cliff
Mutation attemptREVOKEDB errorinherent
Cancel post-cliffvalidate409use TASK-ESOP-005
Cross-tenant grantRLS0 rowsinherent
Vest_start in distant pastwarnallow (audit visible)inherent
Member declinesmanualinherentre-grant
Concurrent signinherentlast-writer-wins for sign timestampsinherent
Bigint overflowbigint VNDinherentinherent

§11 — Implementation notes


End of TASK-ESOP-001 spec.