Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

LC-JSON Validation Surface

Status: Informative reference. The authoritative rules live in NORMATIVE.md — including the artifact-type rule families it incorporates at §3.3.1 — in the companion normative documents (HTML_SAFETY.md, ACCESSIBILITY.md, LOCALIZATION.md), and in the constraints of the JSON Schemas under schemas/. This document catalogs them in one place.

The four reference validators — tools/validate_course.py, tools/lc_collection.py, tools/lc_pack.py, and tools/lc_glossary.py — are non-authoritative reference implementations. They implement the contract; they do not define it. Where a validator’s behavior and a normative source disagree, the normative source wins and the validator is a defect to be fixed. Spec version: 1.1 Last updated: 2026-07-22

This document maps every documented validation rule in LC-JSON (Learning Content JSON) 1.1 to the place where it is enforced. The audience is implementers building consumers, validators, or producer round-trip tests — the same audience as NORMATIVE.md.

The catalog is additive and descriptive: it introduces no new normative rules. The inventory pass that built this catalog (2026-05-24) surfaced eight documented-but-unenforced rules; all eight were closed in the same rc.1-polish session by extending tools/validate_course.py (no schema changes). See §14 Forward-looking deepenings for the deepenings cataloged during the 1.0 cycle (1.0 has since shipped; the still-open items carry forward).

Rules for the 1.1 artifact types are cataloged in §15 (Subject Collection, SC-*), §16 (Curriculum Pack, CP-*), and §17 (Glossary, GL-*); §18 catalogs the 1.1 deepenings to the root document (RD-*) and to courses (CO-*). These four rule families are normative requirementsNORMATIVE.md §3.3.1 incorporates SC-*, CP-*, GL-*, RD-1, and CO-* as conformance rules for their artifact types; the sections below enumerate each rule, cite its source, and tag its enforcement tier. (This catalog adds nothing to those rules; it is the one-map view of them.) The rule ids are load-bearing — the reference validators cite them — so they cannot silently drift. §15–§18 follow §14 rather than sitting beside §3–§11 to keep the 1.0 section numbers (and their anchors) stable.


1. Scope and structure

LC-JSON’s validation surface is split across four enforcement sites:

  • 27 JSON Schemas in schemas/ — declarative constraints (Draft 7) enforced by any conforming JSON Schema validator.
  • NORMATIVE.md — RFC 2119 prose obligations that may or may not be representable in JSON Schema.
  • tools/validate_course.py (the reference validator) — domain checks that run after schema validation, plus consumer-friendly diagnostics.
  • Companion normative documents and informative referencesHTML_SAFETY.md (normative) and ACCESSIBILITY.md (normative for tools claiming the Accessibility Profile; preservation obligations bind every consumer per NORMATIVE.md §12.1); per-type prose in question-types-reference.md and authoring patterns in ITEM_PATTERNS.md (both informative).

A consumer that only runs schema validation will accept documents the spec considers invalid (e.g. an MCQ with no correct option, a placement whose placements[].gap points at a missing @@@N marker). A consumer that re-implements the reference validator from prose will miss rules. This catalog gives implementers one map of “these are all the things a conforming consumer must check, and here’s where each rule is enforced.”

1.1 The three enforcement tiers

The rule tables below tag each rule with one tier:

TierMeaningCitation format
Schema-enforcedExpressed in one of the JSON Schemas under schemas/. Any Draft-7 validator catches violations.schemas/<file>.schema.json: <json-pointer>
Domain-validator-enforcedNot (or not cleanly) expressible in JSON Schema; the reference validator tools/validate_course.py checks it. Conforming consumers MUST replicate these checks to round-trip and grade correctly.validate_course.py: <function-name> + NORMATIVE § where cited
AdvisoryDescribed in prose (NORMATIVE.md, README.md, question-types-reference.md, ITEM_PATTERNS.md) but not mechanically enforced anywhere. SHOULD/MAY rules, naming conventions, behaviors the spec hints at but lets consumers vary. Listed so implementers know what they are choosing.Document and section

A fourth, implicit tier — runtime-enforced — covers grading policy, navigation gating, gradebook display. Out of scope for this document; LC-JSON specifies document validity, not runtime behavior.

1.2 Severity (Domain-validator rows)

The reference validator distinguishes three severities on its domain-rule pass. Schema-enforced rows are always hard errors (any schema violation fails the document); Advisory rows are not enforced. Domain rows carry one of:

  • ERROR — the validator returns non-zero exit; the document is non-conforming. Consumers MUST reject.
  • WARN — the document is reported as suspect but still parses; the validator returns success. Conforming consumers SHOULD surface the warning to the user.
  • NOTE — informational only (e.g. item.points intentionally weighted away from the sum of question points). The validator returns success without raising; no consumer obligation.

Where a single rule is enforced at multiple tiers (e.g. schema + validator double-check for friendlier messages), the row lists both. Satisfying the strictest tier suffices.

1.3 Strict mode and the lenient migration path

The reference validator tools/validate_course.py accepts a --strict flag. The default (lenient) mode emits a warning and falls through with reduced enforcement when it encounters two pre-1.0 document shapes: the wrapped envelope {"course": {...}} and the bare payload {"units": [...]} with no documentType. Neither shape is part of the published 1.0 contract; the lenient handling is a maintainer-side migration aid that allows pre-1.0 document shapes to be ingested during the upgrade — it is not a published affordance third-party producers may rely on.

Under --strict, both shapes are fatal errors. The conformance corpus harness tools/run_corpus.py always invokes the validator with --strict (every fixture is run through the validator); CI runs the harness on every PR; and per NORMATIVE.md §10.3 conformance claims under §10 are evaluated in --strict mode. Third-party consumers and producers should treat the lenient path as a maintainer-side migration aid only. --strict is the mode in which the reference validator attempts to implement the published contract in full; the contract itself is stated in NORMATIVE.md, the companion normative documents, and the schema constraints — not by the validator’s behavior.

Rows in the tables below that depend on this distinction explicitly say “ERROR under --strict; WARN otherwise”; everywhere else, the rule applies uniformly.


2. Where to look

