Credential export — design (v1)
Status: v1 milestone complete — slices 1–4 (OB 3.0) implemented & integration/conformance-validated (2026-06-13); slice 5 (CLR 2.0) added & validated (2026-06-15). Slice 1 (issuer profiles + Vault provisioning + KeySigner
- JWKS), slice 2 (OB 3.0 assembly + learner-pulled export endpoint), slice 3 (the
OB conformance lane, validating against the official 1EdTech schema), and slice 4
(the cross-lane
Achievement.alignmentprojection to durable CASE node URIs) land inapps/api/src/features/credential/+tests/conformance/. The CLR 2.0 assembler (slice 5) is built toclr-export-v1.md. Decisions this builds on: ADR-0013 (person-rooted identity bindings), ADR-0014 (erasure is anonymization — signed documents are un-erasable by construction), ADR-0015 (per-org issuer Profiles, platform-custodial keys, evidence by reference, sign-on-export), ADR-0016 (VC-JOSE/JWT proofs with Ed25519, Data Integrity deferred), ADR-0017 (Supabase Vault custody behind a KeySigner seam, OpenBao the named upgrade trigger) Design groundwork:credential-export-v1-brief.md,identity-issuer.md(runbook — the env-owned issuer-string precedent),case-competency-v1.md(durable competency-node URIs for alignment) Boundary contracts:@conform-ed/contractsopen-badges/v3_0(AchievementCredential,Achievement,AchievementSubject,IdentityObject,Profile),clr/v2_0(CLR 2.0 assembler — slice 5),vc-data-model/v2_0
Decisions (settled 2026-06-13)
Section titled “Decisions (settled 2026-06-13)”- Issuers are rows keyed to organization; the platform default is the one row
with a null org.
issuer_profile.organization_idis nullable — null is the platform default issuer for independent learners; one profile per org. Issuer provenance surviving org archival is already handled by therestrictFK onqualification_award.issuer_organization_id(orgs archive, never delete, so the issuer row always resolves) — the brief’s “replace theset null” is done in schema. - Issuer URIs and JWKS URLs are computed, never stored. They derive from
CREDENTIAL_ISSUER_BASE_URL(fallbackhttps://localhost) + the profile id, exactly mirroringCASE_PROVIDER_BASE_URL/ONEROSTER_PROVIDER_BASE_URL. Environment-specific strings stay out of the schema (theidentity-issuerrunbook principle), and an issuer dying with its domain is semantically correct (ADR-0015) — the opposite of the subject-id rule. - Ed25519 keys: private JWK in Supabase Vault, public JWK duplicated to a
column. The private key lives in
vault.secretsunder a dedicated name scope (issuer_signing_key:{kid}); the public JWK is copied ontoissuer_signing_key.public_jwkso JWKS serving never touches the vault. A dedicatedcredential_signerDB role is the only reader of decrypted issuer secrets (through a name-scoped view); the general application role may write secrets (encryption only) but can never decrypt them (ADR-0017 guardrail 1). - The KeySigner seam is the single signing interface. Every signature flows
through
signCredentialJws(...)inapps/api;VaultKeySigneris the v1 implementation (private key enters app memory at sign time — the one recorded ADR-0017 deviation), connecting ascredential_signerover a separateSIGNING_DATABASE_URL. Custody is an implementation detail; anOpenBaoKeySigner(transit sign, key never leaves the KMS) is the named upgrade behind the same seam (guardrail 2). kidis the signing-key row uuid; rotation is additive. Rotating mints a newactivekey and flips the prior key toretired; both stay in the JWKS, so everykidever emitted keeps verifying. Nokidis ever removed, and rotation is never a re-issuance event (ADR-0016/0017).- Sign-on-export with a deterministic credential id. Nothing signed is
stored. The credential
idisurn:uuid:{uuidv5(awardId, CREDENTIAL_NS)}— stable per award, so re-export is stable and a futurecredentialStatusslot has a fixed target without storing artifacts (ADR-0015’s “identifier slots reserved at issuance”, honored by derivation, not a table). - Pairwise subject identifiers are
identity_bindingrows. On export we reuse-or-mint a binding (provider = 'credential-subject',issuer = issuer_uri,external_subject= a minted pairwise URI) so the subject id is stable from one issuer and uncorrelatable across issuers (ADR-0013). The hashed-emailIdentityObjectsalt lives inbinding_meta; the binding iscascadeerasure — deletion crypto-shreds the link. - Alignment reuses
competency_alignment. Rather than a new table, the competency lane adds aqualification_definitionvalue tocompetency_alignment_target_type(the only coordinated cross-lane schema touch); the credential export reads alignments for the definition and projectsAchievement.alignmentto durable CASE node URIs ({CASE_PROVIDER_BASE_URL}/CFItems/{nodeId}). Additive per credential — the first signed credential ships with zero alignment rows (cross-lane note). - Evidence by reference, including the audit log. The credential embeds
achievement claims plus provenance references only (ADR-0014/0015). The
sign-event log carries no direct person FK — an award husk reference, the
credential urn, the
kid, and a payload digest — so accountability never re-introduces PII into an un-erasable-adjacent record.
Issuer schema (packages/db/src/schema/credential-issuance.ts)
Section titled “Issuer schema (packages/db/src/schema/credential-issuance.ts)”issuer_profile
Section titled “issuer_profile”The CLR/OB issuer Profile, one per issuing Organization plus the platform default.
| Column | Notes |
|---|---|
id |
uuid PK — the stable segment of the issuer URI and JWKS URL |
organization_id |
FK → organization, restrict, nullable; null = platform default issuer. Restrict matches the award FK — issuer rows outlive nothing |
status |
enum active | archived (no-destruction lifecycle; follows the org but is its own row) |
created_by_person_id |
actor FK → person, set null (erasure: sever-class actor, like case_source/oneroster_source) |
profile_meta |
jsonb — display overrides (name/url) when not projected from the org |
| audit columns | auditColumnsUs() |
- Partial unique indexes: one profile per org (
organization_idwhere not null) and exactly one platform default (where organization_id is null). - Display
name/urlproject from the linked organization at assembly time (Emergentwhen platform-scoped) — not duplicated here, since the org row always survives (archival, never deletion).
issuer_signing_key
Section titled “issuer_signing_key”One row per key version per profile. The public half lives here; the private half lives only in the vault.
| Column | Notes |
|---|---|
id |
uuid PK — this is the kid (opaque, unique, durable; embedded in every JWS header) |
issuer_profile_id |
FK → issuer_profile, cascade (profile is durable) |
public_jwk |
jsonb — OKP/Ed25519 public JWK (+ kid, alg: "EdDSA", use: "sig"); the JWKS source, no vault read |
vault_secret_name |
text — pointer into vault.secrets (issuer_signing_key:{id}); the private key is never in this table |
alg |
text, default "EdDSA" (forward-compat; single algorithm in v1) |
status |
enum active | retired — active is selected for new signatures; retired keeps verifying |
rotated_at |
timestamp, nullable — set when flipped to retired |
| audit columns | auditColumnsUs() |
- Partial unique: at most one
activekey per profile ((issuer_profile_id) where status = 'active'). - Not person-linked → no erasure declaration (keys are issuer infra; provisioning
is captured by
change_audit_entry+ the sign-event log).
credential_sign_event (ADR-0017 guardrail 3)
Section titled “credential_sign_event (ADR-0017 guardrail 3)”Application-level audit of every signature. Carries no PII by construction.
| Column | Notes |
|---|---|
id |
uuid PK |
issuer_profile_id |
FK → issuer_profile, cascade |
signing_key_id |
FK → issuer_signing_key, set null (the key may be cleaned up in a far future; the event survives) |
award_id |
FK → qualification_award, set null — the award is sever and survives erasure as a husk; this reaches it while linked, dangles after |
credential_id |
text — the urn:uuid: emitted (decision 6); stable per award |
payload_sha256 |
text — digest of the signed bytes for tamper-evidence; not the content |
signed_at |
timestamp |
created_at_us |
usColumn |
- No direct person FK → not a person-rooted table; no erasure declaration (the
subject is reachable only through the severable award). Index
(issuer_profile_id, signed_at)and(award_id).
Vault scope + signer role (custom migration credential_signing_role)
Section titled “Vault scope + signer role (custom migration credential_signing_role)”A hand-written migration (the external_package_storage_bucket pattern — SQL the
Drizzle schema cannot express):
-- Dedicated signing role: the ONLY reader of decrypted issuer secrets.CREATE ROLE credential_signer NOLOGIN; -- operator grants LOGIN + passwordGRANT USAGE ON SCHEMA public TO credential_signer;
-- Name-scoped decrypted view: issuer secrets only, never the whole vault.CREATE VIEW public.issuer_signing_secret WITH (security_barrier = true) AS SELECT name, decrypted_secret FROM vault.decrypted_secrets WHERE name LIKE 'issuer_signing_key:%';REVOKE ALL ON public.issuer_signing_secret FROM PUBLIC, anon, authenticated, service_role;GRANT SELECT ON public.issuer_signing_secret TO credential_signer;
-- The signer reads keys + the public table, writes the audit log; nothing else.GRANT SELECT ON public.issuer_signing_key TO credential_signer;GRANT SELECT, INSERT ON public.credential_sign_event TO credential_signer;vault.decrypted_secretsis owner/superuser-only by default, so no general role can decrypt — the scoped view is the single, audited read path.- Operator steps (never the agent, like the DB reset and
app.identity_issuersettings):ALTER ROLE credential_signer LOGIN PASSWORD '…'and wireSIGNING_DATABASE_URLfor the deploy. Recorded in a new runbookdocs/runbooks/credential-issuer-keys.md. - Provisioning writes (
vault.create_secret(...)) run on the normal admin connection —create_secretencrypts and never exposes plaintext, so the guardrail (locked decryption) holds.
KeySigner seam (apps/api/src/features/credential/key-signer.ts)
Section titled “KeySigner seam (apps/api/src/features/credential/key-signer.ts)”interface KeySigner { signCredentialJws(input: { issuerProfileId: string; credentialId: string; // for the audit row awardId: string; // for the audit row (husk reference) payload: Record<string, unknown>; // the assembled, contract-valid credential }): Promise<{ jws: string; kid: string }>;}VaultKeySigner (v1):
- Select the
activeissuer_signing_keyfor the profile (kid,vault_secret_name). - Over the signer connection (
SIGNING_DATABASE_URL, rolecredential_signer):SELECT decrypted_secret FROM issuer_signing_secret WHERE name = $vault_secret_name→ the private JWK. importJWK(privateJwk, "EdDSA")(jose, already a dependency).new CompactSign(payloadBytes).setProtectedHeader({ alg: "EdDSA", kid, typ: "vc+jwt", cty: "vc-ld+json" }).sign(key).INSERT credential_sign_event(profile, key, award, credential id, digest).- Return
{ jws, kid }. The private key reference is dropped immediately; never logged, never returned.
The export service depends on the KeySigner interface, never the impl — the
custody swap (OpenBao transit) is a one-file change behind the seam.
JWKS + issuer surface (apps/api/src/features/credential/jwks-routes.ts)
Section titled “JWKS + issuer surface (apps/api/src/features/credential/jwks-routes.ts)”Public, unauthenticated, mirroring /lti/jwks:
GET /credentials/issuers/:profileId/jwks.json→{ keys: [public_jwk, …] }for all keys of the profile (active + retired) — built fromissuer_signing_key.public_jwk, no vault access. Shortcache-control.GET /credentials/issuers/:profileId→ the OB 3.0 issuerProfile({ "@context": …, id: issuer_uri, type: ["Profile"], name, url }), so the credential’sissuer.idURI resolves. Cheap; aids verifier resolution.
issuer_uri = {CREDENTIAL_ISSUER_BASE_URL}/credentials/issuers/{profileId};
the JWKS URL is {issuer_uri}/jwks.json. No first-party verify page in v1
(brief): verification is JWKS-first, the proof family JWKS natively serves
(ADR-0016).
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/achievements/:awardId/credential — the authenticated
learner mints a fresh signed credential for one of their own granted awards.
Nothing is persisted but the sign-event row.
- Authz (learner-self). Load the award; require
award.person_id === principal.personIdandaward.state === 'granted'(else 404/403 — never distinguish “not yours” from “not found” to a learner). Resolve the issuer profile fromaward.issuer_organization_id(else the platform default). - Subject identity. Reuse-or-mint the pairwise
identity_binding(provider = 'credential-subject',issuer = issuer_uri) for this (person, issuer); read/generate the salt inbinding_meta. Build theIdentityObject(hashed: true,identityType: "emailAddress",identityHash = "sha256$" + hex(sha256(email + salt))— the Open Badges convention) from a person emailperson_contact_point; omit it when the learner has no email (the pairwiseidalone is a valid subject). - Assemble (core → OB,
wire.ts). The one-vocabulary boundary:Achievement←qualification_definition:id= achievement URI,type: "Achievement",name= title,description+criteria.narrativefromcriteria_payload,humanCode=code,alignmentprojected fromcompetency_alignment(decision 8 / slice 4; omitted when there are no alignment rows).AchievementSubject:id= pairwise URI,achievement,identifier=[IdentityObject].AchievementCredential: deterministicurn:uuidid,type["VerifiableCredential", "OpenBadgeCredential", "AchievementCredential"],issuer= the issuerProfile/URI,validFrom+awardedDate=granted_at. No embeddedproof— the enveloping JWS is the proof (VC-JOSE, ADR-0016).
- Totality guard. Validate the candidate against
AchievementCredentialSchema(@conform-ed/contracts) before signing — fail closed (mirrors the CASE export’sparseCfPackage). - Sign.
KeySigner.signCredentialJws(...)→ compact JWS; the audit row is written inside the signer. - Return the compact JWS (
application/vc+jwt-style). Nothing signed is stored.
Conformance wiring (DONE 2026-06-13)
Section titled “Conformance wiring (DONE 2026-06-13)”The OB 3.0 conformance lane runs against the export surface, not as endgame
QA — a runner alongside run-lrs-conformance-conform-ed.ts,
run-cmi5-conform-ed.ts, and tools/testing/lti13-conformance/:
tools/scripts/run-ob-conform-ed.ts (delegating to the integration compose
harness) + tests/conformance/ob-credential.conformance.test.ts +
docs/runbooks/ob-conformance-suite.md, run by bun run test:ob:conformance.
It boots the stack, drives the real HTTP routes (provision issuer + key, grant a
fixture award, POST the export endpoint, GET the JWKS + Profile document),
then runs the battery: VC-JOSE shape, kid resolves in the JWKS and the
signature verifies, and — the authoritative assertion — the decoded credential
validates against the vendored official 1EdTech ob_v3p0_achievementcredential
JSON Schema (draft 2019-09, via ajv; the upstream artifact the
@conform-ed/contracts zod schemas are derived from), plus normative structural
checks. Full green is the lane’s validated bar (standards.md).
Sequencing
Section titled “Sequencing”- Issuer + signing infrastructure — DONE (2026-06-13). Schema,
the signing-role migration, the KeySigner seam +
VaultKeySigner, issuer provisioning + key generation/rotation, and the JWKS + issuer-Profile endpoints. Proven end-to-end (throwaway-podman integration) by signing a fixture payload and verifying it against the served JWKS. - Export endpoint — DONE (2026-06-13).
wire.tsOB assembly, pairwise subject-binding minting, learner-self authz, deterministic credential id, contract totality guard, sign-on-export. Unit (assembly/identity-object, route auth/translation) + integration (grant → export → external verify; another learner’s award → 404). - OB conformance lane — DONE (2026-06-13). The runner + conformance test + runbook above; validates the live export against the official 1EdTech schema.
- Alignment projection — DONE (2026-06-13). The
competency_alignment_target_typeenum gainsqualification_definition(greenfield baseline regenerated); the export reads alignments for the definition (lateral join to the durable node’s revision for statement text) and projectsAchievement.alignment(targetType: CFItem) to{CASE_PROVIDER_BASE_URL}/CFItems/{nodeId}. Additive — credentials with no alignment rows omit the field. Covered by a wire unit test and an aligned- credential conformance test (validates against the official schema). - CLR 2.0 assembler — built (slice 5); see
clr-export-v1.md. Built after single-credential signing was proven (brief). Adds no schema: assembles the learner’s granted-award achievements into one platform-transcriptClrCredential, signed on the existing issuer/KeySigner/JWKS machinery, with its own conformance lane (bun run test:clr:conformance).
First implementation PR (slice 1)
Section titled “First implementation PR (slice 1)”Scope is exactly the brief’s named first PR: issuer profiles + Vault provisioning + KeySigner + JWKS — signing and discovery, no credential assembly.
- Schema (
credential-issuance.ts):issuer_profile,issuer_signing_key,credential_sign_event+ enums; register inschema/index.ts,schema-config.test.ts,schema-smoke.test.ts; add theissuer_profilesever/actor declaration toerasure-registry.ts(+ its registry test). - Migrations: regenerate the baseline (greenfield mode, the runbook) so the
three tables land in
initial_baseline; add the customcredential_signing_rolemigration (role + name-scoped vault view + grants). DB reset/apply is the operator’s step — the agent stops at “schema + migrations ready”. - KeySigner seam (
key-signer.ts): the interface +VaultKeySigner+ the lazycredential_signersigning-connection helper (SIGNING_DATABASE_URL). - Issuer service (
issuer-service.ts, platform-admin authz viahasPlatformAdminGrant):provisionIssuerProfile(create the row; idempotent on org / platform-default),generateIssuerKey/rotateIssuerKey(Ed25519 keygen via jose/WebCrypto,vault.create_secretthe private JWK, writepublic_jwk+ flip the prioractivekey toretired). Error-descriptor +httpStatuspattern (the CASE export service). - Routes:
POST /workflow/credentials/issuers,POST /workflow/credentials/issuers/:profileId/keys(rotate),GET /credentials/issuers/:profileId/jwks.json,GET /credentials/issuers/:profileId; path-aliases +app.tsregistration. - Runbook
credential-issuer-keys.md: the operator role-password +SIGNING_DATABASE_URLsteps and rotation procedure. - Tests: unit (keygen shape, JWKS projection, rotation flips status); a
podman-compose integration test that provisions a profile + key, fetches the
JWKS, signs a fixture payload through
VaultKeySigner, and verifies the JWS against the served key with jose — closing the custody → discovery loop before any credential exists.
Deferred (recorded, not blocking)
Section titled “Deferred (recorded, not blocking)”credentialStatus/ revocation — status-list storage; the stable credential id (decision 6) is the reserved slot. Its arrival is an ADR-0017 upgrade trigger (custody → OpenBao transit).- Data Integrity proofs (
eddsa-rdfc-2022) — additive second emitter, no re-issuance; no RDF canonicalization in v1 (ADR-0016). did:webissuer alias — additive to the HTTPS issuer URI if wallet ecosystems demand it (ADR-0015).- Learner-held keys — ADR-0013 custody ladder rungs 2–3; v1 subjects are keyless pairwise ids.
- First-party verify page; admin/on-behalf export — JWKS-only, learner-self in v1.
- OpenBao transit custody — the named upgrade behind the KeySigner seam (ADR-0017 guardrail 4).