Skip to content

Rich QTI assessmentTest authoring: bring authoring up to the delivery engine

Status: accepted

Builds on ADR-0010 (Assessment Definitions reference Item Versions through a canonical test_structure), ADR-0003/0004 (item-version immutability, single-part v0 authoring), ADR-0005 (selection locus), and ADR-0019 (server-anchored delivery). It supersedes the v0 authoring shape declared inert-but-flat in ADR-0010.

conform-ed’s delivery engine (createTestController) already interprets the full QTI 3 assessmentTest: arbitrarily nested assessmentSections, per-level timeLimits, itemSessionControl, selection {select, withReplacement} / ordering, navigationMode / submissionMode, and conditional preConditions / branchRules. emergent’s authoring delivers a single linear/individual part with one flat section (packages/domain/src/assessment-test.ts, buildTestStructure in the teacher portal). The platform can deliver far more than a teacher can author — every structural, timing, and conditional capability of the engine is unreachable from the product.

A second, sharper point: the canonical test_structure shape is emergent’s, but the QTI type surface it must match is conform-ed’s (AssessmentTestView and friends). Today emergent re-encodes that shape as zod in packages/domain, duplicating knowledge conform-ed owns — drift waits to happen on every conform-ed upgrade.

A third: this is pre-launch. There are no users and no data. Any “backward-compatible” accommodation of the v0 flat shape would be self-inflicted technical debt with nothing on the other side of it.

1. Authoring reaches the full delivery surface

Section titled “1. Authoring reaches the full delivery surface”

test_structure becomes a recursive QTI assessmentTest: parts → sections → ordered children (sub-sections or item refs), with timeLimits, itemSessionControl, selection, ordering, section flags (visible/fixed/required/keepTogether), and part navigationMode / submissionMode authorable at the levels the spec allows. The teacher portal grows a recursive tree editor with per-node settings — the “every knob” authoring UX. Structural reordering uses @dnd-kit — the stack Mantine UI’s own DnD list/table examples are built on — adapting those DndList / DndListHandle patterns; its built-in KeyboardSensor keeps reordering keyboard-accessible. The delivery engine already enforces all of it; nothing new is needed downstream.

2. The children model is canonical — no legacy, no back-compat

Section titled “2. The children model is canonical — no legacy, no back-compat”

A section carries an ordered children array (sub-section | itemRef), mirroring conform-ed’s AssessmentSectionView.children. The v0 flat section.itemRefs form is removed outright: the qti-demo seed, buildTestStructure, extractItemVersionIds, and the finalize/QTI tests are migrated to children in the same change. Pre-launch with no data, there is nothing to be compatible with; a dual path would only add debt (same clean-reshape stance as the regenerate-migrations runbook). flattenTestItemRefs, parseAssessmentTestStructure, and assessmentTestViewFromTestStructure keep their signatures (becoming recursive internally), so the learner delivery path (assembleDeliverableTest) and the server key-withholding maintenance (already routed through flattenTestItemRefs) are unaffected.

3. Conditional authoring (preConditions / branchRules) over the full expression surface

Section titled “3. Conditional authoring (preConditions / branchRules) over the full expression surface”

preConditions (boolean gates on show/skip) and branchRules (target + boolean condition) are authorable via a recursive expression builder over the full RpExpressionView operator surface — operands variable / baseValue; logical and / or / not / isNull; comparisons equal / match / gt / gte / lt / lte; arithmetic (sum / subtract / product / divide …); mapResponse; testVariables and aggregates; pattern/string operators — so any expression the delivery engine evaluates can be authored, not just simple gating. The stored/validated shape is the full RpExpressionView (permissive kind); summarizeConditionExpression remains a read-only forward-compat fallback for any kind the builder has not yet wired, not the common path. Conditions that depend on cross-learner aggregates are ADR-0005 selection-locus territory and out of scope — this lane authors the expressions; delivery already evaluates them locally.

4. The generic QTI zod lives in conform-ed; emergent keeps only the authoring delta

Section titled “4. The generic QTI zod lives in conform-ed; emergent keeps only the authoring delta”

conform-ed owns the QTI delivery types but ships no zod for them (the v0 zod in emergent was expedience). The generic QTI validation moves upstream to conform-ed as the single source of truth — reusable for import/parse validation and kept honest against the types it sits beside:

  • conform-ed gains zod for the shared atoms (timeLimits, itemSessionControl, selection, ordering, the recursive rpExpression, branchRule) and the recursive structure (assessmentSection / testPart / assessmentTest). The only authoring difference is the item-ref identity, so the recursive structure is exposed as a factory parameterized by the itemRef schemamakeAssessmentTestSchema(itemRefSchema) — plus a ready assessmentTestViewSchema (the href itemRef). React-free; released single-version per the internal-libs convention (the same flow as the /headless rowTransform work).
  • emergent/domain imports conform-ed’s atoms + factory and defines only the authoring itemRef (carrying itemVersionId, not href), composes the authoring structure schema from the factory, and keeps the itemVersionId → href projection (assessmentTestViewFromTestStructure).

