diff --git a/.github/workflows/cloudflare-candidate.yml b/.github/workflows/cloudflare-candidate.yml
index 467191d..8f46388 100644
--- a/.github/workflows/cloudflare-candidate.yml
+++ b/.github/workflows/cloudflare-candidate.yml
@@ -45,6 +45,8 @@ jobs:
run: bun run typecheck && bun run test
- name: Install Chrome
run: bash .github/scripts/install-media-tools.sh chrome
+ - name: Install Chromium for native browser zoom
+ run: ./node_modules/.bin/playwright install --with-deps --no-shell chromium
- name: Exercise recording, account and Listening Guide browser paths
working-directory: cloudflare
run: ../node_modules/.bin/playwright test --config playwright.config.mjs
diff --git a/TODO.md b/TODO.md
index 2d5defa..9f61f81 100644
--- a/TODO.md
+++ b/TODO.md
@@ -1,136 +1,274 @@
-# STEM Splitter — completion checklist
-
-Updated September 29, 2026. This is the current progression. Work is
-Cloudflare-only; preserve Railway and its `main` release path.
-
-## Released baseline
-
-Canonical application: https://stem-splitter.ailab-452.workers.dev/
-
-Release branch: `codex/cloudflare-migration`. PRs #11 and #13–16 are merged.
-Latest deployed marker: `cloudflare-playback-retention-20260929`; Worker version
-`be74510a-f837-4fa5-8269-a500d0841d88`; merged source `be7b8b4` (PR #16).
-The alias serves the same Worker/data, not a second installation.
-
-- [x] CUNY WorkerIdentity handoff, current Admission checks and account-owned saves.
-- [x] Server-side rack recovery, pagination and local cross-account tests.
-- [x] Ten submitted runs per signed-in person per UTC day; no shared class run cap.
-- [x] Listening Guide through CAIL Gateway with the signed-in person's identity.
-- [x] 90-day Cloudflare audio retention, account listing and bucket lifecycle readback.
-- [x] Instructor grants, optional end date and governed prompt revisions.
-- [x] Crate below the workspace, expanded YouTube input and current subtitle.
-- [x] Mixer timer/playback-race repairs and desktop/mobile regression tests.
-- [x] Canonical asset hashes, signed-in rack reload and existing four-stem playback verified after PR #16.
-
-These receipts do not establish a fresh student/provider journey or classroom
-stress qualification. Remixer, server Auto, discovery and isolation remain off.
-Visitor paid runs are not implemented or enabled.
-
-## 1. Reliable sign-in and recovery
-
-- [ ] Reproduce the reported first-attempt failure with its site/stage identified.
- Bounded Doorway logs show successful authentication followed by stale
- callbacks, but cannot establish the person or the cause of the first failure.
-- [ ] Resolve repeated/stale callback recovery in its owning service without
- replaying codes, accepting invalid state or bypassing Admission.
- [Doorway PR #279](https://github.com/CUNY-AI-Lab/cail-tools-admission/pull/279)
- redirects an already verified session to the Lab tools home without
- granting/renewing access.
- Local tests pass; the shared source gate is blocked by the dependency audit
- already addressed in prerequisite PR #278. Review, release and fresh CUNY
- acceptance remain required.
-- [ ] Release state-aware STEM sign-in recovery: verified sessions return home;
- expired sessions get CUNY Login; unavailable verification gets Retry; denied
- access gets Lab help. Local implementation: `codex/classroom-readiness-20260929`,
- not deployed.
-- [ ] Verify fresh CUNY login, app logout, expiry, revocation and shared-device
- switching. Existing Doorway-session reuse is not fresh CUNY MFA acceptance.
-
-## 2. Classroom acceptance
-
-- [ ] Actual student: sign in → authorized source → completed split → playback →
- note/rename → Guide/chat → logout → login → account recovery/export.
-- [ ] Actual instructor: correct access and prompt-history readback. Do not edit
- app-wide teaching instructions just to produce a test receipt.
-- [ ] Class-only membership, expired/revoked membership, second-student isolation
- and exhausted daily runs. Do not spend a real class's allowance just to force
- the limit; synthetic tests and live acceptance stay separate.
-- [ ] Record exact release, source provenance, browser and result; keep personal
- identifiers and private audio out of Git.
-
-## 3. Three daily visitor runs
-
-- [ ] Provision/verify Turnstile for exact hosts through its approved credential
- path. Latest setup probe: API token absent. No widget/secret was created.
-- [ ] Separate private visitor sessions/ownership, atomic three-run reservations,
- bounded import/concurrency, abuse protection and expiry.
-- [ ] Exhausted-state CUNY Login / request-access / reset-time UI.
-- [ ] Real challenge success and replay denial; fourth-run, concurrency,
- cookie-reset, visitor/member isolation and provider-failure tests.
-- [ ] Promote only after those checks. Do not invent CAIL subjects, downgrade
- denied members to guests or advertise the tier before it is live.
-
-See [daily allowance contract](docs/superpowers/specs/2026-09-29-daily-split-allowances.md).
-
-## 4. Class-specific tools and unified accounting
-
-- [ ] Resolve class context through Admission; add class-scoped Guide settings,
- revision/cache keys and cross-class tests. Current instructions are app-wide.
-- [ ] Define explicit submissions and instructor/course ownership before
- exposing student recordings to a class.
-- [ ] Authorized directory-backed admin labels, not inferred email/subject
- mapping or a second roster/password store.
-- [ ] Shared Gateway asynchronous Replicate contract and real cost settlement,
- including idempotent callbacks, failures, cancellations and uncertain starts.
-- [ ] Prove per-person allowance drawdown and rejection before new paid work.
-
-Existing Replicate billing remains approved and unchanged. Daily counts are
-not monetary accounting. See [class access and usage](docs/class-access-and-usage.md).
-
-## 5. Legacy recordings
-
-- [ ] Authorized inventory and recoverable source backup.
-- [ ] Verified destination identity and explicit ownership; resolve shared or
- ambiguous records rather than assigning the whole class to an instructor.
-- [ ] Journaled/idempotent import and batch-only rollback, preserving dates,
- provenance, notes, prompt history and applicable source retention.
-- [ ] Destination reload/playback/export and isolation acceptance with the person.
-
-No legacy transfer has been performed. See [migration readiness](docs/account-migration-readiness.md).
-
-## 6. Remixer release
-
-- [ ] Validate existing gated Crate → layers → edit → record → download on live
- desktop/mobile, including stem handoff, Archive delivery, memory bounds and credits.
-- [ ] Prove license-conflict/unknown-license handling before enabling the flag.
-- [ ] Account project persistence: ownership, schema, versioned arrangement/media
- references, expiry and recovery. Browser-session layers/takes are not account saves.
-- [ ] Student acceptance and explicit feature promotion.
-
-## 7. Auto and instrument discovery
-
-- [ ] Reviewed Worker-reachable authenticated analyzer connection; a Railway
- private hostname cannot be used directly by this Worker.
-- [ ] Upload/YouTube/Archive parity, frozen 2/4/6 choice, outage fallback and
- evidence before shadow → teacher beta → authoritative Auto.
-- [ ] Human-reviewed corpus/negative controls; select exactly one replacement
- classifier and calibrate family thresholds/abstention. Rejected CLAP evidence
- is not permission to provision it.
-- [ ] Then evaluate optional AudioSep/SAM-Audio isolation with license, quality,
- cost, concurrency and output-integrity gates.
-- [ ] Broaden genre/instrument coverage while rerunning the frozen baseline.
- Coherent long-tail multi-stem research is a later decision.
-
-Exact pins and reviews remain in the [historical roadmap](docs/archive/roadmap-through-2026-09-29.md),
-[processing changelog](docs/model-processing-changelog.md) and acceptance files.
-
-## 8. Final qualification and retirement decision
-
-- [ ] Real provider 2/4/6 splits across upload/authorized import types, webhooks
- and polling, Guide streams/tools and safe upstream failures.
-- [ ] Large files, classroom concurrency, memory, leases/retries, storage expiry
- and operational alerts under the actual Worker runtime.
-- [ ] Backup/restore, ownership-aware migration rehearsal and rollback including
- reconciliation of new Cloudflare writes—not just a URL switch.
-- [ ] Classroom/soak acceptance. Railway retirement needs separate approval and
- confirmation that no retained analyzer/service depends on it.
+# Stem Splitter — next changes in three PRs
+
+Updated October 6, 2026. This is an implementation plan, not a release authorization or a claim that unchecked work is complete.
+
+**Execution update — October 6, 2026, 19:39 UTC:** the user has separately authorized implementing these three PR groups, publishing at most three actual draft PRs, and merging after an independent Astra High audit of the final commits. Implementation workers were started with explicit Astra Max settings, and a separate auditor with explicit Astra High settings; creation requests were accepted, but actual backend model/effort metadata is not independently exposed. Production deployment, database cutover, paid inference/provider jobs, credential changes and permission expansion remain outside that authorization. Portal `main` automatically releases changed Workers; separate deployment approval is pending. Stem's inspected GitHub target workflows check and dry-build; external auto-deployment remains unverified because the configured Cloudflare MCP sessions do not authorize the target account. No merges, deployments or remote database changes have run. Completion boxes remain unchecked until final-head evidence is recorded.
+
+**Resolved product decisions:** course visibility applies only to newly and explicitly assigned course work. Existing personal splits and private transcripts remain private, with no historical backfill or transcript import. Folders remain private or course-scoped. The Listening Guy allowance is 50 human inputs per student across courses per UTC day; failed work without a usable reply releases its reservation, while a delivered partial reply counts once. Split allowance settles against the UTC submission day. Reset removes conversation content; content-free accounting evidence may remain.
+
+**Additional explicit scope — October 6, 19:11 UTC:** implement signed-out guest use with **5 successful splits and 25 human Listening Guy inputs per UTC day**, alongside signed-in 15/50. Guest ownership and sessions must be separate from CUNY identities and course authority, with bot/abuse controls and honest limits on anonymous per-person accounting. Instrument meaningful use and operation lifecycles using redacted, access-controlled existing infrastructure, with bounded retention and explicit loss/replay semantics. Every track waveform must also support direct pointer/touch/keyboard seeking through the existing hardened shared playback logic. Guest sessions/accounting and usage events belong in PR3; waveform seeking belongs in PR2. The maximum remains three actual PRs. These additions expand testing and final independent review; they do not authorize production activation, new persistent credentials or a material security-setting change.
+
+| Implementation checkpoint | Concrete evidence and remaining work |
+| --- | --- |
+| PR1 — [Portal draft #295](https://github.com/CUNY-AI-Lab/cail-tools-admission/pull/295) | Published and independently passed at `9b9d8ed299519fa32b2235bc6c15adfb4093376d`. Local pinned-Bun workspace gate: 1,210 passed, 13 optional skips, both dry builds; frozen install and dependency audit passed. Both account/owner P2s and the Linux narrow-screen failure are corrected. Independent review reran 45 focused tests plus account-switch/outage/long-name browser probes. GitHub's authoritative workspace check passed on this head. Merge/deployment remain held. |
+| PR2 — [Stem classroom draft #28](https://github.com/CUNY-AI-Lab/stem-splitter/pull/28) | Corrected source committed at `1f7e0205a4130bd46cf2180772671e57cc9387fe`: 50 Cloudflare, 36 Chrome, 25 Firefox and 25 WebKit cases pass; the Chromium-only native zoom case is explicitly skipped in the other engines. All 19 local-hosting cases, root suite and dry build pass. All three UI audit defects have negative regression tests: account-outage concealment, course-bound prompt requests and rapid selection/folder-write fencing. Every waveform uses the shared seek path; native 200% mouse and keyboard seeking passed with two synchronized stems after correcting the fixture byte-range responses. The shallow-history schema fixture and title/logo CI expectations are fixed locally. Exact-final-head independent review and refreshed remote CI remain pending; the previously published checkpoint is not approved. |
+| PR3 — [Stem reliability draft #29](https://github.com/CUNY-AI-Lab/stem-splitter/pull/29) | Published checkpoint `aa74797ce38d9b5dbfcc4849daf17b0cd41a4c96` had 66/66 local Cloudflare and 20/20 importer tests, but independent review reproduced five recovery defects: lost commit acknowledgement deleted committed audio, abandoned ingestion blocked the queue, stale importers failed newer work, missing pins held capacity and polling ignored Retry-After. Corrections now pass local negative tests; current working checks include 74 Cloudflare cases, importer21/21 and actual Workerd overlap of a ten-minute MP3 decode with a 12 MiB inline import. Guest 5/25 routed Worker/storage tests pass with mocked providers; guest browser integration, usage-event verification, final restack, CI and independent review remain open. Production cause of the YouTube report remains unconfirmed. No paid job, extractor publication or production change occurred. |
+
+The current Gateway catalog lists both requested model IDs. Read-only deployment/version verification also matches serving Gateway source `36cd8cdb7d61e2aebc94a03171b5990cea00bf5e`, version `98a500ab-2ea7-49b1-9909-ddb06d60cff7` at 100%. Its documented retryable `upstream_rate_limited`, `upstream_unavailable` and `model_unavailable` codes inform local fallback tests; quota/authentication/payment/ambiguous-timeout outcomes do not qualify. This proves neither account entitlement nor a live inference result.
+
+**Priority YouTube regression investigation — October 6:** read-only Railway MCP/CLI inspection identifies the canonical service's latest deployment `d36f9724-ea89-4558-8bad-cbbbf19188f6` as **REMOVED** on October 5, with no active deployment and a public health 404. It cannot currently serve as a live working comparison. Its CLI redeployment metadata lacks a source SHA; the last recorded accepted Railway source `4c00b32fab96c8f9405095742ce3ca7170ace75b` has identical importer, extractor and package sources to Cloudflare baseline `e1918da`. Retained build logs confirm Node 22.23.1, Bun 1.3.14 and youtubei.js 17.2.0. Both hosts expose the expected configuration names; Cloudflare stores the four YouTube settings as secret bindings, whose values were not read. A synthetic 128 KiB inline-audio reproduction succeeds on both host paths and proves that legacy missing configuration and provider 402/422 failures are incorrectly reported as a YouTube block. PR3's durable path separates these categories; unknown failures must not become unsupported source-denial claims. The specific reported live failure remains unconfirmed pending an exact error/URL/time. This execution environment's Cloudflare health read is denied with error 1010; no bypass was attempted. No Railway settings or deployment were changed, no paid prediction was started, and no fourth PR is proposed.
+
+**Baseline:** deployed source `e1918da3cb6fc37f9715d4cd143af5a5c6c880c6`, release branch `codex/cloudflare-migration`, [annotation fix PR #27](https://github.com/CUNY-AI-Lab/stem-splitter/pull/27). The canonical site is https://stem-splitter.ailab-452.workers.dev/. Health and remote branch were checked during planning; both matched this source and no open Stem PRs were listed. The original owner checkout was clean and was not edited.
+
+Keep the PR27 note-input spacing and narrow-screen fix. Preserve CUNY sign-in, current Admission checks, account ownership, 90-day retention, playback and downloads. Work remains Cloudflare-only. The plan now includes read-only inspection and a future PR in the tools portal repository; no Railway, Mapping, OWUI, shared-service deployment, credential changes, paid test jobs, merge or production mutation is authorized by this planning document.
+
+## Three actual PRs across two repositories
+
+The new My Classes request crosses a repository boundary. **Revised grouping:** move the former standalone UI PR into Stem's course/collaboration PR, reserve one real PR for the portal, and retain one for reliability. This replaces the earlier three-Stem-PR grouping; it does not count two repository PRs as one.
+
+| PR | Repository and change | Student/instructor outcome | Dependency |
+| --- | --- | --- | --- |
+| 1 | `CUNY-AI-Lab/cail-tools-admission`: course eligibility contract and My Classes destination | Only students currently enrolled in Agustina's exact class see **Open Stem Splitter** in **Ready to use the Lab?**, alongside the existing three destinations. | Use the verified class ID and linked instructor record below; coordinate the authoritative contract and receiver checks with PR2. |
+| 2 | `CUNY-AI-Lab/stem-splitter`: course permissions, teaching, collaboration and compact UI | Server-enforced student access; instructor roster/conversation review and course prompts; shared folders, attributed notes, Refresh, compact controls and **Stem Splitter** casing. | Consume PR1's reviewed authority contract; preserve PR27 and the approved historical-data policy. |
+| 3 | `CUNY-AI-Lab/stem-splitter`: allowances, guests, usage events, imports and model fallback | Signed-in 15 successful splits/50 inputs and guest 5/25 daily; failed work releases successful-use reservations; private guest ownership, redacted usage evidence, recoverable imports and ordered model fallback. | Build on PR2's course checks and shared controls; verify existing Gateway mappings and the separate guest sponsor/bot-verification activation requirements. |
+
+Use separate commits within each PR for service logic, migrations, tests and UI. Sequence the two Stem PRs to avoid competing edits to `src/index.ts`, `public/app.js` and shared styles. PR3 adopts PR2's controls and includes its new quota/recovery states in final UI regression coverage; there is no later styling PR.
+
+**Cross-repository release dependency:** PR numbers group the reviews, not the deployment order. Review PR1/PR2 against the same contract and require receiver enforcement before the new destination is released. A concrete three-PR sequence is Stem PR2 first, followed by portal PR1; if PR1 introduces a new Admission RPC, Stem must fail closed with an explicit unavailable state until that service arrives. Review that temporary availability impact before any release. The portal's existing pipeline releases changed Admission sources before Doorway, so contract availability precedes its new link. Do not assume an extra feature-toggle commit, configuration PR or fourth activation PR. A different sequence requires a verified existing control and an explicit revision of this plan. These are later release gates, not authorization to merge, deploy or change configuration now. The portal repository automatically releases changed Workers on a push to `main`; neither main nor its workflows may be mutated in this planning task. [P8]
+
+Exactly three planned PRs are allowed. Include any necessary Doorway/Admission shared-contract change inside PR1's existing monorepo. If implementation proves that another repository must change (for example a missing Gateway capability), stop that dependency and explicitly revise the grouping before proposing a fourth PR; do not silently expand scope or bypass a gate.
+
+## Facts and decisions that shape the work
+
+**Verified current behavior:** instructor is an app role, not a course assignment; ordinary instructors still cannot read another account's jobs. Conversations are private even from admins, stored as up to 60 client-supplied entries per split/subject. Folders are instructor-created, and public split links expose audio while excluding notes, names and conversations. Notes have no author field. Guide amendments use one app-wide settings row. The daily guard reserves ten attempts before body validation and never refunds failures; Gateway traffic bypasses the legacy local guide counter. The Gateway adapter currently makes one model attempt. [S1], [S2], [S3], [S4], [S5], [S12]
+
+**Verified portal ownership:** the target is `/my-classes` in `CUNY-AI-Lab/cail-tools-admission`, inspected at `7371f7389a826392fa85017649324033e9fcf362` on `main`. Current Cloudflare readback now confirms that source on both serving Workers, as recorded below. `apps/doorway/src/access-destinations.ts` owns the three static links; `owner-ui.ts` renders their **Ready to use the Lab?** section. The page currently calls owner-only class-list operations, and the navigation's class flag looks for `sourceKind: owner`. Student access needs a deliberate landing/navigation state, without granting roster administration. [P1], [P2], [P3], [P6]
+
+Admission's server summary already supplies canonical `classId`, owner/participant source, state and dates, but its self-service class-source query is capped at 50 records. Use a complete exact-class authorization resolver for the new capability; a truncated presentation summary or broad `resolveMembership` success is insufficient. [P4], [P5]
+
+**Verified course and ownership — October 6, 2026, 17:12 UTC:** the configured CAIL Admin MCP's supported `search_classes` and `get_class` reads identified **MSH 245 The American Musical Experience**, **Fall 2026**, **section 01**, canonical class ID **`msh-245-the-american-musical-experience-fall-2026-01`**. The class is **active**, version **3**, with recorded class dates **August 28–December 23, 2026**. Its single instructor is linked to a CUNY identity. Verification matched the class's application record ID to Agustina Checa's approved application, matched term/section, and matched the instructor email to that application's email, with `subjectLinked: true` and no teacher truncation. The name search located a candidate; these exact-record relationships established ownership. No student identities, instructor email, subject identifiers or invitation capabilities are included here. [K1], [K2], [K3]
+
+**Active enrollment authority:** Admission owns class enrollment, participant/owner provenance, withdrawal and time boundaries; app roles and OpenWebUI group membership are not substitutes. Runtime access must require current Admission permission plus a current active participant source for that exact class ID. Re-resolve the requesting subject server-side on protected requests and treat instructor access through its separate verified class-owner relationship. The class's invitation-claim window is September 29–October 29, 2026; that window controls joining, not the December course-access end. Read current canonical boundaries from Admission instead of deriving permission from these display dates, cached counts or an approved application alone. The administrator MCP supplied this operational verification; student applications must use a narrow private service contract and must never receive administrator MCP credentials. [K2], [P4], [P5]
+
+**Cloudflare serving-source verification — October 6, 2026:** read-only deployment/version inspection found Doorway version `3c963a34-f1f1-45eb-97ef-82691f84fbc6` and Admission version `a34bdb37-a25e-4b2b-8787-9a06c72e2017`, each at **100%**, both tagged/annotated with source `7371f7389a826392fa85017649324033e9fcf362`. This confirms that the portal integration source inspected for PR1 is serving. It does not prove a new CUNY MFA journey or the planned course-aware Stem receiver. The knowledge-base notes establish the component boundaries; these fresh MCP and Cloudflare records supersede their older dated release snapshots. [K2], [K4]
+
+
+| Decision or prerequisite | Proposed default / action before implementation |
+| --- | --- |
+| Which exact class and instructor identity belong to Agustina? | **Resolved:** `msh-245-the-american-musical-experience-fall-2026-01`, MSH 245, Fall 2026 section 01; active and owned by the linked instructor matched to Agustina's approved application. Use this returned canonical ID in controlled configuration and recheck live state before implementation/release. Current active participant enrollment in this exact class governs the student link and protected student app access. A matching name, another active course, generic Lab membership or app Instructor role is insufficient. Use current server time and the complete exact-class resolver; do not infer a negative from a truncated summary. |
+| Which existing work becomes visible to instructors? | **Resolved:** only new course-assigned work follows the course policy with a visible notice. Existing personal/unassigned splits and formerly private transcripts stay private; no backfill or historical transcript import is included. Do not infer course from filename, folder, email or overlapping enrollment. Reset removes content from both student and instructor views; withdrawal and retention are enforced at the current authority and resource boundaries. |
+| What does “50 Listening Guy inputs” mean? | **Resolved:** per student per UTC day across courses: one accepted human message, counted once across tools/fallbacks; generated opening guides and cached reads do not count as human inputs. Release a reservation when no usable reply was delivered; a partially delivered or completed reply counts once. Keep content-free accounting separate from the conversation store. |
+| What is a “day”? | **Resolved:** midnight UTC; show the reset in local time with its timezone. A split's successful charge settles against its submission/reservation day, even if it finishes after midnight. |
+| What does sharing a folder mean? | Default private or enrolled-course sharing with explicit read/comment permissions. Anonymous public folder sharing, or exposing named notes/conversations through existing public audio links, is not included without a separate explicit decision. |
+| Are the exact fallback models usable through the current Gateway? | Verify canonical aliases, actual provider routes, account entitlement, streaming/tools and model-specific error classification. Preserve `glm-5.2` then `deepseek-v4-flash-0731`; do not substitute a newer alias silently. Catalog verification is described under PR3. |
+| Where does “Having trouble?” go? | Reuse the existing `mailto:ailab@gc.cuny.edu` support route found in sign-in recovery unless the owner supplies another destination. The label is “Having trouble?”; do not invent a helpdesk URL or send diagnostics automatically. [S10] |
+
+The exact class ID and linked ownership are verified. Implementing the complete runtime course-authority contract remains joint PR1/PR2 engineering work. The prospective-only disclosure policy and UTC quota semantics above are resolved implementation decisions, not claims about currently deployed behavior. Model account access remains an engineering verification gate; public catalog presence alone does not prove entitlement.
+
+## PR 1 — Enrollment-gated My Classes link and course contract
+
+**Repository:** `CUNY-AI-Lab/cail-tools-admission` (Doorway, Admission and their shared contract in one PR). **Suggested title:** `Add a course-authorized Stem Splitter destination to My Classes`
+
+### Build checklist
+
+- [ ] **Centralize the verified target class.** Use `msh-245-the-american-musical-experience-fall-2026-01`, established through the matched approved application and linked instructor record above. Re-read its current state and ownership before wiring/releasing the feature; do not ask the user to supply information already available through the supported authority. Define a narrow, typed, server-derived student eligibility result for that class. Require a current authenticated subject, current Admission permission and an active participant enrollment whose start/end boundaries permit access now. Preserve the existing meaning of global suspension/revocation and class withdrawal. Never grant access from display names, app-wide roles, client course IDs, browser storage or a remembered successful launch.
+- [ ] **Reuse the authority without copying its database.** Resolve exact subject/class access through a supported private service contract. If an additive resolver or authorized roster contract is needed, include it in this PR under `packages/admission-contract` with its Admission implementation and caller tests. Do not treat the 50-record welcome summary as a complete permission lookup or expose roster/profile data to the browser merely to decide one link. Define denial versus unavailable outcomes, time/revision boundaries and minimal disclosure. Keep internal registry schemas Admission-owned. [P4], [P5]
+- [ ] **Add the stateful destination at the requested location.** In **My Classes → Ready to use the Lab?**, add **Open Stem Splitter** beside **Open the Sandbox**, **Explore Tools** and **Read the FAQ**, only when the server says this student is currently eligible. Pass a page-specific capability/destination into the renderer; do not append a universal fourth item to the static array shared with other surfaces. Preserve the three existing destinations and their policies. Unknown, expired, revoked, unenrolled and signed-out states expose no working course launch; show a useful sign-in/recheck state where appropriate without disclosing another user's enrollment.
+- [ ] **Support students without owner privileges.** Make the target landing section and appropriate My Classes navigation reachable for enrolled participants even though the current page loads an owner class list. Render participant information separately from owner controls; do not grant a student owner roster reads, invitations, quota administration or enrollment mutations just to show the link. A multi-course student qualifies only through the exact target class; retain their other courses and tool access. [P2], [P3], [P6]
+- [ ] **Recheck launch and stale pages.** Use a reviewed destination for the canonical Stem app and its existing safe sign-in/return flow; do not invent a Doorway app mount or expose credentials in a URL. Recheck eligibility at the server launch boundary and independently in Stem under PR2. Keep personalized responses private/no-store; refresh eligibility on return/back-forward-cache restoration and after sign-in/out or known enrollment changes. A stale open tab, copied URL or cached positive result never grants access after revocation. An unavailable authority fails closed for this feature while retaining unrelated destinations.
+- [ ] **Coordinate the receiving app and active work.** Document the minimum contract/configuration revisions for both repositories and the receiver-first release sequence above, including fail-closed behavior while any new authority is unavailable. Recheck the portal's open work, especially PR #279 (session callback recovery) and PR #292 (instructor/API quota reads), before implementation. Keep the portal link text in the exact requested case; remove any applicable uppercase styling without restyling unrelated tools. The Stem-side access enforcement, product naming and classroom UI belong in PR2, not an uncounted fourth PR.
+
+**Main files:** `apps/doorway/src/access-destinations.ts`, `owner-ui.ts`, `my-classes.ts`, `welcome.ts` and the narrow existing configuration/launch boundaries; when needed `packages/admission-contract/src/`, `apps/admission/src/index-self.ts` or the appropriate private RPC, and `registry-access.ts`. Extend the My Classes browser and Worker boundary tests. Read the root and affected-app `AGENTS.md` before implementation. [P1], [P2], [P3], [P4], [P5], [P6], [P8]
+
+### Acceptance tests
+
+- [ ] **P1 — Exact-course eligibility:** active target-class participant sees exactly one correctly cased link in the requested section. Unenrolled, unrelated-course-only, scheduled/not-yet-started, ended, expired, withdrawn/revoked, suspended and signed-out fixtures do not gain access. Multi-course enrollment does not replace the exact-class check. Display-name collisions, forged browser fields and tampered course IDs cannot grant it; class owner/instructor status alone does not satisfy the student-link predicate.
+- [ ] **P2 — Session and cache transitions:** fresh and resumed sessions produce the same current result; expiry, logout/relogin as a second account, revocation while the page is open, browser Back/forward restore, stale cookies/cache and authority timeout remove or disable stale eligibility. A stale visible link and direct/copied Stem URL still fail at the receiver. No cross-account cached launch or enrollment data survives sign-out.
+- [ ] **P3 — Complete authority and boundaries:** a valid target enrollment beyond the summary's first 50 records still works through the exact-class resolver. Exercise start/end instants, empty and malformed authority responses, stale revisions, unavailable service, and owner/participant distinctions. Redirects preserve only approved return paths; neither subjects, JWTs nor session tokens enter browser URLs or logs.
+- [ ] **P4 — Student landing and policy preservation:** an enrolled participant reaches the destination without a successful owner-roster request, and cannot access owner APIs. The three original links remain in place with their prior policies; welcome/other portal pages gain no unconditional Stem link. Verify keyboard access, mobile wrapping and computed text casing. PR1/PR2 integration covers denied direct API/upload/model/media requests after student enrollment ends, while retaining separately authorized instructor/admin paths and the existing anonymous audio-only policy.
+
+**Risks and dependencies:** hiding a link provides no access control. The receiver, not browser UI, must reject unauthorized student work. The exact class ID and linked instructor are verified; real student/MFA acceptance and the new receiving implementation remain later gates. This PR may include an additive service contract in this repository; it authorizes no live roster, binding or shared-service change now.
+
+## PR 2 — Course permissions, collaboration and compact UI
+
+**Suggested title:** `Add course-authorized classroom work and a compact Stem Splitter interface`
+
+### Build checklist: course access and collaboration
+
+- [ ] **Establish course authorization and enforce the portal restriction.** Consume PR1's supported exact-class contract through a small typed course-access module. Require current active enrollment in Agustina's verified class for protected student workspace access and actions, including direct URLs, API/upload/import/model requests and protected reads; a visible or previously successful portal link is never authority. Resolve instructor-course assignments independently so legitimate teaching/administrative paths keep their reviewed scope. Keep app admission/suspension checks and the existing anonymous audio-only projection. Deny on unavailable or stale authority, and validate requested course IDs on every request; a client-selected ID is never permission.
+- [ ] **Bind work to a course deliberately.** Add additive schema/migrations for explicit split-course associations, sharing policy and necessary indexes. Keep stable account ownership. Offer a clear course selector for multi-course students; distinguish personal work. Associate only newly created work after the course disclosure is acknowledged; do not backfill existing records.
+- [ ] **Add the instructor roster entry point.** On the Instructor page, add a button leading to the instructor's permitted course selector/roster. Include enrolled students with zero splits, preferred display names, paginated split lists, status, dates, retained audio and conversation links. Scope every count, search, cursor, detail and media download to the authorized course; avoid a global “all students” query or one query per roster row.
+- [ ] **Provide instructor conversation review.** Add a separate course-authorized, read-only review path; do not weaken the existing owner route or give admins a blanket transcript bypass. Explain student visibility before new course work is shared. No instructor view may generate a new answer, spend allowance, edit a student's conversation or expose it to classmates/public links.
+- [ ] **Make “all conversations” honest.** The current 60-entry replacement document cannot recover overwritten history. Add bounded, paginated durable messages for new course conversations, with message IDs, revision/concurrency checks and provenance; do not import private historical entries. Separate student text from server-produced assistant/tool results. Keep reset/retention behavior explicit and consistent across student/instructor views; no hidden transcript copy remains in replay caches or accounting records after reset.
+- [ ] **Scope prompts to the selected course.** Replace the singleton amendment assumption with a course-keyed setting and append-only course revision history. Keep the code-owned base prompt read-only. Preserve required change notes, optimistic revisions, no-op behavior and audit history. Key guide caches by job, course, base version, course revision and effective fingerprint; reject stale generation after a course prompt changes. Personal/unassigned work uses the base prompt and no guessed course amendment.
+- [ ] **Share folders within authorized courses.** Add server-enforced folder/course grants and read/comment permissions, a clear sharing control, revocation and a stable course link. Adding an item must independently verify its course and the caller's authority. Folder access must not reveal a private item, its title, notes, transcript or audio through a different route. Preserve expired-item placeholders without extending retention.
+- [ ] **Attribute each new note.** Persist the verified author subject plus a bounded, escaped display-name snapshot from the trusted SSO preferred-name mapping. The pinned identity package currently exposes optional `name`; verify that mapping rather than assuming a `preferred_name` claim exists. Use a neutral “Student” fallback for a known author with no usable name. Historical notes with no author show “Author unavailable”; never assign them to the current viewer/owner. Listening Guy-created notes record the initiating student and optional assistant provenance.
+- [ ] **Define collaboration permissions.** Course viewers may read only the shared material; permitted commenters may add notes as themselves. Authors edit/delete their own notes; any instructor moderation must be explicit, course-scoped and audited. Peers cannot impersonate authors, delete a split, reset someone else's chat or alter the course prompt. Keep existing anonymous links audio-only unless the owner separately changes that policy.
+- [ ] **Document the access contract.** Centralize checks for roster, split, folder, note, conversation, prompt, export and media paths. Recheck authorization on every request and invalidate/reject cached access after revocation. Keep roster/operational logs free of transcript bodies, raw JWTs and unnecessary personal identifiers.
+
+**Main files:** `src/identity.ts`, `src/index.ts`, `src/assistant/index.ts`, `src/teacher/auth.ts`, `schema.sql`, additive migrations after the existing `0020`, `public/teacher.html`, `public/teacher.js`, `public/app.js`. Extend the local signed-identity/Admission fixtures and ownership/sharing tests. [S1], [S2], [S3], [S4]
+
+### Acceptance tests: course access and collaboration
+
+- [ ] **C1 — Access matrix:** two courses, two instructors, a multi-course student, an unrelated student, an admin, an anonymous visitor, and expired/disabled/revoked users. Course A instructor sees all retained course-A work and zero course-B/personal work. App Instructor alone grants no roster/transcript access. An otherwise admitted student outside the exact target class cannot bypass the portal through the Stem origin, copied URL or API. Test list/search/count/detail/export/audio, upload/import/model actions, enrollment revocation with a valid app session, and tampered/encoded paths and pagination cursors. Preserve unrelated tool policies and public audio-only access.
+- [ ] **C2 — Roster:** a student with zero splits appears; more than one page of students/splits remains stable and has no duplicates. Finished, pending, failed, unavailable and expired items have accurate states. A query-count assertion prevents an N+1 roster implementation.
+- [ ] **C3 — Conversations:** authorized instructor opens a student's course transcript without any model call or quota charge. Peer/public/other-course requests cannot retrieve it. More than 60 new entries paginate without loss; duplicate writes are idempotent and stale writes return a recoverable conflict. Reset, student withdrawal and 90-day expiry follow the approved visibility policy.
+- [ ] **C4 — Prompts:** course A edit changes only A's next request/cache; B and personal work retain their own policy. Two instructor saves conflict correctly, a no-op creates no revision, and a stale generation cannot publish under a newer prompt revision. Instructor cannot edit the base prompt or forge course/history fields.
+- [ ] **C5 — Folders:** course members can open permitted items after sign-out/sign-in; outsiders cannot. A cross-course/private item cannot be inserted to broaden access. Removing a grant invalidates the shared URL's protected reads immediately; expired audio remains unavailable.
+- [ ] **C6 — Notes:** two students' notes show their own verified preferred names across browsers and exports. Ignore forged author fields; escape markup/control characters. Missing names and historical unknown authors render distinctly. Assistant-created notes preserve the initiating author; unauthorized edit/delete/moderation fails.
+- [ ] **C7 — Migration:** fresh schema and upgraded schema behave identically; an interrupted/replayed backfill does not duplicate assignments or broaden access. Retention purges new conversation/grant/cache data as designed. Rehearse disabling the feature while preserving existing owner-only access and approved new data.
+
+**Risks and dependencies:** this intentionally changes instructor access to student material. The present code says transcripts are private even from admins, so disclosure/backfill cannot be a blanket role-check bypass. Course identity and preferred-name provenance must come from trusted services. Do not claim the current transcript store is a complete, tamper-proof conversation archive. [S1], [S2]
+
+
+
+### Compact UI and shared-split refresh (included in PR2)
+
+#### Build checklist
+
+- [ ] **Use the requested copy.** Change “Or paste a YouTube link” to **“Paste a link to fetch audio”**. Use **“Split apart a song, listen one layer at a time, annotate as you go.”** as the tagline. Keep supported-source guidance/validation honest; the broader label is not a promise of arbitrary URL support.
+- [ ] **Use “Stem Splitter” throughout the product.** Apply the requested casing to **every visible occurrence**, explicitly including the **application main/home page**, its masthead and headings, all other application screens, browser/page titles, navigation and links, buttons, account and instructor screens, roster/folder/shared views, sign-in/out recovery, loading/empty/error states, accessible names and generated user-facing/export copy. Include the tools portal wherever the product appears; its new link is **Open Stem Splitter**. Audit both static markup and runtime-generated strings. Update the runtime station metadata so initialization or switching back to the splitter cannot restore uppercase text. Remove or override `text-transform: uppercase` on affected product/navigation controls, including inherited/shared button rules; changing HTML alone is insufficient. Preserve technical identifiers such as audiences, environment variables, route IDs and database names. [S13], [S14], [S15]
+- [ ] **Compact the masthead.** Use two compact desktop rows: title at left and the CUNY AI Lab logo flush right on the brand row; tagline at left and My account flush right on the tagline row. Keep title/brand order recognizable. Wrap to a readable mobile layout without crowding sign-in, long names or the title; preserve the logo's existing destination.
+- [ ] **Unify button styling.** Define shared base/control-size tokens for action links and buttons across home, account, instructor, roster, folder, import and mixer surfaces. Match height, typography, radius, borders, padding and interaction states. Equalize sibling action widths where helpful; avoid forcing play buttons and every long label into one width. Retain distinct danger/toggle states, visible keyboard focus, accessible names and comfortable hit areas.
+- [ ] **Promote return navigation.** Add a prominent **Back to Splitter** control near the top of every non-home application page, including account, instructor, roster/folder details, recovery and sign-out states. Make it usable by keyboard and ensure compact mobile placement. Preserve current draft/unsaved-change behavior.
+- [ ] **Add shared-split Refresh.** Use the appropriate protected course endpoint or existing public audio endpoint, depending on sharing mode. Fetch current metadata/permissions/notes and merge updates without reloading or reconstructing audio. Keep play position, speed, mute/solo, volume, loop, focus and unsaved note drafts. Show progress, last-updated and a calm failure state; block duplicate clicks, reject stale responses, and handle expired/revoked access. Public refresh must not fetch private notes or conversations. [S9]
+- [ ] **Add bottom support.** Place **Having trouble?** at the very bottom of the application content between two horizontal rules, using the verified Lab email route. Keep copyright/retention notices and navigation readable. Do not auto-attach account details, conversation text or source URLs to support mail.
+- [ ] **Apply economical spacing consistently.** Refine headers, section gaps, form rows and control groups alongside this PR's course features. PR3 must reuse these controls and review its new usage/recovery states against them. Include instructor roster/prompt states and usage/recovery states in the visual review, not just the homepage. Update asset cache versions on all affected entry pages so returning users receive one consistent stylesheet.
+
+**Main files:** `public/index.html`, `public/app.js`, `public/styles.css`, `public/account.html`, `public/account.js`, `public/teacher.html`, `public/teacher.js`, new course views in this PR and recovery markup in `cloudflare/sso.ts`. Preserve permission semantics while changing presentation. [S9], [S10], [S11]
+
+#### Acceptance tests
+
+- [ ] **U1 — Responsive layout:** inspect 320/360/390/414/540/768/1024/1440px plus 200% native browser zoom where supported and a separately labeled CSS-zoom check. No horizontal overflow, clipped buttons, logo/account collision or inaccessible controls. Include long student/course names and validation/error states.
+- [ ] **U2 — Preserve PR27:** retain the existing annotation test for positive gap above the speed row, input/SAVE fit, initial focus, duplicate-open/Escape, repeated save/reload, all speeds and playback while typing. Exercise a shared course split with multiple author names too. Do not restore the negative-margin collision. [S11]
+- [ ] **U3 — Refresh without interruption:** two signed-in course members add/delete permitted notes; Refresh retrieves changes while audio clocks keep advancing. Position, speed, mute/solo, loop, volume and typed draft remain unchanged. Test offline, delayed/out-of-order response, repeated clicks, revoked sharing, expiry and public audio-only mode.
+- [ ] **U4 — Navigation and support:** top Back to Splitter works on every non-home page and preserves the correct safe return path. Footer help sits between both rules, opens the verified destination, and adds no personal data. Keyboard focus order is logical at desktop/mobile widths.
+- [ ] **U5 — Control states and accessibility:** compare enabled/disabled/loading/pressed/danger/focus states across pages. Require at least 44px hit height for primary touch actions and at least 24px targets with adequate separation for compact mixer controls; verify readable contrast, reduced motion and forced-colors behavior. Similar actions have consistent sizes without sacrificing labels or expanding page height unnecessarily.
+- [ ] **U6 — Browser regression:** Chrome, Firefox and WebKit cover the new screens, public/course shares, quota messages, refresh and the existing playback/seek/mute/download/reload flows. Capture real screenshots and review them; passing bounding-box assertions alone is insufficient.
+
+- [ ] **U7 — Product casing:** inspect every visible product-name occurrence on the main/home page and all other application and portal surfaces, including headings, navigation, instructor screens, accessible names, browser titles, loading/error states and generated credits/download copy. Product labels read **Stem Splitter** and the portal link reads **Open Stem Splitter** at every viewport. Check computed `text-transform` through inherited/shared styles, runtime initialization and supported station-switching paths; screenshot review must show the requested casing. Technical identifiers remain stable.
+
+- [ ] **U8 — Seek directly on every waveform:** each track waveform in every song supports pointer click/drag, touch and named keyboard controls. Reuse the main slider's canonical preview/commit/cancel path and synchronized audio clocks; preserve playing/paused state and playback rate. Cover unloaded/invalid durations, both bounds, off-element release/cancellation, repeated rapid seeks, switching tracks/jobs, duplicate-listener and stale-timer prevention, narrow screens and actual native zoom in Chrome, Firefox and WebKit. Keep all prior slider, playback and PR27 regression checks.
+
+**Risks and dependencies:** global CSS selectors can regress specialist mixer controls and PR27. Refresh must use the right authorization projection and must not create a second audio engine. Compact layout is not permission to reduce touch targets or hide support/navigation. Course states ship with their authorization in this PR; PR3 adds and verifies the later accounting states using these same components.
+
+## PR 3 — Correct allowances, reliable imports and model fallback
+
+**Suggested title:** `Count successful splits and make upstream failures recoverable`
+
+### Added guest and observability work
+
+- [ ] **Separate guest authority:** implement a signed, high-entropy Secure/HttpOnly/SameSite guest cookie with server-side expiry/revocation and separate guest ownership records. Do not fabricate CUNY subjects or insert guest identities into `app_users`. Allow only the guest's own private app scope, same-origin writes and explicitly public audio projections. Existing member cookies always use the verified SSO path; no automatic guest downgrade after invalid, expired, suspended or revoked membership. Course prompts, course-assigned work, rosters, instructor/admin APIs and other people's transcripts remain inaccessible.
+- [ ] **Guest 5/25 successful-use accounting:** reuse the durable reservation/settlement/replay mechanism for five successful splits and 25 human Listening Guy inputs per guest per UTC day. Failed work without usable output releases once; partial output counts once; ambiguous provider starts cannot be blindly repeated. Enforce independent attempt/cookie-reset abuse protection and shared concurrency limits. Explain that an anonymous browser session cannot establish a unique human; an IP is not ownership. Show remaining/in-progress/completed counts and the reset, with sign-in offered for the separate eligible-member allowance.
+- [ ] **Guest model and bot-verification boundary:** use an existing documented Gateway application-principal capability only after a separately approved sponsor grant/credential and bot-verification configuration. Do not repurpose a person's JWT, revive the old OpenRouter key, bypass Gateway policy or change shared Admission. Local fixture tests may exercise the complete opt-in path; missing authority/configuration fails closed. Record concrete activation gates without pretending guest use is already deployed. The September 29 record shows guest access was left unimplemented pending private ownership and bot verification; its older 3-attempt policy is superseded by this explicit 5-success/25-input request.
+- [ ] **Redacted operation and use events:** use existing authorized D1/Worker logging only. Record meaningful app visits, import/split/chat/guide lifecycle, playback/seek/download and annotation outcomes with correlation/event/operation IDs, duration, bounded error category, model/fallback and quota effect as applicable. No raw audio, chat text, credentials, signed/source URLs, names or unnecessary student identifiers. Do not send data to a new external telemetry destination.
+- [ ] **Event retention, reliability and access:** retain detailed use events for a bounded documented period (proposed 30 days), separately from the minimal operation evidence needed for unresolved reconciliation. Critical server settlement events are durable and deduplicated; browser activity is best effort, validated/rate-limited and deduplicated. State possible losses/replays and avoid claiming perfect capture. Keep event reads access-controlled and provide a useful documented way to assess outcomes and improvement areas.
+- [ ] **Guest and event acceptance:** test fresh/resumed/expired/tampered/replayed guest sessions, logout/login transitions, two guests and member isolation, forbidden course paths, simultaneous fifth/sixth splits and 25th/26th inputs, midnight, cookie-reset abuse controls, failure releases, partial/unknown output, signed-out UI and event redaction/deduplication/retention. Real bot verification, sponsor entitlement and production activation remain separate evidence.
+
+### Build checklist: accounting
+
+- [ ] **Replace attempt counting with explicit settlement.** Set the student allowance to 15 successful splits per UTC submission day across courses. Validate identity, course access, body, URL and model before reserving capacity. Atomically require `successful + active reservations <= 15`; show “completed”, “in progress” and “available” separately so pending work is not described as used success.
+- [ ] **Track one durable operation through every stage.** Use a stable idempotency key bound to subject, authorized scope and request fingerprint; reject the same key with different input. Tie the reservation, import, separation prediction, job and settlement together. Commit one success only after valid, decodable stems are durably stored and the job reaches `done`. Duplicate submit, reconnect, webhook, poll and rerun of the same operation must not charge twice.
+- [ ] **Release failed work without erasing cost evidence.** Invalid links, failed YouTube fetches, failed provider starts/processing, canceled operations and terminal invalid audio do not consume successful-split allowance. Refund/release exactly once using an auditable event/state transition. Provider failures may still cost money: retain separate cost/attempt records and abuse controls; do not call this a provider-billing refund.
+- [ ] **Handle unknown outcomes and restarts.** A lost provider-start response is not proof that no job started. Persist an uncertain state, reconcile using available provider evidence and avoid blind resubmission. Leases need expiry, a recovery owner and fencing against late completion; do not free a reservation while a late callback can still commit success without rechecking capacity. Settling an old operation never debits the new day or produces negative counters.
+- [ ] **Add the 50-input guard.** Enforce the agreed Listening Guy rule server-side even with `assistantTransport` enabled. Reserve once per human message, settle once across retries/fallbacks/tools, and preserve Gateway's separate monetary allowance. Cached reads, instructor transcript review and automatic opening guides do not consume a human-input slot. Bound automatic guide generation independently so cache races cannot bypass cost controls.
+- [ ] **Update account and failure states.** Show the two app counters and reset time distinctly from estimated Lab model budget. Failed split/import returns leave the success balance unchanged and display a useful next action. Keep HTTP/status semantics and operator correlation IDs while removing raw provider errors, internal hosts and secrets from student-facing text.
+
+### Build checklist: import reliability
+
+- [ ] **Diagnose the reported regression before replacing a provider.** Record a sanitized failing/working pair, canonical video ID, request correlation, UTC time, failure stage, provider status, pinned extractor/runtime versions and sequential/concurrent results. Distinguish URL/validation, account or quota, network/region, source restrictions, fetcher build, provider capacity/credit, media conversion and application timeout/storage faults. No failing example was supplied or reproduced in this planning turn.
+- [ ] **Address concrete source weaknesses.** `replicate-yt-audio/cog.yaml` currently leaves `yt-dlp` unpinned and declares FFmpeg but no JavaScript runtime/EJS dependency; `_run()` has no subprocess timeout. Verify the running image separately from this source, then pin/test compatible dependencies, bounded subprocess execution, cancellation and cleanup. The fetcher already allocates a unique temporary directory; preserve that isolation and test concurrent cleanup rather than assuming a filename collision. [S6], [S7], [E4]
+- [ ] **Respect source restrictions.** Audit the existing client-rotation loops and stop on explicit challenge/authentication/private/age/region/DRM/removed-content denials. Do not add cookie harvesting, CAPTCHA workarounds, proxy rotation, identity disguise or retries designed to defeat a block. Maintain supported extractor compatibility for permitted sources. Neither a third-party fetcher nor YouTube Data API is a guarantee or permission to download every URL. [E4], [E5]
+- [ ] **Coordinate requests durably.** Add a shared import coordinator/queue with per-user fairness, provider-wide concurrency, bounded queue length and separate fetch/separation capacity. The existing 30/minute IP limiter is not a concurrency limit. Persist queued/importing/separating/reconciling/terminal state so closing a tab or restarting a Worker does not lose work. Select conservative numeric caps from provider/account evidence; local fragment-download settings do not cap app-wide requests.
+- [ ] **Coalesce safe duplicates and cache selectively.** One authorized logical operation gets one upstream start. Use distributed single-flight for identical permitted fetches within the approved authorization scope; concurrent waiters have bounded deadlines and cancellation rules. Reuse only authorized stored bytes, with ACL checks, provenance and expiry no later than the original 90-day retention/rights boundary. Do not cache signed URLs, cookies, failed HTML/challenges or unknown-permission content, and do not leak another course's activity through cache responses.
+- [ ] **Make retry policy precise.** Retry only classified transient failures, with a total deadline, capped attempts, exponential backoff/jitter and a shared cooldown. Honor `Retry-After` seconds and HTTP dates without shortening the server's wait; defer in the queue if it exceeds the interactive deadline. The current separator retry caps a wait at 15 seconds, which can retry too early. Never multiply retries across browser, Worker and provider layers or repeat an ambiguously accepted paid start. [S8], [E6], [E7]
+- [ ] **Keep inputs and media bounded.** Reuse canonical YouTube parsing for watch/short/share/music/embed/live-link forms while rejecting actual live streams, playlists without a selected video, malformed IDs, misleading hosts, credentials/ports and unsupported schemes. Allowlisted redirects must not reach private/internal addresses; subprocess URLs stay separate arguments after `--`. Retain the 15-minute/100MB limits, verify decoded nonzero duration, correct container/audio and playable stems, and clean partial objects on terminal failure.
+- [ ] **Offer useful permitted alternatives.** Keep local upload and authorized Archive sources. If authorized direct-download links are added, use an explicit provider/host policy plus SSRF/redirect controls; changing the label alone must not enable arbitrary fetches. For a blocked YouTube source, offer the original/licensed audio file or the user's own Studio/Takeout download. Do not promise universal YouTube success. [E5]
+
+### Build checklist: Listening Guy fallback
+
+- [ ] **Verify exact model identity and routing.** Preferred order is GLM-5.2 then DeepSeek-V4-Flash-0731. Z.ai documents `glm-5.2`; OpenRouter lists `z-ai/glm-5.2` and `deepseek/deepseek-v4-flash-0731`. DeepSeek direct currently documents `deepseek-flash` serving V4.1 and redirects older aliases, so that route is not proof of the requested dated model. The current Stem Gateway accepts canonical names without `/`; resolve its approved alias-to-provider mapping rather than putting provider-qualified slugs into the existing variable or bypassing Gateway. Verify account access and required streaming/tool behavior; do not silently substitute models. [S5], [E1], [E2], [E3]
+- [ ] **Own fallback in one layer.** Use a configured ordered policy, persistent attempt IDs and shared capacity cooldowns. Preserve the same verified app/Gateway subject and course prompt; do not fall back on Admission denial, exhausted personal/app/Gateway allowance, invalid input or a non-retryable policy rejection. Distinguish a model/provider capacity 429 from a user-budget 429. Never override Gateway `should_retry: false` without a documented distinct fallback permission from its owner.
+- [ ] **Bound and settle attempts.** On a confirmed eligible rejection before output, automatically try the backup once, within one logical input reservation and total deadline. Record the actual model and each provider attempt's outcome/cost; one student input is not necessarily one billable provider attempt. Do not issue a parallel speculative call. A timeout with uncertain acceptance needs cancellation/reconciliation rather than a blind duplicate.
+- [ ] **Protect streams and tools.** Avoid mixing text from two attempts. Once content or tool effects are committed, do not silently regenerate or replay `add_note`/other actions; preserve partial text and offer a plain-language recovery path. Validate and execute tool calls at most once using operation/call IDs. Both-model failure yields a calm recoverable message, keeps the mixer working and settles usage under the agreed rule. This fallback affects Listening Guy, not YouTube fetching or Demucs separation.
+
+**Main files:** `src/daily-allowance.ts`, API middleware/job lifecycle in `src/index.ts`, new focused operation/settlement/import modules, `src/youtube.ts`, `replicate-yt-audio/`, `src/separation/replicate.ts`, `cloudflare/gateway.ts`, `src/assistant/index.ts`, schema/migrations, account/import/chat UI state. Preserve the separation-backend seam and reviewed model pins; no blind `latest` upgrade. [S5], [S6], [S7], [S8]
+
+### Acceptance tests
+
+- [ ] **R1 — Daily limits:** 30 simultaneous split requests for one student across two Worker instances admit at most 15 unresolved/successful operations. A second student has an independent 15. Mixed success/failure settlement releases only failures and allows exactly that many replacements; 16th success cannot appear through callback races. Course switching, logout/relogin and multiple browsers do not reset the counter.
+- [ ] **R2 — No failed-split charge:** malformed body/model/URL, failed YouTube fetch, provider 429/402/5xx, rejected/unknown start, failed/canceled processing, corrupt/empty audio and failed R2 ingestion each have an explicit expected ledger state. Terminal failures leave success count unchanged. A repeated failure/callback/refund cannot create credit or duplicate provider starts.
+- [ ] **R3 — Time and recovery:** test UTC midnight, month/year boundaries, DST display, an operation crossing midnight, expired leases, worker crash, lost start response, duplicate/out-of-order webhook plus polling, retry after network loss, late success after cancellation and storage outage. Old reservations cannot charge/refund the wrong day or allow an unfenced late success.
+- [ ] **R4 — Fifty inputs:** 60 simultaneous distinct human messages admit no more than 50 reservations; exact replay uses one. Test the 51st message, rollover, automatic guide/cache hits, tool calls, failed/partial replies, fallback and instructor review. One chat cannot create two quota charges or two notes. App and Gateway limits remain independently enforced.
+- [ ] **R5 — Queue and duplicate control:** run a local 20-student fixture against at least two Worker/coordinator instances. Assert configured global/per-user fetch and split caps, fair progress, finite queue length and one upstream start per idempotent operation. Same-source coalescing preserves ownership and handles one waiter's cancellation without dropping authorized others. No production load test is implied.
+- [ ] **R6 — Import fault matrix:** cover supported URL forms, shell/SSRF attempts, allowed/disallowed redirects, missing extractor dependencies, provider timeout/429/5xx, challenge/auth/region/removed denial, missing format, corrupt media, disk-full and cancellation/restart cleanup. Explicit denials stop attempts; transient retries obey cooldown and attempt/deadline bounds. A successful metadata fetch alone is not a successful audio import.
+- [ ] **R7 — Retry timing:** injected `Retry-After: 90`, HTTP-date, missing and malformed headers; assert no retry before valid server deadlines and no shortened wait from the old 15-second cap. Confirm retry budgets do not multiply across layers and a known provider ID is polled instead of recreated.
+- [ ] **R8 — Fallback:** primary success makes one attempt; eligible primary capacity rejection makes exactly one ordered backup attempt; quota/auth/policy denial makes none. Missing/mis-mapped backup fails configuration checks. Test both failures, timeout before/after accepted start, cancellation, first-token/trailing-stream failure, malformed tool calls and duplicate tool IDs. Student UI never displays raw provider output or suggests that model switching fixes audio import.
+- [ ] **R9 — Migration/rollback:** preserve existing attempt records as historical attempt evidence, not manufactured successful charges. Rehearse conversion/start date, queue draining and rollback with in-flight work; an old ten-attempt reader must not reinterpret the new success ledger. Document reconciliation and disable switches before any production migration.
+
+**Risks and dependencies:** refunding student allowance does not reverse provider spend. Unbounded retries or failed-job refunds without independent abuse controls can create unlimited cost. Cancellation and lost POST responses require explicit uncertain states. Source compatibility, permission, geographic availability and provider capacity remain separate from an application defect. No current production root cause or exact account-level backup entitlement has been established by this planning pass.
+
+## Shared completion and release gates
+
+- [ ] For each PR, use an isolated branch from its repository's current reviewed lane: portal `main`, Stem `codex/cloudflare-migration`. Recheck active work and owner chats, preserve owner edits and keep the PR description scoped to final behavior. These are three planned PRs; none is created, merged or deployed by this document update.
+- [ ] Keep business rules in focused shared modules; Cloudflare code adapts identity/Gateway/storage. Avoid spreading new course, quota and retry logic through unrelated route branches. Do not introduce a second password/roster authority or bypass the reusable session contract.
+- [ ] Add migrations and canonical `schema.sql` together; validate fresh and existing databases using local/test fixtures. Document data classification, indexes, retention, feature-disable behavior and rollback compatibility. Production migrations need a reviewed backup/recovery plan and separate action authorization.
+- [ ] **Stem PR2/PR3 verification:** run meaningful changed-path tests plus repository-required checks: `bun run test`; from `cloudflare/`, `bun run typecheck`, `bun run test`, the full Chrome suite, Firefox `playback.spec.mjs`, targeted WebKit changes, production dependency audit and the candidate dry run. Require normal GitHub Source gate, Pinned analysis image and Cloudflare checks. Update test counts only from completed runs.
+- [ ] **Portal PR1 verification:** follow the root README and affected-app guides. Run changed My Classes browser tests and Node/Vitest Worker boundary tests (not `bun test` for `createTestHarness` files), Doorway `check:format`, `check:lint`, `check:anti-slop`, `check:types` and dry `build`. If Admission/contracts change, also run affected Admission tests/static/build checks, `check:workspace`, callers/receivers and `test:integration`; broad shared changes require root `bun run check`. PR CI always runs the complete workspace gate. Do not describe local harness tests as production E2E. [P7], [P8]
+- [ ] Before a later authorized release, test the exact merged tree, verify current CUNY account/bindings/retention, use an immutable archive and retain the previous Worker version. Compare live source marker and assets, then verify desktop/mobile behavior and existing audio playback. No deployment is part of this TODO-writing task.
+- [ ] Separate fixture evidence from real acceptance: fresh CUNY MFA, real course roster/Agustina access, two-account shared work and exact-provider model behavior need appropriate approved test accounts/access. Paid inference, production load, credentials and changes to shared services require their own scoped authorization. Never describe local mocks as live classroom acceptance.
+
+## Existing backlog retained outside these three PRs
+
+Visitor paid runs/Turnstile, broad legacy-audio migration, Remixer promotion, server Auto/discovery/isolation promotion, new separation-model research and Railway retirement remain deferred. Do not fold them into this request. Preserve their historical plans in [the baseline TODO](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/TODO.md), `docs/archive/roadmap-through-2026-09-29.md`, `MIGRATION.md` and existing acceptance records. Reconcile old prose with actual receipts before asserting that a migration or deployment never happened.
+
+## Verified references
+
+Stem source links below are pinned to deployed `e1918da`, not the repository's unrelated default branch. Portal links are pinned to source `7371f738`, now matched to current Doorway and Admission Cloudflare deployment/version reads. Knowledge-base links are pinned to `e5cd54ad`; course ownership was separately rechecked against current Admission records at 17:12 UTC. Comments describing older class-wide/Railway behavior are historical; current CAIL route checks control. External documentation was checked October 6, 2026; recheck model availability when implementing. No live media fetch, model inference, roster change or production data write was performed for this plan.
+
+[S1]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/identity.ts#L37
+[S2]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/index.ts#L1722
+[S3]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/schema.sql#L310
+[S4]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/assistant/index.ts#L76
+[S5]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/cloudflare/gateway.ts#L9
+[S6]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/youtube.ts#L112
+[S7]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/replicate-yt-audio/predict.py#L23
+[S8]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/separation/replicate.ts#L36
+[S9]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/public/app.js#L2482
+[S10]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/cloudflare/sso.ts#L78
+[S12]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/daily-allowance.ts#L1
+[S11]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/cloudflare/playback.spec.mjs#L54
+
+- Current quota and reservation timing: [daily-allowance.ts](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/daily-allowance.ts#L1), [middleware](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/index.ts#L173), [successful ingestion](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/index.ts#L1917).
+- Public audio-only projection and folder authorization: [shared routes](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/index.ts#L206), [folder routes](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/src/index.ts#L670).
+- Existing app-wide instructor copy and extractor build: [teacher.html](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/public/teacher.html#L37), [cog.yaml](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/replicate-yt-audio/cog.yaml), [pinned identity dependency](https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/cloudflare/package.json).
+
+[S13]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/public/index.html#L27
+[S14]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/public/app.js#L3524
+[S15]: https://github.com/CUNY-AI-Lab/stem-splitter/blob/e1918da3cb6fc37f9715d4cd143af5a5c6c880c6/public/styles.css#L1642
+[P1]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/apps/doorway/src/access-destinations.ts#L1
+[P2]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/apps/doorway/src/owner-ui.ts#L57
+[P3]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/apps/doorway/src/my-classes.ts#L451
+[P4]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/packages/admission-contract/src/self.ts#L24
+[P5]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/apps/admission/src/registry-access.ts#L109
+[P6]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/apps/doorway/src/welcome.ts#L78
+[P7]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/README.md#L23
+[P8]: https://github.com/CUNY-AI-Lab/cail-tools-admission/blob/7371f7389a826392fa85017649324033e9fcf362/AGENTS.md#L19
+
+[K1]: https://github.com/CUNY-AI-Lab/cail-knowledge-base/blob/e5cd54ad3925c8a78f9e7bfddd508c8f8eb95036/07%20Teaching%20and%20Workshops/CALI%202%20T%28h%29inkering%20Tools%20%28Spring%202026%29.md#L18
+[K2]: https://github.com/CUNY-AI-Lab/cail-knowledge-base/blob/e5cd54ad3925c8a78f9e7bfddd508c8f8eb95036/05%20Infrastructure/CAIL%20Tools%20Admission%20Control.md#L73
+[K3]: https://tools.ailab.gc.cuny.edu/admin/mcp
+[K4]: https://github.com/CUNY-AI-Lab/cail-knowledge-base/blob/e5cd54ad3925c8a78f9e7bfddd508c8f8eb95036/05%20Infrastructure/CAIL%20Doorway.md#L13
+
+[E1]: https://docs.z.ai/guides/llm/glm-5.2
+[E2]: https://openrouter.ai/deepseek/deepseek-v4-flash-0731
+[E3]: https://api-docs.deepseek.com/quick_start/pricing/
+[E4]: https://github.com/yt-dlp/yt-dlp/wiki/EJS
+[E5]: https://developers.google.com/youtube/terms/developer-policies
+[E6]: https://docs.aws.amazon.com/wellarchitected/latest/framework/rel_mitigate_interaction_failure_limit_retries.html
+[E7]: https://replicate.com/docs/topics/predictions/rate-limits
+
+- **Model identifiers:** [Z.ai API example][E1], [OpenRouter GLM-5.2](https://openrouter.ai/z-ai/glm-5.2), [OpenRouter dated DeepSeek][E2], [DeepSeek direct current model mapping][E3], [DeepSeek July 31 release history](https://api-docs.deepseek.com/updates/). Catalog presence does not establish CAIL Gateway routing or this account's entitlement.
+- **Extractor/runtime compatibility:** [official yt-dlp repository](https://github.com/yt-dlp/yt-dlp), [EJS requirements][E4], [YouTube extractor notes](https://github.com/yt-dlp/yt-dlp/wiki/Extractors#youtube). Use these for supported maintenance, not access-control circumvention.
+- **Permitted alternatives:** [YouTube API audiovisual restrictions][E5], [YouTube terms](https://www.youtube.com/static?template=terms), [download your own uploads](https://support.google.com/youtube/answer/56100?hl=en). The Data API is not an audio-download fallback; permitted original-file upload remains necessary.
+- **Asynchronous work and retry limits:** [Replicate prediction lifecycle](https://replicate.com/docs/topics/predictions/create-a-prediction), [Replicate rate limits][E7], [bounded backoff/jitter guidance][E6]. These inform the design; they do not prove the current fetcher image or live account's behavior.
diff --git a/cloudflare/README.md b/cloudflare/README.md
index f5efdc6..6dacd03 100644
--- a/cloudflare/README.md
+++ b/cloudflare/README.md
@@ -1,5 +1,15 @@
# Cloudflare deployment
+## Classroom candidate — October 6, 2026
+
+The prospective course implementation and migration `0021` are described in
+[the classroom access contract](../docs/classroom-access.md). This is source and
+local/test evidence, not a deployment receipt. Historical work stays private;
+production migration, Admission release, and live acceptance require their
+separate release gates. `TODO.md` records the coordinated PR1–PR3 status.
+The [local acceptance record](../docs/acceptance/classroom-2026-10-06.md) lists
+test results, screenshots, migration rehearsal and dependency-audit limits.
+
## Current authority — September 29, 2026
The canonical app is https://stem-splitter.ailab-452.workers.dev/. Release from
diff --git a/cloudflare/account.spec.mjs b/cloudflare/account.spec.mjs
index 3c4b9ff..a2c8804 100644
--- a/cloudflare/account.spec.mjs
+++ b/cloudflare/account.spec.mjs
@@ -94,11 +94,11 @@ test('Verified sessions return to Splitter directly; unavailable verification of
available = true;
await page.getByRole('link', { name: 'Retry', exact: true }).click();
await expect(page).toHaveURL(url.href);
- await expect(page.getByRole('heading', { name: 'STEM SPLITTER', exact: true })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Stem Splitter', exact: true })).toBeVisible();
await expect(page.getByRole('link', { name: 'My account', exact: true })).toBeVisible();
await page.goto(expiredLink);
await expect(page).toHaveURL(url.href);
- await expect(page.getByRole('heading', { name: 'STEM SPLITTER', exact: true })).toBeVisible();
+ await expect(page.getByRole('heading', { name: 'Stem Splitter', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: /sign.in/i })).toHaveCount(0);
expect(identityChecks).toBe(3);
expect((await context.cookies(origin)).find(cookie => cookie.name === SESSION_COOKIE)?.value).toBe(token);
@@ -135,7 +135,7 @@ test('Sign out submits a trusted origin, clears cookies and returns to Splitter'
} else if (path === '/api/model-quota') {
await route.fulfill({ json: { quota: null } });
} else if (path === '/') {
- await route.fulfill({ contentType: 'text/html', body: '
', { status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer' } });
for (const value of cleared) response.headers.append('Set-Cookie', value);
return response;
}
diff --git a/cloudflare/test-worker.ts b/cloudflare/test-worker.ts
index 4a280e5..2141b2e 100644
--- a/cloudflare/test-worker.ts
+++ b/cloudflare/test-worker.ts
@@ -1,8 +1,10 @@
+import { courseFixture } from './course-fixture.ts';
+import { STEM_COURSE_ID } from '../src/classroom/contract.ts';
// Local test entrypoint only. Never referenced by the deployment config.
import preview, { type WorkerEnv } from './worker.ts';
import { purgeExpiredListeningConversations } from './retention.ts';
import { SESSION_COOKIE, type WorkerIdentity } from './sso.ts';
-type TestEnv = WorkerEnv & { TEST_JWKS: string; TEST_ADMIN: string; TEST_BROWSER?: string };
+type TestEnv = WorkerEnv & { TEST_JWKS: string; TEST_ADMIN: string; TEST_BROWSER?: string; TEST_ROSTER_SUBJECT?: string };
let gatewayCalls = 0;
let gatewayMessages: Array<{ role: string; content: string }> = [];
export default {
@@ -26,6 +28,8 @@ export default {
const jwt = request.headers.get('x-fixture-identity');
const gatewayJwt = request.headers.get('x-fixture-gateway-identity') || '';
const gatewayMode = request.headers.get('x-fixture-gateway-mode');
+ const courseOwner=request.headers.get('x-fixture-course-role')==='owner';
+ const courseRevoked=request.headers.get('x-fixture-course-state')==='revoked';
const fixtureToken = '00000000-0000-4000-8000-000000000001.' + 'a'.repeat(43);
const identity: WorkerIdentity = {
begin: async () => { throw new Error('No fixture login'); },
@@ -62,7 +66,11 @@ export default {
gatewayMode === 'trailing-error' ? error : { choices: [], usage: { total_tokens: 20 } }];
return new Response(chunks.map(chunk => `data: ${JSON.stringify(chunk)}\n\n`).join('') + 'data: [DONE]\n\n', { headers: { 'Content-Type': 'text/event-stream', 'x-request-id': requestId } });
} } as Fetcher,
- ADMISSION_RESOLVER: { resolveMembership: async ({ subject }) => ({
+ ADMISSION_RESOLVER: {
+ resolveCourseAccess: async ({subject,classId}) => courseRevoked?{ok:false,code:'revoked',retryable:false}:courseFixture(subject,classId,courseOwner),
+ listCourseAssignments: async ({subject}) => {const assignment=await courseFixture(subject,STEM_COURSE_ID,courseOwner);return {ok:true,checkedAt:assignment.checkedAt,expiresAt:assignment.expiresAt,revision:1,assignments:[(({ok,...row})=>row)(assignment)],nextCursor:null};},
+ listCourseRoster: async ({subject,classId}) => {const assignment=await courseFixture(subject,classId,true);return {ok:true,classId,checkedAt:assignment.checkedAt,expiresAt:assignment.expiresAt,revision:1,participants:env.TEST_ROSTER_SUBJECT?await Promise.all([env.TEST_ROSTER_SUBJECT,'cail-00000000000000000000000000000000'].map(async member=>{const row=await courseFixture(member,classId);return {memberId:row.memberId,displayName:member===env.TEST_ROSTER_SUBJECT?'Fixture Student with a long preferred display name':'Zero split student',displayNameSource:row.displayNameSource,startsAt:row.startsAt,expiresAt:row.expiresAt};})):[],nextCursor:null};},
+ resolveMembership: async ({ subject }) => ({
ok: true, expiresAt: new Date(Date.now() + 60000).toISOString(), revision: 1,
accessRole: subject === env.TEST_ADMIN ? 'admin' : 'member',
budgetScope: subject === env.TEST_ADMIN ? 'admin' : 'person',
diff --git a/cloudflare/worker.test.ts b/cloudflare/worker.test.ts
index 66f4932..1e2606f 100644
--- a/cloudflare/worker.test.ts
+++ b/cloudflare/worker.test.ts
@@ -211,7 +211,7 @@ test('workerd: signed identities, write-once audio, full split ingestion, owners
const grantRole = { method: 'PUT', body: JSON.stringify({ role: 'instructor', expiresAt: new Date(Date.now() + 60000).toISOString(), disabled: false, revision: alice.revision }) };
assert.equal((await call(`/api/admin/users/${subjects[0]}`, 2, grantRole)).status, 200);
assert.equal((await call(`/api/admin/users/${subjects[0]}`, 2, grantRole)).status, 409);
- assert.equal((await call('/api/teacher/prompt')).status, 200);
+ assert.equal((await call('/api/teacher/folders')).status, 200);
const permanentBody = { role: 'instructor', expiresAt: null, disabled: false, revision: alice.revision + 1 };
for (const expiresAt of [undefined, '', 'invalid', false, 0, new Date(Date.now() - 1000).toISOString()]) {
assert.equal((await call(`/api/admin/users/${subjects[0]}`, 2, { method: 'PUT', body: JSON.stringify({ ...permanentBody, expiresAt }) })).status, 400);
@@ -226,9 +226,9 @@ test('workerd: signed identities, write-once audio, full split ingestion, owners
assert.equal(updated.role_expires_at, null);
assert.equal(updated.revision, alice.revision + 2);
assert.equal((await (await call('/api/account')).json()).account.role, 'instructor');
- assert.equal((await call('/api/teacher/prompt')).status, 200);
+ assert.equal((await call('/api/teacher/folders')).status, 200);
assert.equal((await call('/api/admin/users')).status, 403);
- assert.equal((await call('/api/teacher/prompt', 1)).status, 403);
+ assert.equal((await call('/api/teacher/folders', 1)).status, 403);
// The existing immutable trigger records the explicit permanent grant.
const audit = await worker.fetch('/__fixture/schema', { method: 'POST', headers: { 'x-fixture': 'local-only' }, body: JSON.stringify([
'CREATE TABLE audit_check (ok INTEGER CHECK (ok = 1))',
diff --git a/docs/acceptance/classroom-2026-10-06.md b/docs/acceptance/classroom-2026-10-06.md
new file mode 100644
index 0000000..fd14fb2
--- /dev/null
+++ b/docs/acceptance/classroom-2026-10-06.md
@@ -0,0 +1,99 @@
+# Classroom candidate checks — October 6, 2026
+
+This is local candidate evidence for PR2, based on baseline
+`e1918da3cb6fc37f9715d4cd143af5a5c6c880c6`. It is not a deployment or live-account
+acceptance receipt. The [access and migration contract](../classroom-access.md)
+defines the authority, prospective disclosure, retention, pause and release
+policy. The independent reviewer records their own findings separately.
+
+## Automated checks
+
+| Check | Result | Local evidence |
+| --- | --- | --- |
+| Root `bun run test` | Typechecks; 321 Worker, 42 server, 5 separator, 30 discovery and 9 comparator tests pass | `/tmp/stem-pr2-root-final.log` |
+| Cloudflare `bun run typecheck && bun run test` | 50 tests pass | `/tmp/stem-classroom-unit-portable.log` |
+| Full Cloudflare Chrome browser suite | 36 tests pass | `/tmp/stem-pr2-chrome-final.log` |
+| Firefox playback, sharing and classroom suite | 25 pass; native Chromium zoom intentionally skipped | `/tmp/stem-pr2-firefox-final.log` |
+| WebKit playback, sharing and classroom suite | 25 pass; native Chromium zoom intentionally skipped | `/tmp/stem-pr2-webkit-final.log` |
+| Native Chromium 200% follow-up | Waveform mouse and keyboard seek move both stems together; note/speed spacing passes | `/tmp/stem-pr2-native-zoom-final.log` |
+| Fully mocked local-hosting browser suite | 19 tests pass | `/tmp/stem-local-hosting-final.log` |
+| Local Wrangler D1 baseline → migration 0021 | Old private jobs, note text and conversation revision/text unchanged; zero course assignments manufactured; lease column present | `/tmp/stem-classroom-d1-reviewed.log` |
+| Cloudflare Wrangler dry build | Pass; no deployment | `/tmp/stem-pr2-dryrun-final.log` |
+| Production dependency audit, Cloudflare | No vulnerabilities in 6 audited packages | `/tmp/stem-classroom-cloudflare-audit.log` |
+| Production dependency audit, legacy root | Four pre-existing moderate Hono advisories; frozen root lock unchanged | `/tmp/stem-classroom-root-audit.log` |
+
+The actual Cloudflare production and test bundles both alias Hono to
+`hono-cloudflare` 4.13.11. The separate root Hono 4.12.34 audit reports
+`GHSA-gqvv-2mrq-wpjv`, `GHSA-g6gw-c38x-mqfc`, `GHSA-crvj-82cr-hjcx`, and
+`GHSA-hxh3-vqpv-xpqv`. This PR does not claim the frozen native-analyzer/root
+dependency tree is clean or change the Railway lane.
+
+The strict wire tests mirror the byte-for-byte Portal fixture pinned at
+`5f13f332c120c6712aead2a355b3cf5bcc6dae58`. They cover malformed UTC dates,
+inverted validity windows, stale receipts and access markers on plain assignment
+pages. Conversation tests cover concurrent duplicate input, late completion
+after reset with the same client message ID, interrupted claims, and storage
+failure before/after commit. Recovery preserves earlier messages and does not
+repeat an uncertain model operation. The upgrade fixture has a pinned baseline
+commit and digest; it no longer depends on Git history being present in CI.
+
+## Browser observations
+
+The Browser plugin was not available. Repository Playwright drove isolated
+local Workers with synthetic identities, roster entries, stored messages and
+audio. No paid model/separation calls or real account mutations were made.
+Local URL ports are assigned per test; the exercised page routes and titles are:
+
+| Route | Title | Interaction and observation |
+| --- | --- | --- |
+| `/?job=course-song` | Stem Splitter | Course peer reads named notes, refreshes during real audio playback, keeps audio element/time/rate/loop/volume and drafts; access revocation clears saved private content and stops playback |
+| `/teacher.html` | Stem Splitter · Instructor | Current course owner edits that course's prompt; stale revisions conflict; changing accounts clears drafts |
+| `/classroom.html` | Stem Splitter · Courses | Owner sees a zero-split student, reads all 70 test messages through pagination, creates a private course folder and edits course grants |
+| `/account.html` | Stem Splitter · Account | Account actions remain separate from verified course ownership; existing recovery/sign-out browser coverage passes |
+| Existing public shared route | Stem Splitter | Refresh uses only the public audio projection and never requests private job or conversation routes |
+
+Refresh and classroom workflows collect uncaught page errors and finish with an
+empty collection. Offline failures and revoked access are deliberately injected
+and shown as recoverable status messages. Tests assert overflow and usable
+controls at widths 320, 360, 390, 414, 540, 768, 1024 and 1440. Course and sharing
+selectors have actual rendered heights of at least 44 pixels in all three
+engines. Additional browser regressions hold account checks and course requests
+open, return 503/network failures, switch courses twice, and finish stale prompt,
+preview, history and folder requests. They verify hidden/inert private views,
+paused audio, same-account draft recovery and writes bound to the originating
+course. PR27's note-entry/speed-control spacing checks remain in the suite.
+
+CSS layout zoom and native browser zoom are different checks. The native
+Chromium test calls the browser's `tabs.setZoom(2)` in a disposable test profile,
+asserts `getZoom() === 2`, doubled device-pixel ratio and reduced CSS viewport,
+then clicks a waveform, uses Home/ArrowRight and checks that both audio clocks
+move together before checking the note/SAVE and speed controls. Its audio fixture
+honors byte-range requests, matching the real media endpoint. The screenshot uses the browser's
+visible-tab capture, not CSS zoom. Firefox and WebKit skip only this
+Chromium-specific native-zoom test and retain their layout/interaction checks.
+
+Every stem waveform now contains a named native range input. Waveforms and the
+main slider use the same preview, cancellation, keyboard and single multi-stem
+commit path. Browser checks cover all six track rows, narrow layouts, paused and
+playing clocks, playback rate, same-song track changes, two songs, missing/invalid
+durations, detached sessions and stale pointer input. All three engines exercise
+native mouse drags, keyboard input and touch taps; Chromium additionally uses
+real touch-start/move/end events for a drag. Existing two-, four- and six-stem
+playback, mix/focus, seek and waveform-peak checks remain passing.
+
+Screenshots are outside the repository under
+`/tmp/stem-classroom-screenshots/`. Reviewable examples:
+
+- `classroom-refresh-mobile-chrome.png`: note draft and Refresh failure state.
+- `classroom-roster-folders-desktop-chrome.png`: zero-split roster and course work.
+- `classroom-folder-detail-chrome.png`: scoped folder and sharing controls.
+- `classroom-roster-folders-mobile-webkit.png`: narrow course/folder layout.
+- `classroom-prompt-desktop-chrome.png`: saved course prompt controls.
+- `classroom-native-browser-200-chromium.png`: actual 200% browser zoom.
+- `waveform-seek-focus-chrome.png`: keyboard focus on a narrow waveform control.
+- `classroom-account-unavailable-chrome.png`: private view concealed during an outage.
+
+These checks do not prove a real CUNY MFA return, live enrollment/owner state,
+production permissions, real provider output or remote migration. Those remain
+separate authorized release acceptance steps. No deployment or live migration
+was performed.
diff --git a/docs/classroom-access.md b/docs/classroom-access.md
new file mode 100644
index 0000000..a86ae81
--- /dev/null
+++ b/docs/classroom-access.md
@@ -0,0 +1,160 @@
+# Prospective classroom work
+
+This candidate uses the existing private `AdmissionResolver` binding. The wire
+contract and byte-for-byte fixture are pinned to cail-tools-admission commit
+`5f13f332c120c6712aead2a355b3cf5bcc6dae58`,
+`packages/admission-contract/src/course.ts` and
+`packages/admission-contract/fixtures/stem-course-v1.json`. No public registry
+endpoint, roster import, new binding, or app-wide instructor promotion is added.
+
+## Authority and disclosure
+
+The production default accepts only
+`msh-245-the-american-musical-experience-fall-2026-01`. The trusted
+`CAIL_COURSE_IDS` configuration can explicitly allow additional courses; it does
+not replace the exact target-class student entry requirement. A student enrolled
+only in another configured course cannot enter the workspace. A current verified
+course owner can use their independent teaching relationship. The existing
+Admission/app suspension and separately granted app operational roles remain.
+
+Every protected request checks current identity, Admission, local disablement,
+and exact course authority. Responses are private/no-store. An unavailable,
+malformed, expired, scheduled, or stale authority receipt never grants access.
+`X-Stem-Course` is a selector. A resource's stored `job_courses.course_id` takes
+precedence over it. Course owners do not become app instructors or admins.
+`X-Stem-Account` identifies only the requesting account so a restored or changed
+browser session can discard the previous account's visible state.
+An identity recheck immediately hides and disables the private view and pauses
+its audio. An outage keeps the view concealed with a Retry action. Only a
+verified same-account response restores its drafts; account changes clear them.
+Course selections also have independent request generations: pending/failed
+loads clear and disable prior course data, and late reads or writes cannot
+replace the newly selected course's prompt, history, roster or folder state.
+
+New work defaults to Personal. Course work requires explicit selection and the
+`course-work-v1` disclosure acknowledgment before creation. Job ownership and
+course association are inserted together. Associations are immutable. Existing
+jobs, annotations, and `listening_conversations` documents are never assigned,
+backfilled, copied to the course store, or disclosed to an instructor. No endpoint
+changes an existing personal split to course work.
+
+| Actor | Personal/unassigned work | Assigned course work | Conversation | Course settings/folders |
+| --- | --- | --- | --- | --- |
+| Current job owner | Existing operational access | Read, notes, own work actions | Own read/chat/reset | Current participant may read granted folders |
+| Current course owner | No access to another owner's work | Read retained course work; comment as self | Separate read-only review; no generation or reset | Edit only their course prompt and folder grants |
+| Current course participant | No access to another owner's work | Read only through a read/comment folder grant; comment only with comment grant | Never a classmate's conversation | Read granted folders only |
+| App instructor only | Existing own-work and legacy instructor scope | No added course privilege | No added access | No course prompt/roster privilege |
+| App admin only | Existing operational job/media scope retained | No course bypass | Personal transcript remains owner-only | No course-owner bypass |
+| Anonymous/public link | Existing explicit audio-only projection | Same explicit audio-only projection | None | None |
+
+Course owners create folders; private folders are visible only to current course
+owners. Read/comment grants admit current participants of that same course.
+Every item is checked independently for the same course, retained audio, and
+completed status. Revocation applies to subsequent folder, job, note, and media
+requests. Course work cannot be placed into a legacy app folder. Expired items
+remain labeled placeholders; folder references do not extend audio retention.
+There are no anonymous folder shares. Existing owner-created public audio links
+retain their prior policy and never expose notes, labels, names, or transcripts.
+
+## Messages, names, and prompts
+
+The roster comes from paginated Admission membership, including students with
+zero splits. Course/app-scoped opaque member IDs join it to prospective work;
+no student's raw subject, digest, or email is returned. Split lists, message
+pages, and prompt history are bounded and scoped before reading.
+
+New course messages are append-only within the current conversation generation.
+The server stores accepted student text and produces assistant/tool records.
+Client-supplied assistant history is ignored. A message ID identifies a logical
+turn; a separate random server claim fences concurrent attempts and late output.
+A stale revision returns a recoverable 409. A pending claim has a 120-second
+lease, longer than the model deadline. After a process/storage failure, a
+fenced recovery records a visible server status and an interrupted receipt
+without erasing earlier messages or rerunning uncertain inference. Reusing that
+message ID returns 409; a new message can continue after reload. Expired claims
+cannot publish late replies or note effects. Reset removes messages and replay
+turns for all viewers, advances the revision, and prevents an old completion from
+restoring content even if a client reuses its former message ID. Student and
+instructor reads expire with the source split at 90 days; the scheduled purge
+also deletes message/turn text and guide caches. The historical 60-entry document
+is still private and is not represented as a recoverable complete archive.
+
+Notes record the trusted initiating subject and a bounded display-name snapshot.
+The source mapping is Admission's verified profile name, with the optional
+verified identity `name` as a fallback; there is no assumed `preferred_name`
+claim. Missing usable names show “Student.” Historical unknown authors show
+“Author unavailable.” Browser rendering escapes text, and exports retain names.
+Assistant-created notes retain the initiating author and server provenance.
+Only an author can edit/delete their notes; no implicit instructor moderation.
+
+Each course has an independent prompt revision and immutable audit history.
+Optimistic revision checks prevent lost edits; a no-op does not create history.
+Guide caches include course, base prompt version/hash, and amendment revision.
+Publishing an older in-flight result fails its current-prompt check. Personal
+work uses the shared base prompt, and the Railway singleton remains separate.
+
+## Migration and release rehearsal
+
+Fresh databases use `schema.sql`; existing Cloudflare databases through baseline
+`e1918da3cb6fc37f9715d4cd143af5a5c6c880c6` use
+`migrations/0021-classroom.sql` once. The additive migration preserves all old
+rows and leaves new associations empty. It adds nullable author columns,
+prospective course tables, scoped indexes, and immutable assignment/prompt/grant
+history triggers. It contains no data backfill. The `ALTER TABLE` statements are
+intentionally one-time: do not replay the raw file or apply it to a fresh schema.
+Record its checksum and migration receipt in the deployment journal.
+
+Local tests apply the old schema with private jobs/notes/transcripts, migrate it,
+and compare table/column contracts with a fresh database. They assert unchanged
+private history and zero manufactured associations. Fresh schema reapplication
+is supported; accidental raw migration replay fails at the duplicate column
+without rewriting historical content. Workerd browser tests use fresh D1.
+The old schema is a digest-checked fixture with its baseline commit recorded in
+`cloudflare/fixtures/pre-classroom-schema.md`, so this test also works in shallow
+CI checkouts without fetching repository history.
+
+For a separate authorized rehearsal, from `cloudflare/` use isolated local state:
+
+```sh
+node_modules/.bin/wrangler d1 execute cail-stem-splitter-preview --local --persist-to /tmp/stem-classroom-d1 --file /tmp/stem-baseline.sql
+node_modules/.bin/wrangler d1 execute cail-stem-splitter-preview --local --persist-to /tmp/stem-classroom-d1 --file ../migrations/0021-classroom.sql
+node_modules/.bin/wrangler deploy --config wrangler.jsonc --dry-run --outdir /tmp/stem-classroom-build
+```
+
+These are local/dry-run commands. A production migration or service release is a
+separate authorization gate. Before that gate, record the exact serving Worker
+version, D1 identity, migration journal, backup/time-travel recovery point,
+row-count checks, and a restore rehearsal in a separate database. Apply the
+migration only to the Cloudflare candidate database; never the root legacy
+Worker database or Railway. Release the private Admission methods first, then
+the receiving Stem authorization, then the portal destination. Missing private
+methods fail closed during that sequence. Run real approved-account acceptance
+before claiming the classroom is live.
+
+`CAIL_CLASSROOM_ENABLED=false` is a narrow maintenance pause, tested locally.
+It stops new course creation/writes/model starts, queued course starts through
+`authorizeStoredCourseWork`, instructor review, and folder sharing. Assigned
+work remains readable only through its exact owner's protected paths; existing
+explicit public audio links retain their existing audio-only policy. It does
+not erase associations, retention deadlines, or audit history. Re-enabling
+restores current-authority checks. No live setting is changed by this PR.
+
+Do not roll back to pre-classroom code after course work exists: older admin and
+folder readers do not understand course boundaries. Use the tested pause on a
+schema-aware build while correcting forward. Before any later code rollback,
+drain or explicitly settle queued/in-flight work, preserve the migration and
+operation journals, and verify the rollback build applies the same private
+read policy. PR3's success ledger/queue has an additional migration and drain
+procedure; old attempt accounting must never reinterpret it. Already-started
+provider work may still finish; neither pause nor rollback promises to cancel
+external work or refund it. Retention continues during a pause.
+
+## Verification scope
+
+All committed fixtures use synthetic identities, rosters, model output, and
+local D1/audio. Browser coverage includes widths 320–1440, PR27 annotation
+spacing, preserved audio/control/draft state through Refresh, separate public
+Refresh requests, account changes, instructor review and folder controls.
+Screenshots are review evidence outside the repository. Native Chromium zoom
+and CSS layout zoom are recorded separately. These checks do not establish a
+live CUNY MFA callback, actual classroom roster, or paid provider result.
diff --git a/migrations/0021-classroom.sql b/migrations/0021-classroom.sql
new file mode 100644
index 0000000..2035730
--- /dev/null
+++ b/migrations/0021-classroom.sql
@@ -0,0 +1,150 @@
+-- Prospective classroom policy. No existing split or transcript is assigned.
+CREATE TABLE IF NOT EXISTS job_courses (
+ job_id TEXT PRIMARY KEY REFERENCES jobs(id) ON DELETE CASCADE,
+ course_id TEXT NOT NULL,
+ member_id TEXT NOT NULL,
+ assigned_by TEXT NOT NULL,
+ policy_version TEXT NOT NULL CHECK(policy_version = 'course-work-v1'),
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS job_courses_member ON job_courses(course_id, member_id, job_id);
+CREATE TRIGGER IF NOT EXISTS job_courses_no_update BEFORE UPDATE ON job_courses
+BEGIN
+ SELECT RAISE(ABORT, 'course assignment is immutable');
+END;
+CREATE TRIGGER IF NOT EXISTS job_courses_no_replace BEFORE INSERT ON job_courses
+WHEN EXISTS(SELECT 1 FROM job_courses WHERE job_id=NEW.job_id)
+BEGIN
+ SELECT RAISE(ABORT, 'course assignment is immutable');
+END;
+
+ALTER TABLE annotations ADD COLUMN author_subject TEXT;
+ALTER TABLE annotations ADD COLUMN author_name TEXT;
+ALTER TABLE annotations ADD COLUMN provenance TEXT;
+
+CREATE TABLE IF NOT EXISTS course_settings (
+ course_id TEXT PRIMARY KEY,
+ amendment TEXT NOT NULL DEFAULT '' CHECK(length(amendment)<=2000),
+ revision INTEGER NOT NULL DEFAULT 0 CHECK(revision>=0),
+ updated_by TEXT,
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+CREATE TABLE IF NOT EXISTS course_prompt_revisions (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ course_id TEXT NOT NULL,
+ revision INTEGER NOT NULL CHECK(revision>0),
+ amendment TEXT NOT NULL CHECK(length(amendment)<=2000),
+ change_note TEXT NOT NULL CHECK(length(trim(change_note)) BETWEEN 1 AND 240),
+ base_version TEXT NOT NULL,
+ base_hash TEXT NOT NULL CHECK(length(base_hash)=64),
+ effective_hash TEXT NOT NULL CHECK(length(effective_hash)=64),
+ actor TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(course_id,revision)
+);
+CREATE TRIGGER IF NOT EXISTS course_prompt_revisions_no_update BEFORE UPDATE ON course_prompt_revisions
+BEGIN
+ SELECT RAISE(ABORT,'course prompt history is immutable');
+END;
+CREATE TRIGGER IF NOT EXISTS course_prompt_revisions_no_delete BEFORE DELETE ON course_prompt_revisions
+BEGIN
+ SELECT RAISE(ABORT,'course prompt history is immutable');
+END;
+CREATE TRIGGER IF NOT EXISTS course_prompt_revisions_no_replace BEFORE INSERT ON course_prompt_revisions
+WHEN EXISTS(SELECT 1 FROM course_prompt_revisions WHERE id=NEW.id OR (course_id=NEW.course_id AND revision=NEW.revision))
+BEGIN
+ SELECT RAISE(ABORT,'course prompt history is immutable');
+END;
+CREATE TABLE IF NOT EXISTS course_guides (
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
+ course_id TEXT NOT NULL,
+ text TEXT NOT NULL,
+ model TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ base_version TEXT NOT NULL,
+ revision INTEGER NOT NULL,
+ fingerprint TEXT NOT NULL,
+ PRIMARY KEY(job_id,course_id)
+);
+
+CREATE TABLE IF NOT EXISTS course_conversations (
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
+ subject TEXT NOT NULL,
+ revision INTEGER NOT NULL DEFAULT 0 CHECK(revision>=0),
+ pending_turn TEXT,
+ pending_expires_at TEXT,
+ expires_at TEXT NOT NULL,
+ PRIMARY KEY(job_id,subject)
+);
+CREATE TABLE IF NOT EXISTS course_turns (
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
+ subject TEXT NOT NULL,
+ turn_id TEXT NOT NULL,
+ input TEXT NOT NULL CHECK(length(input) BETWEEN 1 AND 2000),
+ state TEXT NOT NULL CHECK(state IN ('pending','complete','failed')),
+ reply TEXT,
+ tools TEXT,
+ finish_reason TEXT,
+ revision INTEGER NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ PRIMARY KEY(job_id,subject,turn_id)
+);
+CREATE TABLE IF NOT EXISTS course_messages (
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
+ id TEXT NOT NULL UNIQUE,
+ job_id TEXT NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
+ subject TEXT NOT NULL,
+ turn_id TEXT NOT NULL,
+ kind TEXT NOT NULL CHECK(kind IN ('you','coach','action','status')),
+ provenance TEXT NOT NULL CHECK(provenance IN ('student','server-assistant','server-tool','server-status')),
+ text TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS course_messages_page ON course_messages(job_id,subject,seq);
+
+CREATE TABLE IF NOT EXISTS course_folders (
+ id TEXT PRIMARY KEY,
+ course_id TEXT NOT NULL,
+ name TEXT NOT NULL CHECK(length(trim(name)) BETWEEN 1 AND 80),
+ created_by TEXT NOT NULL,
+ permission TEXT NOT NULL DEFAULT 'private' CHECK(permission IN ('private','read','comment')),
+ revision INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+CREATE INDEX IF NOT EXISTS course_folders_course ON course_folders(course_id,id);
+CREATE TABLE IF NOT EXISTS course_folder_items (
+ folder_id TEXT NOT NULL REFERENCES course_folders(id) ON DELETE CASCADE,
+ job_id TEXT NOT NULL,
+ filename TEXT NOT NULL,
+ model TEXT NOT NULL,
+ added_at TEXT NOT NULL DEFAULT (datetime('now')),
+ PRIMARY KEY(folder_id,job_id)
+);
+CREATE TABLE IF NOT EXISTS course_folder_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ folder_id TEXT NOT NULL,
+ course_id TEXT NOT NULL,
+ actor TEXT NOT NULL,
+ permission TEXT NOT NULL,
+ revision INTEGER NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ UNIQUE(folder_id,revision)
+);
+CREATE TRIGGER IF NOT EXISTS course_folder_events_no_update BEFORE UPDATE ON course_folder_events
+BEGIN
+ SELECT RAISE(ABORT,'folder history is immutable');
+END;
+CREATE TRIGGER IF NOT EXISTS course_folder_events_no_delete BEFORE DELETE ON course_folder_events
+BEGIN
+ SELECT RAISE(ABORT,'folder history is immutable');
+END;
+CREATE TRIGGER IF NOT EXISTS course_folder_events_no_replace BEFORE INSERT ON course_folder_events
+WHEN EXISTS(SELECT 1 FROM course_folder_events WHERE id=NEW.id OR (folder_id=NEW.folder_id AND revision=NEW.revision))
+BEGIN
+ SELECT RAISE(ABORT,'folder history is immutable');
+END;
+CREATE TRIGGER IF NOT EXISTS course_folders_audit AFTER UPDATE OF permission ON course_folders
+BEGIN
+ INSERT INTO course_folder_events(folder_id,course_id,actor,permission,revision)
+VALUES(NEW.id,NEW.course_id,NEW.created_by,NEW.permission,NEW.revision);
+END;
diff --git a/public/account.html b/public/account.html
index 410f0c7..4f49543 100644
--- a/public/account.html
+++ b/public/account.html
@@ -3,9 +3,10 @@
-STEM Splitter · Account
-
+Stem Splitter · Account
+
+
My account
@@ -17,23 +18,23 @@
My account
Daily split allowance unavailable.
Model usage unavailable.
Submitted runs count even if they fail. Listening Guy uses your Lab allowance separately.