# Course Authoring Guide

> See [glossary.md](glossary.md) for assessment terminology (stem, choice, key, distractor, explanation, item bank, question pool, question-slot, etc.).

This document teaches an AI assistant how to create courses for the Course App platform. Courses are built in two layers: a **question bank** (the "what gets asked") and a **course** (the "how it's taught"). Both are written as composable TypeScript DSL scripts.

## Architecture Overview

Persistence layout:

- **Question bank** — named collection of questions plus optional **pools** (selector queries over those questions).
- **Course** — metadata (title, description, mode, passing score) grouping **topics → sections**. Sections' content trees contain `question-slot` placeholders that reference a pool.
- **Slot resolution** — at first load the server picks N questions from the named pool, persists the pick against the learner's registration, and streams a `ResolvedCourse` to the renderer.

Questions are **never** embedded in section content, and courses do **not** carry a `questions` array. Authoring order for a brand-new course is:

1. POST the question bank DSL → get back a `bankId` and the pool IDs you defined.
2. Write course DSL that references those pools inside `questionSlot(...)` calls.
3. POST the course DSL to create v1.0 of the course.

**Multi-bank courses are fine.** Pool IDs are tenant-unique string references; a single course can draw slots from pools in several banks — the course has no explicit bank pointer. Organising by topic (one bank per topic, plus a "finale" bank) is the recommended pattern for any course with more than ~15 questions.

### Two authoring paths

Once a course exists, there are two ways to publish a new version. Pick the one that matches what you're doing — they're not interchangeable in spirit, even though both end up calling the same publish path internally.

| Path | Endpoint | When to use |
|---|---|---|
| **Operations** (primary) | `POST /t/:slug/api/v1/courses/:id/operations` | Editing an existing course. Fix a typo, add a section, swap a question pool, reorder topics. Submit a typed list of verbs naming exactly the ids you're touching. |
| **Whole-course DSL** | `POST /t/:slug/api/v1/courses/:id/versions` | Initial creation, full rewrites, or DSL imports from another tool. The server diffs against the current head and figures out the operation set internally. |

