Task — engineering-spec@1

"HR onboarding saga — orchestrates AUTH + TIME + LEARN + KB + CHAT + REW provisioning on member.active transition with compensating rollback"

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

§1 — Description (BCP-14 normative)

The HR service MUST ship onboarding saga at services/hr/src/onboarding/ orchestrating 6 module provisions in order with compensating rollback, immutable saga state, 6 memory audit kinds.

  1. MUST trigger on member.status transition to 'active' per DEC-1883 — hook at services/hr/src/members.rs.
  1. MUST validate saga_step per DEC-1881, saga_status per DEC-1882.
  1. MUST execute steps in fixed order per DEC-1880 at saga_orchestrator.rs::run(member):
  2. auth_provision — TASK-AUTH-101 create user with role per contract type
  3. time_init — TASK-TIME-001 create member time profile
  4. learn_assign_starter — TASK-LEARN-001 assign starter pack
  5. kb_grant_scope — TASK-KB-001 grant team scope
  6. chat_create_channel — TASK-CHAT-005 add to team channels
  7. rew_init_baseline — TASK-REW-001 init comp record
  1. MUST be idempotent per DEC-1880 — each step checks "already done" before acting.
  1. MUST compensate per DEC-1884 on any failure at compensation.rs::compensate(saga, failed_step):
  1. MUST define table at migration 0009: ``sql CREATE TABLE hr_onboarding_sagas ( saga_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL UNIQUE, current_step TEXT CHECK (current_step IS NULL OR current_step IN ('auth_provision','time_init','learn_assign_starter','kb_grant_scope','chat_create_channel','rew_init_baseline')), completed_steps TEXT[] NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','in_progress','completed','failed','compensating','compensated')), failed_step TEXT, failure_reason TEXT, compensation_log JSONB, trace_id CHAR(32), started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE hr_onboarding_sagas ENABLE ROW LEVEL SECURITY; CREATE POLICY saga_rls ON hr_onboarding_sagas USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON hr_onboarding_sagas FROM cyberos_app; GRANT UPDATE (current_step, completed_steps, status, failed_step, failure_reason, compensation_log, started_at, completed_at) ON hr_onboarding_sagas TO cyberos_app; ``
  1. MUST expose endpoints: ``text POST /v1/hr/onboarding/start (CHRO; manual trigger if hook missed) POST /v1/hr/onboarding/{saga_id}/retry (CHRO; resume failed at step) POST /v1/hr/onboarding/{saga_id}/compensate (CHRO; force rollback) GET /v1/hr/onboarding/sagas/{id} (status) ``
  1. MUST emit 6 memory audit kinds per DEC-1885. PII per TASK-MEMORY-111: member_id (uuid) ok; failure_reason hashed.
  1. MUST thread trace_id across all 6 steps + compensation; visible in each module's audit chain.
  1. MUST NOT skip compensation on failure per DEC-1884 — must execute completed_steps in reverse.
  1. MUST NOT skip steps per DEC-1880 — ordering matters (chat needs auth user).
  1. MUST NOT double-onboard same member (UNIQUE on member_id).

§2 — Why this design

Why saga pattern (DEC-1880)? Cross-module orchestration without 2PC; each step transactional + compensable.

Why fixed order (DEC-1880)? Dependencies: chat needs auth user; learn needs member profile; rew needs contract type. Order is contract.

Why compensation (DEC-1884)? Partial state = worse than no state; rollback ensures clean retry.

Why idempotency (DEC-1880)? Saga retries on transient failure; double-execution must be safe.


§3 — API contract

Sample saga state:

{
  "saga_id": "uuid",
  "member_id": "uuid",
  "current_step": "kb_grant_scope",
  "completed_steps": ["auth_provision", "time_init", "learn_assign_starter"],
  "status": "in_progress",
  "started_at": "2026-05-17T10:00:00Z"
}

Failure state:

{
  "saga_id": "uuid",
  "current_step": "rew_init_baseline",
  "completed_steps": ["auth_provision", "time_init", "learn_assign_starter", "kb_grant_scope", "chat_create_channel"],
  "status": "compensating",
  "failed_step": "rew_init_baseline",
  "failure_reason": "REW partner API timeout",
  "compensation_log": [{"step": "chat_create_channel", "compensated_at": "..."}]
}

§4 — Acceptance criteria

  1. 6-step enum + cardinality test. 2. 6-status enum + cardinality test. 3. Triggered on member.status → active. 4. Steps execute in order. 5. Idempotent steps. 6. Compensation on failure (reverse order). 7. UNIQUE on member_id. 8. 6 memory audit kinds emitted. 9. PII scrubbed (failure_reason SHA256). 10. RLS denies cross-tenant. 11. Trace_id preserved across modules. 12. CHRO-only manual trigger/retry/compensate. 13. Append-only via REVOKE except status cols. 14. Retry resumes from failed step. 15. Compensation log JSONB tracks each reverse op. 16. Saga state queryable. 17. Contract type required (else error). 18. Saga timeout 30min (sev-1 + compensate). 19. Concurrent triggers UNIQUE-rejected. 20. All 6 modules return success on completion.

§5 — Verification

#[tokio::test]
async fn full_flow_completes() {
    let ctx = TestContext::with_new_member_and_contract().await;
    ctx.activate_member(ctx.member_id).await;
    let saga = ctx.wait_for_saga_complete(ctx.member_id).await;
    assert_eq!(saga.status, "completed");
    assert_eq!(saga.completed_steps.len(), 6);
}

#[tokio::test]
async fn step_failure_triggers_compensation() {
    let ctx = TestContext::with_new_member_rew_will_fail().await;
    ctx.activate_member(ctx.member_id).await;
    let saga = ctx.wait_for_saga_state(ctx.member_id, "compensated").await;
    let auth_status = ctx.fetch_auth(ctx.member_id).await;
    assert!(auth_status.is_none());  // compensated back to nothing
}

#[tokio::test]
async fn idempotent_double_trigger() {
    let ctx = TestContext::with_new_member().await;
    ctx.activate_member(ctx.member_id).await;
    let r = ctx.try_activate_again(ctx.member_id).await;
    let sagas = ctx.fetch_sagas(ctx.member_id).await;
    assert_eq!(sagas.len(), 1);
}

#[tokio::test]
async fn trace_id_propagated() {
    let ctx = TestContext::with_traceable_activation().await;
    ctx.activate_member(ctx.member_id).await;
    let saga = ctx.wait_complete(ctx.member_id).await;
    let auth_audit = ctx.fetch_memory_audit("auth.user_created", saga.member_id).await;
    assert_eq!(auth_audit.trace_id, saga.trace_id);
}

// 5.5..5.10

§7 — Dependencies

Upstream: TASK-HR-001. Cross-module: TASK-AUTH-101 (provision), TASK-TIME-001 (init), TASK-LEARN-001 (starter pack), TASK-KB-001 (scope grant), TASK-CHAT-005 (channel), TASK-REW-001 (comp baseline), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Step API timeoutretry 3xfailed; sev-1; compensatemanual retry
Step idempotency violatedstep handlersev-1; investigatebug fix
Compensation step failssev-1 alertpartial compensation loggedmanual intervention
Saga timeout 30mincron checksev-1; compensateinherent
Concurrent activationUNIQUEsecond skippedinherent
Member already onboardedUNIQUE409use retry endpoint
Contract type missingearly validate400set contract first
Cross-tenant triggerRLS403inherent
Module unavailablesev-2; retrypause sagainherent
Compensation log corruptionsev-1 auditmanual reviewbug fix

§11 — Implementation notes


End of TASK-HR-007 spec.