Skip to content

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.

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_key table mirrors issuer_signing_key (person-scoped, Ed25519/ EdDSA, public JWK + Vault pointer, active/retired rotation), served via a per-learner holder JWKS. The Vault private key is read by a dedicated holder_signer role, sibling to credential_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.
  • held_credential stores foreign/imported credentials only. Our own issued credentials stay sign-on-export, projected from qualification_award and 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) + owner person_id + a subject_match ownership 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. No identity_binding FK in v1; cryptographic DID-control binding is additive later.
  • held_credential is 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 pgxsinkit clientProjection.omitColumns: ["rawPayload", "verificationMeta"] — the signed blob and full 4-axis verdict never replicate; the light display columns (incl. coarse verification_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 stable image_url at import (data-URI → object storage; URL → pass-through), reusing the ADR-0035 /img resolver 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).
  • 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.ts pattern, 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 same apps/api process — no cross-service verification need).
  • The host adds authorization-code + PKCE + a consent screen + per-learner grants, the Badge Connect scopes, a .well-known discovery/manifest endpoint, and the getCredentials aggregation endpoint returning the pre-modelled getOpenBadgeCredentialsResponse / getClrCredentialsResponse + imsx_statusInfo envelopes. 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) and oauth_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 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/ClrCredentialsResponse envelopes, imsx_statusInfo, VerifiablePresentationSchema, and the whole credential-verification verifier. 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.

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):

  1. conform-ed contracts (anchor-first): Badge Connect scopes + discovery + RFC 7591 schemas; repatriate OneRoster scopes.
  2. Holder identity + custody: holder_signing_key, KeySigner-seam generalization, holder JWKS, Vault holder_signer role.
  3. 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.
  4. Verifiable Presentation: assembly + holder-sign + validate → flips VP-1 to done.
  5. 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.
  6. Reconciliation: flip the OB/CLR displayer overlay entries to reflect the already-built verifier (the board shows 9/16 / 7/14 with displayer not-applicable though 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.

  • 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.
  • 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_credential and the person-linked OAuth state are cascade. 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_signer Vault role/scope is provisioned alongside credential_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.