This guide leads with the whole-DSL form because it's the most direct way to learn the DSL and to author v1.0 of a fresh course. **For every subsequent edit, prefer operations** — they're explicit about intent ("I am replacing this section" vs "I am adding this topic"), they validate ids precisely, and they produce a cleaner audit trail in `operation_log`. See **Part 4 — Editing an existing course** below and the full operation reference in [`course-versioning.md`](course-versioning.md#operation-based-authoring).

### DSL execution sandbox

Bank, course, and framework scripts run in a restricted JS interpreter. Write **literal** arrays of questions/sections — do not use runtime generators such as `Array.from({length: N}, ...)`, factory loops, or IIFEs that close over outer state. Available helpers are the imported builders plus a small set of pure statics: `Array.from`, `Array.of`, `Array.isArray`, `Object.keys/values/entries/fromEntries/assign`, `Math.max/min/floor/ceil/round/abs`, `JSON.stringify/parse`, `Number.isFinite/isInteger`, `String.fromCharCode`. When in doubt: enumerate.

```
question_banks ── questions (N) ── pools (selectors) ◄──┐
                                                        │
courses ── topics (N) ── sections (N)                   │
                           │                            │
                           └── content.kids[].questionSlot(slotId, rule)
                                                        │
                                 slot_resolutions (per-registration pick)
```

## Rich Text (Markdown in Strings)

All text strings in the DSL support **markdown formatting**. The renderer parses markdown when it's present — plain strings pass through unchanged.

### Inline Formatting

```typescript
// Bold / italic / code
bankRadioQuestion("q1", "What does the **HTTP** protocol stand for?", [...], "a");

// Links in lesson body
lessonParagraph("HTTP Methods", "See [MDN](https://developer.mozilla.org) for details.");
```

### Supported Syntax

| Syntax | Renders As |
|--------|-----------|
| `**bold**` | **bold** |
| `*italic*` | *italic* |
| `` `code` `` | `code` |
| `[text](url)` | clickable link |
| `- item` or `* item` | unordered list |
| `1. item` | ordered list |
| blank line | paragraph break |

### Multi-Line Content

```typescript
lessonParagraph(
  "HTTP Methods",
  "The most common HTTP methods are:\n\n- **GET** — retrieve\n- **POST** — create\n- **PUT** — replace\n- **DELETE** — remove\n\nEach method has defined semantics.",
);
```

---

## Part 1 — Authoring a Question Bank

### Element Structure (`CourseEl`)

Every rendered element follows this shape:

```typescript
interface CourseEl {
  tag: string;                    // Element type
  attr?: Record<string, unknown>; // Attributes
  kids?: (CourseEl | string)[];   // Children
  interaction?: Interaction;      // Only on question elements
}
```

Bank builders produce `QuestionDefinition` objects (shown below), not `CourseEl`s. The server wraps the content tree and stores them in the bank.

### Atoms (Primitives)

Used inside custom question content if you need to hand-roll one. Most of the time the `bank*Question` molecules below cover what you need.

| Function | Purpose |
|----------|---------|
| `questionStem(content)` | Question stem. `content` is a markdown string or an array of mixed strings and media elements for multi-part stems (e.g. `[text, image(src, alt), text]`). |
| `image(src, alt)` | Inline image element — use inside `questionStem()` or other content containers. |
| `choice(content, id)` | Answer option. `content` is a markdown string, a single media element, or an array mixing the two. |
| `choiceLabel(text, id)` | Text-only answer option (sugar over `choice`) |
| `choiceImage(src, alt, id)` | Image-only answer option (sugar over `choice`) |
| `feedbackCorrect(text)` | Green success message |
| `feedbackIncorrect(text)` | Red error message |
| `hintToggle(text)` | Collapsible hint |
| `sectionHeading(text, level?)` | Heading (default level 2) |
| `progressIndicator(current, total)` | Progress bar |
| `explanationText(text)` | Detailed explanation paragraph |
| `divider(margin?)` | Horizontal separator |

### Bank Question Molecules

All ten bank builders return `QuestionDefinition`. First arg is always a bank-unique `id` — this is the ID you reference from pool selectors.

Common final-arg shape:

```typescript
interface BankQuestionOpts {
  scored?: boolean;        // default true (false for long-text & likert)
  retryable?: boolean;
  gating?: boolean;
  tags?: string[];                 // used by byTagsSelector
  competencyIds?: string[];        // used by byCompetenciesSelector
  contentRefs?: Array<{ sectionId: string; anchorId?: string }>;
}
```

#### Non-scored questions (`scored: false`)

Use `scored: false` for surveys, reflections, opinion polls, and self-check prompts — anything where you want to collect a response but not judge it as correct/incorrect. `bankLikertQuestion` and `bankLongTextQuestion` default to `scored: false`; any other bank builder can be made unscored by passing `scored: false` in `opts`.

Effects of `scored: false`:

- The renderer submits the response and the server persists it (`learner_responses.is_correct = NULL`, xAPI `experienced` verb instead of `answered`).
- No correct/incorrect feedback UI; no Try Again button. A subtle "Response recorded." acknowledgment is shown after submission.
- The question is excluded from section-completion score thresholds, course completion tallies, and `question_stats` (`p_value`, discrimination, attempts).
- Question pools that target a difficulty (p-value) drop unscored questions before selection.
- Any `correctResponsesPattern` on an unscored question is ignored at runtime.

Explanations (e.g. a reflection prompt showing a teacher note after submission) still render for unscored questions.

#### `bankRadioQuestion(id, stem, choices, correctId, opts?)`
```typescript
bankRadioQuestion(
  "q-http-meaning",
  "What does **HTTP** stand for?",
  [
    { id: "a", text: "HyperText Transfer Protocol" },
    { id: "b", text: "High Tech Transfer Protocol" },
  ],
  "a",
  { tags: ["http", "basics"], competencyIds: ["comp_http_fundamentals"] },
);
```

#### `bankCheckboxQuestion(id, stem, choices, correctIds, opts?)`
```typescript
bankCheckboxQuestion(
  "q-valid-methods",
  "Which are valid HTTP methods?",
  [
    { id: "a", text: "GET" }, { id: "b", text: "POST" },
    { id: "c", text: "FETCH" }, { id: "d", text: "DELETE" },
  ],
  ["a", "b", "d"],
);
```

#### `bankTrueFalseQuestion(id, stem, correctAnswer, opts?)`
```typescript
bankTrueFalseQuestion("q-stateless", "HTTP is stateless.", true);
```

#### `bankTextInputQuestion(id, stem, correctAnswer, opts?)`
```typescript
bankTextInputQuestion("q-404", "What status code means 'Not Found'?", "404", {
  placeholder: "Enter status code…",
});
```

#### `bankLongTextQuestion(id, stem, opts?)`
Essay-style. Unscored by default.
```typescript
bankLongTextQuestion("q-essay-http", "Explain the HTTP request lifecycle:", {
  rows: 6,
  maxLength: 1000,
});
```

#### `bankNumericQuestion(id, stem, correctAnswer, opts?)`
```typescript
bankNumericQuestion("q-add", "What is 2 + 2?", 4, { min: 0, max: 100, step: 1 });
```

#### `bankMatchingQuestion(id, stem, pairs, opts?)`
```typescript
bankMatchingQuestion(
  "q-match-terms",
  "Match each term to its definition:",
  [
    { leftId: "l1", leftText: "HTTP", rightId: "r1", rightText: "Protocol for web" },
    { leftId: "l2", leftText: "DNS",  rightId: "r2", rightText: "Domain name lookup" },
  ],
);
```

#### `bankSequencingQuestion(id, stem, items, correctOrder, opts?)`
```typescript
bankSequencingQuestion(
  "q-http-lifecycle",
  "Put the HTTP lifecycle in order:",
  [
    { id: "a", text: "DNS resolution" },
    { id: "b", text: "TCP handshake" },
    { id: "c", text: "HTTP request" },
    { id: "d", text: "Server response" },
  ],
  ["a", "b", "c", "d"],
);
```

#### `bankImageSelectionQuestion(id, stem, images, correctId, opts?)`
```typescript
bankImageSelectionQuestion(
  "q-icon-db",
  "Which icon represents a database?",
  [
    { id: "img1", src: "/icons/db.png",     alt: "Database icon" },
    { id: "img2", src: "/icons/server.png", alt: "Server icon" },
  ],
  "img1",
);
```

#### `bankLikertQuestion(id, stem, scale, opts?)`
Rating scale. Unscored by default. Pass a number for a numeric scale or a custom label array.
```typescript
bankLikertQuestion("q-confidence", "How confident are you?", 5, {
  endLabels: ["Not confident", "Very confident"],
});

bankLikertQuestion("q-rate", "Rate this course:", [
  { id: "poor", text: "Poor" },
  { id: "fair", text: "Fair" },
  { id: "good", text: "Good" },
  { id: "excellent", text: "Excellent" },
]);
```

### Pool Selectors

Pools are named queries over the bank. Four selector kinds:

| Function | Purpose |
|----------|---------|
| `explicitSelector(questionIds)` | Hand-picked list of question IDs |
| `byTagsSelector(tags, match?)` | Match by tag; `match: "any"` (default) or `"all"` |
| `byCompetenciesSelector(competencyIds, minProficiency?)` | Questions tagged with these competencies |
| `compoundSelector(selectors)` | AND of sub-selectors |

### `pool(id, name, selector)`

Defines a selectable group within the bank:
```typescript
pool("warmup-pool",  "Warmup Questions",  byTagsSelector(["basics"])),
pool("finale-pool",  "Finale Questions",  explicitSelector(["q-http-meaning", "q-stateless"])),
pool("phishing-any", "Phishing Coverage", byCompetenciesSelector(["comp_phishing"])),
```

### `questionBank(meta, body)`

Top-level compositor — always exported as the default of the bank DSL script:
```typescript
export default questionBank(
  { name: "HTTP Basics Bank", description: "Baseline HTTP questions" },
  {
    questions: [ /* bank*Question(...) */ ],
    pools:     [ /* pool(...) */ ],
  },
);
```

### Complete Bank Example

```typescript
import {
  questionBank, pool, byTagsSelector, explicitSelector,
  bankRadioQuestion, bankCheckboxQuestion, bankTrueFalseQuestion,
} from "@course/components";

export default questionBank(
  { name: "HTTP Basics Bank", description: "Baseline HTTP knowledge" },
  {
    questions: [
      bankRadioQuestion("q-meaning", "What does **HTTP** stand for?", [
        { id: "a", text: "HyperText Transfer Protocol" },
        { id: "b", text: "High Tech Transfer Protocol" },
      ], "a", { tags: ["basics", "terminology"] }),

      bankCheckboxQuestion("q-methods", "Which are valid HTTP methods?", [
        { id: "a", text: "GET" }, { id: "b", text: "POST" },
        { id: "c", text: "FETCH" }, { id: "d", text: "DELETE" },
      ], ["a", "b", "d"], { tags: ["basics", "methods"] }),

      bankTrueFalseQuestion("q-stateless", "HTTP is stateless.", true, {
        tags: ["basics", "theory"],
      }),
    ],
    pools: [
      pool("basics-pool", "All basics", byTagsSelector(["basics"])),
      pool("finale-pool", "Finale",      explicitSelector(["q-meaning", "q-stateless"])),
    ],
  },
);
```

---

## Part 2 — Authoring a Course

### About ids in the DSL

The `id` argument on `topic(...)`, `lessonSection(...)`, `assessmentSection(...)`, and friends is an **authoring-time symbol**. It exists so other parts of the same DSL script can cross-reference the entity (a section's `prerequisites: ["lesson-foo"]` resolves against the literal id you wrote here). The server assigns canonical nanoid PKs on persist; your literal slugs are not preserved as database keys. After the POST, fetch `GET /courses/{id}/dsl` (or `/structure` for an id-only view) to read back the assigned ids you'll need for subsequent operations.

Practical implications: two unrelated courses in the same tenant can both declare a section called `lesson-welcome` without colliding, and you should never assume the literal id you wrote is queryable post-publish.

### Organisms

#### `lessonSection(id, title, contentEls, opts?)`

Teaching section. Default role `learning`.
```typescript
lessonSection("lesson-http-intro", "Introduction to HTTP", [
  lessonParagraph("What is HTTP?", "HTTP is the foundation of the web…"),
  lessonParagraph("How it Works",  "When you visit a URL…"),
]);
```

`opts`:
- `prerequisites?: string[]` — IDs of sections that must be completed first
- `role?: string` — named section role preset (`learning` default, `practice`, or custom)
- `overrides?: SectionRoleOverrides` — per-section property tweaks

#### `assessmentSection(id, title, contentEls, opts?)`

Assessment section. Default role `graded`. Content is typically one or more `questionSlot(...)` calls.
```typescript
assessmentSection("assess-http-basics", "HTTP Knowledge Check", [
  questionSlot("warmup", { pool: "basics-pool", count: 3, ordering: "random" }),
  questionSlot("finale", { pool: "finale-pool", count: 2, ordering: "fixed" }),
], { prerequisites: ["lesson-http-intro"], passingScore: 0.7 });
```

`opts` adds `passingScore?: number` — if set, completion becomes `score-threshold` rather than `all-interactions`.

#### `questionSlot(slotId, rule)` — atom

Placeholder that the server resolves to concrete questions. `slotId` must be unique within the section.

```typescript
interface SelectionRule {
  pool: string | string[];     // Pool ID, or ordered list to union before selecting
  count: number;               // How many questions to draw
  ordering: "fixed" | "random";
  seed?: string;               // Override deterministic seeding (default = registrationId + slotId)
}
```

Examples:
```typescript
// Single pool, 3 random questions
questionSlot("warmup", { pool: "basics-pool", count: 3, ordering: "random" });

// Union two pools, take 2 in fixed order
questionSlot("finale", { pool: ["finale-pool", "bonus-pool"], count: 2, ordering: "fixed" });

// Deterministic seed for reproducible sampling in a demo or test
questionSlot("demo", { pool: "basics-pool", count: 1, ordering: "random", seed: "demo-seed-v1" });
```

#### `topic(id, title, sections, opts?)`

Ordered grouping of sections within a course.
```typescript
topic("topic-http", "HTTP Fundamentals", [
  lessonSection(...),
  assessmentSection(...),
], { description: "Core HTTP concepts" });
```

#### `course(meta, topics)`

Top-level compositor — always the default export.
```typescript
course(
  {
    title: "Web Development Basics",
    description: "Learn the foundations of web development.",
    version: "1.0",
    mode: "teaching",                   // "teaching" | "assessment"
    passingScore: 0.7,                  // optional, 0-1
    competencyFrameworkId: "fw_abc123", // optional
  },
  [
    topic("topic-http",  "HTTP",  [...]),
    topic("topic-html",  "HTML",  [...]),
  ],
);
```

> **`meta.version` is informational only.** Whatever you put here is ignored on write — the server computes the real label (a `MAJOR.MINOR` string like `"1.0"` or `"1.4"`) when the course is published or re-versioned. The new course always starts at `"1.0"`. For incremental edits to an existing course, prefer `POST /t/{slug}/api/v1/courses/{id}/operations` (see [Part 4](#part-4--editing-an-existing-course)); for full rewrites, `POST /t/{slug}/api/v1/courses/{id}/versions` accepts a whole DSL. Both endpoints take an optional `bump: "minor" | "major"` field — see [`course-versioning.md`](course-versioning.md) for the full lifecycle.

### Additional Content Molecules

#### `lessonParagraph(heading, body)`
```typescript
lessonParagraph("Overview", "This lesson covers the **HTTP request lifecycle**.");
```

#### `mediaBlock(src, type, caption?)`
```typescript
mediaBlock("/images/http-diagram.png", "image", "HTTP request/response cycle");
```

#### `interactiveVideo(src, cues, opts?)` + `videoCue(time, content, opts?)`

Embed a video that pauses at timestamped cue points to show a question (or any `CourseEl`). Pair with `questionSlot()` so the cue pulls from your bank.

```typescript
interactiveVideo(
  "/videos/http-intro.mp4",
  [
    videoCue(15, questionSlot("check-1", { pool: "intro-pool", count: 1, ordering: "random" }), {
      label: "Check your understanding",
    }),
    videoCue(48, questionSlot("check-2", { pool: "methods-pool", count: 1, ordering: "random" }), {
      pauseOnReach: false, // let the video keep playing while the cue appears
    }),
  ],
  { poster: "/videos/http-intro.jpg", caption: "HTTP in 60 seconds" },
);
```

Cues fire once the first time playback crosses each `time` (seconds). By default the video pauses when a cue fires — the learner presses play to resume. Seeking backwards does not retrigger cues.

#### `sectionResults(sectionId, opts?)`

Renders a learner-facing results card for a graded section: score, pass/fail badge keyed off the section's `score-threshold` rule, threshold display, and (optionally) a per-question breakdown using the same `<CorrectnessMarker>` infrastructure other widgets use.

```typescript
lessonSection("lesson-wrap-up", "Thanks for finishing", [
  lessonParagraph("How you did", "Here's your finale outcome:"),
  sectionResults("assess-finale", { showQuestionBreakdown: true }),
]);
```

Options (all default to sensible values):

- `showScore?: boolean` (default `true`) — display raw + scaled score.
- `showPassFailBadge?: boolean` (default `true`) — display the pass/fail badge. Only renders when the target section has `completionRule.type === "score-threshold"` AND a complete client correctness signal is available; graded mode (server-emitted outcome, `correct === null` on the client) suppresses the badge but keeps the threshold line visible.
- `showThreshold?: boolean` (default `true`) — display the configured threshold so the learner sees what they had to hit.
- `showQuestionBreakdown?: boolean` (default `false`) — list each scored question with its correctness state. Defence-in-depth: never reveals the canonical correct answer when the section role has `stripAnswerKeys: true`.

**Empty-state behaviour**: when the referenced section hasn't been completed yet (or doesn't exist), `<section-results>` renders nothing. Place it on a section that the learner only reaches after the graded section is complete (e.g. a closing page gated by `prerequisites`).

The `sectionId` attr is run through the same publish-time cross-reference rewriter as `prerequisites`, so author ids in your DSL are resolved to the persisted nanoids automatically — see [`course-versioning.md`](course-versioning.md) §"Cross-reference rewriting at publish".

#### `sectionRetry(opts?)`

Renders a "you didn't pass — retry this section" affordance inside a section that uses `completionRule: { type: "score-threshold" }`. On click, every response in the section is reset and any randomised UI (e.g. sequencing) is re-shuffled. Re-emits the section-level `failed` outcome to xAPI on retry, so the activity timeline records each attempt.

```typescript
assessmentSection("assess-final", "Final", [
  // ...questions...
  sectionRetry(),
], {
  passingScore: 0.7,
  retryable: true, // explicitly opt in
});
```

Renders nothing if dropped into a section that isn't `score-threshold`-gated, isn't `retryable`, or hasn't yet been failed by the learner. The molecule is layout-aware: the retry banner inserts itself only when the learner needs it.

Set `retryable: true` on the section to opt in. Course-level `passingScore` is **ignored** for outcome calculation when any section uses `score-threshold` — only per-section thresholds drive `passed` / `failed`.

### Section Roles

Every section has a `roleKey` that controls six behavioral properties (immediate feedback, answer-key scrubbing, retriability, scoring mode, gating, xAPI record mode). Three presets are built in:

| Role | `immediateFeedback` | `stripAnswerKeys` | `scoringMode` | Typical use |
|------|---------------------|-------------------|---------------|-------------|
| `learning` | true | true | none | Default for `lessonSection` |
| `practice` | true | false | formative | Low-stakes drills |
| `graded`   | false | true | summative | Default for `assessmentSection` |

Override per-section via `{ overrides: { immediateFeedback: true, scoringMode: "formative" } }`.

### Completion Rules & Prerequisites

Each section can declare a `completionRule` that decides when the learner has "finished" it. Other sections that list this section in their `prerequisites` stay locked (🔒 in the player, navigation disabled) until the rule is satisfied.

Three rule types are supported:

| Rule | Shape | When complete |
|------|-------|---------------|
| `view-time` | `{ type: "view-time", minSeconds?: number }` | Section has been visited and at least `minSeconds` (default `0`) elapsed since first visit |
| `all-interactions` | `{ type: "all-interactions" }` | Every scored interaction in the section has been submitted (a section with no scored interactions completes on visit) |
| `score-threshold` | `{ type: "score-threshold", threshold?: number }` | Every scored interaction submitted **and** the fraction correct ≥ `threshold` (default `1.0`) |

The high-level organisms set the rule automatically:

- `lessonSection` → `{ type: "view-time", minSeconds: 0 }` (any visit completes).
- `assessmentSection` without `passingScore` → `{ type: "all-interactions" }`.
- `assessmentSection` with `passingScore` → `{ type: "score-threshold", threshold: passingScore }`.

```typescript
// Visit completes the lesson; learner is then allowed into the assessment.
lessonSection("lesson-intro", "Introduction", [...]);

// Must answer every scored question correctly enough to clear 0.7.
assessmentSection("assess-final", "Final", [...], {
  prerequisites: ["lesson-intro"],
  passingScore: 0.7,
});
```

The player evaluates these rules live against learner responses and visit timing, exposes the set of completed sections via the `onProgressChange` callback (`progress.completedSections`), and uses it to compute prerequisite tooltips for locked sections.

---

## Part 3 — Creating a Course: Step by Step

Use this path for **initial creation** of a new course (v1.0) or a complete rewrite. For incremental edits to an existing course, jump to [Part 4](#part-4--editing-an-existing-course).

### 1. Author and POST the question bank

```typescript
// bank.ts
import {
  questionBank, pool, byTagsSelector,
  bankRadioQuestion, bankCheckboxQuestion,
} from "@course/components";

export default questionBank(
  { name: "HTTP Basics Bank" },
  {
    questions: [
      bankRadioQuestion("q1", "What is HTTP?", [...], "a", { tags: ["intro"] }),
      bankCheckboxQuestion("q2", "Valid methods?", [...], ["a", "b"], { tags: ["methods"] }),
    ],
    pools: [ pool("intro-pool", "Intro", byTagsSelector(["intro"])) ],
  },
);
```

POST the source of that file to the banks endpoint:

```http
POST /t/{tenantSlug}/api/v1/question-banks
Authorization: Bearer course_...
Content-Type: application/json

{ "dsl": "<bank DSL script as a string>" }
```

Response `201`:
```json
{ "id": "bnk_abc", "name": "HTTP Basics Bank", "questionCount": 2, "poolCount": 1 }
```

Pool IDs are the ones you named (`intro-pool`), not generated — so your course DSL can hardcode them.

> **Editing an existing bank?** Use the dedicated bank-edit endpoints rather than re-POSTing the whole DSL: `PATCH /questions/{id}` for cosmetic edits, `POST /questions/{id}/versions` for substantive (correctness-affecting) edits, `POST /question-banks/{id}/questions` to add a new question, and `DELETE /questions/{id}` to deprecate one. Full reference and a worked example are in `docs/api.md` and the matching AI skill: `GET /api/docs/skill/edit-question-bank`.

### 2. Author the course referencing those pools

```typescript
// course.ts
import {
  course, topic, lessonSection, assessmentSection,
  lessonParagraph, questionSlot,
} from "@course/components";

export default course(
  { title: "Web Dev Basics", version: "1.0", mode: "teaching", passingScore: 0.7 },
  [
    topic("topic-http", "HTTP", [
      lessonSection("lesson-intro", "Intro", [
        lessonParagraph("Overview", "HTTP is how the web talks."),
      ]),
      assessmentSection("assess-intro", "Quiz", [
        questionSlot("warmup", { pool: "intro-pool", count: 2, ordering: "random" }),
      ], { prerequisites: ["lesson-intro"], passingScore: 0.7 }),
    ]),
  ],
);
```

### 3. POST the course

`POST` the course DSL to create v1.0 of the course. The server ignores any `version` field in the DSL meta and stamps the new row as `"1.0"`.

```http
POST /t/{tenantSlug}/api/v1/courses
Authorization: Bearer course_...
Content-Type: application/json

{ "dsl": "<course DSL script>", "slug": "web-dev-basics" }
```

Response `201`:
```json
{
  "id": "crs_abc",
  "slug": "web-dev-basics",
  "url": "/c/{tenantSlug}/web-dev-basics",
  "fullUrl": "http://localhost:8789/c/{tenantSlug}/web-dev-basics"
}
```

`slug` is optional and derived from the title if omitted. AI agents authenticate via the Device Authorization Flow — see `docs/ai-course-creation.md`.

### 4. Learner loads the course

When a learner opens the course URL the server walks each section, resolves every `questionSlot` against its pool, persists the picked question IDs to `slot_resolutions` keyed on `(registrationId, slotId)`, and streams the resolved tree to the renderer. Reloads hit the same resolutions so learners see stable questions.

---

## Part 4 — Editing an existing course

Once v1.0 exists, every edit becomes a **new course version**. Course rows are immutable — there is no "edit this section in place" operation. The closest equivalent is "publish a new version that replaces this section with a new one," which mints a new section row and leaves v1.0's section untouched (still referenced by every learner who started on v1.0).

This means **incremental editing IS versioning**. The model isn't "edit the document, then version it" — it's "describe the change, the server publishes a new version." See [`course-versioning.md`](course-versioning.md#version-dont-mutate) for the design rationale.

### Why operations beat re-POSTing the whole DSL

You *can* re-POST the whole DSL to `POST /courses/:id/versions` — the server will diff it against the current head and figure out the operation set internally. But for incremental edits, prefer the operations endpoint:

- **Explicit intent.** `addSection`, `replaceSectionContent`, `reorderTopics` say what you mean. The server doesn't have to guess whether a missing section was a removal or a typo in your re-submitted DSL.
- **Per-id validation.** If you reference a topic id that doesn't exist, the response names exactly which operation failed and why. Whole-DSL diffs are forgiving in ways that hide drift.
- **Clean audit trail.** Each operation writes its own row to `operation_log`. Whole-DSL submits log a single `wholeCoursePublish` row with the entire DSL as payload — useful, but coarser.
- **Concurrency-safe.** Both paths return `409 head_moved` when the chain head moved between your read and your write. Operations make it cheap to retry: re-fetch the head, re-validate your op batch against it, resubmit. Whole-DSL retries mean re-sending the entire course.

### The operation set

The full operation reference (validation rules, error shapes, classifier behaviour) lives in [`course-versioning.md`](course-versioning.md#operation-based-authoring). Quick summary:

| Operation | Effect |
|---|---|
| `editCourseMeta` | Edit the course's `title` and/or `description`. |
| `addTopic` | Add a topic. `afterTopicId` (optional) places it after that topic; null prepends. |
| `removeTopic` | Remove a topic by id. |
| `reorderTopics` | Reorder the course's topics. Must be a permutation of the current set. |
| `editTopicMeta` | Edit a topic's `title` and/or `description`. |
| `addSection` | Add a section to a topic. `afterSectionId` (optional) places it after that section; null prepends. |
| `removeSection` | Remove a section from a topic. |
| `reorderSections` | Reorder sections within a topic. Permutation of the topic's current section set. |
| `replaceSection` | Replace a section in full. The replacement's declared id may differ — same-id means "edit," different-id means "swap." |
| `replaceSectionContent` | Narrower form of `replaceSection` — replaces only the section body, leaves title/role/prereqs/completionRule alone. |

### Submitting an operation batch

```http
POST /t/{tenantSlug}/api/v1/courses/{courseId}/operations
Authorization: Bearer course_...
Content-Type: application/json

{
  "ops": [
    {
      "op": "replaceSectionContent",
      "topicId": "topic-http",
      "sectionId": "lesson-intro",
      "newContent": <CourseEl tree as built by lessonSection's content arg>
    }
  ]
}
```

Response `201`:
```json
{
  "id": "crs_v11",
  "version": "1.1",
  "versionMajor": 1,
  "versionMinor": 1,
  "originalCourseId": "crs_abc",
  "supersededId": "crs_abc",
  "recommendedBump": "major",
  "bumpSignals": ["ops[0].replaceSectionContent: section content changed"],
  "adminUrl": "/t/{tenantSlug}/courses/crs_v11",
  "fullAdminUrl": "http://localhost:8789/t/{tenantSlug}/courses/crs_v11",
  "nextStep": "review"
}
```

Up to 100 operations per batch. Operations apply transactionally — either the whole batch publishes a new version, or none of it does. Pass `bump: "minor" | "major"` in the request body to override the classifier's recommendation; otherwise the server uses `recommendedBump` automatically.

### Common edit recipes

**Fix a typo in a paragraph.** Build the new section content with the same builders you used originally, then submit one `replaceSectionContent`:

```jsonc
{
  "ops": [{
    "op": "replaceSectionContent",
    "topicId": "topic-http",
    "sectionId": "lesson-intro",
    "newContent": <output of lessonSection(...)'s content arg>
  }]
}
```

Note: the classifier defaults this to **major** because lesson content can shift downstream question p-values. Pass `"bump": "minor"` explicitly if you know the edit is purely cosmetic.

**Add a new topic at the end.**

```jsonc
{
  "ops": [{
    "op": "addTopic",
    "afterTopicId": "topic-html",   // last existing topic
    "topic": <output of topic("topic-css", "CSS Basics", [...])>
  }]
}
```

**Reorder topics.** Pass the full ordered id list — must be a permutation of the current topics:

```jsonc
{
  "ops": [{
    "op": "reorderTopics",
    "topicIds": ["topic-html", "topic-http", "topic-css"]
  }]
}
```

**Coherent multi-op batch.** Operations within a batch see the *projected* post-batch state, so you can add a section AND another section that prerequisites it in the same batch:

```jsonc
{
  "ops": [
    {
      "op": "addSection",
      "topicId": "topic-http",
      "afterSectionId": "lesson-intro",
      "section": <lessonSection("lesson-deep-dive", "Deep Dive", [...])>
    },
    {
      "op": "addSection",
      "topicId": "topic-http",
      "afterSectionId": "lesson-deep-dive",
      "section": <assessmentSection("assess-deep-dive", ..., {
        prerequisites: ["lesson-deep-dive"]   // resolves against post-batch state
      })>
    }
  ]
}
```

### Validation errors

If any operation fails validation, the whole batch is rejected with `400`:

```jsonc
{
  "error": "Operation batch validation failed",
  "phase": "validation",
  "details": {
    "code": "operation_validation_failed",
    "validationErrors": [
      {
        "opIndex": 0,
        "code": "topic_not_found",
        "message": "Topic id 'topic-xyz' does not exist in course head version",
        "details": { "topicId": "topic-xyz" }
      }
    ]
  }
}
```

The `validationErrors` array is designed for fix-and-resubmit in one round-trip. See `apps/web/src/routes/t/$tenantSlug/api/v1/courses_.$courseId_.operations.tsx` for the full set of error codes.

### `409 head_moved`

If another publisher landed a new version between your last read and your submit, the response is `409 Conflict` with `details.code === "head_moved"` and `details.currentHeadId` naming the new head. Re-fetch the head's DSL, recompute your operations against it, resubmit.

---

## Authoring with the in-app AI chat

Everything in this document describes the API-direct authoring path: write DSL, POST it via the public REST API. The same DSL is also producible by AI agents in the Course App's in-app chat surface at `/t/{slug}/ai-chat`. The chat operates in two modes:

- **Create mode** — the AI authors a brand-new question bank / course / competency framework end-to-end, posting through the same `POST /question-banks` and `POST /courses` endpoints documented above. Discoverable via the [`create-course` skill](../apps/web/src/routes/api/docs/skill/create-course.tsx) at `GET /api/docs/skill/create-course`.
- **Edit mode** — the AI applies surgical edits to an existing draft course using **operation builders** — DSL helpers (`addTopic`, `replaceSectionContent`, `apply`, …) that produce the same `OperationBatch` shape consumed by `POST /courses/{id}/operations`. The chat page wires these into 12 granular tools plus an `apply_course_operations` mega-tool (13 tools total), all routed through `applyOperationsToDraft` so edits land in-place on a draft (no version bump until the author publishes). Discoverable via the [`edit-course` skill](../apps/web/src/routes/api/docs/skill/edit-course.tsx) at `GET /api/docs/skill/edit-course`.

The DSL the chat AI emits is the DSL described in this document — the same atoms / molecules / organisms, the same `questionSlot` shape, the same fragment evaluation. The chat surface adds a fragment-mode entry point on the executor (`executeTopicFragment`, `executeSectionFragment`, `executeCourseElFragment`, `executeOperationBatchFragment`) so subtree payloads can be evaluated without a full course wrapper, but the builder library itself is unchanged.

For the design behind the in-app edit flow (auto-fork, session pinning, tool surface, analytics), see [`ai-course-editing.md`](ai-course-editing.md).

---

## Aligning to a Competency Framework

1. Create or fetch a framework via `/t/{slug}/api/v1/competency-frameworks` (see `docs/competency-frameworks.md`).
2. Reference it from the course meta: `competencyFrameworkId: "fw_abc"`.
3. Tag individual questions in the **bank** with `competencyIds: [...]`.
4. Use `byCompetenciesSelector(["comp_1", "comp_2"])` to build pools that target specific competencies.

At assessment time the renderer writes per-competency coverage into `assessmentResults.competencyCoverage`.

---

## Best Practices

1. **Unique IDs everywhere** — Questions (bank-unique), pools (bank-unique), sections/topics/slots (course-unique). Use descriptive prefixes: `q-http-meaning`, `lesson-intro`, `assess-final`, `pool-basics`, `warmup-slot`.

2. **Tag generously** — Tags on questions are the primary input to `byTagsSelector` pools. Plan a small taxonomy before you start authoring.

3. **Pool organization** — A few broad pools (by topic/competency) plus a couple of `explicitSelector` pools for fixed finales usually beats many narrow ones.

4. **Mix question types** — Radio, checkbox, true/false, fill-in. Keeps assessments engaging; also exercises more of the interaction pipeline.

5. **Progressive difficulty** — Put low-stakes drills in `role: "practice"` sections (formative scoring) before `graded` assessments.

6. **Prerequisites** — Use `prerequisites: ["lesson-id"]` on assessment sections to force completion order.

7. **Passing scores** — 0.6–0.8 is typical. Set on course meta as a fallback; override per-assessment with `passingScore`.

8. **Separate the layers** — Sections describe layout and what pool to draw from. Banks describe the content of questions. Never inline answer data in a section's content tree.

---

## Interaction Types Reference

| Type | xAPI Type | Correct Pattern Format |
|------|-----------|------------------------|
| Single choice | `choice` | `["a"]` |
| Multiple choice | `multiple-choice` | `["a[,]b[,]d"]` (sorted) |
| True/false | `true-false` | `["true"]` or `["false"]` |
| Fill-in | `fill-in` | exact text |
| Numeric | `numeric` | number as string |
| Long fill-in | `long-fill-in` | free text (typically unscored) |
| Matching | `matching` | `["a[.]1[,]b[.]2"]` |
| Sequencing | `sequencing` | `["a[,]b[,]c"]` |
| Likert | `likert` | scale value (typically unscored) |
| Image choice | `image-choice` | `["img1"]` |

The bank builders compute `correctResponsesPattern` in these formats for you.

## xAPI Verbs Emitted

- `initialized` — course loads
- `experienced` — section viewed
- `answered` — question submitted
- `completed` — all scored interactions answered
- `passed` / `failed` — score vs passing threshold
- `suspended` / `resumed` — tab blur/focus

## Imports

```typescript
// Bank DSL
import {
  // Bank question builders
  bankRadioQuestion, bankCheckboxQuestion, bankTrueFalseQuestion,
  bankTextInputQuestion, bankLongTextQuestion, bankNumericQuestion,
  bankMatchingQuestion, bankSequencingQuestion, bankImageSelectionQuestion,
  bankLikertQuestion,
  // Pool selectors + organisms
  explicitSelector, byTagsSelector, byCompetenciesSelector, compoundSelector,
  pool, questionBank,
} from "@course/components";

// Course DSL
import {
  // Atoms
  questionStem, image, choice, choiceLabel, choiceImage, feedbackCorrect, feedbackIncorrect,
  hintToggle, sectionHeading, progressIndicator, explanationText, divider,
  questionSlot,
  // Content molecules
  lessonParagraph, mediaBlock, interactiveVideo, videoCue,
  // Organisms
  lessonSection, assessmentSection, courseHeader, courseFooter, scoreCard,
  topic, course,
} from "@course/components";

import type {
  CourseEl, Interaction, CourseDefinition, CourseSection, CourseTopic,
  SelectionRule, QuestionDefinition, QuestionPoolDefinition,
  QuestionBankDefinition, PoolSelector,
  CompletionRule, StyleDef, SectionRoleOverrides,
} from "@course/shared";
```
