Skip to content

CLR export — design (v1)

Status: implemented & validated 2026-06-15 (slices 5a–5c, branch clr-export) — the credential lane’s last v1-milestone item. This is the Comprehensive Learner Record assembler — slice 5 of credential-export-v1.md, specified here once single-credential OB 3.0 signing was proven (slices 1–4 done 2026-06-13). Built to this design with one refinement recorded inline: the inner achievements keep their own org issuer (decision 1), so the shared issuer/subject/email/alignment resolution moved to apps/api/src/features/credential/export-shared.ts, consumed by both export services. validate:full 58/58, credential integration 7/7 (OB regression + CLR), and the CLR conformance lane (bun run test:clr:conformance) green against the official 1EdTech clr_v2p0_clrcredential schema. It adds no schema: a CLR is assembled, signed, and served on the exact issuer / key / KeySigner / JWKS / sign-event machinery the OB lane already ships. The work is an assembler (wire.ts), an export endpoint, and a conformance lane — nothing the operator must provision beyond what OB already needed. Decisions this builds on: ADR-0013 (person-rooted identity bindings — the pairwise subject), ADR-0014 (erasure is anonymization — signed documents are un-erasable, so no PII is frozen in), ADR-0015 (per-org issuer Profiles, platform-custodial keys, evidence by reference, sign-on-export), ADR-0016 (VC-JOSE/JWT proofs with Ed25519), ADR-0017 (Supabase Vault custody behind a KeySigner seam). Design groundwork: credential-export-v1.md (the OB lane this extends — every decision below inherits its decisions 1–9 verbatim), lrs-clr-record-mapping.md (the record-mapping doc whose two open decisions — CLR profile granularity and redaction policy — this design resolves), case-competency-v1.md (durable competency-node URIs the inner credentials align to). Boundary contracts: @conform-ed/contracts clr/v2_0 (ClrCredential, ClrSubject, VerifiableCredential, Association, re-exporting OB v3_0 Achievement/AchievementCredential/IdentityObject), open-badges/v3_0, vc-data-model/v2_0.

A ClrCredential is a Verifiable Credential whose subject (ClrSubject) carries a required verifiableCredential[] — the bundle. v1 fills that bundle with the learner’s granted-award achievements (the same OB 3.0 bodies slice 2 already assembles), wraps them in one signed envelope, and serves the compact JWS. The CLR is the learner’s portable transcript: one signature over their whole record, each constituent achievement still naming its own issuer.

These extend credential-export-v1.md decisions 1–9; the issuer, custody, proof, pairwise-subject, sign-on-export, and evidence-by-reference rules are unchanged.

  1. The CLR is the platform-transcript comprehensive record. One CLR per learner, signed by the platform-default issuer Profile (the null-org row) acting as transcript issuer, bundling every granted qualification_award for that person across all orgs. A CLR is a single VC with a single issuer, so it cannot span signers — the platform transcript issuer vouches for the collection, while each bundled achievement keeps its own issuer field. A per-issuer subset CLR (only one org’s awards) is a later query-scoped variant, not v1.

  2. Inner credentials are embedded as AchievementCredential objects, signed once by the CLR envelope. The bundle entries are the OB 3.0 AchievementCredential bodies (reusing assembleAchievementCredential), embedded as JSON; the outer CLR’s enveloping JWS is the single proof over the whole record. Inner credentials are not independently signed in v1 — the comprehensive bundle is the unit of verification. Independently-signed inner credentials embedded as W3C EnvelopedVerifiableCredential data:application/vc+jwt,… entries is the named upgrade (it needs a contract slot for the enveloped form and is an ADR-0016 proof-nesting decision); deferred.

  3. One coherent subject across the bundle. Every subject id — the ClrSubject id and each inner credential’s credentialSubject.id — is the same pairwise binding for (person, transcript issuer): reuse-or-mint the identity_binding (provider = 'credential-subject', issuer = transcript_issuer_uri). The hashed-email IdentityObject sits once at ClrSubject.identifier; inner credentials omit it (no repeated salt exposure). Because every id is the transcript-scoped pairwise URI, the CLR never leaks the learner’s per-org pairwise ids — cross-issuer correlation stays sealed (ADR-0013).

  4. Sign-on-export, deterministic id, nothing stored. The CLR id is urn:uuid:{uuidv5("clr:" + personId + ":" + transcriptProfileId, CREDENTIAL_NS)} — stable per (learner, transcript issuer), so re-export is stable while the bundle content reflects the learner’s current granted awards. validFrom and awardedDate are the export instant. Mirrors OB decision 6 exactly: a stable identifier slot honored by derivation, not a stored artifact.

  5. Zero new schema. The CLR reuses issuer_profile, issuer_signing_key, and credential_sign_event. The sign-event logs the CLR with award_id = null (the bundle is not one award; the credential_id urn + payload_sha256 digest are the tamper-evidence) — the constituent awards are deliberately not fanned out into per-award audit rows, so accountability gains no person-linkage breadth (ADR-0014). The only touch to existing code is widening the KeySigner seam’s awardId to nullable. No migration, so no DB reset and no operator handoff for this slice.

  6. Redaction by construction (resolves lrs-clr-record-mapping.md decision 3). The CLR exposes only the OB-projected achievement surface — claims, Achievement.alignment to durable CASE nodes, and provenance references (ADR-0014/0015). No institution-private field, raw xAPI excerpt, or evidence binary enters the bundle (decision 2 there is already resolved: reference only, never embedded). A configurable redaction policy waits for a named private field that some consumer needs conditionally included.

  7. Associations deferred. ClrSubject.association (the CLR AssociationType vocabulary — isPartOf, isChildOf, precedes, …) is emitted empty in v1: the first CLR is a flat bundle. Projecting qualification hierarchy or prerequisite chains as associations is an additive later slice — the same way OB Achievement.alignment arrived as slice 4 rather than at the start.

  8. Conformance-first. A CLR conformance lane (bun run test:clr:conformance) validates the signed CLR against the official 1EdTech clr_v2p0_clrcredential JSON Schema, wired with the slice — not endgame QA — exactly as the OB lane validates against ob_v3p0_achievementcredential.