WhatWhere
JSON Schemasschemas/*.schema.json — 27 files
Reference validator (course, questionSet)tools/validate_course.py
Conformance language (RFC 2119 MUSTs/SHOULDs/MAYs)NORMATIVE.md
Per-type property referencequestion-types-reference.md
Subject Collection property referencesubject-collection-reference.md
Curriculum Pack property referencecurriculum-pack-reference.md
Glossary property referenceglossary-reference.md
HTML safety profile (elements, attributes, URL schemes, sanitization)HTML_SAFETY.md
Accessibility profile (preservation + opt-in delivery claim)ACCESSIBILITY.md
Item authoring patterns (consumer-policy plurality)ITEM_PATTERNS.md
Conformance test fixturestests/ — manifest + valid/invalid sets

3. Root document

Required root fields (NORMATIVE.md §3.2). Both artifact types (course, questionSet) share these.

RuleTierSourceNORMATIVE §
Producer MUST emit $schema pointing at the canonical published schema URLSchema-enforced (producer validity)course.schema.json: /required[*]="$schema", question-set.schema.json: /required[*]="$schema"§3.2, §4.7
Consumer SHOULD tolerate documents that omit $schema (infer the schema from documentType + specVersion); MUST reject any other root-field omissionAdvisory (consumer-side import tolerance)NORMATIVE.md §3.2§3.2
$schema, when present, is a URISchema-declared via format: "uri" (annotation; not universally enforced by Draft-7 validators — see §13)course.schema.json: /properties/$schema/format="uri", question-set.schema.json: /properties/$schema/format="uri"§4.7
documentType required at rootSchema-enforcedcourse.schema.json: /required[*]="documentType", question-set.schema.json: /required[*]="documentType"§3.2
documentType is "course" (course document)Schema-enforcedcourse.schema.json: /properties/documentType/const="course"§3.2, §4.2, §5.3
documentType is "questionSet" (question-set document)Schema-enforcedquestion-set.schema.json: /properties/documentType/const="questionSet"§3.2, §4.2, §5.3
Non-canonical documentType casing rejected ("Course", "questionset", "question-set")Schema-enforced (via const)course.schema.json / question-set.schema.json const; validate_course.py: dispatch_document_shape provides casing-tolerant dispatch as a maintainer-side migration aid (§1.3) — disabled under --strict§4.2, §5.3
specVersion required at rootSchema-enforcedcourse.schema.json: /required[*]="specVersion", question-set.schema.json: /required[*]="specVersion"§3.2, §4.6
specVersion matches ^1\.[0-9]+(\.[0-9]+)?$Schema-enforced + Domain-validator-enforced (ERROR for 2.x+)course.schema.json: /properties/specVersion/pattern; validate_course.py: check_spec_version§4.6, §5.2
specVersion MUST NOT carry an -rc.N suffixAdvisoryNORMATIVE.md §4.6, §8.4§4.6
language required at rootSchema-enforcedcourse.schema.json: /required[*]="language", question-set.schema.json: /required[*]="language"§12.1
language is a plausible BCP 47 tag (bare ISO 639-1, or with region/script subtag)Domain-validator-enforced (WARN; schema typed only, no pattern)validate_course.py: validate_course_level (course path) and validate_question_set_flat (question-set path), via _is_plausible_language_tag§13, LOCALIZATION.md §3
supportLanguage is a plausible BCP 47 tag (or omitted/null)Domain-validator-enforced (WARN; schema typed only)validate_course.py: validate_course_level (course path) and validate_question_set_flat (question-set path), via _is_plausible_language_tag§13, LOCALIZATION.md §3
Pre-1.0 wrapped envelope {"course": {...}} rejected (published contract; lenient migration aid in default mode)Domain-validator-enforced (ERROR under --strict; WARN otherwise — see §1.3)validate_course.py: validate_course (--strict branch)§3.2, §4.1
Pre-1.0 bare payload {"units": [...]} rejected (published contract; lenient migration aid in default mode)Domain-validator-enforced (ERROR under --strict; WARN otherwise — see §1.3)validate_course.py: validate_course (--strict branch)§3.2, §4.1
Property names are camelCaseAdvisory (consumer-side import is lenient via JsonNormalizer-style helpers)NORMATIVE.md §4.5§4.5
Extension members keyed x-<namespace> MAY appear on root + Course/Unit/Lesson/Item/QuestionAdvisory (schemas do not restrict additionalProperties on those objects)NORMATIVE.md §7.1§7.1
Extension members MUST NOT appear on matching.pairs[*], matching.categories[*], or placement.placements[*]Schema-enforcedmatching.schema.json: /allOf/1/then/properties/pairs/items/additionalProperties=false etc.; placement.schema.json: /allOf/1/properties/placements/items/additionalProperties=false§7.1
Producer MUST NOT introduce a non-extension field beginning with x-AdvisoryNORMATIVE.md §7.1§7.1
Consumer MUST NOT reject documents solely for unknown fields or x- membersAdvisoryNORMATIVE.md §5.4, §7.4§5.4, §7.4

4. Course-level

Course payload fields on a documentType: "course" document.

RuleTierSourceNORMATIVE §
title required, minLength: 1Schema-enforced + Domain-validator-enforcedcourse.schema.json: /properties/title, /required[*]="title"; validate_course.py: validate_course_level§3.2
sourceCourseId, when present, matches the RFC 4122 UUID pattern (any version; shape-only validation)Schema-enforced + Domain-validator-enforced (WARN if non-UUID)course.schema.json: /properties/sourceCourseId/pattern; validate_course.py: validate_course_level§4.4
sourceCourseId SHOULD be emitted for re-importable or version-tracked coursesAdvisoryNORMATIVE.md §4.4§4.4
version, when present, matches ^[0-9]+(\.[0-9]+){0,2}$ (1–3 numeric segments)Schema-enforced + Domain-validator-enforced (WARN)course.schema.json: /properties/version/pattern; validate_course.py: validate_course_level§4.4
Pre-1.0 identity fields (authorId, authorCourseId) trigger a migration warningDomain-validator-enforced (WARN)validate_course.py: validate_course_level(none — migration aid)
Course objectives[*].id and objectives[*].text requiredSchema-enforcedcourse.schema.json: /properties/objectives/items/required(none)
Course objectives[*].difficultyBand enum: "Recall", "Understand", "Apply", "Analyze", nullSchema-enforcedcourse.schema.json: /properties/objectives/items/properties/difficultyBand/enum(none)
courseObjectiveIds[*] reference course.objectives[].idDomain-validator-enforced (WARN)validate_course.py (objective-reference integrity check)(none — warning-tier integrity check; unresolved references break signpost auto-rendering)
estimatedDurationMinutes >= 0Schema-enforcedcourse.schema.json: /properties/estimatedDurationMinutes/minimum(none)
Course tags[*] are strings (Unit/Lesson/Item additionally enforce minLength: 1)Schema-enforcedcourse.schema.json: /properties/tags/items/type="string"; unit.schema.json / lesson.schema.json / item-base.schema.json: /properties/tags/items/minLength=1(none)
units[] MUST be present at the root (course); is an array when presentDomain-validator-enforced (ERROR if missing); Schema-enforced (array type when present)validate_course.py: validate_course (“Missing ‘units’ array at root level”); course.schema.json: /properties/units/type="array" (the schema’s default: [] would otherwise admit a missing field)(none)

5. Unit-level

RuleTierSourceNORMATIVE §
globalId requiredSchema-enforced + Domain-validator-enforcedunit.schema.json: /required[*]="globalId"; validate_course.py: validate_unit§4.4
globalId matches the RFC 4122 UUID pattern (any version; shape-only validation)Schema-enforced + Domain-validator-enforced (WARN if non-UUID)unit.schema.json: /properties/globalId/pattern; validate_course.py: validate_unit (via is_valid_uuid)§4.4
title required, minLength: 1Schema-enforced + Domain-validator-enforcedunit.schema.json: /properties/title, /required[*]="title"; validate_course.py: validate_unit(none)
description defaults to ""Schema-declared (annotation; not enforced — see §13)unit.schema.json: /properties/description/default(none)
tags[*] minLength: 1Schema-enforcedunit.schema.json: /properties/tags/items/minLength(none)
sequence >= 0Schema-enforcedunit.schema.json: /properties/sequence/minimum(none — import uses array position, not sequence)
sequence duplicates/gaps within siblingsDomain-validator-enforced (WARN, advisory)validate_course.py: validate_sequence_order(none)
objectiveIds[*] reference course.objectives[].idDomain-validator-enforced (WARN)validate_course.py (objective-reference integrity check)(none)
lessons[] array (default [] schema-declared)Schema-enforced (type)unit.schema.json: /properties/lessons(none)

6. Lesson-level

RuleTierSourceNORMATIVE §
globalId required + RFC 4122 UUID pattern (any version; shape-only validation)Schema-enforced + Domain-validator-enforced (WARN)lesson.schema.json: /required, /properties/globalId/pattern; validate_course.py: validate_lesson§4.4
title required, minLength: 1Schema-enforced + Domain-validator-enforcedlesson.schema.json; validate_course.py: validate_lesson(none)
items[] is an array of content/exercise/quiz/contentsequence/signpost (oneOf dispatch)Schema-enforcedlesson.schema.json: /properties/items/items/oneOf(none)
items missing or empty — informationalDomain-validator-enforced (WARN)validate_course.py: validate_lesson (“empty lesson”)(none)
sequence duplicates/gaps within siblings (lesson item ordering)Domain-validator-enforced (WARN, advisory)validate_course.py: validate_sequence_order(none)
objectiveIds[*] reference course.objectives[].idDomain-validator-enforced (WARN)validate_course.py (objective-reference integrity check)(none)

7. Item-level — common

Properties inherited by every item type via item-base.schema.json.

RuleTierSourceNORMATIVE §
type required, enum: content, exercise, quiz, contentsequence, signpostSchema-enforced + Domain-validator-enforceditem-base.schema.json: /properties/type/enum, /required; validate_course.py: validate_item§4.2, §5.3
Non-canonical item-type casing (Content, ExerciseItem) rejectedSchema-enforced (via enum/const) + Domain-validator-enforced (WARN; tolerated via normalize_item_type)item-base.schema.json; validate_course.py: validate_item§4.2, §5.3
globalId required + RFC 4122 UUID pattern (any version; shape-only validation)Schema-enforced + Domain-validator-enforced (WARN)item-base.schema.json: /required, /properties/globalId/pattern; validate_course.py: validate_item§4.4
title required, minLength: 1Schema-enforced + Domain-validator-enforced (WARN if missing)item-base.schema.json; validate_course.py: validate_item(none)
tags[*] minLength: 1Schema-enforceditem-base.schema.json: /properties/tags/items/minLength(none)
suggestedTime >= 0Schema-enforceditem-base.schema.json: /properties/suggestedTime/minimum(none)
isOptional boolean, default false (schema-declared)Schema-enforced (type)item-base.schema.json: /properties/isOptional(none)

7.1 ContentItem

RuleTierSourceNORMATIVE §
type is "content"Schema-enforcedcontent-item.schema.json: /allOf/1/properties/type/const="content"§4.2
html requiredSchema-enforced + Domain-validator-enforcedcontent-item.schema.json: /allOf/1/required[*]="html"; validate_course.py: validate_item (normalized_type == "content")(none)
Deprecated body property → use htmlDomain-validator-enforced (WARN)validate_course.py: validate_item(none)
html content satisfies the HTML safety profileDomain-validator-enforced (ERROR / WARN per HTML_SAFETY.md §8)validate_course.py: validate_html_content§11, HTML_SAFETY.md (see §12 below)

7.2 ExerciseItem

RuleTierSourceNORMATIVE §
type is "exercise"Schema-enforcedexercise-item.schema.json: /allOf/1/properties/type/const="exercise"§4.2
questions[] requiredSchema-enforced + Domain-validator-enforcedexercise-item.schema.json: /allOf/1/required[*]="questions"; validate_course.py: validate_item(none)
instructions required (PascalCase Instructions triggers WARN)Domain-validator-enforced (ERROR if missing, WARN on PascalCase)validate_course.py: validate_item(none)
isGraded boolean, default false (schema-declared)Schema-enforced (type)exercise-item.schema.json: /allOf/1/properties/isGraded/default=false§4.3
passMarkPercent is a number 0 <= x <= 100, default 70.0 (schema-declared)Schema-enforced (type/range)exercise-item.schema.json: /allOf/1/properties/passMarkPercent/{minimum,maximum,default}(none — consumer-policy gated; see ITEM_PATTERNS.md §3)
points >= 0Schema-enforcedexercise-item.schema.json: /allOf/1/properties/points/minimum(none)
Producer/consumer MUST NOT infer grading state from item type aloneAdvisoryNORMATIVE.md §4.3§4.3

7.3 QuizItem

RuleTierSourceNORMATIVE §
type is "quiz"Schema-enforcedquiz-item.schema.json: /allOf/1/properties/type/const="quiz"§4.2
questions[] required, isGraded requiredSchema-enforcedquiz-item.schema.json: /allOf/1/required[*]={"questions","isGraded"}(none)
isGraded boolean, default true (schema-declared)Schema-enforced (type)quiz-item.schema.json: /allOf/1/properties/isGraded/default=true§4.3
passMarkPercent is a number 0 <= x <= 100, default 70.0 (schema-declared)Schema-enforced (type/range)quiz-item.schema.json: /allOf/1/properties/passMarkPercent(none — see ITEM_PATTERNS.md §3)
points >= 0Schema-enforcedquiz-item.schema.json: /allOf/1/properties/points/minimum(none)
item.points vs sum(question.points) mismatch is intentional weightingDomain-validator-enforced (NOTE)validate_course.py (weighted-points NOTE collection)(none)

7.4 ContentSequenceItem

RuleTierSourceNORMATIVE §
type is "contentsequence"Schema-enforcedcontent-sequence-item.schema.json: /allOf/1/properties/type/const§4.2
contentItemId required; value is a UUIDSchema-enforced (required) + Domain-validator-enforced (WARN if non-UUID)content-sequence-item.schema.json: /allOf/1/required, /properties/contentItemId/format="uuid" (annotation — see §13); validate_course.py: validate_item (via is_valid_uuid)(none)
relatedItemIds[*] are UUIDsDomain-validator-enforced (WARN); schema declares format: "uuid" as annotationcontent-sequence-item.schema.json: /allOf/1/properties/relatedItemIds/items/format="uuid" (annotation); validate_course.py: validate_item (via is_valid_uuid)(none)
relatedItemIds non-empty arrayDomain-validator-enforced (ERROR if missing or empty)validate_course.py: validate_item(none)
layout enum: "Auto", "Split", "Vertical", default "Auto" (schema-declared)Schema-enforced (enum) + Domain-validator-enforced (WARN if other)content-sequence-item.schema.json: /allOf/1/properties/layout/enum; validate_course.py: validate_item(none)
contentItemId resolves to a sibling content item declared earlier in the lessonDomain-validator-enforced (ERROR)validate_course.py: validate_item (CSI branch)(none)
Each relatedItemIds[*] resolves to a sibling exercise/quiz item declared earlier in the lessonDomain-validator-enforced (ERROR)validate_course.py: validate_item (CSI branch)(none)

7.5 SignpostItem

RuleTierSourceNORMATIVE §
type is "signpost"Schema-enforcedsignpost-item.schema.json: /allOf/1/properties/type/const§4.2
signpostType required, enum: "intro", "summary"Schema-enforced + Domain-validator-enforced (ERROR if missing or other)signpost-item.schema.json: /allOf/1/required, /properties/signpostType/enum; validate_course.py: validate_item(none)
scope required, enum: "course", "unit", "lesson"Schema-enforced + Domain-validator-enforced (ERROR if missing or other)signpost-item.schema.json: /allOf/1/required, /properties/scope/enum; validate_course.py: validate_item(none)
Signpost items MUST NOT carry questionsDomain-validator-enforced (ERROR)validate_course.py: validate_item (signpost branch)(none)
customHtml, when present, satisfies the HTML safety profileDomain-validator-enforced (ERROR / WARN per HTML_SAFETY.md §8)validate_course.py: validate_html_content§11, HTML_SAFETY.md
A signpost with no objectives (and no customHtml) renders an empty stubAdvisoryITEM_PATTERNS.md §5(none)

8. Question-level — common

Properties inherited by every question via question-base.schema.json. Required by NORMATIVE §4.4: every question MUST carry a globalId.

RuleTierSourceNORMATIVE §
type required + enum (19 values: 12 implemented + 7 reserved)Schema-enforced + Domain-validator-enforcedquestion-base.schema.json: /required[*]="type", /properties/type/enum; validate_course.py: validate_question§4.2, §5.3, §6.1
Non-canonical question-type casing (MultipleChoice, simplegapfill) rejectedSchema-enforced (via enum) + Domain-validator-enforced (ERROR for unknown discriminator)question-base.schema.json: /properties/type/enum; validate_course.py (per-type question dispatch)§4.2, §5.3
globalId required + RFC 4122 UUID pattern (any version; shape-only validation)Schema-enforced + Domain-validator-enforced (WARN if non-UUID)question-base.schema.json: /required[*]="globalId", /properties/globalId/pattern; validate_course.py: validate_question§4.4
prompt required, minLength: 0 (may be empty); empty/whitespace prompt is an ERROR for the 4 real-content types (trueFalseQuestion, multipleChoice, shortAnswer, essay), valid (empty) for the 8 symbolic types, and unconstrained for the 7 reserved types (deferred to v1.1)Schema-enforced (required, minLength: 0) + Domain-validator-enforced (ERROR on real-content empty; WARN if missing)question-base.schema.json: /required[*]="prompt", /properties/prompt/minLength; validate_course.py: validate_question(none)
points is a non-negative number, MAY be null, default 1.0 (schema-declared)Schema-enforced (type/range) + Domain-validator-enforced (WARN if missing)question-base.schema.json: /properties/points/{type,minimum,default}; validate_course.py: validate_question(none)
difficulty is a number 0.0 <= x <= 10.0, default 5.0 (schema-declared)Schema-enforced (type/range)question-base.schema.json: /properties/difficulty/{minimum,maximum,default}(none — author estimate; see question-types-reference.md Common Properties)
tags[*] stringsSchema-enforcedquestion-base.schema.json: /properties/tags/items/type(none)
hint is string or null, default nullSchema-enforcedquestion-base.schema.json: /properties/hint(none)
feedback is an object or null; feedback.{correct,incorrect} are strings; feedback.choiceFeedback is {string: string}Schema-enforcedquestion-base.schema.json: /properties/feedback(none)
Deprecated questionType (instead of type)Domain-validator-enforced (ERROR)validate_course.py: validate_question(none — migration aid)
For non-question fields: producer MUST NOT embed HTML in plain-text fieldsAdvisoryHTML_SAFETY.md §1.1§11

9. Question-level — by type (12 implemented)

Properties enforced per question-type schema, plus per-type domain-validator rules. The 7 reserved types are covered in §10.

9.1 simpleGapFill

RuleTierSourceNORMATIVE §
type is "simpleGapFill"Schema-enforcedsimple-gap-fill.schema.json: /allOf/1/properties/type/const§4.2
sentence required, contains @@@, minLength: 4Schema-enforcedsimple-gap-fill.schema.json: /allOf/1/required, /properties/sentence/{pattern,minLength}(none)
acceptedAnswers required, minItems: 1, each minLength: 1Schema-enforcedsimple-gap-fill.schema.json: /allOf/1/required, /properties/acceptedAnswers/{minItems,items/minLength}(none)
caseSensitive boolean, default false (schema-declared)Schema-enforced (type)simple-gap-fill.schema.json: /allOf/1/properties/caseSensitive(none)

9.2 trueFalseQuestion

RuleTierSourceNORMATIVE §
type is "trueFalseQuestion"Schema-enforcedtrue-false-question.schema.json: /allOf/1/properties/type/const§4.2
correctAnswer required, booleanSchema-enforced + Domain-validator-enforced (ERROR if missing both v1 and v2 forms, ERROR if non-boolean, WARN on boolean-ish coercion)true-false-question.schema.json: /allOf/1/required, /properties/correctAnswer/type; validate_course.py: validate_true_false_question(none)
Pre-1.0 TF shape (options / optionsAndPoints) deprecatedDomain-validator-enforced (WARN; multiple positive-points options WARN; zero positives WARN)validate_course.py: validate_true_false_question(none)
displayStyle enum: "TrueFalse", "CorrectIncorrect", "CheckmarkX", default "TrueFalse" (schema-declared)Schema-enforced (enum) + Domain-validator-enforced (WARN if other)true-false-question.schema.json: /allOf/1/properties/displayStyle/enum; validate_course.py: validate_true_false_question(none)
penalizeIncorrect boolean, default false (schema-declared)Schema-enforced (type)true-false-question.schema.json: /allOf/1/properties/penalizeIncorrect(none)
incorrectPenaltyPercent is 0..100, default 50.0 (schema-declared)Schema-enforced (type/range) + Domain-validator-enforced (WARN if out of range)true-false-question.schema.json: /allOf/1/properties/incorrectPenaltyPercent/{minimum,maximum}; validate_course.py: validate_true_false_question(none)
feedback.choiceFeedback deprecated on TFDomain-validator-enforced (WARN)validate_course.py: validate_true_false_question(none — TF v2 forbids the field; see question-types-reference.md)

9.3 multipleChoice

RuleTierSourceNORMATIVE §
type is "multipleChoice"Schema-enforcedmultiple-choice.schema.json: /allOf/1/properties/type/const§4.2
options required, minItems: 2, each minLength: 1Schema-enforcedmultiple-choice.schema.json: /allOf/1/required, /properties/options/{minItems,items/minLength}(none)
optionsAndPoints required, {string: number} mapSchema-enforcedmultiple-choice.schema.json: /allOf/1/required, /properties/optionsAndPoints(none)
At least one optionsAndPoints value > 0 (an MCQ MUST have a correct answer)Domain-validator-enforced (ERROR)validate_course.py: validate_multiple_choice(none)
optionsAndPoints keys cover every entry in optionsDomain-validator-enforced (ERROR if missing; WARN if optionsAndPoints contains extras not in options)validate_course.py: validate_multiple_choice(none)
allowMultipleCorrect boolean, default false (schema-declared)Schema-enforced (type)multiple-choice.schema.json: /allOf/1/properties/allowMultipleCorrect(none)
allowPartialCredit boolean, default true (schema-declared)Schema-enforced (type)multiple-choice.schema.json: /allOf/1/properties/allowPartialCredit(none)
penalizeIncorrect boolean, default false (schema-declared)Schema-enforced (type)multiple-choice.schema.json: /allOf/1/properties/penalizeIncorrect(none)
showLetterLabels boolean, default false (schema-declared)Schema-enforced (type)multiple-choice.schema.json: /allOf/1/properties/showLetterLabels(none)
shuffleOptions governs per-question option randomization (per-question discretion)AdvisoryNORMATIVE.md §5.6 (multipleChoice is explicitly exempt from the §5.6 randomization MUST)§5.6

9.4 wordBankCloze

RuleTierSourceNORMATIVE §
type is "wordBankCloze"Schema-enforcedword-bank-cloze.schema.json: /allOf/1/properties/type/const§4.2
passage required, matches @@@\d+, minLength: 4Schema-enforcedword-bank-cloze.schema.json: /allOf/1/required, /properties/passage/{pattern,minLength}(none)
wordBank required, minItems: 1, each minLength: 1Schema-enforcedword-bank-cloze.schema.json: /allOf/1/required, /properties/wordBank(none)
gapAcceptedAnswers required, {"^[0-9]+$": [string]}, each gap minItems: 1, each accepted answer minLength: 1Schema-enforcedword-bank-cloze.schema.json: /allOf/1/required, /properties/gapAcceptedAnswers/patternProperties(none)
passage @@@N marker set MUST equal gapAcceptedAnswers key setDomain-validator-enforced (ERROR)validate_course.py: validate_word_bank_cloze (cloze gap-consistency check)(none)
@@@N marker numbers SHOULD be sequential starting at 1Domain-validator-enforced (WARN)validate_course.py: validate_word_bank_cloze (cloze gap-consistency check)(none)
allowWordReuse boolean, default false (schema-declared)Schema-enforced (type)word-bank-cloze.schema.json: /allOf/1/properties/allowWordReuse(none)
bankPosition enum: "above", "below", "side"Schema-enforcedword-bank-cloze.schema.json: /allOf/1/properties/bankPosition/enum(none)
gapCaseSensitive / gapFeedback value typesSchema-enforcedword-bank-cloze.schema.json: /allOf/1/properties/gapCaseSensitive, gapFeedback(none)

9.5 multiGapCloze

RuleTierSourceNORMATIVE §
type is "multiGapCloze"Schema-enforcedmulti-gap-cloze.schema.json: /allOf/1/properties/type/const§4.2
passage required, matches @@@\d+, minLength: 4Schema-enforcedmulti-gap-cloze.schema.json: /allOf/1/required, /properties/passage(none)
gapAcceptedAnswers requiredSchema-enforcedmulti-gap-cloze.schema.json: /allOf/1/required(none)
Each accepted answer MUST NOT contain , or : (scoring-engine wire format)Schema-enforced + Domain-validator-enforced (ERROR)multi-gap-cloze.schema.json: /allOf/1/properties/gapAcceptedAnswers/patternProperties/.../items/not/pattern="[,:]"; validate_course.py: validate_multi_gap_cloze(none — wire-format consequence)
Other punctuation in answers SHOULD be limited to apostrophes and hyphensDomain-validator-enforced (WARN)validate_course.py: validate_multi_gap_cloze(none)
passage @@@N marker set MUST equal gapAcceptedAnswers key setDomain-validator-enforced (ERROR)validate_course.py: validate_multi_gap_cloze (cloze gap-consistency check)(none)
@@@N marker numbers SHOULD be sequential starting at 1Domain-validator-enforced (WARN)validate_course.py: validate_multi_gap_cloze (cloze gap-consistency check)(none)
allowPartialCredit boolean, default true (schema-declared)Schema-enforced (type)multi-gap-cloze.schema.json: /allOf/1/properties/allowPartialCredit(none)

9.6 multipleChoiceCloze

RuleTierSourceNORMATIVE §
type is "multipleChoiceCloze"Schema-enforcedmultiple-choice-cloze.schema.json: /allOf/1/properties/type/const§4.2
passage, gapOptions, correctAnswers requiredSchema-enforcedmultiple-choice-cloze.schema.json: /allOf/1/required(none)
Each gap’s gapOptions has minItems: 2Schema-enforcedmultiple-choice-cloze.schema.json: /allOf/1/properties/gapOptions/patternProperties/.../minItems(none)
correctAnswers[N] is a non-negative integerSchema-enforcedmultiple-choice-cloze.schema.json: /allOf/1/properties/correctAnswers/patternProperties/.../{type,minimum}(none)
correctAnswers[N] index in bounds of gapOptions[N]Domain-validator-enforced (ERROR)validate_course.py: validate_multiple_choice_cloze(none)
passage @@@N marker set MUST equal gapOptions key setDomain-validator-enforced (ERROR)validate_course.py: validate_multiple_choice_cloze (cloze gap-consistency check)(none)
gapOptions key set MUST equal correctAnswers key setDomain-validator-enforced (ERROR)validate_course.py: validate_multiple_choice_cloze(none)
@@@N marker numbers SHOULD be sequential starting at 1Domain-validator-enforced (WARN)validate_course.py: validate_multiple_choice_cloze (cloze gap-consistency check)(none)
shuffleOptions boolean, default false (schema-declared)Schema-enforced (type)multiple-choice-cloze.schema.json: /allOf/1/properties/shuffleOptions(none)

9.7 shortAnswer

RuleTierSourceNORMATIVE §
type is "shortAnswer"Schema-enforcedshort-answer.schema.json: /allOf/1/properties/type/const§4.2
acceptedAnswers required, minItems: 1, each minLength: 1Schema-enforcedshort-answer.schema.json: /allOf/1/required, /properties/acceptedAnswers(none)
acceptedAnswers[0] is the canonical form for displayAdvisory (question-types-reference.md §7)(no enforcement)(none)
caseSensitive boolean, default false (schema-declared)Schema-enforced (type)short-answer.schema.json: /allOf/1/properties/caseSensitive(none)

9.8 essay

RuleTierSourceNORMATIVE §
type is "essay"Schema-enforcedessay.schema.json: /allOf/1/properties/type/const§4.2
expectedAnswer required (string, may be empty)Schema-enforcedessay.schema.json: /allOf/1/required(none)
expectedLines, minWords, maxWords are integers >= 0 (0 = no limit)Schema-enforcedessay.schema.json: /allOf/1/properties/{expectedLines,minWords,maxWords}(none)
maxWords >= minWords when both > 0Domain-validator-enforced (WARN)validate_course.py: validate_essay(none)
rubricText is Markdown when presentAdvisory (question-types-reference.md §8)(none)(none)

9.9 sentenceTransformation

RuleTierSourceNORMATIVE §
type is "sentenceTransformation"Schema-enforcedsentence-transformation.schema.json: /allOf/1/properties/type/const§4.2
promptSentence, keyword, targetSentence, acceptedChunks requiredSchema-enforced + Domain-validator-enforced (ERROR if missing)sentence-transformation.schema.json: /allOf/1/required; validate_course.py: validate_sentence_transformation(none)
targetSentence contains exactly one @@@ placeholder (minLength: 4); multiple @@@ markers are non-conforming because SentenceTransformation chunks are sequential answer pieces typed at that single position, not separate gapsSchema-enforced (pattern requires at least one @@@; minLength) + Domain-validator-enforced (ERROR if more than one @@@; WARN if zero)sentence-transformation.schema.json: /allOf/1/properties/targetSentence/{pattern,minLength}; validate_course.py: validate_sentence_transformation(none)
acceptedChunks keys are ^[0-9]+$, each value minItems: 1, each chunk minLength: 1Schema-enforcedsentence-transformation.schema.json: /allOf/1/properties/acceptedChunks/patternProperties(none)
Chunk numbers SHOULD be sequential starting at 1Domain-validator-enforced (WARN)validate_course.py: validate_sentence_transformation(none)
keyword SHOULD be uppercaseDomain-validator-enforced (WARN)validate_course.py: validate_sentence_transformation(none)
Deprecated PascalCase chunks/keyword fields (AcceptedChunks, Keyword, …) → camelCaseDomain-validator-enforced (WARN)validate_course.py: validate_sentence_transformation (deprecated_props map)(none)
allOrNothing boolean, default false (schema-declared); chunkCaseSensitive / chunkFeedback typed mapsSchema-enforced (types) + Domain-validator-enforced (WARN if not boolean / not dict)sentence-transformation.schema.json; validate_course.py: validate_sentence_transformation(none)

9.10 matching

matching carries an if/then/else branch in the schema, keyed off matchingMode.

RuleTierSourceNORMATIVE §
type is "matching"Schema-enforcedmatching.schema.json: /allOf/1/properties/type/const§4.2
matchingMode required, enum: "pairs", "classification"Schema-enforcedmatching.schema.json: /allOf/1/required, /properties/matchingMode/enum(none)
pairs mode: pairs[] required, minItems: 2, each {item,match} required, additionalProperties: falseSchema-enforcedmatching.schema.json: /allOf/1/then/{required,properties/pairs}§7.1 (closed object disallows x- extensions inside)
pairs mode: categories MUST NOT be presentSchema-enforcedmatching.schema.json: /allOf/1/then/not/required[*]="categories"(none)
classification mode: categories[] required, minItems: 2, each {label,items} required (items.minItems: 1), additionalProperties: falseSchema-enforcedmatching.schema.json: /allOf/1/else/{required,properties/categories}§7.1
classification mode: pairs MUST NOT be presentSchema-enforcedmatching.schema.json: /allOf/1/else/not/required[*]="pairs"(none)
distractors[*] non-empty stringsSchema-enforcedmatching.schema.json: /allOf/1/properties/distractors/items/minLength(none)
allowPartialCredit boolean, default true (schema-declared)Schema-enforced (type)matching.schema.json: /allOf/1/properties/allowPartialCredit(none)
Consumers MUST randomize the choice pool (matches + distractors)Advisory + runtime obligationNORMATIVE.md §5.6§5.6
Consumers MUST randomize row order in classification modeAdvisory + runtime obligationNORMATIVE.md §5.6§5.6

9.11 ordering

RuleTierSourceNORMATIVE §
type is "ordering"Schema-enforcedordering.schema.json: /allOf/1/properties/type/const§4.2
sourceText required, minLength: 1Schema-enforcedordering.schema.json: /allOf/1/required, /properties/sourceText/minLength(none)
items required, minItems: 2, each minLength: 1Schema-enforcedordering.schema.json: /allOf/1/required, /properties/items(none)
distractors[*] non-empty strings, default [] (schema-declared)Schema-enforced (item type)ordering.schema.json: /allOf/1/properties/distractors(none)
scoringMode enum: "strict", "kendall" (when present)Schema-enforcedordering.schema.json: /allOf/1/properties/scoringMode/enum(none)
scoringMode default: "strict" for orderingUnit:"word", "kendall" for "sentence"/"paragraph"Advisory (description prose; no JSON Schema literal default)ordering.schema.json: /allOf/1/properties/scoringMode/description(none)
orderingUnit enum: "word", "sentence", "paragraph", default "word" (schema-declared; advisory display hint)Schema-enforced (enum)ordering.schema.json: /allOf/1/properties/orderingUnit/{enum,default}(none)

9.12 placement

RuleTierSourceNORMATIVE §
type is "placement"Schema-enforcedplacement.schema.json: /allOf/1/properties/type/const§4.2
placementUnit, passage, placements requiredSchema-enforcedplacement.schema.json: /allOf/1/required(none)
placementUnit enum: "sentence", "paragraph", "sectionLabel", default "sentence" (schema-declared)Schema-enforced (enum)placement.schema.json: /allOf/1/properties/placementUnit/enum(none)
passage minLength: 1, MUST contain at least one @@@N markerSchema-enforcedplacement.schema.json: /allOf/1/properties/passage/{minLength,pattern}(none)
placements minItems: 1, each {gap >= 1, item.minLength >= 1}, additionalProperties: falseSchema-enforcedplacement.schema.json: /allOf/1/properties/placements/items§7.1 (closed)
Every placements[*].gap references a @@@N marker present in passageDomain-validator-enforced (ERROR)validate_course.py: validate_placement(none)
No duplicate gap values within placements[]Domain-validator-enforced (ERROR)validate_course.py: validate_placement(none)
@@@N markers SHOULD be sequential starting at 1Domain-validator-enforced (WARN)validate_course.py: validate_placement(none)
placementUnit: "paragraph" — marker SHOULD stand alone in its paragraphDomain-validator-enforced (WARN)validate_course.py: validate_placement(none)
placementUnit: "sectionLabel" — marker SHOULD be at the start of a paragraph followed by a spaceDomain-validator-enforced (WARN)validate_course.py: validate_placement(none)
Extra @@@N markers without a placements[] entry are valid decoy gaps (TOEFL Sentence Insertion variant)Advisoryplacement.schema.json: /allOf/1/properties/placements/description, question-types-reference.md §11(none)
distractors[*] non-empty stringsSchema-enforcedplacement.schema.json: /allOf/1/properties/distractors/items/minLength(none)
Consumers MUST randomize the choice pool (placements items + distractors)Advisory + runtime obligationNORMATIVE.md §5.6§5.6

10. Reserved and unknown types

The 7 reserved question types — association, hotspot, graphicGapMatch, graphicAssociate, graphicOrder, fileUpload, mediaPromptedEssay — are declared in question-base.schema.json’s enum but have no per-type schemas in 1.0. Their handling is normative under NORMATIVE.md §6 (the fallback contract):

RuleTierSourceNORMATIVE §
Reserved-type discriminator MUST be accepted by consumersSchema-enforcedquestion-base.schema.json: /properties/type/enum§5.5, §6.1
Reserved-type question MUST satisfy question-base.schema.json (type, globalId, prompt required; points validated against the base type/range when present, defaulting to 1.0 schema-declared)Schema-enforced (required fields + type/range on points)question-base.schema.json: /required=["type","globalId","prompt"], /properties/points/{type,minimum,default}; validator dispatches reserved types to question-base.schema.json§6.3
Consumer MUST preserve every member of reserved-type question objects across read/write cycles (semantic preservation; key order is producer-discretion per §6.2)Advisory (round-trip preservation — runtime obligation, not document-validity)NORMATIVE.md §6.2, §6.4§6.2, §6.4
Consumer MUST NOT silently drop reserved-type questions from questions[]AdvisoryNORMATIVE.md §6.2§6.2
Consumer MUST treat reserved-type earned points as 0 (max still counts)Advisory (runtime obligation)NORMATIVE.md §6.2§6.2
Consumer MUST report the unsupported question to the user at import (UI banner / log / returned warning)AdvisoryNORMATIVE.md §6.2§6.2
Consumer SHOULD render a non-interactive placeholder naming the typeAdvisoryNORMATIVE.md §6.2, ACCESSIBILITY.md §7§6.2, §12
Consumer SHOULD disable navigation gating for unsupported questionsAdvisoryNORMATIVE.md §6.2§6.2
Producer SHOULD NOT emit reserved types in cross-implementation distributionAdvisoryNORMATIVE.md §6.3§6.3
Producer SHOULD use the published reserved name exactly (hotspot, not Hotspot); SHOULD document tool-specific extensions in IMPLEMENTATIONS.md / READMEAdvisoryNORMATIVE.md §6.5§6.5
Producer: emitting a discriminator value not listed in question-base.schema.json’s enum is non-conforming at 1.0 — the schema rejects it; the reference validator surfaces it with a friendlier message naming the allowed valuesSchema-enforced + Domain-validator-enforced (ERROR)question-base.schema.json: /properties/type/enum; validate_course.py (per-type question dispatch)§6.1
Consumer (1.0-only) reading a 1.x+ document with a type discriminator unknown to the consumer: apply the §6 fallback (preserve in full, treat earned points as 0, render placeholder, report to user) — do NOT reject the documentAdvisory (runtime / forward-compat obligation, not document-validity)NORMATIVE.md §6.1, §6.2, §6.4§6.1, §6.2

The two rows above are not in conflict: the producer-validity row describes the 1.0 strict-validator behavior on a document whose type enum is exhausted at 1.0 (the schema and the reference validator agree it’s a malformed 1.0 document). The consumer-import row describes the runtime obligation a 1.0-only consumer carries when it ingests a 1.x+ document whose newer type it does not recognize — there, NORMATIVE §6 binds the consumer to graceful fallback rather than rejection. A 1.0 consumer cannot validate a 1.x+ document under a 1.0 schema and therefore SHOULD NOT use schema validation as the ingest gate when reading future-minor content; consumer-side ingest is governed by §6, not by question-base.schema.json.


11. Question Sets (flat artifact)

A documentType: "questionSet" document is a flat questions list with no course hierarchy. Required root fields apply (see §3) plus:

RuleTierSourceNORMATIVE §
title required, minLength: 1Schema-enforcedquestion-set.schema.json: /required[*]="title", /properties/title/minLength§3.2
language required at rootSchema-enforcedquestion-set.schema.json: /required[*]="language"§12.1
questions[] required (may be empty)Schema-enforcedquestion-set.schema.json: /required[*]="questions"(none)
sourceQuestionSetId, when present, matches the RFC 4122 UUID pattern (any version; shape-only validation)Schema-enforcedquestion-set.schema.json: /properties/sourceQuestionSetId/pattern(none)
version matches ^[0-9]+(\.[0-9]+){0,2}$, default "1.0" (schema-declared)Schema-enforced (pattern)question-set.schema.json: /properties/version/pattern(none)
Each questions[*] validates against its per-type schema (per-question dispatch)Domain-validator-enforced (ERROR on schema failure or unknown discriminator)validate_course.py (per-type question dispatch), validate_question_set_flat§5.1, §5.3

12. Cross-cutting

12.1 HTML safety profile

HTML appears in two fields: ContentItem.html and SignpostItem.customHtml. The full normative profile is in HTML_SAFETY.md. The reference validator’s HTML checks live in validate_course.py: validate_html_content and mirror that profile.

SurfaceSeverity (HTML_SAFETY.md §8)Validator function
Forbidden elements (<script>, <iframe>, <form>, <input>, <button>, <style>, <link>, <meta>, <base>, <svg>, <math>, etc.)ERROR — consumer MUST rejectvalidate_html_content (HTML_FORBIDDEN_TAGS)
Event-handler attributes (onclick, onload, onerror, …)ERRORvalidate_html_content (attr_name.startswith("on"))
Form-submission attributes (srcdoc, formaction, …)ERRORvalidate_html_content
javascript: or vbscript: URL in any URL-bearing attributeERRORvalidate_html_content
expression(...) / javascript: inside style CSS valueERRORvalidate_html_content
data: URL in any URL-bearing attribute (including <img src>)WARNvalidate_html_content
Other forbidden URL schemes (blob:, file:, chrome:, ftp:, ws:, gopher:, view-source:)WARNvalidate_html_content
tel: URL (consumer-policy gated; see ITEM_PATTERNS.md §3)WARNvalidate_html_content
Unknown element (not in HTML_ALLOWED_TAGS, not in forbidden list)WARN — strip while preserving textvalidate_html_content
Unknown attribute on an allowed element (outside §3 allowlist)WARN — strip the attributevalidate_html_content
CSS property outside the HTML_SAFETY.md §3.4 allowlistWARN — strip the propertyvalidate_html_content
<a target="_blank"> without rel="noopener noreferrer"WARN — consumer MUST normalizevalidate_html_content
<img> without alt attributeWARN — empty alt="" permitted for decorative imagesvalidate_html_content
<video>/<audio> with autoplay or loopWARN — producer MUST NOT emit; consumer SHOULD ignorevalidate_html_content

A conforming consumer MUST sanitize HTML before render regardless of producer claims (HTML_SAFETY.md §5).

12.2 Accessibility preservation

ACCESSIBILITY.md defines two layers: a base-conformance preservation floor that binds every consumer, and an opt-in Accessibility Profile claim that binds delivery.

Round-trip preservation (base conformance, NORMATIVE.md §12.1):

RuleTierSourceNORMATIVE §
alt on <img> MUST round-tripAdvisory (runtime / round-trip obligation)NORMATIVE.md §12.1§12.1
<track> elements (incl. kind, src, srclang, label, default) MUST round-trip on <video>/<audio>AdvisoryNORMATIVE.md §12.1§12.1
lang and dir attributes on HTML-bearing elements MUST round-tripAdvisoryNORMATIVE.md §12.1§12.1
Document-root language MUST round-tripSchema-enforced (required) + runtime preservation obligationcourse.schema.json: /required[*]="language"; NORMATIVE.md §12.1§12.1
Document-root supportLanguage MUST round-trip when present (including explicit null)AdvisoryNORMATIVE.md §12.1§12.1
Reserved-type questions MUST round-trip with any accessibility metadata they carryAdvisoryNORMATIVE.md §6.4, §12.1§6.4, §12.1
Extension-preserving consumers (§7.4) SHOULD round-trip x--namespaced extension members carrying accessibility dataAdvisoryNORMATIVE.md §12.1§12.1

Opt-in Accessibility Profile delivery obligations (binding only when claimed): see ACCESSIBILITY.md §§2–8. Not duplicated here.

Validator severity for accessibility issues at the current baseline (ACCESSIBILITY.md §8):

IssueSeverityValidator function
Missing alt on <img>WARNvalidate_html_content
<video> without <track kind="captions"|"subtitles">WARN (current baseline; promotion to ERROR under the --accessibility flag is targeted for 1.0 final)validate_html_content (post-pass scan for <video>…</video> blocks)
<iframe>, <script>, event handlersERRORvalidate_html_content
Missing language at document rootERROR (schema-enforced)course.schema.json / question-set.schema.json required
Reserved-type question without titleNOTE(advisory; not currently surfaced)

12.3 Randomization requirements

NORMATIVE.md §5.6 binds two surfaces. These are consumer rendering obligations, not document-validity rules — a document is conforming whether or not consumers randomize it. Listed here so implementers know what they MUST do at render time:

  • Choice pool for matching (pairs/classification) and placement MUST be presented in randomized order.
  • Row order in matching classification mode MUST be randomized.
  • The randomization algorithm and any seeding strategy are consumer-defined.
  • Exemptions: multipleChoice (per-question shuffleOptions instead), matching pairs rows, ordering source tiles.

12.4 Extensions (x- members)

Extension rules from NORMATIVE.md §7. Round-trip preservation by extension-preserving consumers is a runtime obligation, not a document-validity rule.

RuleTierSourceNORMATIVE §
x- keys MAY appear on root + Course/Unit/Lesson/Item/QuestionAdvisory (schemas omit additionalProperties: false on these objects)NORMATIVE.md §7.1§7.1
x- keys MUST NOT appear on closed objects (matching.pairs[*], matching.categories[*], placement.placements[*])Schema-enforcedmatching.schema.json / placement.schema.json (additionalProperties: false)§7.1
Producer MUST NOT emit non-extension fields whose name begins with x-AdvisoryNORMATIVE.md §7.1§7.1
Producer MUST NOT emit an extension under a namespace it does not ownAdvisoryNORMATIVE.md §7.2§7.2
Extensions are strictly additive — removing every x- member MUST leave a conforming document with equivalent learner-facing meaningAdvisoryNORMATIVE.md §7.3§7.3
Consumer MUST NOT reject documents solely for x- members or interpret members outside its own namespaceAdvisoryNORMATIVE.md §7.4§7.4
Extension-preserving consumers SHOULD round-trip unrecognized x- members on the same objectAdvisory (round-trip behavior)NORMATIVE.md §7.4§7.4

12.5 Versioning and URL stability

NORMATIVE.md §8 binds publication-side guarantees. Not document-validity rules per se, but consumers SHOULD enforce them when resolving $schema:

  • $schema URL identifies the specific publication; specVersion identifies the contract version (§4.6, §4.7, §8.4).
  • /X.Y/ paths are reserved for accepted final releases; /X.Y-rc.N/ paths are immutable once published; rc.N → final adoption is an explicit re-export (§8.1, §8.3).
  • A document declaring specVersion: "1.0" with $schema at /1.0-rc.N/ validates against /1.0-rc.N/ and is not required to validate against /1.0/ (§8.4).

12.6 Discriminator casing

Reiteration of §3, §7, §8: conforming consumers MUST reject non-canonical casings on documentType, item type, and question type (NORMATIVE.md §4.2, §5.3). The schemas enforce these via const / enum. The reference validator additionally provides lenient migration paths (PascalCase → camelCase warnings, casing-tolerant documentType dispatch) that are disabled under --strict.

12.7 globalId uniqueness

RuleTierSourceNORMATIVE §
globalId values unique across all entities in a document (Units, Lessons, Items, Questions share one namespace; comparison case-insensitive)Domain-validator-enforced (ERROR)validate_course.py: _collect_duplicate_global_id_errors (course and questionSet paths)§4.4

JSON Schema cannot express cross-entity uniqueness across nesting levels, so this rule is domain-validator-only. Reference fields that point at a globalId (contentItemId, relatedItemIds) are references, not declarations, and are exempt. Conformance fixture: tests/invalid/40-duplicate-global-id.json.


13. Conformance note

The catalog tiers describe what the reference validators enforce today under --strict (§1.3). A gap between that behavior and the normative sources is a validator defect, not a relaxation of the contract.

Producers MUST emit documents that satisfy every Schema-enforced rule and every Domain-validator-enforced (ERROR) rule. Producers SHOULD additionally honor Domain-validator-enforced (WARN) rules; the validator’s warnings flag suspect-but-not-rejected content that authors typically want to fix.

Consumers MUST reject documents that fail any Schema-enforced or Domain-validator-enforced (ERROR) rule, with explicit exceptions where a rule distinguishes producer-emission from consumer-import. This matches NORMATIVE.md §3.2’s strict-producer / lenient-consumer split — a producer that omits $schema is non-conforming with respect to that document, but a consumer that rejects an otherwise-valid document on the basis of a missing $schema is overly strict. The producer-emission-vs-consumer-import rules are:

  • The $schema rows in §3 — the canonical example: MUST emit, SHOULD tolerate absence.
  • The SubjectCollection closure rules SC-6, SC-7, SC-8 (§15) — a producer MUST NOT emit a violating document (ERROR), but a consumer SHOULD (not MUST) reject one, and MAY instead ingest it with the violations surfaced (NORMATIVE.md §5.7).
  • The alignment-claim vocabulary rule SC-10 (§15) is producer-tier only, and its consumer side is not a weakened rejection — it is the opposite. A producer MUST NOT emit a claim outside the 1.1 vocabulary (ERROR). A consumer encountering a claim value outside that set MUST NOT reject the document, MUST NOT interpret the claim, and MUST preserve the entry verbatim across read/write cycles (NORMATIVE.md §4.10, §5.5). Consumers therefore never apply SC-10 as a rejection criterion at all.
  • Identity-less members (SC-3) are not in this set — a consumer MUST reject those (§3.4).

Domain-validator-enforced (WARN) rules describe sanitization, accessibility, or migration-aid behavior — consumers SHOULD surface them but are not required to reject on their basis. NOTE-tier rows are informational only.

Advisory rules carry the RFC 2119 weight stated in the cited section (NORMATIVE.md MUST/SHOULD/MAY, HTML_SAFETY.md §8 severity, ACCESSIBILITY.md §8). Consumers that diverge from advisory SHOULD/MAY rules are non-canonical but not non-conforming. Where a rule is enforced in multiple tiers (schema + validator), satisfying the strictest tier suffices.

A note on JSON Schema format keywords. Several rows above cite format: "uri" or format: "uuid" from the schemas. Under JSON Schema Draft 7, format is an annotation by default — a validator only enforces it when configured with a FormatChecker (or equivalent). The reference validator’s Draft7Validator instance runs without explicit format assertions, so format-only claims are not guaranteed by the schema pass alone. Rows that depend on these formats also cite a regex pattern (for UUIDs on globalId properties) or a domain-validator backstop (validate_course.py: validate_item via is_valid_uuid for contentItemId / relatedItemIds). Implementers re-implementing the validator in other languages should either enable format-assertion in their JSON Schema library or replicate the regex/domain backstops.

A note on JSON Schema default keywords. Several rows above cite a property’s default value (e.g. isGraded defaults to true on quiz, points defaults to 1.0 on questions, placementUnit defaults to "sentence"). Under JSON Schema Draft 7, default is an annotation — most validators (including jsonschema for Python, AJV with default options, etc.) do not apply or enforce it. A producer that omits the property emits a document that validates; a consumer that reads such a document MUST apply the default itself if it wants the documented behavior. The defaults are listed here so implementers know what the spec intends absent an explicit value — they are schema-declared, not schema-enforced. Consumers SHOULD NOT rely on the validator filling in defaults; producers SHOULD emit explicit values when the documented default does not match their intent.

Where a reference validator and a normative document disagree, the normative document wins. Discrepancies should be reported as issues against this spec; the validator is updated to track.


14. Forward-looking deepenings (1.0 final)

(Historical: this section records the deepenings cataloged during the 1.0 cycle. 1.0 has since shipped and 1.1-rc.1 is the current publication; the still-open items below carry forward. Rules for the 1.1 artifact types are in §15§19.)

The inventory pass that produced this catalog (2026-05-24) surfaced eight documented-but-unenforced rules. All eight were closed in the same rc.1-polish session by extending tools/validate_course.py (no schema changes — the closures land in the domain-validator pass). The corresponding rows in the per-type tables above are tagged Domain-validator-enforced rather than Advisory; new invalid conformance fixtures (tests/invalid/21-mcq-no-correct-option.json, 22-mcq-options-points-missing-entry.json, 23-word-bank-cloze-gap-count-mismatch.json, 24-multiple-choice-cloze-index-out-of-bounds.json) pin the ERROR-tier checks. The corpus runs 64/64 under python tools/run_corpus.py (the harness invokes validate_course.py --strict internally on every fixture; the 36 fixtures at the time of that rc.1 pass, plus the two prompt-correction fixtures added in rc.2, plus the per-type / referential-integrity / grading-matrix / globalId-uniqueness expansion added in rc.3).

Three areas remain explicitly forward-looking for 1.0 final or beyond:

  • --accessibility validator flag. The <video> without <track kind="captions"\|"subtitles"> check (§12.2) is WARN at the current baseline. The 1.0-final --accessibility flag promotes it (and related accessibility warnings) to ERROR so tooling that wants to fail-build on accessibility-profile claims can do so.
  • Tag namespace conventions. Optional best-practice tag prefixes (stage:, level:, exam:, …) are described informally in ITEM_PATTERNS.md §1. No schema-level constraint, no validator check; left to convention for 1.0. Referential-integrity validation on objectiveIds is closed at rc.1 (consumers MUST report unresolved IDs per the validator).
  • Reserved-type per-type schemas. The 7 reserved question types (hotspot, association, etc.) validate against question-base.schema.json only (§10). First-class per-type schemas remain deferred to a future minor version (targeted for 2027) and are not part of 1.1.

Future deepenings (a new accessibility rule promoted to ERROR, a new cross-document rule added by 1.1) will surface as new rows in the per-type tables above or as new entries in this section. The published /1.0-rc.2/ and /1.0-rc.3/ schema URLs remain immutable per NORMATIVE.md §8.3; any future closures land at /1.0/ or a later version path.


15. Subject Collection (SC-*)

Rules for documentType: "subjectCollection" documents.

#RuleSourceTierSeverity
SC-1Root triplet $schema/documentType/specVersion present; documentType == "subjectCollection"NORMATIVE §3.2schemaERROR
SC-2globalId, version, title, scope present; scope.subject.id non-emptyNORMATIVE §3.3; schema requiredschemaERROR
SC-3Every tag and objective carries a non-empty id (identity is not optional)NORMATIVE §3.4schemaERROR
SC-4Member ids unique within the document (tags and objectives separately)NORMATIVE §3.4domainERROR
SC-5Tag slug present and unique within the documentschema + domaindomainERROR
SC-6Every tags[].categoryId resolves within categories[] (closure)NORMATIVE §4.9domainERROR
SC-7Every tags[].parentId, when present, is the member id of another tag in the document (parents are member ids, never slugs), and the parent relation is acyclic — no tag is its own parent, and no chain of parentId links returns to a tag already on that chain. The relation therefore forms a forestNORMATIVE §4.9domainERROR
SC-8Every objectives[].tagIds entry resolves within the document’s tags[] (closure)NORMATIVE §4.9domainERROR
SC-9difficultyBand within the enum (Recall/Understand/Apply/Analyze/null)schemaschemaERROR
SC-10externalAlignments[].claim within the 1.1 producer vocabulary (references/alignedTo/covers; assesses/verifiedBy reserved). Producer-tier by design: the schema deliberately leaves claim an open string so consumers can satisfy the §5.5 preserve-unknown-claims rule without a schema failure — the vocabulary binds what a 1.1 producer may emit, never what a consumer accepts. scheme and id non-empty (schema)NORMATIVE §4.10, §5.5domain (producer-tier; the reference vocabulary tooling enforces it as an authoring/emission gate) + schema (scheme/id)ERROR (emission)
SC-11Category ids unique within the documentschema + domaindomainERROR
SC-12aliases, when present, is an array (not a bare string)schemaschemaERROR
SC-13license populated with a concrete value on documents intended for distribution ("unspecified" only for private drafts)NORMATIVE §4.11advisoryWARN
SC-14Objective text reads as a can-do capability (active verb, completes “…be able to:”)subject-collection-reference.md §5advisoryNOTE

Reference implementation note: SC-4…SC-8, SC-11, SC-12 are implemented by the vocabulary-document validator in the reference tooling; SC-1 and SC-2’s root-triplet checks are schema-enforced.


16. Curriculum Pack (CP-*)

Rules for documentType: "curriculumPack" documents.

#RuleSourceTierSeverity
CP-1Root triplet present; documentType == "curriculumPack"NORMATIVE §3.2schemaERROR
CP-2packMode is "manifest" or "bundle"schemaschemaERROR
CP-3collectionRefs[] entries carry non-empty globalId; contentRefs[] entries carry non-empty type + id (the referenced document’s type-directed identity, NORMATIVE §4.4: course→sourceCourseId, questionSet→sourceQuestionSetId, else root globalId). contentRefs[].type is a producer-closed vocabulary — course, questionSet, or glossary (a SubjectCollection is referenced via collectionRefs; curriculumPack is prohibited — no pack nesting, §4.4). Like alignment claim, it binds producers only and is left schema-open (§5.8): a producer emitting any other value is non-conforming; a consumer MUST NOT reject an unrecognized type (§5.4), treating the ref as unresolvableNORMATIVE §4.4, §5.8schema (type/id non-empty) + domain (producer type-vocabulary)ERROR (emission)
CP-4Step shape: id non-empty and unique within the pack; kind within the five-value enum; label non-empty; year/term/weekOfTerm/durationLessons integers ≥ 1; the contentRef key present on every step (null = unauthored slot); selector, when present, uses the node-globalId-bearing grammar unit:/lesson:/item: (interior nodes retain globalId; only the document root’s identity is type-directed)curriculum-pack-reference.md §4.1–§4.3schema (per-step) + domain (id uniqueness)ERROR
CP-5pacing present when sequence[] is non-empty; years/lessonsPerWeek ≥ 1; weeksPerTerm, when declared, has termsPerYear entries ≥ 1; teachingWeeksPerYear, when both present, equals sum(weeksPerTerm)curriculum-pack-reference.md §5domainERROR
CP-6Position within the frame: year ≤ pacing.years; term ≤ termsPerYear; weekOfTerm within the term; a step’s week span (ceil(durationLessons/lessonsPerWeek) weeks from its start) stays inside its termcurriculum-pack-reference.md §4.1, §5domainERROR
CP-7Term capacity: per (year, term), Σ durationLessons ≤ weeksPerTerm[term−1] × lessonsPerWeekcurriculum-pack-reference.md §5domainERROR
CP-8dependsOn: ids exist; no self-reference; every edge points at a step strictly earlier in the (year, term, weekOfTerm) timeline, or in the same week and earlier in document order (within a week, document order is the schedule)curriculum-pack-reference.md §4.4domainERROR
CP-9Checkpoint presence: a step of kind assessment/mock carries a checkpoint object; any other kind carries null or omits the keycurriculum-pack-reference.md §4.2, §4.5domainERROR
CP-10Checkpoint shape: kind formative/summative (mock ⇒ summative); format non-empty; scope listed/allTaughtToDate; listed ⇒ assessesObjectiveIds non-empty; allTaughtToDate ⇒ assessesObjectiveIds == []curriculum-pack-reference.md §4.5domainERROR
CP-11Taught-before-used: every listed assessed objective, and every objective on a review step, is first taught (a teaching step’s objectiveIds) strictly earlier in the timelinecurriculum-pack-reference.md §4.5domainERROR
CP-12Bill of materials: every non-null step contentRef appears in root contentRefs[] with a matching typecurriculum-pack-reference.md §3, §4.3domainERROR
CP-13Coverage block: collectionGlobalId non-empty and present in collectionRefs[]; assertions[] within the two-value 1.1 vocabulary (unknown assertion strings are errors)curriculum-pack-reference.md §6schema + domainERROR
CP-14With the referenced collection available: every objectiveId/tagId used anywhere (steps, checkpoints, exemptions) resolves to a collection member; declared coverage assertions hold over the non-exempt member set. Without it, validators skip these checks visibly rather than failcurriculum-pack-reference.md §6domain (collection-resolved)ERROR
CP-15Bundle closure: a bundle carries embedded {collections, content} and a manifest does not; every ref (root and step-level) resolves to an embedded document of the matching documentType, identity verbatim; a course selector resolves to a node of that kind inside the embedded coursecurriculum-pack-reference.md §2domain (bundle)ERROR
CP-16A bundle MUST NOT re-mint any embedded document’s type-directed portable identity (root globalId, or a course/questionSet’s sourceCourseId / sourceQuestionSetId; NORMATIVE §4.4) or its member idscurriculum-pack-reference.md §2consumer obligation — an import behavior, not a static-document check; cataloged in §19(behavioral)
CP-17Advisory hygiene: refs SHOULD pin version (consumers surface mismatches rather than silently substituting); a teaching step SHOULD list objectives; sequence[] SHOULD serialize in timeline order; an unauthored slot SHOULD carry an authoringNote (buffer steps excepted); week occupancy SHOULD stay within lessonsPerWeek; an objective SHOULD meet a formative checkpoint before a summative one; a checkpoint’s effective assessed set SHOULD be non-empty; a review SHOULD revisit no sooner than recyclingPolicy.minSpacingWeeks (default 2) absolute weeks after first teach; a bundle SHOULD embed nothing unreferenced and SHOULD surface pinned-version driftcurriculum-pack-reference.md §2–§7advisoryWARN

Reference implementation note: CP-4…CP-15 and CP-17 are implemented by the pack validator in the reference tooling, whose docstring carries the rule-by-rule list as E01–E15/W01–W10. Embedded-document validity: the embedded block’s documents are typed as generic objects in curriculum-pack.schema.json, so the pack schema does not by itself reject a malformed embedded artifact. CP-15 checks each embedded document’s identity (documentType + its type-directed identity per NORMATIVE §4.4 — a course/questionSet by sourceCourseId/sourceQuestionSetId, a collection/glossary by root globalId) and closure (every ref resolves to an embedded document of the matching type; selectors resolve inside embedded courses). Full validation of each embedded document — against its own schema and its own domain rules, dispatched on the embedded document’s documentType — is performed by the shared emission gate before a bundle is written. Every writer in the reference tooling runs that gate (the bundle assembler, lc_pack.save(), lc_collection.save(), and the pack validator’s own --validate pass over a bundle), so an embedded artifact that is schema-valid but domain-invalid is rejected on the producer path rather than packaged. This is a producer-path guarantee of the reference tooling, not a constraint the pack schema itself expresses.


17. Glossary (GL-*)

Rules for documentType: "glossary" documents.

The declared-inventory rules follow a single severity principle: a false claim is an ERROR; a missing claim is a WARN. Gloss-rule and inventory ERRORs are interchange-boundary rules — they gate what a producer may emit, and a work-in-progress document inside an authoring tool may legitimately fail them mid-authoring (glossary-reference.md §2).

#RuleSourceTierSeverity
GL-1Root triplet present; documentType == "glossary"; globalId/version/title/language non-empty; entries[] presentNORMATIVE §3.2; schemaschemaERROR
GL-2translationLanguages, when present, is an array of unique, non-empty, BCP 47-shaped stringsglossary-reference.md §1schema + domainERROR
GL-3Entry shape: id non-empty and unique; term non-empty; kind within word/phrase; typed optional fields (strings non-empty incl. firstMention, linkAutomatically boolean, examples[].text non-empty); translation maps (translations, definitionTranslations, example translations) keyed by BCP 47 tags with non-empty valuesglossary-reference.md §2schema (per-field) + domain (id uniqueness)ERROR
GL-4The gloss rule: every entry carries a definition, or at least one translations value, or at least one definitionTranslations value (interchange-boundary)glossary-reference.md §2domainERROR
GL-5Inventory membership: when translationLanguages is declared (non-empty), every translation key used anywhere in the document is a member of it. Language tags are compared case-insensitively (BCP 47 §2.1.1): a document declaring es and using the key ES satisfies this rule, and satisfies GL-6 as well. Absent and empty declarations are equivalent — no claim is made, and GL-5/GL-6 are vacuous (GL-8 then applies)glossary-reference.md §1domainERROR
GL-6No false claims: every declared translationLanguages value is used by at least one translation value somewhere in the document, again compared case-insensitively. The same case-insensitive key governs GL-2’s uniqueness check: es and ES in one translationLanguages array are a duplicate declaration, not two languagesglossary-reference.md §1domainERROR
GL-7Duplicate matching surface (term/otherForms) across entries. Surfaces are compared after Unicode normalization to NFC followed by casefold() — the Unicode caseless-matching operation, not simple lowercasing, so German Straße and STRASSE are one surface and canonically equivalent compositions of an accented character do not read as two — legal (senses are separate entries) but auto-linking consumers must disambiguate. This warning is routinely accepted, not fixed: two senses of one spelling are the model working as designed, so GL-7 exists to inform disambiguation, not to be driven to zeroglossary-reference.md §2advisoryWARN
GL-8Translations are present but the document declares no translationLanguages (missing claim — fires on imports; documents authored against the declaration always carry it)glossary-reference.md §1advisoryWARN
GL-9An entry’s otherForms repeats its own term (redundant matching surface)glossary-reference.md §2advisoryWARN
GL-10Language-code lint: a declared translationLanguages value (or, absent a declaration, a used key) whose 2-letter primary subtag is not ISO 639-1, or whose 2-letter region subtag is not ISO 3166-1 alpha-2. 3-letter primary subtags and script subtags are shape-checked onlyglossary-reference.md §2advisoryWARN
GL-11Absent firstMention is legal — never an error or warning (imported glossaries carry no lesson provenance; the entry renders as course-scoped background vocabulary). Surfaced only as a maturity signal (the validator’s success report counts entries with/without it). A firstMention naming a lesson the importer does not hold is treated as absentglossary-reference.md §2.1advisoryNOTE

Reference implementation note: GL-1…GL-10 are implemented by the glossary validator in the reference tooling (rule ids GE01–GE05/GW01–GW04 in its docstring: GL-1↔GE01, GL-2↔GE01, GL-3↔GE02, GL-4↔GE03, GL-5↔GE04, GL-6↔GE05, GL-7↔GW01, GL-8↔GW02, GL-9↔GW03, GL-10↔GW04); GL-11 is NOTE-tier — implemented as the maturity report’s firstMention n/total line, not as a GE/GW rule.


18. 1.1 deepenings — root and course (RD-*, CO-*)

Rules that deepen the root document (§3) and the course artifact (§4) as of 1.1. They add no new fields; they enforce obligations NORMATIVE already carried.

18.1 Root document, all artifact types

#RuleSourceTierSeverity
RD-1specVersion$schema version agreement: when both fields are present, specVersion is well-formed 1.x, and $schema is an lc-json.org versioned URL, the URL’s version path MUST match specVersion on major.minor (patch releases share the minor’s URL per §8.1: 1.0.1 legitimately pairs with /1.0/ or /1.0-rc.N/). Field absence is governed separately by §3.2 and is not re-reported hereNORMATIVE §8.4domainERROR
RD-2Unrecognized non-x- fields are surfaced informationally, one line per stray, stating the §5.4 consequence (consumers may ignore; free to drop on re-export; a future spec version may claim the name) and the remedy (x-<vendor>-… for guaranteed preservation, §7.4). x- members are listed separately as extension members with preservation expected. Never rises above NOTE — §5.4 makes these fields legal; this surfaces silent-drop risk and catches typos (titel). Skips: closed objects (already ERROR by contract), members of reserved/unknown-type questions (§6 preserves them wholesale), and the interior of x- subtrees (the namespace owner’s business)NORMATIVE §5.4, §7 (explains existing rules; adds none)advisoryNOTE

A document that pins a $schema publication other than the validator’s own is not an error — the reference validator surfaces it as a NOTE, because validating an rc.1 document with an rc.1-tagged validator is the normal case and validating it with a later one is legitimate.

18.2 Course-level

#RuleSourceTierSeverity
CO-1Every id in courseObjectiveIds and unit/lesson objectiveIds resolves within the course’s own objectives[] pool (carried copies embedded; no dangling vocabulary references)NORMATIVE §4.9domainWARN for specVersion 1.0 documents (the 1.0 WARN-tier objective-reference-integrity posture, unchanged); ERROR for documents declaring specVersion ≥ 1.1 (this restates §4.9’s MUST)
CO-2license/canonicalUrl/derivedFrom validate against the shared publication field groupschema (composed)schemaERROR
CO-3glossaryRefs, when present at course/unit/lesson, is an array of non-empty glossary globalId strings (placement encodes scope; no {globalId, scope} object form; junctions stop at Lesson)glossary-reference.md; course.schema.jsonschemaERROR
CO-4A glossaryRefs entry that resolves to no glossaries[] pool copy and no held document (a dangling ref) — consumers SHOULD surface it, naming the missing globalId, SHOULD preserve it for later binding, and MUST NOT fail the importglossary-reference.mdadvisory (consumer-surfaced)WARN
CO-5glossaries[] pool hygiene: a pool entry no glossaryRefs references is a stowaway; the pool SHOULD hold one copy per referenced glossary, deduplicated by globalId. Per-copy validity is NOT schema-enforced: the glossaries[] items are typed as generic objects so a carried copy can travel whole (a $ref to the full glossary schema would collide with the pre-port/pool-copy tolerance for an omitted $schema/specVersion). A producer SHOULD ensure each pool copy is a valid glossary; a consumer MAY re-validate copies with the glossary validator. The reference course validator surfaces the dangling-ref (CO-4) and stowaway conditions but does not re-run the GL-* rules on each copyglossary-reference.md; course.schema.jsonadvisory (stowaway) + producer-obligation (per-copy validity)WARN (stowaway)

19. Consumer-side obligations not expressible as document checks

Cataloged for implementers; these are behavioral, tested by round-trip/import tests rather than document validation.

  • Member reconciliation by id — no duplication; membership recording for tags; link-never-overwrite for non-owned objectives; verbatim creation of absent members (NORMATIVE.md §5.7).
  • Display-collision handling never merges members or reassigns ids (NORMATIVE.md §5.7).
  • Clean rejection of unimplemented documentTypes, naming the type (NORMATIVE.md §5.1).
  • Preservation of unknown alignment-claim values, and of unrecognized fields inside sequence[] steps, across read/write cycles (NORMATIVE.md §5.5; curriculum-pack-reference.md §4 — the step shape itself is firm as of 1.1).
  • Preservation of sequence[] array order — within a week, document order is the schedule (curriculum-pack-reference.md §4.4).
  • Emission of the contentRef key even when null: serializers that drop null values must exempt it (curriculum-pack-reference.md §4.1).
  • Glossary entry reconciliation by id on re-import — update, never duplicate; ids preserved verbatim, never re-minted (NORMATIVE.md §3.4/§5.7 as extended to glossary entries).
  • firstMention handling on import: a value naming a lesson the importer does not hold is treated as absent (never an error); an importer that regenerates lesson GlobalIds MUST remap firstMention on glossaries imported alongside (glossary-reference.md §2.1).
  • Bundle re-mint prohibition (CP-16): a consumer importing a bundle MUST NOT re-mint any embedded document’s identity (root globalId or a course/questionSet’s sourceCourseId/sourceQuestionSetId) or member ids — an import-behavior obligation, not a static-document check (curriculum-pack-reference.md §2).