Task — engineering-spec@1

"ESOP annual valuation — CFO base + Board multiplier sign-off with immutable share-price snapshot per year"

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

§1 — Description (BCP-14 normative)

The ESOP service MUST ship annual valuation at services/esop/src/valuation/ with CFO propose + Board ≥3-sign + immutable per-year, 5 memory audit kinds.

  1. MUST validate valuation_status against closed enum per DEC-2271.
  1. MUST define tables at migration 0003: ```sql CREATE TABLE esop_annual_valuations ( valuation_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, valuation_year INT NOT NULL, base_share_price_vnd BIGINT NOT NULL CHECK (base_share_price_vnd >= 0), board_multiplier NUMERIC(7,4) NOT NULL DEFAULT 1.0 CHECK (board_multiplier > 0), committed_share_price_vnd BIGINT, status TEXT NOT NULL DEFAULT 'drafted' CHECK (status IN ('drafted','cfo_proposed','board_approved','dismissed')), cfo_proposed_by UUID, cfo_proposed_at TIMESTAMPTZ, committed_at TIMESTAMPTZ, correction_of UUID REFERENCES esop_annual_valuations(valuation_id), trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant_id, valuation_year, correction_of) ); ALTER TABLE esop_annual_valuations ENABLE ROW LEVEL SECURITY; CREATE POLICY valuations_rls ON esop_annual_valuations USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON esop_annual_valuations FROM cyberos_app; GRANT UPDATE (status, cfo_proposed_by, cfo_proposed_at, committed_at, committed_share_price_vnd) ON esop_annual_valuations TO cyberos_app;

CREATE TABLE esop_valuation_board_signs ( sign_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, valuation_id UUID NOT NULL REFERENCES esop_annual_valuations(valuation_id), board_member_id UUID NOT NULL, signed_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (valuation_id, board_member_id) ); ALTER TABLE esop_valuation_board_signs ENABLE ROW LEVEL SECURITY; CREATE POLICY signs_rls ON esop_valuation_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_valuation_board_signs FROM cyberos_app; ```

  1. MUST enforce board threshold per DEC-2272 at board_sign_gate.rs::can_commit(valuation):
  1. MUST be unique per year per DEC-2273 — UNIQUE(tenant_id, valuation_year, correction_of) allows original + corrections.
  1. MUST expose endpoints: ``text POST /v1/esop/valuations (CFO drafts/proposes) POST /v1/esop/valuations/{id}/board-sign (board member self) POST /v1/esop/valuations/{id}/dismiss (CFO; pre-board-approved) GET /v1/esop/valuations/{year} (current committed) ``
  1. MUST emit 5 memory audit kinds per DEC-2274. PII per TASK-MEMORY-111: share price SHA256.
  1. MUST thread trace_id from propose → sign → commit → audit.
  1. MUST NOT commit without threshold per DEC-2272.
  1. MUST NOT mutate prior valuation per DEC-2273 (correction = new row).

§2 — Why this design

Why CFO + Board (DEC-2270)? CFO has financial info; Board has governance authority — both required.

Why ≥3 sign (DEC-2272)? Majority approval for material decision; configurable per tenant board size.

Why immutable + correction_of (DEC-2273)? Audit lineage; corrections via new row preserve history.


§3 — API contract

Sample valuation propose:

POST /v1/esop/valuations
{
  "valuation_year": 2026,
  "base_share_price_vnd": 50000,
  "board_multiplier": 1.0
}

Sample committed:

{
  "valuation_id": "uuid",
  "valuation_year": 2026,
  "base_share_price_vnd": 50000,
  "board_multiplier": 1.0,
  "committed_share_price_vnd": 50000,
  "status": "board_approved",
  "board_signs_count": 3
}

§4 — Acceptance criteria

  1. valuation_status enum cardinality 4. 2. CFO propose required first. 3. Board ≥3 signs (configurable). 4. Threshold reached → auto-commit. 5. base_share_price_vnd ≥ 0. 6. board_multiplier > 0. 7. committed = base × multiplier. 8. UNIQUE(tenant, year, correction_of). 9. 5 memory audit kinds emitted. 10. PII scrubbed (share price SHA256). 11. RLS denies cross-tenant. 12. CFO-only propose. 13. Board member-only sign. 14. Trace_id preserved. 15. Append-only via REVOKE except status cols. 16. Correction via new row. 17. Dismiss pre-approval only. 18. bigint VND. 19. NUMERIC(7,4) multiplier. 20. Board threshold config per tenant.

§5 — Verification

#[tokio::test]
async fn board_3_signs_auto_commits() {
    let ctx = TestContext::with_proposed_valuation().await;
    ctx.board_sign(ctx.board1, ctx.val_id).await;
    ctx.board_sign(ctx.board2, ctx.val_id).await;
    let v = ctx.fetch_valuation(ctx.val_id).await;
    assert_ne!(v.status, "board_approved");
    ctx.board_sign(ctx.board3, ctx.val_id).await;
    let v2 = ctx.fetch_valuation(ctx.val_id).await;
    assert_eq!(v2.status, "board_approved");
    assert_eq!(v2.committed_share_price_vnd, Some(v2.base_share_price_vnd * v2.board_multiplier));
}

#[tokio::test]
async fn correction_via_new_row() {
    let ctx = TestContext::with_committed_valuation().await;
    let corr = ctx.propose_correction(ctx.val_id, 60000).await;
    assert!(corr.is_ok());
    assert_eq!(corr.row.correction_of, Some(ctx.val_id));
}

#[tokio::test]
async fn duplicate_year_blocked() {
    let ctx = TestContext::with_committed_valuation_2026().await;
    let r = ctx.try_propose_valuation(2026, 50000).await;
    assert!(r.is_err());  // UNIQUE
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-ESOP-001. Downstream: TASK-ESOP-004 (put-option uses committed price). Cross-module: TASK-AUTH-101 (CFO + board roles), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Threshold not reachedinherentstays at cfo_proposedget more signs
Same board sign twiceUNIQUEsecond skipinherent
Cross-tenant signRLS0 rowsinherent
Decimal precisionbigint + NUMERIC(7,4)inherentinherent
Negative priceCHECK400use positive
Multiplier ≤ 0CHECK400use positive
Concurrent commit attemptUPDATE WHERE not committedfirst winsinherent
Dismiss post-approvalreject409use correction
Year mismatchvalidate400use correct year
Board config missingdefault 3inherenttenant config

§11 — Implementation notes


End of TASK-ESOP-003 spec.