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).
Intent
Section titled “Intent”- Give the standards track a durable rule for deciding, per module, whether code
belongs in the shared
conform-ed(MIT) toolkit or in theemergentapplication. - 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.
Non-goals
Section titled “Non-goals”- Not a migration plan or a campaign. Nothing moves until separately requested.
- Not a re-litigation of the
@conform-ed/contractsdependency 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.
Terminology
Section titled “Terminology”- Contract layer — the Zod schemas + derived types for a standard’s wire format.
Lives in
@conform-ed/contracts. Already MIT and already consumed byemergent. - 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.tsmodule 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 awire.tsis artifact-and-conformance layer; everything on the other side is bespoke integration. - Artifact-shaped vs integration-shaped standard — see The externalisability spectrum.
The headline finding
Section titled “The headline finding”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.
The three-layer split (with evidence)
Section titled “The three-layer split (with evidence)”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. TheVaultKeySignerimplementation insidekey-signer.ts(Supabase Vault custody over thecredential_signerrole /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 incase/wire.tsis 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 externalisability spectrum
Section titled “The externalisability spectrum”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)”- Reframe the conformance lanes as conform-ed suites. Highest charter-fit; compounds
directly with the QTI/cmi5/LTI13 lanes already there. The
run-*-conformancescripts 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. - Extract VC-JOSE signing (
signing-keys.ts+ theKeySignerinterface) into a small@conform-ed/credentials(or…/vc-jose) package. Cleanest pure module, universal VC need, pairs with the existingvc-data-model+open-badges+clrcontracts. Low effort, strong win. Publish the interface, leaveVaultKeySignerinemergent. - OB/CLR builders (
wire.ts/export-shared.tsassemble / 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 andexport-shared.tsalready factors the common OB/CLR assembly, so the builder shape is settled and OB + CLR can move together. - CASE / OneRoster generic bits (
computeCasePackageContentHash+canonicalize;writeCsvDocument+csvField+ column maps). Small, opportunistic — move only when already in those files for another reason.
Invariants and constraints
Section titled “Invariants and constraints”- 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.tsboundary is the extraction line. Do not extract code from the bespoke side of awire.ts. If something on the bespoke side looks generic, the fix is to refine thewire.tsseam, not to lift glue. - Publish interfaces, not custody.
KeySigner(interface) is shareable;VaultKeySigner(Supabase Vault +credential_signerrole) is not. Secrets and custody never leaveemergent(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.
Honest caveat
Section titled “Honest caveat”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.
Implementation anchors
Section titled “Implementation anchors”- 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(theKeySignerinterface only). - Conformance lanes:
bun run test:ob:conformanceandbun run test:clr:conformance,tests/conformance/,tools/scripts/. - Bespoke (do not move): the
*-service.ts/provider-*.ts/reconcile-service.tsfiles in each lane,packages/db/src/schema/*, and theVaultKeySignerimpl. - Target home:
conform-edworkspace (/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.
Open decisions and confirmation needed
Section titled “Open decisions and confirmation needed”- 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 theopen-badgescontracts. Not decided. - Timing of the OB/CLR builder extraction — now unblocked: CLR slice 5 has landed
(and
export-shared.tsfactors the common assembly), so OB and CLR builders can move as a pair rather than OB-then-rework.