Capability B: emergent as credential holder and Badge Connect host
Status: accepted
emergent today plays the credential issuer role only (ADR-0015/0016/0017): it
mints per-org signed Open Badges 3.0 / CLR 2.0 / VC-DM 2.0 credentials, sign-on-export,
verified externally via published JWKS. Capability B is the deliberately-deferred
opposite side — emergent as a holder/wallet (it holds credentials other issuers
minted, and presents them) and as a Badge Connect / CLR API host (external client
wallets pull a learner’s credentials from emergent). This ADR records the decisions
from the Capability-B scoping review. It does not cover Capability A (verify/display
third-party credentials), which is already built — @conform-ed/credential-verification
plus emergent’s apps/api/src/features/credential-verification (resolvers + verify
endpoint) and the admin-console verifier UI — and which Capability B consumes.
Decision
Section titled “Decision”Scope — launch-scope, full breadth, unbundled
Section titled “Scope — launch-scope, full breadth, unbundled”- Capability B ships at launch, at full breadth (a product call: max standards
coverage now, refine on customer pull — these are industry standards, not novel
invention). It is unbundled into three nested capabilities, not one: hold
third-party credentials, present a signed Verifiable Presentation, and host
the Badge Connect / CLR API.
host ⊇ hold, and all depend on the (built) verifier. - The trivial “a learner views the badges we issued” feature is not Capability B
and needs none of this machinery — it renders from
qualification_award+ the issuer infra directly.
Holder-key custody — platform-custodial per learner
Section titled “Holder-key custody — platform-custodial per learner”- A Verifiable Presentation is signed by the holder (the learner), a different custody model than per-org issuer keys. Holder keys are platform-custodial, per-learner, in Supabase Vault behind the generalized KeySigner seam (which signs as either an issuer profile or a holder person) — the same custody philosophy ADR-0015 chose for issuers (“key custody at the least capable layer” is rejected; a learner is an even less capable custodian than an org).
- A
holder_signing_keytable mirrorsissuer_signing_key(person-scoped, Ed25519/ EdDSA, public JWK + Vault pointer,active/retiredrotation), served via a per-learner holder JWKS. The Vault private key is read by a dedicatedholder_signerrole, sibling tocredential_signer. - The holder key is presentation-only: never the learner’s login identity
(GoTrue/
auth.users) and never a subject identifier baked into imported credentials. - Erasure class
cascade— on erasure the key (and its Vault secret, via the erasure protocol) is deleted. This is cleaner than self-sovereign: VPs are exported signed documents already out of erasure scope (ADR-0014); destroying the key merely stops new presentations. did:key/ self-sovereign holder keys are the named additive upgrade (the same deferred-but-additive move ADR-0016 made for Data Integrity proofs), for learners who later demand true SSI.
Data model
Section titled “Data model”held_credentialstores foreign/imported credentials only. Our own issued credentials stay sign-on-export, projected fromqualification_awardand unioned in at the wallet/host read surface. Ed25519 is deterministic (RFC 8032), so given deterministic assembly (deterministicCredentialId/deterministicClrId) re-deriving our own credential is byte-identical — there is no “stable bytes” reason to persist it. Byte-stable deterministic assembly is now a standing invariant.- Erasure class
cascade(whole-row delete). The signed payload is un-erasable by construction (scrub breaks the signature; sever orphans a learner-only artifact). This is the mirror of, and distinct from, ADR-0014’s “exported credentials out of scope” carve-out: an imported credential is foreign-issued data brought into our DB, now person-linked → in scope,cascade. (ADR-0014 gains a one-line amendment recording this.) For foreign third-party PII embedded in the blob, emergent is a processor of an opaque signed document the learner imported, not the controller of the foreign issuer’s data — third-party-subject erasure is out of scope. - PII lives only in the opaque blob. Denormalize only low/no-PII derived fields
(
credential_id,issuer_uri,credential_type,valid_from/valid_until, verification cache, badge art). Do not lift learner names/emails/evidence into typed columns — one PII home, which cascades. - Subject binding:
subject_id(the raw foreign identifier — the one borderline-PII column, justified for dedup/match and erasure-safe since it cascades) + ownerperson_id+ asubject_matchownership enum (oauth_source/verified_email/verified_did/self_asserted) — a distinct 5th axis beside the verification verdict. Ownership is recorded, not enforced at import and filtered at the host surface. Noidentity_bindingFK in v1; cryptographic DID-control binding is additive later. held_credentialis a read sync table (per-learner replica, server-anchored writes — import/verify/VP-sign all need network or the Vault key, so the device never writes it). The heavy/detailed columns are server-only via pgxsinkitclientProjection.omitColumns: ["rawPayload", "verificationMeta"]— the signed blob and full 4-axis verdict never replicate; the light display columns (incl. coarseverification_status/verified_at,display_meta,image_url) do. This removes any device-size concern (no threshold to tune, no table split). Badge art is resolved to a stableimage_urlat import (data-URI → object storage; URL → pass-through), reusing the ADR-0035/imgresolver rather than a second image path.- Size: generous ~5 MB/credential import cap (server-storage abuse guard only, since the blob never reaches the device).
Badge Connect / CLR API host
Section titled “Badge Connect / CLR API host”- emergent extracts a shared OAuth2 authorization-server core that both the
existing OneRoster provider (client-credentials) and the Badge Connect host
(authorization-code) build on — single token implementation, the pre-launch
reshape-cleanly rule. The core (registered-client + SHA-256 secret hash + stateless
scoped JWT mint/verify + scope-subset) generalizes the working OneRoster
provider-auth-service.tspattern, on top of a vetted OAuth2 library (@jmondi/oauth2-server) for the security-critical authorization-code + PKCE + introspection mechanics. Access tokens stay HS256 stateless (AS and resource server are the sameapps/apiprocess — no cross-service verification need). - The host adds authorization-code + PKCE + a consent screen + per-learner grants,
the Badge Connect scopes, a
.well-knowndiscovery/manifest endpoint, and thegetCredentialsaggregation endpoint returning the pre-modelledgetOpenBadgeCredentialsResponse/getClrCredentialsResponse+imsx_statusInfoenvelopes. It surfaces a holder’s wallet = our-issued ∪ owned, currently-valid imported credentials. - OAuth state:
oauth_authorization(code + PKCE challenge + scopes +person_id+client_id+expires_at) andoauth_refresh_token(hashed,person_id, scopes, revoked) are person-linked →cascade; the client-registration row is not person-linked (no erasure class). Access tokens stay stateless (no table). - Manual/admin client registration ships first (OneRoster’s exact pattern); RFC 7591 dynamic registration (required for full OB-HST conformance) is a trailing sub-slice with abuse guards (rate-limit + optional approval gate).
Import-acceptance policy — issue narrow, accept broad
Section titled “Import-acceptance policy — issue narrow, accept broad”- Our issuance stays VC-JOSE-only (ADR-0016 unchanged). Verification is inbound
and the built verifier handles both enveloping JOSE and embedded Data Integrity
eddsa-rdfc-2022(the RDF-canonicalization lift is done) — so emergent issues one proof family and accepts two. - The signature axis is the hard import gate (an unverifiable signature is not a credential → reject with an actionable error). Window / revocation / schema are stored display state, not gates — an expired or revoked credential is still real and is held with its cached verdict. The host filters to currently-valid owned credentials; the learner’s own wallet shows everything.
- The verdict is a cache: re-verify lazily on detail-view and always fresh-check before any outward presentation/export; a scheduled background sweep is a later refinement.
conform-ed / emergent split
Section titled “conform-ed / emergent split”- conform-ed ships no application runtime — the shared OAuth2 AS, the wallet domain, holder-key custody, RLS, sync, and UI all stay in emergent. The protocol mechanics come from the off-the-shelf library; reinventing OAuth2 in conform-ed is rejected.
- conform-ed owns the spec-defined, reusable contract layer: already present are the
credential schemas, the
getOpenBadge/ClrCredentialsResponseenvelopes,imsx_statusInfo,VerifiablePresentationSchema, and the wholecredential-verificationverifier. To be added: Badge Connect scope-URI constants (OB/CLR), the service-discovery/manifest schema, and RFC 7591 registration schemas; plus repatriating OneRoster’s scope constants from emergent.
Phasing
Section titled “Phasing”Tables land with their slices but are designed as one coherent set (this ADR), landed as one migration batch per the regenerate-migrations runbook (Anton’s DB step):
- conform-ed contracts (anchor-first): Badge Connect scopes + discovery + RFC 7591 schemas; repatriate OneRoster scopes.
- Holder identity + custody:
holder_signing_key, KeySigner-seam generalization, holder JWKS, Vaultholder_signerrole. - Wallet:
held_credential(sync,omitColumns), import-on-verify reusing the built verifier, image extraction via ADR-0035/img, the union read surface, learner-web wallet UI, Badge Connect client + file/URL import. - Verifiable Presentation: assembly + holder-sign + validate → flips VP-1 to done.
- Badge Connect / CLR host: shared OAuth2 AS core (with OneRoster regression coverage),
authz-code + PKCE + consent,
getCredentials, discovery, manual registration → flips OB-HST/CLR-HST; RFC 7591 trailing. - Reconciliation: flip the OB/CLR displayer overlay entries to reflect the
already-built verifier (the board shows
9/16/7/14with displayernot-applicablethough the code landed — built-but-not-claimed).
The verifier being done makes the critical path slices 1→2 (the wallet), with 3 and 4 stacked after.
Considered and rejected
Section titled “Considered and rejected”- Defer Capability B post-launch. The recommended default for a language-learning MVP, overridden by the product call to maximize standards breadth before launch.
did:key/ self-sovereign holder keys (now). Rejected for the same reason ADR-0015 rejected did:key issuers (no rotation; compromise = identity loss), worsened by emergent’s multi-device sync rail: a device-held key means lost-device = lost-identity, or syncing a private key through the rail. Reserved as an additive upgrade.- Unified credential store (persist our own signed artifacts alongside imported). Departs from sign-on-export and creates a second PII-freeze surface for no gain, given byte-stable deterministic re-derivation.
- A per-row sync size threshold / table split for the heavy blob. Superseded by
pgxsinkit
omitColumns— the blob is simply a server-only column. - A parallel Badge-Connect-specific AS (leave OneRoster untouched). Rejected for two divergent AS implementations; the pre-launch reshape-cleanly rule favors the shared core.
- Modelling the OAuth2 AS in conform-ed. conform-ed ships no runtime and OAuth2 is a solved IETF wheel, not a conformance target.
Consequences
Section titled “Consequences”- New tables (
held_credential,holder_signing_key,oauth_authorization,oauth_refresh_token, the generalized client-registration table) each declare their ADR-0014 erasure class (CI-enforced by the schema-smoke test);held_credentialand the person-linked OAuth state arecascade. ADR-0014’s model gains the one-line “imported held credentials =cascade” amendment. - The KeySigner seam generalizes from issuer-only to “signing principal” (issuer profile
or holder person); a
holder_signerVault role/scope is provisioned alongsidecredential_signer. - conform-ed contract additions land before the emergent host slice consumes them (anchor-first); the OneRoster AS extraction carries regression coverage.
- Overlay flips on completion: VP-1, OB-HST-1/2, CLR-HST-1/2 to done, plus the separate displayer reconciliation (OB-DSP-*, CLR-DSP-*).
- The migration batch and any DB reset/apply are Anton’s operator steps.
- Held-credential re-verification (revocation changes over time) is a standing operational concern: lazy on view, mandatory-fresh before outward presentation.