Skip to content

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)

  1. The durable node carries identity only. competency_node is reduced to framework FK + stable node_key (+ audit). Every descriptive property — statement text and the CASE-absorbed field set — is per-edition state and lives on competency_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.
  2. Reference rules (the brief’s central open decision):
    • person_competency_statedurable node only. One current state per person + node, spanning editions — the longitudinal fix ADR-0018 exists for. Contributing editions live in evidence rows and derivation_meta.
    • person_competency_evidencedurable 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_alignmentdurable 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).
  3. competency_association replaces the version-scoped edge table. The parent|prerequisite|related enum is replaced by the full CASE 1.1 vocabulary, kebab-cased to neutral terms (1:1 — isTranslationOf is 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.
  4. Asserting-edition semantics on associations. competency_framework_version_id is nullable: set, it scopes an edition’s own structure (e.g. its is-child-of tree — 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).
  5. 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.
  6. 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.
  7. Erasure classes re-declared (ADR-0014): person_competency_evidence is sever (person_id set null — evidence survives for framework-level aggregates); person_competency_state cascades (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)”
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
  • 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_id nullable for sever erasure.
  • person_competency_state: unique (person, node); LWW columns (client-sync candidate later, like the rest of the derived-state family).
  • ADR-0010-style in-place reshape at historic-minimum cost: zero application code used these tables (seeds + schema tests only, plus the package_evidence_link FK).
  • 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 generate is 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)”
  1. Crosswalk provenance lives in mapping tables, not core columns. An imported association gets a case_association_mapping row (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 on competency_association.
  2. 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 (reconciling assessment_definition.rubric_schema) is its own future slice on product need — recorded, not drifting.
  3. Neutral vocabulary tables land NOW (Anton overrode the free-strings recommendation — the score-scale precedent): core education_level and subject reference tables serve both boundaries. The CASE consumer populates them; OneRoster course/class grades/subjects re-point at them in the same slice. competency_node_revision.education_level (jsonb string array) is dropped in favor of junction tables.
  • education_level — platform-scoped neutral level registry: code (unique; CEDS-style tokens like 09, KG arrive from both boundaries), label (defaults to the code on import until curated), optional sequence for ordering, level_meta jsonb, audit.
  • subject — neutral subject registry: title (unique), optional code (partial-unique; OneRoster subjectCodes / CASE hierarchyCode), subject_meta jsonb, audit.
  • Junctions (unique pairs, cascade both sides): competency_node_revision_education_level, competency_node_revision_subject (CASE item-level educationLevel / subject); learning_container_education_level, learning_container_subject, offering_education_level, offering_subject (OneRoster course/class grades + 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 (file first; rest columns 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 is lastChangeDateTime + 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_mappingcompetency_framework (+ latest_version_id, latest_content_hash — the re-import edition decision), case_item_mapping → durable competency_node (THE node-continuity anchor, ADR-0018), case_association_mappingcompetency_association (provenance per decision 8), case_subject_mappingsubject (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)”
  1. Document → framework via document mapping (create: code = document GUID, title = document title; update: title refresh). Edition decision: package content hash equal to latest_content_hash → reuse the mapped edition (idempotent re-import; upserts no-op); different → mint a new competency_framework_version (label = CFDocument version ?? lastChangeDateTime, de-conflicted with a batch suffix; published immediately — imported editions are adopted facts, and immutability is ours, not the wire’s).
  2. Definitions: CFSubjects upsert subject vocabulary rows (+ subject mapping); concepts/licenses/itemTypes/groupings snapshot-only.
  3. 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): titlefullStatement (the only universally-required CASE field — totality), coding_schemehumanCodingScheme, node_typeCFItemType, language, abbreviated_statement; education levels and item subjects upsert vocabulary + junction rows; everything else (alternativeLabel, listEnumeration, conceptKeywords, notes, status dates, lastChangeDateTime) goes to revision_meta — wire mutability metadata never becomes core mutation.
  4. 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 or ext: association type → snapshot held/skipped with an issue, no core row (the held-user pattern; a later import of the missing framework resolves on re-import).
  5. 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.

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 (Emergent when platform-scoped), lastChangeDateTime = edition audit time.
  • CFItems ← one per node with a revision in the edition: identifier = node id, fullStatementtitle, humanCodingSchemecoding_scheme, CFItemTypenode_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-edition is-child-of gets a synthesized isChildOf → 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 (CFSubject hierarchyCode is required; code-less subjects round-trip through item.subject titles 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.

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.