Skip to content

Externalisation boundary — what ships to conform-ed (OSS) vs stays in emergent

Status: analysis (advisory; no code moved). Captured 2026-06-13 from a review of the OneRoster, Open Badges 3.0, CASE, and CLR 2.0 lanes against the existing conform-ed workspace (MIT). This document records where the line falls between reusable standards machinery and Emergent-specific glue, so the standards track has a written, repeatable rule rather than a per-PR judgement call.

This is a thinking-clarity artifact for the credential/standards track. It does not authorise any extraction. Each move named here is a separate, explicitly-requested unit of work (same cadence as the credential-lane slices).

  • Give the standards track a durable rule for deciding, per module, whether code belongs in the shared conform-ed (MIT) toolkit or in the emergent application.
  • Record the evidence (file-level, dependency-coupling) behind that rule so it does not have to be re-derived.
  • Rank the concrete extraction candidates by reuse × effort × charter-fit, and be explicit about what is not worth externalising.
  • Not a migration plan or a campaign. Nothing moves until separately requested.
  • Not a re-litigation of the @conform-ed/contracts dependency direction — that is settled and working (see ADR-0009).
  • Does not cover QTI (already substantially in conform-ed) beyond using it as the model for what “fully externalised” looks like.
  • Contract layer — the Zod schemas + derived types for a standard’s wire format. Lives in @conform-ed/contracts. Already MIT and already consumed by emergent.
  • Artifact-and-conformance layer — pure, dependency-light code that produces, signs/verifies, hashes, serialises, or conformance-checks a standard’s artifact, given neutral inputs. Depends only on the contract layer + standard libraries (jose, ajv, fflate, node:crypto). No DB, no domain model, no authz.
  • Bespoke integration layer — code that resolves Emergent rows, enforces Emergent authz, holds custody decisions, drives stateful sync, and exposes Emergent routes. Coupled to our Drizzle schema, our identity/erasure model, and our operational choices.
  • Wire boundary — the wire.ts module each lane already ships, self-described as “the one-vocabulary boundary” where standard terms meet the neutral core. Empirically this is the externalisation line: everything on the contract side of a wire.ts is artifact-and-conformance layer; everything on the other side is bespoke integration.
  • Artifact-shaped vs integration-shaped standard — see The externalisability spectrum.

The single biggest reusable asset is already externalised. @conform-ed/contracts (MIT, v0.0.18) already ships schemas + types for OB v3.0, CLR v2.0, CASE v1.1, OneRoster v1.2 (CSV + all three REST bindings), QTI (2.1/2.2/3.0.1), Caliper v1.2, LTI (1.3/AGS/NRPS/Deep-Linking/Proctoring), xAPI (1.0.3/2.0), cmi5, Common Cartridge (1.3/1.4), H5P, the VC data model v2.0, and CAT — and emergent already consumes them across all four credential/standards lanes. The schema/contract layer is done.

conform-ed’s charter (its CONTEXT.md) is explicitly “standards-conformance tooling for education interop,” and it already carries the supporting machinery: a conformance test runner (packages/core), packages/reporting (JUnit/summary writers), and live conformance lanes for QTI, cmi5, and LTI 1.3. conform-ed is shaped to receive conformance suites and artifact builders — that is what it is for.

So the real question is never “which module do we externalise.” It is: which slices of the logic layer have crossed from Emergent-specific into spec-shaped? And the codebase has already drawn that line for us — the wire.ts files.

