Skip to content

fix(create-lesson): strip stray ext_payload from core exercises on edit-mode reconstruction (#1919)#1926

Merged
astrapi69 merged 3 commits into
developfrom
claude/ext-payload-edit-mode-bug-f64b8z
Jul 22, 2026
Merged

fix(create-lesson): strip stray ext_payload from core exercises on edit-mode reconstruction (#1919)#1926
astrapi69 merged 3 commits into
developfrom
claude/ext-payload-edit-mode-bug-f64b8z

Conversation

@astrapi69

Copy link
Copy Markdown
Owner

Description

Reopening a saved core-only lesson for editing failed the Review "Valid lesson structure" check with /steps/1/exercise/ext_payload must be object, even though the same lesson was fully green in the create flow immediately before saving. Save changes was blocked on a lesson the author never touched an extension in.

Closes #1919

Root cause (API/server mode only)

The GET lessons endpoint serves the lesson via response_model=Lesson with FastAPI's default exclude_none=False, so the Pydantic Exercise.ext_payload: dict | None = None serializes as ext_payload: null on every core exercise. Proven directly against the model:

Exercise(id='e1', type='matching', prompt='P').model_dump(mode='json')
  -> ext_payload present = True -> None

The lesson JSON-Schema types ext_payload as object-only (type: object, "Absent on core exercises") — while every other optional exercise field tolerates null via anyOf: [..., {type: null}]. So ext_payload is the single field that breaks. lessonToDraftInput copied the reloaded exercise verbatim into the wizard, and checkDraft → buildLessonFromDraft's ajv validation then rejected the null.

Dexie mode parses via the engine adapter (parseLesson), which omits the key entirely — verified a full generate → save → parse round-trip is clean — so this reproduced only in API mode.

Fix

reconstructExercise (in lessonToDraftInput) restores the invariant "ext_payload present iff the exercise is an ext: extension type": a core exercise carries no ext_payload; an extension exercise keeps its real payload. Mode-agnostic (a no-op in Dexie mode, since the key is already absent there). Scoped precisely to ext_payload — the broader regression check confirmed no other optional field breaks the schema the same way.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)

Testing

  • make test slice passes locally: draft-to-lesson, lib/content/lesson/, lib/exercises/authoring/, CreateLesson, plus storage/content/ + lib/content/ sweep (77 files / 916 tests green); tsc --noEmit clean; eslint clean on changed files.
  • TDD RED-first: 3 new tests in draft-to-lesson.test.ts reproduce the API-mode reload (ext_payload: null on core exercises) — before the fix they failed with the exact /steps/1/exercise/ext_payload must be object; after the fix they pass.
  • Regression: an extension exercise's ext_payload is preserved through reconstruction (no over-correction), including in a mixed core+extension lesson.

Root cause additionally verified end-to-end at the source (backend Pydantic Exercise.model_dump emits ext_payload: None) and at the sink (Dexie engine parseLesson round-trip omits the key).

Documentation

Checklist

  • Self-review performed
  • Google-style docstring on the new helper explaining the WHY
  • No new warnings or errors

Generated by Claude Code

claude and others added 3 commits July 21, 2026 20:16
…it-mode reconstruction (#1919)

Reopening a saved core-only lesson for editing failed the Review
"Valid lesson structure" check with
"/steps/1/exercise/ext_payload must be object", although the same
lesson was fully valid in the create flow immediately before saving.

Root cause (API/server mode): the GET lessons endpoint serves the
lesson via response_model=Lesson with FastAPI's default
exclude_none=False, so the Pydantic Exercise.ext_payload
(dict | None = None) is serialized as ext_payload: null on every
core exercise. The lesson JSON-Schema types ext_payload as
object-only ("Absent on core exercises"), while every other optional
exercise field tolerates null via anyOf. lessonToDraftInput copied
the exercise verbatim into the wizard, so the re-validation on the
Review step rejected the null. Dexie mode parses via the engine
adapter, which omits the key, so it only reproduced in API mode.

Fix: reconstructExercise restores the invariant "ext_payload present
iff the exercise is an ext: extension type" — a core exercise carries
no ext_payload, an extension exercise keeps its real payload. Mode-
agnostic (a no-op in Dexie mode). Verified an extension exercise's
payload is preserved (no over-correction).

Closes #1919
…ory (#1919)

The Frontend Tests CI job's `bun audit --audit-level=high` gate went red
on a newly-published advisory GHSA-4c8g-83qw-93j6 (fast-uri host
confusion via failed IDN canonicalization, affecting >=3.0.0 <3.1.3),
pulled transitively through ajv -> fast-uri. Not introduced by this
branch's code change; the advisory started failing the audit gate.

Add a `fast-uri: ^3.1.3` override (same pattern as the existing qs /
brace-expansion security pins) so the resolved version is 3.1.4. ajv
schema validation is unaffected (lesson-schema-validator + draft-to-lesson
suites green).
@astrapi69
astrapi69 merged commit 577e7fd into develop Jul 22, 2026
15 checks passed
@astrapi69
astrapi69 deleted the claude/ext-payload-edit-mode-bug-f64b8z branch July 22, 2026 07:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Edit mode: reopened lesson fails 'Valid lesson structure' with ext_payload must be object

2 participants