Task — engineering-spec@1

"LEARN Hội đồng Chuyên môn (Specialist Council) — 3-5 judges + multi-dim scoring + per-judge anonymity within council"

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

§1 — Description (BCP-14 normative)

The LEARN service MUST ship Hội đồng Chuyên môn at services/learn/src/council/ with 3-5 judges + 5-dim scoring + median aggregation, 5 memory audit kinds.

  1. MUST validate score_dimension against closed enum per DEC-2111, council_status per DEC-2112.
  1. MUST enforce 3-5 judges per DEC-2110 at council convene.
  1. MUST define tables at migration 0004: ```sql CREATE TABLE learn_councils ( council_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, candidate_member_id UUID NOT NULL, skill_id UUID, promotion_target_level INT, status TEXT NOT NULL DEFAULT 'convened' CHECK (status IN ('convened','scoring','completed','dismissed')), convened_by UUID NOT NULL, convened_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, trace_id CHAR(32) ); ALTER TABLE learn_councils ENABLE ROW LEVEL SECURITY; CREATE POLICY councils_rls ON learn_councils USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON learn_councils FROM cyberos_app; GRANT UPDATE (status, completed_at) ON learn_councils TO cyberos_app;

CREATE TABLE learn_council_judges ( council_id UUID NOT NULL REFERENCES learn_councils(council_id), judge_id UUID NOT NULL, tenant_id UUID NOT NULL, assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (council_id, judge_id) ); ALTER TABLE learn_council_judges ENABLE ROW LEVEL SECURITY; CREATE POLICY judges_rls ON learn_council_judges USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON learn_council_judges FROM cyberos_app;

CREATE TABLE learn_council_scores ( score_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, council_id UUID NOT NULL REFERENCES learn_councils(council_id), judge_id UUID NOT NULL, dimension TEXT NOT NULL CHECK (dimension IN ('technical','leadership','impact','collaboration','growth_potential')), score INT NOT NULL CHECK (score >= 1 AND score <= 5), rationale TEXT, submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(), trace_id CHAR(32), UNIQUE (council_id, judge_id, dimension) ); ALTER TABLE learn_council_scores ENABLE ROW LEVEL SECURITY; CREATE POLICY scores_rls ON learn_council_scores USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON learn_council_scores FROM cyberos_app; ```

  1. MUST aggregate at aggregator.rs::aggregate(council) per DEC-2114:
  1. MUST mark complete only when all judges submitted all 5 dimensions.
  1. MUST expose endpoints: ``text POST /v1/learn/councils (CHRO convenes) POST /v1/learn/councils/{id}/judges (add judge — 3-5 enforced) POST /v1/learn/councils/{id}/scores (judge submits) GET /v1/learn/councils/{id} (status + aggregate if completed) POST /v1/learn/councils/{id}/dismiss ``
  1. MUST emit 5 memory audit kinds per DEC-2115. PII per TASK-MEMORY-111: rationale SHA256; scores ok (small integers).
  1. MUST thread trace_id from convene → scoring → complete → audit.
  1. MUST NOT allow <3 or >5 judges per DEC-2110.
  1. MUST NOT mutate prior score per DEC-2113.
  1. MUST NOT double-score same dimension by same judge (UNIQUE).

§2 — Why this design

Why 3-5 judges (DEC-2110)? Odd numbers + bounded — prevents tie + cognitive overhead.

Why 5 dimensions (DEC-2111)? Industry-standard rubric (Google L-ladder pattern).

Why median (DEC-2114)? Robust to outlier judges; mean would let one extreme score sway.

Why per-judge anonymity (DEC-2113)? Reduces social pressure; TASK-LEARN-005 enforces post-completion isolation.


§3 — API contract

Sample council convene:

POST /v1/learn/councils
{
  "candidate_member_id": "uuid",
  "skill_id": "uuid",
  "promotion_target_level": 4
}

Sample judge score:

POST /v1/learn/councils/{id}/scores
{
  "dimension": "technical",
  "score": 4,
  "rationale": "Strong system design; needs more breadth in distributed systems."
}

Aggregate (when completed):

{
  "council_id": "uuid",
  "status": "completed",
  "aggregate": {
    "technical": 4,
    "leadership": 3,
    "impact": 4,
    "collaboration": 4,
    "growth_potential": 4,
    "overall_recommendation": "promote"
  },
  "judges_count": 5
}

§4 — Acceptance criteria

  1. score_dimension enum cardinality 5. 2. council_status enum cardinality 4. 3. 3-5 judges enforced. 4. Score CHECK 1-5. 5. UNIQUE(council, judge, dimension). 6. Median aggregation. 7. Completed when all judges × all dims submitted. 8. 5 memory audit kinds emitted. 9. PII scrubbed (rationale SHA256). 10. RLS denies cross-tenant. 11. CHRO-only convene. 12. Trace_id preserved. 13. Append-only via REVOKE except status cols. 14. Dismiss endpoint allowed. 15. Judge cannot self-score (member ≠ judge). 16. Same-tenant judges only. 17. promotion_target_level optional (general assessment OK). 18. Aggregate hidden until completed. 19. History queryable per candidate. 20. Overall recommendation logic per spec.

§5 — Verification

#[tokio::test]
async fn 3_judges_minimum() {
    let ctx = TestContext::with_council_convened().await;
    ctx.add_judge(ctx.judge1).await;
    ctx.add_judge(ctx.judge2).await;
    let r = ctx.try_start_scoring(ctx.council_id).await;
    assert!(r.is_err());  // need 3+
    ctx.add_judge(ctx.judge3).await;
    let r2 = ctx.try_start_scoring(ctx.council_id).await;
    assert!(r2.is_ok());
}

#[tokio::test]
async fn median_aggregation() {
    let ctx = TestContext::with_5_judges_scored([3, 4, 4, 5, 5], "technical").await;
    ctx.complete_council().await;
    let agg = ctx.fetch_aggregate(ctx.council_id).await;
    assert_eq!(agg.technical, 4);  // median of [3,4,4,5,5]
}

#[tokio::test]
async fn double_score_rejected() {
    let ctx = TestContext::with_council_scoring().await;
    ctx.submit_score(ctx.judge_id, "technical", 4).await;
    let r = ctx.submit_score(ctx.judge_id, "technical", 5).await;
    assert!(r.is_err());  // UNIQUE
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-LEARN-001. Downstream: TASK-LEARN-005 (isolation), TASK-LEARN-006 (promotion approval). Cross-module: TASK-AUTH-101 (CHRO + judge roles), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Judge count <3validatereject startadd judge
Judge count >5validatereject addinherent
Score out of rangeCHECK400use 1-5
Double scoreUNIQUE409inherent
Judge = candidatevalidaterejectuse different judge
Cross-tenant judgeRLS0 rowsinherent
Premature complete (missing scores)checkrejectsubmit remaining
Judge withdraws mid-councilmanual reassigninherentreplacement judge
Decimal precision N/Aintegersinherentinherent
Concurrent scoreUNIQUEfirst winsinherent

§11 — Implementation notes


End of TASK-LEARN-004 spec.