Task — engineering-spec@1

"HR termination workflow — Good-Leaver / Bad-Leaver branch with CFO+CEO co-sign + ESOP forfeiture + access revocation cascade"

draftTASK-HR-009
module hr · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-HR-001 · blocks TASK-ESOP-005

§1 — Description (BCP-14 normative)

The HR service MUST ship termination workflow at services/hr/src/termination/ with GL/BL branch + CFO+CEO dual sign + cascade to ESOP/AUTH/PORTAL/PROJ, 5 memory audit kinds.

  1. MUST validate termination_kind against closed enum per DEC-1871, termination_stage per DEC-1872.
  1. MUST require dual sign-off per DEC-1873 at dual_sign_gate.rs::can_execute(termination):
  1. MUST branch GL/BL per DEC-1870:
  1. MUST cascade on executed per DEC-1874 at cascade_executor.rs::execute(termination):
  1. MUST define table at migration 0008: ``sql CREATE TABLE hr_terminations ( termination_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL UNIQUE, -- one termination per member kind TEXT NOT NULL CHECK (kind IN ('good_leaver_voluntary','good_leaver_redundancy','good_leaver_retirement','bad_leaver_misconduct','bad_leaver_breach_contract','mutual_separation')), stage TEXT NOT NULL DEFAULT 'initiated' CHECK (stage IN ('initiated','cfo_signed','ceo_signed','executed','dispute')), termination_date DATE NOT NULL, initiated_by UUID NOT NULL, cfo_signed_by UUID, cfo_signed_at TIMESTAMPTZ, ceo_signed_by UUID, ceo_signed_at TIMESTAMPTZ, executed_at TIMESTAMPTZ, reason TEXT, dispute_reason TEXT, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE hr_terminations ENABLE ROW LEVEL SECURITY; CREATE POLICY term_rls ON hr_terminations USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON hr_terminations FROM cyberos_app; GRANT UPDATE (stage, cfo_signed_by, cfo_signed_at, ceo_signed_by, ceo_signed_at, executed_at, dispute_reason) ON hr_terminations TO cyberos_app; ``
  1. MUST expose endpoints: ``text POST /v1/hr/terminations (CHRO initiates) POST /v1/hr/terminations/{id}/cfo-sign (CFO) POST /v1/hr/terminations/{id}/ceo-sign (CEO) POST /v1/hr/terminations/{id}/dispute (member-self or counsel) GET /v1/hr/terminations/{id} (status) ``
  1. MUST emit 5 memory audit kinds per DEC-1875. PII per TASK-MEMORY-111: reason+dispute_reason hashed; ids ok.
  1. MUST thread trace_id from initiate → sign → execute → cascade → audit.
  1. MUST NOT execute without both signatures per DEC-1873.
  1. MUST NOT allow same person both CFO+CEO roles per DEC-1873.
  1. MUST NOT skip cascade on execute per DEC-1874.

§2 — Why this design

Why GL/BL branch (DEC-1870)? ESOP forfeiture + severance differ materially; legal exposure if applied wrong.

Why dual sign (DEC-1873)? Termination = significant financial+legal action; single-signer risk = fraud or bias.

Why separation of duties (DEC-1873)? Same person signing both roles = no real second check; explicit constraint.

Why cascade (DEC-1874)? Ex-member retaining access = security disaster; auto-revoke on execute closes the loop.


§3 — API contract

POST   /v1/hr/terminations                body: {member_id, kind, termination_date, reason}
POST   /v1/hr/terminations/{id}/cfo-sign
POST   /v1/hr/terminations/{id}/ceo-sign
POST   /v1/hr/terminations/{id}/dispute   body: {dispute_reason}
GET    /v1/hr/terminations/{id}

Sample termination:

{
  "member_id": "uuid",
  "kind": "good_leaver_voluntary",
  "termination_date": "2026-06-30",
  "reason": "Career change to startup"
}

§4 — Acceptance criteria

  1. kind enum cardinality 6. 2. stage enum cardinality 5. 3. CFO+CEO dual sign required. 4. Same person can't sign both. 5. GL → ESOP fully vested up to term_date. 6. BL → ESOP forfeiture per policy. 7. Cascade to ESOP-005 + AUTH-101 + PORTAL-008 + PROJ-013. 8. 5 memory audit kinds emitted. 9. PII scrubbed (reason SHA256). 10. RLS denies cross-tenant. 11. CHRO initiate, CFO sign, CEO sign — role-gated. 12. Trace_id preserved. 13. UNIQUE on member_id (one termination per). 14. Append-only via REVOKE except 7 cols. 15. Dispute halts execution. 16. AUTH deprovision verified post-execute. 17. DSAR offer email sent. 18. Open issues reassigned to manager. 19. Cascade failure → sev-1 + rollback termination. 20. No termination during probation without specific kind.

§5 — Verification

#[tokio::test]
async fn dual_sign_required_for_execute() {
    let ctx = TestContext::with_initiated_termination().await;
    ctx.cfo_sign(ctx.term_id).await;
    let r = ctx.try_execute(ctx.term_id).await;
    assert!(r.is_err());  // CEO not signed
    ctx.ceo_sign(ctx.term_id).await;
    let r2 = ctx.execute(ctx.term_id).await;
    assert!(r2.is_ok());
}

#[tokio::test]
async fn same_person_both_roles_rejected() {
    let ctx = TestContext::with_initiated_termination().await;
    ctx.cfo_sign_as(ctx.user_a, ctx.term_id).await;
    let r = ctx.try_ceo_sign_as(ctx.user_a, ctx.term_id).await;
    assert!(r.is_err());
}

#[tokio::test]
async fn cascade_revokes_auth() {
    let ctx = TestContext::with_dual_signed_termination().await;
    ctx.execute(ctx.term_id).await;
    let auth_status = ctx.fetch_member_auth(ctx.member_id).await;
    assert_eq!(auth_status.active, false);
}

#[tokio::test]
async fn good_leaver_full_vesting() {
    let ctx = TestContext::with_member_partially_vested().await;
    let r = ctx.execute_gl_termination(ctx.member_id, "2026-06-30").await;
    let esop = ctx.fetch_esop_vesting(ctx.member_id).await;
    assert_eq!(esop.vested_at(ctx.term_date), esop.entitled_at(ctx.term_date));
}

// 5.5..5.10

§7 — Dependencies

Upstream: TASK-HR-001. Cross-module: TASK-AUTH-101 (CFO/CEO/CHRO roles + deprovision), TASK-ESOP-005 (GL/BL branch), TASK-PORTAL-008 (DSAR), TASK-PROJ-013 (issue reassign), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
One signer missinggate checkexecute rejected 409get sign
Same-person dualvalidatereject 403use different signer
Cascade ESOP failssev-1rollback termination stageretry execute
Cascade AUTH failssev-1termination haltedretry
Dispute during signstage→disputehalt executeresolve dispute
Termination date pastwarnallow (backdated terms exist)inherent
Re-termination attemptUNIQUE409inherent
Concurrent signUPDATE WHERE pendingfirst winsinherent
BL without misconduct evidencewarn auditstill execute (HR judgment)inherent
Reason missingoptional but warninherentinherent

§11 — Implementation notes


End of TASK-HR-009 spec.