Assembly (apps/api/src/features/credential/wire.ts)

Section titled “Assembly (apps/api/src/features/credential/wire.ts)”

The OB→core boundary already lives here; the CLR adds a second assembler beside assembleAchievementCredential, reusing it for each bundle entry.

const W3C_VC_V2_CONTEXT = "https://www.w3.org/ns/credentials/v2"; // existing
const CLR_V20_CONTEXT = "https://purl.imsglobal.org/spec/clr/v2p0/context-2.0.0.json";
const OPEN_BADGES_V30_CONTEXT = "https://purl.imsglobal.org/spec/ob/v3p0/context-3.0.3.json"; // existing
export type AssembleClrCredentialInput = {
clrId: string; // urn:uuid (decision 4)
issuerUri: string; // the transcript issuer Profile URI
issuerName: string;
subjectUri: string; // the (person, transcript-issuer) pairwise URI
name: string; // e.g. "Comprehensive Learner Record"
validFrom: Date; // export instant
identityObject?: Record<string, unknown>; // hashed email, once, at ClrSubject
credentials: Record<string, unknown>[]; // the inner AchievementCredential bodies
};
  • deterministicClrId(personId, transcriptProfileId) — the urn:uuid:uuidv5 (decision 4), reusing the existing uuidV5 + CREDENTIAL_ID_NAMESPACE.
  • assembleClrCredential(input) shapes:
    • @context: [W3C_VC_V2_CONTEXT, CLR_V20_CONTEXT, OPEN_BADGES_V30_CONTEXT] (the order ClrCredentialContextSchema requires).
    • type: ["VerifiableCredential", "ClrCredential"].
    • id, name, validFrom + awardedDate (= validFrom), issuer: { id: issuerUri, type: "Profile", name: issuerName }.
    • credentialSubject (ClrSubject): { id: subjectUri, type: "ClrSubject", verifiableCredential: credentials, ...(identityObject ? { identifier: [identityObject] } : {}) }. No proof — the enveloping JWS is the proof (ADR-0016).
  • parseClrCredential(candidate)ClrCredentialSchema.safeParse, the fail-closed totality guard (mirrors parseAchievementCredential / parseCfPackage). It validates the whole bundle, so any inner-credential shape the clr/v2_0 contract rejects is caught at build time, before signing.

Inner entries are assembled in embedded mode: assembleAchievementCredential called with subjectUri = the transcript pairwise URI and no identityObject (decision 3). Slice-4 alignment carries through unchanged — each inner achievement keeps its Achievement.alignment to durable CASE node URIs.

Export pipeline (apps/api/src/features/credential/export-service.ts)

Section titled “Export pipeline (apps/api/src/features/credential/export-service.ts)”

