From 808bdfaa74e7e6a11a88173ee43c5c86d9a8da51 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:41:55 -0400 Subject: [PATCH 001/206] docs: define staged classroom simulation contract --- docs/classroom-staged-simulation.md | 599 ++++++++++++++++++++++++++++ 1 file changed, 599 insertions(+) create mode 100644 docs/classroom-staged-simulation.md diff --git a/docs/classroom-staged-simulation.md b/docs/classroom-staged-simulation.md new file mode 100644 index 0000000..270b1ff --- /dev/null +++ b/docs/classroom-staged-simulation.md @@ -0,0 +1,599 @@ +# Classroom Staged Simulation and Debrief Contract + +## Purpose + +This document is the canonical architecture/server contract for #313 — Instructor-orchestrated Case Study simulation and staged debrief. + +#313 builds directly on the merged #312 live-class substrate at `b141d55050207e4cf7ce842a4452a220a5268968`. + +The target experience is a facilitated class, not a self-paced LMS: + +```text +Instructor starts/resumes live class + -> selects a staged Case Study + -> starts Stage 1 + -> Students receive only released case material + -> teams reason and document in the same Intake + -> teams may mark Ready for debrief + -> Instructor observes/coaches + -> Instructor begins debrief + -> optional server-enforced edit freeze + -> immutable pre-debrief checkpoints preserve team work + -> Instructor facilitates discussion / teaching points + -> Instructor advances the whole class + -> newly released material appears automatically + -> same Intake continues across stages + -> final debrief / completion +``` + +The Instructor controls major transitions. Student/team work remains local to each authorized collaboration workspace. + +## Scope of #313 + +Own: +- class-level exercise lifecycle; +- current stage and phase; +- server-gated progressive release of protected Case Study material; +- Instructor-only facilitation/debrief material; +- Student stage-aware case reference UI; +- team readiness and debrief checkpoints; +- optional server-enforced Student edit freeze; +- Instructor exercise console and progress signals; +- late-join and reassignment behavior; +- staged browser/security/accessibility acceptance. + +Do not turn Intake into: +- a graded LMS; +- a general course-authoring system; +- an account/identity platform; +- a second collaboration engine; +- a place where Instructor-only solution material is serialized into Student Intake. + +## Relationship to #312 + +#312 remains authoritative for: +- class identity; +- Instructor capability; +- human Student join code; +- Student class-session capability; +- Waiting / assignment state; +- team/individual workspaces; +- assignment-specific editable workspace capabilities; +- reassignment/unassign revocation; +- collaboration snapshots/revisions/presence; +- read-only Instructor observation; +- field-level coaching. + +#313 layers exercise orchestration over those primitives. It must not reinterpret or duplicate them. + +## Non-negotiable security invariants + +1. Future-stage Student content is withheld **server-side**. It must not be sent to the browser and hidden with CSS, `hidden`, disabled controls, or client-side filtering. +2. Instructor facilitation notes, teaching points, model/exemplar reasoning, and answer material are never returned by Student endpoints unless a future explicit release action authorizes a specifically Student-safe item. +3. A human class join code is never exercise authorization. +4. A Student class-session capability may read only the represented Student's class-level exercise state/released Student material. It may not enumerate teams, classmates, Instructor notes, or future content. +5. Student workspace edit capability continues to authorize only the current collaboration workspace. It does not grant stage-control authority. +6. Student cannot start, pause, resume, debrief, advance, complete, or release exercise content. +7. Instructor may control only the class represented by the Instructor capability. +8. Exercise state is separate from `kt-intake-full-v2`, collaboration snapshot state, coaching state, exports, templates, and summaries. +9. Reassignment never copies/merges Intake snapshots and never carries team readiness/checkpoint state from the old workspace to the destination workspace. +10. Protected authored Case Study source and staged content remain absent from public browser/static assets. +11. Standalone remains backend-optional and unaffected. +12. Existing non-staged protected Case Study behavior remains compatible unless the represented class has an active staged exercise for that Case Study. + +## Critical protected-content decision + +The current #295 Case Study record contains a complete protected `state` suitable for applying a full Case Study/teaching artifact. For staged simulation this complete payload must be treated as **source/exemplar material**, not as the Student progressive-disclosure feed. + +A staged Case Study must therefore have an explicit server-only simulation definition. Do **not** infer stages automatically from the filled `state`, and do not invent stage boundaries for official cases without authoritative case material. + +Recommended authored shape for a `case-study` record: + +```json +{ + "id": "case-id", + "name": "Case name", + "description": "Short teaching description", + "templateKind": "case-study", + "supportedModes": ["intake", "is-is-not", "dc", "full"], + "state": { "...": "complete protected source/exemplar Intake" }, + "simulation": { + "version": 1, + "studentContent": [ + { + "id": "briefing-1", + "kind": "narrative", + "title": "Initial briefing", + "body": "Student-safe content" + } + ], + "instructorContent": [ + { + "id": "teach-1", + "kind": "facilitation", + "title": "Stage 1 teaching points", + "body": "Instructor-only content" + } + ], + "stages": [ + { + "id": "stage-1", + "title": "Clarify the situation", + "studentObjective": "What the learner is trying to accomplish, without giving the answer.", + "initialReleaseIds": ["briefing-1"], + "optionalReleaseIds": [], + "intakeTargetIds": ["problem.one-line"], + "suggestedMinutes": 15, + "instructorContentIds": ["teach-1"], + "defaultDebriefEditPolicy": "frozen" + } + ] + } +} +``` + +This is a contract shape, not permission to author official case staging from guesses. Production staged definitions should be source-faithful and explicitly reviewed. + +### Student content block types + +Start with a small allowlist rather than arbitrary HTML: +- `narrative` — titled plain/rich-safe text block; +- `image` — server-protected image/evidence reference with required alt text; +- `table` — structured headers/rows; +- `document-page` — future protected page/image/PDF-page reference; +- `prompt` — Student-safe question/instruction; +- `evidence` — discrete released fact/evidence item. + +Do not allow executable markup, script, inline event handlers, arbitrary remote embeds, or public asset URLs that bypass Classroom authorization. + +### Instructor content + +Instructor-only content may include: +- facilitation notes; +- likely reasoning / teaching points; +- debrief prompts; +- anticipated misconceptions; +- exemplar/model reasoning; +- recommended timing; +- reveal guidance. + +Instructor content IDs and payloads are omitted entirely from Student responses. + +## Exercise domain model + +Persist orchestration state separately from static Case Study definitions. + +### classroom_exercises + +One row represents one run of one Case Study in one class. + +Recommended fields: +- `id`; +- `class_id`; +- `case_study_id`; +- `status: draft | active | paused | completed`; +- `current_stage_id` (nullable before start); +- `stage_phase: work | debrief`; +- `exercise_revision` positive monotonic integer; +- `student_editing_enabled` boolean; +- `started_at`; +- `completed_at`; +- `created_at`; +- `updated_at`. + +A class may have historical completed exercises. At most one non-completed exercise should be active for a class unless a later product decision explicitly introduces parallel exercises. + +The database stores the selected `case_study_id` and dynamic lifecycle state, not a second copy of the static stage definition. + +### Released optional content + +Initial stage release is deterministic from the stage definition. + +Optional hints/supplemental evidence need durable state. Prefer an additive release table: + +`classroom_exercise_releases` +- `exercise_id`; +- `stage_id`; +- `content_id`; +- `released_at`. + +The server validates that a released ID belongs to the current stage's `optionalReleaseIds`. A Student response is the union of: +- all initial releases from completed/current stages that are intended to remain visible; +- explicitly released optional content; +- no future-stage content. + +### Workspace stage state + +Readiness is a **team/workspace** concept, not participant identity. + +`classroom_exercise_workspace_state` +- `exercise_id`; +- `workspace_id`; +- `stage_id`; +- `ready_for_debrief` boolean; +- `ready_at`; +- `ready_workspace_revision`; +- timestamps. + +A reassigned Student adopts the destination workspace's readiness state. Their old team's readiness stays with the old workspace. + +### Immutable debrief checkpoints + +A collaboration revision number alone is insufficient to reconstruct what a team believed before a debrief because collaboration currently retains only the live snapshot. + +When the Instructor begins debrief, capture an immutable stage checkpoint for each active workspace: + +`classroom_exercise_checkpoints` +- `exercise_id`; +- `stage_id`; +- `workspace_id`; +- `workspace_revision`; +- `snapshot` JSONB; +- `captured_at`. + +Checkpoint snapshots are evidence for facilitation/comparison only: +- never become the live Intake automatically; +- never overwrite team work; +- are class/workspace scoped; +- inherit class retention; +- are never sent to another team; +- are not included in normal Intake Save/Load/export. + +This gives future debrief tooling a trustworthy “what the team had at debrief start” view without requiring collaboration history as a prerequisite. + +## Lifecycle state machine + +### Draft + +Exercise exists and the Instructor can inspect its full staged definition. + +Student receives no staged exercise content. + +Allowed Instructor transitions: +- start first stage; +- replace/cancel draft selection. + +### Active + work + +Students receive the cumulative Student-safe release through the current stage. + +Student Intake editing is normally enabled. + +Instructor may: +- release an optional current-stage content item; +- pause; +- begin debrief; +- observe/coach teams. + +Teams may mark Ready for debrief while assigned. + +### Paused + +Current stage and phase remain unchanged; `status=paused` records the hold. + +Pause is not automatically an edit freeze. The Instructor may choose the exercise editing policy independently. + +Resume returns to the same phase. + +### Active + debrief + +Entering debrief: +1. validates expected exercise revision; +2. captures immutable workspace checkpoints exactly once for that stage; +3. applies the stage's default edit policy unless Instructor explicitly overrides it; +4. changes `stage_phase` to `debrief`; +5. increments exercise revision. + +Student continues to see released case material and their own live Intake. If editing is frozen, the UI becomes read-only and the server rejects Student snapshot mutation. + +Instructor may: +- facilitate using Instructor-only notes; +- observe checkpoint vs current work; +- coach; +- unfreeze/refreeze if policy permits; +- advance to the next stage; +- complete the exercise on the final stage. + +### Advance + +Advance is Instructor-only and optimistic-revision protected. + +The server: +- validates current stage/phase; +- resolves the next stage from the immutable simulation definition; +- sets next `current_stage_id`; +- sets phase to `work`; +- restores the next stage's default editing policy; +- clears/creates workspace readiness for the new stage without changing Intake; +- increments exercise revision. + +Student clients discover the new revision and fetch the new safe release. + +### Completed + +No future automatic release occurs. + +Completion does **not** automatically release Instructor exemplar/model material. If learner-facing exemplar reveal is later desired, it must be an explicit separately authorized release. + +## API direction + +Use separate exercise endpoints rather than overloading assignment/status routes. + +### Instructor + +`POST /api/classes/exercise` +- Instructor class capability; +- create/select a draft exercise from a Case Study that has a valid `simulation` definition; +- reject non-staged cases for this path. + +`GET /api/classes/exercise` +- Instructor class capability; +- return current exercise lifecycle plus complete staged definition including Instructor-only content. + +`PATCH /api/classes/exercise` +- Instructor class capability; +- requires `expectedRevision` for state-changing actions; +- actions: + - `start`; + - `pause`; + - `resume`; + - `release-content`; + - `begin-debrief`; + - `set-editing`; + - `advance`; + - `complete`. + +Return 409 on revision conflict; never perform a best-effort double advance. + +Instructor controls use stable IDs in request bodies, not case/stage identifiers in URLs. + +### Student + +`GET /api/classes/exercise/student` +- Student **class-session capability**, not the human join code; +- deliberately class-level so a safely admitted late/unassigned Student can receive the current class release while Waiting; +- returns only: + - public exercise identity; + - current Student-visible stage title/objective; + - status/phase/revision; + - editing-enabled state; + - cumulative released Student content; + - represented Student's current workspace readiness if assigned; +- never returns future stage metadata, Instructor content IDs, Instructor content, exemplar `state`, other team readiness, or workspace enumeration. + +`PUT /api/classes/exercise/student/ready` +- Student class-session capability; +- server resolves the represented participant's **current** assigned workspace; +- marks/unmarks readiness for current exercise/stage; +- rejects Waiting/unassigned Students; +- records current collaboration revision as evidence; +- stale pre-reassignment workspace authority cannot mark the old team ready. + +Student stage reads are class-session authorized because stage release is class-level. Intake edits remain workspace-capability authorized. + +## Existing protected Case Study endpoint interaction + +This is a critical anti-bypass rule. + +While a class has an active staged exercise for Case Study X: +- Student `POST /api/classes/case-studies/student` must **not** return Case Study X's complete `state`; +- the Student must use the staged exercise endpoint to receive only released content; +- Instructor `POST /api/classes/case-studies` may continue to receive the complete protected record; +- unrelated non-staged protected resources may retain current behavior unless a later product rule narrows them. + +Do not rely on the Student drawer hiding the full-case Apply button. The server must close the full-payload bypass for the active staged case. + +A completed exercise still does not imply automatic exemplar release. + +## Server-enforced edit freeze + +If the Instructor freezes Student editing: +- Student UI projects the Intake read-only and clearly says the class is paused/debriefing; +- existing presence/observation may continue; +- Student snapshot mutation through `PUT /api/workspaces/session` must be rejected server-side for `classroom-student` aliases in the frozen exercise; +- legacy Standalone collaboration is unaffected; +- Instructor observer remains read-only as before; +- unfreeze resumes the same collaboration workspace/revision. + +Prefer a specific non-terminal response such as HTTP 423 with a stable machine-readable Classroom code. The collaboration controller must treat this as temporary read-only state, not invalid/expired authority. + +No freeze is complete until the server write path enforces it. + +## Student client lifecycle + +The Student exercise controller is separate from `classroomStudent.js` assignment authority but consumes the active Student class-session capability. + +Responsibilities: +- poll exercise revision/state; +- hold only currently released Student-safe content in memory; +- render current stage/phase; +- announce stage/pause/debrief changes accessibly; +- open case reference beside Intake without navigating away; +- expose team Ready control only while assigned; +- project temporary edit freeze; +- discard staged content on class disconnect/role switch. + +Never persist protected staged content or exercise credentials into Intake state. + +### Case reference UX + +Preferred desktop direction: +- resizable or bounded side panel beside Intake; +- current stage header + objective; +- released content grouped in release order; +- obvious “new evidence” indication after Instructor release; +- Intake remains editable in the main pane when allowed. + +Preferred narrow/mobile direction: +- accessible drawer/sheet; +- returns focus to the invoking control; +- no horizontal two-column dependency. + +The exact layout is a UI tranche decision and must be verified in real browsers. + +## Instructor exercise console + +Extend the existing #312 live dashboard rather than building a second Instructor app. + +The console should eventually show: +- selected Case Study; +- current stage + phase; +- exercise revision/status; +- elapsed/suggested time as advisory only; +- Start / Pause / Resume / Begin debrief / Advance / Complete controls; +- optional evidence/hint release controls; +- Freeze/Unfreeze editing control; +- team readiness; +- team activity/progress/coaching indicators; +- one-click read-only observation; +- Instructor-only stage notes/debrief prompts. + +Accessible buttons/selects are the canonical controls. Keyboard operation must not depend on drag-and-drop. + +## Late join + +A newly admitted Student: +- receives a class-session capability as in #312; +- may read the **current** Student release from the exercise endpoint even while Waiting; +- receives no future or Instructor content; +- joins the current stage rather than replaying old transitions; +- when assigned, connects to the destination team's current Intake without changing class exercise state. + +## Reassignment + +Exercise stage is class-global. + +When Student A moves Team A -> Team B: +- Student A's staged case release does not change; +- old Team A workspace authority is revoked by #312; +- Student A connects to Team B's current Intake; +- Team A readiness/checkpoints remain Team A; +- Student A inherits Team B's current readiness state; +- no checkpoint or Intake snapshot is copied between teams; +- future releases continue from the class exercise state. + +## Failure and concurrency rules + +- Every Instructor lifecycle mutation uses `expectedRevision`. +- Duplicate/retried begin-debrief must not create duplicate checkpoints. +- Duplicate/retried release-content must be idempotent. +- Duplicate/retried advance must not skip a stage. +- Student polling treats a newer exercise revision as authoritative. +- An out-of-order older response must never replace newer stage state in the browser. +- Case definition changes after an exercise starts must not silently reinterpret the running exercise. Initial implementation should rely on immutable deployed definition/version and record the simulation definition version on the exercise. +- If a staged definition is missing/corrupt after an exercise was created, fail closed: do not expose full Case Study state to Students. + +## Accessibility requirements + +- current stage and lifecycle changes announced through a polite live region; +- pause/freeze/debrief state is conveyed in text, not color alone; +- Student case panel is fully keyboard reachable and closable; +- all content block images require alt text; +- tables use semantic headers; +- Instructor controls have explicit accessible names and disabled/busy states; +- focus is intentionally managed after stage transitions/dialog actions; +- no timing requirement depends on a Student reacting within a fixed duration; +- real-browser axe serious/critical violations remain fatal. + +## Browser acceptance journeys + +Before #313 is mergeable, deterministic real-browser coverage should prove: + +1. Instructor starts class, creates teams, selects a staged synthetic test case, and starts Stage 1. +2. Two Students see only Stage 1 content; a direct attempt to fetch Stage 2/future content fails. +3. Student full-payload request for the active staged case cannot bypass progressive disclosure. +4. Team A and Team B keep separate Intake work. +5. Team marks Ready; Instructor sees readiness without seeing another team's editable authority. +6. Late Student joins during Stage 1 and sees exactly the current cumulative release. +7. Instructor releases optional evidence; all Students receive it without page reload. +8. Instructor begins debrief; immutable team checkpoints are captured. +9. With freeze enabled, Student UI is read-only **and direct server PUT is rejected**. +10. Instructor observer/coaching still works during debrief. +11. Instructor advances to Stage 2; Students receive new content and retain their same team Intake. +12. Instructor moves a Student A -> B; stage access remains correct, old A edit authority fails, and destination Intake wins. +13. Unassigned Student remains in the current exercise safely but cannot mark a workspace Ready. +14. Instructor-only notes/model content never appear in Student responses, DOM, public browser assets, logs, or error messages. +15. Mobile case reference and all stage controls remain keyboard/screen-reader viable. + +Use a **synthetic deterministic staged case in the test fixture** until authoritative production stage content has been explicitly supplied/reviewed. Do not fabricate official case pages merely to satisfy browser tests. + +## Implementation sequence + +### Tranche 1 — architecture / server contract +- this document; +- cold-start/workstream/roadmap/API-agent governance; +- no runtime/schema changes. + +### Tranche 2 — staged Case Study authoring schema +- extend Case Study validator/server-only manifest for optional `simulation`; +- keep public manifest unchanged; +- add validation tests proving Student/Instructor separation; +- no production staged definition unless authoritative content exists. + +### Tranche 3 — exercise persistence/repository +- additive exercise/release/workspace-state/checkpoint tables; +- repository methods; +- deterministic in-memory parity; +- optimistic revision and idempotency tests. + +### Tranche 4 — exercise APIs +- Instructor create/read/lifecycle actions; +- Student class-session stage read; +- Student readiness; +- full-payload bypass closure for active staged case; +- authorization matrix. + +### Tranche 5 — server-enforced editing policy +- classroom-student write guard; +- temporary lock response contract; +- no impact on Standalone collaboration. + +### Tranche 6 — Instructor exercise console +- staged case selection; +- lifecycle/release/freeze controls; +- readiness/progress integrated with existing dashboard. + +### Tranche 7 — Student case reference +- stage-aware side panel/drawer; +- released content only; +- ready/debrief/freeze UX; +- automatic revision transitions. + +### Tranche 8 — checkpoints / debrief views +- stage checkpoint capture/inspection; +- Instructor facilitation prompts; +- preserve Student work; never overwrite with exemplar. + +### Tranche 9 — integrated browser/security/accessibility acceptance +- complete multi-browser staged journey; +- unauthorized future/full payload checks; +- late join/reassignment; +- server freeze; +- mobile/a11y. + +### Tranche 10 — final docs/security/merge readiness +- full diff audit; +- cold-start reconciliation; +- merge only from a green documented head. + +## Rollback boundary + +The first server changes should be additive: +- #312 class/participant/workspace behavior remains valid without an exercise; +- existing collaboration remains authoritative for live Intake; +- existing non-staged protected Case Studies remain supported; +- exercise tables/routes may be unused if the staged UI is rolled back; +- no destructive migration is required to disable #313. + +Do not make Standalone or ordinary Classroom assignment boot depend on an active exercise. + +## Exact first implementation action after this architecture tranche + +Extend the authored Case Study schema and build validator with an **optional** server-only `simulation` definition, plus tests that prove: +- Standard Templates cannot carry protected simulation definitions into the public manifest; +- staged Case Study Student release and Instructor content are structurally distinct; +- stable content/stage IDs are unique and cross-references are valid; +- no existing Case Study is forced to become staged; +- existing generated manifests remain compatible. + +Stop before persistence/API/UI work and return to a green gate. From 2a35dc5b81eaa8427bf91d7b8897e09417af18cc Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:42:28 -0400 Subject: [PATCH 002/206] docs: activate staged simulation roadmap --- docs/classroom-roadmap.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/classroom-roadmap.md b/docs/classroom-roadmap.md index f92f38e..951b8e5 100644 --- a/docs/classroom-roadmap.md +++ b/docs/classroom-roadmap.md @@ -221,7 +221,9 @@ The eight-slice #288 program establishes the secure Classroom foundation; it doe - **#312 — Instructor-run live class, roster, and dynamic team management:** Instructor-created classes, one human-friendly Student join path, waiting/late roster, accessible team formation, live reassignment, automatic Student workspace switching, and high-signal team monitoring. - **#313 — Instructor-orchestrated case simulation and staged debrief:** staged protected Case Study release, case reference beside Intake, Instructor pause/debrief/advance controls, progressive disclosure, team progress, and server-gated future-stage / Instructor-only material. -#312 is implemented on draft PR #314 (`feature/classroom-live-management`): Start Class, one human Student join code, Waiting/late roster, stable Student class sessions, accessible team creation/assignment, safe reassignment/unassign with stale-authority cutoff, observer/coaching integration, and integrated multi-browser acceptance are green. The final #312 gate is documentation/security/merge readiness. #313 remains the next dependent product slice and must start from the merged #312 substrate. +#312 is complete and merged as PR #314 at `b141d55050207e4cf7ce842a4452a220a5268968`: Start Class, one human Student join code, Waiting/late roster, stable Student class sessions, accessible team creation/assignment, safe reassignment/unassign with stale-authority cutoff, observer/coaching integration, and integrated multi-browser acceptance are the live substrate. + +#313 is now active on `feature/classroom-staged-simulation`, based directly on that merge. Its canonical contract is `docs/classroom-staged-simulation.md`. The first tranche is architecture/governance only; staged Case Study authoring schema comes next, before exercise persistence/API/UI. ## Stacked PR rules From 4cc497e4410d3562c20161199212cc98879e5355 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:42:55 -0400 Subject: [PATCH 003/206] docs: checkpoint staged simulation workstream --- docs/classroom-workstream.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md index 79bf91e..f036e82 100644 --- a/docs/classroom-workstream.md +++ b/docs/classroom-workstream.md @@ -381,6 +381,40 @@ Continue #295 on draft PR #307 from the current branch head. - #312 browser acceptance now covers the complete live-class lifecycle without adding #313 staged simulation state. - Exact next action: perform the final #312 docs/security/merge-readiness review: reconcile API/architecture docs with the implemented one-code lifecycle, review the full `main -> #314` diff and unresolved review/security state, confirm additive rollback/legacy compatibility, and move #314 out of draft only if the final documented head remains green. Keep #313 as the next separate product slice. +## #312 merge boundary + +- #312 completed in PR #314. +- Squash merge on `main`: `b141d55050207e4cf7ce842a4452a220a5268968`. +- Final #312 head before merge: `d6a2f27b95639340ad46f6e596368f064671a08f`. +- Final gate: repository quality **252 tests / 251 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. +- #312 issue closed. +- #313 starts from the merge commit, not from the old #312 feature branch. + +## Active #313 implementation + +- Issue: #313 — Instructor-orchestrated case simulation and staged debrief. +- Branch: `feature/classroom-staged-simulation`. +- Base: #312 merge `b141d55050207e4cf7ce842a4452a220a5268968`. +- Canonical contract: `docs/classroom-staged-simulation.md`. +- Architecture checkpoint: `808bdfaa74e7e6a11a88173ee43c5c86d9a8da51`. +- First tranche is architecture/governance only; no exercise tables/routes/UI yet. +- Decided model: + - staged simulation is class-level orchestration layered over #312 assignment/workspaces; + - exercise lifecycle is separate from Intake/collaboration/coaching state; + - Student staged reads use the stable Student class-session capability, allowing safe current-stage access while Waiting; + - Student collaboration edits remain assignment-specific workspace-capability authorized; + - future-stage and Instructor-only content are withheld server-side; + - existing full protected Case Study `state` is source/exemplar material and cannot serve as the Student staged feed; + - a staged Case Study requires an explicit server-only `simulation` definition; never infer official stages from a completed Intake payload; + - while a staged exercise is active, the Student full-payload Case Study endpoint must not provide a bypass to the selected case's complete `state`; + - workspace readiness is team-scoped; + - beginning debrief captures immutable per-workspace checkpoints so pre-debrief reasoning can be discussed later without overwriting live Intake; + - optional edit freeze must be enforced on the Student collaboration write path, not only in the UI; + - late join enters the current class stage; reassignment preserves class stage while destination team Intake/readiness wins; + - Instructor lifecycle mutations use optimistic exercise revision so retries cannot double-advance. +- Production stage content is intentionally **not authored yet**. Use synthetic deterministic staged content for infrastructure tests until authoritative case pages/evidence/facilitation material is supplied and reviewed. +- Exact next action after this architecture tranche: extend Case Study authoring validation/server-only manifest with an **optional** `simulation` definition and focused tests for unique IDs/cross-references, Student-vs-Instructor content separation, public-manifest exclusion, and backward compatibility. Stop before exercise persistence/API/UI. + ## Approved Classroom follow-on direction The current #288 program establishes the secure Classroom foundation, but it is **not** the final Instructor-led teaching experience. From dae820db1e3d39e55abdf7d018f2450ab456dd10 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:43:13 -0400 Subject: [PATCH 004/206] docs: add staged simulation API guardrails --- api/AGENTS.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/api/AGENTS.md b/api/AGENTS.md index 0cfbe45..f809beb 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -133,3 +133,22 @@ Rules: - assignment changes use optimistic `assignment_revision` checks; a conflicting update may leave old access revoked, but must never leave old access valid or remap it to a new workspace. - stale presence cleanup after reassignment uses internal workspace identity, not the revoked bearer token. - individual live workspaces remain single-participant; cross-class destination assignment is rejected generically. + +## #313 staged simulation direction + +Read `docs/classroom-staged-simulation.md` before modifying exercise/stage/protected-resource behavior. + +Rules: +- exercise lifecycle is class-scoped Instructor authority and remains separate from Intake snapshots, collaboration revision, and coaching revision; +- Student staged exercise reads authenticate with the stable Student class-session capability and return only the represented learner's class-level release/readiness context; +- a Student class-session token still does **not** authorize collaboration edits; +- future-stage material and Instructor-only facilitation/model/exemplar content must be omitted server-side from Student responses; +- never ship a complete staged Case Study to the browser and rely on UI hiding for progressive disclosure; +- existing protected Case Study `state` is complete source/exemplar material; when that case is the active staged exercise, the Student full-payload route must not bypass staged release; +- optional content release, debrief, and stage advance are Instructor-only and optimistic-revision protected; +- Student/team Ready state resolves the participant's current assignment server-side; stale pre-reassignment authority cannot mutate old-team exercise state; +- beginning debrief must capture idempotent immutable workspace checkpoints before any optional post-debrief edits; +- if Student editing is frozen, enforce it on the `classroom-student` collaboration write path; UI read-only projection alone is not authority; +- late joiners receive only the current cumulative Student release; reassignment changes workspace Intake/readiness, not class exercise stage; +- do not create production stage definitions from guessed official case boundaries. Synthetic deterministic staged content is acceptable in tests until authoritative material is supplied. + From 294fa82164f10c3a0cc14a2f8577abde51b04c31 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:43:30 -0400 Subject: [PATCH 005/206] docs: govern staged Case Study authoring --- templates/AGENTS.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/templates/AGENTS.md b/templates/AGENTS.md index c29a578..66e4ed8 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -39,6 +39,23 @@ Role policy remains separate from confidentiality: The rotating Case Study mode password is pedagogy only. Authorization comes from the active classroom capability. + + +## Optional staged simulation definition (#313) + +A protected `case-study` may later add a server-only `simulation` object validated by `scripts/build-templates-manifest.mjs`. Standard Templates must not use this field. + +Keep three concepts separate: +- `state` — complete protected source/exemplar Intake payload; +- `simulation.studentContent` — only content blocks eligible for controlled Student release; +- `simulation.instructorContent` — facilitation/debrief/model material that Student endpoints must never return. + +`simulation.stages` references stable content IDs. Stage/content IDs must be unique and cross-references valid. Future-stage Student content must remain server-gated, not preloaded into browser assets. + +Do not infer or fabricate official stage boundaries from a completed `state`. Until authoritative case pages/evidence/facilitation material is supplied and reviewed, use synthetic staged definitions only in deterministic tests/fixtures rather than production authored Case Studies. + +Read `docs/classroom-staged-simulation.md` for the full lifecycle and authorization contract before adding or editing staged content. + ## Editing Workflow 1. Edit/add the authored JSON under `templates/`. 2. Run `npm run build:templates`. From 8f158aa58ba70b188e4b4bfe28490499f1279a4c Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:43:45 -0400 Subject: [PATCH 006/206] docs: add staged simulation cold-start rules --- AGENTS.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index da599de..6e40dda 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ See `docs/REPOSITORY-OPERATIONS.md#delivery-resilience-for-ai-assisted-work` for ## Long-running Classroom Program -The Standalone / Student / Instructor program is tracked by #288. Any work touching experience roles, classes, classroom workspaces, instructor observation, coaching, or protected Case Studies must read `docs/classroom-architecture.md`, `docs/classroom-roadmap.md`, and `docs/classroom-workstream.md` before editing. Class/API authorization work must additionally read `docs/classroom-api.md` and `api/AGENTS.md`. +The Standalone / Student / Instructor program is tracked by #288. Any work touching experience roles, classes, classroom workspaces, instructor observation, coaching, or protected Case Studies must read `docs/classroom-architecture.md`, `docs/classroom-roadmap.md`, and `docs/classroom-workstream.md` before editing. Class/API authorization work must additionally read `docs/classroom-api.md` and `api/AGENTS.md`. Staged exercise/debrief work under #313 must also read `docs/classroom-staged-simulation.md`. Classroom invariants: @@ -48,6 +48,7 @@ Classroom invariants: - student credentials never provide class/workspace enumeration; - Case Study unlock passwords are pedagogy, not authentication; - protected Case Study metadata/payloads are server-gated, absent from public browser/static assets, and authored `templates/*.json` remains excluded from Vercel deployment; `src/templates.manifest.js` is Standard-only and browser code must never import `api/protected-case-studies.manifest.js`. +- staged simulation content follows `docs/classroom-staged-simulation.md`: future-stage and Instructor-only material are server-withheld, complete protected Case Study `state` cannot bypass an active staged release, and exercise/checkpoint state never enters Intake serialization; - public Vercel static output comes only from generated `dist/`; never set `outputDirectory` back to `.` or otherwise expose repository docs/tests/scripts/AGENTS/authoring files. `scripts/build-vercel-public.mjs` owns the allowlisted browser bundle and is part of the canonical quality gate. Every classroom PR must update `docs/classroom-workstream.md` before handoff with its issue, PR/base dependency, current HEAD, completed/incomplete work, validation, risks, and exact next action. For stacked PRs, state the merge order and never describe an unmerged dependent PR as independently mergeable. From 1b845b15a14e71bf0a20e112cd721832f25af3d0 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:45:13 -0400 Subject: [PATCH 007/206] docs: defer API staged rules to tested runtime tranche --- api/AGENTS.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/api/AGENTS.md b/api/AGENTS.md index f809beb..0cfbe45 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -133,22 +133,3 @@ Rules: - assignment changes use optimistic `assignment_revision` checks; a conflicting update may leave old access revoked, but must never leave old access valid or remap it to a new workspace. - stale presence cleanup after reassignment uses internal workspace identity, not the revoked bearer token. - individual live workspaces remain single-participant; cross-class destination assignment is rejected generically. - -## #313 staged simulation direction - -Read `docs/classroom-staged-simulation.md` before modifying exercise/stage/protected-resource behavior. - -Rules: -- exercise lifecycle is class-scoped Instructor authority and remains separate from Intake snapshots, collaboration revision, and coaching revision; -- Student staged exercise reads authenticate with the stable Student class-session capability and return only the represented learner's class-level release/readiness context; -- a Student class-session token still does **not** authorize collaboration edits; -- future-stage material and Instructor-only facilitation/model/exemplar content must be omitted server-side from Student responses; -- never ship a complete staged Case Study to the browser and rely on UI hiding for progressive disclosure; -- existing protected Case Study `state` is complete source/exemplar material; when that case is the active staged exercise, the Student full-payload route must not bypass staged release; -- optional content release, debrief, and stage advance are Instructor-only and optimistic-revision protected; -- Student/team Ready state resolves the participant's current assignment server-side; stale pre-reassignment authority cannot mutate old-team exercise state; -- beginning debrief must capture idempotent immutable workspace checkpoints before any optional post-debrief edits; -- if Student editing is frozen, enforce it on the `classroom-student` collaboration write path; UI read-only projection alone is not authority; -- late joiners receive only the current cumulative Student release; reassignment changes workspace Intake/readiness, not class exercise stage; -- do not create production stage definitions from guessed official case boundaries. Synthetic deterministic staged content is acceptable in tests until authoritative material is supplied. - From 46109491808bedfbf5e778d0979859b3cd96b56c Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:49:26 -0400 Subject: [PATCH 008/206] feat: validate staged Case Study definitions --- scripts/staged-simulation-schema.mjs | 358 +++++++++++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 scripts/staged-simulation-schema.mjs diff --git a/scripts/staged-simulation-schema.mjs b/scripts/staged-simulation-schema.mjs new file mode 100644 index 0000000..8288053 --- /dev/null +++ b/scripts/staged-simulation-schema.mjs @@ -0,0 +1,358 @@ +/** + * @module stagedSimulationSchema + * @summary Validates and normalizes optional server-only staged Case Study definitions. + * @description + * Staged simulation metadata is authored only on protected Case Studies and is + * emitted only to the server-only protected manifest. This module deliberately + * keeps Student-release content structurally separate from Instructor-only + * facilitation/model content and rejects ambiguous or orphan references. + */ + +export const STAGED_SIMULATION_VERSION = 1; + +export const STUDENT_CONTENT_KINDS = Object.freeze([ + 'narrative', + 'prompt', + 'evidence', + 'image', + 'table', + 'document-page' +]); + +export const INSTRUCTOR_CONTENT_KINDS = Object.freeze([ + 'facilitation', + 'debrief', + 'exemplar' +]); + +export const DEBRIEF_EDIT_POLICIES = Object.freeze(['open', 'frozen']); + +const STABLE_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/u; +const STUDENT_BLOCK_FIELDS = Object.freeze({ + narrative: Object.freeze(['id', 'kind', 'title', 'body']), + prompt: Object.freeze(['id', 'kind', 'title', 'body']), + evidence: Object.freeze(['id', 'kind', 'title', 'body']), + image: Object.freeze(['id', 'kind', 'title', 'assetId', 'alt']), + table: Object.freeze(['id', 'kind', 'title', 'headers', 'rows']), + 'document-page': Object.freeze(['id', 'kind', 'title', 'assetId', 'page', 'alt']) +}); +const INSTRUCTOR_BLOCK_FIELDS = Object.freeze(['id', 'kind', 'title', 'body']); +const STAGE_FIELDS = Object.freeze([ + 'id', + 'title', + 'studentObjective', + 'initialReleaseIds', + 'optionalReleaseIds', + 'intakeTargetIds', + 'suggestedMinutes', + 'instructorContentIds', + 'defaultDebriefEditPolicy' +]); +const ROOT_FIELDS = Object.freeze(['version', 'studentContent', 'instructorContent', 'stages']); + +function isRecord(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function unique(values) { + return new Set(values).size === values.length; +} + +function nonEmptyString(value) { + return typeof value === 'string' && Boolean(value.trim()); +} + +function checkAllowedFields(value, allowed, ctx, errors) { + for (const field of Object.keys(value)) { + if (!allowed.includes(field)) errors.push(`${ctx}.${field} is not allowed in simulation version 1`); + } +} + +function checkStableId(value, ctx, errors) { + if (!nonEmptyString(value) || !STABLE_ID_PATTERN.test(value)) { + errors.push(`${ctx} must be a stable lowercase slug (letters, numbers, hyphens; max 64 characters)`); + return false; + } + return true; +} + +function checkStringArray(value, ctx, errors, { stableIds = false } = {}) { + if (!Array.isArray(value)) { + errors.push(`${ctx} must be an array`); + return []; + } + const normalized = []; + value.forEach((item, index) => { + if (!nonEmptyString(item)) { + errors.push(`${ctx}[${index}] must be a non-empty string`); + return; + } + const trimmed = item.trim(); + if (stableIds && !STABLE_ID_PATTERN.test(trimmed)) { + errors.push(`${ctx}[${index}] must be a stable lowercase slug`); + return; + } + normalized.push(trimmed); + }); + if (!unique(normalized)) errors.push(`${ctx} must not contain duplicate values`); + return normalized; +} + +function validateStudentContentBlock(block, index, errors) { + const ctx = `simulation.studentContent[${index}]`; + if (!isRecord(block)) { + errors.push(`${ctx} must be an object`); + return; + } + + const kind = typeof block.kind === 'string' ? block.kind.trim() : ''; + if (!STUDENT_CONTENT_KINDS.includes(kind)) { + errors.push(`${ctx}.kind must be one of: ${STUDENT_CONTENT_KINDS.join(', ')}`); + return; + } + checkAllowedFields(block, STUDENT_BLOCK_FIELDS[kind], ctx, errors); + checkStableId(block.id, `${ctx}.id`, errors); + if (!nonEmptyString(block.title)) errors.push(`${ctx}.title must be a non-empty string`); + + if (['narrative', 'prompt', 'evidence'].includes(kind)) { + if (!nonEmptyString(block.body)) errors.push(`${ctx}.body must be a non-empty string`); + return; + } + + if (kind === 'image') { + checkStableId(block.assetId, `${ctx}.assetId`, errors); + if (!nonEmptyString(block.alt)) errors.push(`${ctx}.alt must be a non-empty string`); + return; + } + + if (kind === 'document-page') { + checkStableId(block.assetId, `${ctx}.assetId`, errors); + if (!Number.isInteger(block.page) || block.page < 1) { + errors.push(`${ctx}.page must be a positive integer`); + } + if (!nonEmptyString(block.alt)) errors.push(`${ctx}.alt must be a non-empty string`); + return; + } + + if (!Array.isArray(block.headers) || block.headers.length === 0) { + errors.push(`${ctx}.headers must be a non-empty array`); + } else if (block.headers.some(header => !nonEmptyString(header))) { + errors.push(`${ctx}.headers must contain only non-empty strings`); + } + + if (!Array.isArray(block.rows) || block.rows.length === 0) { + errors.push(`${ctx}.rows must be a non-empty array`); + } else { + const width = Array.isArray(block.headers) ? block.headers.length : 0; + block.rows.forEach((row, rowIndex) => { + if (!Array.isArray(row) || row.length !== width || row.some(cell => typeof cell !== 'string')) { + errors.push(`${ctx}.rows[${rowIndex}] must contain exactly ${width} string cells`); + } + }); + } +} + +function validateInstructorContentBlock(block, index, errors) { + const ctx = `simulation.instructorContent[${index}]`; + if (!isRecord(block)) { + errors.push(`${ctx} must be an object`); + return; + } + checkAllowedFields(block, INSTRUCTOR_BLOCK_FIELDS, ctx, errors); + checkStableId(block.id, `${ctx}.id`, errors); + if (!INSTRUCTOR_CONTENT_KINDS.includes(block.kind)) { + errors.push(`${ctx}.kind must be one of: ${INSTRUCTOR_CONTENT_KINDS.join(', ')}`); + } + if (!nonEmptyString(block.title)) errors.push(`${ctx}.title must be a non-empty string`); + if (!nonEmptyString(block.body)) errors.push(`${ctx}.body must be a non-empty string`); +} + +function validateStage(stage, index, errors) { + const ctx = `simulation.stages[${index}]`; + if (!isRecord(stage)) { + errors.push(`${ctx} must be an object`); + return; + } + checkAllowedFields(stage, STAGE_FIELDS, ctx, errors); + checkStableId(stage.id, `${ctx}.id`, errors); + if (!nonEmptyString(stage.title)) errors.push(`${ctx}.title must be a non-empty string`); + if (!nonEmptyString(stage.studentObjective)) { + errors.push(`${ctx}.studentObjective must be a non-empty string`); + } + + checkStringArray(stage.initialReleaseIds, `${ctx}.initialReleaseIds`, errors, { stableIds: true }); + checkStringArray(stage.optionalReleaseIds, `${ctx}.optionalReleaseIds`, errors, { stableIds: true }); + checkStringArray(stage.intakeTargetIds, `${ctx}.intakeTargetIds`, errors); + checkStringArray(stage.instructorContentIds, `${ctx}.instructorContentIds`, errors, { stableIds: true }); + + if ( + stage.suggestedMinutes !== null + && stage.suggestedMinutes !== undefined + && (!Number.isInteger(stage.suggestedMinutes) || stage.suggestedMinutes < 1 || stage.suggestedMinutes > 480) + ) { + errors.push(`${ctx}.suggestedMinutes must be null or an integer from 1 to 480`); + } + + if (!DEBRIEF_EDIT_POLICIES.includes(stage.defaultDebriefEditPolicy)) { + errors.push(`${ctx}.defaultDebriefEditPolicy must be one of: ${DEBRIEF_EDIT_POLICIES.join(', ')}`); + } +} + +/** + * Validate an optional staged simulation definition. + * + * @param {unknown} simulation Candidate simulation definition. + * @returns {string[]} Validation errors; empty when valid. + */ +export function validateStagedSimulation(simulation) { + const errors = []; + if (!isRecord(simulation)) return ['simulation must be an object']; + + checkAllowedFields(simulation, ROOT_FIELDS, 'simulation', errors); + + if (simulation.version !== STAGED_SIMULATION_VERSION) { + errors.push(`simulation.version must equal ${STAGED_SIMULATION_VERSION}`); + } + + if (!Array.isArray(simulation.studentContent)) { + errors.push('simulation.studentContent must be an array'); + } else { + simulation.studentContent.forEach((block, index) => validateStudentContentBlock(block, index, errors)); + } + + if (!Array.isArray(simulation.instructorContent)) { + errors.push('simulation.instructorContent must be an array'); + } else { + simulation.instructorContent.forEach((block, index) => validateInstructorContentBlock(block, index, errors)); + } + + if (!Array.isArray(simulation.stages) || simulation.stages.length === 0) { + errors.push('simulation.stages must be a non-empty array'); + } else { + simulation.stages.forEach((stage, index) => validateStage(stage, index, errors)); + } + + if (errors.length) return errors; + + const studentIds = simulation.studentContent.map(block => block.id.trim()); + const instructorIds = simulation.instructorContent.map(block => block.id.trim()); + const stageIds = simulation.stages.map(stage => stage.id.trim()); + const allContentIds = [...studentIds, ...instructorIds]; + + if (!unique(studentIds)) errors.push('simulation.studentContent ids must be unique'); + if (!unique(instructorIds)) errors.push('simulation.instructorContent ids must be unique'); + if (!unique(allContentIds)) errors.push('Student and Instructor content ids must not collide'); + if (!unique(stageIds)) errors.push('simulation.stages ids must be unique'); + + const studentSet = new Set(studentIds); + const instructorSet = new Set(instructorIds); + const usedStudentIds = new Set(); + const usedInstructorIds = new Set(); + + simulation.stages.forEach((stage, index) => { + const initial = stage.initialReleaseIds.map(value => value.trim()); + const optional = stage.optionalReleaseIds.map(value => value.trim()); + const overlap = initial.filter(id => optional.includes(id)); + if (overlap.length) { + errors.push(`simulation.stages[${index}] must not list the same Student content as both initial and optional release: ${overlap.join(', ')}`); + } + + [...initial, ...optional].forEach(id => { + if (!studentSet.has(id)) { + errors.push(`simulation.stages[${index}] references unknown Student content id: ${id}`); + } else { + usedStudentIds.add(id); + } + }); + + stage.instructorContentIds.map(value => value.trim()).forEach(id => { + if (!instructorSet.has(id)) { + errors.push(`simulation.stages[${index}] references unknown Instructor content id: ${id}`); + } else { + usedInstructorIds.add(id); + } + }); + }); + + studentIds.filter(id => !usedStudentIds.has(id)).forEach(id => { + errors.push(`simulation.studentContent id is not referenced by any stage: ${id}`); + }); + instructorIds.filter(id => !usedInstructorIds.has(id)).forEach(id => { + errors.push(`simulation.instructorContent id is not referenced by any stage: ${id}`); + }); + + return errors; +} + +function normalizeStudentBlock(block) { + const common = { + id: block.id.trim(), + kind: block.kind.trim(), + title: block.title.trim() + }; + if (['narrative', 'prompt', 'evidence'].includes(common.kind)) { + return { ...common, body: block.body.trim() }; + } + if (common.kind === 'image') { + return { ...common, assetId: block.assetId.trim(), alt: block.alt.trim() }; + } + if (common.kind === 'document-page') { + return { + ...common, + assetId: block.assetId.trim(), + page: block.page, + alt: block.alt.trim() + }; + } + return { + ...common, + headers: block.headers.map(value => value.trim()), + rows: block.rows.map(row => row.map(value => value.trim())) + }; +} + +/** + * Normalize a simulation definition after validation. + * + * @param {object} simulation Valid simulation definition. + * @returns {object} Canonical version-1 server manifest shape. + */ +export function normalizeStagedSimulation(simulation) { + return { + version: STAGED_SIMULATION_VERSION, + studentContent: simulation.studentContent.map(normalizeStudentBlock), + instructorContent: simulation.instructorContent.map(block => ({ + id: block.id.trim(), + kind: block.kind.trim(), + title: block.title.trim(), + body: block.body.trim() + })), + stages: simulation.stages.map(stage => ({ + id: stage.id.trim(), + title: stage.title.trim(), + studentObjective: stage.studentObjective.trim(), + initialReleaseIds: stage.initialReleaseIds.map(value => value.trim()), + optionalReleaseIds: stage.optionalReleaseIds.map(value => value.trim()), + intakeTargetIds: stage.intakeTargetIds.map(value => value.trim()), + suggestedMinutes: stage.suggestedMinutes ?? null, + instructorContentIds: stage.instructorContentIds.map(value => value.trim()), + defaultDebriefEditPolicy: stage.defaultDebriefEditPolicy + })) + }; +} + +/** + * Validate whether a resource may carry staged simulation metadata. + * + * @param {object} options Validation context. + * @param {string} options.templateKind Resource kind. + * @param {unknown} options.simulation Optional simulation definition. + * @returns {string[]} Validation errors. + */ +export function validateSimulationForResource({ templateKind, simulation }) { + if (simulation === undefined) return []; + if (templateKind !== 'case-study') { + return ['simulation is allowed only on protected case-study resources']; + } + return validateStagedSimulation(simulation); +} From 1f716470cfdf95a64c00a8a99a62735133feae61 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:49:41 -0400 Subject: [PATCH 009/206] feat: emit optional staged simulation metadata --- scripts/build-templates-manifest.mjs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/build-templates-manifest.mjs b/scripts/build-templates-manifest.mjs index 76668de..c9e85c8 100755 --- a/scripts/build-templates-manifest.mjs +++ b/scripts/build-templates-manifest.mjs @@ -8,6 +8,10 @@ import { fileURLToPath } from 'node:url'; import { TEMPLATE_MODE_IDS } from '../src/templateModes.js'; import { TEMPLATE_KINDS } from '../src/templateKinds.js'; +import { + normalizeStagedSimulation, + validateSimulationForResource +} from './staged-simulation-schema.mjs'; const templatesDir = new URL('../templates/', import.meta.url); const publicManifestPath = new URL('../src/templates.manifest.js', import.meta.url); @@ -156,7 +160,7 @@ async function readTemplateFile(fileName) { if (!isRecord(data)) { throw new Error(`${fileName} must contain a JSON object`); } - const { id, name, description, supportedModes, templateKind, state } = data; + const { id, name, description, supportedModes, templateKind, state, simulation } = data; if (typeof id !== 'string' || !id.trim()) { errors.push('id must be a non-empty string'); } @@ -179,6 +183,10 @@ async function readTemplateFile(fileName) { } const stateErrors = validateSerializedAppState(state); errors.push(...stateErrors.map(message => `state invalid: ${message}`)); + + const simulationErrors = validateSimulationForResource({ templateKind, simulation }); + errors.push(...simulationErrors.map(message => `simulation invalid: ${message}`)); + if (errors.length) { throw new Error(`${fileName} failed validation:\n- ${errors.join('\n- ')}`); } @@ -188,7 +196,8 @@ async function readTemplateFile(fileName) { description: description.trim(), supportedModes: supportedModes.map(mode => mode.trim()), templateKind, - state + state, + ...(simulation === undefined ? {} : { simulation: normalizeStagedSimulation(simulation) }) }; } From 9322cecabe9a487e072dd279519b5f358f089ebd Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:50:28 -0400 Subject: [PATCH 010/206] test: cover staged Case Study schema --- tests/stagedCaseSimulation.unit.test.mjs | 253 +++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 tests/stagedCaseSimulation.unit.test.mjs diff --git a/tests/stagedCaseSimulation.unit.test.mjs b/tests/stagedCaseSimulation.unit.test.mjs new file mode 100644 index 0000000..9cc6e66 --- /dev/null +++ b/tests/stagedCaseSimulation.unit.test.mjs @@ -0,0 +1,253 @@ +/** + * Focused validation for optional server-only staged Case Study definitions. + */ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + normalizeStagedSimulation, + validateSimulationForResource, + validateStagedSimulation +} from '../scripts/staged-simulation-schema.mjs'; + +function validSimulation() { + return { + version: 1, + studentContent: [ + { + id: 'initial-briefing', + kind: 'narrative', + title: ' Initial briefing ', + body: ' A machine has stopped unexpectedly. ' + }, + { + id: 'quality-table', + kind: 'table', + title: 'Quality readings', + headers: ['Line', 'Reject rate'], + rows: [['A', '2%'], ['B', '7%']] + }, + { + id: 'inspection-image', + kind: 'image', + title: 'Inspection photo', + assetId: 'inspection-photo-1', + alt: 'Close view of the affected surface' + }, + { + id: 'supplement-page', + kind: 'document-page', + title: 'Supplemental report', + assetId: 'report-1', + page: 2, + alt: 'Second page of the supplemental report' + } + ], + instructorContent: [ + { + id: 'stage-one-notes', + kind: 'facilitation', + title: 'Stage 1 facilitation', + body: 'Ask teams what they know before offering more evidence.' + }, + { + id: 'stage-two-exemplar', + kind: 'exemplar', + title: 'Stage 2 exemplar', + body: 'Use only during the Instructor-led debrief.' + } + ], + stages: [ + { + id: 'stage-1', + title: 'Clarify the situation', + studentObjective: 'Establish what is known before diagnosing.', + initialReleaseIds: ['initial-briefing', 'quality-table'], + optionalReleaseIds: ['inspection-image'], + intakeTargetIds: ['problem.one-line'], + suggestedMinutes: 15, + instructorContentIds: ['stage-one-notes'], + defaultDebriefEditPolicy: 'frozen' + }, + { + id: 'stage-2', + title: 'Use additional evidence', + studentObjective: 'Refine the analysis using the newly released evidence.', + initialReleaseIds: ['supplement-page'], + optionalReleaseIds: [], + intakeTargetIds: ['problem.one-line', 'pa.what-object'], + suggestedMinutes: null, + instructorContentIds: ['stage-two-exemplar'], + defaultDebriefEditPolicy: 'open' + } + ] + }; +} + +test('valid staged simulation keeps Student and Instructor content structurally separate', () => { + const simulation = validSimulation(); + assert.deepEqual(validateStagedSimulation(simulation), []); + + const normalized = normalizeStagedSimulation(simulation); + assert.equal(normalized.version, 1); + assert.equal(normalized.studentContent[0].title, 'Initial briefing'); + assert.equal(normalized.studentContent[0].body, 'A machine has stopped unexpectedly.'); + assert.equal(normalized.instructorContent[0].kind, 'facilitation'); + assert.equal(normalized.stages[0].defaultDebriefEditPolicy, 'frozen'); + + const studentIds = new Set(normalized.studentContent.map(item => item.id)); + const instructorIds = new Set(normalized.instructorContent.map(item => item.id)); + for (const stage of normalized.stages) { + for (const id of [...stage.initialReleaseIds, ...stage.optionalReleaseIds]) { + assert.equal(studentIds.has(id), true, `${id} should resolve only to Student content`); + assert.equal(instructorIds.has(id), false, `${id} must not resolve to Instructor content`); + } + for (const id of stage.instructorContentIds) { + assert.equal(instructorIds.has(id), true, `${id} should resolve only to Instructor content`); + assert.equal(studentIds.has(id), false, `${id} must not resolve to Student content`); + } + } +}); + +test('simulation is optional for protected cases and forbidden on Standard Templates', () => { + assert.deepEqual( + validateSimulationForResource({ templateKind: 'case-study', simulation: undefined }), + [], + 'existing non-staged Case Studies must remain valid' + ); + + const errors = validateSimulationForResource({ + templateKind: 'standard', + simulation: validSimulation() + }); + assert.ok(errors.some(message => message.includes('only on protected case-study resources'))); +}); + +test('staged simulation rejects duplicate identities and cross-namespace collisions', () => { + const simulation = validSimulation(); + simulation.studentContent.push({ + ...simulation.studentContent[0], + title: 'Duplicate briefing' + }); + simulation.instructorContent[1] = { + ...simulation.instructorContent[1], + id: 'stage-one-notes' + }; + simulation.stages[1] = { + ...simulation.stages[1], + id: 'stage-1' + }; + + const errors = validateStagedSimulation(simulation); + assert.ok(errors.includes('simulation.studentContent ids must be unique')); + assert.ok(errors.includes('simulation.instructorContent ids must be unique')); + assert.ok(errors.includes('simulation.stages ids must be unique')); + + const collision = validSimulation(); + collision.instructorContent[0] = { + ...collision.instructorContent[0], + id: 'initial-briefing' + }; + collision.stages[0] = { + ...collision.stages[0], + instructorContentIds: ['initial-briefing'] + }; + const collisionErrors = validateStagedSimulation(collision); + assert.ok(collisionErrors.includes('Student and Instructor content ids must not collide')); +}); + +test('stage references must exist in the correct content namespace and cannot double-release one item', () => { + const simulation = validSimulation(); + simulation.stages[0] = { + ...simulation.stages[0], + initialReleaseIds: ['initial-briefing', 'future-evidence'], + optionalReleaseIds: ['initial-briefing', 'stage-one-notes'], + instructorContentIds: ['quality-table'] + }; + + const errors = validateStagedSimulation(simulation); + assert.ok(errors.some(message => message.includes('same Student content as both initial and optional release'))); + assert.ok(errors.some(message => message.includes('unknown Student content id: future-evidence'))); + assert.ok(errors.some(message => message.includes('unknown Student content id: stage-one-notes'))); + assert.ok(errors.some(message => message.includes('unknown Instructor content id: quality-table'))); +}); + +test('orphan Student or Instructor content is rejected so authored protected material has an explicit stage', () => { + const simulation = validSimulation(); + simulation.studentContent.push({ + id: 'unused-evidence', + kind: 'evidence', + title: 'Unused evidence', + body: 'This should not silently ride along in a staged definition.' + }); + simulation.instructorContent.push({ + id: 'unused-teaching-note', + kind: 'debrief', + title: 'Unused note', + body: 'This must be deliberately assigned to a stage.' + }); + + const errors = validateStagedSimulation(simulation); + assert.ok(errors.includes('simulation.studentContent id is not referenced by any stage: unused-evidence')); + assert.ok(errors.includes('simulation.instructorContent id is not referenced by any stage: unused-teaching-note')); +}); + +test('content blocks reject unsafe/ambiguous shapes instead of accepting arbitrary markup or URLs', () => { + const simulation = validSimulation(); + simulation.studentContent[0] = { + id: 'initial-briefing', + kind: 'narrative', + title: 'Initial briefing', + body: 'Safe text', + html: '' + }; + simulation.studentContent[2] = { + id: 'inspection-image', + kind: 'image', + title: 'Inspection photo', + assetId: 'https://example.com/public-bypass.png', + alt: '' + }; + simulation.studentContent[3] = { + id: 'supplement-page', + kind: 'document-page', + title: 'Supplemental report', + assetId: 'report-1', + page: 0, + alt: 'Report' + }; + + const errors = validateStagedSimulation(simulation); + assert.ok(errors.some(message => message.includes('.html is not allowed'))); + assert.ok(errors.some(message => message.includes('assetId must be a stable lowercase slug'))); + assert.ok(errors.some(message => message.includes('.alt must be a non-empty string'))); + assert.ok(errors.some(message => message.includes('.page must be a positive integer'))); +}); + +test('table blocks require a semantic rectangular string matrix', () => { + const simulation = validSimulation(); + simulation.studentContent[1] = { + id: 'quality-table', + kind: 'table', + title: 'Quality readings', + headers: ['Line', 'Reject rate'], + rows: [['A'], ['B', 7]] + }; + + const errors = validateStagedSimulation(simulation); + assert.ok(errors.some(message => message.includes('rows[0] must contain exactly 2 string cells'))); + assert.ok(errors.some(message => message.includes('rows[1] must contain exactly 2 string cells'))); +}); + +test('version-1 definitions reject unknown lifecycle/content fields rather than silently widening the contract', () => { + const simulation = validSimulation(); + simulation.futureStagesVisible = true; + simulation.stages[0] = { + ...simulation.stages[0], + autoAdvanceWhenReady: true + }; + + const errors = validateStagedSimulation(simulation); + assert.ok(errors.some(message => message.includes('simulation.futureStagesVisible is not allowed'))); + assert.ok(errors.some(message => message.includes('autoAdvanceWhenReady is not allowed'))); +}); From 1e22806b8a1e50e94150a13ecf3b7083f0747667 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:50:41 -0400 Subject: [PATCH 011/206] test: protect staged simulation manifest boundary --- tests/protectedCaseManifest.unit.test.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/protectedCaseManifest.unit.test.mjs b/tests/protectedCaseManifest.unit.test.mjs index 3911a1f..08c9364 100644 --- a/tests/protectedCaseManifest.unit.test.mjs +++ b/tests/protectedCaseManifest.unit.test.mjs @@ -8,6 +8,7 @@ import { test } from 'node:test'; import { PROTECTED_CASE_STUDY_MANIFEST } from '../api/protected-case-studies.manifest.js'; import { TEMPLATE_MANIFEST } from '../src/templates.manifest.js'; import { TEMPLATE_KINDS } from '../src/templateKinds.js'; +import { validateStagedSimulation } from '../scripts/staged-simulation-schema.mjs'; test('public manifest contains only Standard Templates while protected cases stay server-side', async () => { assert.ok(TEMPLATE_MANIFEST.length > 0, 'at least one public Standard Template should exist'); @@ -22,8 +23,20 @@ test('public manifest contains only Standard Templates while protected cases sta 'server-only manifest must contain only Case Studies' ); + assert.ok( + TEMPLATE_MANIFEST.every(entry => !Object.hasOwn(entry, 'simulation')), + 'public Standard Template manifest must never contain staged simulation definitions' + ); + const publicIds = new Set(TEMPLATE_MANIFEST.map(entry => entry.id)); for (const caseStudy of PROTECTED_CASE_STUDY_MANIFEST) { + if (caseStudy.simulation !== undefined) { + assert.deepEqual( + validateStagedSimulation(caseStudy.simulation), + [], + `${caseStudy.id} staged simulation should remain valid in the server-only manifest` + ); + } assert.equal(publicIds.has(caseStudy.id), false, `${caseStudy.id} must not be public`); } From af6b6f21a8e4f25f014a8362857a1c15e252775b Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:51:13 -0400 Subject: [PATCH 012/206] docs: add tested staged simulation API guardrails --- api/AGENTS.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/api/AGENTS.md b/api/AGENTS.md index 0cfbe45..f809beb 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -133,3 +133,22 @@ Rules: - assignment changes use optimistic `assignment_revision` checks; a conflicting update may leave old access revoked, but must never leave old access valid or remap it to a new workspace. - stale presence cleanup after reassignment uses internal workspace identity, not the revoked bearer token. - individual live workspaces remain single-participant; cross-class destination assignment is rejected generically. + +## #313 staged simulation direction + +Read `docs/classroom-staged-simulation.md` before modifying exercise/stage/protected-resource behavior. + +Rules: +- exercise lifecycle is class-scoped Instructor authority and remains separate from Intake snapshots, collaboration revision, and coaching revision; +- Student staged exercise reads authenticate with the stable Student class-session capability and return only the represented learner's class-level release/readiness context; +- a Student class-session token still does **not** authorize collaboration edits; +- future-stage material and Instructor-only facilitation/model/exemplar content must be omitted server-side from Student responses; +- never ship a complete staged Case Study to the browser and rely on UI hiding for progressive disclosure; +- existing protected Case Study `state` is complete source/exemplar material; when that case is the active staged exercise, the Student full-payload route must not bypass staged release; +- optional content release, debrief, and stage advance are Instructor-only and optimistic-revision protected; +- Student/team Ready state resolves the participant's current assignment server-side; stale pre-reassignment authority cannot mutate old-team exercise state; +- beginning debrief must capture idempotent immutable workspace checkpoints before any optional post-debrief edits; +- if Student editing is frozen, enforce it on the `classroom-student` collaboration write path; UI read-only projection alone is not authority; +- late joiners receive only the current cumulative Student release; reassignment changes workspace Intake/readiness, not class exercise stage; +- do not create production stage definitions from guessed official case boundaries. Synthetic deterministic staged content is acceptable in tests until authoritative material is supplied. + From 2a0a60f1b3fe23bce1fa28643d1283ba75484366 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:55:52 -0400 Subject: [PATCH 013/206] docs: checkpoint staged Case Study schema --- docs/classroom-staged-simulation.md | 62 +++++++++++++++++++++++++---- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/docs/classroom-staged-simulation.md b/docs/classroom-staged-simulation.md index 270b1ff..ce983ed 100644 --- a/docs/classroom-staged-simulation.md +++ b/docs/classroom-staged-simulation.md @@ -159,6 +159,43 @@ Instructor-only content may include: Instructor content IDs and payloads are omitted entirely from Student responses. +## Implementation checkpoint — staged Case Study authoring schema + +Tranche 2 is implemented before exercise persistence/API/UI. + +Implemented: +- `scripts/staged-simulation-schema.mjs` defines simulation version 1 validation and canonical normalization; +- `scripts/build-templates-manifest.mjs` accepts optional `simulation` only on protected `case-study` resources and preserves it only in the server-only Case Study manifest; +- existing Case Studies without `simulation` remain valid and unchanged; +- Standard Templates are rejected if they attempt to carry `simulation`, protecting the public manifest boundary; +- Student content and Instructor content are separate namespaces and their IDs may not collide; +- stage IDs and content IDs must be stable lowercase slugs and unique; +- stage Student release references must resolve only to Student content; +- stage Instructor references must resolve only to Instructor content; +- one content item cannot be both initial and optional release in the same stage; +- orphan Student/Instructor content is rejected so protected material cannot silently ride along outside an explicit stage; +- version-1 definitions reject unknown fields instead of silently widening the contract; +- Student content kinds currently supported by the authoring contract are `narrative`, `prompt`, `evidence`, `image`, `table`, and `document-page`; +- protected image/document references use stable `assetId` values rather than arbitrary URLs; +- image/document blocks require accessible alternate text; table blocks require a rectangular string matrix; +- Instructor content kinds are `facilitation`, `debrief`, and `exemplar`; +- stage `defaultDebriefEditPolicy` is explicitly `open | frozen`. + +Durable implementation commits: +- `46109491808bedfbf5e778d0979859b3cd96b56c` — version-1 staged simulation validator/normalizer; +- `1f716470cfdf95a64c00a8a99a62735133feae61` — optional simulation integration into the protected manifest build; +- `9322cecabe9a487e072dd279519b5f358f089ebd` — focused synthetic schema/cross-reference/security tests; +- `1e22806b8a1e50e94150a13ecf3b7083f0747667` — public/protected manifest boundary regression; +- `af6b6f21a8e4f25f014a8362857a1c15e252775b` — server-agent guardrails restored alongside real tests. + +Validation on `af6b6f21...`: +- canonical repository quality: **260 tests / 259 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; +- Template Manifest Guard and Dependency Review: green; +- no production authored Case Study gained a staged definition, so existing generated protected/public resource behavior remains compatible. + +**Exact next tranche:** implement additive exercise persistence/repository primitives only: `classroom_exercises`, optional-content releases, per-workspace stage readiness, immutable debrief checkpoints, optimistic exercise revision, idempotent checkpoint/release behavior, class scoping, retention/expiry, and deterministic in-memory parity. Do not add HTTP exercise routes, edit-freeze enforcement, or Instructor/Student UI in that tranche. + ## Exercise domain model Persist orchestration state separately from static Case Study definitions. @@ -587,13 +624,22 @@ The first server changes should be additive: Do not make Standalone or ordinary Classroom assignment boot depend on an active exercise. -## Exact first implementation action after this architecture tranche +## Exact next implementation action + +Implement **Tranche 3 only**: additive exercise persistence/repository primitives and deterministic in-memory parity. + +Required repository semantics: +- create/select one draft exercise for one class from an already validated staged Case Study identity/version; +- at most one non-completed exercise per class initially; +- optimistic `exercise_revision` mutation; +- current stage/status/phase/editing policy persistence; +- idempotent optional content releases scoped to exercise + stage; +- workspace-scoped Ready state that records workspace revision evidence; +- immutable per-workspace debrief checkpoint capture that is idempotent for exercise + stage + workspace; +- class/workspace ownership validation; +- retention bounded by the owning class; +- no Intake snapshot merge/copy during ordinary stage/assignment operations. -Extend the authored Case Study schema and build validator with an **optional** server-only `simulation` definition, plus tests that prove: -- Standard Templates cannot carry protected simulation definitions into the public manifest; -- staged Case Study Student release and Instructor content are structurally distinct; -- stable content/stage IDs are unique and cross-references are valid; -- no existing Case Study is forced to become staged; -- existing generated manifests remain compatible. +Add deterministic in-memory repository parity and focused tests before any HTTP route consumes these methods. -Stop before persistence/API/UI work and return to a green gate. +Stop before exercise HTTP handlers, collaboration edit-freeze enforcement, or Instructor/Student UI. From ccae0a1800b7e2dd269c2da19ad095685698db1e Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:56:09 -0400 Subject: [PATCH 014/206] docs: advance staged simulation to persistence --- docs/classroom-workstream.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md index f036e82..887cc67 100644 --- a/docs/classroom-workstream.md +++ b/docs/classroom-workstream.md @@ -413,7 +413,15 @@ Continue #295 on draft PR #307 from the current branch head. - late join enters the current class stage; reassignment preserves class stage while destination team Intake/readiness wins; - Instructor lifecycle mutations use optimistic exercise revision so retries cannot double-advance. - Production stage content is intentionally **not authored yet**. Use synthetic deterministic staged content for infrastructure tests until authoritative case pages/evidence/facilitation material is supplied and reviewed. -- Exact next action after this architecture tranche: extend Case Study authoring validation/server-only manifest with an **optional** `simulation` definition and focused tests for unique IDs/cross-references, Student-vs-Instructor content separation, public-manifest exclusion, and backward compatibility. Stop before exercise persistence/API/UI. +- Staged Case Study authoring-schema checkpoint: + - `46109491808bedfbf5e778d0979859b3cd96b56c` adds version-1 server-side simulation validation/normalization; + - `1f716470cfdf95a64c00a8a99a62735133feae61` integrates optional `simulation` into the protected Case Study manifest build only; + - `9322cecabe9a487e072dd279519b5f358f089ebd` / `1e22806b8a1e50e94150a13ecf3b7083f0747667` add synthetic schema and public-boundary regression coverage; + - `af6b6f21a8e4f25f014a8362857a1c15e252775b` restores #313 API guardrails alongside real tests. + - Standard Templates cannot carry `simulation`; Student and Instructor content are separate, stable-ID/cross-reference validated namespaces; unsafe arbitrary fields/URLs are rejected; existing non-staged Case Studies remain unchanged. + - no official production case has been staged or guessed. + - validation on `af6b6f21...`: repository quality **260 tests / 259 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; Template Manifest Guard and Dependency Review green. +- Exact next action: implement **Tranche 3 only** — additive exercise persistence/repository + deterministic in-memory parity: class exercise lifecycle state, optimistic revision, optional releases, workspace readiness, immutable idempotent debrief checkpoints, class/workspace scoping, and retention. Do not add HTTP exercise routes, collaboration freeze enforcement, or UI yet. ## Approved Classroom follow-on direction From ea964de7afc002ca307a903db7e1493d56837f13 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:14:38 -0400 Subject: [PATCH 015/206] feat: add staged exercise persistence schema --- api/_classroom.js | 75 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/api/_classroom.js b/api/_classroom.js index 05154fc..01a534e 100644 --- a/api/_classroom.js +++ b/api/_classroom.js @@ -324,6 +324,81 @@ async function initializeClassroomRepository() { await sql`CREATE INDEX IF NOT EXISTS classroom_coaching_feedback_workspace_idx ON classroom_coaching_feedback (workspace_id, updated_at)`; + await sql`CREATE TABLE IF NOT EXISTS classroom_exercises ( + id BIGSERIAL PRIMARY KEY, + public_id UUID UNIQUE NOT NULL, + class_id BIGINT NOT NULL REFERENCES classroom_classes(id) ON DELETE CASCADE, + case_study_id VARCHAR(160) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'draft' + CHECK (status IN ('draft', 'active', 'paused', 'completed')), + current_stage_id VARCHAR(80), + stage_phase VARCHAR(16) NOT NULL DEFAULT 'work' + CHECK (stage_phase IN ('work', 'debrief')), + exercise_revision INTEGER NOT NULL DEFAULT 1 CHECK (exercise_revision > 0), + student_editing_enabled BOOLEAN NOT NULL DEFAULT TRUE, + started_at TIMESTAMPTZ, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + UNIQUE (class_id, id) + )`; + await sql`CREATE UNIQUE INDEX IF NOT EXISTS classroom_exercises_one_open_per_class_idx + ON classroom_exercises (class_id) + WHERE status <> 'completed'`; + await sql`CREATE INDEX IF NOT EXISTS classroom_exercises_class_history_idx + ON classroom_exercises (class_id, created_at DESC)`; + await sql`CREATE INDEX IF NOT EXISTS classroom_exercises_expiry_idx + ON classroom_exercises (expires_at)`; + + await sql`CREATE TABLE IF NOT EXISTS classroom_exercise_releases ( + exercise_id BIGINT NOT NULL REFERENCES classroom_exercises(id) ON DELETE CASCADE, + stage_id VARCHAR(80) NOT NULL, + content_id VARCHAR(80) NOT NULL, + released_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (exercise_id, stage_id, content_id) + )`; + + await sql`CREATE TABLE IF NOT EXISTS classroom_exercise_workspace_state ( + class_id BIGINT NOT NULL, + exercise_id BIGINT NOT NULL, + workspace_id BIGINT NOT NULL, + stage_id VARCHAR(80) NOT NULL, + ready_for_debrief BOOLEAN NOT NULL DEFAULT FALSE, + ready_at TIMESTAMPTZ, + ready_workspace_revision INTEGER CHECK (ready_workspace_revision IS NULL OR ready_workspace_revision > 0), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (exercise_id, workspace_id, stage_id), + FOREIGN KEY (class_id, exercise_id) + REFERENCES classroom_exercises(class_id, id) + ON DELETE CASCADE, + FOREIGN KEY (class_id, workspace_id) + REFERENCES classroom_workspaces(class_id, workspace_id) + ON DELETE CASCADE + )`; + await sql`CREATE INDEX IF NOT EXISTS classroom_exercise_workspace_state_class_idx + ON classroom_exercise_workspace_state (class_id, exercise_id, stage_id)`; + + await sql`CREATE TABLE IF NOT EXISTS classroom_exercise_checkpoints ( + class_id BIGINT NOT NULL, + exercise_id BIGINT NOT NULL, + stage_id VARCHAR(80) NOT NULL, + workspace_id BIGINT NOT NULL, + workspace_revision INTEGER NOT NULL CHECK (workspace_revision > 0), + snapshot JSONB NOT NULL, + captured_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (exercise_id, stage_id, workspace_id), + FOREIGN KEY (class_id, exercise_id) + REFERENCES classroom_exercises(class_id, id) + ON DELETE CASCADE, + FOREIGN KEY (class_id, workspace_id) + REFERENCES classroom_workspaces(class_id, workspace_id) + ON DELETE CASCADE + )`; + await sql`CREATE INDEX IF NOT EXISTS classroom_exercise_checkpoints_class_idx + ON classroom_exercise_checkpoints (class_id, exercise_id, stage_id)`; + async function classByInstructor(tokenHash) { const rows = await sql`SELECT id AS internal_id, public_id AS id, title, student_join_code AS "joinCode", From 3e519b8f4ab136404ade550376af0c80e06574f1 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:16:58 -0400 Subject: [PATCH 016/206] feat: add staged exercise repository primitives --- api/_classroom.js | 475 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 475 insertions(+) diff --git a/api/_classroom.js b/api/_classroom.js index 01a534e..2a2e3fd 100644 --- a/api/_classroom.js +++ b/api/_classroom.js @@ -764,6 +764,481 @@ async function initializeClassroomRepository() { return classByInstructor(instructorHash); }, + async createExercise(instructorHash, { publicId, caseStudyId }) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + + const rows = await sql`INSERT INTO classroom_exercises + (public_id, class_id, case_study_id, expires_at) + VALUES ( + ${publicId}::uuid, + ${classroom.internal_id}, + ${caseStudyId}, + ${classroom.expiresAt}::timestamptz + ) + ON CONFLICT DO NOTHING + RETURNING + public_id AS id, + case_study_id AS "caseStudyId", + status, + current_stage_id AS "currentStageId", + stage_phase AS "stagePhase", + exercise_revision AS "exerciseRevision", + student_editing_enabled AS "studentEditingEnabled", + started_at AS "startedAt", + completed_at AS "completedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + expires_at AS "expiresAt"`; + + if (rows[0]) { + return { status: 'created', classroom, exercise: rows[0] }; + } + + const existing = await sql`SELECT + public_id AS id, + case_study_id AS "caseStudyId", + status, + current_stage_id AS "currentStageId", + stage_phase AS "stagePhase", + exercise_revision AS "exerciseRevision", + student_editing_enabled AS "studentEditingEnabled", + started_at AS "startedAt", + completed_at AS "completedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + expires_at AS "expiresAt" + FROM classroom_exercises + WHERE class_id = ${classroom.internal_id} + AND status <> 'completed' + AND expires_at > NOW() + ORDER BY created_at DESC, id DESC + LIMIT 1`; + + return existing[0] + ? { status: 'exists', classroom, exercise: existing[0] } + : null; + }, + + async getCurrentExerciseForInstructor(instructorHash) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + const rows = await sql`SELECT + public_id AS id, + case_study_id AS "caseStudyId", + status, + current_stage_id AS "currentStageId", + stage_phase AS "stagePhase", + exercise_revision AS "exerciseRevision", + student_editing_enabled AS "studentEditingEnabled", + started_at AS "startedAt", + completed_at AS "completedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + expires_at AS "expiresAt" + FROM classroom_exercises + WHERE class_id = ${classroom.internal_id} + AND status <> 'completed' + AND expires_at > NOW() + ORDER BY created_at DESC, id DESC + LIMIT 1`; + return rows[0] ? { classroom, exercise: rows[0] } : null; + }, + + async getExerciseForInstructor(instructorHash, exercisePublicId) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + const rows = await sql`SELECT + public_id AS id, + case_study_id AS "caseStudyId", + status, + current_stage_id AS "currentStageId", + stage_phase AS "stagePhase", + exercise_revision AS "exerciseRevision", + student_editing_enabled AS "studentEditingEnabled", + started_at AS "startedAt", + completed_at AS "completedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + expires_at AS "expiresAt" + FROM classroom_exercises + WHERE class_id = ${classroom.internal_id} + AND public_id = ${exercisePublicId}::uuid + AND expires_at > NOW()`; + return rows[0] ? { classroom, exercise: rows[0] } : null; + }, + + async updateExerciseLifecycle(instructorHash, { + exercisePublicId, + expectedRevision, + status, + currentStageId, + stagePhase, + studentEditingEnabled + }) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + + const rows = await sql`UPDATE classroom_exercises + SET status = ${status}, + current_stage_id = ${currentStageId}, + stage_phase = ${stagePhase}, + student_editing_enabled = ${studentEditingEnabled}, + exercise_revision = exercise_revision + 1, + started_at = CASE + WHEN ${status} = 'active' AND started_at IS NULL THEN NOW() + ELSE started_at + END, + completed_at = CASE + WHEN ${status} = 'completed' THEN COALESCE(completed_at, NOW()) + ELSE completed_at + END, + updated_at = NOW() + WHERE class_id = ${classroom.internal_id} + AND public_id = ${exercisePublicId}::uuid + AND exercise_revision = ${expectedRevision} + AND expires_at > NOW() + RETURNING + public_id AS id, + case_study_id AS "caseStudyId", + status, + current_stage_id AS "currentStageId", + stage_phase AS "stagePhase", + exercise_revision AS "exerciseRevision", + student_editing_enabled AS "studentEditingEnabled", + started_at AS "startedAt", + completed_at AS "completedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + expires_at AS "expiresAt"`; + if (rows[0]) return { status: 'updated', classroom, exercise: rows[0] }; + + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + return current + ? { status: 'conflict', classroom: current.classroom, exercise: current.exercise } + : null; + }, + + async releaseExerciseContent(instructorHash, { + exercisePublicId, + expectedRevision, + stageId, + contentId + }) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + + const rows = await sql`WITH inserted AS ( + INSERT INTO classroom_exercise_releases + (exercise_id, stage_id, content_id) + SELECT e.id, ${stageId}, ${contentId} + FROM classroom_exercises e + WHERE e.class_id = ${classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND e.exercise_revision = ${expectedRevision} + AND e.expires_at > NOW() + ON CONFLICT DO NOTHING + RETURNING exercise_id, stage_id, content_id, released_at + ), + bumped AS ( + UPDATE classroom_exercises e + SET exercise_revision = exercise_revision + 1, + updated_at = NOW() + FROM inserted i + WHERE e.id = i.exercise_id + AND e.exercise_revision = ${expectedRevision} + RETURNING + e.id AS internal_id, + e.public_id AS id, + e.case_study_id AS "caseStudyId", + e.status, + e.current_stage_id AS "currentStageId", + e.stage_phase AS "stagePhase", + e.exercise_revision AS "exerciseRevision", + e.student_editing_enabled AS "studentEditingEnabled", + e.started_at AS "startedAt", + e.completed_at AS "completedAt", + e.created_at AS "createdAt", + e.updated_at AS "updatedAt", + e.expires_at AS "expiresAt" + ) + SELECT + b.*, + i.stage_id AS "releaseStageId", + i.content_id AS "releaseContentId", + i.released_at AS "releasedAt" + FROM bumped b + JOIN inserted i ON i.exercise_id = b.internal_id`; + + if (rows[0]) { + const row = rows[0]; + const { internal_id: _internalId, releaseStageId, releaseContentId, releasedAt, ...exercise } = row; + return { + status: 'updated', + classroom, + exercise, + release: { stageId: releaseStageId, contentId: releaseContentId, releasedAt } + }; + } + + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + + const releases = await sql`SELECT + r.stage_id AS "stageId", + r.content_id AS "contentId", + r.released_at AS "releasedAt" + FROM classroom_exercise_releases r + JOIN classroom_exercises e ON e.id = r.exercise_id + WHERE e.class_id = ${classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND r.stage_id = ${stageId} + AND r.content_id = ${contentId} + AND e.expires_at > NOW()`; + + return releases[0] + ? { + status: 'unchanged', + classroom, + exercise: current.exercise, + release: releases[0] + } + : { + status: 'conflict', + classroom, + exercise: current.exercise + }; + }, + + async listExerciseReleasesForInstructor(instructorHash, exercisePublicId) { + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + const releases = await sql`SELECT + r.stage_id AS "stageId", + r.content_id AS "contentId", + r.released_at AS "releasedAt" + FROM classroom_exercise_releases r + JOIN classroom_exercises e ON e.id = r.exercise_id + WHERE e.class_id = ${current.classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND e.expires_at > NOW() + ORDER BY r.released_at, r.stage_id, r.content_id`; + return { ...current, releases }; + }, + + async setExerciseWorkspaceReadinessBySession(sessionHash, { + exercisePublicId, + stageId, + ready, + workspaceRevision + }) { + const context = await this.getParticipantBySession(sessionHash); + if (!context) return null; + if (!context.internal.workspaceId || !context.assignment) { + return { status: 'waiting', ...context }; + } + + const exercises = await sql`SELECT + id AS "internalId", + public_id AS id, + case_study_id AS "caseStudyId", + status, + current_stage_id AS "currentStageId", + stage_phase AS "stagePhase", + exercise_revision AS "exerciseRevision", + student_editing_enabled AS "studentEditingEnabled", + started_at AS "startedAt", + completed_at AS "completedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + expires_at AS "expiresAt" + FROM classroom_exercises + WHERE class_id = ${context.internal.classId} + AND public_id = ${exercisePublicId}::uuid + AND current_stage_id = ${stageId} + AND status <> 'completed' + AND expires_at > NOW()`; + const exercise = exercises[0]; + if (!exercise) return null; + + const rows = await sql`INSERT INTO classroom_exercise_workspace_state + ( + class_id, exercise_id, workspace_id, stage_id, + ready_for_debrief, ready_at, ready_workspace_revision + ) + VALUES ( + ${context.internal.classId}, + ${exercise.internalId}, + ${context.internal.workspaceId}, + ${stageId}, + ${ready}, + CASE WHEN ${ready} THEN NOW() ELSE NULL END, + CASE WHEN ${ready} THEN ${workspaceRevision} ELSE NULL END + ) + ON CONFLICT (exercise_id, workspace_id, stage_id) DO UPDATE + SET ready_for_debrief = EXCLUDED.ready_for_debrief, + ready_at = CASE WHEN EXCLUDED.ready_for_debrief THEN NOW() ELSE NULL END, + ready_workspace_revision = EXCLUDED.ready_workspace_revision, + updated_at = NOW() + WHERE classroom_exercise_workspace_state.ready_for_debrief IS DISTINCT FROM EXCLUDED.ready_for_debrief + OR classroom_exercise_workspace_state.ready_workspace_revision IS DISTINCT FROM EXCLUDED.ready_workspace_revision + RETURNING + stage_id AS "stageId", + ready_for_debrief AS "readyForDebrief", + ready_at AS "readyAt", + ready_workspace_revision AS "readyWorkspaceRevision", + created_at AS "createdAt", + updated_at AS "updatedAt"`; + + let readiness = rows[0] || null; + if (!readiness) { + const existing = await sql`SELECT + stage_id AS "stageId", + ready_for_debrief AS "readyForDebrief", + ready_at AS "readyAt", + ready_workspace_revision AS "readyWorkspaceRevision", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM classroom_exercise_workspace_state + WHERE exercise_id = ${exercise.internalId} + AND workspace_id = ${context.internal.workspaceId} + AND stage_id = ${stageId}`; + readiness = existing[0] || null; + } + + const { internalId: _internalId, ...publicExercise } = exercise; + return { + status: rows[0] ? 'updated' : 'unchanged', + classroom: context.classroom, + participant: context.participant, + workspace: context.assignment, + exercise: publicExercise, + readiness + }; + }, + + async listExerciseWorkspaceStateForInstructor(instructorHash, exercisePublicId) { + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + const states = await sql`SELECT + cw.public_id AS "workspaceId", + cw.workspace_kind AS "workspaceKind", + cw.label AS "workspaceLabel", + s.stage_id AS "stageId", + s.ready_for_debrief AS "readyForDebrief", + s.ready_at AS "readyAt", + s.ready_workspace_revision AS "readyWorkspaceRevision", + s.created_at AS "createdAt", + s.updated_at AS "updatedAt" + FROM classroom_exercise_workspace_state s + JOIN classroom_exercises e ON e.id = s.exercise_id + JOIN classroom_workspaces cw + ON cw.class_id = s.class_id + AND cw.workspace_id = s.workspace_id + WHERE s.class_id = ${current.classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND e.expires_at > NOW() + AND cw.revoked_at IS NULL + ORDER BY cw.created_at, cw.workspace_id, s.stage_id`; + return { ...current, workspaceState: states }; + }, + + async captureExerciseCheckpoint(instructorHash, { + exercisePublicId, + stageId, + workspacePublicId, + workspaceRevision, + snapshot + }) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + + const rows = await sql`INSERT INTO classroom_exercise_checkpoints + ( + class_id, exercise_id, stage_id, workspace_id, + workspace_revision, snapshot + ) + SELECT + ${classroom.internal_id}, + e.id, + ${stageId}, + cw.workspace_id, + ${workspaceRevision}, + ${JSON.stringify(snapshot)}::jsonb + FROM classroom_exercises e + JOIN classroom_workspaces cw + ON cw.class_id = e.class_id + WHERE e.class_id = ${classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND e.expires_at > NOW() + AND cw.public_id = ${workspacePublicId}::uuid + AND cw.revoked_at IS NULL + ON CONFLICT DO NOTHING + RETURNING + stage_id AS "stageId", + workspace_revision AS "workspaceRevision", + snapshot, + captured_at AS "capturedAt"`; + + if (rows[0]) { + return { + status: 'captured', + classroom, + workspace: { id: workspacePublicId }, + checkpoint: rows[0] + }; + } + + const existing = await sql`SELECT + cp.stage_id AS "stageId", + cp.workspace_revision AS "workspaceRevision", + cp.snapshot, + cp.captured_at AS "capturedAt" + FROM classroom_exercise_checkpoints cp + JOIN classroom_exercises e ON e.id = cp.exercise_id + JOIN classroom_workspaces cw + ON cw.class_id = cp.class_id + AND cw.workspace_id = cp.workspace_id + WHERE cp.class_id = ${classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND cp.stage_id = ${stageId} + AND cw.public_id = ${workspacePublicId}::uuid + AND e.expires_at > NOW()`; + + return existing[0] + ? { + status: 'unchanged', + classroom, + workspace: { id: workspacePublicId }, + checkpoint: existing[0] + } + : null; + }, + + async listExerciseCheckpointsForInstructor(instructorHash, exercisePublicId, stageId) { + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + const checkpoints = await sql`SELECT + cw.public_id AS "workspaceId", + cw.workspace_kind AS "workspaceKind", + cw.label AS "workspaceLabel", + cp.stage_id AS "stageId", + cp.workspace_revision AS "workspaceRevision", + cp.snapshot, + cp.captured_at AS "capturedAt" + FROM classroom_exercise_checkpoints cp + JOIN classroom_exercises e ON e.id = cp.exercise_id + JOIN classroom_workspaces cw + ON cw.class_id = cp.class_id + AND cw.workspace_id = cp.workspace_id + WHERE cp.class_id = ${current.classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND cp.stage_id = ${stageId} + AND e.expires_at > NOW() + ORDER BY cw.created_at, cw.workspace_id`; + return { ...current, checkpoints }; + }, + async rotateStudentJoin(instructorHash, nextHash) { const rows = await sql`UPDATE classroom_classes SET student_join_token_hash = ${nextHash}, updated_at = NOW() From 8595e1ec1072b365e82f759494ea0edb25001a4c Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:17:53 -0400 Subject: [PATCH 017/206] test: mirror staged exercise repository semantics --- tests/helpers/classroom-test-repositories.mjs | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) diff --git a/tests/helpers/classroom-test-repositories.mjs b/tests/helpers/classroom-test-repositories.mjs index 5c86877..1143b69 100644 --- a/tests/helpers/classroom-test-repositories.mjs +++ b/tests/helpers/classroom-test-repositories.mjs @@ -155,11 +155,16 @@ export function createWorkspaceRepository() { /** Create an in-memory classroom repository. @returns {object} Repository. */ export function createClassroomRepository() { let nextInternalId = 1; + let nextExerciseInternalId = 1; const classes = []; const workspaces = []; const memberships = new Map(); const participants = new Map(); const coaching = new Map(); + const exercises = []; + const exerciseReleases = new Map(); + const exerciseWorkspaceState = new Map(); + const exerciseCheckpoints = new Map(); const activeByInstructor = hash => classes.find(item => item.instructorHash === hash && !item.revoked) || null; const activeByJoin = hash => classes.find(item => item.studentJoinHash === hash && item.joinsEnabled && !item.revoked) || null; @@ -174,6 +179,21 @@ export function createClassroomRepository() { expiresAt: item.expiresAt, joinCode: item.studentJoinCode || null } : null; + const publicExercise = exercise => exercise ? { + id: exercise.id, + caseStudyId: exercise.caseStudyId, + status: exercise.status, + currentStageId: exercise.currentStageId, + stagePhase: exercise.stagePhase, + exerciseRevision: exercise.exerciseRevision, + studentEditingEnabled: exercise.studentEditingEnabled, + startedAt: exercise.startedAt, + completedAt: exercise.completedAt, + createdAt: exercise.createdAt, + updatedAt: exercise.updatedAt, + expiresAt: exercise.expiresAt + } : null; + const cloneValue = value => value == null ? value : JSON.parse(JSON.stringify(value)); return { classes, @@ -181,6 +201,10 @@ export function createClassroomRepository() { memberships, participants, coaching, + exercises, + exerciseReleases, + exerciseWorkspaceState, + exerciseCheckpoints, async createClass({ publicId, title, instructorHash, studentJoinHash, studentJoinCode = null }) { const item = { internalId: nextInternalId++, @@ -435,6 +459,348 @@ export function createClassroomRepository() { waiting: false }; }, + async createExercise(instructorHash, { publicId, caseStudyId }) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const existing = exercises.find(exercise => ( + exercise.classInternalId === item.internalId + && exercise.status !== 'completed' + )); + if (existing) { + return { + status: 'exists', + classroom: publicClass(item), + exercise: publicExercise(existing) + }; + } + if (exercises.some(exercise => exercise.id === publicId)) return null; + + const exercise = { + internalId: nextExerciseInternalId++, + id: publicId, + classInternalId: item.internalId, + caseStudyId, + status: 'draft', + currentStageId: null, + stagePhase: 'work', + exerciseRevision: 1, + studentEditingEnabled: true, + startedAt: null, + completedAt: null, + createdAt: 'created', + updatedAt: 'created', + expiresAt: item.expiresAt + }; + exercises.push(exercise); + return { + status: 'created', + classroom: publicClass(item), + exercise: publicExercise(exercise) + }; + }, + async getCurrentExerciseForInstructor(instructorHash) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.status !== 'completed' + )); + return exercise + ? { classroom: publicClass(item), exercise: publicExercise(exercise) } + : null; + }, + async getExerciseForInstructor(instructorHash, exercisePublicId) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.id === exercisePublicId + )); + return exercise + ? { classroom: publicClass(item), exercise: publicExercise(exercise) } + : null; + }, + async updateExerciseLifecycle(instructorHash, { + exercisePublicId, + expectedRevision, + status, + currentStageId, + stagePhase, + studentEditingEnabled + }) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.id === exercisePublicId + )); + if (!exercise) return null; + if (exercise.exerciseRevision !== expectedRevision) { + return { + status: 'conflict', + classroom: publicClass(item), + exercise: publicExercise(exercise) + }; + } + + exercise.status = status; + exercise.currentStageId = currentStageId; + exercise.stagePhase = stagePhase; + exercise.studentEditingEnabled = studentEditingEnabled; + exercise.exerciseRevision += 1; + if (status === 'active' && !exercise.startedAt) exercise.startedAt = 'started'; + if (status === 'completed' && !exercise.completedAt) exercise.completedAt = 'completed'; + exercise.updatedAt = 'updated'; + return { + status: 'updated', + classroom: publicClass(item), + exercise: publicExercise(exercise) + }; + }, + async releaseExerciseContent(instructorHash, { + exercisePublicId, + expectedRevision, + stageId, + contentId + }) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.id === exercisePublicId + )); + if (!exercise) return null; + + const key = exercise.internalId + ':' + stageId + ':' + contentId; + const existing = exerciseReleases.get(key); + if (existing) { + return { + status: 'unchanged', + classroom: publicClass(item), + exercise: publicExercise(exercise), + release: { ...existing } + }; + } + if (exercise.exerciseRevision !== expectedRevision) { + return { + status: 'conflict', + classroom: publicClass(item), + exercise: publicExercise(exercise) + }; + } + + const release = { stageId, contentId, releasedAt: 'released' }; + exerciseReleases.set(key, release); + exercise.exerciseRevision += 1; + exercise.updatedAt = 'updated'; + return { + status: 'updated', + classroom: publicClass(item), + exercise: publicExercise(exercise), + release: { ...release } + }; + }, + async listExerciseReleasesForInstructor(instructorHash, exercisePublicId) { + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === current.classroom.internal_id + && candidate.id === exercisePublicId + )); + const prefix = exercise.internalId + ':'; + return { + ...current, + releases: [...exerciseReleases.entries()] + .filter(([key]) => key.startsWith(prefix)) + .map(([, value]) => ({ ...value })) + .sort((a, b) => ( + a.stageId.localeCompare(b.stageId) + || a.contentId.localeCompare(b.contentId) + )) + }; + }, + async setExerciseWorkspaceReadinessBySession(sessionHash, { + exercisePublicId, + stageId, + ready, + workspaceRevision + }) { + const context = await this.getParticipantBySession(sessionHash); + if (!context) return null; + if (!context.internal.workspaceId || !context.assignment) { + return { status: 'waiting', ...context }; + } + const exercise = exercises.find(candidate => ( + candidate.classInternalId === context.internal.classId + && candidate.id === exercisePublicId + && candidate.currentStageId === stageId + && candidate.status !== 'completed' + )); + if (!exercise) return null; + + const key = exercise.internalId + ':' + context.internal.workspaceId + ':' + stageId; + const previous = exerciseWorkspaceState.get(key); + const nextRevision = ready ? workspaceRevision : null; + if (previous + && previous.readyForDebrief === ready + && previous.readyWorkspaceRevision === nextRevision) { + return { + status: 'unchanged', + classroom: context.classroom, + participant: context.participant, + workspace: context.assignment, + exercise: publicExercise(exercise), + readiness: { ...previous } + }; + } + + const readiness = { + classInternalId: context.internal.classId, + exerciseInternalId: exercise.internalId, + workspaceInternalId: context.internal.workspaceId, + stageId, + readyForDebrief: ready, + readyAt: ready ? 'ready' : null, + readyWorkspaceRevision: nextRevision, + createdAt: previous?.createdAt || 'created', + updatedAt: 'updated' + }; + exerciseWorkspaceState.set(key, readiness); + const { + classInternalId: _classInternalId, + exerciseInternalId: _exerciseInternalId, + workspaceInternalId: _workspaceInternalId, + ...publicReadiness + } = readiness; + return { + status: 'updated', + classroom: context.classroom, + participant: context.participant, + workspace: context.assignment, + exercise: publicExercise(exercise), + readiness: publicReadiness + }; + }, + async listExerciseWorkspaceStateForInstructor(instructorHash, exercisePublicId) { + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === current.classroom.internal_id + && candidate.id === exercisePublicId + )); + return { + ...current, + workspaceState: [...exerciseWorkspaceState.values()] + .filter(state => state.exerciseInternalId === exercise.internalId) + .map(state => { + const workspace = workspaces.find(candidate => ( + candidate.classInternalId === current.classroom.internal_id + && candidate.workspaceId === state.workspaceInternalId + && !candidate.revoked + )); + if (!workspace) return null; + return { + workspaceId: workspace.id, + workspaceKind: workspace.kind, + workspaceLabel: workspace.label, + stageId: state.stageId, + readyForDebrief: state.readyForDebrief, + readyAt: state.readyAt, + readyWorkspaceRevision: state.readyWorkspaceRevision, + createdAt: state.createdAt, + updatedAt: state.updatedAt + }; + }) + .filter(Boolean) + }; + }, + async captureExerciseCheckpoint(instructorHash, { + exercisePublicId, + stageId, + workspacePublicId, + workspaceRevision, + snapshot + }) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.id === exercisePublicId + )); + if (!exercise) return null; + const workspace = workspaces.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.id === workspacePublicId + && !candidate.revoked + )); + if (!workspace) return null; + + const key = exercise.internalId + ':' + stageId + ':' + workspace.workspaceId; + const existing = exerciseCheckpoints.get(key); + if (existing) { + return { + status: 'unchanged', + classroom: publicClass(item), + workspace: { id: workspace.id }, + checkpoint: cloneValue(existing) + }; + } + + const checkpoint = { + stageId, + workspaceRevision, + snapshot: cloneValue(snapshot), + capturedAt: 'captured', + classInternalId: item.internalId, + exerciseInternalId: exercise.internalId, + workspaceInternalId: workspace.workspaceId + }; + exerciseCheckpoints.set(key, checkpoint); + const { + classInternalId: _classInternalId, + exerciseInternalId: _exerciseInternalId, + workspaceInternalId: _workspaceInternalId, + ...publicCheckpoint + } = checkpoint; + return { + status: 'captured', + classroom: publicClass(item), + workspace: { id: workspace.id }, + checkpoint: cloneValue(publicCheckpoint) + }; + }, + async listExerciseCheckpointsForInstructor(instructorHash, exercisePublicId, stageId) { + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + if (!current) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === current.classroom.internal_id + && candidate.id === exercisePublicId + )); + return { + ...current, + checkpoints: [...exerciseCheckpoints.values()] + .filter(checkpoint => ( + checkpoint.exerciseInternalId === exercise.internalId + && checkpoint.stageId === stageId + )) + .map(checkpoint => { + const workspace = workspaces.find(candidate => ( + candidate.classInternalId === current.classroom.internal_id + && candidate.workspaceId === checkpoint.workspaceInternalId + )); + return { + workspaceId: workspace?.id || null, + workspaceKind: workspace?.kind || null, + workspaceLabel: workspace?.label || null, + stageId: checkpoint.stageId, + workspaceRevision: checkpoint.workspaceRevision, + snapshot: cloneValue(checkpoint.snapshot), + capturedAt: checkpoint.capturedAt + }; + }) + .filter(checkpoint => checkpoint.workspaceId) + }; + }, async rotateStudentJoin(instructorHash, nextHash) { const item = activeByInstructor(instructorHash); if (!item) return null; From f7c1e580ca8fdc5e818e1f6974af374850f6d3a6 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:19:01 -0400 Subject: [PATCH 018/206] test: prove staged exercise repository contract --- ...lassroom-exercise-repository.unit.test.mjs | 416 ++++++++++++++++++ 1 file changed, 416 insertions(+) create mode 100644 tests/classroom-exercise-repository.unit.test.mjs diff --git a/tests/classroom-exercise-repository.unit.test.mjs b/tests/classroom-exercise-repository.unit.test.mjs new file mode 100644 index 0000000..bf23952 --- /dev/null +++ b/tests/classroom-exercise-repository.unit.test.mjs @@ -0,0 +1,416 @@ +/** + * Repository-level contract tests for #313 staged exercise persistence. + * + * These intentionally stop below HTTP/UI. They prove the deterministic repository + * semantics that the later exercise handlers will orchestrate. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createClassroomRepository, + createWorkspaceRepository, + testTokenHash +} from './helpers/classroom-test-repositories.mjs'; + +const CLASS_A_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const CLASS_B_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; +const EXERCISE_A_ID = '11111111-1111-4111-8111-111111111111'; +const EXERCISE_A2_ID = '22222222-2222-4222-8222-222222222222'; +const EXERCISE_B_ID = '33333333-3333-4333-8333-333333333333'; +const WORKSPACE_A_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const WORKSPACE_B_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const WORKSPACE_C_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; +const PARTICIPANT_A = '44444444-4444-4444-8444-444444444444'; + +async function classPair(classrooms) { + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + await classrooms.createClass({ + publicId: CLASS_B_ID, + title: 'Class B', + instructorHash: testTokenHash('B'), + studentJoinHash: testTokenHash('D'), + studentJoinCode: 'M8RNQ5W3' + }); +} + +async function addWorkspace(classrooms, workspaceRepo, { + instructor = 'A', + publicId = WORKSPACE_A_ID, + capability = 'P', + claim = 'X', + label = 'Team A' +} = {}) { + const workspace = await workspaceRepo.create( + testTokenHash(capability), + { pre: { oneLine: label + ' Intake' } }, + 30, + label + ); + await classrooms.addWorkspace(testTokenHash(instructor), { + publicId, + workspaceId: workspace.id, + kind: 'group', + label, + claimHash: testTokenHash(claim) + }); + return workspace; +} + +test('exercise creation permits one open exercise per class and inherits class retention', async () => { + const classrooms = createClassroomRepository(); + await classPair(classrooms); + + const createdA = await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_A_ID, + caseStudyId: 'synthetic-staged-case' + }); + assert.equal(createdA.status, 'created'); + assert.equal(createdA.exercise.status, 'draft'); + assert.equal(createdA.exercise.exerciseRevision, 1); + assert.equal(createdA.exercise.currentStageId, null); + assert.equal(createdA.exercise.expiresAt, createdA.classroom.expiresAt); + + const duplicateOpen = await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_A2_ID, + caseStudyId: 'another-case' + }); + assert.equal(duplicateOpen.status, 'exists'); + assert.equal(duplicateOpen.exercise.id, EXERCISE_A_ID); + assert.equal(classrooms.exercises.length, 1); + + const createdB = await classrooms.createExercise(testTokenHash('B'), { + publicId: EXERCISE_B_ID, + caseStudyId: 'synthetic-staged-case' + }); + assert.equal(createdB.status, 'created'); + assert.equal(createdB.classroom.id, CLASS_B_ID); + assert.equal(classrooms.exercises.length, 2); + + assert.equal(await classrooms.getExerciseForInstructor(testTokenHash('B'), EXERCISE_A_ID), null); + + await classrooms.revokeClass(testTokenHash('A')); + assert.equal(await classrooms.getCurrentExerciseForInstructor(testTokenHash('A')), null); +}); + +test('exercise lifecycle mutation is optimistic and completion permits a later exercise', async () => { + const classrooms = createClassroomRepository(); + await classPair(classrooms); + await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_A_ID, + caseStudyId: 'synthetic-staged-case' + }); + + const started = await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 1, + status: 'active', + currentStageId: 'stage-1', + stagePhase: 'work', + studentEditingEnabled: true + }); + assert.equal(started.status, 'updated'); + assert.equal(started.exercise.exerciseRevision, 2); + assert.equal(started.exercise.startedAt, 'started'); + + const stale = await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 1, + status: 'paused', + currentStageId: 'stage-1', + stagePhase: 'work', + studentEditingEnabled: true + }); + assert.equal(stale.status, 'conflict'); + assert.equal(stale.exercise.status, 'active'); + assert.equal(stale.exercise.exerciseRevision, 2); + + const debrief = await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 2, + status: 'active', + currentStageId: 'stage-1', + stagePhase: 'debrief', + studentEditingEnabled: false + }); + assert.equal(debrief.exercise.exerciseRevision, 3); + assert.equal(debrief.exercise.stagePhase, 'debrief'); + assert.equal(debrief.exercise.studentEditingEnabled, false); + + const completed = await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 3, + status: 'completed', + currentStageId: 'stage-1', + stagePhase: 'debrief', + studentEditingEnabled: false + }); + assert.equal(completed.exercise.exerciseRevision, 4); + assert.equal(completed.exercise.completedAt, 'completed'); + assert.equal(await classrooms.getCurrentExerciseForInstructor(testTokenHash('A')), null); + + const next = await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_A2_ID, + caseStudyId: 'next-staged-case' + }); + assert.equal(next.status, 'created'); + assert.equal(next.exercise.id, EXERCISE_A2_ID); +}); + +test('optional release is idempotent and only a new release advances exercise revision', async () => { + const classrooms = createClassroomRepository(); + await classPair(classrooms); + await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_A_ID, + caseStudyId: 'synthetic-staged-case' + }); + await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 1, + status: 'active', + currentStageId: 'stage-1', + stagePhase: 'work', + studentEditingEnabled: true + }); + + const first = await classrooms.releaseExerciseContent(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 2, + stageId: 'stage-1', + contentId: 'hint-1' + }); + assert.equal(first.status, 'updated'); + assert.equal(first.exercise.exerciseRevision, 3); + + const replay = await classrooms.releaseExerciseContent(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 2, + stageId: 'stage-1', + contentId: 'hint-1' + }); + assert.equal(replay.status, 'unchanged'); + assert.equal(replay.exercise.exerciseRevision, 3); + + const staleDifferentRelease = await classrooms.releaseExerciseContent(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 2, + stageId: 'stage-1', + contentId: 'evidence-2' + }); + assert.equal(staleDifferentRelease.status, 'conflict'); + assert.equal(staleDifferentRelease.exercise.exerciseRevision, 3); + + const second = await classrooms.releaseExerciseContent(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 3, + stageId: 'stage-1', + contentId: 'evidence-2' + }); + assert.equal(second.status, 'updated'); + assert.equal(second.exercise.exerciseRevision, 4); + + const listed = await classrooms.listExerciseReleasesForInstructor( + testTokenHash('A'), + EXERCISE_A_ID + ); + assert.deepEqual( + listed.releases.map(({ stageId, contentId }) => ({ stageId, contentId })), + [ + { stageId: 'stage-1', contentId: 'evidence-2' }, + { stageId: 'stage-1', contentId: 'hint-1' } + ] + ); + assert.equal(await classrooms.listExerciseReleasesForInstructor(testTokenHash('B'), EXERCISE_A_ID), null); +}); + +test('readiness belongs to the current workspace and reassignment never carries old-team readiness', async () => { + const classrooms = createClassroomRepository(); + const workspaceRepo = createWorkspaceRepository(); + await classPair(classrooms); + await addWorkspace(classrooms, workspaceRepo, { + publicId: WORKSPACE_A_ID, + capability: 'P', + claim: 'X', + label: 'Team A' + }); + await addWorkspace(classrooms, workspaceRepo, { + publicId: WORKSPACE_B_ID, + capability: 'Q', + claim: 'Y', + label: 'Team B' + }); + + await classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId: PARTICIPANT_A, + displayName: 'Alex', + sessionHash: testTokenHash('S') + }); + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: WORKSPACE_A_ID, + workspaceRepository: workspaceRepo + }); + await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_A_ID, + caseStudyId: 'synthetic-staged-case' + }); + await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + expectedRevision: 1, + status: 'active', + currentStageId: 'stage-1', + stagePhase: 'work', + studentEditingEnabled: true + }); + + const readyA = await classrooms.setExerciseWorkspaceReadinessBySession(testTokenHash('S'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + ready: true, + workspaceRevision: 7 + }); + assert.equal(readyA.status, 'updated'); + assert.equal(readyA.workspace.id, WORKSPACE_A_ID); + assert.equal(readyA.readiness.readyWorkspaceRevision, 7); + + const replay = await classrooms.setExerciseWorkspaceReadinessBySession(testTokenHash('S'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + ready: true, + workspaceRevision: 7 + }); + assert.equal(replay.status, 'unchanged'); + + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: WORKSPACE_B_ID, + workspaceRepository: workspaceRepo + }); + + const beforeReadyB = await classrooms.listExerciseWorkspaceStateForInstructor( + testTokenHash('A'), + EXERCISE_A_ID + ); + assert.deepEqual(beforeReadyB.workspaceState.map(state => state.workspaceId), [WORKSPACE_A_ID]); + + const readyB = await classrooms.setExerciseWorkspaceReadinessBySession(testTokenHash('S'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + ready: true, + workspaceRevision: 3 + }); + assert.equal(readyB.workspace.id, WORKSPACE_B_ID); + + const both = await classrooms.listExerciseWorkspaceStateForInstructor( + testTokenHash('A'), + EXERCISE_A_ID + ); + assert.deepEqual( + both.workspaceState.map(state => ({ + workspaceId: state.workspaceId, + revision: state.readyWorkspaceRevision + })), + [ + { workspaceId: WORKSPACE_A_ID, revision: 7 }, + { workspaceId: WORKSPACE_B_ID, revision: 3 } + ] + ); + + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: null, + workspaceRepository: workspaceRepo + }); + const waiting = await classrooms.setExerciseWorkspaceReadinessBySession(testTokenHash('S'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + ready: true, + workspaceRevision: 4 + }); + assert.equal(waiting.status, 'waiting'); +}); + +test('debrief checkpoints are class-scoped, immutable, idempotent, and snapshot-isolated', async () => { + const classrooms = createClassroomRepository(); + const workspaceRepo = createWorkspaceRepository(); + await classPair(classrooms); + await addWorkspace(classrooms, workspaceRepo, { + publicId: WORKSPACE_A_ID, + capability: 'P', + claim: 'X', + label: 'Team A' + }); + await addWorkspace(classrooms, workspaceRepo, { + instructor: 'B', + publicId: WORKSPACE_C_ID, + capability: 'R', + claim: 'Z', + label: 'Other Class Team' + }); + await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_A_ID, + caseStudyId: 'synthetic-staged-case' + }); + + const sourceSnapshot = { + pre: { oneLine: 'Team A before debrief' }, + kt: { rows: [{ id: 'what', is: 'Observed fact' }] } + }; + const captured = await classrooms.captureExerciseCheckpoint(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + workspacePublicId: WORKSPACE_A_ID, + workspaceRevision: 9, + snapshot: sourceSnapshot + }); + assert.equal(captured.status, 'captured'); + assert.equal(captured.checkpoint.workspaceRevision, 9); + + sourceSnapshot.pre.oneLine = 'Mutated after capture'; + + const replay = await classrooms.captureExerciseCheckpoint(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + workspacePublicId: WORKSPACE_A_ID, + workspaceRevision: 10, + snapshot: { pre: { oneLine: 'Different later state' } } + }); + assert.equal(replay.status, 'unchanged'); + assert.equal(replay.checkpoint.workspaceRevision, 9); + assert.equal(replay.checkpoint.snapshot.pre.oneLine, 'Team A before debrief'); + + const crossClassWorkspace = await classrooms.captureExerciseCheckpoint(testTokenHash('A'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + workspacePublicId: WORKSPACE_C_ID, + workspaceRevision: 1, + snapshot: {} + }); + assert.equal(crossClassWorkspace, null); + + const wrongInstructor = await classrooms.captureExerciseCheckpoint(testTokenHash('B'), { + exercisePublicId: EXERCISE_A_ID, + stageId: 'stage-1', + workspacePublicId: WORKSPACE_A_ID, + workspaceRevision: 9, + snapshot: {} + }); + assert.equal(wrongInstructor, null); + + const listed = await classrooms.listExerciseCheckpointsForInstructor( + testTokenHash('A'), + EXERCISE_A_ID, + 'stage-1' + ); + assert.equal(listed.checkpoints.length, 1); + assert.equal(listed.checkpoints[0].workspaceId, WORKSPACE_A_ID); + assert.equal(listed.checkpoints[0].workspaceRevision, 9); + assert.equal(listed.checkpoints[0].snapshot.pre.oneLine, 'Team A before debrief'); +}); From 09ffaa6c1ce081166240fe484bbccea6b29901f5 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:08 -0400 Subject: [PATCH 019/206] feat: pin staged exercise definition identity --- api/_classroom.js | 30 ++++++++++++++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/api/_classroom.js b/api/_classroom.js index 2a2e3fd..9d7908e 100644 --- a/api/_classroom.js +++ b/api/_classroom.js @@ -329,6 +329,8 @@ async function initializeClassroomRepository() { public_id UUID UNIQUE NOT NULL, class_id BIGINT NOT NULL REFERENCES classroom_classes(id) ON DELETE CASCADE, case_study_id VARCHAR(160) NOT NULL, + simulation_version INTEGER NOT NULL CHECK (simulation_version > 0), + simulation_fingerprint CHAR(64) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'paused', 'completed')), current_stage_id VARCHAR(80), @@ -764,22 +766,34 @@ async function initializeClassroomRepository() { return classByInstructor(instructorHash); }, - async createExercise(instructorHash, { publicId, caseStudyId }) { + async createExercise(instructorHash, { + publicId, + caseStudyId, + simulationVersion, + simulationFingerprint + }) { const classroom = await classByInstructor(instructorHash); if (!classroom) return null; const rows = await sql`INSERT INTO classroom_exercises - (public_id, class_id, case_study_id, expires_at) + ( + public_id, class_id, case_study_id, + simulation_version, simulation_fingerprint, expires_at + ) VALUES ( ${publicId}::uuid, ${classroom.internal_id}, ${caseStudyId}, + ${simulationVersion}, + ${simulationFingerprint}, ${classroom.expiresAt}::timestamptz ) ON CONFLICT DO NOTHING RETURNING public_id AS id, case_study_id AS "caseStudyId", + simulation_version AS "simulationVersion", + simulation_fingerprint AS "simulationFingerprint", status, current_stage_id AS "currentStageId", stage_phase AS "stagePhase", @@ -798,6 +812,8 @@ async function initializeClassroomRepository() { const existing = await sql`SELECT public_id AS id, case_study_id AS "caseStudyId", + simulation_version AS "simulationVersion", + simulation_fingerprint AS "simulationFingerprint", status, current_stage_id AS "currentStageId", stage_phase AS "stagePhase", @@ -826,6 +842,8 @@ async function initializeClassroomRepository() { const rows = await sql`SELECT public_id AS id, case_study_id AS "caseStudyId", + simulation_version AS "simulationVersion", + simulation_fingerprint AS "simulationFingerprint", status, current_stage_id AS "currentStageId", stage_phase AS "stagePhase", @@ -851,6 +869,8 @@ async function initializeClassroomRepository() { const rows = await sql`SELECT public_id AS id, case_study_id AS "caseStudyId", + simulation_version AS "simulationVersion", + simulation_fingerprint AS "simulationFingerprint", status, current_stage_id AS "currentStageId", stage_phase AS "stagePhase", @@ -901,6 +921,8 @@ async function initializeClassroomRepository() { RETURNING public_id AS id, case_study_id AS "caseStudyId", + simulation_version AS "simulationVersion", + simulation_fingerprint AS "simulationFingerprint", status, current_stage_id AS "currentStageId", stage_phase AS "stagePhase", @@ -951,6 +973,8 @@ async function initializeClassroomRepository() { e.id AS internal_id, e.public_id AS id, e.case_study_id AS "caseStudyId", + e.simulation_version AS "simulationVersion", + e.simulation_fingerprint AS "simulationFingerprint", e.status, e.current_stage_id AS "currentStageId", e.stage_phase AS "stagePhase", @@ -1042,6 +1066,8 @@ async function initializeClassroomRepository() { id AS "internalId", public_id AS id, case_study_id AS "caseStudyId", + simulation_version AS "simulationVersion", + simulation_fingerprint AS "simulationFingerprint", status, current_stage_id AS "currentStageId", stage_phase AS "stagePhase", From 6979abc429c6d67169e044758cf394eb052ef85b Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:30 -0400 Subject: [PATCH 020/206] test: mirror staged definition identity --- tests/helpers/classroom-test-repositories.mjs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/helpers/classroom-test-repositories.mjs b/tests/helpers/classroom-test-repositories.mjs index 1143b69..53112c1 100644 --- a/tests/helpers/classroom-test-repositories.mjs +++ b/tests/helpers/classroom-test-repositories.mjs @@ -182,6 +182,8 @@ export function createClassroomRepository() { const publicExercise = exercise => exercise ? { id: exercise.id, caseStudyId: exercise.caseStudyId, + simulationVersion: exercise.simulationVersion, + simulationFingerprint: exercise.simulationFingerprint, status: exercise.status, currentStageId: exercise.currentStageId, stagePhase: exercise.stagePhase, @@ -459,7 +461,12 @@ export function createClassroomRepository() { waiting: false }; }, - async createExercise(instructorHash, { publicId, caseStudyId }) { + async createExercise(instructorHash, { + publicId, + caseStudyId, + simulationVersion, + simulationFingerprint + }) { const item = activeByInstructor(instructorHash); if (!item) return null; const existing = exercises.find(exercise => ( @@ -480,6 +487,8 @@ export function createClassroomRepository() { id: publicId, classInternalId: item.internalId, caseStudyId, + simulationVersion, + simulationFingerprint, status: 'draft', currentStageId: null, stagePhase: 'work', From 257981611cccea907a37bc8a037caa68cbb9bc88 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:23:55 -0400 Subject: [PATCH 021/206] test: pin exercise staged definition metadata --- ...lassroom-exercise-repository.unit.test.mjs | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/tests/classroom-exercise-repository.unit.test.mjs b/tests/classroom-exercise-repository.unit.test.mjs index bf23952..cfe1bc7 100644 --- a/tests/classroom-exercise-repository.unit.test.mjs +++ b/tests/classroom-exercise-repository.unit.test.mjs @@ -22,6 +22,7 @@ const WORKSPACE_A_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; const WORKSPACE_B_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; const WORKSPACE_C_ID = 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'; const PARTICIPANT_A = '44444444-4444-4444-8444-444444444444'; +const SIMULATION_FINGERPRINT = 'a'.repeat(64); async function classPair(classrooms) { await classrooms.createClass({ @@ -69,17 +70,23 @@ test('exercise creation permits one open exercise per class and inherits class r const createdA = await classrooms.createExercise(testTokenHash('A'), { publicId: EXERCISE_A_ID, - caseStudyId: 'synthetic-staged-case' + caseStudyId: 'synthetic-staged-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT }); assert.equal(createdA.status, 'created'); assert.equal(createdA.exercise.status, 'draft'); + assert.equal(createdA.exercise.simulationVersion, 1); + assert.equal(createdA.exercise.simulationFingerprint, SIMULATION_FINGERPRINT); assert.equal(createdA.exercise.exerciseRevision, 1); assert.equal(createdA.exercise.currentStageId, null); assert.equal(createdA.exercise.expiresAt, createdA.classroom.expiresAt); const duplicateOpen = await classrooms.createExercise(testTokenHash('A'), { publicId: EXERCISE_A2_ID, - caseStudyId: 'another-case' + caseStudyId: 'another-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT }); assert.equal(duplicateOpen.status, 'exists'); assert.equal(duplicateOpen.exercise.id, EXERCISE_A_ID); @@ -87,7 +94,9 @@ test('exercise creation permits one open exercise per class and inherits class r const createdB = await classrooms.createExercise(testTokenHash('B'), { publicId: EXERCISE_B_ID, - caseStudyId: 'synthetic-staged-case' + caseStudyId: 'synthetic-staged-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT }); assert.equal(createdB.status, 'created'); assert.equal(createdB.classroom.id, CLASS_B_ID); @@ -104,7 +113,9 @@ test('exercise lifecycle mutation is optimistic and completion permits a later e await classPair(classrooms); await classrooms.createExercise(testTokenHash('A'), { publicId: EXERCISE_A_ID, - caseStudyId: 'synthetic-staged-case' + caseStudyId: 'synthetic-staged-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT }); const started = await classrooms.updateExerciseLifecycle(testTokenHash('A'), { @@ -157,7 +168,9 @@ test('exercise lifecycle mutation is optimistic and completion permits a later e const next = await classrooms.createExercise(testTokenHash('A'), { publicId: EXERCISE_A2_ID, - caseStudyId: 'next-staged-case' + caseStudyId: 'next-staged-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT }); assert.equal(next.status, 'created'); assert.equal(next.exercise.id, EXERCISE_A2_ID); @@ -168,7 +181,9 @@ test('optional release is idempotent and only a new release advances exercise re await classPair(classrooms); await classrooms.createExercise(testTokenHash('A'), { publicId: EXERCISE_A_ID, - caseStudyId: 'synthetic-staged-case' + caseStudyId: 'synthetic-staged-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT }); await classrooms.updateExerciseLifecycle(testTokenHash('A'), { exercisePublicId: EXERCISE_A_ID, @@ -259,7 +274,9 @@ test('readiness belongs to the current workspace and reassignment never carries }); await classrooms.createExercise(testTokenHash('A'), { publicId: EXERCISE_A_ID, - caseStudyId: 'synthetic-staged-case' + caseStudyId: 'synthetic-staged-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT }); await classrooms.updateExerciseLifecycle(testTokenHash('A'), { exercisePublicId: EXERCISE_A_ID, @@ -356,7 +373,9 @@ test('debrief checkpoints are class-scoped, immutable, idempotent, and snapshot- }); await classrooms.createExercise(testTokenHash('A'), { publicId: EXERCISE_A_ID, - caseStudyId: 'synthetic-staged-case' + caseStudyId: 'synthetic-staged-case', + simulationVersion: 1, + simulationFingerprint: SIMULATION_FINGERPRINT }); const sourceSnapshot = { From c01dfff2f2bd1f9d7625901860210cc00eee7051 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:24:18 -0400 Subject: [PATCH 022/206] fix: keep exercise definition columns migration-safe --- api/_classroom.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/api/_classroom.js b/api/_classroom.js index 9d7908e..3fe4e25 100644 --- a/api/_classroom.js +++ b/api/_classroom.js @@ -345,6 +345,19 @@ async function initializeClassroomRepository() { expires_at TIMESTAMPTZ NOT NULL, UNIQUE (class_id, id) )`; + await sql`ALTER TABLE classroom_exercises + ADD COLUMN IF NOT EXISTS simulation_version INTEGER`; + await sql`ALTER TABLE classroom_exercises + ADD COLUMN IF NOT EXISTS simulation_fingerprint CHAR(64)`; + await sql`UPDATE classroom_exercises + SET simulation_version = COALESCE(simulation_version, 1), + simulation_fingerprint = COALESCE(simulation_fingerprint, repeat('0', 64)) + WHERE simulation_version IS NULL OR simulation_fingerprint IS NULL`; + await sql`ALTER TABLE classroom_exercises + ALTER COLUMN simulation_version SET NOT NULL`; + await sql`ALTER TABLE classroom_exercises + ALTER COLUMN simulation_fingerprint SET NOT NULL`; + await sql`CREATE UNIQUE INDEX IF NOT EXISTS classroom_exercises_one_open_per_class_idx ON classroom_exercises (class_id) WHERE status <> 'completed'`; From a2bdcfbdce0fc646ea2fef34c7a9d9c4617702ea Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:25:38 -0400 Subject: [PATCH 023/206] docs: checkpoint staged exercise persistence --- docs/classroom-staged-simulation.md | 88 ++++++++++++++++++++++------- 1 file changed, 67 insertions(+), 21 deletions(-) diff --git a/docs/classroom-staged-simulation.md b/docs/classroom-staged-simulation.md index ce983ed..6193c5b 100644 --- a/docs/classroom-staged-simulation.md +++ b/docs/classroom-staged-simulation.md @@ -196,6 +196,51 @@ Validation on `af6b6f21...`: **Exact next tranche:** implement additive exercise persistence/repository primitives only: `classroom_exercises`, optional-content releases, per-workspace stage readiness, immutable debrief checkpoints, optimistic exercise revision, idempotent checkpoint/release behavior, class scoping, retention/expiry, and deterministic in-memory parity. Do not add HTTP exercise routes, edit-freeze enforcement, or Instructor/Student UI in that tranche. +## Implementation checkpoint — exercise persistence / repository + +Tranche 3 is implemented below the HTTP/UI layer. + +Persistence: +- `classroom_exercises` stores one exercise run with public UUID, owning class, protected Case Study ID, pinned simulation version + SHA-256 definition fingerprint, lifecycle status/stage/phase, optimistic `exercise_revision`, editing-policy state, timestamps, and class-bounded expiry; +- a partial unique index permits at most one non-completed exercise per class; +- `classroom_exercise_releases` stores optional Student-content releases by exercise + stage + content ID; +- `classroom_exercise_workspace_state` stores team/workspace readiness and the collaboration revision observed when Ready was marked; +- `classroom_exercise_checkpoints` stores immutable per-workspace stage snapshots/revisions for debrief evidence; +- class/workspace composite foreign keys prevent cross-class workspace state/checkpoint rows; +- exercise rows inherit the owning class's absolute expiry; all repository access also requires the active, unexpired owning class. + +Repository semantics: +- draft creation is idempotent at the class level: an existing non-completed exercise is returned rather than creating a parallel run; +- exercise lifecycle mutation requires the expected exercise revision and returns an explicit conflict without mutating on stale input; +- lifecycle mutations increment the revision once and retain start/completion timestamps; +- a new optional release is atomic with one exercise-revision increment; +- replaying the same optional release is an unchanged success and does not increment revision, even if the caller repeats its earlier expected revision; +- a different release with a stale revision conflicts; +- Student readiness resolves from the stable Student class session to the **currently assigned** workspace; Waiting Students cannot create workspace readiness; +- readiness remains attached to the old team after reassignment, while subsequent readiness writes attach to the destination team; +- checkpoint capture validates Instructor class + exercise + workspace scope and is first-write-wins for exercise + stage + workspace; +- replaying checkpoint capture returns the original revision/snapshot rather than overwriting debrief evidence; +- checkpoint snapshots are cloned in deterministic parity so later mutation of the source object cannot mutate captured evidence; +- no stage/lifecycle operation copies or merges live Intake snapshots. + +Definition drift protection: +- each exercise pins the staged simulation schema version and a 64-character definition fingerprint supplied by the later API/service layer; +- a migration-safe fallback fingerprint of all zeroes is used only if an earlier schema-only table somehow existed before these columns; the API tranche must treat a fingerprint mismatch/sentinel as fail-closed rather than silently reinterpret a running exercise. + +Durable implementation commits: +- `ea964de7afc002ca307a903db7e1493d56837f13` — additive exercise/release/workspace-state/checkpoint schema; +- `3e519b8f4ab136404ade550376af0c80e06574f1` — real Neon repository primitives; +- `8595e1ec1072b365e82f759494ea0edb25001a4c` — deterministic in-memory parity; +- `f7c1e580ca8fdc5e818e1f6974af374850f6d3a6` — focused repository contract tests; +- `09ffaa6c1ce081166240fe484bbccea6b29901f5`, `6979abc429c6d67169e044758cf394eb052ef85b`, `257981611cccea907a37bc8a037caa68cbb9bc88`, and `c01dfff2f2bd1f9d7625901860210cc00eee7051` — pin simulation definition identity and make those columns migration-safe. + +Validation on the pre-definition-hardening implementation head `f7c1e58...`: +- repository quality: **265 tests / 264 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; +- CI, CodeQL, Dependency Review, and Template Manifest Guard: green. + +The final documented head must rerun the same complete gate after definition-identity hardening. + ## Exercise domain model Persist orchestration state separately from static Case Study definitions. @@ -204,10 +249,12 @@ Persist orchestration state separately from static Case Study definitions. One row represents one run of one Case Study in one class. -Recommended fields: -- `id`; +Implemented fields include: +- internal `id` plus public UUID `public_id`; - `class_id`; - `case_study_id`; +- `simulation_version`; +- `simulation_fingerprint` — SHA-256 of the normalized staged definition supplied by the exercise service; - `status: draft | active | paused | completed`; - `current_stage_id` (nullable before start); - `stage_phase: work | debrief`; @@ -216,7 +263,8 @@ Recommended fields: - `started_at`; - `completed_at`; - `created_at`; -- `updated_at`. +- `updated_at`; +- `expires_at` copied from the owning class. A class may have historical completed exercises. At most one non-completed exercise should be active for a class unless a later product decision explicitly introduces parallel exercises. @@ -517,7 +565,7 @@ When Student A moves Team A -> Team B: - Duplicate/retried advance must not skip a stage. - Student polling treats a newer exercise revision as authoritative. - An out-of-order older response must never replace newer stage state in the browser. -- Case definition changes after an exercise starts must not silently reinterpret the running exercise. Initial implementation should rely on immutable deployed definition/version and record the simulation definition version on the exercise. +- Case definition changes after an exercise starts must not silently reinterpret the running exercise. The exercise pins both simulation version and a definition fingerprint; later API reads/mutations must fail closed when the current server-only definition does not match that identity. - If a staged definition is missing/corrupt after an exercise was created, fail closed: do not expose full Case Study state to Students. ## Accessibility requirements @@ -626,20 +674,18 @@ Do not make Standalone or ordinary Classroom assignment boot depend on an active ## Exact next implementation action -Implement **Tranche 3 only**: additive exercise persistence/repository primitives and deterministic in-memory parity. - -Required repository semantics: -- create/select one draft exercise for one class from an already validated staged Case Study identity/version; -- at most one non-completed exercise per class initially; -- optimistic `exercise_revision` mutation; -- current stage/status/phase/editing policy persistence; -- idempotent optional content releases scoped to exercise + stage; -- workspace-scoped Ready state that records workspace revision evidence; -- immutable per-workspace debrief checkpoint capture that is idempotent for exercise + stage + workspace; -- class/workspace ownership validation; -- retention bounded by the owning class; -- no Intake snapshot merge/copy during ordinary stage/assignment operations. - -Add deterministic in-memory repository parity and focused tests before any HTTP route consumes these methods. - -Stop before exercise HTTP handlers, collaboration edit-freeze enforcement, or Instructor/Student UI. +Implement **Tranche 4 only — exercise APIs and authorization** on top of the now-proven repository contract. + +Required server/API slice: +- Instructor `POST /api/classes/exercise` selects/creates a draft only from a protected Case Study with a valid explicit `simulation` definition, computes/pins the normalized definition fingerprint, and refuses a definition mismatch; +- Instructor `GET /api/classes/exercise` returns only the represented class's current exercise plus the complete Instructor-authorized staged definition; +- Instructor lifecycle PATCH actions for start, pause, resume, optional-content release, begin-debrief, advance, and complete use `expectedRevision` and the immutable stage ordering from the pinned definition; +- **do not expose a claimed edit-freeze guarantee yet**: temporary collaboration write-lock enforcement remains Tranche 5; begin-debrief may persist phase/checkpoints while editing remains effectively open until that guard lands; +- begin-debrief captures each current class workspace's collaboration snapshot/revision exactly once through the immutable checkpoint primitive; +- Student `GET /api/classes/exercise/student` uses the Student **class-session** capability and returns only current/cumulative released Student-safe content, never future stage metadata or Instructor content; +- Student Ready route resolves the participant's current assignment server-side and records the server-observed workspace revision; Waiting cannot mark Ready; +- close the active-staged-case bypass in Student protected Case Study full-payload retrieval so the complete `state` cannot be fetched while that case is the current non-completed staged exercise; +- add a complete authorization/error matrix: wrong class, human join code, Instructor-vs-Student credential confusion, stale revision, non-staged case, definition drift, Waiting readiness, cross-class workspace/checkpoint attempts, and future/Instructor-content withholding; +- keep all staged responses `no-store` / `no-referrer`. + +Stop before Student collaboration PUT freeze/423 behavior and before Instructor/Student exercise UI. From a782a7430240c67adf356c521dc0d8088b0e59b4 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:25:58 -0400 Subject: [PATCH 024/206] docs: advance staged simulation to API tranche --- docs/classroom-workstream.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md index 887cc67..5092c4e 100644 --- a/docs/classroom-workstream.md +++ b/docs/classroom-workstream.md @@ -421,7 +421,15 @@ Continue #295 on draft PR #307 from the current branch head. - Standard Templates cannot carry `simulation`; Student and Instructor content are separate, stable-ID/cross-reference validated namespaces; unsafe arbitrary fields/URLs are rejected; existing non-staged Case Studies remain unchanged. - no official production case has been staged or guessed. - validation on `af6b6f21...`: repository quality **260 tests / 259 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; Template Manifest Guard and Dependency Review green. -- Exact next action: implement **Tranche 3 only** — additive exercise persistence/repository + deterministic in-memory parity: class exercise lifecycle state, optimistic revision, optional releases, workspace readiness, immutable idempotent debrief checkpoints, class/workspace scoping, and retention. Do not add HTTP exercise routes, collaboration freeze enforcement, or UI yet. +- Staged exercise persistence/repository checkpoint: + - `ea964de7afc002ca307a903db7e1493d56837f13` adds additive exercise, optional-release, workspace-readiness, and immutable-checkpoint tables with class/workspace foreign-key scoping and one-open-exercise-per-class enforcement. + - `3e519b8f4ab136404ade550376af0c80e06574f1` adds real repository primitives for draft creation, optimistic lifecycle mutation, release replay/idempotency, Student-session/current-workspace readiness, class-scoped checkpoint capture/listing, and class-bounded expiry. + - `8595e1ec1072b365e82f759494ea0edb25001a4c` mirrors those semantics in the deterministic repository. + - `f7c1e580ca8fdc5e818e1f6974af374850f6d3a6` proves one-open-exercise, optimistic conflict, completion/history, release idempotency, readiness staying with teams through reassignment, Waiting rejection, immutable snapshot-isolated checkpoints, and cross-class rejection. + - `09ffaa6c...` through `c01dfff2f2bd1f9d7625901860210cc00eee7051` additionally pin simulation version + definition fingerprint on each exercise and keep those columns migration-safe so later APIs can fail closed on definition drift. + - pre-hardening validation on `f7c1e58...`: repository quality **265 tests / 264 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. + - no exercise HTTP route, Student collaboration write lock, or exercise UI exists yet. +- Exact next action: implement **Tranche 4 only — exercise APIs/authorization**: Instructor create/read/lifecycle/release/debrief/checkpoint/advance/complete, Student class-session current release + workspace readiness, pinned-definition drift checks, and active-staged-case full-payload bypass closure. Build the complete credential/class/future-content authorization matrix. Do **not** claim or expose server-enforced editing freeze until Tranche 5, and do not add Instructor/Student exercise UI yet. ## Approved Classroom follow-on direction From 9de46184c507ff4c928c51e065e22769c071ad8c Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:26:29 -0400 Subject: [PATCH 025/206] docs: describe staged exercise persistence --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index f23f1eb..e40b2a5 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ The database stores only SHA-256 capability hashes. `CLASS_EXPIRY_DAYS` optional See `docs/classroom-api.md` for the endpoint/capability matrix and `SECURITY.md` for the security boundary. Instructor observation is implemented through the separate GET-only `/api/classes/observe` path, authorized by the Instructor class capability after class/workspace ownership is proven. It never resolves through the editable workspace-alias path. +#313 staged simulation is currently below the HTTP/UI layer. The protected Case Study manifest may carry an explicit server-only `simulation` definition, while additive Neon tables persist class exercise lifecycle, optional releases, workspace readiness, and immutable debrief checkpoints. Running exercises pin the staged definition version plus a definition fingerprint and inherit the owning class expiry. These tables do **not** duplicate live Intake state: collaboration workspaces remain the only live snapshot/revision source of truth. Exercise HTTP routes, Student write-freeze enforcement, and exercise UI are not yet active in this tranche. + ### Conflict recovery and two-window testing Updates use optimistic revision control: each complete snapshot retains the revision observed when it was captured. Text edits wait about 300 ms so typing is responsive, blur flushes pending text, and completed select, checkbox, radio, and button-driven changes save immediately where the owning control is identifiable. Only one PUT is sent at a time; edits made during that request collapse to the latest snapshot and follow the successful response at its returned revision. A stale write receives HTTP 409 and cannot overwrite the newer row. From d93e9df10f5ba2e2756bb4b82be594b90abf307f Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:26:45 -0400 Subject: [PATCH 026/206] docs: add staged exercise persistence architecture --- docs/classroom-architecture.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/classroom-architecture.md b/docs/classroom-architecture.md index 1d9a3f1..9521071 100644 --- a/docs/classroom-architecture.md +++ b/docs/classroom-architecture.md @@ -70,11 +70,13 @@ Class independent feedback revision created/updated metadata - -> Classroom Exercise / Stage State [future #313] - selected protected Case Study - exercise status / current stage - released Student content - Instructor-only facilitation/debrief material + -> Classroom Exercise / Stage State [#313] + selected protected Case Study + pinned staged-definition identity + exercise status / current stage / phase / optimistic revision + optional Student-content release state + per-workspace readiness + immutable per-workspace debrief checkpoints + Instructor-only facilitation/debrief material remains in protected definition ``` The **Classroom Workspace** references the existing collaboration workspace rather than duplicating its snapshot, revision, participant, or presence engine. #312 deliberately separates stable class participation from mutable workspace assignment so a Student can wait unassigned, move between teams, or be unassigned again without changing identity or merging team Intake state. @@ -260,7 +262,14 @@ The Classroom persistence model is additive: - `classroom_participants` — #312 live participant identity, optional current assignment, `assignment_revision`, Student class-session capability hash, and current workspace-access capability hash; - `classroom_coaching_feedback` — separate class/workspace/target coaching channel. -Protected Case Study delivery (#295) adds no public/browser data table and remains server-gated. #313 should add exercise/stage state separately from Intake snapshots and from participant authorization; it must not overload collaboration revision or coaching state. +Protected Case Study delivery (#295) adds no public/browser data table and remains server-gated. #313 Tranche 3 now adds exercise orchestration persistence separately from Intake snapshots and participant authorization: + +- `classroom_exercises` — one exercise run, owning class, protected Case Study identity, pinned simulation version/fingerprint, lifecycle/stage/phase, optimistic revision, editing-policy state, and class-bounded expiry; +- `classroom_exercise_releases` — explicitly released optional Student content by exercise/stage/content ID; +- `classroom_exercise_workspace_state` — workspace/team Ready state plus collaboration-revision evidence; +- `classroom_exercise_checkpoints` — immutable pre-debrief workspace snapshot/revision evidence. + +These tables do not replace or duplicate collaboration state. Checkpoints are historical facilitation evidence only; the live Intake remains in `collaboration_workspaces`. Exercise state must not overload collaboration revision or coaching revision. Schema initialization/migration must remain idempotent and documented. Browser boot for Standalone must never depend on Classroom schema creation. From 9fde9be91a1db4fe6f3473e4f4c26e474fcde1ad Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:26:59 -0400 Subject: [PATCH 027/206] docs: record staged exercise database contract --- docs/classroom-api.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/classroom-api.md b/docs/classroom-api.md index 4678d53..a44eede 100644 --- a/docs/classroom-api.md +++ b/docs/classroom-api.md @@ -269,6 +269,15 @@ The legacy membership table remains supported for two-code compatibility. Live a Database foreign keys and handler authorization jointly enforce class/workspace isolation. Existing collaboration tables, Standalone secret links, and the legacy Classroom path remain valid. +#313 Tranche 3 adds persistence primitives but **no exercise HTTP routes yet**: + +- `classroom_exercises` — class-scoped exercise run with protected Case Study ID, pinned simulation version + definition fingerprint, status/stage/phase, optimistic `exercise_revision`, editing-policy state, timestamps, and owning-class expiry; +- `classroom_exercise_releases` — idempotent optional-content release records; +- `classroom_exercise_workspace_state` — workspace-scoped Ready state and workspace-revision evidence; +- `classroom_exercise_checkpoints` — immutable exercise/stage/workspace snapshot + revision evidence captured for debrief. + +At most one non-completed exercise exists per class in the initial model. Exercise mutation is optimistic-revision protected; replaying an already-recorded optional release does not advance revision. Ready state follows the workspace, not the participant, and checkpoint rows are first-write-wins. All repository access is class/workspace scoped and bounded by the class expiry. + No classroom credential is stored in browser Intake state. ## Rotation and revocation From 457cb50edd94e355a9e7ec83f11972590da6b4b7 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:27:14 -0400 Subject: [PATCH 028/206] docs: add staged exercise repository guardrails --- api/AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/AGENTS.md b/api/AGENTS.md index f809beb..7c6642d 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -150,5 +150,9 @@ Rules: - beginning debrief must capture idempotent immutable workspace checkpoints before any optional post-debrief edits; - if Student editing is frozen, enforce it on the `classroom-student` collaboration write path; UI read-only projection alone is not authority; - late joiners receive only the current cumulative Student release; reassignment changes workspace Intake/readiness, not class exercise stage; +- exercise persistence uses one non-completed exercise per class initially, optimistic `exercise_revision`, idempotent optional releases, workspace-scoped readiness, immutable first-write-wins debrief checkpoints, and class-bounded expiry; +- every running exercise pins the staged simulation version plus a definition fingerprint; later API reads/mutations must fail closed if the current protected definition does not match; +- checkpoint snapshots are facilitation evidence only and must never overwrite or become the live collaboration snapshot automatically; +- do not expose `student_editing_enabled=false` as a security guarantee until the `classroom-student` collaboration PUT path enforces the lock server-side; edit-freeze enforcement belongs to the dedicated follow-on tranche; - do not create production stage definitions from guessed official case boundaries. Synthetic deterministic staged content is acceptable in tests until authoritative material is supplied. From c80a145b262bfd29a5b021417b10584e8215cdf6 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:32:48 -0400 Subject: [PATCH 029/206] feat: add exercise API support repository primitives --- api/_classroom.js | 189 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/api/_classroom.js b/api/_classroom.js index 3fe4e25..28a3c99 100644 --- a/api/_classroom.js +++ b/api/_classroom.js @@ -901,6 +901,195 @@ async function initializeClassroomRepository() { return rows[0] ? { classroom, exercise: rows[0] } : null; }, + async getCurrentExerciseByClassInternalId(classInternalId) { + const rows = await sql`SELECT + e.public_id AS id, + e.case_study_id AS "caseStudyId", + e.simulation_version AS "simulationVersion", + e.simulation_fingerprint AS "simulationFingerprint", + e.status, + e.current_stage_id AS "currentStageId", + e.stage_phase AS "stagePhase", + e.exercise_revision AS "exerciseRevision", + e.student_editing_enabled AS "studentEditingEnabled", + e.started_at AS "startedAt", + e.completed_at AS "completedAt", + e.created_at AS "createdAt", + e.updated_at AS "updatedAt", + e.expires_at AS "expiresAt" + FROM classroom_exercises e + JOIN classroom_classes c ON c.id = e.class_id + WHERE e.class_id = ${classInternalId} + AND e.status <> 'completed' + AND e.expires_at > NOW() + AND c.revoked_at IS NULL + AND c.expires_at > NOW() + ORDER BY e.created_at DESC, e.id DESC + LIMIT 1`; + return rows[0] || null; + }, + + async hasExerciseForClassCase(classInternalId, caseStudyId) { + const rows = await sql`SELECT 1 AS found + FROM classroom_exercises e + JOIN classroom_classes c ON c.id = e.class_id + WHERE e.class_id = ${classInternalId} + AND e.case_study_id = ${caseStudyId} + AND e.expires_at > NOW() + AND c.revoked_at IS NULL + AND c.expires_at > NOW() + LIMIT 1`; + return Boolean(rows[0]); + }, + + async getExerciseForStudentSession(sessionHash) { + const context = await this.getParticipantBySession(sessionHash); + if (!context) return null; + + const rows = await sql`SELECT + id AS "internalId", + public_id AS id, + case_study_id AS "caseStudyId", + simulation_version AS "simulationVersion", + simulation_fingerprint AS "simulationFingerprint", + status, + current_stage_id AS "currentStageId", + stage_phase AS "stagePhase", + exercise_revision AS "exerciseRevision", + student_editing_enabled AS "studentEditingEnabled", + started_at AS "startedAt", + completed_at AS "completedAt", + created_at AS "createdAt", + updated_at AS "updatedAt", + expires_at AS "expiresAt" + FROM classroom_exercises + WHERE class_id = ${context.internal.classId} + AND status <> 'completed' + AND expires_at > NOW() + ORDER BY created_at DESC, id DESC + LIMIT 1`; + const exercise = rows[0] || null; + if (!exercise) { + return { ...context, exercise: null, releases: [], readiness: null }; + } + + const releases = await sql`SELECT + stage_id AS "stageId", + content_id AS "contentId", + released_at AS "releasedAt" + FROM classroom_exercise_releases + WHERE exercise_id = ${exercise.internalId} + ORDER BY released_at, stage_id, content_id`; + + let readiness = null; + if (context.internal.workspaceId && exercise.currentStageId) { + const states = await sql`SELECT + stage_id AS "stageId", + ready_for_debrief AS "readyForDebrief", + ready_at AS "readyAt", + ready_workspace_revision AS "readyWorkspaceRevision", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM classroom_exercise_workspace_state + WHERE exercise_id = ${exercise.internalId} + AND workspace_id = ${context.internal.workspaceId} + AND stage_id = ${exercise.currentStageId}`; + readiness = states[0] || null; + } + + const { internalId: _internalId, ...publicExercise } = exercise; + return { + ...context, + exercise: publicExercise, + releases, + readiness + }; + }, + + async beginExerciseDebrief(instructorHash, { + exercisePublicId, + expectedRevision, + stageId + }) { + const classroom = await classByInstructor(instructorHash); + if (!classroom) return null; + + const rows = await sql`WITH target AS ( + UPDATE classroom_exercises e + SET stage_phase = 'debrief', + student_editing_enabled = TRUE, + exercise_revision = exercise_revision + 1, + updated_at = NOW() + WHERE e.class_id = ${classroom.internal_id} + AND e.public_id = ${exercisePublicId}::uuid + AND e.exercise_revision = ${expectedRevision} + AND e.status = 'active' + AND e.current_stage_id = ${stageId} + AND e.stage_phase = 'work' + AND e.expires_at > NOW() + RETURNING + e.id AS internal_id, + e.class_id, + e.public_id AS id, + e.case_study_id AS "caseStudyId", + e.simulation_version AS "simulationVersion", + e.simulation_fingerprint AS "simulationFingerprint", + e.status, + e.current_stage_id AS "currentStageId", + e.stage_phase AS "stagePhase", + e.exercise_revision AS "exerciseRevision", + e.student_editing_enabled AS "studentEditingEnabled", + e.started_at AS "startedAt", + e.completed_at AS "completedAt", + e.created_at AS "createdAt", + e.updated_at AS "updatedAt", + e.expires_at AS "expiresAt" + ), + captured AS ( + INSERT INTO classroom_exercise_checkpoints + ( + class_id, exercise_id, stage_id, workspace_id, + workspace_revision, snapshot + ) + SELECT + t.class_id, + t.internal_id, + t."currentStageId", + cw.workspace_id, + w.revision, + w.snapshot_json + FROM target t + JOIN classroom_workspaces cw + ON cw.class_id = t.class_id + AND cw.revoked_at IS NULL + JOIN collaboration_workspaces w + ON w.id = cw.workspace_id + AND w.expires_at > NOW() + ON CONFLICT DO NOTHING + RETURNING workspace_id + ) + SELECT + t.*, + (SELECT COUNT(*)::int FROM captured) AS "capturedCount" + FROM target t`; + + if (rows[0]) { + const row = rows[0]; + const { + internal_id: _internalId, + class_id: _classId, + capturedCount, + ...exercise + } = row; + return { status: 'updated', classroom, exercise, capturedCount }; + } + + const current = await this.getExerciseForInstructor(instructorHash, exercisePublicId); + return current + ? { status: 'conflict', classroom: current.classroom, exercise: current.exercise } + : null; + }, + async updateExerciseLifecycle(instructorHash, { exercisePublicId, expectedRevision, From 065e60ae4f578b0ded6965aab0b085b07ed16b6c Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:33:15 -0400 Subject: [PATCH 030/206] test: mirror exercise API support primitives --- tests/helpers/classroom-test-repositories.mjs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/tests/helpers/classroom-test-repositories.mjs b/tests/helpers/classroom-test-repositories.mjs index 53112c1..3c90b9e 100644 --- a/tests/helpers/classroom-test-repositories.mjs +++ b/tests/helpers/classroom-test-repositories.mjs @@ -529,6 +529,123 @@ export function createClassroomRepository() { ? { classroom: publicClass(item), exercise: publicExercise(exercise) } : null; }, + async getCurrentExerciseByClassInternalId(classInternalId) { + const item = classes.find(candidate => candidate.internalId === Number(classInternalId) && !candidate.revoked); + if (!item) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.status !== 'completed' + )); + return publicExercise(exercise); + }, + async hasExerciseForClassCase(classInternalId, caseStudyId) { + const item = classes.find(candidate => candidate.internalId === Number(classInternalId) && !candidate.revoked); + if (!item) return false; + return exercises.some(exercise => ( + exercise.classInternalId === item.internalId + && exercise.caseStudyId === caseStudyId + )); + }, + async getExerciseForStudentSession(sessionHash) { + const context = await this.getParticipantBySession(sessionHash); + if (!context) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === context.internal.classId + && candidate.status !== 'completed' + )); + if (!exercise) return { ...context, exercise: null, releases: [], readiness: null }; + + const releasePrefix = exercise.internalId + ':'; + const releases = [...exerciseReleases.entries()] + .filter(([key]) => key.startsWith(releasePrefix)) + .map(([, value]) => ({ ...value })) + .sort((a, b) => ( + a.releasedAt.localeCompare(b.releasedAt) + || a.stageId.localeCompare(b.stageId) + || a.contentId.localeCompare(b.contentId) + )); + + let readiness = null; + if (context.internal.workspaceId && exercise.currentStageId) { + const key = exercise.internalId + ':' + context.internal.workspaceId + ':' + exercise.currentStageId; + const state = exerciseWorkspaceState.get(key); + if (state) { + const { + classInternalId: _classInternalId, + exerciseInternalId: _exerciseInternalId, + workspaceInternalId: _workspaceInternalId, + ...publicReadiness + } = state; + readiness = { ...publicReadiness }; + } + } + + return { + ...context, + exercise: publicExercise(exercise), + releases, + readiness + }; + }, + async beginExerciseDebrief(instructorHash, { + exercisePublicId, + expectedRevision, + stageId, + workspaceRepository + }) { + const item = activeByInstructor(instructorHash); + if (!item) return null; + const exercise = exercises.find(candidate => ( + candidate.classInternalId === item.internalId + && candidate.id === exercisePublicId + )); + if (!exercise) return null; + if ( + exercise.exerciseRevision !== expectedRevision + || exercise.status !== 'active' + || exercise.currentStageId !== stageId + || exercise.stagePhase !== 'work' + ) { + return { + status: 'conflict', + classroom: publicClass(item), + exercise: publicExercise(exercise) + }; + } + + let capturedCount = 0; + for (const workspace of workspaces.filter(candidate => ( + candidate.classInternalId === item.internalId && !candidate.revoked + ))) { + const key = exercise.internalId + ':' + stageId + ':' + workspace.workspaceId; + if (exerciseCheckpoints.has(key)) continue; + const observation = await workspaceRepository.observeById(workspace.workspaceId); + if (!observation) continue; + exerciseCheckpoints.set(key, { + stageId, + workspaceRevision: observation.revision, + snapshot: cloneValue(observation.snapshot), + capturedAt: 'captured', + classInternalId: item.internalId, + exerciseInternalId: exercise.internalId, + workspaceInternalId: workspace.workspaceId + }); + capturedCount += 1; + } + + exercise.stagePhase = 'debrief'; + // Tranche 4 deliberately does not claim edit freeze; Tranche 5 will add the + // collaboration write guard before this policy can become authoritative. + exercise.studentEditingEnabled = true; + exercise.exerciseRevision += 1; + exercise.updatedAt = 'updated'; + return { + status: 'updated', + classroom: publicClass(item), + exercise: publicExercise(exercise), + capturedCount + }; + }, async updateExerciseLifecycle(instructorHash, { exercisePublicId, expectedRevision, From b504034f325bfdfcea6c0f9c6e91be0ad5016c91 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:34:22 -0400 Subject: [PATCH 031/206] fix: bind readiness writes to observed assignment --- api/_classroom.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/api/_classroom.js b/api/_classroom.js index 28a3c99..ed81b56 100644 --- a/api/_classroom.js +++ b/api/_classroom.js @@ -1256,13 +1256,20 @@ async function initializeClassroomRepository() { exercisePublicId, stageId, ready, - workspaceRevision + workspaceRevision, + expectedWorkspaceInternalId = null }) { const context = await this.getParticipantBySession(sessionHash); if (!context) return null; if (!context.internal.workspaceId || !context.assignment) { return { status: 'waiting', ...context }; } + if ( + expectedWorkspaceInternalId !== null + && Number(context.internal.workspaceId) !== Number(expectedWorkspaceInternalId) + ) { + return { status: 'conflict', ...context }; + } const exercises = await sql`SELECT id AS "internalId", From c72cd3946a8561861203fc679491fa778b7d2e28 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:34:38 -0400 Subject: [PATCH 032/206] test: mirror readiness assignment race guard --- tests/helpers/classroom-test-repositories.mjs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/helpers/classroom-test-repositories.mjs b/tests/helpers/classroom-test-repositories.mjs index 3c90b9e..4bac68a 100644 --- a/tests/helpers/classroom-test-repositories.mjs +++ b/tests/helpers/classroom-test-repositories.mjs @@ -749,13 +749,20 @@ export function createClassroomRepository() { exercisePublicId, stageId, ready, - workspaceRevision + workspaceRevision, + expectedWorkspaceInternalId = null }) { const context = await this.getParticipantBySession(sessionHash); if (!context) return null; if (!context.internal.workspaceId || !context.assignment) { return { status: 'waiting', ...context }; } + if ( + expectedWorkspaceInternalId !== null + && Number(context.internal.workspaceId) !== Number(expectedWorkspaceInternalId) + ) { + return { status: 'conflict', ...context }; + } const exercise = exercises.find(candidate => ( candidate.classInternalId === context.internal.classId && candidate.id === exercisePublicId From 5563bd9c935ac3a7ae7186c1301d20f8f43e116a Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:36:36 -0400 Subject: [PATCH 033/206] feat: add staged exercise API handlers --- api/_classroomExercise.js | 564 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 564 insertions(+) create mode 100644 api/_classroomExercise.js diff --git a/api/_classroomExercise.js b/api/_classroomExercise.js new file mode 100644 index 0000000..61e5f0a --- /dev/null +++ b/api/_classroomExercise.js @@ -0,0 +1,564 @@ +/** + * @module api/classroomExercise + * @description + * Server-only #313 staged exercise orchestration. Instructor handlers receive + * the complete staged definition; Student handlers receive only the cumulative + * Student-safe release through the current stage. No exercise route grants + * collaboration edit authority. + */ + +import { createHash, randomUUID } from 'node:crypto'; + +import { getClassroomRepository } from './_classroom.js'; +import { PROTECTED_CASE_STUDY_MANIFEST } from './protected-case-studies.manifest.js'; +import { + getWorkspaceRepository, + hashWorkspaceToken, + parseAuthorizationToken +} from './_workspace.js'; + +function send(res, status, body) { + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Referrer-Policy', 'no-referrer'); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + return res.status(status).json(body); +} + +function methodNotAllowed(res, allowed) { + res.setHeader('Allow', allowed); + return send(res, 405, { error: 'Method not allowed.' }); +} + +function requireBearer(req) { + if (req.headers?.authorization === undefined) { + return { ok: false, status: 401, error: 'Authorization required.' }; + } + const token = parseAuthorizationToken(req.headers.authorization); + return token + ? { ok: true, token } + : { ok: false, status: 400, error: 'Invalid authorization.' }; +} + +function positiveRevision(value) { + const revision = Number(value); + return Number.isInteger(revision) && revision > 0 ? revision : null; +} + +function requestedCaseStudyId(body) { + const id = typeof body?.caseStudyId === 'string' ? body.caseStudyId.trim() : ''; + return id && id.length <= 160 ? id : null; +} + +function canonicalJson(value) { + if (Array.isArray(value)) { + return '[' + value.map(canonicalJson).join(',') + ']'; + } + if (value && typeof value === 'object') { + return '{' + Object.keys(value) + .sort() + .map(key => JSON.stringify(key) + ':' + canonicalJson(value[key])) + .join(',') + '}'; + } + return JSON.stringify(value); +} + +/** + * Return a stable SHA-256 identity for one normalized staged definition. + * + * @param {object} simulation Normalized server-only simulation definition. + * @returns {string} Lowercase SHA-256 hex digest. + */ +export function fingerprintStagedSimulation(simulation) { + return createHash('sha256').update(canonicalJson(simulation)).digest('hex'); +} + +function stagedCaseStudy(manifest, caseStudyId) { + const entry = manifest.find(candidate => candidate.id === caseStudyId) || null; + return entry?.templateKind === 'case-study' && entry.simulation ? entry : null; +} + +function caseStudySummary(caseStudy) { + return { + id: caseStudy.id, + name: caseStudy.name, + description: caseStudy.description, + supportedModes: [...caseStudy.supportedModes] + }; +} + +function instructorDefinition(caseStudy) { + return { + ...caseStudySummary(caseStudy), + simulation: caseStudy.simulation + }; +} + +function definitionMatches(exercise, caseStudy) { + if (!exercise || !caseStudy?.simulation) return false; + return exercise.simulationVersion === caseStudy.simulation.version + && exercise.simulationFingerprint === fingerprintStagedSimulation(caseStudy.simulation); +} + +function currentStage(caseStudy, exercise) { + if (!exercise?.currentStageId) return { stage: null, index: -1 }; + const index = caseStudy.simulation.stages.findIndex(stage => stage.id === exercise.currentStageId); + return { + stage: index >= 0 ? caseStudy.simulation.stages[index] : null, + index + }; +} + +function releasedStudentContent(caseStudy, exercise, releases) { + const { index } = currentStage(caseStudy, exercise); + if (index < 0 || exercise.status === 'draft') return []; + + const blocks = new Map(caseStudy.simulation.studentContent.map(block => [block.id, block])); + const releasesByStage = new Map(); + for (const release of releases || []) { + const items = releasesByStage.get(release.stageId) || []; + items.push(release); + releasesByStage.set(release.stageId, items); + } + + const result = []; + const seen = new Set(); + const append = (stageId, contentId, releaseType, releasedAt = null) => { + if (seen.has(contentId)) return; + const content = blocks.get(contentId); + if (!content) return; + seen.add(contentId); + result.push({ + stageId, + releaseType, + releasedAt, + content + }); + }; + + caseStudy.simulation.stages.slice(0, index + 1).forEach(stage => { + stage.initialReleaseIds.forEach(contentId => append(stage.id, contentId, 'initial')); + const allowedOptional = new Set(stage.optionalReleaseIds); + for (const release of releasesByStage.get(stage.id) || []) { + if (allowedOptional.has(release.contentId)) { + append(stage.id, release.contentId, 'optional', release.releasedAt || null); + } + } + }); + + return result; +} + +function studentExercisePayload(context, caseStudy) { + if (!context.exercise) { + return { + class: context.classroom, + participant: context.participant, + assignment: context.assignment, + exercise: null + }; + } + + const { stage } = currentStage(caseStudy, context.exercise); + return { + class: context.classroom, + participant: context.participant, + assignment: context.assignment, + exercise: { + id: context.exercise.id, + caseStudyId: context.exercise.caseStudyId, + caseStudy: caseStudySummary(caseStudy), + status: context.exercise.status, + stagePhase: context.exercise.stagePhase, + exerciseRevision: context.exercise.exerciseRevision, + // Tranche 4 has not yet installed the collaboration PUT lock. Do not + // represent the persisted policy bit as enforced authority. + studentEditingEnabled: true, + editFreezeEnforced: false, + currentStage: stage + ? { + id: stage.id, + title: stage.title, + studentObjective: stage.studentObjective + } + : null, + releasedContent: releasedStudentContent( + caseStudy, + context.exercise, + context.releases + ), + readiness: context.readiness + ? { + stageId: context.readiness.stageId, + readyForDebrief: context.readiness.readyForDebrief, + readyAt: context.readiness.readyAt, + readyWorkspaceRevision: context.readiness.readyWorkspaceRevision + } + : null + } + }; +} + +async function instructorExercisePayload(repository, instructorHash, classroom, exercise, caseStudy) { + const releases = await repository.listExerciseReleasesForInstructor(instructorHash, exercise.id); + const workspaceState = await repository.listExerciseWorkspaceStateForInstructor(instructorHash, exercise.id); + const checkpoints = exercise.currentStageId + ? await repository.listExerciseCheckpointsForInstructor( + instructorHash, + exercise.id, + exercise.currentStageId + ) + : null; + + return { + class: classroom, + exercise, + caseStudy: instructorDefinition(caseStudy), + releases: releases?.releases || [], + workspaceState: workspaceState?.workspaceState || [], + checkpoints: checkpoints?.checkpoints || [], + editFreezeEnforced: false + }; +} + +function conflict(res, result, fallback = 'Exercise changed. Refresh and retry.') { + return send(res, 409, { + error: fallback, + exercise: result?.exercise || null + }); +} + +/** + * Instructor staged exercise create/read/lifecycle handler. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classExerciseHandler({ + getRepository = getClassroomRepository, + getWorkspaceRepo = getWorkspaceRepository, + manifest = PROTECTED_CASE_STUDY_MANIFEST, + idFactory = randomUUID +} = {}) { + return async (req, res) => { + if (!['POST', 'GET', 'PATCH'].includes(req.method)) { + return methodNotAllowed(res, 'POST, GET, PATCH'); + } + const authorization = requireBearer(req); + if (!authorization.ok) return send(res, authorization.status, { error: authorization.error }); + const instructorHash = hashWorkspaceToken(authorization.token); + + try { + const repository = await getRepository(); + const classroom = await repository.getClassByInstructor(instructorHash); + if (!classroom) return send(res, 404, { error: 'Class not found.' }); + + if (req.method === 'POST') { + const caseStudyId = requestedCaseStudyId(req.body); + if (!caseStudyId) return send(res, 400, { error: 'Invalid Case Study selection.' }); + const caseStudy = stagedCaseStudy(manifest, caseStudyId); + if (!caseStudy) return send(res, 404, { error: 'Staged Case Study not found.' }); + + const existing = await repository.getCurrentExerciseForInstructor(instructorHash); + if (existing) { + const existingCase = stagedCaseStudy(manifest, existing.exercise.caseStudyId); + if (!definitionMatches(existing.exercise, existingCase)) { + return conflict(res, existing, 'Exercise definition is unavailable or has changed.'); + } + if (existing.exercise.caseStudyId !== caseStudyId) { + return conflict(res, existing, 'Another exercise is already open for this class.'); + } + const body = await instructorExercisePayload( + repository, + instructorHash, + existing.classroom, + existing.exercise, + existingCase + ); + return send(res, 200, { ...body, created: false }); + } + + const result = await repository.createExercise(instructorHash, { + publicId: idFactory(), + caseStudyId, + simulationVersion: caseStudy.simulation.version, + simulationFingerprint: fingerprintStagedSimulation(caseStudy.simulation) + }); + if (!result) return send(res, 404, { error: 'Class not found.' }); + + const resolvedCase = stagedCaseStudy(manifest, result.exercise.caseStudyId); + if (!definitionMatches(result.exercise, resolvedCase)) { + return conflict(res, result, 'Exercise definition is unavailable or has changed.'); + } + if (result.exercise.caseStudyId !== caseStudyId) { + return conflict(res, result, 'Another exercise is already open for this class.'); + } + + const body = await instructorExercisePayload( + repository, + instructorHash, + result.classroom, + result.exercise, + resolvedCase + ); + return send(res, result.status === 'created' ? 201 : 200, { + ...body, + created: result.status === 'created' + }); + } + + const current = await repository.getCurrentExerciseForInstructor(instructorHash); + if (!current) { + return req.method === 'GET' + ? send(res, 200, { class: classroom, exercise: null }) + : send(res, 404, { error: 'Exercise not found.' }); + } + const caseStudy = stagedCaseStudy(manifest, current.exercise.caseStudyId); + if (!definitionMatches(current.exercise, caseStudy)) { + return conflict(res, current, 'Exercise definition is unavailable or has changed.'); + } + + if (req.method === 'GET') { + return send(res, 200, await instructorExercisePayload( + repository, + instructorHash, + current.classroom, + current.exercise, + caseStudy + )); + } + + const expectedRevision = positiveRevision(req.body?.expectedRevision); + if (!expectedRevision) return send(res, 400, { error: 'Invalid exercise revision.' }); + const action = req.body?.action; + const { stage, index } = currentStage(caseStudy, current.exercise); + let result = null; + + if (action === 'start') { + if (current.exercise.status !== 'draft' || current.exercise.currentStageId !== null) { + return conflict(res, current, 'Exercise cannot be started from its current state.'); + } + const firstStage = caseStudy.simulation.stages[0]; + if (!firstStage) return conflict(res, current, 'Exercise has no staged definition.'); + result = await repository.updateExerciseLifecycle(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + status: 'active', + currentStageId: firstStage.id, + stagePhase: 'work', + studentEditingEnabled: true + }); + } else if (action === 'pause') { + if (current.exercise.status !== 'active') { + return conflict(res, current, 'Exercise is not active.'); + } + result = await repository.updateExerciseLifecycle(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + status: 'paused', + currentStageId: current.exercise.currentStageId, + stagePhase: current.exercise.stagePhase, + studentEditingEnabled: true + }); + } else if (action === 'resume') { + if (current.exercise.status !== 'paused') { + return conflict(res, current, 'Exercise is not paused.'); + } + result = await repository.updateExerciseLifecycle(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + status: 'active', + currentStageId: current.exercise.currentStageId, + stagePhase: current.exercise.stagePhase, + studentEditingEnabled: true + }); + } else if (action === 'release-content') { + const contentId = typeof req.body?.contentId === 'string' ? req.body.contentId.trim() : ''; + if ( + current.exercise.status !== 'active' + || current.exercise.stagePhase !== 'work' + || !stage + || !stage.optionalReleaseIds.includes(contentId) + ) { + return send(res, 400, { error: 'Invalid current-stage content release.' }); + } + result = await repository.releaseExerciseContent(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + stageId: stage.id, + contentId + }); + } else if (action === 'begin-debrief') { + if (current.exercise.status !== 'active' || current.exercise.stagePhase !== 'work' || !stage) { + return conflict(res, current, 'Exercise is not ready to enter debrief.'); + } + const workspaceRepository = await getWorkspaceRepo(); + result = await repository.beginExerciseDebrief(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + stageId: stage.id, + workspaceRepository + }); + } else if (action === 'advance') { + if (current.exercise.status !== 'active' || current.exercise.stagePhase !== 'debrief' || !stage) { + return conflict(res, current, 'Exercise is not ready to advance.'); + } + const nextStage = caseStudy.simulation.stages[index + 1]; + if (!nextStage) return send(res, 409, { error: 'Exercise is already at its final stage.' }); + result = await repository.updateExerciseLifecycle(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + status: 'active', + currentStageId: nextStage.id, + stagePhase: 'work', + studentEditingEnabled: true + }); + } else if (action === 'complete') { + if ( + current.exercise.status !== 'active' + || current.exercise.stagePhase !== 'debrief' + || !stage + || index !== caseStudy.simulation.stages.length - 1 + ) { + return conflict(res, current, 'Exercise cannot be completed from its current state.'); + } + result = await repository.updateExerciseLifecycle(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + status: 'completed', + currentStageId: stage.id, + stagePhase: 'debrief', + studentEditingEnabled: true + }); + } else { + return send(res, 400, { error: 'Invalid exercise action.' }); + } + + if (!result) return send(res, 404, { error: 'Exercise not found.' }); + if (result.status === 'conflict') return conflict(res, result); + if (!definitionMatches(result.exercise, caseStudy)) { + return conflict(res, result, 'Exercise definition is unavailable or has changed.'); + } + + return send(res, 200, { + ...(await instructorExercisePayload( + repository, + instructorHash, + result.classroom, + result.exercise, + caseStudy + )), + changed: result.status === 'updated', + capturedCount: Number.isInteger(result.capturedCount) ? result.capturedCount : undefined + }); + } catch { + return send(res, 500, { error: 'Unable to access staged exercise.' }); + } + }; +} + +/** + * Student current staged-release handler. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classStudentExerciseHandler({ + getRepository = getClassroomRepository, + manifest = PROTECTED_CASE_STUDY_MANIFEST +} = {}) { + return async (req, res) => { + if (req.method !== 'GET') return methodNotAllowed(res, 'GET'); + const authorization = requireBearer(req); + if (!authorization.ok) return send(res, authorization.status, { error: authorization.error }); + + try { + const repository = await getRepository(); + const context = await repository.getExerciseForStudentSession( + hashWorkspaceToken(authorization.token) + ); + if (!context) return send(res, 404, { error: 'Student class session not found.' }); + if (!context.exercise) return send(res, 200, studentExercisePayload(context, null)); + + const caseStudy = stagedCaseStudy(manifest, context.exercise.caseStudyId); + if (!definitionMatches(context.exercise, caseStudy)) { + return send(res, 409, { error: 'Exercise definition is unavailable or has changed.' }); + } + return send(res, 200, studentExercisePayload(context, caseStudy)); + } catch { + return send(res, 500, { error: 'Unable to load staged exercise.' }); + } + }; +} + +/** + * Student current-workspace readiness handler. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {Function} Vercel handler. + */ +export function classStudentExerciseReadyHandler({ + getRepository = getClassroomRepository, + getWorkspaceRepo = getWorkspaceRepository, + manifest = PROTECTED_CASE_STUDY_MANIFEST +} = {}) { + return async (req, res) => { + if (req.method !== 'PUT') return methodNotAllowed(res, 'PUT'); + const authorization = requireBearer(req); + if (!authorization.ok) return send(res, authorization.status, { error: authorization.error }); + if (typeof req.body?.ready !== 'boolean') { + return send(res, 400, { error: 'Invalid readiness state.' }); + } + + const sessionHash = hashWorkspaceToken(authorization.token); + try { + const repository = await getRepository(); + const context = await repository.getExerciseForStudentSession(sessionHash); + if (!context) return send(res, 404, { error: 'Student class session not found.' }); + if (!context.exercise || !context.exercise.currentStageId) { + return send(res, 409, { error: 'No active exercise stage.' }); + } + const caseStudy = stagedCaseStudy(manifest, context.exercise.caseStudyId); + if (!definitionMatches(context.exercise, caseStudy)) { + return send(res, 409, { error: 'Exercise definition is unavailable or has changed.' }); + } + if (context.exercise.status !== 'active' || context.exercise.stagePhase !== 'work') { + return send(res, 409, { error: 'Readiness is not available in the current exercise phase.' }); + } + if (!context.internal.workspaceId || !context.assignment) { + return send(res, 409, { status: 'waiting', error: 'Assign a workspace before marking Ready.' }); + } + + let workspaceRevision = null; + if (req.body.ready) { + const workspaceRepository = await getWorkspaceRepo(); + const observation = await workspaceRepository.observeById(context.internal.workspaceId); + if (!observation) return send(res, 404, { error: 'Assigned workspace not found.' }); + workspaceRevision = observation.revision; + } + + const result = await repository.setExerciseWorkspaceReadinessBySession(sessionHash, { + exercisePublicId: context.exercise.id, + stageId: context.exercise.currentStageId, + ready: req.body.ready, + workspaceRevision, + expectedWorkspaceInternalId: context.internal.workspaceId + }); + if (!result) return send(res, 404, { error: 'Exercise readiness not found.' }); + if (result.status === 'waiting') { + return send(res, 409, { status: 'waiting', error: 'Assign a workspace before marking Ready.' }); + } + if (result.status === 'conflict') { + return send(res, 409, { error: 'Student assignment changed. Refresh and retry.' }); + } + return send(res, 200, { + class: result.classroom, + participant: result.participant, + assignment: result.workspace, + readiness: result.readiness, + changed: result.status === 'updated' + }); + } catch { + return send(res, 500, { error: 'Unable to update exercise readiness.' }); + } + }; +} From 0aead4672a744cc11da990a089d75c909c4dee5d Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:36:52 -0400 Subject: [PATCH 034/206] feat: expose Instructor staged exercise route --- api/classes/exercise.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 api/classes/exercise.js diff --git a/api/classes/exercise.js b/api/classes/exercise.js new file mode 100644 index 0000000..c8d0e1b --- /dev/null +++ b/api/classes/exercise.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/exercise + * @description Instructor-authorized staged Classroom exercise lifecycle. + */ +import { classExerciseHandler } from '../_classroomExercise.js'; + +export default classExerciseHandler(); From 14fcd952df986325faa865a698278fb83cf13356 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:36:56 -0400 Subject: [PATCH 035/206] feat: expose Student staged exercise route --- api/classes/exercise/student.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 api/classes/exercise/student.js diff --git a/api/classes/exercise/student.js b/api/classes/exercise/student.js new file mode 100644 index 0000000..d62e75f --- /dev/null +++ b/api/classes/exercise/student.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/exercise/student + * @description Student class-session current staged release endpoint. + */ +import { classStudentExerciseHandler } from '../../_classroomExercise.js'; + +export default classStudentExerciseHandler(); From a39721c63d0de6510f8f92da69f5805c282479d7 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:37:00 -0400 Subject: [PATCH 036/206] feat: expose Student exercise readiness route --- api/classes/exercise/student/ready.js | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 api/classes/exercise/student/ready.js diff --git a/api/classes/exercise/student/ready.js b/api/classes/exercise/student/ready.js new file mode 100644 index 0000000..4b772a1 --- /dev/null +++ b/api/classes/exercise/student/ready.js @@ -0,0 +1,7 @@ +/** + * @module api/classes/exercise/student/ready + * @description Student class-session current-workspace Ready endpoint. + */ +import { classStudentExerciseReadyHandler } from '../../../_classroomExercise.js'; + +export default classStudentExerciseReadyHandler(); From e4531d8ee81cb472218b29676f237cdda8eb2e4c Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:37:18 -0400 Subject: [PATCH 037/206] fix: block staged Student full-case bypass --- api/_protectedCaseStudies.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/api/_protectedCaseStudies.js b/api/_protectedCaseStudies.js index 5093529..7b694f7 100644 --- a/api/_protectedCaseStudies.js +++ b/api/_protectedCaseStudies.js @@ -46,7 +46,8 @@ function createProtectedCaseStudyHandler({ getRepository, manifest, authorize, - unauthorizedMessage + unauthorizedMessage, + blockPayload = null }) { return async (req, res) => { if (!['GET', 'POST'].includes(req.method)) return methodNotAllowed(res); @@ -68,6 +69,12 @@ function createProtectedCaseStudyHandler({ const caseStudy = requestedCaseStudy(manifest, req.body); if (!caseStudy) return send(res, 404, { error: 'Case Study not found.' }); + if (blockPayload && await blockPayload(repository, context, caseStudy)) { + return send(res, 409, { + error: 'Case Study content is available only through the staged exercise.' + }); + } + return send(res, 200, { class: context.classroom, workspace: context.workspace || null, @@ -118,6 +125,10 @@ export function studentCaseStudiesHandler({ getRepository, manifest, authorize: (repository, tokenHash) => repository.getStudentContext(tokenHash), - unauthorizedMessage: 'Class resources not found.' + unauthorizedMessage: 'Class resources not found.', + blockPayload: async (repository, context, caseStudy) => { + if (!context.internal?.classId || !repository.hasExerciseForClassCase) return false; + return repository.hasExerciseForClassCase(context.internal.classId, caseStudy.id); + } }); } From 5afc0041db89348d97ca0986cab89491765d0e00 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:39:09 -0400 Subject: [PATCH 038/206] test: cover staged exercise API authorization --- tests/classroom-exercise-api.unit.test.mjs | 610 +++++++++++++++++++++ 1 file changed, 610 insertions(+) create mode 100644 tests/classroom-exercise-api.unit.test.mjs diff --git a/tests/classroom-exercise-api.unit.test.mjs b/tests/classroom-exercise-api.unit.test.mjs new file mode 100644 index 0000000..d654aff --- /dev/null +++ b/tests/classroom-exercise-api.unit.test.mjs @@ -0,0 +1,610 @@ +/** + * Focused #313 exercise API / progressive-disclosure authorization tests. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + classExerciseHandler, + classStudentExerciseHandler, + classStudentExerciseReadyHandler, + fingerprintStagedSimulation +} from '../api/_classroomExercise.js'; +import { studentCaseStudiesHandler } from '../api/_protectedCaseStudies.js'; +import { + createClassroomRepository, + createWorkspaceRepository, + response, + testTokenHash +} from './helpers/classroom-test-repositories.mjs'; + +const CLASS_A_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const WORKSPACE_A_ID = 'cccccccc-cccc-4ccc-8ccc-cccccccccccc'; +const WORKSPACE_B_ID = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd'; +const EXERCISE_ID = '11111111-1111-4111-8111-111111111111'; +const PARTICIPANT_A = '22222222-2222-4222-8222-222222222222'; +const PARTICIPANT_B = '33333333-3333-4333-8333-333333333333'; + +const STAGED_CASE = Object.freeze({ + id: 'synthetic-staged-case', + name: 'Synthetic Staged Case', + description: 'Deterministic staged Case Study for server authorization tests.', + templateKind: 'case-study', + supportedModes: ['full'], + state: { pre: { oneLine: 'COMPLETE SOURCE / EXEMPLAR — NEVER STAGED TO STUDENT' } }, + simulation: { + version: 1, + studentContent: [ + { id: 'brief-1', kind: 'narrative', title: 'Initial briefing', body: 'Student Stage 1 briefing.' }, + { id: 'hint-1', kind: 'evidence', title: 'Optional hint', body: 'Optional Student evidence.' }, + { id: 'evidence-1', kind: 'evidence', title: 'Second evidence', body: 'Second optional Student fact.' }, + { id: 'brief-2', kind: 'narrative', title: 'Stage 2 briefing', body: 'Student Stage 2 briefing.' } + ], + instructorContent: [ + { id: 'teach-1', kind: 'facilitation', title: 'Stage 1 teaching', body: 'INSTRUCTOR ONLY STAGE 1.' }, + { id: 'answer-2', kind: 'exemplar', title: 'Stage 2 exemplar', body: 'INSTRUCTOR ONLY MODEL ANSWER.' } + ], + stages: [ + { + id: 'stage-1', + title: 'Clarify the situation', + studentObjective: 'Understand the initial situation.', + initialReleaseIds: ['brief-1'], + optionalReleaseIds: ['hint-1', 'evidence-1'], + intakeTargetIds: ['problem.one-line'], + suggestedMinutes: 10, + instructorContentIds: ['teach-1'], + defaultDebriefEditPolicy: 'frozen' + }, + { + id: 'stage-2', + title: 'Analyze new evidence', + studentObjective: 'Integrate the newly released evidence.', + initialReleaseIds: ['brief-2'], + optionalReleaseIds: [], + intakeTargetIds: ['problem.one-line'], + suggestedMinutes: 8, + instructorContentIds: ['answer-2'], + defaultDebriefEditPolicy: 'open' + } + ] + } +}); + +const NON_STAGED_CASE = Object.freeze({ + id: 'ordinary-case', + name: 'Ordinary Case', + description: 'Protected but not staged.', + templateKind: 'case-study', + supportedModes: ['full'], + state: { pre: { oneLine: 'Ordinary full protected payload' } } +}); + +const MANIFEST = Object.freeze([STAGED_CASE, NON_STAGED_CASE]); + +async function setupClass({ withSecondWorkspace = false } = {}) { + const classrooms = createClassroomRepository(); + const workspaceRepo = createWorkspaceRepository(); + + await classrooms.createClass({ + publicId: CLASS_A_ID, + title: 'Class A', + instructorHash: testTokenHash('A'), + studentJoinHash: testTokenHash('C'), + studentJoinCode: 'K7FMP4Q2' + }); + + const workspaceA = await workspaceRepo.create( + testTokenHash('P'), + { pre: { oneLine: 'Team A before debrief' } }, + 30, + 'Team A' + ); + await classrooms.addWorkspace(testTokenHash('A'), { + publicId: WORKSPACE_A_ID, + workspaceId: workspaceA.id, + kind: 'group', + label: 'Team A', + claimHash: testTokenHash('X') + }); + + let workspaceB = null; + if (withSecondWorkspace) { + workspaceB = await workspaceRepo.create( + testTokenHash('Q'), + { pre: { oneLine: 'Team B before debrief' } }, + 30, + 'Team B' + ); + await classrooms.addWorkspace(testTokenHash('A'), { + publicId: WORKSPACE_B_ID, + workspaceId: workspaceB.id, + kind: 'group', + label: 'Team B', + claimHash: testTokenHash('Y') + }); + } + + return { classrooms, workspaceRepo, workspaceA, workspaceB }; +} + +async function admit(classrooms, { + participantId = PARTICIPANT_A, + name = 'Alex', + session = 'S' +} = {}) { + return classrooms.admitParticipant({ + joinCode: 'K7FMP4Q2', + participantId, + displayName: name, + sessionHash: testTokenHash(session) + }); +} + +function instructorHandler(classrooms, workspaceRepo) { + return classExerciseHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + manifest: MANIFEST, + idFactory: () => EXERCISE_ID + }); +} + +async function createExercise(handler) { + const created = response(); + await handler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { caseStudyId: STAGED_CASE.id } + }, created); + assert.equal(created.statusCode, 201); + return created; +} + +async function patchExercise(handler, body) { + const res = response(); + await handler({ + method: 'PATCH', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body + }, res); + return res; +} + +test('staged definition fingerprint is canonical across object key order', () => { + const reordered = { + stages: STAGED_CASE.simulation.stages, + instructorContent: STAGED_CASE.simulation.instructorContent, + studentContent: STAGED_CASE.simulation.studentContent, + version: 1 + }; + assert.equal( + fingerprintStagedSimulation(STAGED_CASE.simulation), + fingerprintStagedSimulation(reordered) + ); + assert.match(fingerprintStagedSimulation(STAGED_CASE.simulation), /^[a-f0-9]{64}$/u); +}); + +test('Instructor exercise creation is class-authorized, staged-only, idempotent, and definition-pinned', async () => { + const { classrooms, workspaceRepo } = await setupClass(); + await admit(classrooms); + const handler = instructorHandler(classrooms, workspaceRepo); + + const nonStaged = response(); + await handler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { caseStudyId: NON_STAGED_CASE.id } + }, nonStaged); + assert.equal(nonStaged.statusCode, 404); + + const created = await createExercise(handler); + assert.equal(created.body.created, true); + assert.equal(created.body.exercise.exerciseRevision, 1); + assert.equal(created.body.exercise.simulationVersion, 1); + assert.equal( + created.body.exercise.simulationFingerprint, + fingerprintStagedSimulation(STAGED_CASE.simulation) + ); + assert.equal(created.body.caseStudy.simulation.instructorContent[0].body, 'INSTRUCTOR ONLY STAGE 1.'); + + const replay = response(); + await handler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) }, + body: { caseStudyId: STAGED_CASE.id } + }, replay); + assert.equal(replay.statusCode, 200); + assert.equal(replay.body.created, false); + assert.equal(classrooms.exercises.length, 1); + + const studentCredential = response(); + await handler({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, studentCredential); + assert.equal(studentCredential.statusCode, 404); + + const humanJoinCode = response(); + await handler({ + method: 'GET', + headers: { authorization: 'Bearer K7FM-P4Q2' } + }, humanJoinCode); + assert.equal(humanJoinCode.statusCode, 400); +}); + +test('Instructor lifecycle is revision-safe, optional release is idempotent, and debrief checkpoint transition is atomic', async () => { + const { classrooms, workspaceRepo } = await setupClass({ withSecondWorkspace: true }); + const handler = instructorHandler(classrooms, workspaceRepo); + await createExercise(handler); + + const started = await patchExercise(handler, { action: 'start', expectedRevision: 1 }); + assert.equal(started.statusCode, 200); + assert.equal(started.body.exercise.currentStageId, 'stage-1'); + assert.equal(started.body.exercise.exerciseRevision, 2); + + const released = await patchExercise(handler, { + action: 'release-content', + expectedRevision: 2, + contentId: 'hint-1' + }); + assert.equal(released.statusCode, 200); + assert.equal(released.body.changed, true); + assert.equal(released.body.exercise.exerciseRevision, 3); + assert.deepEqual(released.body.releases.map(item => item.contentId), ['hint-1']); + + const releaseReplay = await patchExercise(handler, { + action: 'release-content', + expectedRevision: 2, + contentId: 'hint-1' + }); + assert.equal(releaseReplay.statusCode, 200); + assert.equal(releaseReplay.body.changed, false); + assert.equal(releaseReplay.body.exercise.exerciseRevision, 3); + + const staleDifferentRelease = await patchExercise(handler, { + action: 'release-content', + expectedRevision: 2, + contentId: 'evidence-1' + }); + assert.equal(staleDifferentRelease.statusCode, 409); + assert.equal(staleDifferentRelease.body.exercise.exerciseRevision, 3); + + const debrief = await patchExercise(handler, { + action: 'begin-debrief', + expectedRevision: 3 + }); + assert.equal(debrief.statusCode, 200); + assert.equal(debrief.body.exercise.stagePhase, 'debrief'); + assert.equal(debrief.body.exercise.exerciseRevision, 4); + assert.equal(debrief.body.capturedCount, 2); + assert.equal(debrief.body.checkpoints.length, 2); + assert.equal(debrief.body.editFreezeEnforced, false); + assert.equal(debrief.body.exercise.studentEditingEnabled, true); + + await workspaceRepo.update( + testTokenHash('P'), + { pre: { oneLine: 'Team A changed after checkpoint' } }, + 1 + ); + const checkpointA = debrief.body.checkpoints.find(item => item.workspaceId === WORKSPACE_A_ID); + assert.equal(checkpointA.snapshot.pre.oneLine, 'Team A before debrief'); + + const staleDebriefReplay = await patchExercise(handler, { + action: 'begin-debrief', + expectedRevision: 3 + }); + assert.equal(staleDebriefReplay.statusCode, 409); + + const advanced = await patchExercise(handler, { action: 'advance', expectedRevision: 4 }); + assert.equal(advanced.statusCode, 200); + assert.equal(advanced.body.exercise.currentStageId, 'stage-2'); + assert.equal(advanced.body.exercise.stagePhase, 'work'); + assert.equal(advanced.body.exercise.exerciseRevision, 5); + + const cannotCompleteWork = await patchExercise(handler, { action: 'complete', expectedRevision: 5 }); + assert.equal(cannotCompleteWork.statusCode, 409); + + const stage2Debrief = await patchExercise(handler, { + action: 'begin-debrief', + expectedRevision: 5 + }); + assert.equal(stage2Debrief.statusCode, 200); + assert.equal(stage2Debrief.body.exercise.exerciseRevision, 6); + + const completed = await patchExercise(handler, { action: 'complete', expectedRevision: 6 }); + assert.equal(completed.statusCode, 200); + assert.equal(completed.body.exercise.status, 'completed'); + assert.equal(completed.body.exercise.exerciseRevision, 7); + + const current = response(); + await handler({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) } + }, current); + assert.equal(current.statusCode, 200); + assert.equal(current.body.exercise, null); +}); + +test('Student staged read exposes only cumulative released Student content and never future or Instructor material', async () => { + const { classrooms, workspaceRepo } = await setupClass(); + await admit(classrooms); + const instructor = instructorHandler(classrooms, workspaceRepo); + await createExercise(instructor); + + const student = classStudentExerciseHandler({ + getRepository: async () => classrooms, + manifest: MANIFEST + }); + + const draft = response(); + await student({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, draft); + assert.equal(draft.statusCode, 200); + assert.equal(draft.body.assignment, null); + assert.equal(draft.body.exercise.currentStage, null); + assert.deepEqual(draft.body.exercise.releasedContent, []); + + await patchExercise(instructor, { action: 'start', expectedRevision: 1 }); + + const stage1 = response(); + await student({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, stage1); + assert.equal(stage1.statusCode, 200); + assert.equal(stage1.body.exercise.currentStage.id, 'stage-1'); + assert.deepEqual( + stage1.body.exercise.releasedContent.map(item => item.content.id), + ['brief-1'] + ); + const stage1Json = JSON.stringify(stage1.body); + assert.equal(stage1Json.includes('Stage 2 briefing'), false); + assert.equal(stage1Json.includes('INSTRUCTOR ONLY'), false); + assert.equal(stage1Json.includes('MODEL ANSWER'), false); + assert.equal(stage1Json.includes('COMPLETE SOURCE'), false); + + await patchExercise(instructor, { + action: 'release-content', + expectedRevision: 2, + contentId: 'hint-1' + }); + const withHint = response(); + await student({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, withHint); + assert.deepEqual( + withHint.body.exercise.releasedContent.map(item => item.content.id), + ['brief-1', 'hint-1'] + ); + + await patchExercise(instructor, { action: 'begin-debrief', expectedRevision: 3 }); + await patchExercise(instructor, { action: 'advance', expectedRevision: 4 }); + + const stage2 = response(); + await student({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, stage2); + assert.equal(stage2.body.exercise.currentStage.id, 'stage-2'); + assert.deepEqual( + stage2.body.exercise.releasedContent.map(item => item.content.id), + ['brief-1', 'hint-1', 'brief-2'] + ); + assert.equal(stage2.body.exercise.editFreezeEnforced, false); + + const instructorCredential = response(); + await student({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) } + }, instructorCredential); + assert.equal(instructorCredential.statusCode, 404); +}); + +test('Student Ready records the server-observed current workspace revision and Waiting cannot mark Ready', async () => { + const { classrooms, workspaceRepo, workspaceA } = await setupClass(); + await admit(classrooms); + await admit(classrooms, { + participantId: PARTICIPANT_B, + name: 'Blair', + session: 'T' + }); + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: WORKSPACE_A_ID, + workspaceRepository: workspaceRepo + }); + + const instructor = instructorHandler(classrooms, workspaceRepo); + await createExercise(instructor); + await patchExercise(instructor, { action: 'start', expectedRevision: 1 }); + + const readyHandler = classStudentExerciseReadyHandler({ + getRepository: async () => classrooms, + getWorkspaceRepo: async () => workspaceRepo, + manifest: MANIFEST + }); + + const ready = response(); + await readyHandler({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) }, + body: { ready: true, workspaceRevision: 999999 } + }, ready); + assert.equal(ready.statusCode, 200); + assert.equal(ready.body.readiness.readyWorkspaceRevision, 1); + assert.equal(ready.body.assignment.id, WORKSPACE_A_ID); + + await workspaceRepo.update( + testTokenHash('P'), + { pre: { oneLine: 'New Team A work' } }, + workspaceA.revision + ); + + const refreshed = response(); + await readyHandler({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) }, + body: { ready: true } + }, refreshed); + assert.equal(refreshed.statusCode, 200); + assert.equal(refreshed.body.readiness.readyWorkspaceRevision, 2); + + const waiting = response(); + await readyHandler({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'T'.repeat(43) }, + body: { ready: true } + }, waiting); + assert.equal(waiting.statusCode, 409); + assert.equal(waiting.body.status, 'waiting'); +}); + +test('readiness race guard refuses to attach an old workspace revision after reassignment', async () => { + const { classrooms, workspaceRepo, workspaceA } = await setupClass({ withSecondWorkspace: true }); + await admit(classrooms); + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: WORKSPACE_A_ID, + workspaceRepository: workspaceRepo + }); + await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_ID, + caseStudyId: STAGED_CASE.id, + simulationVersion: 1, + simulationFingerprint: fingerprintStagedSimulation(STAGED_CASE.simulation) + }); + await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_ID, + expectedRevision: 1, + status: 'active', + currentStageId: 'stage-1', + stagePhase: 'work', + studentEditingEnabled: true + }); + + const beforeMove = await classrooms.getParticipantBySession(testTokenHash('S')); + const observedA = await workspaceRepo.observeById(beforeMove.internal.workspaceId); + assert.equal(observedA.revision, workspaceA.revision); + + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: WORKSPACE_B_ID, + workspaceRepository: workspaceRepo + }); + + const stale = await classrooms.setExerciseWorkspaceReadinessBySession(testTokenHash('S'), { + exercisePublicId: EXERCISE_ID, + stageId: 'stage-1', + ready: true, + workspaceRevision: observedA.revision, + expectedWorkspaceInternalId: beforeMove.internal.workspaceId + }); + assert.equal(stale.status, 'conflict'); + + const state = await classrooms.listExerciseWorkspaceStateForInstructor( + testTokenHash('A'), + EXERCISE_ID + ); + assert.deepEqual(state.workspaceState, []); +}); + +test('definition drift fails closed for Instructor and Student staged reads', async () => { + const { classrooms, workspaceRepo } = await setupClass(); + await admit(classrooms); + const instructor = instructorHandler(classrooms, workspaceRepo); + await createExercise(instructor); + classrooms.exercises[0].simulationFingerprint = 'b'.repeat(64); + + const instructorRead = response(); + await instructor({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) } + }, instructorRead); + assert.equal(instructorRead.statusCode, 409); + + const student = classStudentExerciseHandler({ + getRepository: async () => classrooms, + manifest: MANIFEST + }); + const studentRead = response(); + await student({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'S'.repeat(43) } + }, studentRead); + assert.equal(studentRead.statusCode, 409); +}); + +test('Student full Case Study payload cannot bypass a staged run, including after completion', async () => { + const { classrooms, workspaceRepo } = await setupClass(); + await admit(classrooms); + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: WORKSPACE_A_ID, + workspaceRepository: workspaceRepo + }); + await classrooms.issueParticipantWorkspaceAccess({ + sessionHash: testTokenHash('S'), + accessHash: testTokenHash('W'), + workspaceRepository: workspaceRepo + }); + + const protectedHandler = studentCaseStudiesHandler({ + getRepository: async () => classrooms, + manifest: MANIFEST + }); + + const beforeExercise = response(); + await protectedHandler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { caseStudyId: STAGED_CASE.id } + }, beforeExercise); + assert.equal(beforeExercise.statusCode, 200); + assert.equal(beforeExercise.body.caseStudy.state.pre.oneLine.includes('COMPLETE SOURCE'), true); + + await classrooms.createExercise(testTokenHash('A'), { + publicId: EXERCISE_ID, + caseStudyId: STAGED_CASE.id, + simulationVersion: 1, + simulationFingerprint: fingerprintStagedSimulation(STAGED_CASE.simulation) + }); + + const duringExercise = response(); + await protectedHandler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { caseStudyId: STAGED_CASE.id } + }, duringExercise); + assert.equal(duringExercise.statusCode, 409); + assert.equal(JSON.stringify(duringExercise.body).includes('COMPLETE SOURCE'), false); + + const unrelated = response(); + await protectedHandler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { caseStudyId: NON_STAGED_CASE.id } + }, unrelated); + assert.equal(unrelated.statusCode, 200); + + await classrooms.updateExerciseLifecycle(testTokenHash('A'), { + exercisePublicId: EXERCISE_ID, + expectedRevision: 1, + status: 'completed', + currentStageId: null, + stagePhase: 'work', + studentEditingEnabled: true + }); + + const afterCompletion = response(); + await protectedHandler({ + method: 'POST', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { caseStudyId: STAGED_CASE.id } + }, afterCompletion); + assert.equal(afterCompletion.statusCode, 409); +}); From cb52bbf63780046acc63f1bbe8354bdd204dfd71 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:57:31 -0400 Subject: [PATCH 039/206] docs: checkpoint staged exercise APIs --- docs/classroom-staged-simulation.md | 61 ++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/docs/classroom-staged-simulation.md b/docs/classroom-staged-simulation.md index 6193c5b..771e862 100644 --- a/docs/classroom-staged-simulation.md +++ b/docs/classroom-staged-simulation.md @@ -672,20 +672,51 @@ The first server changes should be additive: Do not make Standalone or ordinary Classroom assignment boot depend on an active exercise. +## Implementation checkpoint — exercise APIs / authorization + +Tranche 4 is implemented on top of the additive exercise repository contract. + +Routes: +- Instructor `POST /api/classes/exercise` creates/reuses the represented class's draft only for an explicitly staged protected Case Study and pins simulation version + canonical SHA-256 definition fingerprint; +- Instructor `GET /api/classes/exercise` returns the represented class's current exercise, releases, workspace readiness, checkpoints, and the complete Instructor-authorized staged definition; +- Instructor `PATCH /api/classes/exercise` supports `start`, `pause`, `resume`, `release-content`, `begin-debrief`, `advance`, and `complete`, all revision-safe and stage-order aware; +- Student `GET /api/classes/exercise/student` authenticates with the stable Student class-session capability and returns only the represented learner's public class/assignment context plus cumulative Student-safe content released through the current stage; +- Student `PUT /api/classes/exercise/student/ready` resolves the participant's current workspace server-side, records the server-observed collaboration revision when Ready is set, rejects Waiting, and fails closed if reassignment races the readiness write. + +Progressive-disclosure / security semantics: +- every staged read or lifecycle mutation revalidates the pinned simulation version/fingerprint against the current protected definition and returns conflict on definition drift; +- Student payload construction never returns future-stage metadata, Instructor content IDs/content, exemplar/model material, or the complete protected Case Study `state`; +- optional content is released only for the current work phase and replay of the same release remains idempotent; +- entering debrief captures immutable current-workspace snapshots/revisions before later live edits; +- Student full protected Case Study retrieval is blocked for any class/case that has a staged exercise record, including after completion; completion is not an implicit exemplar reveal; +- unrelated protected non-staged resources retain their existing behavior; +- Instructor and Student credentials are not interchangeable and the human join code has no exercise authority; +- all staged responses remain `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. + +Important deliberate boundary: +- Tranche 4 reports `editFreezeEnforced: false` and Student-safe payloads continue to represent editing as effectively enabled; +- `begin-debrief` persists debrief phase/checkpoints, but **server-enforced collaboration write freeze is not claimed yet**; +- the actual `classroom-student` collaboration PUT guard belongs to Tranche 5. + +Implementation/security test checkpoint: `5afc0041db89348d97ca0986cab89491765d0e00`. + +Validation: +- repository quality: **273 tests / 272 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; +- CI, CodeQL, Dependency Review, and Template Manifest Guard: green. + ## Exact next implementation action -Implement **Tranche 4 only — exercise APIs and authorization** on top of the now-proven repository contract. - -Required server/API slice: -- Instructor `POST /api/classes/exercise` selects/creates a draft only from a protected Case Study with a valid explicit `simulation` definition, computes/pins the normalized definition fingerprint, and refuses a definition mismatch; -- Instructor `GET /api/classes/exercise` returns only the represented class's current exercise plus the complete Instructor-authorized staged definition; -- Instructor lifecycle PATCH actions for start, pause, resume, optional-content release, begin-debrief, advance, and complete use `expectedRevision` and the immutable stage ordering from the pinned definition; -- **do not expose a claimed edit-freeze guarantee yet**: temporary collaboration write-lock enforcement remains Tranche 5; begin-debrief may persist phase/checkpoints while editing remains effectively open until that guard lands; -- begin-debrief captures each current class workspace's collaboration snapshot/revision exactly once through the immutable checkpoint primitive; -- Student `GET /api/classes/exercise/student` uses the Student **class-session** capability and returns only current/cumulative released Student-safe content, never future stage metadata or Instructor content; -- Student Ready route resolves the participant's current assignment server-side and records the server-observed workspace revision; Waiting cannot mark Ready; -- close the active-staged-case bypass in Student protected Case Study full-payload retrieval so the complete `state` cannot be fetched while that case is the current non-completed staged exercise; -- add a complete authorization/error matrix: wrong class, human join code, Instructor-vs-Student credential confusion, stale revision, non-staged case, definition drift, Waiting readiness, cross-class workspace/checkpoint attempts, and future/Instructor-content withholding; -- keep all staged responses `no-store` / `no-referrer`. - -Stop before Student collaboration PUT freeze/423 behavior and before Instructor/Student exercise UI. +Implement **Tranche 5 only — server-enforced Student editing policy**. + +Required slice: +- derive whether the represented `classroom-student` workspace belongs to a class whose current staged exercise forbids editing; +- enforce that policy in the collaboration **write** path server-side before any snapshot revision mutation; +- choose and document one stable temporary-lock HTTP response contract (prefer a distinct status such as 423 if it integrates cleanly with the current collaboration client); +- do not block Instructor observation, presence heartbeats, coaching, Student stage reads/readiness, or ordinary GET collaboration reads; +- do not affect Standalone collaboration or Classroom classes with no staged exercise; +- make pause/debrief/edit-policy semantics explicit: no UI-only security; +- add direct API tests proving a frozen Student PUT cannot mutate snapshot/revision, an allowed Student PUT still works, and Standalone/legacy collaboration remains unchanged; +- mirror the policy in the deterministic repository/browser fixture only as needed for later UI/browser slices. + +Stop before Instructor exercise-console UI or Student staged-case panel. From 91406722c87f3a3e337df4d61bfb415a87f02bfc Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:57:54 -0400 Subject: [PATCH 040/206] docs: document staged exercise API contract --- docs/classroom-api.md | 70 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/docs/classroom-api.md b/docs/classroom-api.md index a44eede..083b852 100644 --- a/docs/classroom-api.md +++ b/docs/classroom-api.md @@ -229,6 +229,76 @@ The build produces two generated resources: - `api/protected-case-studies.manifest.js` — server-only Case Study metadata and payloads. Authored `templates/*.json` files are excluded from Vercel uploads. Production functions consume the committed server-only manifest, while GitHub CI continues to validate all authored JSON and generated-file freshness. +## Staged exercise orchestration (#313) + +#313 adds class-level exercise orchestration without changing collaboration authority. + +### `POST /api/classes/exercise` + +Requires the Instructor class capability. + +Body: +- `caseStudyId` for a protected Case Study carrying an explicit server-only `simulation` definition. + +Creates the represented class's draft exercise when no non-completed exercise exists. The exercise pins the normalized simulation version plus canonical SHA-256 definition fingerprint. Repeating the same selection returns the existing exercise; selecting a different case while another exercise is open conflicts. + +### `GET /api/classes/exercise` + +Requires the Instructor class capability. + +Returns only the represented class's current non-completed exercise plus: +- complete Instructor-authorized staged definition; +- optional-content releases; +- workspace readiness/progress state; +- current-stage immutable debrief checkpoints. + +Every read fails closed if the pinned simulation version/fingerprint no longer matches the current protected definition. + +### `PATCH /api/classes/exercise` + +Requires the Instructor class capability and positive `expectedRevision`. + +Supported actions: +- `start`; +- `pause`; +- `resume`; +- `release-content` with current-stage optional `contentId`; +- `begin-debrief`; +- `advance`; +- `complete`. + +Lifecycle changes use optimistic `exercise_revision`. Optional-content replay is idempotent; a stale request for a different release conflicts. `begin-debrief` captures immutable class/workspace-scoped snapshot/revision checkpoints before later live changes. Stage ordering comes from the pinned staged definition, never client-provided next-stage IDs. + +**Tranche 4 does not yet claim an edit freeze.** Responses expose `editFreezeEnforced: false`; server enforcement on Student collaboration PUT is the next dedicated tranche. + +### `GET /api/classes/exercise/student` + +Requires the stable Student **class-session** capability, not a workspace edit capability or human join code. + +Returns: +- represented public class/participant/current assignment context; +- public exercise identity/status/phase/revision; +- current Student-visible stage title/objective; +- cumulative released Student content through the current stage; +- represented workspace readiness when assigned. + +It never returns future-stage metadata, Instructor content/IDs, complete Case Study `state`, another workspace's readiness, or workspace enumeration. Waiting Students can read the current class release safely. + +### `PUT /api/classes/exercise/student/ready` + +Requires the Student class-session capability. + +Body: +- `ready: boolean`. + +When setting Ready, the server resolves the participant's current assigned workspace and records the collaboration revision observed server-side. Waiting is rejected. A reassignment race conflicts rather than attaching readiness to the old workspace. + +### Protected full-payload interaction + +When a class has a staged exercise record for Case Study X, Student `POST /api/classes/case-studies/student` must not return X's complete protected payload. The Student must use the staged exercise endpoint instead. This block remains after exercise completion; completing a simulation is not an implicit exemplar/model-answer release. Instructor full protected access remains class-scoped and unchanged, and unrelated protected resources keep their existing behavior. + +All exercise responses are private/no-store/no-referrer. Exercise state, releases, readiness, and checkpoints remain outside Intake serialization and collaboration revision state. + ## Individual and group semantics ### Individual From 2282925ce7f5f52609f85bb0a8610856ecf87653 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:58:10 -0400 Subject: [PATCH 041/206] docs: advance staged simulation to edit-freeze tranche --- docs/classroom-workstream.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md index 5092c4e..ed8df1a 100644 --- a/docs/classroom-workstream.md +++ b/docs/classroom-workstream.md @@ -429,7 +429,17 @@ Continue #295 on draft PR #307 from the current branch head. - `09ffaa6c...` through `c01dfff2f2bd1f9d7625901860210cc00eee7051` additionally pin simulation version + definition fingerprint on each exercise and keep those columns migration-safe so later APIs can fail closed on definition drift. - pre-hardening validation on `f7c1e58...`: repository quality **265 tests / 264 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. - no exercise HTTP route, Student collaboration write lock, or exercise UI exists yet. -- Exact next action: implement **Tranche 4 only — exercise APIs/authorization**: Instructor create/read/lifecycle/release/debrief/checkpoint/advance/complete, Student class-session current release + workspace readiness, pinned-definition drift checks, and active-staged-case full-payload bypass closure. Build the complete credential/class/future-content authorization matrix. Do **not** claim or expose server-enforced editing freeze until Tranche 5, and do not add Instructor/Student exercise UI yet. +- Staged exercise API / authorization checkpoint: + - Instructor `POST/GET/PATCH /api/classes/exercise` now owns class-scoped draft creation/read plus start/pause/resume/optional release/begin-debrief/advance/complete with optimistic exercise revision and immutable stage ordering. + - Student `GET /api/classes/exercise/student` uses the stable class-session capability and returns only current/cumulative Student-safe released content; future stage and Instructor-only material remain server-withheld. + - Student `PUT /api/classes/exercise/student/ready` resolves the current assignment server-side, records server-observed workspace revision, rejects Waiting, and fails closed on reassignment races. + - every read/mutation validates the pinned simulation version/fingerprint against the current protected definition. + - active/completed staged cases block Student full protected Case Study payload retrieval for that case; completion does not imply exemplar release. + - Instructor full protected access and unrelated protected resources retain their existing behavior. + - Tranche 4 deliberately returns `editFreezeEnforced: false`; no security claim is made until collaboration PUT enforcement lands. + - implementation/security-test checkpoint: `5afc0041db89348d97ca0986cab89491765d0e00`. + - validation: repository quality **273 tests / 272 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. +- Exact next action: implement **Tranche 5 only — server-enforced Student editing policy**. Enforce the staged exercise editing policy in the `classroom-student` collaboration write path before revision mutation, keep GET/presence/coaching/readiness unaffected, preserve Standalone and no-exercise Classroom behavior, define a stable temporary-lock response contract, and add direct API regressions proving frozen writes cannot mutate snapshot/revision. Stop before Instructor/Student exercise UI. ## Approved Classroom follow-on direction From 9eea334b1a7ea791f3edf3d3330ac512b06bbb32 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:00:10 -0400 Subject: [PATCH 042/206] feat: make collaboration writes capability-policy aware --- api/_workspace.js | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/api/_workspace.js b/api/_workspace.js index 61e281a..9b4b035 100644 --- a/api/_workspace.js +++ b/api/_workspace.js @@ -334,6 +334,14 @@ async function initializeRepository() { WHERE token_hash = ${tokenHash} AND revoked_at IS NULL RETURNING token_hash`; return rows.length > 0; + }, + async getCapabilityKind(tokenHash) { + const rows = await sql`SELECT capability_kind AS kind + FROM collaboration_workspace_capabilities + WHERE token_hash = ${tokenHash} + AND revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > NOW())`; + return rows[0]?.kind || null; } }; } @@ -413,7 +421,10 @@ export function presenceHandler({ getRepository = getWorkspaceRepository } = {}) } /** Creates a Vercel load/update handler. @param {object} [dependencies] Dependencies. @returns {Function} Handler. */ -export function workspaceHandler({ getRepository = getWorkspaceRepository } = {}) { +export function workspaceHandler({ + getRepository = getWorkspaceRepository, + getWritePolicy = null +} = {}) { return async (req, res) => { const authorization = req.headers?.authorization; if (authorization === undefined) return send(res, 401, { error: 'Authorization required.' }); @@ -439,7 +450,24 @@ export function workspaceHandler({ getRepository = getWorkspaceRepository } = {} const validation = validateSnapshot(req.body?.snapshot); if (!validation.ok) return send(res, validation.status, { error: validation.status === 413 ? 'Snapshot is too large.' : 'Invalid request.' }); if (!Number.isInteger(req.body?.revision) || req.body.revision < 1) return send(res, 400, { error: 'Invalid request.' }); - const result = await repository.update(hashWorkspaceToken(token), req.body.snapshot, req.body.revision); + + const tokenHash = hashWorkspaceToken(token); + if (getWritePolicy && repository.getCapabilityKind) { + const capabilityKind = await repository.getCapabilityKind(tokenHash); + if (capabilityKind === 'classroom-student') { + const policy = await getWritePolicy(tokenHash); + if (!policy) return send(res, 404, { error: 'Workspace not found.' }); + if (policy.allowed === false) { + return send(res, 423, { + error: 'Student editing is temporarily locked by the Instructor.', + code: 'classroom-editing-locked', + exercise: policy.exercise || null + }); + } + } + } + + const result = await repository.update(tokenHash, req.body.snapshot, req.body.revision); if (result.status === 'conflict') return send(res, 409, { error: 'Revision conflict.', revision: result.revision }); if (result.status === 'missing') return send(res, 404, { error: 'Workspace not found.' }); return send(res, 200, result.workspace); From 6a8fb4f55b653ca5acc66954fcc8d28fe375ace4 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:00:20 -0400 Subject: [PATCH 043/206] feat: resolve Classroom Student write policy --- api/_classroomWritePolicy.js | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 api/_classroomWritePolicy.js diff --git a/api/_classroomWritePolicy.js b/api/_classroomWritePolicy.js new file mode 100644 index 0000000..2b646ee --- /dev/null +++ b/api/_classroomWritePolicy.js @@ -0,0 +1,50 @@ +/** + * @module api/classroomWritePolicy + * @description + * Resolves server-enforced Classroom Student snapshot-write policy without + * making Standalone collaboration depend on Classroom authorization. + */ + +import { getClassroomRepository } from './_classroom.js'; + +/** + * Create a resolver for one active classroom-student capability hash. + * + * @param {object} [dependencies] Injectable dependencies. + * @returns {(accessHash:string)=>Promise} Write-policy resolver. + */ +export function createClassroomStudentWritePolicy({ + getRepository = getClassroomRepository +} = {}) { + return async accessHash => { + const repository = await getRepository(); + const context = await repository.getStudentContext(accessHash); + if (!context) return null; + + const exercise = await repository.getCurrentExerciseByClassInternalId( + context.internal.classId + ); + + if (!exercise) { + return { + allowed: true, + classroom: context.classroom, + workspace: context.workspace, + exercise: null + }; + } + + return { + allowed: exercise.studentEditingEnabled !== false, + classroom: context.classroom, + workspace: context.workspace, + exercise: { + id: exercise.id, + status: exercise.status, + stagePhase: exercise.stagePhase, + exerciseRevision: exercise.exerciseRevision, + studentEditingEnabled: exercise.studentEditingEnabled + } + }; + }; +} From 1aec6a17647871ab23c36572ab339f2dd3e63350 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:00:31 -0400 Subject: [PATCH 044/206] feat: enforce Classroom Student write policy --- api/workspaces/session.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api/workspaces/session.js b/api/workspaces/session.js index de6f8e1..ac4f552 100644 --- a/api/workspaces/session.js +++ b/api/workspaces/session.js @@ -2,6 +2,9 @@ * @module api/workspaces/session * @description Stable Vercel endpoint that loads and conditionally updates the bearer-token workspace. */ +import { createClassroomStudentWritePolicy } from '../_classroomWritePolicy.js'; import { workspaceHandler } from '../_workspace.js'; -export default workspaceHandler(); +export default workspaceHandler({ + getWritePolicy: createClassroomStudentWritePolicy() +}); From 96eab74f64838775f544990e495fe62a3319827f Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:00:44 -0400 Subject: [PATCH 045/206] feat: persist debrief Student editing policy --- api/_classroom.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/_classroom.js b/api/_classroom.js index ed81b56..cf835b5 100644 --- a/api/_classroom.js +++ b/api/_classroom.js @@ -1009,7 +1009,8 @@ async function initializeClassroomRepository() { async beginExerciseDebrief(instructorHash, { exercisePublicId, expectedRevision, - stageId + stageId, + studentEditingEnabled }) { const classroom = await classByInstructor(instructorHash); if (!classroom) return null; @@ -1017,7 +1018,7 @@ async function initializeClassroomRepository() { const rows = await sql`WITH target AS ( UPDATE classroom_exercises e SET stage_phase = 'debrief', - student_editing_enabled = TRUE, + student_editing_enabled = ${studentEditingEnabled}, exercise_revision = exercise_revision + 1, updated_at = NOW() WHERE e.class_id = ${classroom.internal_id} From 438915ea67d1f2048d140b8c8ad5b7c2b0afece6 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:01:02 -0400 Subject: [PATCH 046/206] test: mirror Classroom write-policy primitives --- tests/helpers/classroom-test-repositories.mjs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/helpers/classroom-test-repositories.mjs b/tests/helpers/classroom-test-repositories.mjs index 4bac68a..03b7f5f 100644 --- a/tests/helpers/classroom-test-repositories.mjs +++ b/tests/helpers/classroom-test-repositories.mjs @@ -88,6 +88,10 @@ export function createWorkspaceRepository() { alias.revoked = true; return true; }, + async getCapabilityKind(hash) { + const alias = aliasByHash.get(hash); + return alias && !alias.revoked ? alias.capabilityKind : null; + }, async load(hash) { const workspace = resolve(hash); return workspace ? { @@ -591,7 +595,8 @@ export function createClassroomRepository() { exercisePublicId, expectedRevision, stageId, - workspaceRepository + workspaceRepository, + studentEditingEnabled }) { const item = activeByInstructor(instructorHash); if (!item) return null; @@ -634,9 +639,7 @@ export function createClassroomRepository() { } exercise.stagePhase = 'debrief'; - // Tranche 4 deliberately does not claim edit freeze; Tranche 5 will add the - // collaboration write guard before this policy can become authoritative. - exercise.studentEditingEnabled = true; + exercise.studentEditingEnabled = studentEditingEnabled; exercise.exerciseRevision += 1; exercise.updatedAt = 'updated'; return { From 4eb7f5e6b8a719add994c79acbff31c04a4ebcbb Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:01:15 -0400 Subject: [PATCH 047/206] feat: enforce staged debrief editing policy --- api/_classroomExercise.js | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/api/_classroomExercise.js b/api/_classroomExercise.js index 61e5f0a..6292524 100644 --- a/api/_classroomExercise.js +++ b/api/_classroomExercise.js @@ -170,10 +170,8 @@ function studentExercisePayload(context, caseStudy) { status: context.exercise.status, stagePhase: context.exercise.stagePhase, exerciseRevision: context.exercise.exerciseRevision, - // Tranche 4 has not yet installed the collaboration PUT lock. Do not - // represent the persisted policy bit as enforced authority. - studentEditingEnabled: true, - editFreezeEnforced: false, + studentEditingEnabled: context.exercise.studentEditingEnabled !== false, + editFreezeEnforced: true, currentStage: stage ? { id: stage.id, @@ -216,7 +214,7 @@ async function instructorExercisePayload(repository, instructorHash, classroom, releases: releases?.releases || [], workspaceState: workspaceState?.workspaceState || [], checkpoints: checkpoints?.checkpoints || [], - editFreezeEnforced: false + editFreezeEnforced: true }; } @@ -396,7 +394,25 @@ export function classExerciseHandler({ exercisePublicId: current.exercise.id, expectedRevision, stageId: stage.id, - workspaceRepository + workspaceRepository, + studentEditingEnabled: stage.defaultDebriefEditPolicy !== 'frozen' + }); + } else if (action === 'set-editing') { + if ( + current.exercise.status !== 'active' + || current.exercise.stagePhase !== 'debrief' + || !stage + || typeof req.body?.enabled !== 'boolean' + ) { + return send(res, 400, { error: 'Invalid debrief editing policy.' }); + } + result = await repository.updateExerciseLifecycle(instructorHash, { + exercisePublicId: current.exercise.id, + expectedRevision, + status: current.exercise.status, + currentStageId: current.exercise.currentStageId, + stagePhase: current.exercise.stagePhase, + studentEditingEnabled: req.body.enabled }); } else if (action === 'advance') { if (current.exercise.status !== 'active' || current.exercise.stagePhase !== 'debrief' || !stage) { From 507da02269336fb414d2ccc5390be4cbb26ed97c Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:02:25 -0400 Subject: [PATCH 048/206] fix: make Classroom edit freeze mutation-atomic --- api/_workspace.js | 125 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 120 insertions(+), 5 deletions(-) diff --git a/api/_workspace.js b/api/_workspace.js index 9b4b035..37cc7bd 100644 --- a/api/_workspace.js +++ b/api/_workspace.js @@ -190,6 +190,106 @@ async function initializeRepository() { const current = await sql`SELECT revision FROM collaboration_workspaces WHERE id = ${workspaceId} AND expires_at > NOW()`; return current[0] ? { status: 'conflict', revision: current[0].revision } : { status: 'missing' }; }, + async updateClassroomStudent(tokenHash, snapshot, revision) { + const rows = await sql`WITH scope AS ( + SELECT cp.class_id, cap.workspace_id + FROM collaboration_workspace_capabilities cap + JOIN classroom_participants cp + ON cp.workspace_access_token_hash = cap.token_hash + AND cp.workspace_id = cap.workspace_id + AND cp.revoked_at IS NULL + WHERE cap.token_hash = ${tokenHash} + AND cap.capability_kind = 'classroom-student' + AND cap.revoked_at IS NULL + AND (cap.expires_at IS NULL OR cap.expires_at > NOW()) + UNION ALL + SELECT cm.class_id, cap.workspace_id + FROM collaboration_workspace_capabilities cap + JOIN classroom_memberships cm + ON cm.access_token_hash = cap.token_hash + AND cm.workspace_id = cap.workspace_id + WHERE cap.token_hash = ${tokenHash} + AND cap.capability_kind = 'classroom-student' + AND cap.revoked_at IS NULL + AND (cap.expires_at IS NULL OR cap.expires_at > NOW()) + LIMIT 1 + ) + UPDATE collaboration_workspaces w + SET snapshot_json = ${JSON.stringify(snapshot)}::jsonb, + revision = revision + 1, + updated_at = NOW() + WHERE w.id = (SELECT workspace_id FROM scope) + AND w.revision = ${revision} + AND w.expires_at > NOW() + AND NOT EXISTS ( + SELECT 1 + FROM classroom_exercises e + JOIN scope s ON s.class_id = e.class_id + WHERE e.status <> 'completed' + AND e.expires_at > NOW() + AND e.student_editing_enabled = FALSE + ) + RETURNING w.revision, w.expires_at`; + if (rows[0]) return { status: 'updated', workspace: rows[0] }; + + const current = await sql`WITH scope AS ( + SELECT cp.class_id, cap.workspace_id + FROM collaboration_workspace_capabilities cap + JOIN classroom_participants cp + ON cp.workspace_access_token_hash = cap.token_hash + AND cp.workspace_id = cap.workspace_id + AND cp.revoked_at IS NULL + WHERE cap.token_hash = ${tokenHash} + AND cap.capability_kind = 'classroom-student' + AND cap.revoked_at IS NULL + AND (cap.expires_at IS NULL OR cap.expires_at > NOW()) + UNION ALL + SELECT cm.class_id, cap.workspace_id + FROM collaboration_workspace_capabilities cap + JOIN classroom_memberships cm + ON cm.access_token_hash = cap.token_hash + AND cm.workspace_id = cap.workspace_id + WHERE cap.token_hash = ${tokenHash} + AND cap.capability_kind = 'classroom-student' + AND cap.revoked_at IS NULL + AND (cap.expires_at IS NULL OR cap.expires_at > NOW()) + LIMIT 1 + ) + SELECT + w.revision, + e.public_id AS "exerciseId", + e.status AS "exerciseStatus", + e.stage_phase AS "stagePhase", + e.exercise_revision AS "exerciseRevision", + e.student_editing_enabled AS "studentEditingEnabled" + FROM scope s + JOIN collaboration_workspaces w + ON w.id = s.workspace_id AND w.expires_at > NOW() + LEFT JOIN LATERAL ( + SELECT public_id, status, stage_phase, exercise_revision, student_editing_enabled + FROM classroom_exercises + WHERE class_id = s.class_id + AND status <> 'completed' + AND expires_at > NOW() + ORDER BY created_at DESC, id DESC + LIMIT 1 + ) e ON TRUE`; + if (!current[0]) return { status: 'missing' }; + if (current[0].studentEditingEnabled === false) { + return { + status: 'locked', + revision: current[0].revision, + exercise: { + id: current[0].exerciseId, + status: current[0].exerciseStatus, + stagePhase: current[0].stagePhase, + exerciseRevision: current[0].exerciseRevision, + studentEditingEnabled: false + } + }; + } + return { status: 'conflict', revision: current[0].revision }; + }, async upsertPresence(tokenHash, participantId, requestedName, activity = {}) { const workspaceId = await resolveWorkspaceId(tokenHash); if (!workspaceId) return null; @@ -452,22 +552,37 @@ export function workspaceHandler({ if (!Number.isInteger(req.body?.revision) || req.body.revision < 1) return send(res, 400, { error: 'Invalid request.' }); const tokenHash = hashWorkspaceToken(token); + let writePolicy = null; if (getWritePolicy && repository.getCapabilityKind) { const capabilityKind = await repository.getCapabilityKind(tokenHash); if (capabilityKind === 'classroom-student') { - const policy = await getWritePolicy(tokenHash); - if (!policy) return send(res, 404, { error: 'Workspace not found.' }); - if (policy.allowed === false) { + writePolicy = await getWritePolicy(tokenHash); + if (!writePolicy) return send(res, 404, { error: 'Workspace not found.' }); + if (writePolicy.allowed === false) { return send(res, 423, { error: 'Student editing is temporarily locked by the Instructor.', code: 'classroom-editing-locked', - exercise: policy.exercise || null + exercise: writePolicy.exercise || null }); } } } - const result = await repository.update(tokenHash, req.body.snapshot, req.body.revision); + const result = writePolicy && repository.updateClassroomStudent + ? await repository.updateClassroomStudent( + tokenHash, + req.body.snapshot, + req.body.revision, + writePolicy + ) + : await repository.update(tokenHash, req.body.snapshot, req.body.revision); + if (result.status === 'locked') { + return send(res, 423, { + error: 'Student editing is temporarily locked by the Instructor.', + code: 'classroom-editing-locked', + exercise: result.exercise || writePolicy?.exercise || null + }); + } if (result.status === 'conflict') return send(res, 409, { error: 'Revision conflict.', revision: result.revision }); if (result.status === 'missing') return send(res, 404, { error: 'Workspace not found.' }); return send(res, 200, result.workspace); From fb8cb73b29bc1ba25c807316ff3d3fa3070e8310 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:02:36 -0400 Subject: [PATCH 049/206] test: mirror atomic Classroom Student write path --- tests/helpers/classroom-test-repositories.mjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/helpers/classroom-test-repositories.mjs b/tests/helpers/classroom-test-repositories.mjs index 03b7f5f..1ec92d1 100644 --- a/tests/helpers/classroom-test-repositories.mjs +++ b/tests/helpers/classroom-test-repositories.mjs @@ -110,6 +110,15 @@ export function createWorkspaceRepository() { workspace.revision += 1; return { status: 'updated', workspace: { revision: workspace.revision, expires_at: workspace.expires_at } }; }, + async updateClassroomStudent(hash, snapshot, revision, writePolicy = null) { + if (writePolicy?.allowed === false) { + const workspace = resolve(hash); + return workspace + ? { status: 'locked', revision: workspace.revision, exercise: writePolicy.exercise || null } + : { status: 'missing' }; + } + return this.update(hash, snapshot, revision); + }, async upsertPresence(hash, participantId, displayName) { const workspace = resolve(hash); if (!workspace) return null; From d66eeeace6f9e41dade765b6b0331a69a40ebbb0 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:03:55 -0400 Subject: [PATCH 050/206] test: prove server-enforced Classroom edit freeze --- tests/classroom-exercise-api.unit.test.mjs | 124 ++++++++++++++++++++- 1 file changed, 121 insertions(+), 3 deletions(-) diff --git a/tests/classroom-exercise-api.unit.test.mjs b/tests/classroom-exercise-api.unit.test.mjs index d654aff..da657c0 100644 --- a/tests/classroom-exercise-api.unit.test.mjs +++ b/tests/classroom-exercise-api.unit.test.mjs @@ -11,7 +11,9 @@ import { classStudentExerciseReadyHandler, fingerprintStagedSimulation } from '../api/_classroomExercise.js'; +import { createClassroomStudentWritePolicy } from '../api/_classroomWritePolicy.js'; import { studentCaseStudiesHandler } from '../api/_protectedCaseStudies.js'; +import { workspaceHandler } from '../api/_workspace.js'; import { createClassroomRepository, createWorkspaceRepository, @@ -280,8 +282,8 @@ test('Instructor lifecycle is revision-safe, optional release is idempotent, and assert.equal(debrief.body.exercise.exerciseRevision, 4); assert.equal(debrief.body.capturedCount, 2); assert.equal(debrief.body.checkpoints.length, 2); - assert.equal(debrief.body.editFreezeEnforced, false); - assert.equal(debrief.body.exercise.studentEditingEnabled, true); + assert.equal(debrief.body.editFreezeEnforced, true); + assert.equal(debrief.body.exercise.studentEditingEnabled, false); await workspaceRepo.update( testTokenHash('P'), @@ -395,7 +397,8 @@ test('Student staged read exposes only cumulative released Student content and n stage2.body.exercise.releasedContent.map(item => item.content.id), ['brief-1', 'hint-1', 'brief-2'] ); - assert.equal(stage2.body.exercise.editFreezeEnforced, false); + assert.equal(stage2.body.exercise.editFreezeEnforced, true); + assert.equal(stage2.body.exercise.studentEditingEnabled, true); const instructorCredential = response(); await student({ @@ -405,6 +408,121 @@ test('Student staged read exposes only cumulative released Student content and n assert.equal(instructorCredential.statusCode, 404); }); +test('server-enforced debrief freeze blocks Classroom Student PUT without mutating Intake while Standalone remains writable', async () => { + const { classrooms, workspaceRepo } = await setupClass(); + await admit(classrooms); + await classrooms.assignParticipant(testTokenHash('A'), { + participantId: PARTICIPANT_A, + workspacePublicId: WORKSPACE_A_ID, + workspaceRepository: workspaceRepo + }); + await classrooms.issueParticipantWorkspaceAccess({ + sessionHash: testTokenHash('S'), + accessHash: testTokenHash('W'), + workspaceRepository: workspaceRepo + }); + + await workspaceRepo.create( + testTokenHash('Z'), + { pre: { oneLine: 'Standalone before' } }, + 30, + 'Standalone' + ); + + const instructor = instructorHandler(classrooms, workspaceRepo); + await createExercise(instructor); + await patchExercise(instructor, { action: 'start', expectedRevision: 1 }); + + const session = workspaceHandler({ + getRepository: async () => workspaceRepo, + getWritePolicy: createClassroomStudentWritePolicy({ + getRepository: async () => classrooms + }) + }); + + const beforeDebrief = response(); + await session({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { + revision: 1, + snapshot: { pre: { oneLine: 'Team A during work' } } + } + }, beforeDebrief); + assert.equal(beforeDebrief.statusCode, 200); + assert.equal(beforeDebrief.body.revision, 2); + + const debrief = await patchExercise(instructor, { + action: 'begin-debrief', + expectedRevision: 2 + }); + assert.equal(debrief.statusCode, 200); + assert.equal(debrief.body.exercise.studentEditingEnabled, false); + assert.equal(debrief.body.editFreezeEnforced, true); + + const frozenWrite = response(); + await session({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { + revision: 2, + snapshot: { pre: { oneLine: 'MUST NOT SAVE' } } + } + }, frozenWrite); + assert.equal(frozenWrite.statusCode, 423); + assert.equal(frozenWrite.body.code, 'classroom-editing-locked'); + + const afterFrozenWrite = await workspaceRepo.load(testTokenHash('W')); + assert.equal(afterFrozenWrite.revision, 2); + assert.equal(afterFrozenWrite.snapshot.pre.oneLine, 'Team A during work'); + + const frozenRead = response(); + await session({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + query: {} + }, frozenRead); + assert.equal(frozenRead.statusCode, 200); + assert.equal(frozenRead.body.revision, 2); + assert.equal(frozenRead.body.snapshot.pre.oneLine, 'Team A during work'); + + const standaloneWrite = response(); + await session({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'Z'.repeat(43) }, + body: { + revision: 1, + snapshot: { pre: { oneLine: 'Standalone changed while class is frozen' } } + } + }, standaloneWrite); + assert.equal(standaloneWrite.statusCode, 200); + assert.equal(standaloneWrite.body.revision, 2); + + const unfreeze = await patchExercise(instructor, { + action: 'set-editing', + expectedRevision: 3, + enabled: true + }); + assert.equal(unfreeze.statusCode, 200); + assert.equal(unfreeze.body.exercise.studentEditingEnabled, true); + assert.equal(unfreeze.body.exercise.exerciseRevision, 4); + + const afterUnfreeze = response(); + await session({ + method: 'PUT', + headers: { authorization: 'Bearer ' + 'W'.repeat(43) }, + body: { + revision: 2, + snapshot: { pre: { oneLine: 'Team A after unfreeze' } } + } + }, afterUnfreeze); + assert.equal(afterUnfreeze.statusCode, 200); + assert.equal(afterUnfreeze.body.revision, 3); + + const finalTeamA = await workspaceRepo.load(testTokenHash('W')); + assert.equal(finalTeamA.snapshot.pre.oneLine, 'Team A after unfreeze'); +}); + test('Student Ready records the server-observed current workspace revision and Waiting cannot mark Ready', async () => { const { classrooms, workspaceRepo, workspaceA } = await setupClass(); await admit(classrooms); From f3304a2f470b8c96468de13c787cbe0378b8f8b9 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:04:39 -0400 Subject: [PATCH 051/206] fix: preserve edit policy across pause and resume --- api/_classroomExercise.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/_classroomExercise.js b/api/_classroomExercise.js index 6292524..bd9860e 100644 --- a/api/_classroomExercise.js +++ b/api/_classroomExercise.js @@ -355,7 +355,7 @@ export function classExerciseHandler({ status: 'paused', currentStageId: current.exercise.currentStageId, stagePhase: current.exercise.stagePhase, - studentEditingEnabled: true + studentEditingEnabled: current.exercise.studentEditingEnabled !== false }); } else if (action === 'resume') { if (current.exercise.status !== 'paused') { @@ -367,7 +367,7 @@ export function classExerciseHandler({ status: 'active', currentStageId: current.exercise.currentStageId, stagePhase: current.exercise.stagePhase, - studentEditingEnabled: true + studentEditingEnabled: current.exercise.studentEditingEnabled !== false }); } else if (action === 'release-content') { const contentId = typeof req.body?.contentId === 'string' ? req.body.contentId.trim() : ''; From 261b28d488538964ccd73132ed568d73628f6411 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:04:53 -0400 Subject: [PATCH 052/206] test: preserve freeze across pause and resume --- tests/classroom-exercise-api.unit.test.mjs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/classroom-exercise-api.unit.test.mjs b/tests/classroom-exercise-api.unit.test.mjs index da657c0..40b1a5b 100644 --- a/tests/classroom-exercise-api.unit.test.mjs +++ b/tests/classroom-exercise-api.unit.test.mjs @@ -498,14 +498,30 @@ test('server-enforced debrief freeze blocks Classroom Student PUT without mutati assert.equal(standaloneWrite.statusCode, 200); assert.equal(standaloneWrite.body.revision, 2); + const paused = await patchExercise(instructor, { + action: 'pause', + expectedRevision: 3 + }); + assert.equal(paused.statusCode, 200); + assert.equal(paused.body.exercise.studentEditingEnabled, false); + assert.equal(paused.body.exercise.exerciseRevision, 4); + + const resumed = await patchExercise(instructor, { + action: 'resume', + expectedRevision: 4 + }); + assert.equal(resumed.statusCode, 200); + assert.equal(resumed.body.exercise.studentEditingEnabled, false); + assert.equal(resumed.body.exercise.exerciseRevision, 5); + const unfreeze = await patchExercise(instructor, { action: 'set-editing', - expectedRevision: 3, + expectedRevision: 5, enabled: true }); assert.equal(unfreeze.statusCode, 200); assert.equal(unfreeze.body.exercise.studentEditingEnabled, true); - assert.equal(unfreeze.body.exercise.exerciseRevision, 4); + assert.equal(unfreeze.body.exercise.exerciseRevision, 6); const afterUnfreeze = response(); await session({ From 75faed589f6221abd05653673ce559cbe7e337e6 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:08:30 -0400 Subject: [PATCH 053/206] docs: checkpoint server-enforced exercise freeze --- docs/classroom-staged-simulation.md | 75 +++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/docs/classroom-staged-simulation.md b/docs/classroom-staged-simulation.md index 771e862..672db31 100644 --- a/docs/classroom-staged-simulation.md +++ b/docs/classroom-staged-simulation.md @@ -693,30 +693,67 @@ Progressive-disclosure / security semantics: - Instructor and Student credentials are not interchangeable and the human join code has no exercise authority; - all staged responses remain `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. -Important deliberate boundary: -- Tranche 4 reports `editFreezeEnforced: false` and Student-safe payloads continue to represent editing as effectively enabled; -- `begin-debrief` persists debrief phase/checkpoints, but **server-enforced collaboration write freeze is not claimed yet**; -- the actual `classroom-student` collaboration PUT guard belongs to Tranche 5. +Tranche 4 implementation/security-test checkpoint: `5afc0041db89348d97ca0986cab89491765d0e00`. -Implementation/security test checkpoint: `5afc0041db89348d97ca0986cab89491765d0e00`. - -Validation: +Tranche 4 validation: - repository quality: **273 tests / 272 pass / 0 fail / 1 intentional skip**; - required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; - CI, CodeQL, Dependency Review, and Template Manifest Guard: green. -## Exact next implementation action +## Implementation checkpoint — server-enforced Student editing policy + +Tranche 5 makes the staged editing policy an actual server authorization boundary rather than a UI convention. + +Write-path contract: +- only snapshot mutation through `PUT /api/workspaces/session` is gated; ordinary GET, presence, observation, coaching, staged reads, and readiness remain available; +- the collaboration repository first distinguishes an active `classroom-student` alias from a primary/Standalone workspace token; +- Standalone/primary collaboration therefore does not depend on Classroom policy resolution; +- a Classroom Student alias resolves its represented class/workspace and current non-completed staged exercise; +- if `student_editing_enabled=false`, the PUT returns **HTTP 423 Locked** with stable code `classroom-editing-locked`; +- a locked request does not change snapshot or collaboration revision; +- the real Neon mutation repeats the lock predicate inside the SQL UPDATE itself, so an Instructor freeze racing an already-started Student save fails closed rather than slipping through after a preflight check; +- both #312 live-participant aliases and legacy Classroom membership aliases are covered by the atomic scope lookup; +- classes with no staged exercise remain writable exactly as before. + +Exercise-policy contract: +- entering debrief applies the stage's explicit `defaultDebriefEditPolicy`: `frozen` sets Student editing false, `open` leaves it true; +- Instructor `set-editing` is a revision-safe debrief-only server action for an explicit freeze/unfreeze override; +- Pause and Resume preserve the current editing policy; pausing is not itself a freeze and resuming a frozen debrief does not silently unlock it; +- advancing to the next work stage restores Student editing for work; +- completing an exercise removes it from current-exercise policy lookup, so the staged lock no longer applies after completion; +- staged Instructor/Student payloads may now truthfully expose `editFreezeEnforced: true` and the persisted `studentEditingEnabled` state. + +Focused regression proves: +- Classroom Student write succeeds during work; +- Stage 1 debrief defaults frozen; +- direct frozen PUT returns 423; +- snapshot and revision remain unchanged after the rejected write; +- GET continues while frozen; +- Standalone write continues while the class is frozen; +- Pause -> Resume preserves the freeze; +- Instructor unfreeze increments exercise revision and restores Student writes. + +Implementation checkpoint: `261b28d488538964ccd73132ed568d73628f6411`. -Implement **Tranche 5 only — server-enforced Student editing policy**. +Validation: +- repository quality: **274 tests / 273 pass / 0 fail / 1 intentional skip**; +- required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; +- CI, CodeQL, Dependency Review, and Template Manifest Guard: green. -Required slice: -- derive whether the represented `classroom-student` workspace belongs to a class whose current staged exercise forbids editing; -- enforce that policy in the collaboration **write** path server-side before any snapshot revision mutation; -- choose and document one stable temporary-lock HTTP response contract (prefer a distinct status such as 423 if it integrates cleanly with the current collaboration client); -- do not block Instructor observation, presence heartbeats, coaching, Student stage reads/readiness, or ordinary GET collaboration reads; -- do not affect Standalone collaboration or Classroom classes with no staged exercise; -- make pause/debrief/edit-policy semantics explicit: no UI-only security; -- add direct API tests proving a frozen Student PUT cannot mutate snapshot/revision, an allowed Student PUT still works, and Standalone/legacy collaboration remains unchanged; -- mirror the policy in the deterministic repository/browser fixture only as needed for later UI/browser slices. +## Exact next implementation action -Stop before Instructor exercise-console UI or Student staged-case panel. +Implement **Tranche 6 only — Instructor exercise console** on the now-enforced server contract. + +Required product slice: +- integrate staged exercise controls into the existing live Instructor dashboard rather than creating a second classroom shell; +- list only protected Case Studies that carry an explicit staged `simulation` definition as startable simulations; +- allow Instructor selection/create of a draft and show stage title/objective plus Instructor-only facilitation content; +- provide accessible Start, Pause, Resume, optional-content Release, Begin debrief, Freeze/Unfreeze, Advance, and Complete controls with optimistic revision refresh/conflict handling; +- show workspace/team readiness and current stage progress beside the existing roster/workspace navigation; +- keep existing read-only observer/coaching one click away; +- clearly distinguish exercise pause from Student editing freeze; +- do not expose future Student content in Instructor-to-Student DOM/state accidentally; Instructor-only material may be shown only in Instructor experience; +- retain same-device class resume: exercise state is server state and must be re-fetched, never persisted into Intake; +- extend deterministic Instructor feature/browser fixtures only enough to prove the console lifecycle before cutting over Student staged-case UI. + +Stop before the Student stage-aware case reference panel and before production official Case Study staging. From babf802687df73f5c8be0f2dfc260a301e16be03 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:08:46 -0400 Subject: [PATCH 054/206] docs: document Classroom Student edit lock --- docs/classroom-api.md | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/classroom-api.md b/docs/classroom-api.md index 083b852..b35e2a2 100644 --- a/docs/classroom-api.md +++ b/docs/classroom-api.md @@ -269,7 +269,24 @@ Supported actions: Lifecycle changes use optimistic `exercise_revision`. Optional-content replay is idempotent; a stale request for a different release conflicts. `begin-debrief` captures immutable class/workspace-scoped snapshot/revision checkpoints before later live changes. Stage ordering comes from the pinned staged definition, never client-provided next-stage IDs. -**Tranche 4 does not yet claim an edit freeze.** Responses expose `editFreezeEnforced: false`; server enforcement on Student collaboration PUT is the next dedicated tranche. +`begin-debrief` also applies the current stage's authored `defaultDebriefEditPolicy`. During debrief, Instructor action `set-editing` with boolean `enabled` provides an explicit revision-safe freeze/unfreeze override. Pause/Resume preserve the current editing policy; pause is not synonymous with freeze. + +### Server-enforced Student editing lock + +For an active `classroom-student` workspace alias, `PUT /api/workspaces/session` enforces the current exercise's `student_editing_enabled` before any collaboration snapshot revision mutation. + +When editing is frozen: +- the response is **423 Locked**; +- body includes stable `code: "classroom-editing-locked"` plus current exercise context; +- snapshot and collaboration revision remain unchanged; +- GET workspace reads remain available; +- presence, Instructor observation/coaching, Student exercise reads, and readiness are not blocked. + +The production mutation repeats the freeze predicate atomically inside the SQL UPDATE, rather than relying only on a preflight policy lookup. This closes the race where an Instructor freezes between a Student's permission check and snapshot write. + +Primary/Standalone collaboration tokens bypass Classroom policy entirely. Classroom classes with no current staged exercise remain writable. Both current live-participant and legacy Classroom membership `classroom-student` aliases are covered. + +Exercise responses now expose `editFreezeEnforced: true` and the authoritative `studentEditingEnabled` value. ### `GET /api/classes/exercise/student` From 36065468ef3e9c99c04e5e3c46ea6c0f55ec516d Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:09:00 -0400 Subject: [PATCH 055/206] docs: guard atomic Classroom edit freeze --- api/AGENTS.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api/AGENTS.md b/api/AGENTS.md index 7c6642d..f27054c 100644 --- a/api/AGENTS.md +++ b/api/AGENTS.md @@ -148,11 +148,15 @@ Rules: - optional content release, debrief, and stage advance are Instructor-only and optimistic-revision protected; - Student/team Ready state resolves the participant's current assignment server-side; stale pre-reassignment authority cannot mutate old-team exercise state; - beginning debrief must capture idempotent immutable workspace checkpoints before any optional post-debrief edits; -- if Student editing is frozen, enforce it on the `classroom-student` collaboration write path; UI read-only projection alone is not authority; +- if Student editing is frozen, enforce it on the `classroom-student` collaboration snapshot write path; UI read-only projection alone is not authority; +- frozen Student snapshot PUT uses HTTP 423 with stable code `classroom-editing-locked`; the rejected request must not advance collaboration revision; +- the real workspace mutation must repeat the Classroom freeze predicate atomically with the UPDATE so a freeze racing a Student save fails closed; a preflight policy check alone is insufficient; +- Classroom freeze must not block workspace GET, presence, Instructor observation/coaching, Student staged reads/readiness, or Standalone/primary collaboration tokens; +- Pause/Resume preserve the current Student editing policy. Only stage debrief defaults and explicit Instructor debrief `set-editing` may change that policy; - late joiners receive only the current cumulative Student release; reassignment changes workspace Intake/readiness, not class exercise stage; - exercise persistence uses one non-completed exercise per class initially, optimistic `exercise_revision`, idempotent optional releases, workspace-scoped readiness, immutable first-write-wins debrief checkpoints, and class-bounded expiry; - every running exercise pins the staged simulation version plus a definition fingerprint; later API reads/mutations must fail closed if the current protected definition does not match; - checkpoint snapshots are facilitation evidence only and must never overwrite or become the live collaboration snapshot automatically; -- do not expose `student_editing_enabled=false` as a security guarantee until the `classroom-student` collaboration PUT path enforces the lock server-side; edit-freeze enforcement belongs to the dedicated follow-on tranche; +- `student_editing_enabled=false` is now a server-enforced security boundary for `classroom-student` snapshot PUT; preserve that invariant when changing exercise or collaboration code; - do not create production stage definitions from guessed official case boundaries. Synthetic deterministic staged content is acceptable in tests until authoritative material is supplied. From c16104ddc6c5421c97fa689cd28205934d37da44 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:09:15 -0400 Subject: [PATCH 056/206] docs: define staged disclosure and edit-freeze security --- SECURITY.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/SECURITY.md b/SECURITY.md index b5f9384..a60075b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -33,6 +33,10 @@ Protected Case Study delivery is a separate server boundary. The public browser Protected Case Study catalog responses contain metadata only; payloads are selected in authenticated POST bodies, not query strings. All protected Case Study responses use `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. The rotating mode password remains a pedagogical progression control and is **not** an authentication boundary. +For #313 staged simulation, Student progressive disclosure is server-side authorization, not UI hiding. A Student class-session may receive only the cumulative Student-safe release through the current exercise stage. Future-stage material, Instructor facilitation/model/exemplar material, and the complete protected Case Study source `state` must not be returned through the staged Student endpoint. If a class has a staged exercise record for a Case Study, Student full-payload retrieval for that case is blocked even after exercise completion unless a future explicit reveal contract is added. + +Staged Student edit freeze is also server-enforced. When the represented class's current exercise has `student_editing_enabled=false`, an active `classroom-student` alias may still GET the collaboration workspace and maintain presence, but `PUT /api/workspaces/session` returns HTTP **423** with code `classroom-editing-locked`. The write must not mutate snapshot or revision. Production enforcement repeats the lock predicate atomically inside the workspace UPDATE to close check-then-write races. Standalone/primary collaboration tokens and classes with no current staged exercise are not subject to this Classroom lock. + Authored JSON under `templates/*.json` is build-time source and is excluded from Vercel uploads with `.vercelignore`. Production must use committed generated manifests and run `npm run verify:protected-cases`; do not change the Vercel build back to a command that needs excluded authoring JSON. The verifier must remain in the canonical quality gate and must prove protected IDs/names are absent from public browser runtime assets. Public static delivery is also an explicit allowlist boundary. `scripts/build-vercel-public.mjs` emits only browser runtime files plus the intentionally public `docs/eula.md` to `dist/`, and Vercel must keep `outputDirectory: "dist"`. Never return static output to the repository root: production previously exposed internal Markdown and build scripts when `outputDirectory` was `.`. Other `docs/`, `tests/`, `scripts/`, AGENTS files, authoring material, and repository metadata must not be public assets. From 1a0b316fabfb46f07267acbc6393b3a1ad2c7edc Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:09:30 -0400 Subject: [PATCH 057/206] docs: advance staged simulation to Instructor console --- docs/classroom-workstream.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md index ed8df1a..10aea45 100644 --- a/docs/classroom-workstream.md +++ b/docs/classroom-workstream.md @@ -439,7 +439,15 @@ Continue #295 on draft PR #307 from the current branch head. - Tranche 4 deliberately returns `editFreezeEnforced: false`; no security claim is made until collaboration PUT enforcement lands. - implementation/security-test checkpoint: `5afc0041db89348d97ca0986cab89491765d0e00`. - validation: repository quality **273 tests / 272 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. -- Exact next action: implement **Tranche 5 only — server-enforced Student editing policy**. Enforce the staged exercise editing policy in the `classroom-student` collaboration write path before revision mutation, keep GET/presence/coaching/readiness unaffected, preserve Standalone and no-exercise Classroom behavior, define a stable temporary-lock response contract, and add direct API regressions proving frozen writes cannot mutate snapshot/revision. Stop before Instructor/Student exercise UI. +- Server-enforced Student editing-policy checkpoint: + - `9eea334b1a7ea791f3edf3d3330ac512b06bbb32` adds capability-kind-aware workspace write policy preflight; `6a8fb4f55b653ca5acc66954fcc8d28fe375ace4` resolves current Classroom exercise write policy; `1aec6a17647871ab23c36572ab339f2dd3e63350` wires the production session route. + - `96eab74f64838775f544990e495fe62a3319827f` / `4eb7f5e6b8a719add994c79acbff31c04a4ebcbb` apply authored debrief defaults, expose truthful enforcement state, and add revision-safe debrief `set-editing`. + - `507da02269336fb414d2ccc5390be4cbb26ed97c` hardens the real Neon `classroom-student` mutation with an atomic SQL freeze predicate so Instructor freeze racing a Student PUT fails closed. + - `f3304a2f470b8c96468de13c787cbe0378b8f8b9` makes Pause/Resume preserve the current edit policy instead of silently unlocking a frozen debrief. + - `d66eeeace6f9e41dade765b6b0331a69a40ebbb0` / `261b28d488538964ccd73132ed568d73628f6411` prove allowed work writes, 423 frozen-write rejection with zero snapshot/revision mutation, frozen GET availability, Standalone isolation, freeze persistence through Pause/Resume, and explicit unfreeze restoring writes. + - stable temporary-lock contract: HTTP **423 Locked**, `code: classroom-editing-locked`. + - validation on `261b28d...`: repository quality **274 tests / 273 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. +- Exact next action: implement **Tranche 6 only — Instructor exercise console** inside the existing live Instructor dashboard. Add staged Case Study selection/draft creation, stage/facilitation display, accessible lifecycle/release/debrief/freeze/advance/complete controls, readiness/progress, conflict refresh, and existing observer/coaching navigation. Keep Student staged-case panel and production official-case staging deferred. ## Approved Classroom follow-on direction From 2624f1e92ba1e9ec1a6abbea183b46813b1cc2d3 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:50:25 -0400 Subject: [PATCH 058/206] Expose staged exercise discovery to instructors --- api/_classroomExercise.js | 29 +++++++++++++++++++++-------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/api/_classroomExercise.js b/api/_classroomExercise.js index bd9860e..9c3713a 100644 --- a/api/_classroomExercise.js +++ b/api/_classroomExercise.js @@ -86,6 +86,12 @@ function caseStudySummary(caseStudy) { }; } +function stagedCaseStudyCatalog(manifest) { + return manifest + .filter(entry => entry?.templateKind === 'case-study' && entry.simulation) + .map(caseStudySummary); +} + function instructorDefinition(caseStudy) { return { ...caseStudySummary(caseStudy), @@ -307,7 +313,11 @@ export function classExerciseHandler({ const current = await repository.getCurrentExerciseForInstructor(instructorHash); if (!current) { return req.method === 'GET' - ? send(res, 200, { class: classroom, exercise: null }) + ? send(res, 200, { + class: classroom, + exercise: null, + availableCaseStudies: stagedCaseStudyCatalog(manifest) + }) : send(res, 404, { error: 'Exercise not found.' }); } const caseStudy = stagedCaseStudy(manifest, current.exercise.caseStudyId); @@ -316,13 +326,16 @@ export function classExerciseHandler({ } if (req.method === 'GET') { - return send(res, 200, await instructorExercisePayload( - repository, - instructorHash, - current.classroom, - current.exercise, - caseStudy - )); + return send(res, 200, { + ...(await instructorExercisePayload( + repository, + instructorHash, + current.classroom, + current.exercise, + caseStudy + )), + availableCaseStudies: stagedCaseStudyCatalog(manifest) + }); } const expectedRevision = positiveRevision(req.body?.expectedRevision); From 0c2be87f3bea204e5cf5e26db6be58cd0474ed05 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:50:27 -0400 Subject: [PATCH 059/206] Add Instructor exercise console client --- src/classroomExerciseInstructor.js | 230 +++++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 src/classroomExerciseInstructor.js diff --git a/src/classroomExerciseInstructor.js b/src/classroomExerciseInstructor.js new file mode 100644 index 0000000..c3909e7 --- /dev/null +++ b/src/classroomExerciseInstructor.js @@ -0,0 +1,230 @@ +/** + * @module classroomExerciseInstructor + * @summary Owns Instructor staged-exercise discovery and the read-only console foundation. + * @description + * Receives the active Instructor capability from the class lifecycle, keeps it + * in memory only, and reads /api/classes/exercise. Tranche 6A deliberately + * exposes no exercise mutation actions; later Tranche 6 slices extend this + * controller with revision-safe lifecycle controls. + */ + +export const INSTRUCTOR_EXERCISE_ENDPOINT = '/api/classes/exercise'; + +const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/; + +function validCapability(value) { + return typeof value === 'string' && CAPABILITY_PATTERN.test(value.trim()); +} + +function sanitizeCaseStudySummary(value) { + if (!value || typeof value !== 'object') return null; + const id = typeof value.id === 'string' ? value.id.trim() : ''; + const name = typeof value.name === 'string' ? value.name.trim() : ''; + if (!id || !name) return null; + return { + id, + name, + description: typeof value.description === 'string' ? value.description : '', + supportedModes: Array.isArray(value.supportedModes) + ? value.supportedModes.filter(mode => typeof mode === 'string') + : [] + }; +} + +/** + * Create the Instructor exercise console controller. + * + * @param {object} [options] Dependency overrides. + * @returns {object} Exercise console controller. + */ +export function createInstructorExerciseConsoleController({ + fetchImpl = globalThis.fetch?.bind(globalThis), + documentRef = globalThis.document +} = {}) { + let capability = ''; + let epoch = 0; + let destroyed = false; + let loading = false; + let lastError = ''; + let classroom = null; + let exercise = null; + let caseStudy = null; + let availableCaseStudies = []; + + const element = id => documentRef?.getElementById?.(id) || null; + + const renderAvailable = () => { + const list = element('instructorExerciseAvailableList'); + if (!list) return; + list.replaceChildren(); + if (!availableCaseStudies.length) { + const item = documentRef.createElement('li'); + item.className = 'instructor-exercise-console__empty'; + item.textContent = 'No staged Case Studies are authored for classroom simulation yet.'; + list.append(item); + return; + } + availableCaseStudies.forEach(item => { + const row = documentRef.createElement('li'); + const name = documentRef.createElement('strong'); + name.textContent = item.name; + row.append(name); + if (item.description) { + const description = documentRef.createElement('span'); + description.textContent = item.description; + row.append(description); + } + list.append(row); + }); + }; + + const render = () => { + const panel = element('instructorExerciseConsole'); + const status = element('instructorExerciseStatus'); + const caseName = element('instructorExerciseCaseName'); + const detail = element('instructorExerciseDetail'); + const refreshButton = element('instructorExerciseRefreshBtn'); + if (!panel) return; + + panel.hidden = !capability; + if (!capability) return; + if (refreshButton) refreshButton.disabled = loading; + + if (loading) { + if (status) status.textContent = 'Loading exercise…'; + } else if (lastError) { + if (status) status.textContent = lastError; + } else if (exercise) { + const phase = exercise.status === 'draft' + ? 'Draft' + : exercise.status === 'paused' + ? 'Paused' + : exercise.stagePhase === 'debrief' + ? 'Debrief' + : exercise.status === 'completed' + ? 'Completed' + : 'In progress'; + if (status) status.textContent = phase; + } else { + const count = availableCaseStudies.length; + if (status) status.textContent = count === 1 + ? '1 staged Case Study available' + : `${count} staged Case Studies available`; + } + + if (exercise && caseStudy) { + if (caseName) caseName.textContent = caseStudy.name || 'Current exercise'; + const stage = Array.isArray(caseStudy.simulation?.stages) + ? caseStudy.simulation.stages.find(item => item.id === exercise.currentStageId) + : null; + if (detail) detail.textContent = stage + ? `${stage.title} · ${stage.studentObjective}` + : 'Draft exercise — no stage has started.'; + } else { + if (caseName) caseName.textContent = 'No staged exercise selected'; + if (detail) detail.textContent = availableCaseStudies.length + ? 'Staged Case Studies are available. Exercise setup will be enabled in the next console slice.' + : 'The console is connected; no staged Case Study definition is currently available.'; + } + renderAvailable(); + }; + + const resetState = () => { + loading = false; + lastError = ''; + classroom = null; + exercise = null; + caseStudy = null; + availableCaseStudies = []; + }; + + const refresh = async () => { + if (destroyed || !capability || typeof fetchImpl !== 'function') return false; + const localCapability = capability; + const localEpoch = epoch; + loading = true; + lastError = ''; + render(); + try { + const response = await fetchImpl(INSTRUCTOR_EXERCISE_ENDPOINT, { + method: 'GET', + headers: { Authorization: `Bearer ${localCapability}` } + }); + const body = await response.json().catch(() => ({})); + if (destroyed || localEpoch !== epoch || capability !== localCapability) return false; + if (!response.ok) { + lastError = response.status === 401 || response.status === 404 + ? 'Exercise access is no longer available.' + : 'Could not load the class exercise.'; + return false; + } + classroom = body.class && typeof body.class === 'object' ? body.class : null; + exercise = body.exercise && typeof body.exercise === 'object' ? body.exercise : null; + caseStudy = body.caseStudy && typeof body.caseStudy === 'object' ? body.caseStudy : null; + availableCaseStudies = Array.isArray(body.availableCaseStudies) + ? body.availableCaseStudies.map(sanitizeCaseStudySummary).filter(Boolean) + : []; + return true; + } catch { + if (destroyed || localEpoch !== epoch) return false; + lastError = 'Could not load the class exercise.'; + return false; + } finally { + if (!destroyed && localEpoch === epoch) { + loading = false; + render(); + } + } + }; + + const connectInstructor = async token => { + if (!validCapability(token) || destroyed) return false; + epoch += 1; + capability = token.trim(); + resetState(); + render(); + return refresh(); + }; + + const disconnect = () => { + epoch += 1; + capability = ''; + resetState(); + render(); + }; + + const handleRefresh = () => { void refresh(); }; + element('instructorExerciseRefreshBtn')?.addEventListener('click', handleRefresh); + render(); + + return { + connectInstructor, + disconnect, + refresh, + getState: () => ({ + connected: Boolean(capability), + loading, + lastError, + classroom, + exercise, + caseStudy: caseStudy ? sanitizeCaseStudySummary(caseStudy) : null, + availableCaseStudies: availableCaseStudies.map(item => ({ ...item })) + }), + destroy: () => { + if (destroyed) return; + element('instructorExerciseRefreshBtn')?.removeEventListener('click', handleRefresh); + disconnect(); + destroyed = true; + } + }; +} + +/** + * Initialize the Instructor staged exercise console. + * + * @param {Parameters[0]} options Dependencies. + * @returns {ReturnType} Controller. + */ +export function initInstructorExerciseConsole(options) { + return createInstructorExerciseConsoleController(options); +} From 00e6039f9543c25c21420559529e7ffda2088217 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:50:30 -0400 Subject: [PATCH 060/206] Test Instructor staged exercise discovery --- tests/classroom-exercise-api.unit.test.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/classroom-exercise-api.unit.test.mjs b/tests/classroom-exercise-api.unit.test.mjs index 40b1a5b..f14f814 100644 --- a/tests/classroom-exercise-api.unit.test.mjs +++ b/tests/classroom-exercise-api.unit.test.mjs @@ -188,6 +188,25 @@ test('staged definition fingerprint is canonical across object key order', () => assert.match(fingerprintStagedSimulation(STAGED_CASE.simulation), /^[a-f0-9]{64}$/u); }); +test('Instructor exercise GET discovers staged Case Studies without exposing simulation definitions', async () => { + const { classrooms, workspaceRepo } = await setupClass(); + const handler = instructorHandler(classrooms, workspaceRepo); + const res = response(); + await handler({ + method: 'GET', + headers: { authorization: 'Bearer ' + 'A'.repeat(43) } + }, res); + + assert.equal(res.statusCode, 200); + assert.equal(res.body.exercise, null); + assert.deepEqual(res.body.availableCaseStudies.map(item => item.id), [STAGED_CASE.id]); + assert.equal(res.body.availableCaseStudies[0].name, STAGED_CASE.name); + assert.equal('simulation' in res.body.availableCaseStudies[0], false); + assert.equal('state' in res.body.availableCaseStudies[0], false); + assert.equal(JSON.stringify(res.body.availableCaseStudies).includes('INSTRUCTOR ONLY'), false); + assert.equal(JSON.stringify(res.body.availableCaseStudies).includes('COMPLETE SOURCE'), false); +}); + test('Instructor exercise creation is class-authorized, staged-only, idempotent, and definition-pinned', async () => { const { classrooms, workspaceRepo } = await setupClass(); await admit(classrooms); From 000e1f9839baf29d646b5408c172e14bb96aa1c1 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:50:44 -0400 Subject: [PATCH 061/206] Wire Instructor exercise console lifecycle --- main.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/main.js b/main.js index 484ac97..31b2157 100644 --- a/main.js +++ b/main.js @@ -63,6 +63,7 @@ import { initStudentClassroom } from './src/classroomStudent.js'; import { initInstructorClassroom } from './src/classroomInstructor.js'; import { initClassroomCoaching } from './src/classroomCoaching.js'; import { initClassroomCaseStudies } from './src/classroomCaseStudies.js'; +import { initInstructorExerciseConsole } from './src/classroomExerciseInstructor.js'; /** Active shared-session controller, initialized during boot. @type {object|null} */ let collaborationController = null; @@ -70,9 +71,12 @@ let studentClassroomController = null; let instructorClassroomController = null; let classroomCoachingController = null; let classroomCaseStudiesController = null; +let instructorExerciseConsoleController = null; /** Destroys collaboration resources during application or test teardown. @returns {void} */ export function destroyCollaboration() { + instructorExerciseConsoleController?.destroy?.(); + instructorExerciseConsoleController = null; classroomCaseStudiesController?.destroy?.(); classroomCaseStudiesController = null; classroomCoachingController?.destroy?.(); @@ -199,6 +203,9 @@ function boot() { initPreface({ onSave: saveAppState }); initCommsDrawer(); classroomCaseStudiesController = initClassroomCaseStudies({ windowRef: window }); + instructorExerciseConsoleController = initInstructorExerciseConsole({ + documentRef: document + }); initTemplatesDrawer({ protectedCaseStudies: classroomCaseStudiesController }); initNotesWorkspace({ onSave: saveAppState, showToast }); initializeCommunications({ onSave: saveAppState, showToast }); @@ -254,8 +261,14 @@ function boot() { toast: showToast, onObservation: context => classroomCoachingController?.showInstructorWorkspace?.(context), onObservationEnd: () => classroomCoachingController?.hideInstructorWorkspace?.(), - onClassConnected: token => { void classroomCaseStudiesController?.connectInstructor?.(token); }, - onClassDisconnected: () => classroomCaseStudiesController?.disconnect?.() + onClassConnected: token => { + void classroomCaseStudiesController?.connectInstructor?.(token); + void instructorExerciseConsoleController?.connectInstructor?.(token); + }, + onClassDisconnected: () => { + classroomCaseStudiesController?.disconnect?.(); + instructorExerciseConsoleController?.disconnect?.(); + } }); wireThemeToggle(); wireSummaryEvents(); From e59a872e47c99411311cb3857d9b0e1bc687a2cb Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:50:47 -0400 Subject: [PATCH 062/206] Add Instructor exercise console shell --- index.html | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/index.html b/index.html index 389a0f9..392780b 100644 --- a/index.html +++ b/index.html @@ -409,6 +409,27 @@

Class

+ + + + +
From 7e665057f916845b03883e83deec5a4f155393e6 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:50:49 -0400 Subject: [PATCH 063/206] Style Instructor exercise console shell --- styles.css | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/styles.css b/styles.css index b95897c..87918d7 100644 --- a/styles.css +++ b/styles.css @@ -3224,6 +3224,24 @@ body[data-experience-role="instructor"].instructor-class-connected .wrap{max-wid } +/* Instructor staged exercise console */ +.instructor-exercise-console{margin:16px 0 0;padding:12px;border:1px solid var(--line);border-radius:14px;background:var(--panel);} +.instructor-exercise-console__heading{display:flex;align-items:center;justify-content:space-between;gap:10px;} +.instructor-exercise-console__heading>div{display:flex;align-items:baseline;gap:8px;min-width:0;flex-wrap:wrap;} +.instructor-exercise-console__heading strong{color:var(--ink-strong);font-size:13px;} +.instructor-exercise-console__heading span{color:var(--muted);font-size:10px;} +.instructor-exercise-console__heading .btn-secondary{padding:6px 9px;font-size:10px;} +.instructor-exercise-console__summary{margin-top:10px;padding:9px 10px;border-radius:11px;background:var(--panel-muted);} +.instructor-exercise-console__summary>strong{display:block;color:var(--ink-strong);font-size:12px;} +.instructor-exercise-console__summary p{margin:4px 0 0;color:var(--muted);font-size:10px;line-height:1.45;} +.instructor-exercise-console__available{margin-top:9px;} +.instructor-exercise-console__available>span{display:block;color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;} +.instructor-exercise-console__available ul{display:flex;flex-direction:column;gap:5px;max-height:92px;overflow:auto;margin:6px 0 0;padding:0;list-style:none;} +.instructor-exercise-console__available li{padding:7px 8px;border:1px solid var(--line);border-radius:10px;background:var(--panel-muted);} +.instructor-exercise-console__available li strong{display:block;color:var(--ink-strong);font-size:11px;} +.instructor-exercise-console__available li span{display:block;margin-top:2px;color:var(--muted);font-size:9px;line-height:1.35;} +.instructor-exercise-console__available .instructor-exercise-console__empty{color:var(--muted);font-size:10px;line-height:1.4;} + /* Classroom coaching feedback */ .classroom-coaching{margin-top:10px;padding:10px 12px;border:1px solid var(--line);border-radius:12px;background:var(--panel-muted);font-size:12px;line-height:1.45;} .classroom-coaching__heading{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:7px;} From 80734d28fe3b1763c3f47031c6fec141e57c1e0a Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:51:06 -0400 Subject: [PATCH 064/206] Test Instructor exercise console foundation --- ...assroomExerciseInstructor.feature.test.mjs | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/classroomExerciseInstructor.feature.test.mjs diff --git a/tests/classroomExerciseInstructor.feature.test.mjs b/tests/classroomExerciseInstructor.feature.test.mjs new file mode 100644 index 0000000..88dee08 --- /dev/null +++ b/tests/classroomExerciseInstructor.feature.test.mjs @@ -0,0 +1,117 @@ +/** + * Feature coverage for the Tranche 6A Instructor staged-exercise console foundation. + */ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { afterEach, test } from 'node:test'; +import { JSDOM } from 'jsdom'; + +import { + INSTRUCTOR_EXERCISE_ENDPOINT, + createInstructorExerciseConsoleController +} from '../src/classroomExerciseInstructor.js'; + +const INDEX_HTML = readFileSync(new URL('../index.html', import.meta.url), 'utf8'); +const TOKEN = 'i'.repeat(43); +let dom = null; + +function response(status, body) { + return { ok: status >= 200 && status < 300, status, json: async () => body }; +} + +afterEach(() => { + dom?.window.close(); + dom = null; +}); + +test('Instructor exercise console discovers staged summaries without persisting its capability', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async (url, options) => { + requests.push([url, options]); + return response(200, { + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: null, + availableCaseStudies: [ + { id: 'staged-a', name: 'Staged Case A', description: 'First staged simulation.', supportedModes: ['full'] }, + { id: 'staged-b', name: 'Staged Case B', description: 'Second staged simulation.', supportedModes: ['intake'] } + ] + }); + } + }); + + assert.equal(await controller.connectInstructor(TOKEN), true); + assert.equal(requests.length, 1); + assert.equal(requests[0][0], INSTRUCTOR_EXERCISE_ENDPOINT); + assert.equal(requests[0][1].method, 'GET'); + assert.equal(requests[0][1].headers.Authorization, 'Bearer ' + TOKEN); + assert.equal(dom.window.document.getElementById('instructorExerciseConsole').hidden, false); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, '2 staged Case Studies available'); + assert.equal(dom.window.document.querySelectorAll('#instructorExerciseAvailableList li').length, 2); + assert.equal(dom.window.document.getElementById('instructorExerciseAvailableList').textContent.includes('Staged Case A'), true); + assert.equal(dom.window.localStorage.length, 0); + assert.equal(dom.window.document.body.textContent.includes(TOKEN), false); + assert.equal('capability' in controller.getState(), false); + + controller.disconnect(); + assert.equal(dom.window.document.getElementById('instructorExerciseConsole').hidden, true); + assert.deepEqual(controller.getState().availableCaseStudies, []); + controller.destroy(); +}); + +test('Instructor exercise console renders an existing draft without exposing the full simulation through public state', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async () => response(200, { + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: { id: 'exercise-1', status: 'draft', stagePhase: 'work', exerciseRevision: 1, currentStageId: null }, + caseStudy: { + id: 'staged-a', + name: 'Staged Case A', + description: 'Instructor simulation.', + supportedModes: ['full'], + simulation: { + stages: [{ id: 'stage-1', title: 'Hidden until started', studentObjective: 'Objective' }], + instructorContent: [{ body: 'Instructor-only content remains in memory.' }] + } + }, + availableCaseStudies: [{ id: 'staged-a', name: 'Staged Case A', description: 'Instructor simulation.', supportedModes: ['full'] }] + }) + }); + + assert.equal(await controller.connectInstructor(TOKEN), true); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Draft'); + assert.equal(dom.window.document.getElementById('instructorExerciseCaseName').textContent, 'Staged Case A'); + assert.equal(dom.window.document.getElementById('instructorExerciseDetail').textContent, 'Draft exercise — no stage has started.'); + assert.deepEqual(controller.getState().caseStudy, { + id: 'staged-a', + name: 'Staged Case A', + description: 'Instructor simulation.', + supportedModes: ['full'] + }); + assert.equal(JSON.stringify(controller.getState()).includes('Instructor-only content'), false); + controller.destroy(); +}); + +test('Instructor exercise console keeps a connected shell on transient read failure and can refresh', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + let attempt = 0; + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async () => { + attempt += 1; + if (attempt === 1) return response(500, { error: 'Nope' }); + return response(200, { class: { id: 'class-1', title: 'PSDM Class' }, exercise: null, availableCaseStudies: [] }); + } + }); + + assert.equal(await controller.connectInstructor(TOKEN), false); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Could not load the class exercise.'); + assert.equal(dom.window.document.getElementById('instructorExerciseConsole').hidden, false); + assert.equal(await controller.refresh(), true); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, '0 staged Case Studies available'); + controller.destroy(); +}); From 15bc1528df08a6047a4e774b13049633ee11054d Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:51:09 -0400 Subject: [PATCH 065/206] Document Instructor exercise client boundary --- src/AGENTS.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/AGENTS.md b/src/AGENTS.md index 2505e94..969fd1e 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -17,6 +17,8 @@ Keep feature modules independently initialisable, own only their feature-specifi ## Protected Classroom resources +`classroomExerciseInstructor.js` owns the Instructor staged-exercise console client. It receives the Instructor capability only through the class lifecycle, keeps that credential and exercise payload in memory, and reads `/api/classes/exercise`. Exercise state, staged definitions, and capabilities must never enter Intake persistence, summaries, exports, URLs, or public manifests. Tranche 6 lifecycle mutations must remain optimistic-revision protected. + `classroomCaseStudies.js` owns the authorized in-memory protected Case Study catalog/payload client. It receives active capabilities only through Student/Instructor lifecycle callbacks, keeps no localStorage/sessionStorage record, and clears catalog/context when the class role disconnects. Its public state must never expose a bearer capability. `templatesDrawer.js` may combine public Standard Template metadata with the current authorized in-memory Case Study catalog, but must fetch a protected payload only on demand and project it through `projectTemplateState()`. Standalone must work with no protected provider or classroom backend. From d20a303a54ada7388f49ed9342cd07b6c3f86049 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:51:11 -0400 Subject: [PATCH 066/206] Catalog Instructor exercise console anchor --- docs/commenting-guide.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/commenting-guide.md b/docs/commenting-guide.md index 0b11968..233b170 100644 --- a/docs/commenting-guide.md +++ b/docs/commenting-guide.md @@ -49,6 +49,7 @@ Anchors keep HTML and documentation in sync. Preserve existing tokens and regist | `[feature:student-class-entry]` | `index.html`, between role chooser and Instructor shell | Student class join/reconnect gate shown before the assigned Intake is available. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentClassEntryShell`, `studentClassJoinForm`, class/assignment/name inputs, retry controls; all local-only/summary-excluded. | | `[feature:student-experience-notice]` | `index.html`, top of the connected Student Intake | Class/workspace/participant context plus explicit Leave class. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentExperienceNotice`, `studentClassContextTitle`, `studentClassWorkspace`, `studentClassIdentity`, `studentClassLeaveBtn`; no persisted Intake fields. | | `[feature:instructor-shell]` | `index.html`, between header and Intake wrap | Instructor class entry/resume and connected class workspace rail. | `src/classroomInstructor.js`, `src/experienceRoleController.js` | `instructorExperienceShell`, `instructorClassForm`, `instructorClassDashboard`, roster search/filter/list, Leave class; all class/session controls are local-only and summary-excluded. | +| `[feature:instructor-exercise-console]` | `index.html`, inside the connected Instructor dashboard | Read-only Tranche 6 foundation for current staged exercise state and server-filtered staged Case Study availability. | `src/classroomExerciseInstructor.js` | `instructorExerciseConsole`, status/current case detail, staged availability list, Refresh; all exercise context is memory-only and summary-excluded. | | `[feature:instructor-observer]` | `index.html`, top of the temporarily revealed Intake wrap | Live read-only context for the selected Student/team workspace, including revision and recent activity. Dynamic coaching panels are appended beside registered targets inside this same observed Intake; they do not introduce a new structural HTML anchor. | `src/classroomInstructor.js`, `src/classroomCoaching.js`, `src/coachableFields.js` | `instructorObservationNotice`, observer metadata, plus `.classroom-coaching--instructor` / `.classroom-coaching--student` dynamic local-only panels. Observed Student state and coaching UI are excluded from Intake persistence/summary capture. | | `[feature:intake-mode-selector]` | `index.html` header title | Wraps the accessible intake mode selector used to choose between General, IT, Pharma, and Major Incident Management lenses before capture. | `src/intakeModes.js`, `src/intakeModeController.js` | `intakeModeSelect`; options use stable mode values and persist under `meta.intakeMode` in `kt-intake-full-v2`. | | `[feature:major-incident-role-legend]` | `index.html`, below the header | Provides the Major Incident-only four-phase legend mount; phase/primary-role badges decorate mapped workflow headings (including Decision and Risk gates) and every KT row, then hide without unmounting data when the mode changes. Icons and colour swatches are decorative because visible and accessible role text carries their meaning. | `src/majorIncidentRoles.js`, `src/intakeModes.js` | `majorIncidentRoleLegend`, `majorIncidentRoleLegendItems`; role presentation is not persisted, while the decorated controls retain their normal field contracts. | From 60154826f824939bbc1740b679d374f98472fb5d Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:51:47 -0400 Subject: [PATCH 067/206] Document Tranche 6A Instructor console checkpoint --- docs/classroom-workstream.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md index 10aea45..cbbf2c9 100644 --- a/docs/classroom-workstream.md +++ b/docs/classroom-workstream.md @@ -447,7 +447,15 @@ Continue #295 on draft PR #307 from the current branch head. - `d66eeeace6f9e41dade765b6b0331a69a40ebbb0` / `261b28d488538964ccd73132ed568d73628f6411` prove allowed work writes, 423 frozen-write rejection with zero snapshot/revision mutation, frozen GET availability, Standalone isolation, freeze persistence through Pause/Resume, and explicit unfreeze restoring writes. - stable temporary-lock contract: HTTP **423 Locked**, `code: classroom-editing-locked`. - validation on `261b28d...`: repository quality **274 tests / 273 pass / 0 fail / 1 intentional skip**; required Browser E2E **26 passed / 8 intentional skips / 0 failed**; CI, CodeQL, Dependency Review, and Template Manifest Guard green. -- Exact next action: implement **Tranche 6 only — Instructor exercise console** inside the existing live Instructor dashboard. Add staged Case Study selection/draft creation, stage/facilitation display, accessible lifecycle/release/debrief/freeze/advance/complete controls, readiness/progress, conflict refresh, and existing observer/coaching navigation. Keep Student staged-case panel and production official-case staging deferred. +- Tranche 6A — Instructor exercise console foundation: + - implementation spans `2624f1e...` through `d20a303...`; + - `GET /api/classes/exercise` now returns `availableCaseStudies` containing **only** staged Case Study summaries; full `simulation` definitions and protected source `state` are not used for discovery; + - new `src/classroomExerciseInstructor.js` owns an in-memory Instructor exercise client and read-only console foundation; + - the existing Instructor dashboard now includes a `[feature:instructor-exercise-console]` section for current exercise status, staged availability, and explicit refresh; + - Instructor capability and exercise context remain outside Intake persistence, summaries, exports, URLs, and public manifests; + - no exercise mutations were added in 6A; selection/create and Start/Pause/Debrief controls remain intentionally deferred to the next slice; + - focused API/client tests cover staged-only discovery, absence of protected simulation/source material in the discovery list, capability non-persistence, draft rendering, disconnect cleanup, and transient refresh recovery. +- Exact next action: continue **Tranche 6B — staged Case Study selection and draft creation** inside the existing console. Add an accessible staged-case selector/create action, preserve one-open-exercise and optimistic revision semantics, render the resulting draft, and add focused DOM/browser coverage. Do not add Start/Pause/Resume/debrief lifecycle controls until the following slice; keep Student staged-case UI and production official-case staging deferred. ## Approved Classroom follow-on direction From a144a646c98fefc99286de3fd900dcd0ff86dd69 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:51:52 -0400 Subject: [PATCH 068/206] Document Tranche 6A staged simulation checkpoint --- docs/classroom-staged-simulation.md | 40 ++++++++++++++++++----------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/docs/classroom-staged-simulation.md b/docs/classroom-staged-simulation.md index 672db31..7a7251f 100644 --- a/docs/classroom-staged-simulation.md +++ b/docs/classroom-staged-simulation.md @@ -740,20 +740,30 @@ Validation: - required Browser E2E: **26 passed / 8 intentional project-scoped skips / 0 failed**; - CI, CodeQL, Dependency Review, and Template Manifest Guard: green. +## Implementation checkpoint — Tranche 6A Instructor console foundation + +The first Instructor-console slice is implemented without adding lifecycle mutations yet. + +Foundation contract: +- `GET /api/classes/exercise` returns an Instructor-authorized `availableCaseStudies` list containing only protected Case Studies with explicit staged `simulation` definitions; +- discovery items are summaries only: id/name/description/supported modes, with no `simulation`, protected source `state`, future Student content, or Instructor facilitation content; +- `src/classroomExerciseInstructor.js` owns the in-memory Instructor exercise client and reads the current exercise/discovery state on class connect; +- the controller never exposes the raw Instructor capability through public state and persists no exercise/capability data; +- the existing Instructor dashboard owns the new `[feature:instructor-exercise-console]` shell; Tranche 6A exposes status/current case/staged availability plus Refresh only; +- disconnect clears the console immediately; transient read errors keep the shell recoverable through explicit refresh. + +Implementation checkpoint spans `2624f1e...` through `d20a303...`. + ## Exact next implementation action -Implement **Tranche 6 only — Instructor exercise console** on the now-enforced server contract. - -Required product slice: -- integrate staged exercise controls into the existing live Instructor dashboard rather than creating a second classroom shell; -- list only protected Case Studies that carry an explicit staged `simulation` definition as startable simulations; -- allow Instructor selection/create of a draft and show stage title/objective plus Instructor-only facilitation content; -- provide accessible Start, Pause, Resume, optional-content Release, Begin debrief, Freeze/Unfreeze, Advance, and Complete controls with optimistic revision refresh/conflict handling; -- show workspace/team readiness and current stage progress beside the existing roster/workspace navigation; -- keep existing read-only observer/coaching one click away; -- clearly distinguish exercise pause from Student editing freeze; -- do not expose future Student content in Instructor-to-Student DOM/state accidentally; Instructor-only material may be shown only in Instructor experience; -- retain same-device class resume: exercise state is server state and must be re-fetched, never persisted into Intake; -- extend deterministic Instructor feature/browser fixtures only enough to prove the console lifecycle before cutting over Student staged-case UI. - -Stop before the Student stage-aware case reference panel and before production official Case Study staging. +Continue **Tranche 6B — staged Case Study selection and draft creation**. + +Required next slice: +- add an accessible staged-case selector using the server-filtered discovery list; +- create/reuse the selected draft through the existing Instructor POST contract; +- render the created draft and its safe summary inside the existing console; +- preserve one-open-exercise behavior and fail closed on conflicting existing exercise state; +- keep exercise state server-owned and memory-only; +- add focused DOM/API/browser coverage for selection, create, replay, and conflict refresh. + +Do **not** add Start/Pause/Resume/release/debrief/freeze/advance/complete controls in 6B; those follow after the draft-creation slice. Keep Student staged-case UI and production official Case Study staging deferred. From 6a6942653a6b81d5947a92733d3931ee733177bb Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:57:24 -0400 Subject: [PATCH 069/206] Add Instructor exercise draft creation --- src/classroomExerciseInstructor.js | 195 +++++++++++++++++++++++++---- 1 file changed, 172 insertions(+), 23 deletions(-) diff --git a/src/classroomExerciseInstructor.js b/src/classroomExerciseInstructor.js index c3909e7..6e10863 100644 --- a/src/classroomExerciseInstructor.js +++ b/src/classroomExerciseInstructor.js @@ -1,11 +1,11 @@ /** * @module classroomExerciseInstructor - * @summary Owns Instructor staged-exercise discovery and the read-only console foundation. + * @summary Owns Instructor staged-exercise discovery, draft setup, and console state. * @description * Receives the active Instructor capability from the class lifecycle, keeps it - * in memory only, and reads /api/classes/exercise. Tranche 6A deliberately - * exposes no exercise mutation actions; later Tranche 6 slices extend this - * controller with revision-safe lifecycle controls. + * in memory only, and reads/writes the Instructor-authorized exercise endpoint. + * Tranche 6B adds staged Case Study selection plus draft create/reuse only; + * lifecycle actions remain deferred to later Tranche 6 slices. */ export const INSTRUCTOR_EXERCISE_ENDPOINT = '/api/classes/exercise'; @@ -46,13 +46,24 @@ export function createInstructorExerciseConsoleController({ let destroyed = false; let loading = false; let lastError = ''; + let lastNotice = ''; let classroom = null; let exercise = null; let caseStudy = null; let availableCaseStudies = []; + let selectedCaseStudyId = ''; const element = id => documentRef?.getElementById?.(id) || null; + const currentPhaseLabel = () => { + if (!exercise) return ''; + if (exercise.status === 'draft') return 'Draft'; + if (exercise.status === 'paused') return 'Paused'; + if (exercise.stagePhase === 'debrief') return 'Debrief'; + if (exercise.status === 'completed') return 'Completed'; + return 'In progress'; + }; + const renderAvailable = () => { const list = element('instructorExerciseAvailableList'); if (!list) return; @@ -78,6 +89,45 @@ export function createInstructorExerciseConsoleController({ }); }; + const renderSetup = () => { + const select = element('instructorExerciseCaseSelect'); + const createButton = element('instructorExerciseCreateBtn'); + const help = element('instructorExerciseSetupHelp'); + if (!select) return; + + const requestedSelection = exercise?.caseStudyId || selectedCaseStudyId; + select.replaceChildren(); + + const placeholder = documentRef.createElement('option'); + placeholder.value = ''; + placeholder.textContent = availableCaseStudies.length + ? 'Select a staged Case Study' + : 'No staged Case Studies available'; + select.append(placeholder); + + availableCaseStudies.forEach(item => { + const option = documentRef.createElement('option'); + option.value = item.id; + option.textContent = item.name; + select.append(option); + }); + + const selectionExists = availableCaseStudies.some(item => item.id === requestedSelection); + selectedCaseStudyId = selectionExists ? requestedSelection : ''; + select.value = selectedCaseStudyId; + select.disabled = loading || Boolean(exercise) || availableCaseStudies.length === 0; + + if (createButton) { + createButton.disabled = loading || Boolean(exercise) || !selectedCaseStudyId; + createButton.textContent = loading ? 'Working…' : 'Create draft'; + } + if (help) { + help.textContent = exercise + ? 'This class already has an open exercise. Lifecycle controls will be added in the next console slices.' + : 'Choose an explicitly staged Case Study. Creating a draft does not release anything to Students.'; + } + }; + const render = () => { const panel = element('instructorExerciseConsole'); const status = element('instructorExerciseStatus'); @@ -95,21 +145,14 @@ export function createInstructorExerciseConsoleController({ } else if (lastError) { if (status) status.textContent = lastError; } else if (exercise) { - const phase = exercise.status === 'draft' - ? 'Draft' - : exercise.status === 'paused' - ? 'Paused' - : exercise.stagePhase === 'debrief' - ? 'Debrief' - : exercise.status === 'completed' - ? 'Completed' - : 'In progress'; - if (status) status.textContent = phase; + const phase = currentPhaseLabel(); + if (status) status.textContent = lastNotice ? `${phase} · ${lastNotice}` : phase; } else { const count = availableCaseStudies.length; - if (status) status.textContent = count === 1 + const availability = count === 1 ? '1 staged Case Study available' : `${count} staged Case Studies available`; + if (status) status.textContent = lastNotice ? `${availability} · ${lastNotice}` : availability; } if (exercise && caseStudy) { @@ -119,23 +162,41 @@ export function createInstructorExerciseConsoleController({ : null; if (detail) detail.textContent = stage ? `${stage.title} · ${stage.studentObjective}` - : 'Draft exercise — no stage has started.'; + : 'Draft exercise — no stage has started and nothing has been released to Students.'; } else { if (caseName) caseName.textContent = 'No staged exercise selected'; if (detail) detail.textContent = availableCaseStudies.length - ? 'Staged Case Studies are available. Exercise setup will be enabled in the next console slice.' + ? 'Choose a staged Case Study below to create the class exercise draft.' : 'The console is connected; no staged Case Study definition is currently available.'; } + renderSetup(); renderAvailable(); }; const resetState = () => { loading = false; lastError = ''; + lastNotice = ''; classroom = null; exercise = null; caseStudy = null; availableCaseStudies = []; + selectedCaseStudyId = ''; + }; + + const applyPayload = body => { + classroom = body?.class && typeof body.class === 'object' ? body.class : null; + exercise = body?.exercise && typeof body.exercise === 'object' ? body.exercise : null; + caseStudy = body?.caseStudy && typeof body.caseStudy === 'object' ? body.caseStudy : null; + if (Array.isArray(body?.availableCaseStudies)) { + availableCaseStudies = body.availableCaseStudies + .map(sanitizeCaseStudySummary) + .filter(Boolean); + } + if (exercise?.caseStudyId) selectedCaseStudyId = exercise.caseStudyId; + if (!exercise && selectedCaseStudyId && !availableCaseStudies.some(item => item.id === selectedCaseStudyId)) { + selectedCaseStudyId = ''; + } }; const refresh = async () => { @@ -144,6 +205,7 @@ export function createInstructorExerciseConsoleController({ const localEpoch = epoch; loading = true; lastError = ''; + lastNotice = ''; render(); try { const response = await fetchImpl(INSTRUCTOR_EXERCISE_ENDPOINT, { @@ -158,12 +220,7 @@ export function createInstructorExerciseConsoleController({ : 'Could not load the class exercise.'; return false; } - classroom = body.class && typeof body.class === 'object' ? body.class : null; - exercise = body.exercise && typeof body.exercise === 'object' ? body.exercise : null; - caseStudy = body.caseStudy && typeof body.caseStudy === 'object' ? body.caseStudy : null; - availableCaseStudies = Array.isArray(body.availableCaseStudies) - ? body.availableCaseStudies.map(sanitizeCaseStudySummary).filter(Boolean) - : []; + applyPayload(body); return true; } catch { if (destroyed || localEpoch !== epoch) return false; @@ -177,6 +234,80 @@ export function createInstructorExerciseConsoleController({ } }; + const createDraft = async (caseStudyId = selectedCaseStudyId) => { + if ( + destroyed + || !capability + || exercise + || loading + || typeof fetchImpl !== 'function' + ) return false; + + const requestedId = typeof caseStudyId === 'string' ? caseStudyId.trim() : ''; + if (!availableCaseStudies.some(item => item.id === requestedId)) { + lastError = 'Choose a staged Case Study.'; + lastNotice = ''; + render(); + return false; + } + + const localCapability = capability; + const localEpoch = epoch; + selectedCaseStudyId = requestedId; + loading = true; + lastError = ''; + lastNotice = ''; + render(); + + try { + const response = await fetchImpl(INSTRUCTOR_EXERCISE_ENDPOINT, { + method: 'POST', + headers: { + Authorization: `Bearer ${localCapability}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ caseStudyId: requestedId }) + }); + const body = await response.json().catch(() => ({})); + if (destroyed || localEpoch !== epoch || capability !== localCapability) return false; + + if (response.status === 409) { + loading = false; + const reloaded = await refresh(); + if (reloaded && !destroyed && localEpoch === epoch) { + lastNotice = 'current state reloaded'; + render(); + } else if (!lastError) { + lastError = typeof body.error === 'string' ? body.error : 'Exercise changed. Refresh and retry.'; + render(); + } + return false; + } + + if (!response.ok) { + lastError = response.status === 401 || response.status === 404 + ? 'Exercise access is no longer available.' + : typeof body.error === 'string' && body.error + ? body.error + : 'Could not create the exercise draft.'; + return false; + } + + applyPayload(body); + lastNotice = body.created === false ? 'existing draft reused' : 'draft created'; + return true; + } catch { + if (destroyed || localEpoch !== epoch) return false; + lastError = 'Could not create the exercise draft.'; + return false; + } finally { + if (!destroyed && localEpoch === epoch) { + loading = false; + render(); + } + } + }; + const connectInstructor = async token => { if (!validCapability(token) || destroyed) return false; epoch += 1; @@ -194,25 +325,43 @@ export function createInstructorExerciseConsoleController({ }; const handleRefresh = () => { void refresh(); }; + const handleSelect = event => { + selectedCaseStudyId = typeof event?.target?.value === 'string' ? event.target.value : ''; + lastError = ''; + lastNotice = ''; + render(); + }; + const handleSetupSubmit = event => { + event.preventDefault(); + void createDraft(); + }; + element('instructorExerciseRefreshBtn')?.addEventListener('click', handleRefresh); + element('instructorExerciseCaseSelect')?.addEventListener('change', handleSelect); + element('instructorExerciseSetupForm')?.addEventListener('submit', handleSetupSubmit); render(); return { connectInstructor, disconnect, refresh, + createDraft, getState: () => ({ connected: Boolean(capability), loading, lastError, + lastNotice, classroom, exercise, + selectedCaseStudyId, caseStudy: caseStudy ? sanitizeCaseStudySummary(caseStudy) : null, availableCaseStudies: availableCaseStudies.map(item => ({ ...item })) }), destroy: () => { if (destroyed) return; element('instructorExerciseRefreshBtn')?.removeEventListener('click', handleRefresh); + element('instructorExerciseCaseSelect')?.removeEventListener('change', handleSelect); + element('instructorExerciseSetupForm')?.removeEventListener('submit', handleSetupSubmit); disconnect(); destroyed = true; } From 1324ff99f53d91b845c267a22913dc2e854abbfc Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:57:27 -0400 Subject: [PATCH 070/206] Add staged exercise draft selector --- index.html | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/index.html b/index.html index 392780b..0acbcdc 100644 --- a/index.html +++ b/index.html @@ -423,6 +423,16 @@

Class

No staged exercise selected

Staged Case Studies available to this class will appear here.

+
+ +
+ + +
+ Choose an explicitly staged Case Study. Creating a draft does not release anything to Students. +
Available staged Case Studies
    From a457864ea50e867d752fe00b597dd062a2f4c1a0 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:57:30 -0400 Subject: [PATCH 071/206] Style staged exercise draft setup --- styles.css | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/styles.css b/styles.css index 87918d7..8207485 100644 --- a/styles.css +++ b/styles.css @@ -3234,6 +3234,12 @@ body[data-experience-role="instructor"].instructor-class-connected .wrap{max-wid .instructor-exercise-console__summary{margin-top:10px;padding:9px 10px;border-radius:11px;background:var(--panel-muted);} .instructor-exercise-console__summary>strong{display:block;color:var(--ink-strong);font-size:12px;} .instructor-exercise-console__summary p{margin:4px 0 0;color:var(--muted);font-size:10px;line-height:1.45;} +.instructor-exercise-console__setup{margin-top:10px;} +.instructor-exercise-console__setup>label{display:block;margin-bottom:5px;color:var(--ink-strong);font-size:10px;font-weight:750;} +.instructor-exercise-console__setup-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px;} +.instructor-exercise-console__setup-row select{min-width:0;font-size:10px;} +.instructor-exercise-console__setup-row .btn-secondary{padding:7px 9px;font-size:10px;white-space:nowrap;} +.instructor-exercise-console__setup small{display:block;margin-top:5px;color:var(--muted);font-size:9px;line-height:1.4;} .instructor-exercise-console__available{margin-top:9px;} .instructor-exercise-console__available>span{display:block;color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.05em;text-transform:uppercase;} .instructor-exercise-console__available ul{display:flex;flex-direction:column;gap:5px;max-height:92px;overflow:auto;margin:6px 0 0;padding:0;list-style:none;} From e4deeaf3f9e0322d8770eb30709d50e618037b19 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:58:00 -0400 Subject: [PATCH 072/206] Test Instructor exercise draft creation --- ...assroomExerciseInstructor.feature.test.mjs | 170 ++++++++++++++---- 1 file changed, 135 insertions(+), 35 deletions(-) diff --git a/tests/classroomExerciseInstructor.feature.test.mjs b/tests/classroomExerciseInstructor.feature.test.mjs index 88dee08..4a0ae1f 100644 --- a/tests/classroomExerciseInstructor.feature.test.mjs +++ b/tests/classroomExerciseInstructor.feature.test.mjs @@ -1,5 +1,5 @@ /** - * Feature coverage for the Tranche 6A Instructor staged-exercise console foundation. + * Feature coverage for the Tranche 6 Instructor staged-exercise console. */ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; @@ -19,26 +19,55 @@ function response(status, body) { return { ok: status >= 200 && status < 300, status, json: async () => body }; } +function discoveryBody() { + return { + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: null, + availableCaseStudies: [ + { id: 'staged-a', name: 'Staged Case A', description: 'First staged simulation.', supportedModes: ['full'] }, + { id: 'staged-b', name: 'Staged Case B', description: 'Second staged simulation.', supportedModes: ['intake'] } + ] + }; +} + +function draftBody({ created = true, caseStudyId = 'staged-a', name = 'Staged Case A' } = {}) { + return { + class: { id: 'class-1', title: 'PSDM Class' }, + exercise: { + id: 'exercise-1', + caseStudyId, + status: 'draft', + stagePhase: 'work', + exerciseRevision: 1, + currentStageId: null + }, + caseStudy: { + id: caseStudyId, + name, + description: 'Instructor simulation.', + supportedModes: ['full'], + simulation: { + stages: [{ id: 'stage-1', title: 'Hidden until started', studentObjective: 'Objective' }], + instructorContent: [{ body: 'Instructor-only content remains in memory.' }] + } + }, + created + }; +} + afterEach(() => { dom?.window.close(); dom = null; }); -test('Instructor exercise console discovers staged summaries without persisting its capability', async () => { +test('Instructor exercise console discovers staged summaries and enables explicit selection without persisting capability', async () => { dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); const requests = []; const controller = createInstructorExerciseConsoleController({ documentRef: dom.window.document, fetchImpl: async (url, options) => { requests.push([url, options]); - return response(200, { - class: { id: 'class-1', title: 'PSDM Class' }, - exercise: null, - availableCaseStudies: [ - { id: 'staged-a', name: 'Staged Case A', description: 'First staged simulation.', supportedModes: ['full'] }, - { id: 'staged-b', name: 'Staged Case B', description: 'Second staged simulation.', supportedModes: ['intake'] } - ] - }); + return response(200, discoveryBody()); } }); @@ -50,7 +79,17 @@ test('Instructor exercise console discovers staged summaries without persisting assert.equal(dom.window.document.getElementById('instructorExerciseConsole').hidden, false); assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, '2 staged Case Studies available'); assert.equal(dom.window.document.querySelectorAll('#instructorExerciseAvailableList li').length, 2); - assert.equal(dom.window.document.getElementById('instructorExerciseAvailableList').textContent.includes('Staged Case A'), true); + + const select = dom.window.document.getElementById('instructorExerciseCaseSelect'); + const createButton = dom.window.document.getElementById('instructorExerciseCreateBtn'); + assert.equal(select.options.length, 3); + assert.equal(createButton.disabled, true); + + select.value = 'staged-a'; + select.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + assert.equal(controller.getState().selectedCaseStudyId, 'staged-a'); + assert.equal(createButton.disabled, false); + assert.equal(dom.window.localStorage.length, 0); assert.equal(dom.window.document.body.textContent.includes(TOKEN), false); assert.equal('capability' in controller.getState(), false); @@ -61,38 +100,99 @@ test('Instructor exercise console discovers staged summaries without persisting controller.destroy(); }); -test('Instructor exercise console renders an existing draft without exposing the full simulation through public state', async () => { +test('Instructor creates a selected staged draft without releasing Student content or exposing full simulation through public state', async () => { dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; const controller = createInstructorExerciseConsoleController({ documentRef: dom.window.document, - fetchImpl: async () => response(200, { - class: { id: 'class-1', title: 'PSDM Class' }, - exercise: { id: 'exercise-1', status: 'draft', stagePhase: 'work', exerciseRevision: 1, currentStageId: null }, - caseStudy: { - id: 'staged-a', - name: 'Staged Case A', - description: 'Instructor simulation.', - supportedModes: ['full'], - simulation: { - stages: [{ id: 'stage-1', title: 'Hidden until started', studentObjective: 'Objective' }], - instructorContent: [{ body: 'Instructor-only content remains in memory.' }] - } - }, - availableCaseStudies: [{ id: 'staged-a', name: 'Staged Case A', description: 'Instructor simulation.', supportedModes: ['full'] }] - }) + fetchImpl: async (url, options) => { + requests.push([url, options]); + if (options.method === 'GET') return response(200, discoveryBody()); + return response(201, draftBody({ created: true })); + } }); assert.equal(await controller.connectInstructor(TOKEN), true); - assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Draft'); + const select = dom.window.document.getElementById('instructorExerciseCaseSelect'); + select.value = 'staged-a'; + select.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + + assert.equal(await controller.createDraft(), true); + assert.equal(requests.length, 2); + assert.equal(requests[1][1].method, 'POST'); + assert.equal(requests[1][1].headers.Authorization, 'Bearer ' + TOKEN); + assert.equal(requests[1][1].headers['Content-Type'], 'application/json'); + assert.deepEqual(JSON.parse(requests[1][1].body), { caseStudyId: 'staged-a' }); + + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Draft · draft created'); assert.equal(dom.window.document.getElementById('instructorExerciseCaseName').textContent, 'Staged Case A'); - assert.equal(dom.window.document.getElementById('instructorExerciseDetail').textContent, 'Draft exercise — no stage has started.'); - assert.deepEqual(controller.getState().caseStudy, { - id: 'staged-a', - name: 'Staged Case A', - description: 'Instructor simulation.', - supportedModes: ['full'] - }); + assert.equal( + dom.window.document.getElementById('instructorExerciseDetail').textContent, + 'Draft exercise — no stage has started and nothing has been released to Students.' + ); + assert.equal(dom.window.document.getElementById('instructorExerciseCaseSelect').disabled, true); + assert.equal(dom.window.document.getElementById('instructorExerciseCreateBtn').disabled, true); + assert.equal(controller.getState().exercise.exerciseRevision, 1); assert.equal(JSON.stringify(controller.getState()).includes('Instructor-only content'), false); + assert.equal(dom.window.localStorage.length, 0); + controller.destroy(); +}); + +test('Instructor accepts an idempotent existing-draft response as the represented current exercise', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async (_url, options) => options.method === 'GET' + ? response(200, discoveryBody()) + : response(200, draftBody({ created: false })) + }); + + await controller.connectInstructor(TOKEN); + const select = dom.window.document.getElementById('instructorExerciseCaseSelect'); + select.value = 'staged-a'; + select.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + + assert.equal(await controller.createDraft(), true); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Draft · existing draft reused'); + assert.equal(controller.getState().exercise.caseStudyId, 'staged-a'); + controller.destroy(); +}); + +test('Instructor draft conflict reloads authoritative current exercise instead of retrying a stale create', async () => { + dom = new JSDOM(INDEX_HTML, { url: 'https://intake.test/' }); + const requests = []; + let getCount = 0; + const controller = createInstructorExerciseConsoleController({ + documentRef: dom.window.document, + fetchImpl: async (_url, options) => { + requests.push(options.method); + if (options.method === 'POST') { + return response(409, { + error: 'Another exercise is already open for this class.', + exercise: { id: 'exercise-race', caseStudyId: 'staged-b', exerciseRevision: 2 } + }); + } + getCount += 1; + if (getCount === 1) return response(200, discoveryBody()); + return response(200, { + ...discoveryBody(), + ...draftBody({ created: false, caseStudyId: 'staged-b', name: 'Staged Case B' }), + availableCaseStudies: discoveryBody().availableCaseStudies + }); + } + }); + + await controller.connectInstructor(TOKEN); + const select = dom.window.document.getElementById('instructorExerciseCaseSelect'); + select.value = 'staged-a'; + select.dispatchEvent(new dom.window.Event('change', { bubbles: true })); + + assert.equal(await controller.createDraft(), false); + assert.deepEqual(requests, ['GET', 'POST', 'GET']); + assert.equal(controller.getState().exercise.caseStudyId, 'staged-b'); + assert.equal(controller.getState().selectedCaseStudyId, 'staged-b'); + assert.equal(dom.window.document.getElementById('instructorExerciseCaseName').textContent, 'Staged Case B'); + assert.equal(dom.window.document.getElementById('instructorExerciseStatus').textContent, 'Draft · current state reloaded'); controller.destroy(); }); From 57ecf55fb3c829abaaaf48f03a45c41bcb6d3695 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:58:04 -0400 Subject: [PATCH 073/206] Harden integrated classroom browser timeout --- tests/e2e/classroom-live-integration.spec.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/classroom-live-integration.spec.mjs b/tests/e2e/classroom-live-integration.spec.mjs index 34896c9..29f22e3 100644 --- a/tests/e2e/classroom-live-integration.spec.mjs +++ b/tests/e2e/classroom-live-integration.spec.mjs @@ -46,6 +46,7 @@ async function createTeam(page, label) { } test('live class integrates Instructor roster, team sync, isolation, coaching, reassignment, and unassign', async ({ browser }, testInfo) => { + test.setTimeout(45_000); test.skip(testInfo.project.name === 'chromium-mobile', 'Integrated four-browser classroom acceptance is covered once on desktop; mobile Student and Instructor paths have dedicated coverage.'); const contextOptions = { baseURL: testInfo.project.use.baseURL }; From c3440eb7c032c6feee98e79d534f139f4ae9bc83 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:58:31 -0400 Subject: [PATCH 074/206] Add staged exercise browser fixture --- scripts/serve-browser-tests.mjs | 137 ++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/scripts/serve-browser-tests.mjs b/scripts/serve-browser-tests.mjs index 80121c5..7dbfb5e 100644 --- a/scripts/serve-browser-tests.mjs +++ b/scripts/serve-browser-tests.mjs @@ -48,6 +48,44 @@ const STUDENT_LIVE_CLASS = Object.freeze({ title: 'Browser Student Live Classroom', expiresAt: CLASSROOM_EXPIRY }); +const BROWSER_STAGED_CASE = Object.freeze({ + id: 'browser-staged-simulation', + name: 'Browser Staged Simulation', + description: 'Synthetic staged Case Study used only by deterministic browser acceptance.', + supportedModes: ['full'], + simulation: { + version: 1, + studentContent: [ + { + id: 'browser-brief-1', + kind: 'narrative', + title: 'Initial browser briefing', + body: 'Synthetic Student-safe browser briefing.' + } + ], + instructorContent: [ + { + id: 'browser-teach-1', + kind: 'facilitation', + title: 'Browser facilitation note', + body: 'Synthetic Instructor-only browser facilitation.' + } + ], + stages: [ + { + id: 'browser-stage-1', + title: 'Clarify the browser case', + studentObjective: 'Capture the initial situation in Intake.', + initialReleaseIds: ['browser-brief-1'], + optionalReleaseIds: [], + intakeTargetIds: ['problem.one-line'], + suggestedMinutes: 5, + instructorContentIds: ['browser-teach-1'], + defaultDebriefEditPolicy: 'frozen' + } + ] + } +}); let liveClassState = null; let liveInstructorWorkspaces = []; let liveInstructorParticipants = []; @@ -61,6 +99,7 @@ const studentLiveSessions = new Map(); const studentLiveAccessContexts = new Map(); const classroomWorkspaces = new Map(); const classroomCoachingFeedback = new Map(); +const classroomExercises = new Map(); const CONTENT_TYPES = Object.freeze({ '.css': 'text/css; charset=utf-8', @@ -606,6 +645,7 @@ async function handleClassroomApi(request, response, url) { integratedInstructorWorkspaces = []; integratedInstructorWorkspaceStates.clear(); integratedInstructorParticipants = []; + classroomExercises.delete(INTEGRATED_INSTRUCTOR_TOKEN); sendJson(response, 201, { class: integratedInstructorClass(), instructorToken: INTEGRATED_INSTRUCTOR_TOKEN, @@ -623,6 +663,7 @@ async function handleClassroomApi(request, response, url) { }; liveInstructorWorkspaces = []; liveInstructorWorkspaceStates.clear(); + classroomExercises.delete(LIVE_INSTRUCTOR_TOKEN); liveInstructorParticipants = [{ id: LIVE_PARTICIPANT_ID, displayName: 'Waiting Student', @@ -640,6 +681,102 @@ async function handleClassroomApi(request, response, url) { return true; } + if (url.pathname === '/api/classes/exercise') { + const instructorToken = bearerToken(request); + if (!activeInstructorCapability(instructorToken)) { + sendJson(response, 401, { error: 'Missing or invalid instructor authorization.' }); + return true; + } + + const managedFixture = managedInstructorFixture(instructorToken); + const classContext = managedFixture?.classContext || instructorClass(); + const availableCaseStudies = [{ + id: BROWSER_STAGED_CASE.id, + name: BROWSER_STAGED_CASE.name, + description: BROWSER_STAGED_CASE.description, + supportedModes: [...BROWSER_STAGED_CASE.supportedModes] + }]; + + if (request.method === 'GET') { + const exercise = classroomExercises.get(instructorToken) || null; + sendJson(response, 200, exercise + ? { + class: classContext, + exercise: structuredClone(exercise), + caseStudy: structuredClone(BROWSER_STAGED_CASE), + releases: [], + workspaceState: [], + checkpoints: [], + editFreezeEnforced: true, + availableCaseStudies + } + : { + class: classContext, + exercise: null, + availableCaseStudies + }); + return true; + } + + if (request.method === 'POST') { + const body = await readJson(request).catch(() => null); + const caseStudyId = typeof body?.caseStudyId === 'string' ? body.caseStudyId.trim() : ''; + if (caseStudyId !== BROWSER_STAGED_CASE.id) { + sendJson(response, 404, { error: 'Staged Case Study not found.' }); + return true; + } + + const existing = classroomExercises.get(instructorToken); + if (existing) { + if (existing.caseStudyId !== caseStudyId) { + sendJson(response, 409, { + error: 'Another exercise is already open for this class.', + exercise: structuredClone(existing) + }); + return true; + } + sendJson(response, 200, { + class: classContext, + exercise: structuredClone(existing), + caseStudy: structuredClone(BROWSER_STAGED_CASE), + releases: [], + workspaceState: [], + checkpoints: [], + editFreezeEnforced: true, + created: false + }); + return true; + } + + const exercise = { + id: `browser-exercise-${instructorToken.slice(-1)}`, + caseStudyId, + status: 'draft', + currentStageId: null, + stagePhase: 'work', + studentEditingEnabled: true, + exerciseRevision: 1, + simulationVersion: BROWSER_STAGED_CASE.simulation.version, + simulationFingerprint: 'b'.repeat(64) + }; + classroomExercises.set(instructorToken, exercise); + sendJson(response, 201, { + class: classContext, + exercise: structuredClone(exercise), + caseStudy: structuredClone(BROWSER_STAGED_CASE), + releases: [], + workspaceState: [], + checkpoints: [], + editFreezeEnforced: true, + created: true + }); + return true; + } + + sendJson(response, 405, { error: 'Method not allowed.' }); + return true; + } + if (url.pathname === '/api/classes/workspaces') { const instructorToken = bearerToken(request); if (!activeInstructorCapability(instructorToken)) { From 1035ed70d195583393053d9817c27e1678f8484d Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:58:55 -0400 Subject: [PATCH 075/206] Cover Instructor exercise draft creation in browser --- tests/e2e/instructor.spec.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/e2e/instructor.spec.mjs b/tests/e2e/instructor.spec.mjs index 0245209..530f91f 100644 --- a/tests/e2e/instructor.spec.mjs +++ b/tests/e2e/instructor.spec.mjs @@ -68,6 +68,14 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student, await expect(page.locator('[data-participant-id="99999999-9999-4999-8999-999999999999"]')).toContainText('Waiting Student'); await expect(page.getByLabel('Assignment for Waiting Student')).toHaveValue(''); + await expect(page.locator('#instructorExerciseConsole')).toBeVisible(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('1 staged Case Study available'); + await page.getByLabel('Staged Case Study').selectOption('browser-staged-simulation'); + await page.getByRole('button', { name: 'Create draft' }).click(); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('Draft · draft created'); + await expect(page.locator('#instructorExerciseCaseName')).toHaveText('Browser Staged Simulation'); + await expect(page.locator('#instructorExerciseCreateBtn')).toBeDisabled(); + const storedAfterStart = await page.evaluate(key => window.localStorage.getItem(key), INSTRUCTOR_SESSION_STORAGE_KEY); expect(storedAfterStart).toContain('K7FM-P4Q2'); expect(storedAfterStart).not.toContain('studentJoinToken'); @@ -90,6 +98,7 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student, expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes')).toBe(true); expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes/workspaces')).toBe(true); + expect(classroomRequests.some(request => request.method === 'POST' && request.pathname === '/api/classes/exercise')).toBe(true); expect(classroomRequests.some(request => request.method === 'PATCH' && request.pathname === '/api/classes/participants')).toBe(true); expect(classroomRequests.some(request => request.pathname === '/api/classes/join')).toBe(false); expect(classroomRequests.some(request => request.body.includes('assignmentToken'))).toBe(false); @@ -103,6 +112,10 @@ test('Instructor starts a live class, creates a team, assigns a waiting Student, await expect(page.locator('#instructorJoinCode')).toHaveText('K7FM-P4Q2'); await expect(page.locator('#instructorParticipantSummary')).toHaveText('1 student · 0 waiting'); await expect(page.getByLabel('Assignment for Waiting Student')).not.toHaveValue(''); + await expect(page.locator('#instructorExerciseStatus')).toHaveText('Draft'); + await expect(page.locator('#instructorExerciseCaseName')).toHaveText('Browser Staged Simulation'); + await expect(page.getByLabel('Staged Case Study')).toHaveValue('browser-staged-simulation'); + await expect(page.locator('#instructorExerciseCreateBtn')).toBeDisabled(); await expectNoBlockingA11yViolations(page); expect(pageErrors).toEqual([]); From 40a113ba21dbb10a99fe319bde97806492b501ed Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:59:40 -0400 Subject: [PATCH 076/206] Document Instructor draft creation boundary --- src/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AGENTS.md b/src/AGENTS.md index 969fd1e..537b05c 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -17,7 +17,7 @@ Keep feature modules independently initialisable, own only their feature-specifi ## Protected Classroom resources -`classroomExerciseInstructor.js` owns the Instructor staged-exercise console client. It receives the Instructor capability only through the class lifecycle, keeps that credential and exercise payload in memory, and reads `/api/classes/exercise`. Exercise state, staged definitions, and capabilities must never enter Intake persistence, summaries, exports, URLs, or public manifests. Tranche 6 lifecycle mutations must remain optimistic-revision protected. +`classroomExerciseInstructor.js` owns the Instructor staged-exercise console client. It receives the Instructor capability only through the class lifecycle, keeps that credential and exercise payload in memory, and reads `/api/classes/exercise`; draft selection/create may POST only an explicitly discovered staged `caseStudyId`. Exercise state, staged definitions, and capabilities must never enter Intake persistence, summaries, exports, URLs, or public manifests. Draft conflicts must reload authoritative server state rather than retry stale intent; later Tranche 6 lifecycle mutations must remain optimistic-revision protected. `classroomCaseStudies.js` owns the authorized in-memory protected Case Study catalog/payload client. It receives active capabilities only through Student/Instructor lifecycle callbacks, keeps no localStorage/sessionStorage record, and clears catalog/context when the class role disconnects. Its public state must never expose a bearer capability. From a503be8adff69bd0ca5a6dd79d6e7d07538bd241 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:59:43 -0400 Subject: [PATCH 077/206] Update Instructor exercise console anchor --- docs/commenting-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/commenting-guide.md b/docs/commenting-guide.md index 233b170..53740f8 100644 --- a/docs/commenting-guide.md +++ b/docs/commenting-guide.md @@ -49,7 +49,7 @@ Anchors keep HTML and documentation in sync. Preserve existing tokens and regist | `[feature:student-class-entry]` | `index.html`, between role chooser and Instructor shell | Student class join/reconnect gate shown before the assigned Intake is available. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentClassEntryShell`, `studentClassJoinForm`, class/assignment/name inputs, retry controls; all local-only/summary-excluded. | | `[feature:student-experience-notice]` | `index.html`, top of the connected Student Intake | Class/workspace/participant context plus explicit Leave class. | `src/classroomStudent.js`, `src/experienceRoleController.js` | `studentExperienceNotice`, `studentClassContextTitle`, `studentClassWorkspace`, `studentClassIdentity`, `studentClassLeaveBtn`; no persisted Intake fields. | | `[feature:instructor-shell]` | `index.html`, between header and Intake wrap | Instructor class entry/resume and connected class workspace rail. | `src/classroomInstructor.js`, `src/experienceRoleController.js` | `instructorExperienceShell`, `instructorClassForm`, `instructorClassDashboard`, roster search/filter/list, Leave class; all class/session controls are local-only and summary-excluded. | -| `[feature:instructor-exercise-console]` | `index.html`, inside the connected Instructor dashboard | Read-only Tranche 6 foundation for current staged exercise state and server-filtered staged Case Study availability. | `src/classroomExerciseInstructor.js` | `instructorExerciseConsole`, status/current case detail, staged availability list, Refresh; all exercise context is memory-only and summary-excluded. | +| `[feature:instructor-exercise-console]` | `index.html`, inside the connected Instructor dashboard | Instructor staged-exercise discovery and draft setup within the existing class rail. | `src/classroomExerciseInstructor.js` | `instructorExerciseConsole`, staged Case Study selector, Create draft, current case/status, staged availability list, Refresh; all exercise context is memory-only and summary-excluded. | | `[feature:instructor-observer]` | `index.html`, top of the temporarily revealed Intake wrap | Live read-only context for the selected Student/team workspace, including revision and recent activity. Dynamic coaching panels are appended beside registered targets inside this same observed Intake; they do not introduce a new structural HTML anchor. | `src/classroomInstructor.js`, `src/classroomCoaching.js`, `src/coachableFields.js` | `instructorObservationNotice`, observer metadata, plus `.classroom-coaching--instructor` / `.classroom-coaching--student` dynamic local-only panels. Observed Student state and coaching UI are excluded from Intake persistence/summary capture. | | `[feature:intake-mode-selector]` | `index.html` header title | Wraps the accessible intake mode selector used to choose between General, IT, Pharma, and Major Incident Management lenses before capture. | `src/intakeModes.js`, `src/intakeModeController.js` | `intakeModeSelect`; options use stable mode values and persist under `meta.intakeMode` in `kt-intake-full-v2`. | | `[feature:major-incident-role-legend]` | `index.html`, below the header | Provides the Major Incident-only four-phase legend mount; phase/primary-role badges decorate mapped workflow headings (including Decision and Risk gates) and every KT row, then hide without unmounting data when the mode changes. Icons and colour swatches are decorative because visible and accessible role text carries their meaning. | `src/majorIncidentRoles.js`, `src/intakeModes.js` | `majorIncidentRoleLegend`, `majorIncidentRoleLegendItems`; role presentation is not persisted, while the decorated controls retain their normal field contracts. | From 0a9d4b452f5cd8c1c4fc599b8ecf820412e49b77 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:59:45 -0400 Subject: [PATCH 078/206] Document Tranche 6B draft creation checkpoint --- docs/classroom-workstream.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/classroom-workstream.md b/docs/classroom-workstream.md index cbbf2c9..7f62e45 100644 --- a/docs/classroom-workstream.md +++ b/docs/classroom-workstream.md @@ -455,7 +455,17 @@ Continue #295 on draft PR #307 from the current branch head. - Instructor capability and exercise context remain outside Intake persistence, summaries, exports, URLs, and public manifests; - no exercise mutations were added in 6A; selection/create and Start/Pause/Debrief controls remain intentionally deferred to the next slice; - focused API/client tests cover staged-only discovery, absence of protected simulation/source material in the discovery list, capability non-persistence, draft rendering, disconnect cleanup, and transient refresh recovery. -- Exact next action: continue **Tranche 6B — staged Case Study selection and draft creation** inside the existing console. Add an accessible staged-case selector/create action, preserve one-open-exercise and optimistic revision semantics, render the resulting draft, and add focused DOM/browser coverage. Do not add Start/Pause/Resume/debrief lifecycle controls until the following slice; keep Student staged-case UI and production official-case staging deferred. +- Tranche 6B — staged Case Study selection and draft creation: + - implementation spans `6a69426...` through `1035ed7...`, with deterministic browser fixture support at `c3440eb...`; + - the console now exposes an accessible staged Case Study selector and **Create draft** action only when no exercise is open; + - POST sends only the selected server-discovered `caseStudyId`; successful create and idempotent existing-draft reuse both hydrate the represented server exercise without persisting it locally; + - once an exercise exists, selector/create are disabled so the UI cannot imply a second simultaneous exercise; + - a 409 create conflict is never blindly retried: the controller performs an authoritative GET and renders the current exercise, with a visible "current state reloaded" status; + - public controller state continues to sanitize the protected Case Study to summary fields and never exposes the Instructor capability or Instructor-only simulation content; + - deterministic browser acceptance now uses a synthetic staged case owned only by `scripts/serve-browser-tests.mjs`; no official production case content was staged or guessed; + - the live Instructor browser journey proves draft create, server-backed reload/resume, disabled duplicate-create control, and no local exercise persistence; + - 6A final-head repository quality was green, but the existing four-browser integration acceptance exceeded its 30s global timeout (historical green runtime was already 26.1s). 6B raises only that test budget to 45s; no failing product assertion was suppressed. +- Exact next action: implement **Tranche 6C — Start/Pause/Resume and current-stage Instructor context**. Add revision-safe Start/Pause/Resume controls to the existing console, render current stage title/objective plus Instructor-only facilitation material after Start, handle 409 mutation conflicts by authoritative refresh, and add focused DOM/browser coverage. Do not add optional evidence release, Begin Debrief, Freeze/Unfreeze, Advance, or Complete until subsequent slices; keep Student staged-case UI and production official-case staging deferred. ## Approved Classroom follow-on direction From 46747e50bf8ad05072239508475dded8c9909f94 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:59:48 -0400 Subject: [PATCH 079/206] Document Tranche 6B staged simulation checkpoint --- docs/classroom-staged-simulation.md | 37 ++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/docs/classroom-staged-simulation.md b/docs/classroom-staged-simulation.md index 7a7251f..8608d11 100644 --- a/docs/classroom-staged-simulation.md +++ b/docs/classroom-staged-simulation.md @@ -754,16 +754,35 @@ Foundation contract: Implementation checkpoint spans `2624f1e...` through `d20a303...`. +## Implementation checkpoint — Tranche 6B staged Case Study selection and draft creation + +The console now creates the server-owned draft without adding lifecycle actions. + +Draft-setup contract: +- the selector is populated only from the Instructor-authorized `availableCaseStudies` discovery list; +- create sends only the selected `caseStudyId` in an authenticated POST body; +- a successful create hydrates the returned draft and protected Instructor definition in memory, while public controller state exposes only the sanitized Case Study summary; +- an idempotent `created: false` response is treated as the represented existing draft rather than an error; +- once an exercise exists, selector/create controls are disabled; +- a 409 never retries the stale create request; the client performs an authoritative GET and renders the current exercise; +- draft setup releases no Student content and does not modify Intake/collaboration state; +- exercise state remains server-owned and is re-fetched on Instructor class resume/reload; +- deterministic browser coverage uses only a synthetic staged case in the browser fixture. + +Implementation spans `6a69426...` through `1035ed7...`, with synthetic fixture support at `c3440eb...`. + +Validation note carried from 6A: its repository quality gate passed, but the four-browser integration journey hit the old 30-second Playwright test timeout. Its immediately prior green runtime was 26.1 seconds, so the acceptance-specific budget is now 45 seconds rather than removing assertions or weakening behavior. + ## Exact next implementation action -Continue **Tranche 6B — staged Case Study selection and draft creation**. +Implement **Tranche 6C — Start/Pause/Resume and current-stage Instructor context**. Required next slice: -- add an accessible staged-case selector using the server-filtered discovery list; -- create/reuse the selected draft through the existing Instructor POST contract; -- render the created draft and its safe summary inside the existing console; -- preserve one-open-exercise behavior and fail closed on conflicting existing exercise state; -- keep exercise state server-owned and memory-only; -- add focused DOM/API/browser coverage for selection, create, replay, and conflict refresh. - -Do **not** add Start/Pause/Resume/release/debrief/freeze/advance/complete controls in 6B; those follow after the draft-creation slice. Keep Student staged-case UI and production official Case Study staging deferred. +- expose Start only for a draft and send the current `exerciseRevision`; +- expose Pause only for an active exercise and Resume only for a paused exercise; +- render current stage title, Student objective, suggested timing, and Instructor-only current-stage facilitation content after Start; +- refresh authoritative exercise state on optimistic 409 conflicts rather than replaying lifecycle intent; +- clearly distinguish pause state from editing freeze (freeze remains a later debrief slice); +- add focused controller and real-browser coverage for Start -> Pause -> Resume plus reload/resume. + +Do **not** add optional evidence release, Begin Debrief, Freeze/Unfreeze, Advance, or Complete in 6C. Keep Student staged-case UI and production official Case Study staging deferred. From 9f6d811c45620f128de0002177e2862dd1c5cfae Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:00:56 -0400 Subject: [PATCH 080/206] Mark exercise selector option summary-excluded --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 0acbcdc..76fafcd 100644 --- a/index.html +++ b/index.html @@ -427,7 +427,7 @@

    Class

    From bd7658a99724501d94db9e28f74d5ef50e649ff9 Mon Sep 17 00:00:00 2001 From: Shane Chagpar <42649692+MajorIncident@users.noreply.github.com> Date: Tue, 6 Oct 2026 18:01:30 -0400 Subject: [PATCH 081/206] Mark exercise selector label local-only --- index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.html b/index.html index 76fafcd..d009c2b 100644 --- a/index.html +++ b/index.html @@ -424,7 +424,7 @@

    Class

    Staged Case Studies available to this class will appear here.

    - +