Task — engineering-spec@1

"LEARN per-judge score isolation — never exit LEARN boundary; HR receives only summary + recommendation"

draftTASK-LEARN-005
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 per-judge score isolation at services/learn/src/disclosure/ enforcing disclosure filter on all cross-service reads, 4 memory audit kinds.

  1. MUST validate external_disclosure against closed enum per DEC-2121.
  1. MUST filter at filter.rs::apply(council_data, requester_role) per DEC-2120:
  1. MUST gate at access_gate.rs::check(requester, council) per DEC-2123:
  1. MUST define disclosure log at migration 0005: ``sql CREATE TABLE learn_disclosure_log ( log_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, council_id UUID NOT NULL, requester_id UUID NOT NULL, requester_role TEXT NOT NULL, disclosure_kind TEXT NOT NULL CHECK (disclosure_kind IN ('none','aggregate_only','recommendation_only','judge_identities_only')), succeeded BOOLEAN NOT NULL, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX disclosure_council_idx ON learn_disclosure_log(tenant_id, council_id, created_at DESC); ALTER TABLE learn_disclosure_log ENABLE ROW LEVEL SECURITY; CREATE POLICY disclosure_rls ON learn_disclosure_log USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON learn_disclosure_log FROM cyberos_app; ``
  1. MUST expose disclosure endpoint: ``text GET /v1/learn/councils/{id}/disclosure?kind=aggregate_only ``

Returns filtered data; rejects unauthorized roles.

  1. MUST internal council scoring endpoints (TASK-LEARN-004) marked internal_only — direct cross-service raw access blocked.
  1. MUST emit 4 memory audit kinds per DEC-2124. PII per TASK-MEMORY-111: scores never in memory chain.
  1. MUST thread trace_id from request → gate → filter → audit.
  1. MUST NOT expose raw per-judge scores via any cross-service API per DEC-2120.
  1. MUST NOT bypass filter per DEC-2122 (no admin override outside CISO audit).

§2 — Why this design

Why isolation (DEC-2120)? Judge honesty depends on confidence scores won't haunt them politically; exposure breaks the system.

Why role-based disclosure (DEC-2123)? Different consumers need different views — HR needs scores, CEO needs decisions, others need nothing.

Why CISO full-access (DEC-2123)? Audit must verify isolation works; CISO access is logged → traceable.

Why disclosure log (DEC-2124)? Audit "who saw what about whom"; investigates suspected leaks.


§3 — API contract

GET /v1/learn/councils/{id}/disclosure?kind=aggregate_only

Sample aggregate_only response:

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

Sample 403 attempt:

{
  "error": "disclosure_denied",
  "reason": "role 'engineer' cannot access learn.council disclosures"
}

§4 — Acceptance criteria

  1. external_disclosure enum cardinality 4. 2. HR gets aggregate_only. 3. REW gets aggregate_only. 4. CEO+CHRO get recommendation_only. 5. CISO gets full (logged). 6. Other roles 403. 7. Raw scores never exposed externally. 8. 4 memory audit kinds emitted. 9. PII scrubbed (scores never in chain). 10. RLS denies cross-tenant. 11. Disclosure log per access. 12. Trace_id preserved. 13. Append-only log via REVOKE. 14. Internal endpoint marker prevents external use. 15. CISO audit dashboard accessible. 16. Unauthorized attempt → sev-2 audit. 17. Filter pure function. 18. Cross-tenant attempt rejected. 19. Per-council disclosure history queryable. 20. Aggregate_only excludes per-judge breakdown.

§5 — Verification

#[tokio::test]
async fn raw_scores_denied_to_hr() {
    let ctx = TestContext::with_completed_council_and_hr_role().await;
    let r = ctx.try_read_raw_scores_as_hr(ctx.council_id).await;
    assert_eq!(r.status_code, 403);
    let log = ctx.fetch_disclosure_log(ctx.council_id).await;
    assert!(log.iter().any(|l| !l.succeeded));
}

#[tokio::test]
async fn hr_gets_aggregate_only() {
    let ctx = TestContext::with_completed_council_and_hr_role().await;
    let r = ctx.read_disclosure_as_hr(ctx.council_id, "aggregate_only").await;
    assert!(r.contains("technical"));
    assert!(!r.contains("judge_id"));  // raw judge IDs not exposed
}

#[tokio::test]
async fn unauthorized_attempt_audited() {
    let ctx = TestContext::with_engineer_role().await;
    ctx.try_read_disclosure(ctx.council_id).await;
    let audits = ctx.fetch_memory_audits("learn.disclosure_denied").await;
    assert!(!audits.is_empty());
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-LEARN-004. Downstream: TASK-LEARN-006 (promotion uses recommendation_only). Cross-module: TASK-AUTH-101 (role check), TASK-HR-008 (HR consumer), task-REW (REW consumer), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Role not mappedgate default403 + sev-2add role mapping
Disclosure kind not in enumreject400use valid
Cross-tenant readRLS403inherent
Internal endpoint accessed externallyflag check403 + sev-1inherent
Filter bug exposes scorescode review + testssev-1fix
Disclosure log table fillspartition by monthinherentmaintenance
CISO audit dashboard slowindextuneinherent
Concurrent disclosureinherentboth loggedinherent
Cross-service raw query attemptgate403 + sev-2inherent
Council not completedcheck412wait

§11 — Implementation notes


End of TASK-LEARN-005 spec.