Task — engineering-spec@1

"LEARN skill tree schema — 1-5 mastery levels per skill per Member with parent-child skill graph"

draftTASK-LEARN-001
module learn · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-HR-001 · blocks TASK-LEARN-002, TASK-LEARN-004

§1 — Description (BCP-14 normative)

The LEARN service MUST ship skill tree at services/learn/src/skill_tree/ with hierarchical skill graph + per-member mastery + append-only audit, 3 memory audit kinds.

  1. MUST validate skill_domain against closed enum per DEC-2081, mastery_level per DEC-2082.
  1. MUST define tables at migration 0001: ```sql CREATE TABLE learn_skills ( skill_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, name TEXT NOT NULL, description TEXT, domain TEXT NOT NULL CHECK (domain IN ('engineering','design','product','sales','finance','ops','legal','general')), parent_skill_id UUID REFERENCES learn_skills(skill_id), depth INT NOT NULL DEFAULT 0 CHECK (depth >= 0 AND depth <= 4), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (tenant_id, name, parent_skill_id) ); CREATE INDEX skills_parent_idx ON learn_skills(tenant_id, parent_skill_id) WHERE parent_skill_id IS NOT NULL; ALTER TABLE learn_skills ENABLE ROW LEVEL SECURITY; CREATE POLICY skills_rls ON learn_skills USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); GRANT UPDATE (name, description, parent_skill_id, depth) ON learn_skills TO cyberos_app;

CREATE TABLE learn_member_mastery ( mastery_row_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL, skill_id UUID NOT NULL REFERENCES learn_skills(skill_id), mastery_level INT NOT NULL CHECK (mastery_level >= 1 AND mastery_level <= 5), assessed_by UUID NOT NULL, assessment_kind TEXT NOT NULL, -- 'self' | 'peer' | 'council' | 'system' valid_from DATE NOT NULL, valid_to DATE, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX mastery_member_skill_idx ON learn_member_mastery(tenant_id, member_id, skill_id, valid_from DESC); ALTER TABLE learn_member_mastery ENABLE ROW LEVEL SECURITY; CREATE POLICY mastery_rls ON learn_member_mastery USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON learn_member_mastery FROM cyberos_app; ```

  1. MUST enforce nesting depth ≤4 per DEC-2080 at validator.rs::validate(parent_skill_id).
  1. MUST be append-only per DEC-2083 — corrections via new row with new valid_from; prior row's valid_to set.
  1. MUST expose endpoints: ``text POST /v1/learn/skills (CHRO) POST /v1/learn/members/{id}/mastery body: {skill_id, mastery_level, assessment_kind} GET /v1/learn/members/{id}/mastery (current per-skill) GET /v1/learn/skills/tree (hierarchical view) ``
  1. MUST emit 3 memory audit kinds per DEC-2084. PII per TASK-MEMORY-111: skill names + descriptions text SHA-256 hashed; member_id + level ok.
  1. MUST thread trace_id from set → audit.
  1. MUST NOT mutate prior mastery row per DEC-2083.
  1. MUST NOT create cycles in parent_skill_id (validator check).

§2 — Why this design

Why 4-level depth (DEC-2080)? Bounded to prevent infinite trees; covers domain → discipline → skill → subskill.

Why 8 domains (DEC-2081)? Top-level taxonomy covers CyberSkill business; closed enum prevents sprawl.

Why 1-5 mastery (DEC-2082)? Industry-standard scale (Bloom + similar).

Why append-only (DEC-2083)? Audit lineage — promotion decisions reference mastery history.


§3 — API contract

Sample skill tree:

[
  {"skill_id": "uuid", "name": "Rust", "domain": "engineering", "depth": 0,
   "children": [{"skill_id": "uuid", "name": "Async Rust", "depth": 1}]}
]

Sample mastery set:

POST /v1/learn/members/{id}/mastery
{
  "skill_id": "uuid",
  "mastery_level": 3,
  "assessment_kind": "council",
  "valid_from": "2026-06-01"
}

§4 — Acceptance criteria

  1. skill_domain enum cardinality 8. 2. mastery_level CHECK 1-5. 3. parent depth ≤4. 4. Cycle prevention. 5. Append-only mastery. 6. UNIQUE(tenant, name, parent) on skills. 7. 3 memory audit kinds emitted. 8. PII scrubbed (skill text SHA256). 9. RLS denies cross-tenant. 10. CHRO-only skill create. 11. assessment_kind tagged (self/peer/council/system). 12. valid_from + valid_to range. 13. Trace_id preserved. 14. Tree query recursive CTE. 15. Current mastery = max valid_from. 16. Self-reference parent rejected. 17. Skill rename via UPDATE OK. 18. Parent change via UPDATE OK (depth recomputed). 19. Cross-tenant parent FK rejected. 20. Mastery FK to skill enforced.

§5 — Verification

#[tokio::test]
async fn mastery_level_1_to_5_enforced() {
    for level in 1..=5 {
        let r = ctx.set_mastery(ctx.member_id, ctx.skill_id, level).await;
        assert!(r.is_ok());
    }
    for bad in [0, 6, 100] {
        let r = ctx.set_mastery(ctx.member_id, ctx.skill_id, bad).await;
        assert!(r.is_err());
    }
}

#[tokio::test]
async fn parent_depth_5_rejected() {
    let chain = ctx.build_skill_chain(5).await;  // chain of 5 = depth 4
    let r = ctx.add_skill_under(chain.last(), "depth5").await;
    assert!(r.is_err());
}

#[tokio::test]
async fn append_only_no_update() {
    let ctx = TestContext::with_mastery_row().await;
    let r = ctx.try_update_mastery_row(ctx.mastery_row_id).await;
    assert!(r.is_err());
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-HR-001. Downstream: TASK-LEARN-002 (degrees+certs), TASK-LEARN-003 (VP rollup), TASK-LEARN-004 (Council). Cross-module: TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Domain not in enumCHECK400use valid
Depth > 4validatorrejectrestructure tree
Cyclevalidatorrejectinherent
Mastery out of rangeCHECK400use 1-5
Duplicate skill nameUNIQUE409rename
Mastery on deleted skillFK404reactivate skill
Cross-tenant parentFK + RLS404inherent
Decimal precisionnot applicableinherentinherent
Mastery row append raceinherentboth appendinherent
Self-ref parentvalidatorrejectuse different parent

§11 — Implementation notes


End of TASK-LEARN-001 spec.