Task — engineering-spec@1

"LEARN promotion approval workflow — CEO + CHRO sign-off after council vote with cascade to HR + REW comp band update"

draftTASK-LEARN-006
module learn · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-LEARN-004 · blocks none

§1 — Description (BCP-14 normative)

The LEARN service MUST ship promotion workflow at services/learn/src/promotion/ with CEO+CHRO dual-sign + cascade to HR + REW + CHAT, 5 memory audit kinds.

  1. MUST validate promotion_status against closed enum per DEC-2131.
  1. MUST initiate per DEC-2130 when TASK-LEARN-004 council completes with recommendation=promote.
  1. MUST enforce dual-sign at dual_sign_gate.rs::can_execute(promotion) per DEC-2133:
  1. MUST cascade on executed at cascade.rs::execute(promotion) per DEC-2132:
  1. MUST define table at migration 0006: ``sql CREATE TABLE learn_promotions ( promotion_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, candidate_member_id UUID NOT NULL, council_id UUID NOT NULL, skill_id UUID NOT NULL, from_level INT NOT NULL CHECK (from_level >= 1 AND from_level <= 5), to_level INT NOT NULL CHECK (to_level >= 1 AND to_level <= 5), status TEXT NOT NULL DEFAULT 'pending_council' CHECK (status IN ('pending_council','council_recommended','ceo_signed','chro_signed','approved','declined','executed')), ceo_signed_by UUID, ceo_signed_at TIMESTAMPTZ, chro_signed_by UUID, chro_signed_at TIMESTAMPTZ, executed_at TIMESTAMPTZ, decline_reason TEXT, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (council_id) ); ALTER TABLE learn_promotions ENABLE ROW LEVEL SECURITY; CREATE POLICY promotions_rls ON learn_promotions USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON learn_promotions FROM cyberos_app; GRANT UPDATE (status, ceo_signed_by, ceo_signed_at, chro_signed_by, chro_signed_at, executed_at, decline_reason) ON learn_promotions TO cyberos_app; ``
  1. MUST expose endpoints: ``text POST /v1/learn/promotions/{id}/ceo-sign POST /v1/learn/promotions/{id}/chro-sign POST /v1/learn/promotions/{id}/decline body: {reason} GET /v1/learn/promotions/{id} ``
  1. MUST emit 5 memory audit kinds per DEC-2134. PII per TASK-MEMORY-111: decline_reason SHA-256 hashed; status enum + level ints ok.
  1. MUST thread trace_id from initiate → sign → execute → cascade → audit.
  1. MUST NOT execute without dual-sign per DEC-2130.
  1. MUST NOT allow same-person dual-sign per DEC-2133.
  1. MUST NOT skip cascade per DEC-2132.

§2 — Why this design

Why dual-sign (DEC-2130)? Promotion = career + financial impact; single-signer governance gap.

Why separation of duties (DEC-2133)? Same person both roles = no real second check.

Why cascade transactional (DEC-2132)? HR + REW + announcement must align; partial = embarrassment.


§3 — API contract

Sample promotion state:

{
  "promotion_id": "uuid",
  "candidate_member_id": "uuid",
  "from_level": 3,
  "to_level": 4,
  "skill_id": "uuid",
  "status": "ceo_signed",
  "ceo_signed_at": "2026-05-17T10:00:00Z"
}

§4 — Acceptance criteria

  1. promotion_status enum cardinality 7. 2. CEO+CHRO dual-sign required. 3. Same person rejected. 4. Cascade transactional. 5. HR mastery updated. 6. REW comp band updated. 7. CHAT announcement non-blocking. 8. UNIQUE(council_id) — one promotion per council. 9. 5 memory audit kinds emitted. 10. PII scrubbed (decline_reason SHA256). 11. RLS denies cross-tenant. 12. CEO/CHRO role only. 13. Trace_id preserved. 14. Append-only via REVOKE except status cols. 15. Decline allowed at any pre-execute stage. 16. from_level + to_level CHECK 1-5. 17. to_level > from_level enforced. 18. Status workflow enforced. 19. Cascade failure → rollback. 20. Council recommend=decline blocks promotion init.

§5 — Verification

#[tokio::test]
async fn dual_sign_required() {
    let ctx = TestContext::with_promotion_pending().await;
    ctx.ceo_sign(ctx.promotion_id).await;
    let r = ctx.try_execute(ctx.promotion_id).await;
    assert!(r.is_err());  // CHRO missing
    ctx.chro_sign(ctx.promotion_id).await;
    let r2 = ctx.execute(ctx.promotion_id).await;
    assert!(r2.is_ok());
}

#[tokio::test]
async fn same_person_rejected() {
    let ctx = TestContext::with_promotion_pending().await;
    ctx.ceo_sign_as(ctx.user_a, ctx.promotion_id).await;
    let r = ctx.try_chro_sign_as(ctx.user_a, ctx.promotion_id).await;
    assert!(r.is_err());
}

#[tokio::test]
async fn cascade_updates_hr_and_rew() {
    let ctx = TestContext::with_dual_signed_promotion().await;
    ctx.execute(ctx.promotion_id).await;
    let hr_level = ctx.fetch_hr_mastery(ctx.member_id, ctx.skill_id).await;
    let rew_band = ctx.fetch_rew_band(ctx.member_id).await;
    assert_eq!(hr_level, 4);
    assert!(rew_band.updated_at > ctx.before_execute_time);
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-LEARN-004. Cross-module: TASK-LEARN-005 (recommendation source), TASK-HR-001, TASK-REW-001, TASK-CHAT-005, TASK-AUTH-101 (CEO/CHRO), TASK-MEMORY-111.

§10 — Failure modes

FailureDetectionOutcomeRecovery
One signer missinggatereject executewait
Same-person dualvalidate403different signer
HR cascade failsrollbacksev-1; status=approved-pendingretry
REW cascade failsrollbacksev-1retry
CHAT announce failsnon-blockingsev-3inherent
Duplicate promotion per councilUNIQUE409inherent
Council recommend=declineblock init412re-convene
Status workflow violationcheck409follow workflow
Cross-tenant promotionRLS403inherent
from_level >= to_levelCHECK400fix levels

§11 — Implementation notes


End of TASK-LEARN-006 spec.