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
§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.
- MUST validate
promotion_statusagainst closed enum per DEC-2131.
- MUST initiate per DEC-2130 when TASK-LEARN-004 council completes with recommendation=promote.
- MUST enforce dual-sign at
dual_sign_gate.rs::can_execute(promotion)per DEC-2133:
- Both CEO + CHRO signed
- Same person can't sign both (separation of duties)
- MUST cascade on executed at
cascade.rs::execute(promotion)per DEC-2132:
- TASK-HR-001 update member.mastery_level (transactional)
- TASK-REW-001 update comp_band (transactional)
- TASK-CHAT-005 announce in #all (non-blocking)
- Rollback all on any failure
- 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;``
- 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}``
- MUST emit 5 memory audit kinds per DEC-2134. PII per TASK-MEMORY-111: decline_reason SHA-256 hashed; status enum + level ints ok.
- MUST thread trace_id from initiate → sign → execute → cascade → audit.
- MUST NOT execute without dual-sign per DEC-2130.
- MUST NOT allow same-person dual-sign per DEC-2133.
- 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
- 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
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| One signer missing | gate | reject execute | wait |
| Same-person dual | validate | 403 | different signer |
| HR cascade fails | rollback | sev-1; status=approved-pending | retry |
| REW cascade fails | rollback | sev-1 | retry |
| CHAT announce fails | non-blocking | sev-3 | inherent |
| Duplicate promotion per council | UNIQUE | 409 | inherent |
| Council recommend=decline | block init | 412 | re-convene |
| Status workflow violation | check | 409 | follow workflow |
| Cross-tenant promotion | RLS | 403 | inherent |
| from_level >= to_level | CHECK | 400 | fix levels |
§11 — Implementation notes
- §11.1 Cascade order: HR (least-side-effect first) → REW (financial) → CHAT (visible last).
- §11.2 Cascade transaction wraps HR + REW; CHAT outside (non-blocking).
- §11.3 memory audit body: promotion_id, member_id, from/to level, status; decline SHA256.
- §11.4 Decline at any pre-execute stage; council can be re-convened if needed.
- §11.5 Future: peer announcement + congrats flow via TASK-CHAT-005 reactions.
End of TASK-LEARN-006 spec.