POST /workflow/credentials/clr — the authenticated learner mints their own comprehensive record. Nothing is persisted but the one sign-event row.

  1. Authz (learner-self). The subject is principal.personId; no per-award ownership check beyond “the learner’s own awards”. Resolve the platform-default issuer profile (null org) as transcript issuer; if none is provisioned → 409 (the platform issuer must exist, an operator step).
  2. Gather. Load all state = 'granted' qualification_award rows for the person. Zero granted awards → 409 (ClrSubject.verifiableCredential is required and non-empty; there is nothing to record). Order deterministically (by granted_at, then award id) so re-export is byte-stable.
  3. Subject identity. Reuse-or-mint the (person, transcript-issuer) pairwise identity_binding; read/generate the salt in binding_meta; build the hashed-email IdentityObject from a person email contact point (omit when the learner has no email — the pairwise id alone is a valid subject).
  4. Assemble inner credentials. For each award, load its qualification_definition + competency_alignment (the slice-4 loadAchievementAlignments lateral join, reused) and call assembleAchievementCredential in embedded mode. Inner credential ids stay the deterministic per-award urn:uuid (decision 6 of OB) — a verifier can still match a bundled achievement to its standalone OB export by id.
  5. Assemble the CLR. assembleClrCredential(...) over the inner bodies + identity object.
  6. Totality guard. parseClrCredential(candidate) — fail closed.
  7. Sign once. KeySigner.signCredentialJws({ issuerProfileId: transcriptProfileId, credentialId: clrId, awardId: null, payload }) → one compact JWS, one credential_sign_event (award_id = null).
  8. Return the compact JWS (application/vc+jwt-style). Nothing signed is stored.

CredentialExportError (the existing descriptor + httpStatus pattern) covers the 409s. The KeySigner seam change is one line — awardId: string | null — and the insert binds null straight through.

Unchanged. The CLR’s issuer.id resolves through the existing GET /credentials/issuers/:profileId Profile document and its .../jwks.json, served from issuer_signing_key.public_jwk with no vault read. Verification is JWKS-first (ADR-0016): the platform-default issuer’s kid in the JWS header resolves to the served public key.

A CLR lane mirroring the OB one, beside run-ob-conform-ed.ts:

  • tools/scripts/run-clr-conform-ed.ts (delegates to the integration compose harness, EMERGENT_INTEGRATION_SEED_ENABLED=false) + bun run test:clr:conformance.
  • tests/conformance/clr-credential.conformance.test.ts — boots the stack, provisions the platform issuer + key, grants ≥2 fixture awards (ideally from two orgs, to exercise the multi-issuer bundle), POSTs the export endpoint, fetches the JWKS + Profile, then asserts: VC-JOSE shape, the kid resolves and the envelope signature verifies, the bundle has one entry per granted award with a consistent transcript subject id, the IdentityObject sits once at the subject — and, the authoritative assertion, the decoded CLR validates against the vendored official 1EdTech clr_v2p0_clrcredential JSON Schema (draft 2019-09, via ajv) at tests/conformance/fixtures/.
  • The vendored schema + provenance README (the OB fixture pattern); oxfmt ignorePatterns already excludes the fixtures dir.
  • Runbook: extend docs/runbooks/ob-conformance-suite.md (or a sibling clr-conformance-suite.md) with the test:clr:conformance entry.
  • Add the CLR integration test to the test:integration:compose list and the conformance lane to the relevant gate.

Sequencing (slice 5 of credential-export-v1.md)

Section titled “Sequencing (slice 5 of credential-export-v1.md)”

5a. Assembler + unit tests. assembleClrCredential, deterministicClrId, parseClrCredential in wire.ts; unit tests for bundle shape, deterministic id stability, embedded-mode subject-id consistency (inner ids = transcript pairwise uri), single top-level identity object, and the fail-closed guard.

5b. Export service + endpoint + integration. exportClrCredential, the POST /workflow/credentials/clr route (path-alias + app.ts wiring, reusing the OB deps), the one-line nullable-awardId KeySigner change, and a podman-compose integration test: provision platform issuer + key → grant two awards (two orgs) → export → verify the envelope against the JWKS → decode and assert the two-entry bundle, consistent subject, identity object, and per-award inner ids; plus a second-learner isolation check (404/empty) and the zero-awards 409.

5c. Conformance lane. The runner + clr-credential.conformance.test.ts + vendored official CLR schema + runbook + test:clr:conformance script; green against the official 1EdTech schema is the lane’s validated bar (standards.md).

  • Independently-signed inner credentials via W3C EnvelopedVerifiableCredential (data:application/vc+jwt,…) — each bundled achievement standalone-verifiable inside the CLR. Needs a contract slot for the enveloped form and is an ADR-0016 proof-nesting decision.
  • Per-issuer / scoped CLRsPOST /workflow/credentials/issuers/:profileId/clr for one org’s subset, and partial: true point-in-time records. v1 is the single comprehensive transcript.
  • AssociationsClrSubject.association projecting qualification hierarchy / prerequisites with the CLR AssociationType vocabulary (additive, like OB alignment was).
  • credentialStatus / revocation, Data Integrity proofs, did:web issuer, learner-held keys, first-party verify page, OpenBao transit custody — inherited deferrals from credential-export-v1.md; all apply identically to the CLR.
  • GetClrCredentialsResponse REST surface — the CLR 2.0 protocol’s paged-fetch endpoint (compactJwsString[]); v1 is the single learner-pull export, the OB lane’s shape.