DB-coupling counts across the non-test files in each feature confirm the layering (grep for @emergent/db / drizzle / sql` / postgres):

Lane Non-test files touching DB/sql
apps/api/src/features/credential 3 / 6
apps/api/src/features/case 2 / 4
apps/api/src/features/oneroster 4 / 12

The uncoupled files are exactly the externalisation candidates, and they cluster around each lane’s wire.ts.

Layer 1 — Contract (already in conform-ed)

Section titled “Layer 1 — Contract (already in conform-ed)”

@conform-ed/contracts/{open-badges/v3_0, clr/v2_0, case/v1_1, oneroster/v1_2, …}. Zod schemas, peer-dep zod >=4.4.0. The validation totality guards in each lane (parseAchievementCredential, parseCfPackage, and parseClrCredential) already call straight into these. Done. Nothing to do.

Layer 2 — Artifact-and-conformance (generic; could move)

Section titled “Layer 2 — Artifact-and-conformance (generic; could move)”

These are spec-shaped, depend only on the contract layer + standard libs, and have zero DB/domain/authz coupling:

Candidate File (LOC) What it is Coupling
VC-JOSE sign / verify / JWKS credential/signing-keys.ts (82) Ed25519 keygen as JWK, compact JWS (VC-JOSE, alg: EdDSA, typ: vc+jwt) sign + verify, buildJwks. Pure jose. none
OB 3.0 assembly + validation credential/wire.ts, export-shared.ts assembleAchievementCredential, deterministicCredentialId (RFC 4122 v5 uuid), buildEmailIdentityObject (sha256$<hex>), parseAchievementCredential (contract guard), toObAlignment. contract only
CLR 2.0 assembly (slice 5, built 2026-06-15) credential/wire.ts, clr-export-service.ts assembleClrCredential, deterministicClrId, parseClrCredential. Same shape as OB; see clr-export-v1.md. contract only
CASE content-hash + canonical-JSON + parse case/wire.ts (101) computeCasePackageContentHash, canonicalize (key-order-independent), parseCfPackage. contract only
OneRoster CSV serialiser + binding column maps oneroster/csv-export-service.ts (top ~150) writeCsvDocument, csvField (RFC 4180 quoting), the v1.2 bulk-file column sets. contract only
KeySigner interface (the seam, not the impl) credential/key-signer.ts (3 types) KeySigner, SignCredentialJwsInput, SignCredentialJwsResult. A clean BYO-custody seam. none
Conformance lanes test:ob:conformance + test:clr:conformance “Validate signed artifact against the official 1EdTech JSON Schema via ajv.” Generic; mirrors conform-ed’s existing QTI/cmi5/LTI lanes. official schema only

Layer 3 — Bespoke integration (stays in emergent)

Section titled “Layer 3 — Bespoke integration (stays in emergent)”

Coupled to our schema, authz, custody, sync, and routes. Do not attempt to externalise:

  • Credential glue: export-service.ts (row resolution, learner-self authz, pairwise subject binding, sign-on-export), issuer-service.ts (issuer Profile + key lifecycle), routes.ts. The VaultKeySigner implementation inside key-signer.ts (Supabase Vault custody over the credential_signer role / SIGNING_DATABASE_URL) — bespoke custody behind the generic interface (ADR-0017).
  • CASE glue: export-service.ts, reconcile-service.ts (719 L — import, edition reuse, snapshot). The core-association-type subset in case/wire.ts is a curated Emergent opinion (1:1 subset of CASE 1.1), exemplary but not neutral.
  • OneRoster glue (the bulk): reconcile-service.ts (1532 L), provider-query-engine.ts (263 L), provider-rest-routes.ts (842 L), provider-write-service.ts (571 L), provider-projection-service.ts (931 L), batch.ts (794 L), gradebook-push-service.ts, rest-sync-service.ts, provider-auth-service.ts, csv-import-service.ts. This is a stateful rostering/sync integration welded to our schema (ADR-0012: projection, not roster-core). ~3000+ lines that are almost entirely our-data-model-shaped.
  • Schema and identity: the Drizzle schema, AuthenticatedPrincipal / learner-self / assertPlatformAdmin, and the identity-binding / erasure / salt model (ADR-0013/0014). Nobody else’s transcript store, issuer custody, or rostering reconcile looks like ours.

The generalisation worth keeping: externalisability tracks how artifact-shaped vs integration-shaped a standard is.

  • OB 3.0 / CLR 2.0 — highly externalisable. A credential is a closed, self-contained signed artifact. Assembly + signing + conformance are all generic; the only bespoke part is where the inputs come from. Almost the entire lane below the route handler is Layer 2.
  • CASE — partial. The package is artifact-ish (hash / canonicalise / parse are generic), but the core-association-type subset is a curated opinion and the import/edition reconcile is bespoke and stateful.
  • OneRoster — mostly bespoke. It is a stateful rostering/sync integration tied to our schema. Only the CSV serialiser + binding column maps lift cleanly; the REST provider/consumer reconcile engine does not.

This ordering is itself the rule of thumb: the more a standard is a self-contained artifact, the more of its lane is reusable; the more it is an ongoing integration, the more is bespoke.

Recommendation (ranked by reuse × effort × charter-fit)

Section titled “Recommendation (ranked by reuse × effort × charter-fit)”
  1. Reframe the conformance lanes as conform-ed suites. Highest charter-fit; compounds directly with the QTI/cmi5/LTI13 lanes already there. The run-*-conformance scripts are generic “artifact vs official schema” checks that arguably do not belong in the app at all. Sequence: OB and CLR now (both lanes built), then CASE/OneRoster round-trip.
  2. Extract VC-JOSE signing (signing-keys.ts + the KeySigner interface) into a small @conform-ed/credentials (or …/vc-jose) package. Cleanest pure module, universal VC need, pairs with the existing vc-data-model + open-badges + clr contracts. Low effort, strong win. Publish the interface, leave VaultKeySigner in emergent.
  3. OB/CLR builders (wire.ts / export-shared.ts assemble / deterministic-id / hashed-identity). Generic once the input shapes (definition, AchievementAlignmentInput) are decoupled from our domain. Medium effort; do it lazily. Slice 5 has landed and export-shared.ts already factors the common OB/CLR assembly, so the builder shape is settled and OB + CLR can move together.
  4. CASE / OneRoster generic bits (computeCasePackageContentHash + canonicalize; writeCsvDocument + csvField + column maps). Small, opportunistic — move only when already in those files for another reason.
  • Upstream-first is already policy. Per the repo guide: bugs in @conform-ed/* are fixed upstream, then the dependency is bumped — never worked around in the app. This document is the forward direction of the same rule: new reusable machinery is born or promoted in conform-ed, not retrofitted later.
  • The wire.ts boundary is the extraction line. Do not extract code from the bespoke side of a wire.ts. If something on the bespoke side looks generic, the fix is to refine the wire.ts seam, not to lift glue.
  • Publish interfaces, not custody. KeySigner (interface) is shareable; VaultKeySigner (Supabase Vault + credential_signer role) is not. Secrets and custody never leave emergent (ADR-0017).
  • Extraction carries a release-coupling cost. Each move adds a changeset → publish → bump-emergent-dep cycle and a stable public API surface to maintain. The bar for any Layer-2 move is therefore: would someone outside Emergent need this in isolation? That bar is clearly met for conformance lanes (charter) and VC-JOSE signing (universal); it is marginal for the builders and the CASE/OneRoster bits — hence “lazily / opportunistically,” not a campaign.
  • No new schema, no DB work is implied by anything here; these are pure-code moves.

The contracts are ~80% of the reusable value and are already out. The Layer-2 builders are small in LOC (signing 82, credential wire 162, case wire 101, CSV ~150). Externalising them is real but modest, and not free. The two moves that genuinely compound with the QTI investment are the conformance lanes (because that is conform-ed’s literal charter) and VC-JOSE signing (because it is the one piece every OB3/CLR/VC implementer needs in isolation). Everything else should migrate naturally the next time those files are touched.

One sharp framing for the track: we do not externalise modules, we externalise the artifact-and-conformance layer. conform-ed owns the schemas (done), signed-artifact production/verification, and the conformance lanes; emergent owns where data comes from and goes (DB, custody, authz, sync). The wire.ts boundary is already that line.

  • Generic candidates: apps/api/src/features/credential/signing-keys.ts, apps/api/src/features/credential/wire.ts, apps/api/src/features/case/wire.ts, apps/api/src/features/oneroster/csv-export-service.ts (serialiser + column maps), apps/api/src/features/credential/key-signer.ts (the KeySigner interface only).
  • Conformance lanes: bun run test:ob:conformance and bun run test:clr:conformance, tests/conformance/, tools/scripts/.
  • Bespoke (do not move): the *-service.ts / provider-*.ts / reconcile-service.ts files in each lane, packages/db/src/schema/*, and the VaultKeySigner impl.
  • Target home: conform-ed workspace (/home/anton/dev/conform-ed) — @conform-ed/contracts (schemas, present), packages/core + packages/reporting (conformance runner, present), and a prospective @conform-ed/credentials (VC-JOSE).
  • Related: ADR-0009 imported standard boundary, ADR-0012 OneRoster projection, ADR-0015 / 0016 / 0017 (issuer/proof/custody), ADR-0018 CASE boundary, standards.md, credential-export-v1.md, clr-export-v1.md.
  • Whether to act on recommendations 1–2 at all, and when. Both are deferred until separately requested. Recommendation 2 (VC-JOSE) is cleanest to do standalone; recommendation 1 (conformance lanes) is most naturally done as each lane’s conformance work is already open.
  • Package naming/granularity if VC-JOSE is extracted: one @conform-ed/credentials (OB + CLR builders + VC-JOSE) vs a thinner @conform-ed/vc-jose (signing only) plus builders alongside the open-badges contracts. Not decided.
  • Timing of the OB/CLR builder extraction — now unblocked: CLR slice 5 has landed (and export-shared.ts factors the common assembly), so OB and CLR builders can move as a pair rather than OB-then-rework.