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.
The tension (named, not hand-waved)
Section titled “The tension (named, not hand-waved)”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.
Decision
Section titled “Decision”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 recursiverpExpression,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 schema —makeAssessmentTestSchema(itemRefSchema)— plus a readyassessmentTestViewSchema(thehrefitemRef). React-free; released single-version per the internal-libs convention (the same flow as the/headlessrowTransform work). - emergent/domain imports conform-ed’s atoms + factory and defines only the authoring
itemRef(carryingitemVersionId, nothref), composes the authoring structure schema from the factory, and keeps theitemVersionId → hrefprojection (assessmentTestViewFromTestStructure).
5. The build is a separate, sliced lane
Section titled “5. The build is a separate, sliced lane”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.
Consequences
Section titled “Consequences”- 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 itsKeyboardSensor; no other new deps. - conform-ed gains a QTI zod surface (a new export + a release); emergent’s
packages/domainshrinks to the authoring delta and consumes it. - ADR-0010’s “single linear part” v0 note is superseded; the flat
itemRefsshape 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.
Implementation (landed 2026-06-15)
Section titled “Implementation (landed 2026-06-15)”Built slice by slice on dsv1, each independently validated (validate:full green throughout):
- A — conform-ed QTI zod.
@conform-ed/qti-reactgained the RP + assessmentTest validation surface (rp/schema.ts,test/schema.ts): atoms, the recursiverpExpressionSchema, and themakeAssessmentTestSchema(itemRefSchema)factory + readyassessmentTestViewSchema. Released as0.0.20(all packages, one tag); emergent consumes it from the registry. - B — domain.
packages/domainkeeps only the authoring delta: anitemVersionIditemRef, the structure schema composed from the factory, recursiveflattenTestItemRefs, andassessmentTestViewFromTestStructure(theitemVersionId → hrefprojection). The flatitemRefsshape is deleted; seed + tests migrated tochildren. Added theassessment-conditionauthoring model (conditionKindSpecs,createConditionExpression,changeConditionKind,summarizeConditionExpression). - C — API. The teacher create/update routes validate
testStructurewith 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 recursiveTestStructureEditor+NodeSettingsPanelwith@dnd-kitreordering (per-containerSortableContext, cross-container moves resolved by a pureresolveDrop), replacing the flat item picker inAssessmentsTab. - E — expression builder. A recursive, spec-driven
ConditionBuilderauthoring the full editableRpExpressionViewsurface forpreConditions/branchRules; unsupported kinds fall back to the read-only summary. - F — integration.
qti-rich-authoring.integration.test.tsauthors 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): everydeterministicExpressionKindplus 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,namepickers, include/exclude categories).random*(template-only) andcustomOperator(vendor) stay read-only by design — sosummarizeConditionExpressionis now the fallback only for those, not the common case the slice-E bullet above implied.conditionKindSpecs+conditionNameOptionsinpackages/domainare the single source of truth. - H — test-level authoring. Test
timeLimits;outcomeDeclarations(with match + interpolation tables); a recursiveoutcomeProcessingrule builder (setOutcomeValue/lookupOutcomeValue/exitTest/outcomeProcessingFragment/outcomeConditionwith if·else-if·else);testFeedbacks.EditorTestround-trips all of these viafrom/toStructure. The types are derived by indexed-access from the already-publishedAssessmentTestStructure, so no new conform-ed release was needed. - I — itemRef knobs. Per-item
weightsandtemplateDefaultseditors. - J — validation + labels. An advisory
collectStructureIssuessurfaces 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 ofConditionBuilder.tsx(a singleOperandFieldEditorswitching on field kind). en + zh-Hans throughout.
Decisions for Anton
Section titled “Decisions for Anton”All resolved in planning (recorded for provenance):
- Full rich authoring UI — recursive tree editor exposing every level’s knobs. (chosen)
- Include expression authoring for preConditions/branchRules. (chosen)
- No legacy / no back-compat — migrate the seed/tests to
children, delete the flat shape. (chosen) - Generic QTI zod lives in conform-ed, emergent keeps the authoring delta. (chosen)
- Build is split into its own session. (chosen)
- 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;summarizeConditionExpressionis only a read-only forward-compat fallback for not-yet-wired kinds. Scopes slice E larger up front. (chosen 2026-06-15) - 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 officialDndList/DndListHandle/DndTableexamples use — adapting those patterns to the recursive structure (aSortableContextper section’schildren, cross-section moves resolved inonDragEnd).@dnd-kit’sKeyboardSensorprovides keyboard-accessible reordering natively. (chosen 2026-06-15)