Task — engineering-spec@1

"ESOP Member dashboard — personal view only (own grants + vesting + estimated value); cross-Member access requires CFO audit"

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

§1 — Description (BCP-14 normative)

The ESOP service MUST ship Member dashboard at services/esop/src/dashboard/ with self-only default + CFO-audited cross-member access + estimated value, 4 memory audit kinds.

  1. MUST validate dashboard_access_kind against closed enum per DEC-2311.
  1. MUST gate at access_gate.rs::check(requester, viewed_member) per DEC-2310:
  1. MUST compute estimated value at value_calculator.rs::compute(member) per DEC-2312:
  1. MUST log access at migration 0007: ``sql CREATE TABLE esop_dashboard_access_log ( log_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, requester_id UUID NOT NULL, viewed_member_id UUID NOT NULL, access_kind TEXT NOT NULL CHECK (access_kind IN ('self_view','cfo_audit_view','ceo_audit_view','denied')), audit_reason TEXT, ip_address_hash TEXT, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX dashboard_log_member_idx ON esop_dashboard_access_log(tenant_id, viewed_member_id, created_at DESC); ALTER TABLE esop_dashboard_access_log ENABLE ROW LEVEL SECURITY; CREATE POLICY log_rls ON esop_dashboard_access_log USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON esop_dashboard_access_log FROM cyberos_app; ``
  1. MUST expose endpoints: ``text GET /v1/esop/members/{id}/dashboard (self → always OK; cross → requires audit_reason header) ``
  1. MUST emit 4 memory audit kinds per DEC-2314. PII per TASK-MEMORY-111: estimated_value SHA256.
  1. MUST thread trace_id from request → gate → audit.
  1. MUST NOT allow cross-member view without audit reason per DEC-2310.
  1. MUST NOT silently log cross-member view per DEC-2313 (sev-2 memory audit always).

§2 — Why this design

Why self-only default (DEC-2310)? Equity is sensitive personal financial info; default scope = self.

Why CFO/CEO audit access (DEC-2310)? Legitimate need — promotion review, IPO prep, fraud investigation.

Why access log (DEC-2313)? Detects silent snooping; audit trail for board review.


§3 — API contract

Sample self view:

GET /v1/esop/members/{id}/dashboard

{
  "member_id": "uuid",
  "grants": [
    {
      "grant_id": "uuid",
      "kind": "employee_initial",
      "total_shares": 10000,
      "vested_shares": 3333,
      "vesting_pct": 0.333,
      "estimated_value_vnd": 166650000,
      "next_vest_date": "2026-07-01"
    }
  ],
  "total_estimated_value_vnd": 166650000,
  "valuation_year": 2026,
  "share_price_vnd": 50000
}

Cross-member denied:

{"error": "dashboard_access_denied", "reason": "Cross-member view requires audit_reason header (CFO/CEO only)."}

§4 — Acceptance criteria

  1. dashboard_access_kind enum cardinality 4. 2. Self-view always allowed. 3. Cross-member denied for non-CFO/CEO. 4. CFO with audit_reason allowed + logged. 5. CEO with audit_reason allowed + logged. 6. Estimated value = vested × latest committed price. 7. Per-grant breakdown. 8. Next vest date computed. 9. 4 memory audit kinds emitted. 10. PII scrubbed (value SHA256). 11. RLS denies cross-tenant. 12. Trace_id preserved. 13. Append-only access log. 14. IP hashed. 15. denied audits sev-2. 16. Audit reason required for cross. 17. Self-view doesn't require reason. 18. Audit log queryable by CISO. 19. bigint VND. 20. No TASK-ESOP-003 valuation → estimated_value=null + sev-3.

§5 — Verification

#[tokio::test]
async fn self_view_allowed() {
    let ctx = TestContext::with_member_and_grant().await;
    let r = ctx.fetch_dashboard_as_member(ctx.member_id).await;
    assert_eq!(r.status_code, 200);
    assert_eq!(r.body.member_id, ctx.member_id);
}

#[tokio::test]
async fn cross_member_denied_for_engineer() {
    let ctx = TestContext::with_engineer_role().await;
    let r = ctx.try_fetch_dashboard_as(ctx.engineer, ctx.other_member).await;
    assert_eq!(r.status_code, 403);
    let audits = ctx.fetch_memory_audits("esop.dashboard_access_denied").await;
    assert!(!audits.is_empty());
}

#[tokio::test]
async fn cfo_with_reason_logged() {
    let ctx = TestContext::with_cfo().await;
    let r = ctx.fetch_dashboard_as(ctx.cfo, ctx.other_member, "promotion review").await;
    assert_eq!(r.status_code, 200);
    let log = ctx.fetch_access_log(ctx.other_member).await;
    let cfo_audit = log.iter().find(|l| l.access_kind == "cfo_audit_view").unwrap();
    assert_eq!(cfo_audit.audit_reason.as_deref(), Some("promotion review"));
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-ESOP-001. Cross-module: TASK-ESOP-002 (vested), TASK-ESOP-003 (valuation), TASK-AUTH-101 (roles), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Cross-member no reasongate403 + sev-2 auditprovide reason
No valuation for yearcheckestimated=null + sev-3propose valuation
No vested sharesinherentshows 0inherent
Cancelled grantfilterexcludedinherent
Cross-tenantRLS0 rowsinherent
Audit reason missing for CFOgate400provide
Member doesn't existRLS + FK404inherent
Decimal precisionbigintinherentinherent
Audit log fillspartition by monthinherentmaintenance
Concurrent dashboard fetchinherentinherentinherent

§11 — Implementation notes


End of TASK-ESOP-007 spec.