CASE / competency lane — design (v1)
Status: ordered-work item 1 (core schema rework) implemented (2026-06-12);
item 2 (consumer + neutral vocabulary) implemented (2026-06-13, Anton’s three
shape decisions below; file transport, REST lane deferred); item 3
(provider/export projection + round-trip totality gate) implemented
(2026-06-13). The OneRoster deferred campaign that gated this lane is complete
and verified (2026-06-13)
Decisions this builds on: ADR-0018 (CASE is a boundary adapter; competency
nodes become durable referents), ADR-0012 (projection pattern, consumer
architecture, round-trip gate), ADR-0005/0007 (immutable pooling units,
research reproducibility), ADR-0013 (bindings, never identities), ADR-0014
(erasure classes)
Design groundwork: case-competency-v1-brief.md,
competency-framework-model.md (updated to this model)
Boundary contracts: @conform-ed/contracts case/v1_1 (CFPackage,
CFDocument, CFItem, CFAssociation + groupings, CFRubric, REST binding)
Decisions (settled 2026-06-12)
Section titled “Decisions (settled 2026-06-12)”- The durable node carries identity only.
competency_nodeis reduced to framework FK + stablenode_key(+ audit). Every descriptive property — statement text and the CASE-absorbed field set — is per-edition state and lives oncompetency_node_revision. Rationale: any field on the node would be silently mutable across editions, violating the immutable-edition invariant; the durable row is a pure referent for longitudinal history. - Reference rules (the brief’s central open decision):
person_competency_state→ durable node only. One current state per person + node, spanning editions — the longitudinal fix ADR-0018 exists for. Contributing editions live in evidence rows andderivation_meta.person_competency_evidence→ durable node + capture-time edition (both NOT NULL). Psychometric pooling and research reproducibility (ADR-0005/0007) need the edition whose statement the evidence was captured against; mastery history threads through the node.competency_alignment→ durable node, edition optional (set null). Alignments survive new editions; the optional edition records which statement text the alignment was authored against (reproducibility context, not identity).package_evidence_link.competency_node_id→ durable node (re-pointed).
competency_associationreplaces the version-scoped edge table. Theparent|prerequisite|relatedenum is replaced by the full CASE 1.1 vocabulary, kebab-cased to neutral terms (1:1 —isTranslationOfis CASE 1.1 native, not an extension):is-child-of,is-peer-of,is-part-of,exact-match-of,precedes,is-related-to,replaced-by,exemplar,has-skill-level,is-translation-of. Endpoints are durable nodes and may cross frameworks — crosswalks and framework translations are core features, not boundary exotica.- Asserting-edition semantics on associations.
competency_framework_version_idis nullable: set, it scopes an edition’s own structure (e.g. itsis-child-oftree — each edition re-asserts its hierarchy); NULL marks a platform-level assertion independent of any single edition (e.g. a locally authored crosswalk). Uniqueness is enforced per case by partial unique indexes on(version, from, to, type)/(from, to, type). - Node identity-change rule. A revision restates the same competency
for a new edition. When meaning genuinely changes identity, the edition
mints a new durable node linked
replaced-by— never a revised statement on the old node. This is the writer-side discipline that keeps longitudinal history honest. - Revision/framework coherence is writer-asserted (data-invariants #17): a revision’s framework version must belong to its node’s framework. Not FK-enforceable (the two FKs land on different parents); seeds, the future consumer, and authoring writers must uphold it.
- Erasure classes re-declared (ADR-0014):
person_competency_evidenceissever(person_idset null — evidence survives for framework-level aggregates);person_competency_statecascades (derived; meaningless without the person). Framework/node/revision/association tables are not person-linked and carry no declaration.
Schema (settled shape, packages/db/src/schema/competency-framework.ts)
Section titled “Schema (settled shape, packages/db/src/schema/competency-framework.ts)”competency_node — durable referent
Section titled “competency_node — durable referent”| Column | Notes |
|---|---|
id |
uuid PK — the identity longitudinal history attaches to |
competency_framework_id |
FK → framework, cascade |
node_key |
stable human-scoped key; unique per framework |
| audit columns | auditColumnsUs() |
External identifiers (CASE GUIDs/URIs) never live here — they will be held in boundary mapping tables bound to this id (ADR-0013 pattern), a consumer-slice deliverable.
competency_node_revision — per-edition statement + metadata
Section titled “competency_node_revision — per-edition statement + metadata”| Column | Notes |
|---|---|
competency_node_id |
FK → node, cascade |
competency_framework_version_id |
FK → edition, cascade; unique (node, edition) — at most one revision per node per edition |
title / description / level_hint |
the pre-reshape statement fields, now edition-scoped |
coding_scheme, node_type, education_level, language, abbreviated_statement |
the neutral descriptive set absorbed from CASE (ADR-0018). node_type is text, not an enum — CFItemType is an open vocabulary — and deliberately not “item type”: item collides with QTI Item (ADR-0001 vocabulary); education_level is jsonb string[] |
revision_meta |
jsonb — descriptive remainder; never load-bearing |
competency_association — typed, directed, cross-framework
Section titled “competency_association — typed, directed, cross-framework”| Column | Notes |
|---|---|
from_node_id / to_node_id |
FK → durable nodes, cascade; check from <> to; endpoints may belong to different frameworks |
association_type |
enum, the kebab-cased CASE 1.1 vocabulary (decision 3) |
competency_framework_version_id |
nullable FK, cascade — asserting edition (decision 4) |
sequence_number |
sibling ordering for display |
Re-pointed person/alignment tables
Section titled “Re-pointed person/alignment tables”competency_alignment: node (cascade) + optional authored-against edition (set null), time-aware effective window unchanged.person_competency_evidence: node + capture-time edition, both required;person_idnullable forsevererasure.person_competency_state: unique (person, node); LWW columns (client-sync candidate later, like the rest of the derived-state family).
Migration impact
Section titled “Migration impact”- ADR-0010-style in-place reshape at historic-minimum cost: zero application
code used these tables (seeds + schema tests only, plus the
package_evidence_linkFK). - Migrations regenerated as a clean full set per
docs/runbooks/regenerate-migrations.md(greenfield mode) — the baseline absorbs the reshape; no rename/alter churn. Schema ↔ migration drift verified clean (drizzle-kit generateis a no-op). - Seeds (
run-seed.ts) and schema config/smoke tests updated to the node/revision/association shape.
Consumer-session decisions (Anton, 2026-06-13)
Section titled “Consumer-session decisions (Anton, 2026-06-13)”- Crosswalk provenance lives in mapping tables, not core columns. An
imported association gets a
case_association_mappingrow (source + CASE GUID → association id) — the mapping IS the import provenance, exactly like OneRoster org/class mappings. Locally authored associations have no mapping row; their authority is the audit trail. No origin enum oncompetency_association. - CFRubric is boundary-only in v1 (the demographics pattern): imported
rubrics are retained in full in
case_rubric_snapshot(criteria + levels in the payload), never projected into core. The structured core rubric reshape (reconcilingassessment_definition.rubric_schema) is its own future slice on product need — recorded, not drifting. - Neutral vocabulary tables land NOW (Anton overrode the free-strings
recommendation — the score-scale precedent): core
education_levelandsubjectreference tables serve both boundaries. The CASE consumer populates them; OneRoster course/classgrades/subjectsre-point at them in the same slice.competency_node_revision.education_level(jsonb string array) is dropped in favor of junction tables.
Consumer design (v1, settled 2026-06-13)
Section titled “Consumer design (v1, settled 2026-06-13)”Core vocabulary additions
Section titled “Core vocabulary additions”education_level— platform-scoped neutral level registry:code(unique; CEDS-style tokens like09,KGarrive from both boundaries),label(defaults to the code on import until curated), optionalsequencefor ordering,level_metajsonb, audit.subject— neutral subject registry:title(unique), optionalcode(partial-unique; OneRostersubjectCodes/ CASEhierarchyCode),subject_metajsonb, audit.- Junctions (unique pairs, cascade both sides):
competency_node_revision_education_level,competency_node_revision_subject(CASE item-leveleducationLevel/subject);learning_container_education_level,learning_container_subject,offering_education_level,offering_subject(OneRoster course/classgrades+subjects). Document-level CASE subjects stay in the document snapshot (a framework-level subject link is deferred until something reads it).
Boundary schema (prefix case_, mirroring oneroster_)
Section titled “Boundary schema (prefix case_, mirroring oneroster_)”case_source— publisher registration: display name, issuer (unique), transport (filefirst;restcolumns nullable until a REST lane lands), status,created_by_person_id(erasure: sever actor).case_import_batch— one row per CFPackage import: source FK, manifest meta (document GUID, title, version, lastChangeDateTime, content hash), entity counts, outcome, error payload, timestamps (sever actor).- Snapshots (one per batch + identifier, validated wire payload jsonb;
CASE has no
tobedeleted— boundary mutability islastChangeDateTime+ status dates, retained in payloads):case_document_snapshot,case_item_snapshot(+ resolution status + resolved node id),case_association_snapshot(+ resolution status + resolved association id),case_rubric_snapshot(boundary-only home per decision 9),case_definition_snapshot(kind = concept | subject | license | itemType | associationGrouping). - Mappings (unique (source, CASE GUID); core FK cascade):
case_document_mapping→competency_framework(+latest_version_id,latest_content_hash— the re-import edition decision),case_item_mapping→ durablecompetency_node(THE node-continuity anchor, ADR-0018),case_association_mapping→competency_association(provenance per decision 8),case_subject_mapping→subject(CFSubject GUIDs; education levels are bare strings in CASE — no mapping).
Reconcile pipeline (one transaction; batch row outside it)
Section titled “Reconcile pipeline (one transaction; batch row outside it)”- Document → framework via document mapping (create:
code= document GUID, title = document title; update: title refresh). Edition decision: package content hash equal tolatest_content_hash→ reuse the mapped edition (idempotent re-import; upserts no-op); different → mint a newcompetency_framework_version(label = CFDocumentversion??lastChangeDateTime, de-conflicted with a batch suffix;publishedimmediately — imported editions are adopted facts, and immutability is ours, not the wire’s). - Definitions: CFSubjects upsert
subjectvocabulary rows (+ subject mapping); concepts/licenses/itemTypes/groupings snapshot-only. - Items → durable nodes via item mapping (existing GUID mapping = node
continuity across editions; new GUID = new node,
node_key= GUID), then one revision per (node, edition):title←fullStatement(the only universally-required CASE field — totality),coding_scheme←humanCodingScheme,node_type←CFItemType,language,abbreviated_statement; education levels and item subjects upsert vocabulary + junction rows; everything else (alternativeLabel, listEnumeration, conceptKeywords, notes, status dates, lastChangeDateTime) goes torevision_meta— wire mutability metadata never becomes core mutation. - Associations: endpoints resolve through item mappings — including
GUIDs mapped by earlier imports from the same source, which is how
cross-framework crosswalks land. Both endpoints resolved → core
association asserted by this edition (origin →
from, destination →to, type kebab-cased 1:1) + mapping row; unresolved endpoint orext:association type → snapshotheld/skippedwith an issue, no core row (the held-user pattern; a later import of the missing framework resolves on re-import). - Rubrics → snapshots only (decision 9).
OneRoster vocabulary re-point (same slice, decision 10)
Section titled “OneRoster vocabulary re-point (same slice, decision 10)”Consumer reconcile upserts education_level (from course/class grades)
and subject (from subjects + subjectCodes) and syncs the four
junction tables as replace-set per asserted entity (absence on an asserted
entity clears — the org-parent delta semantics); the provider projection
and CSV export emit grades/subjects/subjectCodes from the junctions;
the round-trip gate fixture gains grades + subjects on a course and class.
Transport and routes (v1)
Section titled “Transport and routes (v1)”File-first, mirroring the CSV lane: POST /workflow/case/sources (platform
admin) registers a publisher; POST /workflow/case/imports takes a
CFPackage JSON (base64) + source id, zod-validates against
@conform-ed/contracts case/v1_1 at the boundary, and runs the
reconcile. REST (CASE service binding) reuses the same pipeline later.
Provider/export (item 3 — implemented 2026-06-13)
Section titled “Provider/export (item 3 — implemented 2026-06-13)”Emergent-authored frameworks projected as CASE CFPackages, gated by a round-trip totality CI gate (ADR-0012 pattern). Scope is the export projection only — the brief’s cross-lane note (“the full provider API is not” a v1 output) keeps this lighter than the OneRoster provider: no separate OAuth/IMS surface, just an admin-triggered export. A CFPackage is exactly one CFDocument, so the export unit is one framework edition.
Projection (apps/api/src/features/case/export-service.ts)
Section titled “Projection (apps/api/src/features/case/export-service.ts)”exportCaseFramework({ frameworkId, versionId?, principal }) (platform
admin) selects the edition (explicit versionId, else the latest published,
else the latest created) and projects core → CFPackage — the exact inverse of
the consumer’s wire map:
- CFDocument ← framework + edition:
identifier= edition id (a core uuid; stable, durable-addressed URIs are the credential-lane output),title= framework title,version=version_label,creator= the framework org’s name (Emergentwhen platform-scoped),lastChangeDateTime= edition audit time. - CFItems ← one per node with a revision in the edition:
identifier= node id,fullStatement←title,humanCodingScheme←coding_scheme,CFItemType←node_type,language,abbreviatedStatement,educationLevel← junction level codes,subject← junction subject titles. - CFAssociations ← associations asserted by the edition (camelCased 1:1),
origin/destination as node URIs. Root synthesis: each item that is not
the
from-endpoint of an in-editionis-child-ofgets a synthesizedisChildOf→ CFDocument, reconstructing the CASE tree root the consumer drops on import (the inverse of the consumer’s “root isChildOf→document is skipped”). Re-import skips them again — idempotent. - CFDefinitions.CFSubjects ← subjects carrying a
code(CFSubjecthierarchyCodeis required; code-less subjects round-trip throughitem.subjecttitles alone). - The assembled package is validated against the contract schema before return — a build-time totality guard.
URIs use CASE_PROVIDER_BASE_URL (fallback https://localhost/ims/case/v1p1),
mirroring ONEROSTER_PROVIDER_BASE_URL. Route:
GET /workflow/case/frameworks/:frameworkId/export (+ optional ?versionId=).
Round-trip totality gate (tests/integration/case-roundtrip.integration.test.ts)
Section titled “Round-trip totality gate (tests/integration/case-roundtrip.integration.test.ts)”The ADR-0012 CI-gate pattern: a CFPackage fixture (a small tree + vocabulary +
a coded subject) is imported into source A → framework F_A, F_A is
exported to a CFPackage, that package is re-imported into a clean
source B → F_B, and the two cores are compared structurally (keyed on the
stable coding_scheme, since identifiers are freshly minted each import):
node revisions (statement, type, language, abbreviated statement, education
levels, subjects) and node-to-node associations (type + endpoint coding
schemes) must match. This proves the provider is the exact inverse of the
consumer over the core-representable content.
Cross-lane touchpoint
Section titled “Cross-lane touchpoint”The credential lane (credential-export-v1-brief.md) consumes durable-node
URIs for OB 3.0 Achievement.alignment. Stable, durable-node-addressed URIs
are a v1 output of this lane; the full provider API is not.