Task — engineering-spec@1

"LEARN bằng cấp + chứng chỉ — degree + certification evidence types with issuer + expiry + verification link"

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

§1 — Description (BCP-14 normative)

The LEARN service MUST ship evidence at services/learn/src/evidence/ with 5-kind closed enum + skill link + TASK-DOC-001 scan ref + expiry cron, 4 memory audit kinds.

  1. MUST validate evidence_kind against closed enum per DEC-2091.
  1. MUST define table at migration 0002: ``sql CREATE TABLE learn_evidence ( evidence_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_id UUID NOT NULL, skill_id UUID REFERENCES learn_skills(skill_id), kind TEXT NOT NULL CHECK (kind IN ('degree','certification','course_completion','license','award')), name TEXT NOT NULL, issuer TEXT NOT NULL, issued_date DATE NOT NULL, expires_at DATE, verification_url TEXT, doc_id UUID, -- TASK-DOC-001 scan/photo verified BOOLEAN NOT NULL DEFAULT false, verified_by UUID, verified_at TIMESTAMPTZ, trace_id CHAR(32), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX evidence_member_idx ON learn_evidence(tenant_id, member_id); CREATE INDEX evidence_expiry_idx ON learn_evidence(tenant_id, expires_at) WHERE expires_at IS NOT NULL; ALTER TABLE learn_evidence ENABLE ROW LEVEL SECURITY; CREATE POLICY evidence_rls ON learn_evidence USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON learn_evidence FROM cyberos_app; GRANT UPDATE (verified, verified_by, verified_at) ON learn_evidence TO cyberos_app; ``
  1. MUST run expiry cron per DEC-2093 at 02:00 tenant_tz — flag certs expiring ≤30d; notify CHRO via TASK-CHAT-005.
  1. MUST expose endpoints: ``text POST /v1/learn/members/{id}/evidence body: {kind, name, issuer, issued_date, expires_at?, verification_url?, doc_id?, skill_id?} POST /v1/learn/evidence/{id}/verify (CHRO marks verified) GET /v1/learn/members/{id}/evidence (list) ``
  1. MUST emit 4 memory audit kinds per DEC-2094. PII per TASK-MEMORY-111: name + issuer text SHA256.
  1. MUST thread trace_id from add → audit.
  1. MUST NOT mutate prior evidence per DEC-2090 (append-only).

§2 — Why this design

Why 5 kinds (DEC-2091)? Covers degree, certification, course, license, award — bounded.

Why expiry monitoring (DEC-2093)? Lapsed certifications expose compliance risk (e.g. legal practice cert).

Why TASK-DOC-001 link (DEC-2092)? Visual evidence; auditors can see scans without downloading from external systems.


§3 — API contract

Sample evidence:

{
  "kind": "certification",
  "name": "AWS Solutions Architect Associate",
  "issuer": "AWS",
  "issued_date": "2025-01-15",
  "expires_at": "2028-01-15",
  "verification_url": "https://aws.amazon.com/verify/abc-123",
  "doc_id": "uuid-scan",
  "skill_id": "uuid-aws-skill"
}

§4 — Acceptance criteria

  1. evidence_kind enum cardinality 5. 2. Issuer required. 3. Issued_date required. 4. expires_at optional. 5. doc_id optional TASK-DOC-001 ref. 6. skill_id optional TASK-LEARN-001 link. 7. Verification flag CHRO-only set. 8. Expiry cron 02:00 daily. 9. 30d-expiring flagged + CHAT notification. 10. 4 memory audit kinds emitted. 11. PII scrubbed (name+issuer SHA256). 12. RLS denies cross-tenant. 13. Trace_id preserved. 14. Append-only via REVOKE except verify cols. 15. expiry_idx for fast cron. 16. member_idx for profile view. 17. Verification_url stored as URL string. 18. FK to doc_id allows NULL. 19. FK to skill_id allows NULL. 20. Renewal = new evidence row (preserves history).

§5 — Verification

#[tokio::test]
async fn evidence_kind_enum_enforced() {
    let r = ctx.add_evidence(ctx.member_id, "invalid_kind", ...).await;
    assert!(r.is_err());
}

#[tokio::test]
async fn expiry_30d_flagged() {
    let ctx = TestContext::with_cert_expiring_in_25d().await;
    ctx.run_expiry_cron().await;
    let audits = ctx.fetch_memory_audits("learn.evidence_expired").await;
    assert!(!audits.is_empty() || ctx.check_expiring_alert_sent().await);
}

#[tokio::test]
async fn append_only_no_update() {
    let ctx = TestContext::with_evidence().await;
    let r = ctx.try_update_evidence(ctx.evidence_id, "new name").await;
    assert!(r.is_err());
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-LEARN-001. Cross-module: TASK-DOC-001 (scans), TASK-MCP-007 (cron), TASK-CHAT-005 (notification), TASK-AUTH-101 (CHRO), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
Invalid kindCHECK400use valid
Missing issuervalidate400provide
Cross-tenant doc_idFK + RLS404inherent
Expiry cron skippedcatch-upinherentinherent
Verification by non-CHROrole check403request CHRO
Expired evidence renewalnew rowinherentinherent
Skill_id deletedFK NULLinherentdata fix
verification_url malformedvalidate400fix
Cross-tenant evidenceRLS0 rowsinherent
Large bulk importbatchinherentinherent

§11 — Implementation notes


End of TASK-LEARN-002 spec.