This is a large cross-repo lane (conform-ed zod + release, emergent domain migration, API validation, a recursive tree editor, an expression builder, i18n). It is documented here and in docs/design/qti-rich-authoring-plan.md and executed in its own session, slice by slice (conform-ed → domain → API → tree editor → expression builder → integration), each validated independently.

  • The teacher portal gains a recursive structure tree editor and an expression builder — the largest authoring UI to date; substantial new i18n. It pulls in @dnd-kit (core + sortable + utilities) for node reordering — Mantine UI’s own DnD stack, keyboard-accessible via its KeyboardSensor; no other new deps.
  • conform-ed gains a QTI zod surface (a new export + a release); emergent’s packages/domain shrinks to the authoring delta and consumes it.
  • ADR-0010’s “single linear part” v0 note is superseded; the flat itemRefs shape is deleted, not deprecated.
  • The previously-separate follow-up lanes — nested parts/sections + timing, selection-with- replacement, and expression authoring — collapse into this one. CLR/credential emission on completion remains a separate lane.
  • No migration of stored data (pre-launch); the qti-demo seed is rewritten to the new shape.

Built slice by slice on dsv1, each independently validated (validate:full green throughout):

  • A — conform-ed QTI zod. @conform-ed/qti-react gained the RP + assessmentTest validation surface (rp/schema.ts, test/schema.ts): atoms, the recursive rpExpressionSchema, and the makeAssessmentTestSchema(itemRefSchema) factory + ready assessmentTestViewSchema. Released as 0.0.20 (all packages, one tag); emergent consumes it from the registry.
  • B — domain. packages/domain keeps only the authoring delta: an itemVersionId itemRef, the structure schema composed from the factory, recursive flattenTestItemRefs, and assessmentTestViewFromTestStructure (the itemVersionId → href projection). The flat itemRefs shape is deleted; seed + tests migrated to children. Added the assessment-condition authoring model (conditionKindSpecs, createConditionExpression, changeConditionKind, summarizeConditionExpression).
  • C — API. The teacher create/update routes validate testStructure with the domain schema at the boundary (malformed rejected on ingest), widening to the opaque jsonb column.
  • D — tree editor. A pure tree-state ops lib (test-structure-editor.ts) plus the recursive TestStructureEditor + NodeSettingsPanel with @dnd-kit reordering (per-container SortableContext, cross-container moves resolved by a pure resolveDrop), replacing the flat item picker in AssessmentsTab.
  • E — expression builder. A recursive, spec-driven ConditionBuilder authoring the full editable RpExpressionView surface for preConditions/branchRules; unsupported kinds fall back to the read-only summary.
  • F — integration. qti-rich-authoring.integration.test.ts authors a nested + timed + sampled + conditional test through the real teacher API, asserts the recursive shape persists + round-trips, assembles a deliverable that resolves its Item Versions, and finalizes an attempt over the authored items. i18n shipped en + zh-Hans throughout.

Exhaustive-authoring follow-up (G–J, also 2026-06-15)

Section titled “Exhaustive-authoring follow-up (G–J, also 2026-06-15)”

After A–F, four surface gaps were closed so every authorable assessmentTest field is editable — each its own commit, all green at validate:full:

  • G — full expression union. The condition builder’s editable kind set is grounded in conform-ed’s interpreter (rp/evaluate.ts): every deterministicExpressionKind plus the test aggregates (testVariables, numberCorrect/…, outcomeMaximum/outcomeMinimum), with the per-kind operand attributes (tolerance/bounds, rounding/figures, n/min/max/repeats, shape/coords, weight/section/field identifiers, name pickers, include/exclude categories). random* (template-only) and customOperator (vendor) stay read-only by design — so summarizeConditionExpression is now the fallback only for those, not the common case the slice-E bullet above implied. conditionKindSpecs + conditionNameOptions in packages/domain are the single source of truth.
  • H — test-level authoring. Test timeLimits; outcomeDeclarations (with match + interpolation tables); a recursive outcomeProcessing rule builder (setOutcomeValue / lookupOutcomeValue / exitTest / outcomeProcessingFragment / outcomeCondition with if·else-if·else); testFeedbacks. EditorTest round-trips all of these via from/toStructure. The types are derived by indexed-access from the already-published AssessmentTestStructure, so no new conform-ed release was needed.
  • I — itemRef knobs. Per-item weights and templateDefaults editors.
  • J — validation + labels. An advisory collectStructureIssues surfaces structural problems inline (duplicate identifiers, empty branch targets, over-selection, empty weight/template/outcome identifiers); friendly localized labels for the operator + baseType pickers.
  • New components: TestLevelSettings.tsx, ItemRefSettings.tsx, and a data-driven rewrite of ConditionBuilder.tsx (a single OperandFieldEditor switching on field kind). en + zh-Hans throughout.

All resolved in planning (recorded for provenance):

  1. Full rich authoring UI — recursive tree editor exposing every level’s knobs. (chosen)
  2. Include expression authoring for preConditions/branchRules. (chosen)
  3. No legacy / no back-compat — migrate the seed/tests to children, delete the flat shape. (chosen)
  4. Generic QTI zod lives in conform-ed, emergent keeps the authoring delta. (chosen)
  5. Build is split into its own session. (chosen)
  6. Expression-builder operator breadth — the full RpExpressionView. The builder authors the entire expression union (logical, comparison, arithmetic, mapResponse, testVariables/ aggregates, pattern/string), not just a gating subset; summarizeConditionExpression is only a read-only forward-compat fallback for not-yet-wired kinds. Scopes slice E larger up front. (chosen 2026-06-15)
  7. Tree-editor reordering UX — @dnd-kit (Mantine UI’s DnD stack). Direct-manipulation reordering of nested nodes via @dnd-kit/core + @dnd-kit/sortable + @dnd-kit/utilities — the same library Mantine UI’s official DndList / DndListHandle / DndTable examples use — adapting those patterns to the recursive structure (a SortableContext per section’s children, cross-section moves resolved in onDragEnd). @dnd-kit’s KeyboardSensor provides keyboard-accessible reordering natively. (chosen 2026-06-15)