Task — engineering-spec@1

"ESOP monthly vesting accrual deterministic batch — runs EOM tenant_tz computing per-grant vested shares with cliff respect + immutable accrual rows"

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

§1 — Description (BCP-14 normative)

The ESOP service MUST ship monthly vesting batch at services/esop/src/vesting/ running EOM cron, deterministic calc, immutable accrual rows, 5 memory audit kinds.

  1. MUST validate accrual_status against closed enum per DEC-2261.
  1. MUST compute at calculator.rs::compute(grant, as_of_date) per DEC-2260:
  1. MUST schedule EOM cron via TASK-MCP-007 at 04:30 tenant_tz.
  1. MUST be idempotent per DEC-2262 — UNIQUE(grant_id, year_month).
  1. MUST auto-transition to fully_vested per DEC-2263 when vested >= total.
  1. MUST define table at migration 0002: ```sql CREATE TABLE esop_vesting_accruals ( accrual_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, grant_id UUID NOT NULL REFERENCES esop_sp_grants(grant_id), year_month CHAR(7) NOT NULL, vested_cumulative BIGINT NOT NULL, unvested_remaining BIGINT NOT NULL, monthly_vested BIGINT NOT NULL, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant_id, grant_id, year_month) ); CREATE INDEX accruals_grant_idx ON esop_vesting_accruals(tenant_id, grant_id, year_month DESC); ALTER TABLE esop_vesting_accruals ENABLE ROW LEVEL SECURITY; CREATE POLICY accruals_rls ON esop_vesting_accruals USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON esop_vesting_accruals FROM cyberos_app;

CREATE TABLE esop_vesting_batch_runs ( run_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, year_month CHAR(7) NOT NULL, status TEXT NOT NULL DEFAULT 'running' CHECK (status IN ('running','completed','partial','failed')), grants_processed INT NOT NULL DEFAULT 0, grants_failed INT NOT NULL DEFAULT 0, started_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, trace_id CHAR(32), UNIQUE (tenant_id, year_month) ); ALTER TABLE esop_vesting_batch_runs ENABLE ROW LEVEL SECURITY; CREATE POLICY runs_rls ON esop_vesting_batch_runs USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON esop_vesting_batch_runs FROM cyberos_app; GRANT UPDATE (status, grants_processed, grants_failed, completed_at) ON esop_vesting_batch_runs TO cyberos_app; ```

  1. MUST expose endpoints: ``text POST /v1/esop/vesting/run-batch (CFO manual trigger) GET /v1/esop/grants/{id}/accruals (history per grant) ``
  1. MUST emit 5 memory audit kinds per DEC-2264. PII per TASK-MEMORY-111: vested_cumulative SHA256.
  1. MUST thread trace_id from cron → calc → audit.
  1. MUST NOT mutate prior accrual per DEC-2262 (REVOKE UPDATE/DELETE).
  1. MUST NOT use non-deterministic inputs per DEC-2260 (no now()).

§2 — Why this design

Why deterministic (DEC-2260)? Member challenges vesting → replay must yield same numbers.

Why monthly cron (DEC-2260)? Industry standard — monthly granularity matches grant agreements.

Why auto-transition (DEC-2263)? Without auto-flag, fully-vested grants stay "active" — TASK-ESOP-004 put-option needs flag.


§3 — API contract

Sample accrual:

{
  "accrual_id": "uuid",
  "grant_id": "uuid",
  "year_month": "2026-06",
  "vested_cumulative": 1666,  // 8/48 of 10000
  "unvested_remaining": 8334,
  "monthly_vested": 208
}

§4 — Acceptance criteria

  1. accrual_status enum cardinality 4. 2. Pre-cliff → vested=0. 3. Post-cliff linear. 4. At vest_months → vested=total. 5. Idempotent UNIQUE(grant, month). 6. EOM cron 04:30. 7. Auto-fully_vested transition. 8. 5 memory audit kinds emitted. 9. PII scrubbed (counts SHA256). 10. RLS denies cross-tenant. 11. CFO-only manual trigger. 12. Trace_id preserved. 13. Append-only via REVOKE. 14. Deterministic (no now). 15. Per-grant failure isolated. 16. Bigint shares. 17. Cancelled grants skipped. 18. Accelerated grants handled separately (TASK-ESOP-005). 19. History per grant queryable. 20. vest_start_date used (not grant_date).

§5 — Verification

#[tokio::test]
async fn pre_cliff_zero_vested() {
    let g = ctx.grant_active("2026-01-01", 48, 12, 10000).await;
    ctx.run_batch("2026-06").await;  // 5 months in, < 12 cliff
    let a = ctx.fetch_accrual(g.id, "2026-06").await;
    assert_eq!(a.vested_cumulative, 0);
}

#[tokio::test]
async fn post_cliff_linear() {
    let g = ctx.grant_active("2026-01-01", 48, 12, 10000).await;
    ctx.run_batch("2027-01").await;  // 12 months in
    let a = ctx.fetch_accrual(g.id, "2027-01").await;
    assert_eq!(a.vested_cumulative, 2500);  // 12/48 of 10000
}

#[tokio::test]
async fn idempotent_double_run() {
    let g = ctx.grant_active("2026-01-01", 48, 12, 10000).await;
    ctx.run_batch("2027-01").await;
    ctx.run_batch("2027-01").await;
    let accruals = ctx.fetch_accruals(g.id).await;
    assert_eq!(accruals.iter().filter(|a| a.year_month == "2027-01").count(), 1);
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-ESOP-001. Cross-module: TASK-MCP-007 (cron), TASK-AUTH-101 (CFO), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Cron skippedcatch-upinherentinherent
Duplicate runUNIQUEskipinherent
Cancelled grant in batchfilterskipinherent
Cross-tenant queryRLS0 rowsinherent
Decimal precisionbigintinherentinherent
Per-grant calc failisolatepartialretry
Mid-batch crashresumepartialretry
vest_months = 0CHECK in TASK-ESOP-001preventedinherent
Accelerated grantexclude from monthly batchhandled by TASK-ESOP-005inherent
year_month format invalidvalidate400YYYY-MM

§11 — Implementation notes


End of TASK-ESOP-002 spec.