# Online CBT → App (`app/admin`) — Build Plan & Progress Tracker

> Single source of truth for building the Online CBT in the ZAP app across multiple agent sessions.
> Every session: read this whole file first, do the **next unchecked task**, then update the tracker + session log before ending.

---

## 0. Session protocol (read every time)

1. Read this file top to bottom. Do not re-litigate the **Locked decisions** (§2) — they were settled in a grilling session.
2. Read the `zap-app` skill (`.cursor/skills/zap-app/SKILL.md`) before touching any `app/admin` UI.
3. Pick the **first unchecked task** in §6 whose dependencies are checked. One session = one task (or a small group marked "same session").
4. Before switching any desktop page to the engine: capture the page's AJAX responses on real data (see §5 "Parity check"), then diff after.
5. When done: tick the box, fill in the "Done in" column, append to the **Session log** (§8), and note any new fact in **Discoveries** (§7).
6. Never edit `app/public_html/zap_app/admin/` (stale deploy snapshot).
7. Do not change the DB schema. If a task seems to need it, stop and record it in §7 as a blocker.

---

## 1. Goal

Bring the desktop Online CBT (`admin/cbt*.php`, `question_bank.php`, `manage_questions.php`, `manage-exams.php`, `exam-*.php`, `student-exams.php`, admission CBT authoring) into the app (`app/admin`) with the zap-app Material design, by extracting the inline AJAX/helpers/engine into shared files in `admin/includes/` that **both** desktop and app use.

---

## 2. Locked decisions

| # | Decision |
|---|---|
| Q1 | All three audiences, phased: **staff authoring → students → parents** |
| Q2 | Engine lives in **`admin/includes/`**; **desktop pages are refactored** to use it |
| Q3 | Existing app admission pages (`app/admin/admission-exams.php`, `admission-exam-questions.php`, `admission-exam-submissions.php`) move onto the engine. **`admission-cbt.php` (applicant, logged-out, cross-DB) is NOT ported** — stays on `/private` flow |
| Q4 | `admin/cbt.php` (objective taking engine) is in scope |
| Q5 | **Harden while extracting**: prepared statements / int casts, CSRF on every write, role checks inside engine functions. Same schema, same response shapes |
| Q6 | Same tables, no app-specific columns; app ↔ desktop fully interoperable |
| Q7 | Engine returns **data (arrays)**, never HTML. Renderers: `admin/includes/cbt_desktop_render.php` (byte-identical legacy markup) and `app/admin/partials/zap-cbt-helpers.php` (Material cards) |
| Q8 | Pages keep **posting to themselves with the same POST keys**; page does `require 'includes/cbt_ajax.php'` which dispatches, renders with the page's renderer, and exits. Desktop JS unchanged |
| Q9 | Engine split (§3.1) + one engine for school & admission via a context flag `'school' \| 'admission'` |
| Q10 | New **CBT Center** hub (Pattern E) with tabs Question banks / Exams / To mark; "Online CBT" nav tile → it, visible to all roles the desktop allows |
| Q11 | **Summernote** for question/option authoring in the app |
| Q12 | Students: "My exams" tile → `app/admin/student-exams.php`. Parents: `parent-wards.php?page=cbt` → child → `student-exams.php?student_id=…` (read-only). Plus Learners Center card |
| Q13 | WebView: non-surveillance exams run in app; `visibilitychange`/`pagehide` counted into `exam_students.fullscreen_exits`. Surveillance exams show "Take this exam on a computer". Attempt marked `client=app` in `exam_ended_cond` |
| Q14 | **Per-answer autosave** (`save_answer` action) upserting `exam_student_questions` / `exam_student_theory`; `finish_exam` scores from stored rows server-side. Desktop benefits too |
| Q15 | Taking UI: one question per screen, Prev/Next, sticky timer, navigator grid sheet (answered/flagged/empty) |
| Q16 | Theory images: `<input type="file" accept="image/*" capture="environment">`, client-side compression, same Drive path + `exam_student_theory_images` |
| Q17 | Print: `browser:` link + short-lived signed token (pattern: `app/admin/includes/fpop-app-print-bootstrap.php`) |
| Q18 | AI import (n8n) included in app; **move hardcoded webhook URL to config** |
| Q19 | Summernote lazy-loaded on first authoring sheet open, slim toolbar (B/I/U, lists, image, expand), same image upload callback as desktop |
| Q20 | Rollout per phase: **engine + desktop switch first** (desktop = regression harness), then app pages. Order: `question_bank` → `manage_questions` → `manage-exams` → `exam-questions` |
| Q21 | Ignore `app/public_html/zap_app/admin/` |
| Q22 | App screen map (§4) |
| Q23 | Theory marking: one answer per screen, lightbox images, score stepper capped at question mark, `corrected_text` + `score`, Next = next unmarked |
| Q24 | App admission pages move to engine **in Phase 1**, right after desktop authoring pages |

---

## 3. Architecture

### 3.1 Shared engine — `admin/includes/`

| File | Responsibility | Extracted from |
|---|---|---|
| `cbt_access.php` | Role/ownership checks (admin, sub-admin, academic_admin, principal, form teacher via `form_staff_class_subclass`, subject teacher via `staff_subject_subclass`, student, parent→ward). Context-aware (school/admission) | repeated blocks in `question_bank.php` (~250–270, 623–712), `manage-exams.php` (~369–429, 856–995), `exam-submissions.php` (~41, 131, 148) |
| `cbt_question_bank_engine.php` | Banks CRUD + duplicate, questions, question leads, options, automatic options | `question_bank.php`, `manage_questions.php`, `exam-questions.php` (question parts) |
| `cbt_exam_engine.php` | Exams CRUD, session/term search, class/subclass/subject/topic lookups, assign/unassign questions | `manage-exams.php`, `exam-questions.php` (assign parts), app admission pages |
| `cbt_attempt_engine.php` | `set_start_date`, `check_if_exam_started`, **`save_answer` (new)**, `finish_exam`, `finish_theory_exam`, scoring, time-left (server-authoritative) | `cbt.php`, `cbt-theory.php`, `cbt-marked-theory.php` (all three duplicate these), `student-exams.php` listing |
| `cbt_marking_engine.php` | Submissions list, theory marking, approve/revise score, surveillance (webcams + fullscreen exits), delete image, correction/review data | `exam-submissions.php`, `cbt-marked-theory.php`, `cbt-correction.php`, `cbt-correction-theory.php`, app `admission-exam-submissions.php` |
| `cbt_ajax.php` | Dispatcher: maps legacy POST keys → engine call → page renderer → `echo json_encode`; exits. Enforces CSRF on writes | all inline `isset($_POST[...])` blocks |
| `cbt_desktop_render.php` | Legacy desktop HTML (DataTables rows, Bootstrap dropdowns/modals) from engine arrays — **byte-identical** to current output | inline HTML in desktop handlers |
| `question_import_functions.php` | (existing) n8n import; URL read from config | — |

Engine conventions:
- Functions take `mysqli $db, array $ctx, ...` where `$ctx = ['table_name','status','form','staff_id','student_id','context'=>'school'|'admission']`.
- All IDs `(int)`-cast; strings via prepared statements.
- Return `['ok'=>bool, 'data'=>..., 'error'=>string]` or plain arrays for lists; never echo.
- No `exit`, no `$_POST`/`$_GET` inside engines — only in `cbt_ajax.php`.

### 3.2 App side — `app/admin/`

- `partials/zap-cbt-helpers.php` — Material renderers (cards, chips, metrics) from engine arrays.
- `scripts/zap-cbt-take.js` — taking engine (timer, autosave, navigator, visibility exits).
- `scripts/zap-cbt-author.js` — Summernote lazy loader + authoring sheets.
- CSS: `styles/zapapp.css` under `/* ── zap-app: cbt ── */`, tokens `--cbt-*`, page classes `cbt-*-page`.

### 3.3 Dispatcher surface flag

`cbt_ajax.php` reads `$CBT_SURFACE` (`'desktop'` | `'app'`) set by the including page, and selects the renderer. Desktop pages set `'desktop'` and keep their legacy response shapes.

---

## 4. App screen map

| Page | Pattern | Purpose |
|---|---|---|
| `cbt-center.php` | E hub + tabs | Question banks / Exams / To mark; KPI hero |
| `cbt-question-bank.php` | D (Class + Subject chips) | Questions & leads in a bank; Summernote sheets; Import sheet |
| `cbt-exam.php` | C context hero | Exam detail, assigned questions, assign sheet, Preview / Print / Submissions |
| `cbt-submissions.php` | A + G (Submitted / To mark / Approved) | Attempts, revise-score sheet, fullscreen exits, surveillance link |
| `cbt-mark-theory.php` | custom | One answer per screen marking (Q23) |
| `cbt-preview.php` | taking shell | Staff preview using the same renderer as `cbt-take.php` |
| `student-exams.php` | A + G (Upcoming / Open / Done) | Student list; parent read-only via `student_id` |
| `cbt-take.php` | custom | Objective + theory taking (Q13–Q16) |
| `cbt-review.php` | C | Post-exam review (replaces `cbt-correction*` in app) |

Nav: `app/admin/partials/zap-nav-menu.php` line ~124 `$nav_cbt` → `cbt-center.php`, remove `'status' => 'soon'`, echo for all permitted roles (currently only form teachers at ~410). Add student "My exams" tile, parent `parent-wards.php?page=cbt` tile.

---

## 5. Tracer bullet (Phase 0 — do this first)

Thinnest end-to-end slice proving every architectural layer: **"List question banks"**

1. `admin/includes/cbt_access.php` — just the staff role resolution needed for bank listing.
2. `admin/includes/cbt_question_bank_engine.php` — `cbt_qb_list(mysqli $db, array $ctx): array` (hardened port of `question_bank.php` `return_question_bank`, ~247–315).
3. `admin/includes/cbt_desktop_render.php` — `cbt_desktop_render_qb_rows(array $rows)` reproducing current markup.
4. `admin/includes/cbt_ajax.php` — dispatcher handling only `return_question_bank`.
5. `admin/question_bank.php` — replace inline block with `$CBT_SURFACE = 'desktop'; require 'includes/cbt_ajax.php';`.
6. **Parity check**: capture response before/after for (a) backend/admin, (b) form teacher, (c) subject teacher → identical JSON.
7. `app/admin/cbt-center.php` — Pattern E shell, Question banks tab rendering cards via `zap-cbt-helpers.php` from the same engine call.
8. Nav tile wired (staff roles).

**Parity check method:** log in as each role, run the page's AJAX in DevTools (or `curl` with the session cookie + CSRF token), save JSON to `/tmp/cbt-parity/<page>-<key>-<role>-before.json`, repeat after the switch, `diff`.

**Exit criteria:** desktop question bank list unchanged for all three roles; app CBT Center shows the same banks per role; no SQL string interpolation in the new engine function.

---

## 6. Phases & progress tracker

Legend: `[ ]` todo · `[~]` in progress · `[x]` done · `[!]` blocked (see §7)

### Phase 0 — Tracer bullet

| ✓ | Task | Depends | Done in |
|---|---|---|---|
| [x] | 0.1 Create `cbt_access.php` (staff bank-list roles only) | — | 2026-10-04 S1 |
| [x] | 0.2 `cbt_qb_list` in `cbt_question_bank_engine.php` (hardened) | 0.1 | 2026-10-04 S1 |
| [x] | 0.3 `cbt_desktop_render.php` bank rows + `cbt_ajax.php` (`return_question_bank`) | 0.2 | 2026-10-04 S1 |
| [x] | 0.4 Switch `admin/question_bank.php` listing; parity check 3 roles | 0.3 | 2026-10-04 S1 (see §7 parity note) |
| [x] | 0.5 `app/admin/cbt-center.php` shell + Question banks tab + `zap-cbt-helpers.php` + CSS banner | 0.2 | 2026-10-04 S1 |
| [x] | 0.6 Nav tile → `cbt-center.php` for permitted staff roles | 0.5 | 2026-10-04 S1 |

### Phase 1 — Staff authoring

**1A. Engine + desktop switch** (parity check after each)

| ✓ | Task | Depends | Done in |
|---|---|---|---|
| [x] | 1.1 `cbt_access.php` complete (all staff roles, school + admission context) | P0 | 2026-10-04 S2 |
| [x] | 1.2 `question_bank.php` remaining keys: `submitted`, `create_question_bank`, `get_class`, `return_subjects`, `get_duplicate_modal`, `duplicate_question_bank`, `delete_question_bank`, `process_ai_question_bank` | 1.1 | 2026-10-04 S3 |
| [x] | 1.3 Move n8n webhook URL to config (`school_config` / settings); `question_import_functions.php` reads it | 1.2 | 2026-10-04 S4 |
| [x] | 1.4 `manage_questions.php`: `return_questions_table`, `process_ai_import_questions`, `get_edit_question`, `question_lead`, `get_question_lead` (+ form submits / `$_FILES` image upload) | 1.1 | 2026-10-04 S5 |
| [x] | 1.5 `manage-exams.php`: `return_exams_table`, `get_exam_search_session_term`, `submitted`, `create_exam`, `edit_exam`, `delete_exam`, `get_edit_exam`, `get_class`, `get_subclass`, `get_topic`, `get_automatic_option` | 1.1 | 2026-10-04 S6 |
| [x] | 1.6 `exam-questions.php`: `return_questions_table`, `return_assign_questions_table`, `get_edit_question`, `question_lead`, `assign_question`, `unassign_question`, `get_question_bank`, `get_question_lead` | 1.4, 1.5 | 2026-10-04 S7 |
| [x] | 1.7 `admin/cbt-preview.php` (`process_ai_import_questions` + preview data) and `admin/cbt-print.php` data via engine | 1.6 | 2026-10-04 S8 |
| [x] | 1.8 Desktop admission authoring (`admin/admission-exams.php`, `admission-exam-questions.php`, `admission-exam-submissions.php`) via engine with `context='admission'` | 1.6 | 2026-10-04 S9 |

**1B. App authoring pages**

| ✓ | Task | Depends | Done in |
|---|---|---|---|
| [x] | 1.9 `cbt-center.php` Exams tab + To mark tab (counts only until M.5) + KPI hero | 1.5 | 2026-10-04 S10 |
| [x] | 1.10 `cbt-question-bank.php` (Pattern D) — list questions/leads, create/edit/delete bank | 1.2, 1.4 | 2026-10-04 S11 |
| [x] | 1.11 `scripts/zap-cbt-author.js` — lazy Summernote, slim toolbar, image upload callback; question/option/lead sheets | 1.10 | 2026-10-04 S12 |
| [x] | 1.12 Import-from-file sheet (n8n) in `cbt-question-bank.php` | 1.3, 1.10 | 2026-10-04 S13 |
| [x] | 1.13 `cbt-exam.php` (Pattern C) — exam create/edit sheet, assign/unassign questions sheet | 1.6 | 2026-10-04 S14 |
| [x] | 1.14 `cbt-preview.php` (app) — taking renderer in read-only preview mode (stub OK; finalized in 2.10) | 1.13 | 2026-10-04 S15 |
| [x] | 1.15 Print: `browser:` link + signed token bootstrap (`app/admin/includes/cbt-app-print-bootstrap.php`) → `admin/cbt-print.php` | 1.7 | 2026-10-04 S16 |
| [x] | 1.16 Move app admission pages (`app/admin/admission-exams.php`, `admission-exam-questions.php`, `admission-exam-submissions.php`) onto engine; remove inline SQL | 1.8 | 2026-10-04 S17 |
| [x] | 1.17 Optional "Import questions from file" on the CBT Center "New question bank" sheet (`process_ai_question_bank`, one request; nothing created if it fails). The bank page's Import entry points stay | 1.12 | 2026-10-05 S46 |

### Phase 1D — Admission popups redesign (tracer first)

The `md-timeline-sheet` popups on the app admission pages have no `zapapp.css` rules (found in 1.16). They move onto the app bottom sheet (`md-sl-sheet` + `cbt-bank-sheet`) with a shared `.md-adm-sheet` shell, following the `frontend-design` and `zap-app` skills. D.0 is the tracer: one popup end to end (markup, JS, shell CSS, signature element, tests, live check), so D.1 / D.2 only reuse the pattern.

| ✓ | Task | Depends | Done in |
|---|---|---|---|
| [x] | D.0 Tracer — approve score popup (`admission-exam-submissions.php`): bottom sheet on the shared `.md-adm-sheet` shell, editable score figure with −/+ steppers, score rail (pass line, marked score, live revised fill, pass / below verdict), inline validation, "Approve 3.00" button | 1.16 | 2026-10-04 S18 |
| [x] | D.1 Surveillance popup (`admission-exam-submissions.php`) on the shell: fullscreen exits as a real figure, webcam captures as a time-ordered list with thumbnails or open links, clear empty state | D.0 | 2026-10-04 S19 |
| [x] | D.2 Assign questions popup (`admission-exam-questions.php`) on the shell, reusing the `cbt-assign-sheet` list / select-all / count patterns from `cbt-exam.php` | D.0 | 2026-10-04 S20 |

### Phase 1.5 — Marking (staff)

| ✓ | Task | Depends | Done in |
|---|---|---|---|
| [x] | M.1 `cbt_marking_engine.php`: `return_recent_activity`, `get_surveillance`, `approve_score_modal`, `approve_score`, `delete_image`; switch `admin/exam-submissions.php`; parity | 1.1 | 2026-10-04 S21 (`delete_image` retired, see §7) |
| [x] | M.2 `finish_theory_exam` marking path from `cbt-marked-theory.php` into marking engine; switch desktop | M.1 | 2026-10-04 S22 |
| [x] | M.3 App `cbt-submissions.php` (Pattern A + G: Submitted / To mark / Approved), revise-score sheet, fullscreen exits chip, `client=app` chip | M.1 | 2026-10-04 S23 |
| [x] | M.4 App `cbt-mark-theory.php` (Q23) with `scripts/zap-timeline-lightbox.js` | M.2 | 2026-10-04 S24 |
| [x] | M.5 CBT Center "To mark" tab lists real pending items | M.3 | 2026-10-05 S25 |

### Phase 2 — Students taking

| ✓ | Task | Depends | Done in |
|---|---|---|---|
| [x] | 2.1 `cbt_attempt_engine.php`: dedupe `set_start_date`, `check_if_exam_started`, `finish_exam` from `cbt.php`, `cbt-theory.php`, `cbt-marked-theory.php`; switch all three desktop pages; parity | 1.1 | 2026-10-05 S26 (`cbt-marked-theory.php` is the staff page, done in M.2) |
| [x] | 2.2 New `save_answer` action (objective upsert into `exam_student_questions`; theory upsert into `exam_student_theory`); `finish_exam` scores from stored rows; desktop JS calls `save_answer` on change (additive) | 2.1 | 2026-10-05 S27 |
| [x] | 2.3 `student-exams.php` listing keys (`return_public_files`, `get_description`, `get_search_subjects`, `get_topic`, `get_searched_text`) → engine; switch desktop | 2.1 | 2026-10-05 S28 |
| [x] | 2.4 Review data (`cbt-correction.php`, `cbt-correction-theory.php`) → marking engine (school + admission branches) | M.1 | 2026-10-05 S29 |
| [x] | 2.5 App `student-exams.php` (Pattern A + G: Upcoming / Open / Done); surveillance exams show "Take on a computer" | 2.3 | 2026-10-05 S30 |
| [x] | 2.6 App `cbt-take.php` + `scripts/zap-cbt-take.js`: one-question screens, sticky server-authoritative timer, navigator sheet, autosave, `visibilitychange`/`pagehide` → exits counter, `client=app` on finish | 2.2 | 2026-10-05 S31 (live browser check done in S32, see §7) |
| [x] | 2.7 Theory answers in `cbt-take.php`: textarea + camera capture, client-side compression, Drive upload → `exam_student_theory_images` | 2.6 | 2026-10-05 S32 |
| [x] | 2.8 App `cbt-review.php` (respects `view_answers` / `student_view_score`) | 2.4 | 2026-10-05 S33 |
| [x] | 2.9 Student nav "My exams" tile + Learners Center card | 2.5 | 2026-10-05 S34 |
| [x] | 2.10 Finalize `cbt-preview.php` on the real taking renderer | 2.6 | 2026-10-05 S35 |
| [x] | 2.11 Server-side "answered" counts include photo-only theory answers (`save_answer`'s `answered`, submissions list, marking queue, review), so they match the app client (see §7, 2.7) | 2.7 | 2026-10-05 S36 |

### Phase 3 — Parents

| ✓ | Task | Depends | Done in |
|---|---|---|---|
| [x] | 3.1 `cbt_access.php` parent→ward checks (port `exam-wards.php`) | 2.3 | 2026-10-05 S37 |
| [x] | 3.2 `parent-wards.php?page=cbt` → child picker → `student-exams.php?student_id=…` read-only (no Start button) | 3.1, 2.5 | 2026-10-05 S38 |
| [x] | 3.3 Parent nav tile + Learners Center card | 3.2 | 2026-10-05 S39 |

### Phase 4 — Hardening & cleanup

| ✓ | Task | Depends | Done in |
|---|---|---|---|
| [x] | 4.1 Grep new engines for any `{$` inside SQL strings → zero | all | 2026-10-05 S40 |
| [x] | 4.2 CSRF on every write action in `cbt_ajax.php` (desktop + app) | all | 2026-10-05 S41 |
| [x] | 4.3 Remove dead inline handlers from desktop pages | all | 2026-10-05 S42 |
| [ ] | 4.4 WebView smoke test: app login per role, one full exam taken, marked, reviewed | all | |
| [x] | 4.5 Desktop theory photos: move `drive-ajax.php` `upload_question_images` / `delete_theory_image` onto `cbt_attempt_photo_add` / `_remove` (permission, deadline and limit checks; today any logged-in user can delete any theory image by Drive id) (see §7, 2.7) | 2.7 | 2026-10-05 S43 |
| [x] | 4.6 Desktop attempt resets: move `webcam-google-drive.php` `cbt_reset_exam` / `cbt_reset_admission_exam` / `clear_webcam_images` onto the engine (`cbt_submission_reset` for school; an admission twin and a webcam-clear function, gated by `cbt_can_reset_attempt` + exam ownership, prepared). They check CSRF but no role, so any logged-in user with a token can reset any attempt. Drop the unused copies in `drive-ajax.php` (see §7, 4.2) | 4.2 | 2026-10-05 S44 |
| [x] | 4.7 Webcam capture uploads: move `cbt_webcam` / `cbt_webcam_base64` / `admission_cbt_webcam` / `admission_cbt_webcam_base64` (`webcam-google-drive.php`, posted by `cbt.php` / `admission-cbt.php`; copies in `drive-ajax.php`, posted by `cbt-theory.php`) onto the engine: only the signed-in candidate on their own running surveillance attempt, prepared SQL, and only `data:image/jpeg;base64` captures decoded in memory. Today the ids are `decrypt()`ed into SQL, any logged-in user can add captures to any attempt, and each posted "data URI" goes through `file_get_contents()`, so a server path or URL is read and uploaded to Drive (see §7, 4.6) | 4.6 | 2026-10-05 S45 |

---

## 7. Discoveries / blockers / facts

Facts established during planning (line numbers approximate):

- `app/admin/includes/init.php` already loads `admin/includes/{functions,database,security,...}.php` (~174–184) → shared engine is reachable from app.
- App shell (`app/admin/header.php`) already loads jQuery (~267) and Bootstrap JS (~358).
- `admin/cbt.php`, `cbt-theory.php`, `cbt-marked-theory.php` each duplicate `set_start_date` / `check_if_exam_started` / `finish_exam`.
- `cbt.php` sends answers only at finish as an `options` `"##"`-joined string (~95).
- Surveillance: `exams.surveillance`, `exam_webcams.google_drive_id`, `exam_students.fullscreen_exits`, `webcam.min.js` from `cdn.zap.ng`.
- `cbt-correction.php` already branches school vs admission (`admission_exams` ~29 vs `exams` ~79).
- ~~n8n URL hardcoded in `admin/includes/question_import_functions.php` (~16).~~ Moved to config in 1.3 (see below).
- Nav tile `$nav_cbt` → `comingsoon.php` at `app/admin/partials/zap-nav-menu.php` ~124; echoed only for form teachers ~410.
- App print pattern: `browser:` prefix (`app/admin/finance-parent-online-payment-mobile.php` ~20) + token (`app/admin/includes/fpop-app-print-bootstrap.php`).
- Key tables: `question_bank`, `questions`, `question_lead`, `question_options`, `exams`, `exam_questions`, `exam_students`, `exam_student_questions`, `exam_student_theory`, `exam_student_theory_images`, `exam_webcams`, `admission_exams`, `admission_exam_questions`, `admission_exam_students`, `admission_exam_student_questions`, `admission_class`.

Facts established in Phase 0 (S1):

- **Parity method used for 0.4:** desktop and app sessions are separate on `design-changes.zap.ng`, and the browser only had an app login, so desktop HTTP capture was not possible. Instead: (1) offline harness (`/tmp/cbt-parity/harness.php`) ran the legacy row loop vs `cbt_desktop_render_qb_rows` on synthetic rows (NULL subject, unknown/NULL type, HTML in title, large counts) → byte-identical JSON; (2) temporary app-session script compared legacy SQL vs `cbt_qb_list` on live data for admin scope (43 banks) and real subject teachers (staff #1, #7, #9) → identical rows and rendered JSON. Redo a real desktop capture when a desktop login is available.
- Desktop listing's **form-teacher branch is commented out**; form teachers with status `academic*` use the subject-teacher path (`staff_subject_subclass`). So "form teacher" parity = subject-teacher parity for listing. (Other keys like `get_class` *do* have form-teacher branches.)
- Legacy listing had no branch for other roles (e.g. desktop `director` has status `director`, `account`) → `$q` undefined → DB error. Engine returns `[]` (scope `none`). In the app, `director`/`backend` get status `admin` → see all banks.
- `staff_subject_subclass` contains orphan rows with `staff_id = 0`; engine requires `staff_id > 0` for subject scope.
- Desktop bank rows echo `question_bank_title` **unescaped** (kept for byte parity; input is `xss_clean`ed). Revisit in Phase 4.
- Legacy quirk preserved in `cbt_desktop_render_qb_rows`: an unknown `question_bank_type` reuses the previous row's label (null on first row).
- `admin/question_bank.php` uses CRLF line endings; `zap-nav-menu.php` uses LF. Preserve when editing.
- Dispatcher API: `cbt_ajax_dispatch(mysqli, $ctx, $surface, $_POST, $page, $_FILES)` returns bool (`$page`/`$files` added in 1.2, see below); app surface responds `{status, count, html}` for `return_question_bank` (cards from `zap_cbt_render_qb_cards`). `cbt-center.php` currently renders server-side and does not post to the dispatcher.
- Nav: `$nav_cbt` now shown for admin/backend/director block, principal block (`strpos 'principal'`), and academic form + non-form staff. `sub-admin` and `academic_account` have no nav blocks at all today.
- CBT Center "Exams" / "To mark" tabs show an "on the way" empty state until 1.9.

Facts established in 1.1 (S2):

- **Desktop page gates** (`kick_out`, exact match; `backend` table always allowed) — now `cbt_page_allowed($ctx, $area)`:
  - `banks` (`question_bank.php`, `manage_questions.php`): admin, sub-admin, academic_admin, academic_principal, academic, **principal**. No `academic_account`.
  - `exams` (`manage-exams.php`, `exam-questions.php`, `exam-submissions.php`): admin, sub-admin, academic_admin, academic_principal, academic, **academic_account**. No `principal`.
  - `admission` (3 admission authoring pages): admin, academic_admin, academic_principal, academic, academic_account. **No `sub-admin`.**
- **Admin set is context-dependent**: school = backend/admin/sub-admin/academic_admin; admission = backend/admin/academic_admin (`cbt_is_admin` reads `$ctx['context']`). `cbt_is_school_admin` forces school context (banks are school-wide).
- Submissions extra columns: school backend/admin/sub-admin, admission backend/admin → `cbt_can_see_attempt_diagnostics`. Reset exam: backend/admin/academic_admin in both → `cbt_can_reset_attempt`.
- **Desktop AJAX runs before `header.php`'s `kick_out`**, so legacy handlers had no page gate. Engine functions now check `cbt_page_allowed` themselves (`cbt_qb_list`, `cbt_can_manage_*`).
- Picker rules (`get_class` / `return_subjects` / `get_subclass`), ported as `cbt_staff_classes`, `cbt_staff_subclasses`, `cbt_staff_subjects` returning `{allow_all, rows}`: form+subject teacher = union (form classes first) and both "ALL" options; form-only = form classes/subclasses, subject "ALL" only (no subclass ALL); subject-only = `staff_subject_subclass`, subclass "ALL" only (no subject ALL). Renderers add the "ALL" option when `allow_all`.
- Ownership: `cbt_can_manage_bank` (list-scope `all` → any bank, i.e. admins + principal; else class+subject must be pickable — so form teachers can manage their class's "ALL subjects" banks even though the listing hides them), `cbt_can_manage_question` (via bank), `cbt_can_manage_exam` (school: `exams.class_id+subject_id` in `staff_subject_subclass`; admission: `admission_class.class_id` in `staff_subject_subclass`, any subject — same as listing), `cbt_can_manage_admission_class`. Legacy writes had **no** ownership checks; wire these in as each key moves (1.2+).
- CBT Center tabs now follow the gates: Question banks tab only for `banks`, Exams / To mark only for `exams`; `academic_account` opens on Exams, `principal` sees banks only.
- Verified: offline harness (13 role combos × 8 predicates vs legacy `if` chains) and a live temporary app script (14 real staff: 8 form+subject, 3 subject, 3 admin; 96 picker/ownership checks vs legacy `Staff` methods) → 0 mismatches. No form-only staff exist in the demo data.

Facts established in 1.2 (S3):

- **`$CBT_PAGE` scoping:** keys like `submitted`, `get_class`, `get_question_lead` exist on several pages with different behaviour, so pages now set `$CBT_PAGE` (e.g. `'question_bank'`) before `require 'includes/cbt_ajax.php'`; page-specific keys are routed only for that page (`cbt_ajax_question_bank`). `return_question_bank` stays page-agnostic. Do the same for `manage_questions`, `manage-exams`, `exam-questions` in 1.4–1.6.
- **Engine result shape:** `cbt_ok($data)` / `cbt_fail($error, $code)` in `cbt_access.php` → `{ok, data, error, code}`; renderers map `code` to legacy shapes (e.g. `bad_id` → `{status, msg, title:""}` like `ajax_error`). CSRF failures use the legacy `{status:"failed", msg:"Request cannot be processed"}` for both `csrf_token_is_valid` and `check_csrf_token` styles (identical JSON). `return_subjects` stays CSRF-free (read; desktop JS sends no token).
- **App surface for 1.2 keys:** `{status, msg, data}` (`cbt_ajax_app_result`); pickers return ordered lists `[{value: encrypt(id), label}]` (+ `allow_all`, `all_value` for subjects) because JSON objects with numeric keys lose `class_order` in JS. App must post encrypted ids back, same as desktop.
- **Intentional behaviour changes (hardening / bug fixes):**
  - Ownership on every write: inline edit / delete / duplicate / duplicate modal need `cbt_can_manage_bank`; create, class move and subject change need `cbt_qb_can_target` (scope `all` → any existing class; else picker rules). Duplicate target needs class only (keeps source subject, like legacy).
  - `question_bank_type` validated (`multiple_choice` | `theory`) on create and inline edit. Legacy desktop JS never validated the type, so empty-type banks could be created (they render with the previous row's label).
  - Create / AI import titles now `xss_clean`ed like inline edits (normal titles unchanged; listing still echoes titles unescaped).
  - **Duplicate remaps leads:** legacy copied questions kept the *source* bank's `question_lead_id` (verified: 3 of 7 questions in a copy of bank 1 pointed outside the new bank). Engine copies leads first and maps ids.
  - `get_duplicate_modal` builds class options server-side (ignores posted `classes` HTML) and escapes the echoed bank id — same bytes for legit input.
  - AI import validates bank (title / class / type / permission / duplicate) **before** the upload move and n8n call. Legacy webhook-failure path threw `TypeError` (`$ai_data['text']` on a string) → now `"Processing failed. <reason>"`.
  - Unknown `submitted` `name` → failed JSON (legacy fell through and rendered the whole page).
- `question_bank_title` is `varchar(30)` and the demo DB `sql_mode` is non-strict, so long titles are silently truncated (legacy too). Consider a length check when building `cbt-question-bank.php` (1.10). → Done in S11: the app sheet has `maxlength=30` and `cbt_qb_update` rejects > 30; desktop create unchanged.
- **Parity harness (reusable):** `/tmp/cbt-parity/qb12/{run.php,cases.php}` runs legacy handler source (from `/tmp/cbt-parity/question_bank.pre12.php`) vs the new dispatcher, one PHP process per case, against the demo DB with `question_bank`, `questions`, `question_lead`, `question_options` shadowed by `CREATE TEMPORARY TABLE` copies (stage into `stg_*` first; MariaDB 10.6 allows self-referencing `INSERT … SELECT` on temp tables). Compares body bytes + table state. Result: 61 identical, 11 intended diffs (forbidden / validation / lead remap), 0 unexpected, 9 personas (backend, admin, principal, academic_admin, 2 form+subject, 2 subject, form-only `academic_principal`). Writes never touch real tables.
- Legacy `manage_question_bank()` (`content` in create responses) emits `json_subject_classes` with nondeterministic key order (`ORDER BY subject_name` ties) — harness normalizes it; not a regression.
- Real desktop HTTP capture still pending (no desktop login available), as in S1.

Facts established in 1.3 (S4):

- **Question extractor URL lives in `private/includes/ask_zap.php`** under key `n8n_question_extractor_url` (user decision: do **not** edit `private/includes/settings.php`). In this workspace that file is a symlink to `/home/zeonglob/public_html/zap_admin/private/includes/ask_zap.php` (shared).
- `question_extractor_webhook_url()` in `question_import_functions.php` loads it via `azp_private_file()` (`require_once ask_zap_engine.php`; path is `$_SERVER['DOCUMENT_ROOT']/private/includes/ask_zap.php`, same root the app uses) and returns the trimmed URL, or `''` unless it starts with `https://`; `call_n8n_webhook` then returns the existing `"n8n webhook URL not configured."` failure. All callers (`cbt_question_bank_engine.php`, `manage_questions.php`, `cbt-preview.php`) go through `call_n8n_webhook`, so none changed.
- Deployments whose `ask_zap.php` lacks the key will get "not configured" from AI import — add the key when rolling out.
- Other hardcoded n8n URLs (out of scope for CBT, noted for Phase 4 / later): `admin/extra-result-comment.php`, `admin/form-staff-comment.php`, `app/admin/scripts/zap-shell.js` (comment_for_student), `admin/subscription.php` (webhook-test), `finance_bank_reconciliation_engine.php` (`FBR_N8N_WEBHOOK_URL` constant, documented as edit-in-code).
- Verified: `php -l` on all touched files; CRLF kept; CLI harness `/tmp/qe_url_test2.php <docroot>` → real docroot returns the URL, docroot without the file returns `''`.

Facts established in 1.4 (S5):

- **Question writes live in `drive-ajax.php`, not the page.** `manage_questions.php`, `exam-questions.php`, `admission-exam-questions.php` and `cbt-preview.php` all post `create_question`, `edit_question`, `delete_question`, `delete_question_dir`, `delete_option_dir`, `delete_question_lead_dir`, `update_question_lead` to `drive-ajax.php` (exam / admission / preview pages too, so 1.6–1.8 inherit this). `drive-ajax.php` now does `$CBT_PAGE = 'drive_ajax'; require 'includes/cbt_ajax.php';` right after `$service` is built; the seven inline handlers were removed (file stays LF). Other `drive-ajax.php` keys (e-library, activities, CBT webcam / reset, theory images) are untouched.
- **Page context:** `$CBT_ARGS = ['question_bank_id' => int]` (from `$_GET`) → 7th arg of `cbt_ajax_dispatch`. `manage_questions.php` page header now reads the bank via `cbt_qb_get` (prepared, permission-checked; empty title when not allowed).
- **Storage adapter:** engine write functions take `$storage = ['upload' => fn(array $file): ?array, 'delete' => fn(string $drive_id)]`; `cbt_ajax_drive_storage()` wraps `drive-ajax.php`'s `insertFile` / `deleteFile` / `getFolderId` / global `$service` (Drive title = uploaded file name, as before). `$CBT_ARGS['storage']` overrides it (harness). The app will need its own adapter over `app/admin/GoogleDrive.php` (1.11).
- **New access predicate `cbt_can_author_bank` / `cbt_can_author_question`:** bank manager (`cbt_can_manage_bank`) **or** exams / admission page user who is a school admin or owns the bank's class + subject. Needed because exam / admission pages edit bank questions and allow `academic_account`, which the banks gate excludes. All 1.4 reads and writes use it.
- **Renderers generated from legacy source:** `cbt_desktop_render_q_rows`, `_q_edit_modal`, `_ql_modal`, `_ql_options` were spliced from exact line ranges of the pre-change page (trailing spaces / tabs inside string literals matter for byte parity). Generator: `/tmp/cbt-parity/mq14/gen.php` + `render_skel.php`. Reuse the technique for 1.5–1.8.
- **Legacy bugs found:** `ajax_response()` is **not defined anywhere** → legacy duplicate / existing category-title paths fataled (500); the empty-bank category form posts `lead[0][question_lead_title][]` (array) → PHP 8 `TypeError`, so the first category of a bank could not be saved; `create_question` interpolated `correct_answer` unquoted → non-numeric marks failed the question INSERT but still inserted options with `question_id = 0` and returned success; `delete_question` deleted the question image from Drive once per option row.
- **Intentional behaviour changes:** CSRF (token + same-domain) on all seven drive-ajax question writes (legacy had none; every caller already sends `csrf_token`); ownership on every read / write (forbidden reads return `[]` for the table, failed JSON for modals); question must belong to the page's bank for `get_edit_question`; question lead must be `0` or in the target bank; `question_type` ∈ `multiple_single|theory`; marks validated server-side with the JS messages (correct > 0, incorrect ≤ 0, empty incorrect → 0); uploads validated before any Drive call and by `finfo` content type; oversize messages unified to "Maximum Upload File Size is 5 MB" (legacy edit said "200 MB", options "5 MB 1"); category images now need an image MIME (png/jpg/jpeg/gif); category titles `xss_clean`ed; option text `"0"` kept (legacy dropped it as empty); Drive upload failure → "Image upload failed. Try Again" instead of storing blanks.
- **Drive ids come from the DB, never the request:** image deletes use the row's `google_drive_id` (legacy deleted whatever id was posted); edit keeps option images only when the posted `google_drive_id` belongs to that question's current options and copies dir / embed / thumb from the DB (legacy trusted hidden inputs). Drive files are deleted only when no `questions` / `question_options` / `question_lead` row still references them (`cbt_q_drive_release`) — duplicated banks share Drive files. Images of options dropped on edit and of replaced category images are now released (legacy orphaned them).
- **Parity harness:** `/tmp/cbt-parity/mq14/{run.php,cases.php}` (same temp-table shadowing as 1.2, fake `insertFile` / `deleteFile` logging into state, fixture PNG uploads, 10 personas incl. `academic_account`). Result: 165 identical, 134 intended (forbidden / not found / CSRF / validation / DB-sourced Drive ids / dedup), 0 unexpected. AI import tested on validation paths only (no n8n call). Real desktop HTTP capture still pending, as in S1.

Facts established in 1.5 (S6):

- **New engine `admin/includes/cbt_exam_engine.php`** (school context): `cbt_exam_list` (filters class / subclass / subject / topic / sessions / terms), `cbt_exam_session_terms`, `cbt_exam_classes`, `cbt_exam_subclass_subject_picker` (+ per-subject exam counts for a session-term), `cbt_exam_topics`, `cbt_exam_bank_options`, `cbt_exam_can_create`, `cbt_exam_can_target`, `cbt_exam_check_fields`, `cbt_exam_pick_questions`, `cbt_exam_create`, `cbt_exam_get`, `cbt_exam_update`, `cbt_exam_set_activation`, `cbt_exam_delete`. Every function checks the `exams` page gate; edit / delete / activation / edit-modal use `cbt_can_manage_exam`. 1.6 / 1.8 should extend this file (assign / unassign, admission context).
- **Page context:** `manage-exams.php` sets `$CBT_PAGE = 'manage_exams'` and `$CBT_ARGS = ['sessions', 'terms', 'teacher_create_exam']` (current `$_SESSION` session / term and `$school_config['teacher_create_exam']`); the engine never reads the session or config. The app must pass the same args (1.9 / 1.13).
- Desktop renderers `cbt_desktop_render_exam_{rows,session_terms,subclass_subject,topic_options,edit_modal,bank_options}`; rows / edit modal / bank options spliced from the legacy source (`/tmp/cbt-parity/me15/{gen.php,render_skel.php}`). `get_class` reuses `cbt_desktop_render_class_options`.
- **Legacy quirks kept:** `count_students` means attempts with an end date for admins but attempts with a score for subject teachers; an unknown `exam_type` reuses the previous row's label; the automatic selection algorithm is ported verbatim (shuffle via the global `mt_rand` state). Its category-trimming branch uses `array_splice`, which re-keys the kept questions to 0..n, so `exam_questions + $removed` can add a different number of questions than asked for. Fix only with a product decision.
- **Legacy quirk fixed:** the admin listing without a class filter had no `WHERE`, so `AND e.topic_id …` / `AND e.sessions …` were appended to the last `LEFT JOIN … ON` and silently ignored (filtering by session-term alone showed every exam). The engine builds a proper `WHERE`.
- **Intended behaviour changes:** CSRF on `create_exam`, `edit_exam`, `delete_exam`, `get_automatic_option` (legacy had none; JS already sends the token; `return_exams_table` stays CSRF-free like DataTables reads). Ownership on edit / delete / activation / edit modal (non-owners and missing exams → failed JSON; legacy edited / deleted any id and returned an empty "success" modal for unknown ids). Create requires `cbt_exam_can_create` (school admins, or anyone in the exams gate when `teacher_create_exam = yes`, mirroring the ADD EXAM button) and the class / subject pickers' rules (`cbt_exam_can_target`); topic must be 0 or a topic of that class + subject. Server-side validation with the JS messages (name, duration > 0, `Y-m-d H:i[:s]` dates, end ≥ start, percentage 0–100, 0/1 flags, exam type, question selection, class, subject); edit validates the same fields (legacy saved anything). Activation must be `active` / `inactive`. Automatic selection only uses posted banks that match class + subject + **exam type** and have questions; it needs at least one bank and a positive whole number of questions (legacy fataled on no banks and created an empty exam on a blank count). Exam names are `xss_clean`ed; `exam_description` (Summernote HTML) is stored raw as before because `xss_clean` would strip `style` colours (revisit in Phase 4). Unknown `submitted` names → failed JSON. Exam insert + question assignment run in one transaction. Pickers / topics / session-terms / bank options are empty outside the exams gate (e.g. principal).
- Form teachers may create "ALL subjects" (subject 0) exams, as the picker allows, but the subject-teacher listing / ownership only matches `staff_subject_subclass` class + subject, so they cannot see or manage those exams afterwards. Same as legacy; decide in 1.13 whether `cbt_can_manage_exam` should mirror `cbt_can_manage_bank` (form-class ALL).
- **App surface (`$CBT_SURFACE = 'app'`):** `return_exams_table` → `{status, data: {exams: [... exam_id encrypted, type_label]}}`; pickers → `{value, label}` lists (`get_subclass` returns `all_value` and `subclasses` / `subjects` as `{allow_all, rows}`, with a per-subject `count`); `get_topic` → `no_topic_value` + `topics`; `get_automatic_option` → `banks: [{value, label, count}]`; `get_edit_exam` → `exam` fields; writes → `cbt_ajax_app_result` (create returns `exam_id` encrypted + `question_count`).
- **Parity harness:** `/tmp/cbt-parity/me15/{run.php,cases.php}` (temp-table shadowing of `exams`, `exam_questions`, `exam_students`, `exam_student_questions`; `mt_srand` seeded identically so automatic selection is comparable; 11 personas incl. synthetic `sub-admin` and `academic_account`, principal outside the gate). Result: 316 identical, 118 intended (forbidden / validation / CSRF / no-class filter fix), 0 unexpected. Real desktop HTTP capture still pending, as in S1.

Facts established in 1.6 (S7):

- **Engine additions in `cbt_exam_engine.php`:** `cbt_exam_scope` (exam class + subject when `cbt_can_manage_exam`, else null — every exam-question function goes through it), `cbt_exam_question_list`, `cbt_exam_assignable_questions`, `cbt_exam_bank_choices($type)` (`null` = edit-modal picker, which keeps the legacy `INNER JOIN class / subjects` so "ALL subjects" exams list no banks there; a type = assign picker, which does list subject-0 banks), `cbt_exam_question_edit_data`, `cbt_exam_assign_questions`, `cbt_exam_unassign_question`.
- **Page context:** `exam-questions.php` sets `$CBT_PAGE = 'exam_questions'` and `$CBT_ARGS = ['exam_id', 'question_bank_id']` (both decrypted from `$_GET`; `question_bank_id` only feeds `question_lead`). The page header's exam name now comes from `cbt_exam_get` (blank when the user may not manage the exam). Page went from 1632 to 1055 lines (CRLF kept).
- Desktop renderers `cbt_desktop_render_eq_{rows,assign_rows,edit_modal,ql_modal,bank_options,ql_options}` spliced from the legacy source (`/tmp/cbt-parity/eq16/{gen.php,render_skel.php}`; `gen.php` also splices `engine_add.php` into the engine). The edit modal takes the CSRF token as a parameter (the dispatcher passes `$security->create_csrf_token()`, which is stable per session).
- **Dead legacy UI paths, ported anyway:** (1) the exam question list only offered EDIT for questions assigned to *no* exam, which never holds for an exam's own questions, so EDIT never appears and `get_edit_question` is unreachable from the page. The renderer omits it. (2) If it were reachable, its modal posts `question_type` = the option *text* ("MULTIPLE CHOICE SINGLE ANSWER QUESTION") and has no mark fields, so the 1.4 `edit_question` validation would reject it. (3) The "Question Category" button that posts `question_lead` is commented out, and the page URL carries no `question_bank_id`. Decide in 1.13 whether the app's exam screen offers question edit at all (or links to the bank).
- **Intended behaviour changes:** every key needs `cbt_can_manage_exam` on the page's exam (legacy: none; forbidden reads → `[]` / empty options, modals and writes → failed JSON). CSRF now on `assign_question` / `unassign_question` (JS already sent the token). `get_question_bank` is prepared (legacy interpolated `question_type`: `x' OR '1'='1` listed every bank). `get_edit_question` needs the exam **and** `cbt_can_author_question`; unknown questions → failed (legacy rendered an empty modal). `question_lead` needs `cbt_can_author_bank` on the URL bank (legacy, with no bank in the URL, rendered the empty-category template). `get_question_lead` needs `cbt_can_author_bank` on the posted bank. Assign: every posted question must belong to a bank of the exam's class + subject (otherwise the whole request fails), junk ids fail, and questions already on the exam are skipped (`exam_questions` has **no unique key**, so legacy inserted duplicates and their option counts doubled in the listing). Inserts run in one transaction. Assign / unassign are still allowed when the exam has submissions, as in legacy; consider blocking that in Phase 4.
- **App surface:** list keys → `{status, data: {questions: [... question_id encrypted, count_options int]}}`; `get_edit_question` → `question` (as 1.4, plus `question_bank_id` encrypted), `banks`, `leads` (`{value,label}` lists); `get_question_bank` → `banks`; `get_question_lead` → `leads` + `selected`; writes → `cbt_ajax_app_result` (assign returns `assigned` count).
- **Parity harness:** `/tmp/cbt-parity/eq16/{run.php,cases.php}` (temp-table shadowing of `exams`, `exam_questions`; state compares `(exam_id, question_id)` pairs with multiplicity; 10 personas). Result: 277 identical, 176 intended (forbidden / CSRF / injection / validation / duplicate skip), 0 unexpected. Real desktop HTTP capture still pending, as in S1.

Facts established in 1.7 (S8):

- **Question paper engine:** `cbt_paper_get($db, $ctx, $type, $id)` in `cbt_exam_engine.php`, `$type` ∈ `question_bank` | `exam` | `admission_exam` → `{header, questions}`. `header`: type, id, title (bank title / exam name), exam_type, exam_name, exam_description, class_name, subject_name, topic_name, duration, unix_end_date, surveillance, admission_class_id (null where the type has none). `questions`: ordered list (database order, no shuffling) of question_id, question_lead_id, text, image (Drive id), correct_answer, incorrect_answer, `lead` {title, description, image} or null, `options` [{question_options_id, text, image, answer}] (empty for questions without options). Helpers `cbt_paper_header` / `cbt_paper_questions` skip permission checks (for the attempt engine / app later). The app preview (1.14) and print (1.15) should use `cbt_paper_get`.
- **Permissions (new; legacy had none, so any logged-in user, students included, could open any preview with the answers marked):** banks need `cbt_can_author_bank`, exams `cbt_can_manage_exam` (school), admission exams `cbt_can_manage_exam` (admission context, so `sub-admin` and `principal` are out). Missing ids, an unknown `type`, or a missing class → "Link Incorrect" redirect to `portfolio.php` (legacy rendered an empty page); forbidden → "You are not allowed to view this question bank / exam" redirect. **1.15:** `cbt-print.php` builds `$ctx` with `cbt_ctx_from_globals()`, so the app print bootstrap must set `$table_name` / `$info` / `$id` (or the session) for a staff user.
- **Renderers in a new LF file `admin/includes/cbt_desktop_render_paper.php`** (required by `cbt_desktop_render.php`; `cbt-print.php` requires it directly with `cbt_exam_engine.php`): `cbt_desktop_render_paper_heading`, `cbt_desktop_render_preview_rows`, `cbt_desktop_render_print_questions`, spliced from the legacy pages (`/tmp/cbt-parity/pv17/{gen.php,render_skel.php}`) via `cbt_desktop_paper_legacy_arrays`, which rebuilds the legacy `$questions` / `$options` / `$question_lead` arrays so the loops run verbatim. The file **must stay LF**: both pages are LF and their markup literals span lines (splicing into the CRLF `cbt_desktop_render.php` would add `\r` bytes to the output). Questions without options still get one blank option, as the legacy LEFT JOIN produced.
- **Page context:** `cbt-preview.php` sets `$CBT_PAGE = 'cbt_preview'` and `$CBT_ARGS = ['question_bank_id']`; the dispatcher routes only `process_ai_import_questions` there, to the same handler as `manage_questions` (validation before the upload move and the n8n call, `cbt_can_author_bank`). The dispatcher include runs right after the `$_GET` link checks and before the paper is loaded, so forbidden AJAX gets JSON, not a redirect. Header + paper queries, the inline import handler and the modal's `question_bank_type` query were removed (`$question_bank_type = $exam_type`). Pages stay LF; preview 1484 → 1188 lines, print 399 → 273.
- **Intended behaviour changes:** admission previews now mark the correct option (the legacy admission query never selected `qp.answer`, so every option said "INCORRECT OPTION"); admission papers still use the legacy INNER JOIN on options (admission exams are multiple choice only in the demo data). AI import on the preview page now checks permission (legacy imported into any bank id in the URL).
- Legacy quirks kept: print escapes question / option / category text with `htmlspecialchars` (Summernote HTML prints as literal tags), and still emits the commented-out per-question mark banner; preview echoes text and titles unescaped. Dead `myComparison` / `shuffle_assoc` / `$remove_instruction` left in both pages for 4.3.
- **Parity harness:** `/tmp/cbt-parity/pv17/{run.php,cases.php}` runs the **whole** legacy page (`/tmp/cbt-parity/cbt-{preview,print}.pre17.php`) vs the new page in the CLI with a stub `initialize.php` (fake session, `redirect_to` / `message` markers), every bank / exam / admission exam + id 999, both pages, 10 personas, plus 6 AJAX import variants × 4 banks (no real upload, so no n8n call). Result: 934 identical, 866 intended (700 forbidden, 80 not found, 36 admission correct-answer, 50 AJAX forbidden), 0 unexpected. Real desktop HTTP capture still pending, as in S1.

Facts established in 1.8 (S9):

- **Admission engine (appended to `cbt_exam_engine.php`, CRLF):** `cbt_adm_class_get` (session / class names, null when not permitted), `cbt_adm_exam_list`, `cbt_adm_class_subjects`, `cbt_adm_bank_options`, `cbt_adm_exam_create` (parts `[subject_id, question_bank_ids, no_of_questions]`; validates every part before picking; picks with `cbt_exam_pick_questions` over the legacy `admission_class.class_id = question_bank.class_id` join; a later part for the same subject wins, as in legacy; transaction; inserts `activation='inactive'`, `maximum_attempt=1`), `cbt_adm_exam_get`, `cbt_adm_exam_update` (no `student_view_score`: `cbt_exam_check_fields($in, false)`), `cbt_adm_exam_set_activation`, `cbt_adm_exam_delete` (keeps the legacy cascade of attempts / attempt questions, unlike the school delete), and for the questions page `cbt_adm_exam_header`, `cbt_adm_exam_scope`, `cbt_adm_exam_question_list` (+ subject_name), `cbt_adm_assignable_questions`, `cbt_adm_bank_choices`, `cbt_adm_exam_question_edit_data`, `cbt_adm_assign_questions` (requires a subject, checks the bank belongs to the exam's class, skips duplicates), `cbt_adm_unassign_question`. Dead-key helpers `cbt_adm_classes` / `cbt_adm_subjects` / `cbt_adm_topics` are ported for parity only. Access: page gate = admin, academic_principal, academic_admin, academic_account, academic, backend; writes and single-exam reads use `cbt_can_manage_exam` in admission context (admins: exam exists; teachers: the admission class's `class_id` is in their `staff_subject_subclass`), lists use `cbt_can_manage_admission_class`.
- **Marking engine started: new `admin/includes/cbt_marking_engine.php` (CRLF)** with the admission submission functions: `cbt_adm_submission_list`, `cbt_adm_attempt_get` (the attempt must belong to the exam), `cbt_adm_attempt_surveillance` (+ webcams, newest first), `cbt_adm_attempt_approve` (errors `not_found`, `empty`, `validation`, `too_high` with the legacy messages; percentage = round(revised / total × 100, 2)), `cbt_adm_student_name_parts`. **M.1** should add the school `exam-submissions.php` functions to this file. `reset_exam` and `clear_webcam_images` used by the submissions page live in `webcam-google-drive.php` and were not touched.
- **Page context:** `admission-exams.php` sets `$CBT_PAGE='admission_exams'`, `$CBT_ARGS=['admission_class_id']`; `admission-exam-questions.php` → `'admission_exam_questions'`, `['admission_exam_id', 'question_bank_id']` (the URL bank); `admission-exam-submissions.php` → `'admission_exam_submissions'`, `['admission_exam_id']`. Header queries come from `cbt_adm_class_get` / `cbt_adm_exam_header`; the assign modal's subject list uses `cbt_adm_class_subjects`. No SQL left in the three pages (CRLF kept; 1485 → 843, 1604 → 1020, 731 → 490 lines). CSRF on every key except the list keys (the pages already posted `csrf_token`). App JSON shapes exist for every key, for **1.16**.
- **Renderers (generated by `/tmp/cbt-parity/ad18/gen.php` + `render_skel.php`):** `cbt_desktop_render_adm_exam_rows`, `_adm_exam_edit_modal`, `_adm_automatic_option`, `_adm_bank_options`, `_adm_q_rows`, `_adm_submission_rows`, `_adm_surveillance`, `_adm_approve_modal`. The admission questions-page modals and option lists were byte-identical to the school ones, so `cbt_desktop_render_eq_edit_modal` / `_eq_ql_modal` / `_eq_ql_options` / `_eq_assign_rows` / `_eq_bank_options` are reused. Prepared statements return int COUNTs, so the submission diagnostics columns cast them to string for JSON parity.
- **Intended behaviour changes:** ownership and role checks on every key (legacy had none; sub-admin and principal are out of admission authoring); CSRF on writes; SQL injection closed; surveillance / approve modals read the student name from the database instead of the posted `student_name`; create validates every part and fails cleanly with no banks; approve rejects non-numeric scores and zero totals with a message.
- **Legacy bugs found (now return clean errors):** `get_edit_question` uses an undefined `$exam_id` and fatals (its EDIT link never shows anyway); `question_lead` reads an absent `$_GET` bank and renders the empty template (kept); the list fatals for sub-admin / principal (`$q` undefined); assign without a subject fatals; approve fatals on a non-numeric score or zero total; create with no banks fatals; automatic selection has no bank-type filter (kept). `get_class` / `get_subclass` / `get_topic` on the admission page are dead (JS commented out).
- **Parity harness:** `/tmp/cbt-parity/ad18/{run.php,cases.php}` (temp-table shadowing of `admission_exams`, `admission_exam_questions`, `admission_exam_students`, `admission_exam_student_questions`, `admission_exam_webcams`; fixture attempts 50–54 and webcams; seeded `mt_srand` for automatic selection; the `safe_json_encode` stub includes the production `utf8ize` retry). Result over 725 cases: 478 identical (body + table state), 238 intended (forbidden / CSRF / legacy fatals / spoofed name), 0 unexpected; 11 app JSON smoke cases succeed. Real desktop HTTP capture still pending, as in S1.

Facts established in 1.9 (S10):

- **`cbt_exam_list` rows gained `start_date`, `end_date`, `duration` (int), `sessions`, `terms`** (both scopes' SELECTs). Desktop `cbt_desktop_render_exam_rows` reads named keys only, so desktop output is unchanged (checked byte-for-byte per persona); the app `return_exams_table` JSON now carries these fields too.
- **New `cbt_marking_pending_summary($db, $ctx)` in `cbt_marking_engine.php`** (school context, same scope as `cbt_exam_list`: admins all exams, subject teachers via `staff_subject_subclass` using `EXISTS` so multi-subclass rows don't duplicate; exams gate). Per exam with submissions: `submitted` (end date or `exam_ended_cond`, joined to `students`, exactly as `exam-submissions.php` lists), `to_mark` (theory + `score IS NULL` — desktop's PHP `score == ""` is true only for NULL since decimals come back as `"0.00"`), `approved` (`approval = 1`), `last_submitted`. M.1 / M.5 should reuse it. In-progress theory attempts (no end date) are not counted, as on desktop.
- **CBT Center now:** hero title / stamp follow the tab (banks → banks count, exams → filtered exam count, marking → scripts to mark); a KPI row (Questions → banks tab, Exams, Open now → exams tab, To mark → marking tab; only for permitted areas; first tile for the current tab highlighted); banner per tab (empty banks, exams with no questions, scripts waiting). "To mark" tab item shows a red count badge. All three lists share one client filter (`#cbt-list`, `#cbt-search`, `#cbt-nomatch`; chips match `data-cbt-type`, `data-cbt-status` or `empty`).
- **Exams tab:** server-side session-term filter `?tab=exams&st=<sessions>|||<terms>` (default "All sessions", like desktop; values not in `cbt_exam_session_terms` are ignored), applied in PHP on the full list so the KPI row stays unfiltered. App status per exam (`zap_cbt_exam_status`): `inactive` (activation ≠ active), `upcoming` (start in the future), `ended` (end in the past), else `open`; strip = All / Open / Upcoming / Ended / Inactive / Empty. Cards (`zap_cbt_render_exam_card`): type + status chips, topic, window + duration, Questions / Marks / Attempts (Attempts = engine `count_students`, so its meaning still differs by role). Cards are not tappable yet — link them to `cbt-exam.php` in 1.13.
- **To mark tab:** cards only for exams with `to_mark > 0` (count badge, Submitted, Marked = submitted − to_mark, last submission), plus a "use the web portal to mark" note. Not tappable until M.3 / M.5.
- Demo data: 24 exams all in 2016/2017 1st Term, none open as of 2026-10-04; one unmarked theory script (BASIC SCIENCE, JSS 2). Some exams have end before start (e.g. BASIC SCIENCE) — they show as Ended.
- **Check:** `/tmp/cbt-parity/cc19/check.php` — 13 personas (backend, admin, 9 real subject teachers incl. `academic_account`, principal, student): every summary row is an exam the user can list, counts equal the legacy per-exam rule, every listable exam with submissions appears, desktop exam rows identical with / without the new keys → OK. Browser check of all three tabs on the app admin login (screens in `/tmp/cbt-parity/cc19/`).

Facts established in 1.10 (S11):

- **New app page `app/admin/cbt-question-bank.php?id=<encrypt(bank id)>[&tab=categories]`** (CRLF). Shell = the CBT Center / admission-questions pattern (`zap-ward-page learners-center-page cbt-center-page cbt-bank-page`, `md-check-toolbar`), because `md-lc-card`, `md-check-empty` and the cbt sort / search / KPI styles are scoped to those classes and `md-topic-toolbar` only styles under `.topic-page`. Pattern D's dual context = two `md-check-toolbar__chip`s (Class, Subject) in `.cbt-bank-toolbar__context`; the hero does not repeat them. Page gate: `cbt_can_open_center` + `cbt_qb_get` (i.e. `cbt_can_author_bank`); bad / missing / forbidden ids redirect to `cbt-center.php?tab=banks`. "Manage bank" (actions sheet: Edit details, Delete bank) only when `cbt_can_manage_bank`, so `academic_account` (author via exams gate) gets a read-only page.
- Page renders server-side from the engine (no AJAX listing): hero (type overline, title, "Used in N exams" pill, question stamp), KPI row (Categories, Total marks = Σ `correct_answer`, With image, Need fixing for objective banks), banner when questions need fixing, tabs Questions / Categories, chips All / Need fixing / In a category / No category / With image, client search over question, option and category text. Question cards show sanitized text, marks (+ penalty when `incorrect_answer` < 0), category chip, Drive image links (`question_dir` / `option_dir` view URLs, opened in a new tab; the stored `lh3` thumbnails are not used), lettered options with the correct one highlighted. **"Need fixing"** = objective (`question_type` ≠ `theory`) question with < 2 options or no option with `answer = 1` (demo: bank 1 has 3, bank 4 has 1).
- **Engine additions (`cbt_question_bank_engine.php`):** `cbt_qb_get` also returns `class_name`, `subject_name` (LEFT JOINs; `manage_questions.php` reads the title only). `cbt_q_list` rows gained `question_thumb_dir`, `question_lead_id` (desktop `cbt_desktop_render_q_rows` reads named keys → byte-identical, checked for 6 personas over all 43 banks). New `cbt_q_bank_options` (question_id → options, one query), `cbt_qb_exam_usage` (distinct exams using the bank's questions; shown on the page and in the delete confirmation), `cbt_qb_update` (atomic title + class + subject + type edit: `cbt_can_manage_bank`; title required, **≤ 30 chars** (column is varchar(30); the app is the only caller so desktop's silent truncation is unchanged); changed title unique across all banks as the inline edit; class / subject re-checked with `cbt_qb_can_target(class, subject)` only when they change; one UPDATE).
- **Dispatcher (`cbt_ajax.php`, `question_bank` route):** new CSRF-protected `update_question_bank` (`question_bank_id`, `class_id`, `subject_id` encrypted; `question_bank_title`, `question_bank_type`); app `create_question_bank` now returns `data.question_bank_id` **encrypted** (desktop response unchanged — `$desktop` is built first). Both `cbt-center.php` and `cbt-question-bank.php` now `require` the dispatcher with `$CBT_SURFACE='app'`, `$CBT_PAGE='question_bank'`; the sheets post `return_subjects`, `create_question_bank`, `update_question_bank`, `delete_question_bank` to the page itself.
- **CBT Center:** bank cards are links (stretched `.cbt-bank-card__link`, `zap_cbt_bank_url()`), toolbar shows "New question bank" on the banks tab when `cbt_staff_classes` is non-empty; create redirects to the new bank page, delete redirects to the banks tab.
- **Shared pieces for 1.11+ (`zap-cbt-helpers.php`):** `zap_cbt_rich()` — DOMDocument allowlist sanitizer for Summernote HTML (formatting tags, tables, `img` with http(s) / data:image src only; attributes stripped; script-like elements dropped) — use it in `cbt-take.php` / preview too; `zap_cbt_plain()`, `zap_cbt_num()`, `zap_cbt_media_link()`, `zap_cbt_render_question_card()`, `zap_cbt_render_lead_card()`, `zap_cbt_class_options()` / `zap_cbt_subject_options()` (encrypted values; keeps a bank's current subject selectable even when the picker would not offer it), `zap_cbt_render_bank_sheet()` (`#cbt-bank-sheet`, native `mui-field__select`s, no bootstrap-select). Client: new `app/admin/scripts/zap-cbt-bank.js` (config `window.ZAP_CBT_BANK = {csrf, centerUrl}`; `[data-cbt-sheet]` opens a `md-sl-sheet` + `#logger`, class → subject reload, validation, create / save / delete with `swal`, `[data-cbt-tag-filter]` + `#cbt-bank-search` list filter). 1.11 can add an "Add question" toolbar action next to "Manage bank" (switch `.cbt-toolbar-actions--solo` off) and question / category sheets in the same style.
- CSS: `/* ── zap-app: cbt question bank page ── */` block plus toolbar-action and card-link rules appended to the cbt section of `zapapp.css` (sheet rules are scoped by `.cbt-bank-sheet` because sheets render outside the page wrapper).
- **Checks:** `/tmp/cbt-parity/qb110/{run.php,cases.php}` (temp-table shadowing; `inc_pre/` = pre-change includes) — desktop `return_questions_table` rows identical for 6 personas, desktop create response + state identical, app create / update (admin, principal, own-bank teacher; duplicate, > 30 chars, empty, bad type, no class, CSRF, foreign bank, teacher moving to a foreign class / subject, `academic_account`, missing bank) / delete → 27 / 27 OK. `render.php`: all 43 banks / 173 questions / 34 categories render with no PHP warnings, option counts match `count_options`, sanitizer cases (script, onerror, javascript:, iframe, svg, math, style attrs) → OK. Browser (app admin login, 412 px): CBT Center "New question bank" sheet + subject reload + validation, bank 1 page / categories tab / filters / search, Edit details prefill and a no-op save through the real session (success, data unchanged), all 86 bank-page loads clean, bad ids redirect. Screens in `/tmp/cbt-parity/qb110/`. Create / delete were exercised only on temp tables (not in the browser) to keep the demo data intact.

Facts established in 1.11 (S12):

- **Images are Drive attachments, not inline.** Desktop has no Summernote `onImageUpload` anywhere; question / option / category images are separate file inputs sent to Drive (`question_dir`, `options[k][option_dir]`, `lead[k][question_lead_file]`), and `option_text` is varchar(500) so base64 images would not fit. So the app toolbar "image" button (Q19) opens the slot's file picker; `onImageUpload` is a no-op and drag-and-drop / paste of images is off (paste inserts plain text, as desktop). Client resizes png / jpeg over 1.5 MB on a canvas (max side 1600, JPEG 0.85) and rejects anything still over 5 MB; categories also accept gif.
- **Writes post to `app/admin/google-ajax.php`** (LF): after `$service` is built, a guarded `require` of the dispatcher with `$CBT_SURFACE='app'`, `$CBT_PAGE='drive_ajax'` runs only when the POST has one of `create_question`, `edit_question`, `delete_question`, `delete_question_dir`, `delete_option_dir`, `delete_question_lead_dir`, `update_question_lead` (no key or function-name clashes with that file). It reuses `cbt_ajax_drive_storage()` over the app's `insertFile` / `getFolderId` / `deleteFile`; responses are the engine's `cbt_ok` / `cbt_fail` JSON. No new engine write paths.
- **Engine:** `cbt_q_bank_options` also returns `qo.google_drive_id`, so an app edit can post it back and keep option images (the engine keeps an option image only when the posted id belongs to that question; dropped ones are released). Desktop renderers do not read it.
- **Category save posts the whole list** (`update_question_lead` replaces the bank's categories; missing unused ones are deleted). The sheet sends every lead: the edited one with new values, the rest with their stored `description_raw`. Consequences: a stale page can re-create a category deleted elsewhere since it loaded; **the engine uppercases every title on save** (legacy, same as the desktop modal), so the first app save of a bank uppercases mixed-case demo titles. Delete is blocked client-side when questions use the category (engine also refuses).
- **Sanitizer:** `zap_cbt_rich_clean` now keeps a safe `style` subset (`color`, `background-color`, `text-align`, `font-weight`, `font-style`, `text-decoration`; values hex / rgb(a) / keyword only; never on `img`) via `zap_cbt_rich_style()`. Editor content is prefilled from `zap_cbt_rich()` output, so an app edit normalises legacy HTML (drops disallowed tags / attributes).
- **Page (`cbt-question-bank.php`):** toolbar "Add question" / "Add category" (per tab) beside "Manage bank" (`--solo` when not a manager), add buttons in empty states, Edit / Delete pills on every card (`zap_cbt_render_card_actions`), `#cbt-question-sheet` and `#cbt-lead-sheet` (`zap_cbt_render_question_sheet` / `zap_cbt_render_lead_sheet`), config `window.ZAP_CBT_AUTHOR = {csrf, postUrl, bankId, objective, questions{enc id → …}, leads[…]}` from `zap_cbt_author_payload()`. `zap-cbt-bank.js` exposes `window.ZapCbtSheets = {open, close, alert, go}`; closing a sheet after a save reloads.
- **Summernote 0.8.20 gotchas (all handled in `zap-cbt-author.js`):** (1) `$.summernote.ui` is undefined until the first editor is created, so custom button factories must use `context.ui`; (2) `code()` throws when `placeholder` is empty; (3) the image popover's blur handler can call `hide()` before it is built, so `popover.image / link / table / air` are empty; (4) jQuery `.data('cbtEditor')` reads the `data-cbt-editor` attribute, so the "initialised" flag is `cbtSn`. Init failures fall back to the plain textarea. Summernote CSS / JS load on the first sheet open (`data-zap-summernote` marker).
- **Checks:** `/tmp/cbt-parity/au111/run.php` (temp-table copies, fake storage, exact FormData shapes the sheet sends: create / edit / keep-drop option images / delete / image clears / category add-edit-delete-in-use / CSRF / other staff / theory) → 51 / 51 OK; `render.php` → 43 banks, 173 cards, 380 actions, 0 warnings; `qb110/cases.php` still OK 27 (desktop rows unchanged). Browser (app admin, 412 px): question sheet (5 editors, slim toolbar, validation messages, mark answer, add / remove option re-lettering, expand to full screen), edit prefill incl. Drive image, category sheet prefill / empty and duplicate title / in-use delete alert, theory bank 33 sheet (no options / penalty); a throwaway question was created, edited and deleted through the real session (bank 1 back to 7 questions). Category writes were not run in the browser to avoid uppercasing the demo titles.

Facts established in 1.12 (S13):

- **Route:** `cbt-question-bank.php` now sets `$CBT_ARGS = ['question_bank_id' => decrypt($_GET['id'])]` before the dispatcher include; `cbt_ajax_dispatch` sends `process_ai_import_questions` on the `question_bank` page to the same `cbt_ajax_manage_questions` handler as desktop (CSRF, `cbt_can_author_bank`, validation before the upload move and the n8n call). Other `question_bank` keys are unchanged; on `cbt-center.php` (no bank arg) the key fails with "Invalid question bank ID".
- **Engine:** `cbt_q_import_file` also returns `imported` (bank question count after − before). The app message is "N question(s) imported"; desktop keeps the legacy "Questions Successfully Imported! Successfully imported N question(s)".
- **What the importer stores (legacy `import_questions_to_database`, unchanged):** every question gets `correct_answer = 1`, `incorrect_answer = 0`; passages become categories with the AI's title as is (not uppercased, unlike sheet saves); images in the file are not imported; objective questions need ≥ 2 options, and ones without a correct option show under "Need fixing". The sheet says all of this.
- **UI:** entry points are the empty-state "Import from file" button, a first "Import from file" item in the Manage bank sheet (managers), and a toolbar "Import" button for authors who are not managers (e.g. `academic_account`; that toolbar variant was not seen in the browser because no such login was available). All of them are **hidden when `question_extractor_webhook_url()` is empty**, so deployments without the `ask_zap.php` key do not offer import in the app. `#cbt-import-sheet` (`zap_cbt_render_import_sheet`) has a file picker (`.pdf,.docx,.txt` + MIME types for Android pickers), a chosen-file row, client checks (type, 10 MB), upload progress (first 30% of the bar), then an easing "extracting" bar with elapsed time. Close buttons are disabled while busy; if the sheet is dismissed by tapping outside, the result still arrives as an alert. A `beforeunload` guard runs while busy. On success: swal, then reload on the Questions tab.
- New `app/admin/scripts/zap-cbt-import.js` (CRLF; config from `window.ZAP_CBT_BANK.csrf`, opens sheets via `ZapCbtSheets`). CSS: `/* ── zap-app: cbt import sheet ── */` at the end of `zapapp.css` (`zapapp.min.css` is rebuilt automatically by `zap_min_css_rel` on the next request).
- App swal dialogs always show the yellow "!" icon (`styles/md-sweetalert.css` `.sweet-alert::before`), whatever `type` is passed. That is app-wide, not a CBT bug.
- **Checks:** `/tmp/cbt-parity/im112/run.php` (temp tables; CSRF, no file, bad type, > 10 MB, upload error, valid file stops at `move_uploaded_file` in the CLI, missing bank, no bank arg, other teacher, student, `return_subjects` still routed, desktop response shape) → 23 / 23, no rows written. `au111/run.php` 51 / 51; `qb110/cases.php` OK 27; `qb110/render.php` OK (its sanitizer expectation updated for the 1.11 style subset). Browser (app admin, 412 px): sheet layout, client validation, then a **real n8n import** of a 3-question `.txt` into a throwaway objective bank (~7 s): 3 questions, the passage as 1 category holding 2 of them, correct answers set, 1 mark each, 0 "need fixing". The throwaway bank was then deleted through the app (rows verified gone).

Facts established in 1.13 (S14):

- **Routing:** both app pages choose the dispatcher page from `$_POST['cbt_route']`: `'exams'` → `manage_exams`, otherwise the page default (`exam_questions` on `cbt-exam.php`, `question_bank` on `cbt-center.php`). The two routes share no POST keys. `cbt-exam.php` sets `$CBT_ARGS = ['exam_id' => decrypt($_GET['id']), 'question_bank_id' => 0, 'sessions', 'terms', 'teacher_create_exam']`; `cbt-center.php` adds `sessions` / `terms` / `teacher_create_exam` (all from the app `init.php`). The page opens only when `cbt_can_open_center` and `cbt_exam_get` pass; otherwise it redirects to `cbt-center.php?tab=exams`.
- **Engine additions (desktop output unchanged):** `cbt_exam_list` accepts an `exam_id` filter (the hero needs its class / subject / topic names); `cbt_exam_question_list` also returns `question_type`, `question_lead_id`, `question_bank_id` (desktop renderers read named keys only); new `cbt_exam_question_options` (scope-checked, options for all of an exam's questions in one query). `cbt_ajax_app_exam_question` adds `question_plain` and drops `question_bank_id` from app JSON.
- **Decisions on the 1.6 / 1.9 open questions:** (1) the app exam screen does **not** edit questions. Each card shows its bank as a chip (linked to `cbt-question-bank.php` when `cbt_can_author_bank`) and a Remove button. (2) `cbt_can_manage_exam` is unchanged (no form-class "ALL" mirroring). To avoid stranding form teachers, the app create sheet offers "All subjects" only when the exam list scope is `all` (admins). The server still accepts it, as on desktop. (3) The assign sheet offers Objective and Theory banks, defaulting to the exam type, as desktop does.
- **Page (`app/admin/cbt-exam.php`, CRLF):** toolbar with Class / Subject chips, "Add questions" and "Manage exam"; hero (type, status pill, session · term, topic, question count) with Marks / Duration / Pass mark / Submitted KPIs and one banner (no questions, then need fixing, then scripts to mark); Settings card (Edit, Active switch, Opens / Closes, score / answers / surveillance facts, collapsible instructions); questions list reusing the bank page filter chips / search from `zap-cbt-bank.js`. Exam cards in CBT Center now link here (`zap_cbt_exam_url`). Preview / Print / Submissions are left to 1.14, 1.15 and M.3.
- **Sheets (`zap-cbt-helpers.php` + new `scripts/zap-cbt-exam.js`, CRLF):** `zap_cbt_render_exam_sheet` serves create (CBT Center "New exam", shown when `cbt_exam_can_create`) and edit (class / subject / topic / type locked). Create defaults follow the desktop form: pass mark 50, score shown, answers hidden, surveillance on, objective. New exams start inactive. Manual or "Random from banks" (`get_automatic_option` bank checkboxes, the question count capped at the available total). Instructions use the lazy Summernote with a slim toolbar (no images, plain-text paste). `datetime-local` values are posted with a space instead of `T`. `zap_cbt_render_assign_sheet`: bank type toggle, bank select (auto-selected when only one), checkbox list of not-yet-assigned questions with select-all / indeterminate state and an "Add N questions" button.
- **Client guards:** activation is refused when the exam has 0 questions and asks for confirmation when questions need fixing; Remove warns when students have already submitted; Delete relies on the engine's message when attempts exist.
- **CSS:** `/* ── zap-app: cbt exam page and exam sheets ── */` at the end of `zapapp.css`. Two fixes from the browser check: `[hidden]` now wins anywhere inside the exam sheet (the bank-loading helper stayed visible), and `.cbt-author-sheet .cbt-field-row` cells get `min-width: 0` (the `datetime-local` inputs overflowed the two-column row at 412 px).
- **Data quirk:** exams 12 and 13 each hold an `exam_questions` row for deleted question 79. `cbt_exam_list` `count_questions` counts it (CBT Center card says one more), but the question list joins `questions` and does not. The exam page uses the real list. Not fixed (data only).
- **Checks:** `/tmp/cbt-parity/ex113/run.php` (temp tables; create manual / automatic, validation, edit, activation, assign / unassign, duplicate skip, delete, CSRF, other teacher, student) → 39 / 39; `ex113/render.php` (every exam × 5 personas: scope, counts, unsafe markup, Remove buttons) → OK; desktop parity re-run unchanged (`eq16` 277 / 176 / 0, `me15` 316 / 118 / 0); `qb110/render.php` OK. Browser (app admin, 412 px): create sheet pickers (subjects incl. "All subjects" for admin, topics, 13 banks with counts), exam page hero / settings / question cards, edit prefill incl. instructions in Summernote, assign sheet (already-assigned bank shows the empty state, 30-question bank list, select-all), then a **real round trip**: create (redirects to the new exam, inactive) → activation refused with 0 questions → add 2 → remove 1 → delete (back to CBT Center; exam and its `exam_questions` rows verified gone).

Facts established in 1.14 (S15):

- **New app page `app/admin/cbt-preview.php?type=exam|question_bank|admission_exam&id=<encrypt(id)>`** (CRLF). Data and permissions come only from `cbt_paper_get` (1.7); any failure redirects to `cbt-center.php` (`?tab=banks` for banks, `?tab=exams` otherwise). Banks also call `cbt_qb_get` for the class / subject overline, since `cbt_paper_header` has none for banks. Close returns to `cbt-exam.php`, `cbt-question-bank.php` or `admission-exam-questions.php?admission_exam_id=` (admission is supported but not linked yet; link it in 1.16). Entry points: a "Preview as a student" button in the hero of `cbt-exam.php` and `cbt-question-bank.php` (`.cbt-hero-actions`, shown when there are questions; 1.15 Print and M.3 Submissions can join that row).
- **Taking renderer, shared with 2.6 (`zap-cbt-helpers.php`):** `zap_cbt_render_take_question($q, $index, $total, $exam_type, $reveal)` takes a `cbt_paper_questions()` row. The answer type follows the **paper's** type (exam / bank type), not the question's, as on desktop, so theory questions assigned to an objective exam (e.g. exam 29) show "This question has no options". **`$reveal` must be false for students:** it is the only thing that emits `data-cbt-correct` and the "Correct" badge. Marks and the wrong-answer penalty are always shown, because desktop `cbt.php` shows students both. Helpers: `zap_cbt_preview_url`, `zap_cbt_drive_img` (Drive `thumbnail?sz=` URL for a Drive-shaped id, as desktop uses), `zap_cbt_take_figure` (inline image, tap to zoom), `zap_cbt_rich_visible` (`zap_cbt_rich` output, or `''` when there is no visible text and no image; demo leads hold only mis-decoded BOMs `ï»¿` + `<br>`).
- **`scripts/zap-cbt-take.js` (CRLF), `data-cbt-mode="preview"`:** intro (instructions, totals, surveillance notice, preview switches) → one question per screen, Prev / Next dock (the last Next becomes Finish), flag, top bar with local countdown (5-min snackbar, low / urgent colours) and an answered counter, progress line, answer-sheet sheet (grid of bubbles: answered / flagged / current), image zoom layer, ←/→ keys (ignored in inputs). Preview switches: "Show correct answers" (objective only; turned on automatically at Finish) and "Student order" (client-side shuffle of questions and options, re-lettered, like `cbt.php`'s `shuffle_assoc`). The result sheet scores locally from `data-cbt-correct`, `data-cbt-marks` and `data-cbt-penalty` (right = +marks, wrong = +penalty, blank = 0); theory shows answered / blank counts. Nothing is posted. The camera button only shows a snackbar in preview. **2.6** adds the take mode: server start time / timer, `save_answer` autosave, visibility exits, a real submit.
- Sheets use the same `.menu-active` + `#logger` mechanics as the other CBT sheets, without `zap-cbt-bank.js`. Grid cells must not have `md-ripple` (its `overflow: hidden` clips the flag dot).
- **Shell CSS** `/* ── zap-app: cbt taking shell ── */` (tokens `--take-*`; signature = answer-sheet bubbles in OMR rose `#c9486e` that fill with graphite when chosen, and a ruled answer area for theory). The bottom nav, back FAB and Ask dock are hidden with **`body.cbt-take-app`** selectors: the app shell rewrites `<html>`'s class list after load and drops page classes, but body classes survive (the same reason `subject-assessment-edit.php` re-adds its class on body).
- **Checks:** `/tmp/cbt-parity/pv114/render.php`: every bank / exam / admission exam (+ id 999) × 5 personas (admin, form+subject teacher, `academic_account`, principal, student). Access matches `cbt_paper_get` (the student opens nothing, the principal gets banks only), 364 screens render with no PHP warnings, option and correct-option counts match the data, student-mode output has no `data-cbt-correct` or badge, and there is no unsafe markup → OK. Browser (app admin, 412 px): exam 29 (passages, Drive images, theory questions without options, surveillance notice) — answer / flag / jump from the sheet / finish scored 1 of 14 (1 right, 2 wrong, 9 blank) as expected; theory bank 34 with Student order (questions reordered and renumbered, typed answers counted, no reveal switch, no timer); empty bank 33 empty state; hero button on the exam page.
Facts established in 1.15 (S16):

- **`encrypt()` is not a signature.** The live `encrypt` / `decrypt` in `admin/includes/functions.php` (~2267) is triple base64 + urlencode, so the finance `pt=` token (`fpop_mint_print_access_token`) can be forged by anyone who decodes it (out of CBT scope; flag for Phase 4 / finance). A CBT print shows the correct answers, so the CBT token is a real HMAC instead.
- **Token (`app/admin/includes/cbt-app-print-token.php`, CRLF):** `payload.sig`, both base64url; payload JSON `{t: table, u: user id, p: paper type, i: paper id, d: $db_name, e: expiry}`; sig = HMAC-SHA256 with key `sha256(google_client_secret | google_client_id | zap-cbt-print-v1)` (per-school `settings` rows, server-only, same idea as `zap_drive_thumb_secret`). TTL 2 h (`zap_cbt_print_token_mint`, min 5 min). Minted only for `staff`, `backend`, `admin`, `director`, `account`, `owner`; never for students / parents. No `google_client_secret` → no token (the link still works in a browser that has an app session). Rotating the Google secret invalidates outstanding links. `zap_cbt_print_url($type, $id)` builds the `browser:` link for the signed-in user; `zap-cbt-helpers.php` requires the token file.
- **Bootstrap (`app/admin/includes/cbt-app-print-bootstrap.php`, CRLF):** `?type=&id=<encrypt(id)>[&pt=]`. Session user (`$_SESSION['user_key']`) wins; a token for a different user than the session → "no access" (fpop semantics); token for another type / id → "invalid"; bad / expired / other-school / tampered token without a session → "expired, tap Print again". A token user's role is re-read from the DB (`staff.status`, `form`, `activation = 'activated'`; executives must still exist in `$executives_table`; backend / director → `admin`, account → `account`, owner → `owner`), so role changes and deactivation apply immediately. Permission is `cbt_paper_get` as on desktop (owner / account get "not allowed" for exams). Plain error page like `fpop_app_print_fail`; `Cache-Control: no-store`.
- **Multi-school domains:** the token binds `$db_name`. In the system browser without a session the app `init.php` uses the domain default DB, so a token minted for a non-default school of a multi-school domain fails with "expired". Single-school domains (the norm) are fine. Revisit only if a multi-school deployment needs app print.
- **Shared print view:** the desktop markup (`<!DOCTYPE` … `</html>`) moved byte-for-byte into LF `admin/includes/cbt_print_view.php`; `admin/cbt-print.php` now ends with `require("includes/cbt_print_view.php");` (LF kept, 273 → 99 lines). The only code change inside the view: `$dr = $cbt_print_assets ?? "assets/";` — the app page sets `$cbt_print_assets = '/admin/assets/'`. New `app/admin/cbt-print.php` = init + bootstrap + view; output is the desktop paper (same auto `window.print()`).
- **UI:** "Print paper" (`fa-print`, `data-cbt-print`) next to "Preview as a student" in the hero of `cbt-exam.php` and `cbt-question-bank.php` (shown when there are questions). `zap-cbt-bank.js`: inside the WebView the `browser:` href is left to the native shell; in a plain browser the click opens the URL without the prefix in a new tab. Admission exams get their Print button in 1.16 (the bootstrap already supports `type=admission_exam`).
- **Checks:** desktop parity `/tmp/cbt-parity/pr115/go.sh` (pre-1.15 page vs new, every print case from `pv17`: 10 personas × all banks / exams / admission exams + id 999) → 780 / 780 identical (390 papers, rest redirects). `/tmp/cbt-parity/pr115/tests.php` (runs the app page in the CLI with a stub `init.php`): 53 access checks (token / session / both, no auth, garbage / expired / tampered payload / tampered signature / other DB / rotated secret, wrong id / type, session ≠ token user, missing staff, student / parent never minted, no secret, student / principal sessions, principal bank token, missing paper, bad type / id, all 5 executive kinds, missing executive, staff 9 token over all 24 exams = desktop scope) → OK; app paper vs desktop paper for staff 2 over every bank / exam / admission exam → 74 / 74 identical (after the asset prefix). `ex113` / `qb110` / `pv114` render harnesses still OK. Live (app admin session): button on exam 29 and bank 1, link fetched with the session and with `credentials: 'omit'` (token only) → same 29,560-byte paper, `no-store`; no token / bad signature → the right messages; `/admin/assets/*.css` and jQuery load (200). The native WebView `browser:` hand-off itself was not exercised (no device).

Facts established in 1.16 (S17):

- **App admission pages on the engine:** `app/admin/admission-exams.php` (979 → 578 lines), `admission-exam-questions.php` (552 → 362) and `admission-exam-submissions.php` (532 → 323) contain no SQL (CRLF kept). Each sets `$CBT_SURFACE='app'`, `$CBT_PAGE` (`admission_exams` / `admission_exam_questions` / `admission_exam_submissions`) and `$CBT_ARGS` like the desktop pages, includes `admin/includes/cbt_ajax.php`, then builds `cbt_ctx_build(..., 'admission')`. Headers come from `cbt_adm_class_get` / `cbt_adm_exam_header`; when either returns null the page redirects (to `admission-center.php`, or to `index.php` for users without admission access). Subject options come from `cbt_adm_class_subjects`. Lists render server-side, and after any write the page reloads; the old AJAX list keys are no longer called by the app.
- **Renderers** (new section at the end of `partials/zap-cbt-helpers.php`): `zap_cbt_render_adm_exam_card`, `zap_cbt_render_adm_question_card` and `zap_cbt_render_adm_submission_card($row, $can_reset, $reset_available)`. The exam and submission cards are byte-identical to the legacy AJAX markup, except that the exam card adds `data-questions` (activation is refused client-side at 0 questions) and `data-submitted` (shown in the delete confirmation). Question cards use `zap_cbt_plain` for the excerpt (80 characters) and for the View sheet text; the View sheet only links `http(s)` media.
- **JS contract:** pickers are `{value: encrypt(id), label}`; the pages read `data.subjects`, `data.banks` (with counts), `data.exam`, `data.questions` (`question_plain`, `question_lead_title`, `count_options`) and `data.attempt` (approve: `student_name`, `score`, `total_score`, `revised_score`; surveillance: `webcams [{image, time}]`, `fullscreen_exits`, built with text nodes). `datetime-local` values are converted between `T` and the engine's `Y-m-d H:i`. jQuery adds `[]` to array keys itself, so a trailing `[]` is stripped from form names before posting. Reset / clear webcam images still post to `admin/webcam-google-drive.php` (untouched); the reset button shows only when `cbt_can_reset_attempt`.
- **Engine / dispatcher:** `cbt_adm_submission_list` also selects `es.approval, es.revised_score` (desktop reads named keys only, so its output is unchanged). `approve_score_modal` now builds the desktop modal only for the desktop surface; the app gets `{status, data: {attempt}}`. Previously the desktop renderer ran eagerly and needed `space_items()`.
- **Hero actions on the questions page** (shown when there are questions): "Preview as an applicant" (`zap_cbt_preview_url('admission_exam', id)`) and "Print paper" (`zap_cbt_print_url`, `data-cbt-print`), using `.admission-questions-page .md-adm-hero-actions`. `cbt-preview.php` failure redirects now go to `admission-center.php` for `admission_exam`. Close already returned to the questions page.
- **Admission hub:** `admission-center.php` shows the class card's Exams CTA only when `cbt_can_manage_admission_class` passes. A lone CTA spans both grid columns.
- **Styling fixes found in the browser:** the questions and submissions pages lacked `learners-center-page` on the wrapper (hero unstyled) and the `md-adm-tools` search card. Both were added, with search-card CSS for those two pages in the `zap-app: admission exam pages on the CBT engine (1.16)` block of `zapapp.css` (LF). **Not fixed (pre-existing, markup unchanged from legacy):** the `md-timeline-sheet` modals on these pages (assign questions, approve score, surveillance) have no `zapapp.css` rules and render as plain full-screen content. They work, but need a design pass.
- **Intended behaviour changes:**
  - Plain `principal` (and `sub-admin`) lose app admission exam access, matching desktop and the admission gate. The hub hides the link.
  - Ownership, CSRF and validation apply on every key.
  - Edit, delete and activation are scoped by exam ownership, not by the class in the URL.
  - Assign skips duplicates and rejects questions outside the class / subject.
  - Approve validates the revised score: it must be numeric and between 0 and the current score.
  - Surveillance and approve names come from the DB, not the posted `student_name`.
  - Automatic create rejects incomplete subject / bank rows; legacy silently skipped them.
- **Checks** (`/tmp/cbt-parity/ad116/`; `pre/` holds the pre-1.16 pages, `sandbox/` holds page copies with a stub `includes/authenticate.php` that shadows temp tables, so the real DB rows are untouched):
  - `render.php`, server-rendered lists vs legacy AJAX lists, 9 personas × admission classes 1–25 + 999 and exams 1–8 + 999: 817 / 817.
  - `flows.php`, which covers create (manual, automatic, too many, foreign bank, missing name, bad dates, bad CSRF, forbidden personas), edit, activation, delete cascade, page gates, assign / duplicate / foreign question, unassign, approve modal / surveillance / approve (valid, too high, non-numeric, other exam, forbidden, CSRF), the reset control per role and the hub link: 45 / 45.
  - Desktop 1.8 parity rerun: 476 same / 238 intended / 0 unexpected, the same as the S9 report (its "478" was a typo).
  - Live (app admin session):
    - The exams page renders as before.
    - Edit prefill is correct, and a no-op edit save left the row unchanged.
    - The automatic create sheet loads subjects and banks with counts.
    - Exam 2 shows 15 question cards with Preview / Print. The assign chain (subject → 14 banks → question list) works; nothing was assigned.
    - Exam 1 submissions show 2 cards, the approve modal prefilled from the DB, and surveillance (0 exits, no captures).

Facts established in D.0 (S18):

- **Shell:** admission popups use `class="menu menu-box-bottom md-sl-sheet cbt-bank-sheet md-adm-sheet md-adm-<name>-sheet" data-menu-height="auto" data-menu-effect="menu-over"`, with a `<form class="md-sl-sheet__panel" role="dialog" aria-modal="true">` panel, the `md-sl-sheet__header` / `content md-sl-sheet__body cbt-sheet-body` / `cbt-sheet-footer` parts, and `cbt-form-error` for inline errors. Pages open sheets with their existing `openSheet()` (`#logger` backdrop plus `menu-active`). Sheets render outside the page wrapper, so `.md-adm-sheet` repeats the `--adm-*` tokens and adds `--adm-pass` / `--adm-fail` (+ `-soft`) from the admissions tools strip. CSS: `zap-app: admission sheets` block at the end of `zapapp.css` (LF).
- **Approve popup design:** the score is the editable thing, so it is the large figure (Jost 2.75rem, tabular, `!important` because a theme input rule forces 12px), flanked by −/+ steppers (whole steps, clamped between 0 and the marked score). The rail encodes three true values: the pass-mark line (`minimum_percentage`), a hollow notch at the marked score, and a fill for the score being approved. The panel and the verdict ("25% · Below the 50% pass mark") turn green or red, and the verdict shows only for valid input. The lead states the marked score and "Last approved at …" when there is a revised score. The submit button echoes the number ("Approve 2.50") and is disabled while the input is invalid (empty, not a number, below 0, above the marked score, or no total score). The server rules in `cbt_adm_attempt_approve` are unchanged.
- **Data:** no engine change. The pass mark comes from a new `data-min_pass` on the card's Approve button (`zap_cbt_render_adm_submission_card`). The ad116 render test strips it, the same way as the exam card's `data-questions` / `data-submitted`. The prefill is the revised score when one exists, otherwise the marked score (legacy left it empty when there was no revised score).
- **Checks:**
  - ad116 render 817 / 817 and flows 45 / 45 after the change.
  - Live: opened for both exam 1 applicants. The invalid value 5 shows "cannot be higher than the marked 3.00", disables Approve and hides the verdict. The stepper clamps. A real approve at the existing 3.00 (no data change; the webcam-clear `$.post` was stubbed in the browser) returned success, closed the sheet and showed the confirmation.
  - At a 390 × 844 phone viewport the sheet is 447px tall with no body scroll.
- **Seen, not changed:** the success `swal` shows the theme's warning-style icon for `type: 'success'` (app-wide swal theming).

Facts established in D.1 (S19):

- **Shell:** the surveillance popup uses the D.0 shell with `md-adm-surv-sheet`. Its single Close button needs a one-column footer (`.md-adm-surv-sheet .cbt-sheet-footer`). CSS lives in the same `zap-app: admission sheets` block, after the approve rules.
- **Design:**
  - Two figures sit at the top: "Left fullscreen" (green at 0, red above 0) and "Webcam captures", with a note such as "About one every 3 min" or "Surveillance off".
  - The signature element is the exam reel. The track spans the time allowed (or longer, if the attempt or a capture ran past it). A soft fill runs from the start to the submission. There is one tick per capture, red when the capture falls outside the attempt (a 30-second slack allows for drift). Hovering or focusing a capture row highlights its tick.
  - Captures are listed oldest first as an `<ol>`. Each row has a 64 × 48 thumbnail, the clock time, its offset into the exam (or "Before the exam started" / "After submission"), and an Open link to the Drive viewer in a new tab.
  - The empty state names the cause: either surveillance was off for the exam, or the camera sent nothing.
- **Data (additive, desktop unchanged):**
  - `cbt_adm_attempt_surveillance` now also returns `window` (`start` / `end` as `UNIX_TIMESTAMP()` of the attempt dates, which is the clock `admission-cbt.php` compares with `time()`, plus `duration` and `surveillance`).
  - The app branch of `get_surveillance` returns captures oldest first. It keeps `image` / `time` and adds `view`, `thumb`, `clock` and `offset`, plus a `timeline` object (`start_clock`, `start_day`, `end_clock`, `elapsed`, `allowed`, `surveillance`). Drive ids are checked against `[A-Za-z0-9_-]` before they go into URLs.
- **Capture timestamps:** `admission_exam_webcams.timestamp` is server `time()` for direct uploads but the device's `Date.now()/1000` for queued offline uploads (`admission_cbt_webcam_base64`). So captures can fall outside the attempt, and the UI labels them instead of hiding them.
- **Thumbnails:** they use Drive's public `thumbnail?sz=w240`. If that fails (for example, a private file), the row shows a camera placeholder and keeps the Open link. The signed `drive-thumb.php` proxy only serves timeline media (`news_images` / `news_attachment`). Widening it to applicant webcam photos would be a security decision, so it was left alone.
- **Harness:** new `/tmp/cbt-parity/ad116/surv.php` (15 checks: order, excluded other-student and bad ids, keys, timeline, empty / surveillance-off / no end date, 4 forbidden personas, CSRF, other exam, page markup, real webcam table untouched). The stub does not shadow `admission_exam_webcams`. `CREATE TEMPORARY TABLE x LIKE db.x` fails with the same name, so a first attempt wrote fixture rows to the real (previously empty) demo table. They were deleted and `AUTO_INCREMENT` was reset to 1. The fixture now stages rows in `stg_webcams` and creates the shadow table with a single `CREATE TEMPORARY TABLE … SELECT`.
- **Checks:**
  - surv 15 / 15, ad116 render 817 / 817, flows 45 / 45.
  - Desktop admission parity (ad18 `cases.php`) gives 476 same / 238 intended / 0 unexpected, identical to before. The app smoke case shows both fixture captures with offsets.
  - Live at a 390 × 844 viewport:
    - Real attempt: the empty, surveillance-off state renders, and the reel shows 16 s used of 5 min.
    - Stubbed response (9 captures, 3 exits, one after submission): figures, ticks, the red outside tick, the broken-thumbnail placeholder, "After submission" and the hover link all render, and the footer stays in view.

Facts established in D.2 (S20):

- **Shell:** the assign popup uses the D.0 shell plus `cbt-assign-sheet`, which brings in the `cbt-exam.php` sticky select-all head, `cbt-check` rows, count label and empty note. Full class list: `md-sl-sheet cbt-bank-sheet cbt-assign-sheet md-adm-sheet md-adm-assign-sheet`. The toolbar Assign button is now `data-adm-assign`, opened with the page's `openSheet()`. The theme's `openModal` / `data-menu` opener is no longer used. CSS sits at the end of the `zap-app: admission sheets` block. Checkbox accents and the checked-row tint use the admission blues instead of the CBT `#1a73e8`.
- **Design:**
  - The signature element is the paper mix. A figure gives the number of questions on the exam. Below it, a bar has one segment per subject, each as wide as that subject's share of the paper (`flex-grow` = count), ordered by name. The picked subject's segment turns dark blue. The ticked questions appear as a striped segment right after it, and the head shows "+2 → 17".
  - Subjects are radio chips (they replaced the subject select). A chip shows its count on the paper only when it is above 0, plus a "+n" badge for the current selection. Hovering or focusing a chip highlights its segment.
  - Then come the bank select (it auto-picks when there is only one bank), the select-all head with "2 of 2 selected", and the question rows (category · options).
  - The submit button counts the selection ("Assign 2 questions") and stays disabled at 0. Errors show inline in `cbt-form-error`; they used to be `swal` warnings. On success the sheet closes, then "Assigned · n questions added to the exam" (or "Already on the exam" when every question was a duplicate) reloads the page.
  - Empty states: no subjects with banks ("Create one in the CBT Center"), no bank for the subject, bank exhausted, and load failures. A stale-request guard like the one in `cbt-exam.php` protects both the bank and the list loads.
- **Data:** no engine or dispatcher change. The mix is counted in the page from `cbt_adm_exam_question_list` rows (`subject_name` of the bank, "No subject" when empty). Chip counts match on the trimmed subject name. The page's old `$subject_options` HTML was removed. POST keys are unchanged (`get_question_bank`, `return_assign_questions_table`, `assign_question` with `subject_id` + `assign_row[]`).
- **Checks:**
  - New `/tmp/cbt-parity/ad116/assign.php` renders the full page for 4 personas × exams 1–8 (28 pages). For each page it checks for no warnings, the new sheet and opener (no `md-timeline-sheet` / `data-menu`), mix total = assigned cards, segment sum = cards, chip counts never 0 and never above the card total, and chip ids that decode, or the no-subject message. Result: 365 / 365.
  - ad116 render 817 / 817 and flows 45 / 45 (the sandbox copies the page; `cp` is aliased to `-i`, so use `\cp -f`).
  - Live at 390 × 844 on exam 2 (15 questions across 3 subjects): the chips wrap compactly. Mathematics loads 14 banks; a 2-question bank shows "+2 → 17", the striped segment and "Assign 2 questions". The body scrolls with the footer pinned. A stubbed failure shows the inline error and re-enables the button. A stubbed success closes the sheet and shows "2 questions added to the exam". Switching subject clears the pending segment and badge. No real assign was made.

Facts established in M.1 (S21):

- **School submissions engine (appended to `cbt_marking_engine.php`, CRLF):** `cbt_submission_exam_title` (page heading, `''` when not permitted), `cbt_submission_list` (the legacy list query, prepared, plus `approval`, `revised_score`, `fullscreen_exits`; ids cast to int), `cbt_submission_attempt_get` (the attempt must belong to the page's exam; `student_name` = last first other from `students`, as the list builds it; LEFT JOIN so an unknown student gives `''`), `cbt_submission_surveillance` (+ webcams newest first and the same `window` as the admission version, ready for M.3), `cbt_submission_approve` (codes `not_found`, `not_marked`, `empty`, `validation`, `too_high`, `db`). Every function is gated by `cbt_can_manage_exam` in school context (exams gate; admins any exam; subject teachers by class + subject in `staff_subject_subclass`). The revised-score checks moved into a shared `cbt_marking_check_revised($attempt, $revised)`, which `cbt_adm_attempt_approve` now also uses (same checks, same order, same messages).
- **Page context:** `exam-submissions.php` sets `$CBT_PAGE = 'exam_submissions'`, `$CBT_ARGS = ['exam_id' => int]` and includes the dispatcher where the inline handlers were; the heading comes from `cbt_submission_exam_title`. No SQL left in the page (CRLF kept, 831 → 574 lines). The `recent_activity()` table-header function and the dead `view_activity` / `marked_script` / `#remove_image` JS were left for 4.3.
- **Renderers** `cbt_desktop_render_submission_rows($rows, $diagnostics, $can_reset)`, `_submission_surveillance($attempt)`, `_submission_approve_modal($attempt)` were spliced from the pre-change page by `/tmp/cbt-parity/m1/gen.php` (the legacy diagnostics / reset `if`s became `cbt_can_see_attempt_diagnostics` / `cbt_can_reset_attempt`, which match them exactly). Prepared COUNTs are cast back to strings for JSON parity. Legacy quirks kept: student names are echoed unescaped in `data-student_name` and the modals; an unrecognised `exam_ended_cond` reuses the previous row's label.
- **Dispatcher:** new `cbt_ajax_exam_submissions` route (CSRF on `get_surveillance`, `approve_score_modal`, `approve_score`, `delete_image`; the list stays CSRF-free). App JSON for M.3: `return_recent_activity` → `{submissions (ids encrypted), diagnostics, can_reset}`; `get_surveillance` → `attempt` with oldest-first `webcams` (`image`, `view`, `thumb`, `time`, `clock`, `offset`) and `timeline`; `approve_score_modal` → `attempt`; `approve_score` → `cbt_ajax_app_result`. The app surveillance builder was factored out of the admission route into `cbt_ajax_app_surveillance($app, $attempt)` (admission output unchanged), plus `cbt_ajax_app_attempt` for school ids.
- **`delete_image` retired.** It was copied from `class-activity-submission.php`: it deleted any `assignments_marked_file` row by posted id and `unlink()`ed a **client-supplied, decrypted path** (`encrypt` is only base64), with no role check, and the AJAX ran before `kick_out`, so any logged-in user could delete arbitrary files the web user can write. Nothing posts it to this page (the page's own remove handler posts `marked_activity_delete_image` to `drive-ajax.php`, and no button renders `#remove_image` here). The route now answers `{status: "failed", msg: "Request cannot be processed"}` and touches nothing. The same handler still exists in `admin/class-activity-submission.php` (~453) and similar `delete_image` handlers in other admin pages; out of CBT scope, flag for Phase 4 / a security pass.
- **Legacy bugs found (now clean errors):** `approve_score` interpolated `student_score` into the UPDATE (verified: `1', approval = 0, score = '9` rewrote the row) and had no CSRF check; any user could approve any attempt of any exam; a zero total fataled (`DivisionByZeroError`); an unknown attempt fataled `get_surveillance` and returned an empty "success" approve modal; unmarked theory attempts (score NULL) got "greater than Current Score" for any value (now "This submission has not been marked yet"); non-numeric scores were compared as strings.
- **Intended behaviour changes:** ownership on every key (non-owners, principal, students get `[]` / "Submission not found"); the attempt must be on the page's exam; modal names come from the DB, not the posted `student_name`; CSRF on `approve_score` (the page JS already sends the token).
- **Parity harness:** `/tmp/cbt-parity/m1/{run.php,cases.php}` (temp-table shadowing of `exam_students`, `exam_student_questions`, `exam_webcams`, `assignments_marked_file`; fixtures 200–205: other end conditions, unknown student, unmarked theory, in-progress attempt, webcams incl. an empty Drive id; the legacy `unlink` is replaced by a marker; real-table `CHECKSUM` compared before / after). 11 personas × all 24 exams + 999 (title, list) and every attempt (surveillance, approve modal; legacy body normalised to the DB name), 24 approve cases, CSRF, `delete_image`. Result: 483 identical, 472 intended, 0 unexpected; real tables untouched; 7 app JSON smoke cases OK. Full page (`page.php`, stub `initialize.php` and layout): 5 personas × 7 exams, GET and list POST → identical for every permitted user, differing only for forbidden ones (blank heading, empty list). Admission regressions after the shared refactor: ad18 submissions subset 210 / 99 / 0, ad116 surv 15 / 15, flows 45 / 45. Real desktop HTTP capture still pending, as in S1.

Facts established in M.2 (S22):

- **`cbt-marked-theory.php` is the staff marking page** ("ASSESS EXAM" on theory rows of `exam-submissions.php`), not a student page. Legacy had no permission check at all (no `kick_out`, no header include), so any logged-in user, students included, could open any student's script and rewrite its score. Its two keys: `finish_theory_exam` (autosave every 10 s: corrected text + question scores) and `finish_exam` ("COMPLETE ASSESSMENT": same save, then the attempt's `score` / `revised_score` = Σ question scores and `score_percentage` / `revised_score_percentage` out of the typed total exam score; approval untouched). `fullscreen` / `cond` are posted but unused.
- **Theory marking engine (appended to `cbt_marking_engine.php`, which now also requires `cbt_exam_engine.php`):** `cbt_theory_attempt_get` (the student's **latest** attempt, `ORDER BY exam_student_id DESC`, as the legacy "last row wins" loops picked; codes `not_found`, `forbidden` via `cbt_can_manage_exam` school, `not_theory`, `not_submitted` = no end date and no end condition), `cbt_theory_marking_get` (`header` = `cbt_paper_header('exam')`, which is the page's header query; `student`; `attempt`; `questions` = `cbt_paper_questions('exam')` rows plus `answer` {text, corrected_text, score, total_score} or null and `images` = non-empty Drive ids oldest first, both scoped to that one attempt), `cbt_theory_marking_input` (validation), `cbt_theory_marking_store` (upsert; `exam_student_theory` has no unique key, so UPDATE touches every duplicate row, as legacy), `cbt_theory_marking_save`, `cbt_theory_marking_finish` (codes `empty`, `validation`, `too_high`, `db`; store + attempt update in one transaction). **M.4 should use `cbt_theory_marking_get` / `_save` / `_finish` directly.**
- **Page:** sets `$CBT_PAGE = 'marked_theory'`, `$CBT_ARGS = ['exam_id', 'student_id']` and includes the dispatcher; header / student / attempt / question queries replaced by `cbt_theory_marking_get` (failure → `message` + redirect to `portfolio.php`). The markup loop is unchanged and fed by `cbt_desktop_marked_theory_arrays()` (in `cbt_desktop_render.php`), which rebuilds the legacy `$questions` / `$question_lead` / `$theory_text` / `$theory_images`. No SQL left (CRLF kept, 740 → 589 lines). One additive JS change: the empty `response.status === "failed"` branch of the finish call now shows `swal("Assessment not saved", msg)` (legacy left the confirm loader spinning forever, e.g. on "Please input total exam score"). Dead `myComparison` / instruction / webcam leftovers kept for 4.3.
- **Dispatcher `cbt_ajax_marked_theory`:** CSRF on both keys (legacy used `check_csrf_token`, same failure JSON). Desktop: autosave `{status: "success"}`; finish `{status, redirect_url: "exam-submissions.php?exam_id=…"}`; empty total → `{status, msg, title: ""}` (legacy `ajax_error`); other failures `{status: "failed", msg}`. App: `cbt_ajax_app_result` ("Marking saved" `{saved}`, "Assessment completed" `{score, total_score, score_percentage}`).
- **Intended behaviour changes:** permission (exams gate + ownership) on page and both keys; the page and writes need a submitted attempt on a **theory** exam (legacy would overwrite an objective exam's score with Σ theory scores, and wrote with `exam_student_id = ""` when there was no attempt); every posted question must be on the exam; a score must be empty, numeric, ≥ 0 and ≤ the question's mark ("Question N: …", N = page position); the stored per-question `total_score` is the question's mark from the DB (legacy stored the posted `total_score`, `NULL` when absent); the total exam score must be numeric > 0 (legacy fataled on non-numeric) and ≥ Σ scores; missing `theory_text` / `question_score` arrays are treated as empty (legacy fataled); inserts set `question_text = ''` explicitly (same result in the non-strict demo DB, works under strict mode); images with an empty Drive id are skipped (legacy rendered "Click to view" buttons pointing at `/file/d//preview`, e.g. attempt 89), and only the latest attempt's answers / images are shown (legacy's questions query joined every attempt of the student).
- **Kept:** unmarked questions count as 0 on finish; only questions present in `theory_text` are stored and summed (the page always posts both arrays with the same keys); marking can still be redone after approval and overwrites `revised_score`, as legacy.
- **Parity harness:** `/tmp/cbt-parity/m2/{run.php,cases.php,app.php}` (`pre/` = pre-change page and includes; temp-table shadowing of `exam_students`, `exam_student_theory`, `exam_student_theory_images` staged via `stg_*`; fixtures 300 insert path, 301 objective exam, 302 / 303 two attempts, a duplicate theory row on attempt 85). 11 personas × 10 attempt cases (valid, unsubmitted 87, objective, no attempt, missing exam, two attempts, empty Drive ids) × full page / autosave / finish / CSRF, plus 15 validation variants on the update and insert paths. Result: 176 identical (body + table state; full pages byte-identical for permitted users), 267 intended, 27 "either" (two-attempt and empty-Drive-id fixtures: writes identical, pages differ only by the dropped images), 0 unexpected; real tables untouched; app smoke 5 / 5. Regressions: m1 483 / 472 / 0, cc19 OK, ad116 surv 15 / 15 and flows 45 / 45, ex113 render OK. No live desktop check (no desktop login), as in S1.

Facts established in M.3 (S23):

- **New app page `app/admin/cbt-submissions.php?id=<encrypt(exam id)>[&view=to_mark|approved][&from=marking]`** (CRLF). Same shell as `cbt-exam.php` (`zap-ward-page learners-center-page cbt-center-page cbt-bank-page cbt-subs-page`, `md-check-toolbar` with Class / Subject chips). Gate: `cbt_can_open_center` + `cbt_exam_get`, else redirect to `cbt-center.php?tab=exams`. It sets `$CBT_PAGE = 'exam_submissions'`, `$CBT_ARGS = ['exam_id']` and includes the dispatcher, so the sheets post the M.1 keys (`approve_score_modal`, `approve_score`, `get_surveillance`) to the page itself. The list is rendered server-side from `cbt_submission_list`, and the page reloads after writes. Back goes to the exam page, or to the CBT Center To mark tab when `from=marking`.
- **Layout:**
  - The hero shows the exam name, session and pass mark, plus a Submitted stamp.
  - The signature element is the score spread (`zap_cbt_render_score_spread`): one dot per marked attempt on a 0–100% line, a pass-mark rule, a tinted fail zone, and dots closer than 5 points stacked in up to 4 lanes. A caption reads "N passed · N failed · average X% · N not marked yet". The hero has no KPI row; the strip carries the counts.
  - The strip is Submitted / To mark / Approved (`?view=` preselects a chip; changing it updates the URL with `replaceState`). It works with a search over student name and class, and each filter has its own empty text.
  - Cards (`zap_cbt_render_submission_card`) show initials coloured by state, a Passed / Failed / Not marked chip, and the score as a figure (`4 / 11`, `36.36%`) over a mini rail with the pass tick. They add "Approved at X (Y%)" when an approved revised score differs, and facts chips: date, time spent, how it ended, "Answered n of m" (diagnostics roles, objective only), Approved, "Left fullscreen n times" (red), and "Taken in the app" (purple).
- **Actions follow desktop:** objective attempts get Approve score (Revise score once approved). Theory attempts get Mark script / Re-mark linking to `cbt-mark-theory.php?exam_id=&student_id=` **only once that file exists (M.4)**; until then they show "Mark on the web portal". Desktop never offers Approve on theory rows (`approval` is set automatically for non-surveillance objective exams in `cbt.php`), so the app doesn't either. Every row has Surveillance, and reset roles get Reset. The desktop SCRIPT link (`cbt-correction*`) has no app equivalent until 2.8.
- **Sheets:** `zap_cbt_render_submission_sheets()` reuses the admission approve and surveillance shells and CSS (`md-adm-sheet md-adm-approve-sheet` / `md-adm-surv-sheet`, ids `cbt-sub-*`), so no new sheet CSS was needed. The logic in the new `scripts/zap-cbt-submissions.js` (CRLF, config `window.ZAP_CBT_SUBS = {csrf}`) is a port of the inline admission script. Duplicate, by choice: moving `admission-exam-submissions.php` onto the shared helpers / script is left for 4.3. Approve errors show inline in the sheet.
- **`client=app` contract for 2.6:** `zap_cbt_attempt_end()` reads `exam_ended_cond` as `<condition>[;client=app]` (`|` is also accepted) and maps `user_ended` → "Submitted", `time_expired` → "Time ran out", `time_expired_after` → "Saved after time ran out". The column is `varchar(30)`; `time_expired_after;client=app` is 29 characters, so it fits. **2.6 must write that format, and must update the desktop list renderer**, which matches `exam_ended_cond` with `===` and would otherwise reuse the previous row's label for app attempts.
- **Reset moved onto the engine:** new `cbt_submission_reset($db, $ctx, $exam_id, $exam_student_id)` in `cbt_marking_engine.php` (`cbt_can_reset_attempt` = backend / admin / academic_admin, plus `cbt_submission_attempt_get`, so the attempt must be on the page's exam and the exam must be manageable). It runs the legacy multi-table delete as one prepared statement: attempt, objective answers, theory answers and images, and the student's webcam rows for the exam. Drive files are left in place, as the legacy school reset does (its `deleteFile` calls are commented out). New dispatcher key `reset_attempt` on the `exam_submissions` route (CSRF; app message "Exam reset"; desktop shape `{status: "success"}`, though desktop JS doesn't post it). Reason: app and desktop sessions are separate, so the app cannot use `admin/webcam-google-drive.php`. That file's `cbt_reset_exam` / `clear_webcam_images` also have no role or ownership check; flag both for Phase 4.
- **App approve does not clear webcam captures.** Desktop follows a successful approve with `clear_webcam_images`, which deletes the captures from Drive and the DB. The app leaves them (same reason, and approval shouldn't destroy evidence). The admission app page's clear call posts `admission_exam_student_id` to a handler that reads `exam_student_id`, so it never worked either. Decide in Phase 4 whether desktop should keep clearing. (Decided in 4.6, S44: clear after approval everywhere.)
- **Entry points:** the `cbt-exam.php` hero has "Submissions · N" when there are submissions (opens on To mark when scripts are waiting). CBT Center To mark cards link to `…&view=to_mark&from=marking`. The To mark tab note now reads "Tap an exam to see its scripts…" and disappears once `cbt-mark-theory.php` exists. **M.5** still has to list individual pending scripts in that tab.
- **Checks:**
  - New `/tmp/cbt-parity/m3/run.php` on temp-table copies of `exam_students`, `exam_student_questions`, `exam_webcams`, `exam_student_theory`, `exam_student_theory_images`. Fixtures: 400 is an app attempt with exits and webcams, 401 is theory with answers and images, 402 / 403 are approved attempts on exam 30.
  - It renders every exam + 999 for 8 personas (backend, admin, academic_admin, 2 subject teachers, academic_account, principal, student). It checks permission scope, card and spread-dot counts, to_mark / approved against the legacy rule, buttons per type and role, escaping and the fixture chips.
  - It runs reset cases: the owning teacher, student, principal and academic_account are refused; another exam's attempt, a missing id and a junk id fail; bad CSRF fails; academic_admin and admin resets remove exactly the attempt's rows; the desktop response shape is checked. It also checks the app approve / surveillance JSON, too-high and non-owner approve. Real-table checksums are unchanged. Result: OK.
  - Regressions: m1 483 / 472 / 0, m2 176 / 267 / 27 either / 0, cc19, ex113 render and pv114 render all OK.
  - Live (app admin, desktop-width WebView pane):
    - Exam 23 (theory, 2 marked) shows the spread, cards and the surveillance sheet with 3 real captures and the reel.
    - Exam 31 opened with `view=approved` shows the "Approved at 3 (27.27%)" line, and Revise score is prefilled 3.00 with a red verdict.
    - Exam 20: a **real no-op approve** at the existing 3.00 succeeded and the row was unchanged.
    - From the CBT Center To mark card to exam 26, the To mark chip is preselected and Back goes to the To mark tab.
    - The reset confirm was cancelled with the attempt kept. A real reset ran only in the harness.
  - Thumbnails show the camera placeholder because the demo Drive files are private, as on the admission page.

Facts established in M.4 (S24):

- **New app `cbt-mark-theory.php?exam_id=enc&student_id=enc`** (CRLF). It sets `$CBT_SURFACE='app'`, `$CBT_PAGE='marked_theory'`, `$CBT_ARGS=['exam_id','student_id']` and requires the dispatcher, so autosave (`finish_theory_exam`) and complete (`finish_exam` + `total_exam_score`) post to the page itself, with CSRF, and get the app JSON shape (`{saved}` / `{score, total_score, score_percentage}`). Gate: `cbt_can_open_center` + `cbt_exam_get` + `cbt_theory_marking_get`. On failure it redirects to the exam's submissions page, or to `cbt-center.php?tab=marking`. Back goes to submissions (`view=to_mark` while the attempt is unmarked). No engine or dispatcher change was needed.
- **Q23 layout:** one question per screen, reusing the taking shell (`body.cbt-take-app`, `cbt-take-*` bar / main / dock / sheets, bottom nav hidden). The bar shows the student (initials, name · class), the exam, and a tally "awarded/total" that opens the script grid sheet (`#cbt-mark-nav-sheet`: per question score / mark, camera icon when photos exist, current and unmarked states, "Review and complete"). Each screen has the question paper (passage collapsed), the student's script on ruled paper with a hand-drawn red score ring, photos, Annotate / Show original, and a score card (Clear, −/+ steps of 0.5 when the mark ≤ 5 else 1, number input, tick chips for marks ≤ 12). Dock: Previous and **Next unmarked** (wraps, skips marked questions; becomes "Review and complete" when nothing is left).
- **Helpers in `partials/zap-cbt-helpers.php`** (section "Theory marking"): `zap_cbt_mark_url`, `zap_cbt_mark_student_name`, `zap_cbt_mark_ticks`, `zap_cbt_mark_photo`, `zap_cbt_mark_figure`, `zap_cbt_render_mark_question`. The prefill follows desktop (`cbt_desktop_marked_theory_arrays`): the corrected text when it has visible text, else the student's text, always through `zap_cbt_rich`.
- **Annotation without Summernote:** the answer is `contenteditable` with `document.execCommand` (`styleWithCSS`, red pen `foreColor #d93025`, strike, underline, clear formatting, undo). Paste is converted to plain text. `zap_cbt_rich` keeps `color` styles, so red-pen marks survive saving and reload. The student's original is still visible via "Show original" when the correction differs.
- **`scripts/zap-cbt-mark.js`** (CRLF, config `window.ZAP_CBT_MARK = {csrf, pass}`): every save posts **every question** (`theory_text` + `question_score`), because the engine only stores and sums the questions present. Autosave is debounced 1.2 s, retries after 6 s on network failure, and is held while any score is invalid (empty, < 0, > mark); `sendBeacon` runs on `pagehide`. Engine messages ("Question N: …") use paper position, which matches the screen numbers.
- **Completion sheet:** total with an editable "out of" (attempt `total_score` if > 0, else Σ marks), verdict and rail with the pass line at `minimum_percentage`, a warning listing unmarked questions ("will count as 0") with jump chips, and inline engine errors. Invalid scores or a sum above "out of" block completion. The done panel offers **Next script** (the next to_mark attempt after this student in `cbt_submission_list` order, wrapping) and Back to submissions.
- **Lightbox reuse:** photos and question / passage figures open `window.ZapTimelineLightbox.open(carousel, i, {autoplay:false})` with a detached carousel of `.md-ig-slide--image` slides (`img.md-ig-slide__photo[data-drive-id]`). The page adds "Open in Google Drive" to the lightbox foot and hides the timeline meta / like on `body.cbt-mark-app`. Broken thumbnails (private Drive files) fall back to a camera icon.
- **Design:** new zapapp.css block "zap-app: cbt theory marking": red-pen tokens (`--mark-pen #d93025` etc.), ruled script with red margin line, the ring's ink-draw / pop animations, tick blobs, save status states; respects reduced motion.
- **Entry points light up automatically:** `cbt-submissions.php` cards show Mark script / Re-mark (`is_file` check) and the CBT Center To mark note disappears.
- **Checks:**
  - New `/tmp/cbt-parity/m4/run.php` on temp copies of `exam_students`, `exam_student_theory`, `exam_student_theory_images`, with fixture attempt 500 (exam 26, XSS answer, red correction, bad + good Drive ids). 7 personas render (scripts opened: backend 5, admin 5, teacher1 2, teacher9 1, acct9 1, principal 0, student 0); screens, prefill, photo counts and escaping are checked. Dispatcher cases: autosave, too high, non-owner, student, bad CSRF, sum above out of, complete stored `1.50 / 1.50 / 2.00 / 75.00`, next-script order. Real-table checksums unchanged. Result: OK. Regressions m2, m3, cc19 OK.
  - Live (app admin, 390×690, writes stubbed): exam 26 / ADAM JAMES end-to-end (tick → ring, tally, autosave payload; lightbox + Drive link; Next unmarked; invalid score holds saving and blocks completion; complete posts `finish_exam`, done panel). Exam 23 / VICTORY MADUKA OBI opened on the unmarked Q3; red pen saved as `<span style="color: rgb(217, 48, 37);">`; script grid correct. Demo rows confirmed unchanged afterwards.

Facts established in M.5 (S25):

- **New `cbt_marking_pending_scripts($db, $ctx)` in `cbt_marking_engine.php`:** the individual scripts behind `cbt_marking_pending_summary`'s `to_mark` (same scope, exams gate and rule: ended theory attempt, `score IS NULL`, joined to `students`), **oldest submission first** (`GREATEST(start_date, end_date)`). Rows: `exam_student_id`, `exam_id`, `student_id`, `name`, `class_name` (student's class + subclass), `submitted_at`, `exam_ended_cond`, `answered` (theory rows with non-blank text), `photos` (non-empty Drive ids), `count_question`. Prepared, no interpolation. Only loaded on the To mark tab when there is something to mark.
- **To mark tab:** each exam card (no longer one big link) lists its pending scripts as rows in a "queue" with a red margin line that echoes the marking screen's ruled script: initials, name, class, "Answered n of m" or "Blank script" (no text and no photos), photo count, phone icon for `client=app`, and a waiting-age pill (`zap_cbt_waiting`: min / h / d, then mo / y past 60 days; amber from 3 days, red from 7). Rows link to `cbt-mark-theory.php?…&from=marking`. Four rows show, the rest sit behind "Show N more". The header shows "Oldest waiting …" instead of last submission. The footer has "x of y marked" with a thin progress rail and a Submissions link (`view=to_mark&from=marking`). Cards are ordered by their oldest waiting script, not latest submission. The hero banner gained **Mark oldest**, which opens the oldest script anywhere in scope. The "use the web portal" note is gone.
- **Search** now matches student names and classes too: when the query matches the exam it shows the whole card; when it only matches students it shows just those rows (the overflow and "Show more" are bypassed while searching).
- **`cbt-mark-theory.php` `from=marking`:** Back, the empty state, the done panel and the failure redirect go to `cbt-center.php?tab=marking` ("Back to To mark"), and **Next script follows the CBT Center queue across exams** (next pending script after this one in `cbt_marking_pending_scripts` order, wrapping), keeping `from=marking`. Without `from` the page behaves as in M.4.
- **Known edge (unchanged from M.4):** the mark page opens a student's *latest* attempt; if a student had two ended unmarked attempts on one exam, both rows open the same script. Not seen in data.
- **Checks:** new `/tmp/cbt-parity/m5/run.php` on temp copies of `exam_students`, `exam_student_theory`, `exam_student_theory_images`, `students` (fixtures 600–607 on exam 26: ages 1 h to 10 days, one ended by condition with no end date, one `client=app`, one blank, one with photos, one marked and one in progress that must not appear, an XSS student name). 14 personas: per-exam script count = summary `to_mark`, ids = the legacy raw rule, nothing outside the summary, oldest-first order, row / overflow / "Show more" counts, every link decodes to a pending script, escaping; plus tones, facts, card order and empty dates. Real-table checksums unchanged. OK. Regressions cc19, m3, m4 OK.
  - Live (app admin, 390 px): To mark tab shows BASIC SCIENCE with ADAM JAMES ("Answered 0 of 2 · 1 photo", "3 y" red), Mark oldest and the row both link with `from=marking`; search by student / subject / no match behaves; the mark page opened from the row shows "Back to To mark" in the bar and done panel. No writes.

Facts established in 2.1 (S26):

- **Scope:** only `cbt.php` (objective) and `cbt-theory.php` (theory) are student taking pages. `cbt-marked-theory.php` is the staff marking page and already moved in M.2.
- **New engine `admin/includes/cbt_attempt_engine.php`** (CRLF). `$type` is the taking page: `'objective'` (any `exam_type` except theory, as `student-exams.php` links them) or `'theory'`. Functions:
  - `cbt_attempt_exam` is the common gate. Codes: `forbidden`, `not_found`, `wrong_type`. Returns `header` = `cbt_paper_header('exam')` and `attempt` = `cbt_attempt_row` (the student's **latest** attempt, or null).
  - `cbt_attempt_can_begin` (codes `not_eligible`, `not_open`, `expired`) and `cbt_attempt_eligible`. Eligibility is the `return_public_files` listing rule: active, the student's class, topic published or 0, subject 0 or a subject the student is doing.
  - `cbt_attempt_page_get` returns header, student, attempt and questions. Objective questions are only those with options. Theory questions add `answer` and `images` (non-empty Drive ids) from the latest attempt.
  - `cbt_attempt_start` (`set_start_date`): `new_exam`, `finished`, `seconds_left`. `cbt_attempt_started` (`check_if_exam_started`).
  - `cbt_attempt_finish_objective`, `cbt_attempt_save_theory` (student autosave), `cbt_attempt_finish_theory`, plus helpers `cbt_attempt_cond`, `_theory_input`, `_theory_store`, `_exam_options`.
  - **2.2 / 2.6 should build on these** (`save_answer` next to `cbt_attempt_save_theory`, the app taking page on `cbt_attempt_page_get` / `_start` / `_finish_*`).
- **Access:** new `cbt_can_act_for_student($db, $ctx, $student_id)` in `cbt_access.php`. Students may act for themselves, parents for wards (`students.parent_id` = the session parent id), and backend for anyone. This matches who `student-exams.php` lets in. The legacy taking pages only required a login, so any staff member could open or finish any student's exam via `?student_id=`. `$ctx` gained `parent_id`. Phase 3.1 can reuse the predicate. Desktop parents can still take an exam for a ward, as before; the app (Q12) will be read-only.
- **Rules:**
  - A **new** attempt needs eligibility and an open window (`start_date ≤ now ≤ end_date`), both on the page and on `set_start_date`. Legacy had no start-date, class, activation or subject check.
  - An **existing** attempt can always be resumed or finished. Finish has no window check, so a student whose exam closes mid-attempt can still submit, as in legacy.
  - The page still dies with "Exam Expired" once the exam's end date has passed (legacy).
  - New page refusals reuse the legacy redirect text: "Wrong Exam" (not eligible, wrong type, missing) and "Exam Not Started". `forbidden` goes to `portfolio.php` with the message.
- **Dispatcher:** new `take` route `cbt_ajax_take`. `$CBT_ARGS` = `type`, `exam_id`, `student_id`, `back_link`. Desktop shapes are byte-identical to legacy, including key order in `{new_exam, [finish_exam], length_of_exam, status}`. CSRF applies to `set_start_date` (new; both pages already sent the token), `finish_exam` and `finish_theory_exam`. `check_if_exam_started` is a read (nothing posts it). `finish_theory_exam` is only routed for theory, so the objective page falls through as before. App JSON: `cbt_ajax_app_result` with "Exam started" `{new_exam, finished, seconds_left, exam_student_id}`, "Answers saved" `{saved}`, "Exam submitted" `{finished, score, …}`.
- **Pages:** both pages require the dispatcher and render from `cbt_attempt_page_get`. Their markup loops are fed by `cbt_desktop_take_arrays($type, $questions)` in `cbt_desktop_render.php`. No SQL is left in either page. Line endings kept (`cbt.php` LF, `cbt-theory.php` CRLF). The commented-out "mark if entered after time" block in `cbt.php` was deleted. Additive JS: the empty `status === "failed"` branches of finish and start now show a swal ("Exam not submitted" / "Exam not started" + message); before, the student was left with a spinning button.
- **Legacy bugs closed:**
  - **Scoring exploit:** every posted option id of the exam was stored and scored, so posting every option of every question scored 100% wherever `incorrect_answer` is 0 (exam 13 in the harness). The same option posted twice scored twice. Now only the first option per question is stored, and duplicates are dropped. `jquery-persist` keeps one value per input name, so real traffic is unaffected.
  - **SQL injection** through `cond` (`user_ended', score = '99` rewrote the score) and `fullscreen`. `cond` is now limited to `user_ended | time_expired | time_expired_after` with an optional `;client=app` (anything else is stored as `''`), and `fullscreen` is cast to an int ≥ 0.
  - **Unscored attempts:** an empty `fullscreen` produced `fullscreen_exits = ,`, a SQL error. The attempt stayed unscored while the response said success. The desktop JS sends an empty value whenever localStorage `fullscreen` exists without an exit count for this exam (the student entered fullscreen and never left).
  - **Fatals:** an exam with no questions threw `DivisionByZeroError` (now scores 0 / 0 / 0%). Theory autosave with no attempt, or with a non-array `theory_text`, also fataled.
  - **Theory rewrites:** after submission, and even after marking, the student's answers stayed writable through autosave. Reopening the page also re-ran finish, which rewrote `end_date` / `exam_ended_cond`. Autosave is now refused ("This exam has already been submitted"), and finishing a submitted attempt is a no-op success.
  - **Junk attempts:** `set_start_date` created `exam_students` rows for any id (even a missing exam), and for upcoming, inactive and other-class exams.
  - A scored objective attempt still received posted answers on a repeat finish; it is now left alone.
  - Unknown theory question ids are skipped rather than inserted. They are not refused, so a question unassigned mid-exam cannot block the student's other answers.
- **Kept on purpose:**
  - Time left is still `strtotime(start_date) + duration·60 − time()` (PHP and MySQL are both WAT here).
  - Finish and autosave do **not** enforce the time limit server-side yet. That belongs to 2.2 / 2.6 (server-authoritative timer).
  - Answers posted at finish are stored only when no answers are stored yet, so 2.2's autosaved rows will win.
  - Theory answers are stored as raw Summernote HTML. Desktop marking echoes them unescaped; flag for Phase 4.
  - A new attempt row now sets `end_date '0000-00-00 00:00:00'`, `approval 0`, `fullscreen_exits 0` and `exam_ended_cond ''` explicitly (the same values the non-strict default gave). The insert is `INSERT … SELECT … WHERE NOT EXISTS` to narrow double-click races. Both finishes lock the attempt row (`FOR UPDATE`) and re-check it inside their transaction.
  - The theory-image and webcam keys of `drive-ajax.php` / `webcam-google-drive.php` are untouched (2.7 / Phase 4).
  - **2.6 must still update the desktop submissions renderer** for `;client=app` (see M.3).
- **Parity harness:** `/tmp/cbt-parity/a21/{run.php,cases.php,show.php}` (`pre/` = pre-change files).
  - Setup: temp-table shadowing of `exams` (windows shifted around `NOW()`: 13 / 16 / 22 / 23 / 26 / 28 / 29 / 30 open, 27 upcoming, 21 inactive, 35 with an unpublished topic), `exam_students`, `exam_student_questions`, `exam_student_theory` and `exam_student_theory_images`. Fixture attempts 600–608 cover: in progress, stored answers, already scored, theory in progress, an image, no surveillance, an expired exam, an exam with no questions, and an empty Drive id.
  - Personas: 7 students, a parent with and without their ward, backend, admin, teacher. The objective page is seeded with `mt_srand` so its shuffle is comparable.
  - Result over 91 cases (AJAX keys plus full pages): 52 identical (body + table state), 39 intended, 0 unexpected. Real tables were unchanged. App JSON smoke tests OK, including `user_ended;client=app` stored as is.
  - Regressions: m1 483 / 472 / 0, m2 176 / 267 / 0, and m3, m4, m5, cc19 all OK. No live desktop check (no desktop student login), as in S1.

Facts established in 2.2 (S27):

- **New `save_answer` key on the `take` route** (both pages, CSRF). POST `question_id` (raw int, as the app renderer and the desktop radios carry it) and `answer`: objective = the chosen `question_options_id`, or empty / 0 to clear the choice; theory = the answer text. Desktop reply `{status: "success", answered}` or `{status: "failed", msg, code}`; app `cbt_ajax_app_result` "Answer saved" `{question_id, answer (option id | null; true for theory), answered}`, plus `code` on failure. **2.6 should drive its autosave and answered counter from this.**
- **Engine (`cbt_attempt_engine.php`):**
  - `cbt_attempt_save_answer($db, $ctx, $type, $exam_id, $student_id, $question_id, $answer)`. Failure codes: `forbidden`, `not_found`, `wrong_type`, `not_started`, `ended`, `time_up`, `bad_question` (not on the exam), `bad_option` (not an option of that question), `db`. Each write runs in a transaction with the attempt row locked (`FOR UPDATE`) and re-checked.
  - `cbt_attempt_open` is the shared "may answers be saved" gate (started, not finished, time not up). The bulk theory autosave (`finish_theory_exam`) now uses it, and it also locks and re-checks the attempt row.
  - `cbt_attempt_objective_store` replaces a question's choice: it deletes **every** stored option of the question, then inserts the new one. That also cleans up the duplicate rows the legacy exploit could leave. `exam_student_questions` has no unique key, so there is no `ON DUPLICATE KEY`.
  - `cbt_attempt_objective_answered`, plus the helpers `cbt_attempt_seconds_left`, `cbt_attempt_time_up`, `cbt_attempt_finished` and `cbt_attempt_grace_seconds` (120 s).
- **Server-side time limit:** answers are refused once `start + duration + 120 s` has passed (`time_up`). The grace period covers timer drift and the finish request in flight. A finish after that still ends the attempt, but posted answers are ignored, the autosaved rows are scored (objective) or kept (theory), and `exam_ended_cond` becomes `time_expired_after` (keeping `;client=app`). Nothing wrote that value before, and the app already labels it "Saved after time ran out". The desktop renderer already matches it. `cbt_attempt_cond($cond, $late)` handles this.
- **Objective finish merges** (this replaces 2.1's "store posted answers only when nothing is stored"). Posted answers are the client's final state: they replace the stored choice for the questions they cover, and questions they don't cover keep their autosaved choice. So a desktop autosave that failed is corrected by the finish, an app finish can post nothing, and answers given on another device survive. Scoring reads the stored rows and counts **one option per question** (the earliest stored row), so legacy duplicates can't double-count.
- **Desktop JS (`cbt.php`, additive, LF kept):** delegated `change` on `#datatable input[type=radio]` and `click` on `.reset_option` post `save_answer`. There is one request in flight per question; a newer choice waits and only the latest is sent. Failures are silent because finish still posts every answer from `jqpersist_vals`. The reset handler finds the radios with `closest("td")`, since the button sits outside the options `.card-box`. `cbt-theory.php` is unchanged: its 10 s `finish_theory_exam` autosave already stores every answer.
- **Known limits:** an answer cleared while its autosave failed comes back at finish (the posted state omits it, so the stored row stands). A student whose browser stayed offline past the deadline loses the answers given offline. Both are accepted.
- **Checks:**
  - `/tmp/cbt-parity/a22/` (copy of a21; the legacy page gets the same autosave block injected). Result: 50 identical, 46 intended, 0 unexpected. Two cases moved from same to intended: posted final answers replace the autosaved ones (attempt 601), and a finish on a long-expired attempt is recorded as `time_expired_after` (606). New `save_answer` dispatcher cases: desktop, CSRF, staff, theory, app. Real tables unchanged.
  - `flow.php` runs 46 engine checks in one process on temp tables: change / clear / foreign option / foreign question / junk ids / staff / backend / wrong type; finishing from stored rows only; the posted-over-stored merge; legacy duplicate scoring and cleanup; inside grace vs past grace for save, bulk autosave and finish (objective and theory); saving after submit; `cbt_attempt_cond`. OK.
  - `jstest.js` runs the cbt.php block under Node with a stubbed jQuery: the per-question queue, ignoring other inputs, Reset clears. OK.
  - Regressions: m1 483 / 472 / 0, m2 176 / 267 / 0, m3, m4, m5, cc19 OK. No live desktop check (no desktop student login), as in S1.

Facts established in 2.3 (S28):

- **Engine (appended to `cbt_attempt_engine.php`, CRLF):**
  - `cbt_student_exams_student` returns the page header student (`first_name`, `last_name`, `activation`, `class_id`, `subclass_id`). Codes: `forbidden`, `not_found`.
  - `cbt_student_exam_list($db, $ctx, $student_id, $subject_id = null, $topic_id = null)`. `null` means no filter; a number is an exact match, and 0 means all subjects / no topic. It uses the same rule as `cbt_attempt_eligible`, ordered by start date, latest first, with ties broken by `exam_id DESC`. Rows carry the legacy columns plus `surveillance`, `exam_student_id` and `exam_ended_cond`, and `count_questions` is a subquery. The attempt columns come from the student's **latest** attempt (`MAX(exam_student_id)`), as on the taking page.
  - `cbt_student_exam_state($row, $now)` applies the desktop rules: `status` is `not_marked` | `in_progress` | `not_written` | `failed` | `passed` | null; `action` is `correction` | `finished` | `upcoming` | `expired` | `start`; plus `ended`. **2.5 should drive its Upcoming / Open / Done strip and buttons from this.**
  - `cbt_student_exam_score_visible` hides the score from students only, when `student_view_score = 0`.
  - `cbt_student_exam_description` (the exam must be on the student's list), `cbt_student_exam_subjects`, `cbt_student_exam_topics` and `cbt_student_exam_filter_names`.
  - Every function checks `cbt_can_act_for_student` (student self, parent → ward, backend). Forbidden reads return `[]` / empty names / `forbidden`.
- **Dispatcher:** new `student_exams` route, `$CBT_ARGS = ['student_id']`. CSRF on `get_description` / `get_topic`, as legacy (`check_csrf_token`, same failure JSON). The list, subjects and searched text stay CSRF-free reads. Desktop shapes are byte-identical, including key order. App JSON:
  - `return_public_files` → `{exams}`: ids encrypted, `class_id` / `exam_student_id` dropped, hidden scores null, plus `status` / `action` / `ended`.
  - `get_search_subjects` → `subjects`, as `{value, label}`.
  - `get_topic` → `no_topic_value` + `topics`.
  - `get_searched_text` → `{subject_name, topic_name}`.
  - `get_description` → `{description}`.
  - Helper `cbt_ajax_filter_id`: an empty value means no filter, and a value that doesn't decrypt becomes -1 (no rows).
- **Renderers:** `cbt_desktop_render_student_exam_rows($rows, $is_student, $student_id)` (keeps the "previous row's label" quirk when `status` is null) and `cbt_desktop_render_student_exam_options($pairs, $no_topic)`.
- **Page:** sets `$CBT_PAGE = 'student_exams'` and requires the dispatcher right after `public_files()`. The header student comes from `cbt_student_exams_student`: `forbidden` gives "Access Denied" → `portfolio.php`, and a missing or non-activated student gives "User Blocked" as before. No SQL left (CRLF kept, 650 → 478 lines). The JS is unchanged.
- **Legacy bugs closed:**
  - SQL injection through `subject_id` / `topic_id` (list, topics, searched text). `student_id` was interpolated too.
  - No ward check: any parent could list, and open the page for, any student via `?student_id=`. Because the AJAX ran before `kick_out`, any logged-in staff member could also fetch any student's list.
  - `get_description` returned any exam's instructions by id.
  - A topic filter without a subject, or an id that didn't decrypt, built broken SQL and fataled. The topic filter now applies on its own.
  - **The parent / backend CORRECTION link was malformed** (`cbt-correction.php?type=student-exams&exam_id=X?exam_id=X&student_id=Y`), so `exam_id` never decrypted and correction could not open for anyone but students. It is now `…&exam_id=X&student_id=Y` (and likewise for `cbt-correction-theory.php`).
- **Kept on purpose (product decisions, flag for later):**
  - Parents and backend see scores even when `student_view_score = 0`.
  - The topic filter lists unpublished topics.
  - Subject / topic names are echoed unescaped in the options.
  - Exam names in the rows are unescaped.
  - `get_searched_text` resolves any subject / topic id (names only).
  - Non-backend staff now get "Access Denied" before the header instead of `kick_out`'s "Access Denied 5".
- **Parity harness:** `/tmp/cbt-parity/se23/{run.php,cases.php}` (`pre/` = pre-change page, `layout/` stubs). It runs the **whole** page in the CLI for both AJAX and GET, on temp copies of `exams` / `exam_students`.
  - Fixtures: open / upcoming windows; `student_view_score = 0` on 17 / 29; an HTML exam name; attempts 700–705 covering scored and hidden, in progress, unmarked theory, failed, a correction row, and theory in progress.
  - Personas: 6 students (one blocked, one in class 2), 2 parents, backend, admin, teacher.
  - Result over 83 cases: 59 identical and 24 intended, 0 unexpected. 4 of the identical cases needed only the legacy CORRECTION link normalised to the fixed form. Every status and action label is covered, and real tables are unchanged.
  - Regressions: a22 50 / 46 / 0 and flow 46 / 46, m1 483 / 472 / 0, m2 176 / 267 / 0, and m3, m4, m5, cc19 OK. No live desktop check (no desktop student / parent login), as in S1.

Facts established in 2.4 (S29):

- **Engine (appended to `cbt_marking_engine.php`, CRLF; the file now also requires `cbt_attempt_engine.php`):**
  - `cbt_review_get($db, $ctx, $type, $exam_id, $student_id, $now = null)`: `$type` is the review page, `'objective'` (`cbt-correction.php`, every exam type except theory) or `'theory'` (`cbt-correction-theory.php`).
  - It returns `viewer`, `header` (`cbt_paper_header('exam')`), `exam` (view_answers, student_view_score, minimum_percentage, start / end date), `student`, `attempt` (the **latest** attempt with score, revised score, approval, exits, end condition and unix times), `score_visible`, and `questions`.
  - Objective `questions` are `cbt_paper_questions('exam')` rows plus `chosen` (option id or null), `result` (`right` | `wrong` | `blank`) and `awarded` (`correct_answer` / `incorrect_answer` / 0). Theory rows add `answer` and `images`, exactly as `cbt_theory_marking_get` does.
  - Failure codes: `not_found`, `forbidden`, `wrong_type`, `no_attempt`, `not_available`.
  - `cbt_adm_review_get($db, $ctx, $admission_exam_id, $admission_student_id)`: staff only (`cbt_can_manage_exam`, admission context). It returns `header`, `student` (`admission_student_id`, `student_name`), the latest `attempt`, and admission paper questions with `chosen` / `result` / `awarded`.
  - Helpers: `cbt_review_viewer`, `cbt_review_chosen` (first stored row per question: the one `cbt_attempt_finish_objective` scores), `cbt_review_mark_choices`, and `cbt_marking_theory_answers` (factored out of `cbt_theory_marking_get`, which now calls it; output unchanged).
  - **2.8 should build `cbt-review.php` on `cbt_review_get`.** In the checks, Σ `awarded` equals the stored score on every real attempt. `score_visible` is false only for students when `student_view_score = 0`.
- **Access (legacy had none: any logged-in user could open any student's or applicant's script, with the answers marked, by editing the base64 ids):**
  - **Staff** (`viewer = 'staff'`): anyone who manages the exam (`cbt_can_manage_exam`) can open any attempt, including unmarked or in-progress ones, as the SCRIPT link on submissions rows allows. Backend counts as staff.
  - **Students and parents** (`viewer = 'student'`, via `cbt_can_act_for_student`: a student for themselves, a parent for a ward) get the script only when the student list would show CORRECTION (`cbt_student_exam_state`). That means `view_answers = 1`, the exam's end date has passed (string comparison against the PHP clock, as the list does), and the latest attempt has a score percentage.
  - Admission scripts are staff only. Sub-admin, principal, students and parents are refused.
- **Pages:** both now `require_once` `cbt_marking_engine.php` + `cbt_desktop_render.php` and contain no SQL. `cbt-correction.php` (LF, 493 → 456 lines) gets its question loop data from new `cbt_desktop_review_arrays()` (`cbt_desktop_paper_legacy_arrays` + `student_options`). `cbt-correction-theory.php` (CRLF, 503 → 476) reuses `cbt_desktop_marked_theory_arrays` (same arrays as its legacy query). The markup loops are unchanged.
  - Failure handling: `no_attempt` / `not_available` → message + the page's back link (`student-exams.php`, or `exam-submissions.php` for staff). The other failures → `portfolio.php`, or `dashboard.php` on the admission branch, as the link errors already did.
  - No dispatcher route: neither page has AJAX handlers.
- **Intended behaviour changes:**
  - Permission on every open (see above).
  - The wrong page type is refused ("Link Incorrect"). Nothing links a theory exam to `cbt-correction.php` or the reverse; legacy rendered empty option boxes.
  - Only the latest attempt is shown. Legacy merged every attempt's chosen options (last row wins), so an answer from an older attempt could appear ticked on the latest script.
  - With legacy duplicate answer rows, the **first** row is ticked (the one the score counted). Legacy ticked the last.
- **Kept on purpose:**
  - Text, options and theory answers are still echoed unescaped (flagged for Phase 4).
  - The PHP warnings from undefined `$student_options[...]` / the admission branch's `$exam_id` / `$student_id` in radio ids.
  - The theory page has no Back button.
  - Dead `myComparison` / `$remove_instruction` / instruction JS left for 4.3.
- **Parity harness** `/tmp/cbt-parity/r24/` (`pre/` = pre-change pages and includes; `fixtures.php` shared by `run.php`, `cases.php`, `flow.php`):
  - It runs the **whole** page in the CLI on temp copies of `exams`, `exam_students`, `exam_student_questions`, `exam_student_theory`, `exam_student_theory_images`, `admission_exam_students` and `admission_exam_student_questions`.
  - Fixtures: corrections opened on exams 31 / 30 / 23; exam 18 still open; attempts 800–807 (duplicate rows, two objective attempts, unscored, scored on an open exam, two theory attempts, theory with red-pen corrections and an empty Drive id).
  - Personas: 10 staff, 7 students, 4 parents. Expected access is computed independently in `cases.php`.
  - Result over 469 cases: 76 identical, 381 intended (refusals), 12 either (the duplicate-row and two-attempt fixtures; diffs are only the ticked options), 0 unexpected. 88 renders, including 13 student / parent renders that are byte-identical to legacy.
  - `flow.php`: 44 / 44 engine checks (choices / results / awarded = score, latest attempt, score visibility, every gate, `$now`, theory answers / images, admission, marking refactor).
  - Real tables unchanged.
  - Regressions: m1 483 / 472 / 0, m2 176 / 267 / 0, m3 / m4 / m5 / cc19 OK, a22 50 / 46 / 0 and flow 46 / 46, se23 59 / 24 / 0. No live desktop check (no desktop login), as in S1.

Facts established in 2.5 (S30):

- **Engine additions (`cbt_attempt_engine.php`, CRLF):**
  - `cbt_student_exams_student` also returns `other_name`, `activation`, `subclass_id` and `class_name` (`CONCAT_WS(' ', class_name, subclass_name)` via LEFT JOINs).
  - `cbt_student_exam_list` rows also carry `e.exam_description` (the instructions), so the app page needs no per-exam description call.
  - `cbt_ajax_app_student_exam` drops `class_id`, `exam_student_id` and `exam_description`, so the app JSON shape from 2.3 is unchanged. Desktop output is untouched (se23 still 59 / 24 / 0).
- **Page `app/admin/student-exams.php` (new, CRLF):**
  - Students see their own list, with `student_id` taken from the session. Parents and backend pass `?student_id=<encrypt>`, which is checked by `cbt_can_act_for_student` through the `student_exams` dispatcher route (`$CBT_SURFACE = 'app'`, `$CBT_ARGS = ['student_id']`).
  - If the student is not found, not allowed or not activated, the page redirects home: learners-center.php for students, parent-wards.php for parents, index.php for anyone else (including admin).
  - Layout:
    - Toolbar with Student and Class chips.
    - Hero titled "Your exams" or "<First>'s exams", with a ticket for the next exam (the open exam whose clock runs out first, otherwise the soonest upcoming one) and a "N done · average X%" caption.
    - Strip with Open / Upcoming / Done tabs. `?view=` preselects a tab; the default is the first non-empty bucket.
    - Search within the active tab, cards, and an Instructions bottom sheet.
  - Renderers are `zap_cbt_my_*` / `zap_cbt_render_my_*` in `partials/zap-cbt-helpers.php`. The JS is `scripts/zap-cbt-my-exams.js`. CSS is the "cbt student exams" block at the end of `zapapp.css` (ticket stub, card rail colour via `data-cbt-tone`).
- **Buckets come from `cbt_student_exam_state`:** action `start` → Open, `upcoming` → Upcoming, everything else → Done. Open and Upcoming are sorted by the clock; Done keeps the engine's list order.
  - The clock on an in-progress attempt is "Time left" until min(attempt start + duration, exam end). Otherwise it is "Closes in" / "Opens in". Countdowns use the server clock (skew-corrected), refresh every 15 s, turn amber under 10 min, and reload the page once when a live countdown reaches zero.
  - Chips: Open, In progress, Upcoming, Passed, Failed, Not marked, Not submitted, Missed (`not_written` on an ended exam) and Submitted.
  - **Intended difference from desktop:** when the score is hidden from the student (`student_view_score = 0`), the chip says "Submitted" with "Your teacher hasn't released the score." Desktop still shows PASSED / FAILED. Parents and backend see the score.
- **Actions:**
  - Surveillance exams that are open or upcoming show a "Take this exam on a computer" note instead of a button (locked decision 2).
  - Only the student can start an exam. Parents and backend get "Only <first> can start this exam, from their own login", which makes 3.2's read-only view already true.
  - Start / Resume and "Review answers" links appear only when `cbt-take.php` (2.6) or `cbt-review.php` (2.8) exist (`is_file`). Until then the card shows "Take this exam on the web portal for now." or "Answers can be reviewed on the web portal." **2.6 and 2.8 need no change here.** The URLs are `cbt-take.php?exam_id=<encrypt>` and `cbt-review.php?exam_id=…&student_id=…`.
  - Instructions go through `zap_cbt_rich` into a `<template>`, so scripts and handlers are stripped.
- **Not done here:** the student nav tile and Learners Center card (2.9), and the parent entry from `parent-wards.php` (3.2).
- **Harness `/tmp/cbt-parity/se25/`** (`fixtures.php`, `render.php`, `cases.php`; temp-table shadowing, one process per render):
  - It covers the access redirects, every persona, buckets computed independently, take / review / computer notes, hidden scores, the choice of ticket exam, sanitization, the app JSON shape, and PHP / JS `until` parity.
  - Result: 758 checks, 0 failed. Real tables unchanged.
  - Regressions: se23 59 / 24 / 0, r24 76 / 381 / 12 / 0 and flow 44 / 44, a22 50 / 46 / 0 and flow 46 / 46, m1 483 / 472 / 0, m2 176 / 267 / 0, m3 / m4 / m5 / cc19 OK.
  - Live: the admin session is redirected to index.php. There is no app student login, so the student-35 render was checked visually by injecting it into a live app page at 390 px: ticket, open cards (camera note, Resume, Time left), Done tab, and the Instructions sheet with sanitized markup.

Facts established in 2.6 (S31):

- **New app page `app/admin/cbt-take.php?exam_id=<encrypt>`** (CRLF). Students only (`student_id` from the session). Parents go to parent-wards.php and anyone else to index.php. Surveillance exams and missing exams go back to the student list, and any POST from those gets `{status: failed, code: forbidden}` before the dispatcher runs. Blocked students (`activation` ≠ activated, via `cbt_student_exams_student`) go to the list. It sets `$CBT_PAGE = 'take'` and `$CBT_ARGS = [type (from cbt_paper_header), exam_id, student_id, back_link]`, so it uses the 2.1 / 2.2 keys unchanged. `cbt_attempt_page_get` failures redirect to `student-exams.php` (`not_open` → `?view=upcoming`, `expired` → `?view=done`). A finished attempt also goes to `?view=done`. The student-exams Start / Resume links switched on by themselves (`is_file`).
- **Order:** objective questions and options are shuffled per student by a seeded hash (`zap_cbt_take_order`, seed `take:<exam>:<student>`), so a resume keeps the same order. Desktop reshuffles on every load. Theory keeps paper order, as desktop does.
- **Prefill:** objective choices come from `cbt_review_chosen` (the first stored row, which is the one that is scored). Theory answers go through `zap_cbt_answer_plain`, which strips script / style, turns block tags and `<br>` into newlines and decodes entities, into a plain textarea. The JS saves escaped text with `<br>` (`toHtml`), so desktop marking and `zap_cbt_rich` render it. Finish posts only the theory answers edited in the app, so rich desktop answers that weren't touched are not flattened.
- **Renderer:** `zap_cbt_render_take_question` gained `$opts` (`chosen`, `answer`, `photo`, `clear`). Preview output is unchanged (pv114 OK). In take mode the photo button is off until 2.7. Objective screens get "Clear my answer", which posts `save_answer` with an empty answer.
- **Clock:** Start / Resume posts `set_start_date`. The deadline is min(now + `seconds_left`, exam end on the server clock, skew-corrected). Snackbars show at 5 min and 1 min. At zero the attempt is submitted automatically (`time_expired;client=app`) in a locked sheet that offers retry. A resumed attempt with no time left shows "Submit my answers". A new attempt whose window closes before the full duration shows a "you'll have N min" note.
- **Autosave:** `save_answer` runs per question (objective on change; theory debounced 1.5 s and flushed on blur). There is one request in flight per question and the latest value wins. Network failures retry after 5 s and on `online`. The status line shows Saving / All saved / Offline / Not saved. `time_up` triggers the submit; `ended` shows "Already handed in". On `pagehide` / hidden / Leave, pending answers are sent with `sendBeacon`.
- **Leaving the app (Q13):** the first `visibilitychange` → hidden (or `pagehide`) after each return counts one exit in localStorage (`zap-cbt-exits:<md5 12>`). A snackbar on return says it is recorded. The count is posted as `fullscreen` on finish and cleared after success. The back arrow opens a "clock keeps running" leave sheet.
- **Submit:** the confirm sheet shows a tally drawn as answer-sheet bubbles (answered / blank / flagged) and jump chips for blank questions. Finish posts `cond = user_ended;client=app`, the objective `options` string of every checked option, or theory `theory_text` for edited answers. The done card has a rose "Handed in" stamp. Objective attempts show the score / pass rail only when `student_view_score = 1`; otherwise "Your teacher will release the score". Theory shows "Your teacher will mark your answers".
- **Engine (additive):** `cbt_attempt_page_get` also returns `exam` (`minimum_percentage`, `student_view_score`, `view_answers`, `unix_start_date`). `cbt_attempt_finish_theory` has an optional `$fullscreen` that stores `fullscreen_exits`. The dispatcher passes it only for the app surface, so desktop theory still ignores it, as legacy did.
- **Desktop submissions list:** `cbt_desktop_render_submission_rows` matches the condition before `;client=app` (or `|`) and adds `<label class="label label-info">App</label>` for app attempts. Existing values render byte-identically (m1 483 / 472 / 0).
- **CSS:** new `zap-app: cbt student taking` block at the end of `zapapp.css` (LF).
- **Checks** (`/tmp/cbt-parity/t26/`; `fixtures.php` shadows exams / exam_students / answer tables):
  - `cases.php` renders the whole page in the CLI: 66 / 66. It covers access and redirects (parent, staff, backend, camera, missing, upcoming, closed, other class, submitted, scored, blocked), forbidden POSTs, no answer key / photo button, sanitized instructions, question set = questions with options, stable per-student order, resume pre-checks and Clear, theory prefill escaped with no script, time-over and short-window states, hidden score config.
  - `flow.php` runs the dispatcher in one process: 50 / 50. It covers CSRF, start / resume, save / clear / foreign option / foreign question / other student, finish merging posted over autosaved with the expected score and rows, `user_ended;client=app` + exits 3, a no-op second finish, save after finish, theory autosave / finish with exits 2 and only edited answers changed, desktop theory exits untouched with the same shape, late finish → `time_expired_after;client=app`, desktop App labels, and helper units.
  - Real tables unchanged.
  - Regressions unchanged: pv114 OK, se25 758 / 0, a22 50 / 46 / 0 and flow 46 / 46, r24 76 / 381 / 12 / 0 and flow 44 / 44, se23 59 / 24 / 0, m1 483 / 472 / 0, m3 / m4 / m5 / cc19 OK.
- **Live check (done in S32):** the rendered student-35 page was injected into a live app page at 390 px with `fetch`, XHR, `sendBeacon` and file inputs stubbed (harness `/tmp/cbt-parity/t27/inject.php`). `document.visibilityState` is a non-configurable property, so the injected copy of the script reads `window.__visState` instead. All of these passed: resume with the stored answer and the clock, objective choose → `save_answer` → answered counter, Prev / Next, a real leave counting 1 exit, offline queue → retry on `online`, time-up auto-submit (`time_expired;client=app`), and submit / done (`user_ended;client=app` + `fullscreen`).

Facts established in 2.7 (S32):

- **Engine (`cbt_attempt_engine.php`, CRLF), new functions:**
  - `cbt_attempt_photo_limit()` returns 6 photos per answer.
  - `cbt_attempt_photo_ids($db, $exam_student_id, $question_id)` returns the non-empty Drive ids, oldest first. Empty legacy ids don't count toward the limit.
  - `cbt_attempt_photo_add($db, $ctx, $storage, $exam_id, $student_id, $question_id, $file)`:
    - Gates: `cbt_attempt_open` (theory, before the deadline + 120 s), the question must be on the exam, the file must be JPG / PNG and pass `cbt_q_image_error` (5 MB), and the limit must not be reached.
    - Uploads the file as `theory_<attempt>_<question>_<YmdHis>.jpg|png`.
    - Then, in one transaction: lock the attempt (`FOR UPDATE`), re-check the limit, and insert into `exam_student_theory_images`. If the insert fails, the Drive file is deleted.
    - Error codes: `bad_question`, `bad_file`, `too_many`, `upload`, `db`, `time_up`, `ended`.
  - `cbt_attempt_photo_remove($db, $ctx, $storage, $exam_id, $student_id, $drive_id)`:
    - The row must belong to the latest attempt (`bad_photo` otherwise), and the attempt must still be open.
    - Deletes the row under the lock. After commit, it deletes the Drive file only if no other row references that id.
  - Both return `{question_id, google_drive_id, photos}`. No schema change.
- **Route:** new `take_photo` page in `cbt_ajax.php` (app surface only, CSRF). Keys are `add_theory_photo` (`exam_id` encrypted, `question_id`, file `photo`) and `remove_theory_photo` (`exam_id`, `google_drive_id`).
  - Only the logged-in student can use it. Surveillance exams get `forbidden`.
  - The reply is app JSON "Photo added" / "Photo removed", plus `code` on failure.
  - The take page has no Drive client, so the app posts to `google-ajax.php`. It `require`s the dispatcher behind a guard (`$CBT_PAGE = 'take_photo'`, the same pattern as 1.11). That file builds Drive from the stored refresh token, so it works for student sessions.
  - `cbt_ajax_drive_storage($folder_id)` now takes an optional folder. Theory photos go to the same Drive folder as desktop (`cbt_ajax_theory_photo_folder()`).
- **Renderer:** `zap_cbt_render_take_question` takes `photos` (array of Drive ids) and `photo_max`. Theory screens render `zap_cbt_take_photos`:
  - A head ("Photos of your working", "N of 6") and the prints (`zap_cbt_take_shot`: w320 thumbnail, zoom to s2000, remove button, `fa-image` placeholder when the thumbnail fails).
  - "Take a photo of your working" (camera input, `accept="image/*" capture="environment"`) and "Choose from gallery" (multiple).
  - When full, a note: "That's the most photos for one answer. Remove one to add another."
  - Malformed ids are dropped. Preview output is unchanged (pv114 OK).
- **Page:** the theory intro says the camera doesn't count as leaving. There is a "Remove this photo?" sheet (preview, "Your teacher won't see it", Keep it / Remove), and the config adds `photoUrl` / `examId` (theory only).
- **JS (`zap-cbt-take.js`):**
  - Compression: each photo is re-encoded on the device to JPEG, longest side 1600 px, quality 0.82. A 3000×4000 PNG became about 68 KB. If the canvas fails, the original is sent when it is a JPG / PNG of 5 MB or less.
  - Uploads: one XHR at a time. Each print shows a progress ring (`--cbt-shot-p`), then a graphite tick bubble.
    - `upload` / `db` errors leave the print red with Retry. Retry only acts on failed prints.
    - Network errors re-queue the photo and retry it on `online`.
    - `time_up` / `ended` follow the answer-save path. Other errors show a snackbar and drop the print.
  - A gallery pick fills only the remaining room, with a snackbar. Photos still being compressed count as busy and take up room.
  - A photo-only answer counts as answered in the client (grid, counter, submit tally).
  - Picker and leave counting: opening a picker sets `picking`, so the WebView going to the background for the camera is not counted as an exit, and pending answers are beaconed.
  - Submit waits up to 30 s for photos to send (15 s on time-up), with the label "Sending photos…". The submit note warns about failed or pending photos.
  - The leave button and `beforeunload` refuse or warn while photos are uploading.
- **CSS:** "Theory photo tray…" block appended after the student-taking block in `zapapp.css` (LF): tilted white prints, the tick bubble, the conic progress ring, the red Retry state, the gallery link, the full note, and the remove sheet frame. Reduced motion is respected.
- **Checks (`/tmp/cbt-parity/t27/`):**
  - Fixtures are t26 plus photos on attempt 802 / question 24: two valid ids, an empty id, an id containing an XSS payload, and a shared id on attempt 805.
  - `flow.php` 55 / 55: CSRF, surface, forbidden, other student, bad question / file, limit (empty ids not counted, 7th refused), upload failure leaves no row, DB failure deletes the Drive file, remove on the latest attempt only, a shared Drive id is kept, after finish → `ended`, past deadline → `time_up`, and marking / review see the photos.
  - `cases.php` 20 / 20: tray markup, counts, the full state, escaping / malformed ids, no tray on objective or preview pages, and the config.
  - The t26 `cases.php` "no photo button" assertion became "photo tray on theory (2.7)". This is an intended change.
  - Regressions unchanged: t26 66 / 66 and flow 50 / 50, a22 50 / 46 / 0 and flow 46 / 46, r24 76 / 381 / 12 / 0 and flow 44 / 44, m1 483 / 472 / 0, m2 176 / 267 / 0, se23 59 / 24 / 0, se25 758 / 0, m3 / m4 / m5 / cc19 / pv114 OK. Real tables unchanged.
  - Live at 390 px (injected, network stubbed), all passed:
    - Camera round trip with 0 exits counted, and the upload fields and JPEG blob checked.
    - Progress ring, Retry, offline → `online`, and the gallery overfill snackbar.
    - Remove sheet → `remove_theory_photo`, photo-only answer counted, and the broken-thumbnail placeholder.
    - Submit during compression / upload sends the photos before finish.
  - Bugs fixed during the live check: Retry on a print that wasn't failed, compression not counted as busy, and a placeholder that clashed with `.md-ripple::after`.
- **Known limits / follow-ups:**
  - Server-side "answered" counts (submissions list, `save_answer`'s `answered`) still count text only, so a photo-only answer shows as blank there. Revisit with 2.8 / marking.
  - Drive thumbnails depend on the folder's sharing. Broken ones show a placeholder.
  - Switching to another app while the picker is open is not counted as an exit (accepted).
  - **Desktop `drive-ajax.php` `upload_question_images` / `delete_theory_image` are untouched and have no permission checks: anyone logged in can delete any theory image by Drive id. Phase 4 should move desktop onto `cbt_attempt_photo_add` / `_remove`.** (Done in 4.5, S43.)

Facts established in 2.8 (S33):

- **Page:** new `app/admin/cbt-review.php` (no dispatcher, read-only) on the taking shell plus `cbt-mark-app` (red-pen tokens). It reads the type from `cbt_paper_header`, then calls `cbt_review_get`, so access is exactly the engine's: staff who manage the exam see any attempt; students / parents only with `view_answers = 1`, the exam closed and the attempt scored. New JS `scripts/zap-cbt-review.js`, helpers `zap_cbt_review_*` / `zap_cbt_render_review_strip` / `zap_cbt_render_review_question` in `zap-cbt-helpers.php`, new zapapp.css block "cbt script review".
- **Layout:** cover (title, subject · class · topic, score ring + pass verdict rail, "Approved at …" when the revised score differs, Handed in / Time used / Questions facts, end condition note; staff also see "Taken in the app" and leave count), a strip of one cell per question (jumps to the card), filter chips (objective: All / Wrong / Not answered / Right; theory with visible score: All / Lost marks / Full marks; empty chips dropped), then one card per question. Objective: hand-drawn tick / cross / dash in the margin, the student's choice filled, the right answer circled in red, a wrong choice struck through, "Your answer" / "Right answer" tags, a "Not answered" note. Theory: ruled script with the teacher's corrected text (Show original toggle), photos / figures in `ZapTimelineLightbox`, and a red ring with the score.
- **Hidden score (`student_view_score = 0`):** the engine only flips `score_visible`; desktop correction pages ignore it, so the app enforces it. Students still see right / wrong marks and the right answers (the teacher turned on `view_answers`) but no marks, rings, totals, verdict or theory filters; the cover says "Your teacher hasn't released the score."
- **Entry points:** app `student-exams.php` Review links switched on automatically (`is_file`). Objective cards on app `cbt-submissions.php` gained "View script" (`…&from=submissions`, new `review_page` card option); theory cards keep Mark / Re-mark.
- **Redirects / back links:** students fail back to `student-exams.php?view=done`; parents to `parent-wards.php` on forbidden / not found, otherwise to the ward's done list; staff (with `from=submissions` or a staff account) to the exam's submissions page; others to `cbt-center.php?tab=exams` or `index.php`. The back link follows the same rule ("Back to submissions" / "Back to my exams" / "Back to exams").
- **Harness `/tmp/cbt-parity/rv28/`** (temp-table shadowing on the r24 fixtures, exam 31 with `student_view_score = 0`, a script tag in answer 953): 21 personas × 19 targets plus `from=submissions` variants, 540 cases, 139 renders, **2680 checks / 0 failed**, real tables unchanged. Checks independent access / redirect, one card + cell per question, ring / hidden-note visibility, option marker counts against the engine, tallies, escaping, staff-only diagnostics, theory corrections, submission-card links.
- **Regressions:** r24 76 / 381 / 12 / 0, se25 758 / 0, m3 / m4 / cc19 OK; real tables unchanged. The "View script" link on submission cards is checked by rv28, not m3.
- **Live at 390 px as app admin:** exam 20 / student 32 (all right), exam 31 / student 31 (4 right, 6 wrong; Wrong filter, strike-through and red circle), theory exam 23 / student 31 (rings, dashed unmarked ring, strip scores, jump ping). Fixed during the check: the Handed in date wrapping onto three lines (time moved to its own line) and the theory tally wording (now "1 with full marks · 1 with part marks · 1 not marked").
- **Known limits:** theory results on the review come from the teacher's score (full / part / zero / not marked), not from whether text was typed, so photo-only answers are already shown correctly here; 2.11 still covers the other counts. Admission scripts have no app review (staff only on desktop; not in scope).

Facts established in 2.9 (S34):

- **Nav (`partials/zap-nav-menu.php`, LF):** new `$nav_my_exams` tile ("My exams", "Take online exams and see results", `icon/cbt.png`) → `student-exams.php`, shown to students only, right after Learners Center. Parents and staff menus unchanged; the parent entry is 3.3.
- **Learners Center (`learners-center.php`, CRLF):** students only. The page loads `zap-cbt-helpers.php` and puts an exams row under the student's own card tiles (`(int) $L['student_id'] === $user_id`). Parents get no row and the helpers aren't loaded for them. For 3.3, their wards can sit in other branches (`enc_db`), so the row would need that branch's DB.
- **Helpers (`zap-cbt-helpers.php`, CRLF):**
  - `zap_cbt_lc_exams($db, $ctx, $student_id, $now)` builds the model from the same `cbt_student_exam_list` → `zap_cbt_my_exam` → `zap_cbt_my_summary` chain as `student-exams.php`, so counts, buckets and the "next" exam match that page.
  - It returns null when that page would redirect: no access through `cbt_can_act_for_student`, or the student isn't activated. Blocked students get no row.
  - Model fields are `tone` (progress | open | upcoming | done | empty), `view` (the tab to open, matching the page's default tab), the stub count / label, overline, title, meta, `camera` (note text), `more` (counts of the other buckets) and `counts`.
  - `zap_cbt_render_lc_exams($m, $href)` renders the row. Copy says "your" for students and stays neutral otherwise, ready for 3.3.
- **Design:** a small hall ticket that echoes the `student-exams.php` hero. The stub shows the count for the tab it opens (green Open, amber when an attempt is in progress, ink blue Upcoming, graphite Done). The empty state has a grey stub with an icon. The body has the overline, the next exam's name (ellipsised), "Subject · Closes in / Time left / Opens in …" (upcoming adds the date), and for camera exams "Take it on a computer, with the camera on". The countdown is static text from the server (no JS on this page). CSS is a new block at the end of `zapapp.css` ("cbt Learners Center exams row", LF), with tones as `--lc-cbt-*` tokens.
- **Checks (`/tmp/cbt-parity/lc29/`):**
  - `run.php` (se25 fixtures, temp tables) checks every demo student against an independent recount from `cbt_student_exam_state`: view, bucket counts, stub count, more line, title = next exam, tone, camera note, link, escaping. It adds scenarios for upcoming-only, all closed and no exams, plus foreign-student and staff denial. **806 / 0**, real tables unchanged.
  - `page.php` renders the full `learners-center.php` in the CLI: students 31 / 32 / 35 / 36 get the row (35: in progress on a camera exam); a parent gets no row; no warnings.
  - `nav.php`: the tile shows for students only.
  - Regressions unchanged: se25 758 / 0, rv28 2680 / 0.
  - Live at 390 px by injection (no app student login): the real student-35 card plus open / upcoming / done / empty rows.

Facts established in 2.10 (S35):

- **Shared taking sheets (`zap-cbt-helpers.php`, CRLF), used by both `cbt-take.php` and `cbt-preview.php`:**
  - `zap_cbt_take_expect($o)` is the intro's "Before you start" list. Options: `resume`, `duration`, `theory`, `penalty`.
  - `zap_cbt_render_take_nav_sheet($total, $finish_label)` is the answer-sheet grid.
  - `zap_cbt_render_take_submit_sheet($o)` is the tally / blank-chips confirm panel plus the done card. Options: `title`, `pass`, `done_url` (take), or `preview` + `back_url`. Preview mode changes the copy, puts a "Preview" stamp where take has "Handed in", adds a close button, adds `data-cbt-done-rows` and `data-cbt-done-staff`, and replaces the done link with Start again / Close preview.
  - `zap_cbt_render_take_photo_inputs()` and `zap_cbt_render_take_zoom()`.
  - The take page's markup is unchanged apart from whitespace. 150 renders (6 roles × every exam + 999, t27 fixtures) were compared before and after with whitespace, the server clock, the CSRF token and asset versions normalised.
- **`cbt-preview.php` now matches the student screen:**
  - Theory questions show the real photo tray (empty, `cbt_attempt_photo_limit()`) and the camera / gallery inputs.
  - Objective questions get "Clear my answer".
  - School exams without surveillance show the student's "Before you start" list (new-attempt wording) under a "Students read this before they start" caption. Banks, admission exams and surveillance exams don't, because students never see that intro for them.
  - The old finish / result sheet is gone. Finish opens the real submit sheet, and "Finish preview" shows the done card scored locally, with the pass rail and verdict, the Right / Wrong / Left blank rows, and a staff note. When `student_view_score = 0`, the note says students won't see the score yet.
  - Pass mark and score release come from `cbt_exam_get` (school exams only; banks and admission exams pass `null`) through `window.ZAP_CBT_TAKE = {showScore, pass}`. The page has no CSRF token and posts nothing.
  - The "Student order" switch now shows on objective papers only. Desktop and app taking never shuffle theory, so the old theory shuffle didn't match what students get.
- **JS (`zap-cbt-take.js`, CRLF), preview mode:**
  - `finish()` opens the submit sheet. On time-up, preview skips the sheet and goes straight to `previewDone(true)`.
  - `previewDone()` fills the done card. It also reveals the answers on objective papers.
  - Photos are compressed and added to the tray as `done` without uploading, so a photo-only answer counts as answered. Remove drops the photo without the confirm sheet.
  - The Clear button now shows in both modes.
  - `openSubmit()` resets the panels in preview.
  - Start again resets flags (including the "Flagged" label, which used to stick), Clear buttons, photo trays and the done panel.
- **Fixed a 2.7 take-mode bug:** Font Awesome's `.fa { display: var(--fa-display, inline-block) }` loads after `zapapp.css` with the same specificity, so the `fa-image` placeholder covered every photo in the tray, for students too. The rule is now `.cbt-take-shot .cbt-take-shot__ph`. The 2.7 live check missed this because it ran with stubbed Drive thumbnails. `zapapp.min.css` rebuilds itself on the next request (`zap_css_min.php`).
- **Checks:**
  - New `/tmp/cbt-parity/pv210/`. `cases.php` runs the full `cbt-preview.php` in the CLI for 5 personas × every bank, exam and admission exam, plus id 999: 380 pages, **3041 checks / 0 failed**. It checks access against `cbt_paper_get`, screens, the nav / submit / done markup and the close link, that the old sheet and the take-only sheets are absent, that there is no CSRF, trays / pickers / no switches on theory, option and correct counts plus Clear and both switches on objective, the expect list and each of its lines against the paper, the surveillance note, config `pass` / `showScore` against the exam row, the pass marker, and no unexpected inline scripts. Real tables are unchanged.
  - `take_snap.php` checks the take page's markup before and after the refactor.
  - Regressions unchanged: pv114 OK, t26 66 / 66 and flow 50 / 50, t27 20 / 20 and flow 55 / 55, rv28 2680 / 0, lc29 806 / 0, se25 758 / 0, m4 OK.
  - Live at 390 px as app admin:
    - Theory exam 23: real tray, a generated photo added and counted (1 of 6, 1/3), the submit tally (1 answered / 2 blank / 1 flagged) with Q2 / Q3 chips, the done card with the "Preview" stamp, and Start again fully reset.
    - Objective exam 29: Clear, then the done card showing 3 / 14 with "21% · Below the 50% pass mark", the rail, rows and the answer key revealed.
    - Exam 30: the intro list with its caption.

Facts established in 2.11 (S36):

- **One rule for "answered" on the server, matching the app client:**
  - A theory question counts as answered when its stored text has a non-space character (`REGEXP '[^[:space:]]'`, like the client's `trim()`) **or** it has at least one photo with a non-empty Drive id.
  - An objective question counts as answered when it has a stored option.
  - Only the exam's own questions count. Several photos on one question count once.
- **New in `cbt_attempt_engine.php`:**
  - `cbt_attempt_theory_answered_sql()` and `cbt_attempt_objective_answered_sql()` return fixed subqueries over the alias `exam_students AS es`. There is no interpolation, so callers concatenate them into their own SQL.
  - New `cbt_attempt_theory_answered($db, $exam_id, $exam_student_id)`. `cbt_attempt_objective_answered` now uses the shared subquery, with the same result.
- **Callers:**
  - Theory `save_answer` returns the new count, so clearing the text of a question that still has photos keeps it answered.
  - `cbt_attempt_photo_add` / `_remove` also return `answered` (additive; the app client still counts locally).
  - In `cbt_marking_pending_scripts`, `answered` now follows the new rule, so a photo-only script reads "Answered 1 of N", not "Blank script".
  - `cbt_submission_list` gains `answered` (theory or objective rule by exam type) and `photos`. `count_options` is unchanged, and the desktop renderer still uses it, so desktop output is identical.
- **App submission card:**
  - Theory cards now show "Answered X of N" and "N photos" to everyone who can open the page, as the To mark queue already did.
  - Objective cards still show "Answered" only with diagnostics, but now from `answered`, which counts distinct questions, not legacy duplicate option rows.
  - The review (`cbt-review.php`) and marking screens already treated photos as answers (see 2.8). No change was needed there.
- **Checks:**
  - New `/tmp/cbt-parity/an211/run.php`, on the t27 temp-table fixtures: **28 / 28 checks**. It covers the count rules, photo-only / blank / whitespace answers, foreign questions, `save_answer` (engine and app route), photo add / remove counts, queue row and card markup, submissions rows and cards (theory, blank, objective with duplicate rows, diagnostics on / off), and that the desktop rows ignore the new keys.
  - Regressions unchanged: m1 483 / 472 / 0, m2 176 / 267 / 0, a22 50 / 46 / 0 and flow 46 / 46, se23 59 / 24 / 0, r24 76 / 381 / 0, rv28 2680 / 0, t26 66 / 66 and flow 50 / 50, t27 20 / 20 and flow 55 / 55, m3 / m4 / m5 / cc19 OK.
  - No live browser check: the only UI change is up to two extra fact rows on server-rendered submission cards.

Facts established in 3.1 (S37):

- **The ward predicate already existed.** `cbt_can_act_for_student` (2.1) is the parent → ward ownership check (`students.parent_id` = session parent id, in the current DB). It allows any ward, including blocked and graduated ones; the app pages already redirect non-activated students. Unchanged here apart from its docblock.
- **New in `cbt_access.php` (CRLF):**
  - `cbt_parent_wards($db, $ctx, $parents_table)`: the `exam-wards.php` list, prepared. Parents only. Activated wards in a class (INNER JOIN `class`, so graduated wards are left out), joined to the owner DB `parents` row, ordered by full name and then `student_id`. Rows are `{student_id (int), name, class_name}`, raw (desktop prints them unescaped, as before).
  - `cbt_parent_ward_schools($db, $ctx, $link_table)`: the schools the parent has wards in, from `$parent_student_school` (owner DB) joined to `zeonglob_schools.schools`, as `{school_id, database_name}` ordered by school id. Same query as app `parent-wards.php`.
  - `cbt_parent_select_ward_school($db, $ctx, $school_id, $link_table)`: `select_db` to a ward's school, but only for a school in that list. Anything else returns false and leaves `$db` on the current DB. **3.2 must use this instead of `connect_to_school_database()`**, then still check `cbt_can_act_for_student` in the selected DB.
  - `cbt_access_table_ident()`: table names (`table` or `db.table`) come from settings and are matched against `[A-Za-z0-9_]`, so a bad value gives an empty result, never SQL.
- **Multi-school parents are real in the demo:** `parent_student_school` links (parent, student, school). Parent 1 has wards in school 21 (`zeonglob_zap_demo_sec`) and 207 (`zeonglob_zap_demo_nurpri`). Parent ids are shared across the schools of a main school (one owner DB `parents` table), so `students.parent_id` works the same in every branch DB.
- **Existing app ward pages are unsafe here (not CBT, not changed):** `topics.php`, `class-activity-student.php`, `elibrary.php` and `googlemeetings.php` call `connect_to_school_database(decrypt($_GET['school_id']))`. It puts the id straight into SQL (`encrypt()` is only base64, so the value is attacker-controlled) and never checks that the parent is linked to that school. Recorded under open items.
- **Desktop `exam-wards.php` (CRLF):** `wards()` now prints `cbt_desktop_render_parent_wards(cbt_parent_wards($db, cbt_ctx_from_globals(), $parents_table))`. No SQL left in the page. The new renderer in `cbt_desktop_render.php` reproduces the legacy table and DataTables script byte for byte.
- **Intended difference:** two wards with the same full name now keep `student_id` order; legacy left ties to MySQL.
- **Checks (`/tmp/cbt-parity/wd31/`):**
  - `run.php` renders the whole legacy page (`pre/exam-wards.php`) or the new one in the CLI with the se23 layout stub. `fixtures.php` adds temp-table wards for parent 2: HTML in the name, no subclass, two namesakes, blocked, graduated.
  - `cases.php`: every demo parent plus parent 0 / 99999, a student, staff and backend. **45 / 45 pages identical** (parent 2 differs only in namesake order, counted as intended), 45 ward rows.
  - Engine checks **52 / 52**: listing rules (blocked and graduated hidden, raw names, order, every listed ward passes `cbt_can_act_for_student`, no cross-parent leak), non-parents get nothing, bad table names give nothing, parent 1 → schools 21 and 207, refusals leave the DB unchanged (unlinked school, 0, `'999 OR 1=1'`, student, backend), parent 1 → 207 switches to nurpri where its wards pass and parent 2's don't, then back to 21.
  - Regressions unchanged: se23 59 / 24 / 0, se25 758 / 0, lc29 806 / 0, r24 76 / 381 / 12 / 0.
  - No live check (no desktop parent login), as in S1.

Facts established in 3.2 (S38):

- **`parent-wards.php?page=cbt` (LF):** new `cbt` entry in `$page_meta` (toolbar "Online exams", hero "Exams"). For `cbt` only, the page loads `zap-cbt-helpers.php` and builds a `$cbt_ctx`. Other `page=` values are unchanged.
  - New local helper `zap_ward_cbt_entry()` calls `zap_cbt_lc_exams` (2.9), so a card's Open / Upcoming / Done counts and the tab it opens are the ones `student-exams.php` shows. It returns null, and the ward is skipped, when that page would turn the parent away (no access or not activated). Graduated wards (no class) are skipped too, so the list matches desktop `exam-wards.php` / `cbt_parent_wards`: activated wards in a class.
  - Cards reuse `zap_ward_card` ("Check Exams"). The link is `student-exams.php?student_id=…&school_id=…&view=…` through new `zap_cbt_ward_exams_url()`; there is no `view` when the ward has no exams. A student opening `page=cbt` gets their own card linking to `student-exams.php`.
  - The parent loop still walks the schools with its own (unchanged) query and leaves the connection on the last school, as the other ward pages do, so `header.php` runs in that DB for multi-school parents. This is legacy behaviour and wasn't changed.
- **`student-exams.php` (CRLF):** parents may pass `school_id` (decrypted, `(int)`). When it is present, `cbt_parent_select_ward_school` must succeed **before the dispatcher** (so app POSTs also run in the ward's school), else the page redirects to `parent-wards.php?page=cbt`. `cbt_can_act_for_student` then checks the ward in that DB, as 3.1 required. The page returns to the session DB (`$db_name`) before the HTML and chrome. Students and backend ignore `school_id`. Parent redirects and the back button now go to `parent-wards.php?page=cbt`. No `school_id` keeps the 2.5 behaviour (session DB). The page was already read-only for parents (2.5).
- **`cbt-review.php` (CRLF):** same `school_id` gate and DB restore. The back / list URL and the parent redirects carry `school_id` and go to `parent-wards.php?page=cbt`. `zap_cbt_review_url()` takes an optional `school_id`, which `zap_cbt_my_exam` passes through the new `school_id` option. Review photos are direct Drive URLs, so nothing else depends on the DB.
- **Checks (`/tmp/cbt-parity/pw32/`):**
  - `fixtures.php` is the se25 fixtures plus nurpri temp tables. It adds open, upcoming and done (reviewable, scored attempt) exams for ward 717 (class 1), and a camera exam for 777 (class 10). The demo has no nurpri exams for those classes.
  - `render.php` renders any of the three pages in the CLI and reports the connection's DB at the end. `cases.php` computes wards and buckets on its own from the raw tables. **261 / 261**, real tables (both DBs) unchanged.
  - The wards page was rendered for 8 parents (1 has wards in both schools; 2 / 3 have blocked, deactivated and graduated wards; 40 has wards only in nurpri; 99999 has none) and for student 35. Checked: card set, names, counts, view, link school, no graduated section, and no cbt links on `topic` / `googlemeetings` / no page.
  - student-exams: 17 cases. Covered: cross-school wards, the 2.5 link without a school, the wrong school for a ward, an unlinked school (parent 2's nurpri student 3, whose id also exists in sec), 999, junk, 0, `'207 OR 1=1'` (int-cast to 207, a linked school), and student / backend ignoring `school_id`. Also checked: buckets, review links (exam, student, school), no take link for parents, back link, DB restored, and the app `return_public_files` POST answering from nurpri.
  - cbt-review: 5 cases. A nurpri script renders with a back link carrying the school. A missing school or an unlinked parent goes to the wards page. An open exam or a missing attempt goes to the ward's done list with the school.
  - Harness expectations updated for the intended redirect change (`parent-wards.php` → `parent-wards.php?page=cbt`): se25 cases 87–90, rv28 line 116.
  - Regressions: se25 758 / 0, rv28 2680 / 0, lc29 806 / 0 and nav OK, wd31 45 / 45 and 52 / 52.
  - No live check (no app parent login). No new CSS or JS; the cards are the existing ward cards.

Facts established in 3.3 (S39):

- **Nav (`partials/zap-nav-menu.php`, LF):** new `$nav_parent_exams` tile ("Online exams", "Your children's exams and results", `icon/cbt.png`) → `parent-wards.php?page=cbt`, shown to parents only, right after Learners Center (the same slot as the student "My exams" tile).
- **Learners Center (`learners-center.php`, CRLF):** `$lc_exams` is now a map keyed `"school_id:student_id"` → `['m' => zap_cbt_lc_exams model, 'href' => link]`. Each card looks up its own key, so the student branch (key `0:<user_id>`, link `student-exams.php?view=…`) and the parent branch share one render call. `zap-cbt-helpers.php` loads whenever there are learners.
  - Parents: one `cbt_parent_ward_schools` query maps linked school ids to DB names. For each learner, the page selects that school's DB, builds the model there and links through `zap_cbt_ward_exams_url($sid, $school_id, $view)`, so counts, the tab and the link match the `parent-wards.php?page=cbt` card and `student-exams.php`. A learner whose school isn't in the parent's linked list gets no row. The page then returns to the DB it was on before (`get_current_database_name` / `select_database_name`, as `zap_lc_fetch_learners` does).
  - The row component and copy are the 2.9 ones (neutral wording for parents, "Taken on a computer, with the camera on"). No new CSS or JS.
- **Checks (`/tmp/cbt-parity/lc33/`):**
  - `render.php` renders the full page in the CLI on the pw32 fixtures (sec + nurpri temp tables) and reports the connection's DB at the end.
  - `cases.php` computes the cards and buckets on its own from the raw tables. 8 parents (1 has wards in schools 21 and 207; 2 / 3 have blocked, deactivated and graduated wards; 40 only nurpri; 99999 none): card count, one row per card, link path / student / school, unique links, view, tone, stub count, neutral copy, the 777 camera note, rows seen in both schools, DB restored. Students 31 / 32 / 35 / 36 keep their own row and link; staff are redirected. Nav per role, with the parent tile right after Learners Center. **195 / 195**, real tables (both DBs) unchanged.
  - Regressions unchanged: lc29 806 / 0 (its `page.php parent:2` now shows a row, intended), pw32 261 / 261, se25 758 / 0.
  - No live check (no app parent login). The row is the existing 2.9 component, already checked at 390 px.

Facts established in 4.1 (S40):

- **Scope:** `cbt_access.php`, `cbt_question_bank_engine.php`, `cbt_exam_engine.php`, `cbt_attempt_engine.php`, `cbt_marking_engine.php`, `cbt_ajax.php`, plus the renderers (`cbt_desktop_render*.php`, `cbt_print_view.php`).
- **`{$` inside SQL: zero.** Before the change there were two, both in `cbt_access.php` (3.1): the settings table names `$parents_table` and `$parent_student_school` in `cbt_parent_wards` / `cbt_parent_ward_schools`. They were already matched against `[A-Za-z0-9_]`. A new `cbt_access_table_sql()` now turns a valid name into a backtick-quoted identifier (`` `db`.`table` ``, '' when invalid), and both queries concatenate that. Table names cannot be bound, so this is the only form they can take. The remaining `{$` hits are not SQL: three validation messages in `cbt_marking_engine.php` (`"Question {$n}: …"`), the renderers' modal titles and the print view's asset URLs.
- **Wider sweep (other ways a value could reach SQL), all clean:**
  - No `->query()`, `multi_query`, `real_query` or `mysqli_query` in the engines; everything goes through `prepare`.
  - No bare `$var` inside a double-quoted SQL string.
  - Every concatenation into SQL is one of: a `?` list (`implode(',', array_fill(0, n, '?'))` in `cbt_exam_engine.php`), a fixed fragment (`$where[]` / `$filter` / `$sql .=` pieces that contain only `?` placeholders, the shared `$select` column lists), a fixed helper (`cbt_attempt_theory_answered_sql()`), or a ternary / map choosing between literal statements (`cbt_exam_can_manage`, `cbt_review_chosen`, `cbt_q_clear_image`).
- **Checks:** `php -l` OK, CRLF kept; helper check (`db.table`, `table` quoted; `a.b.c`, a backtick, `;` and '' rejected); wd31 45 / 45 pages and 52 / 52 engine checks, pw32 261 / 261, lc33 195 / 195.

Facts established in 4.2 (S41):

- **Audit result:** every write key of every `cbt_ajax.php` handler already needed CSRF before this session (writes: banks, questions and categories via `drive_ajax`, AI imports, exams, assign / unassign, admission exams, approve, reset, theory marking, `set_start_date` / `save_answer` / finish, theory photos). No engine write is called from outside the dispatcher, and no CBT page has an inline `$_POST` handler left. List reads stay token-free on purpose (`return_question_bank`, `return_subjects`, `return_*_table`, `return_recent_activity`, `return_public_files`, `check_if_exam_started`, `get_search_subjects`, `get_searched_text`); the modal / picker reads the pages already post a token with keep needing one.
- **One table:** the per-handler lists moved into `cbt_ajax_csrf_keys($handler)` (`take_objective` / `take_theory` split as before). Every handler calls `cbt_ajax_csrf_blocked($surface, $post, cbt_ajax_csrf_keys('…'))`; the `question_bank` route's hand-written loop is gone. A new write key must go into this table.
- **Stricter check:** `cbt_ajax_csrf_ok()` no longer uses `Security::csrf_token_is_valid()`. It needs a non-empty string session token, a string POST token, `hash_equals`, and `request_is_same_domain()` as before. The legacy check passed when both tokens were "" (what `destroy_csrf_token()` leaves; nothing calls it today) and compared with `===`. `security.php` itself is unchanged (the rest of the site uses it).
- **Clients:** the app beacons (`zap-cbt-take.js` `save_answer`, `zap-cbt-mark.js` marking autosave) send `csrf_token`; `sendBeacon` sends the Referer under the default policy, so the same-domain check holds.
- **Outside the dispatcher (task 4.6):** `webcam-google-drive.php` `cbt_reset_exam` / `cbt_reset_admission_exam` / `clear_webcam_images` (used by desktop `exam-submissions.php` / `admission-exam-submissions.php`, app admission submissions) call `check_csrf_token()` but have **no role or ownership check** and build SQL from an `is_numeric` id. Unused copies of the three sit in `drive-ajax.php` (the theory-image keys that sat next to them moved to the dispatcher in 4.5). (Done in 4.6, S44.)
- **Checks (`/tmp/cbt-parity/cs42/run.php`):**
  - Static: parses every handler's `isset($post[…])` keys and fails on an unclassified key, a classified key that is not handled, a write missing from `cbt_ajax_csrf_keys()`, or a handler not using the table: 315 / 315.
  - Live: every CSRF key on all 16 routes (incl. the `question_bank` / `cbt_preview` import routes) × desktop / app × 9 bad cases (no token, wrong, wrong case, array, empty session token + empty post token, no session token, no referer, foreign referer, look-alike host) → exactly the CSRF failure: 1,260 calls; nothing changed in the 16 shadowed CBT tables; a valid token reaches the handler for all 140 key / surface pairs (`delete_image` stays refused); list reads need no token. Real tables checksummed on a second connection (temp tables shadow even `db.table` names on the same connection; probe in `shadow_probe.php`). **1,762 / 1,762.**
  - Mutation check: dropping `approve_score` from the table fails the static check; restoring the legacy token check fails 139 "empty session token" calls and the table checks.
  - Regressions: qb110 27, au111 51 / 0, im112 23 / 0, ex113 39 / 39, m3 / m4 / m5 OK, t26 66 + 50, t27 55, an211 28; parity mq14 165 / 134 / 0, me15 316 / 118 / 0, eq16 277 / 176 / 0, ad18 476 / 238 / 0, m1 483 / 472 / 0, m2 176 / 267 / 27 / 0, a22 50 / 46 / 0, se23 59 / 24 / 0, r24 76 / 381 / 12 / 0; rv28 2680 / 0, pw32 261 / 261, lc33 195 / 195, lc29 806 / 0, se25 758 / 0 (one run under heavy load showed 2 clock-boundary failures; four reruns clean).

Facts established in 4.3 (S42):

- **Starting point:** no CBT page had an inline `$_POST` handler or SQL write left (4.2). What remained was client code posting keys that nothing answers, clicks bound to elements that are never rendered, and PHP left over from the extraction. Found by listing every key each page posts and every delegated selector it binds, then matching them against `cbt_ajax.php`, the desktop renderers, the other endpoints and the pre-refactor copies.
- **Removed (deletions only, 437 lines over 14 pages, CRLF / LF kept):**
  - `exam-submissions.php`: `view_activity` and `marked_script` clicks (copied from class activities; no element, no handler, even in the pre-M.1 page), the `a#remove_image` click that posted `marked_activity_delete_image` to `drive-ajax.php` (no `#remove_image` renders here, see M.1), the session → term picker (`select_session` / `get_term`; the page has no such select) and the never-called `send_app_notifications()`.
  - `admission-exam-submissions.php`: the same session → term picker and `send_app_notifications()`.
  - `question_bank.php`: the `.staff_subjects` click (`get_staff_subjects`; no element, the only handler lives in `manage_staff.php`) and an unscoped `SELECT … FROM question_bank` that ran on every page render into an unused `$question_bank` (the list comes from `return_question_bank`).
  - `manage_questions.php`, `exam-questions.php`, `admission-exam-questions.php`: the `.add_lead` click (its button was already commented out and no server ever handled `add_lead`).
  - `student-exams.php`: the `.get_description` click (its button was commented out before 2.3). The dispatcher key `get_description` stays: it's a gated, prepared read in the CSRF table, and removing it would mean changing the dispatcher contract.
  - `cbt-correction-theory.php`: a never-called `finish_exam()` that posted marking data to this read-only page (no dispatcher there).
  - `myComparison()` (only referenced from commented-out `uasort` lines) in `cbt.php`, `cbt-theory.php`, `cbt-correction.php`, `cbt-correction-theory.php`, `cbt-marked-theory.php`, `cbt-preview.php`, `cbt-print.php`; the unused `shuffle_assoc()` in `cbt-preview.php` / `cbt-print.php` (`cbt.php` still uses it); `cbt-print.php`'s unused `$remove_instruction`. `cbt-preview.php` keeps `$remove_instruction` (its JS reads it).
- **Left on purpose:** site-wide layout boilerplate that binds selectors these pages don't render (`#preloader`, `.jquery-progress`, phone masks, the instruction bar on the review pages, `closeFullscreen` / `removeItemOnce` / `lock_exam_screen` helpers). They send no requests and appear on non-CBT pages too.
- **Checks (`/tmp/cbt-parity/dh43/`):** `run.sh` runs ten harnesses (se23, m1, m2, r24, a22, pv17, ad18, eq16, mq14, qb12; 8,520 AJAX / page outputs) once with the pre-4.3 pages swapped in (`pre/`) and once with the edited pages, then compares every `new` output. m1, m2, pv17, ad18, eq16, mq14 are identical. r24 (30 correction pages) and se23 (4 student-exams pages) differ only by removed lines. The other differences are not code: se23 list rows show fixture dates relative to the clock (09:21 vs 09:34), the a22 timer is one second apart, and the qb12 create responses differ only in the key order of `json_subject_classes` (equal once parsed; that query orders by subject name only, which leaves ties to MySQL). `php -l` OK on all 14 pages; no reference to a removed key or function left.

Facts established in 4.5 (S43):

- **Route:** `drive-ajax.php` already included the dispatcher (`$CBT_PAGE = 'drive_ajax'`). `cbt_ajax.php` now sends `upload_question_images` / `delete_theory_image` on that page to the new desktop-only `cbt_ajax_theory_photo_desktop()`, before the question writes. It uses the same Drive folder as the app (`cbt_ajax_theory_photo_folder()`) and the same storage adapter (`$args['storage']` lets harnesses inject a fake). The app surface does not use this route (the app keeps `take_photo`). Both keys are in the CSRF table as `theory_photo_desktop`.
- **Removed:** the inline handlers in `drive-ajax.php` (interpolated SQL, no checks; the delete removed every row with that Drive id, in any attempt) and the unused copies in `webcam-google-drive.php` (no client, different Drive folder). `webcam-google-drive.php` has no dispatcher, so those keys now get no answer there; its reset / clear keys are 4.6.
- **Desktop behaviour now matches the app engine:** CSRF required; only someone who may act for the student (the student, the parent of that ward, backend; staff are refused as in 2.2) and only on that student's running attempt of a theory exam; refused once submitted or time is up; JPG / PNG only, 5 MB, at most 6 photos per answer; the Drive file is named after the attempt and question (`theory_<attempt>_<question>_…`); delete only touches the caller's latest-attempt row and keeps the Drive file if another row still uses it. Camera (surveillance) exams are allowed on desktop, as before. Legacy response shapes are kept (`{status:'success', google_drive_id}` / `{status:'success'}`; failures `{status:'failed', msg}`).
- **`cbt-theory.php`:** the delete request now sends `exam_id`, `student_id` and `csrf_token`; the upload sends `csrf_token`, checks JPG / PNG and 5 MB on the client (was 10 MB, any type); both show the server's message on failure (before, a failure did nothing); both file inputs got `accept="image/jpeg, image/png, image/jpg"`.
- **Checks:** new `/tmp/cbt-parity/ph45/flow.php` 52 / 52 (routing, every role, every gate, file type / size / count, Drive naming and failure, delete scoping incl. shared and injection-shaped ids, refused after finish, real tables unchanged). cs42 now covers the new handler: static 326 / 0, live 1,278 blocked / 142 reach the handler, 1,813 / 0. a22 50 / 46 / 0 and r24 76 / 381 / 12 / 0 after teaching both about intended page edits: a22 undoes the 4.5 `cbt-theory.php` edits on the new render before comparing; r24 strips the dead `finish_exam()` that 4.3 removed from `cbt-correction-theory.php` (r24 needs byte parity and had been reporting those 26 theory pages as unexpected since 4.3). mq14 165 / 134 / 0, au111 51 / 0, t27 55, a22 flow 46 / 0.

Facts established in 4.6 (S44):

- **Decision (user, S44):** webcam captures are cleared after a score is approved everywhere: school and admission, desktop and app. (M.3 had left the app not clearing, and the admission clear never worked because both admission pages post `admission_exam_student_id` to a handler that read `exam_student_id`.)
- **Where it runs:** the submissions pages don't load the Google Drive client, so the Drive work stays on the Drive endpoints. New dispatcher page `attempt_cleanup` (`cbt_ajax_attempt_cleanup`, CSRF table entry `attempt_cleanup`), included for just these two keys by desktop `webcam-google-drive.php` (after `$service` exists) and app `google-ajax.php`:
  - `clear_webcam_images` with `exam_student_id` → `cbt_submission_webcams_clear`; with `admission_exam_student_id` → `cbt_adm_attempt_webcams_clear`. Only someone who can manage the attempt's exam (the people who can approve it), and only once the attempt is approved (`not_approved` otherwise). The attempt's exam is looked up from the attempt id, since the pages post only that.
  - `cbt_reset_admission_exam` → new `cbt_adm_attempt_reset`: `cbt_can_reset_attempt` (backend / admin / academic_admin, the same rule as the reset button) plus exam ownership; deletes the attempt, its answers and the applicant's captures for the exam in one prepared statement, then the captures' Drive files (as legacy did), skipping ids another row still uses or that aren't Drive-shaped.
  - Shared `cbt_marking_webcams_clear()`: per capture, deletes the Drive file unless a row for another student / exam uses the same id, then the row. If Drive refuses, the row is kept (the capture stays listed and a later clear retries). Empty ids just lose the row. Captures are stored per exam + student, not per attempt, as the legacy delete assumed.
  - Desktop replies `{status: success}` / `{status: failed, msg}`; app replies `{status, msg, data: {cleared, kept}}` (clear) or `msg: "Exam reset"`.
- **School reset:** desktop `exam-submissions.php` now posts the existing `reset_attempt` key to itself (the route the app has used since M.3; `cbt_submission_reset`, Drive files left as before). The page shows the server's message on failure (before, a failure did nothing); `admission-exam-submissions.php` too.
- **Removed:** the three inline handlers in `webcam-google-drive.php` and the copies in `drive-ajax.php`. The `drive-ajax.php` copies were reachable (top-level, after the dispatcher returns), not just unused.
- **Drive adapter:** `cbt_ajax_drive_storage()`'s delete now returns false when there is no Drive service or the endpoint's `deleteFile()` printed an API error. That message is captured, where it used to be printed into the JSON reply. Callers that ignore the return value (question writes, theory photos) behave as before.
- **App:** `admission-exam-submissions.php` posts reset and clear to its own `google-ajax.php`. It used to post to `../../admin/webcam-google-drive.php`, which can't work from the app (separate sessions), and showed "Reset unavailable" when that file was missing. The fallback and `zap_cbt_render_adm_submission_card()`'s `$reset_available` argument are gone. `scripts/zap-cbt-submissions.js` clears captures after a successful approve.
- **Legacy behaviour changes:** the owning teacher, principals, students and anyone else with a token can no longer reset or clear (before: anyone logged in, any attempt); clearing needs an approved attempt; ids must be `encrypt()`ed (the old `is_numeric` check accepted plain ids); shared Drive ids are no longer deleted from under another row.
- **Found, not fixed (task 4.7):** the capture uploads (`cbt_webcam`, `cbt_webcam_base64`, admission twins) in `webcam-google-drive.php` and `drive-ajax.php` interpolate `decrypt()`ed ids into SQL, don't check the caller is that candidate on a running attempt, and pass each posted "data URI" to `file_get_contents()`, so a local path or URL is read and uploaded to Drive. (Done in 4.7, S45.)
- **Checks:** new `/tmp/cbt-parity/rs46/flow.php` 60 / 60 (CSRF on both surfaces, routing, every role, unapproved / missing / unencrypted ids, shared and empty Drive ids, Drive refusal keeps rows and a retry clears them, admission reset rows + Drive files, desktop school reset through the page route, the adapter's error capture, static checks on the endpoints / pages / app JS, real tables unchanged); disabling the approval and reset-permission gates fails 10 checks. cs42 now covers `attempt_cleanup`: static 337 / 0, live 1,314 blocked / 146 reach the handler, 1,864 / 0. The ad116 sandbox copy of the app admission page was refreshed (old copy kept as `rs46/ad116-sandbox-subs.pre46.php`): flows 45 / 0, surv 15 / 0. m1 483 / 472 / 0, ad18 476 / 238 / 0, mq14 165 / 134 / 0, m3 / m4 / m5 OK, ph45 52, t27 55, a22 flow 46 / 0, au111 51 / 0, im112 23 / 0. `php -l` / `node --check` clean; CRLF / LF kept.

Facts established in 4.7 (S45):

- **One key per context, posted only:** the clients now send every capture through the queued keys, `cbt_webcam_base64` (school) and `admission_cbt_webcam_base64` (admission), as POST to `webcam-google-drive.php`. The direct GET-plus-file uploads (`?cbt_webcam=1`, `?admission_cbt_webcam=1`, sent with `Webcam.upload`) are gone; a stale page that still sends one gets an empty reply. New dispatcher page `webcam_capture` (`cbt_ajax_webcam_capture`, desktop only, CSRF table entry `webcam_capture`). The endpoint passes `$CBT_ARGS['school_dbs']` = the `db_name` of every `$school_config['schools']` entry.
- **Removed:** the four inline handlers in `webcam-google-drive.php` and the four copies in `drive-ajax.php`. `cbt-theory.php` used to post its captures to `drive-ajax.php` (hard-coded Drive folder `1hk5V…`); it now uses `webcam-google-drive.php` like the other two, so every capture goes to the school's `folder_id` (`getFolderId()`).
- **Engine (`cbt_attempt_engine.php`):**
  - `cbt_capture_attempt`: only the signed-in student, as themselves, on their own attempt. Students only (not parents, backend or staff). The exam must have surveillance on, and the attempt must be running per `cbt_attempt_open`: started, not finished (objective: scored; theory: submitted) and within duration + 120 s.
  - `cbt_adm_capture_attempt`: applicants have no login (Q3), so the ids in the `private/exam.php` link are the only credential. Since `encrypt()` is only base64, anyone who guesses an applicant's ids can add captures, but only while that applicant's surveillance attempt is running (unscored, within time). Nothing stronger is possible without touching `admission-cbt.php`'s flow or the schema.
  - `cbt_capture_store`, shared by both contexts:
    - `cbt_capture_decode` accepts only `data:image/jpeg;base64,` strings, decodes them in memory (strict base64), and requires JPEG magic bytes plus `getimagesizefromstring` = JPEG, at most 2 MB. Nothing is read from a path or URL.
    - Each capture is written to a private temp file only for the Drive client, then removed. The Drive file is named `webcam_<attempt>_<time>.jpg` (admission `adm_webcam_…`); legacy used random names and a description.
    - At most 10 captures per request; the rest come back in `retry`. At most duration + 30 captures per attempt; extra ones are dropped (`rejected`).
    - The attempt row is locked and re-checked before each insert. If the insert fails, or the attempt was finished during the upload, the Drive file is deleted.
    - Timestamps: the device time is shifted by the device's clock error (`client_time` vs server time). Anything outside [attempt start − 5 min, now], or not a number, is stored as now. Legacy stored the server's `time()` for direct uploads and the raw device time for queued ones.
  - All SQL is prepared literal statements (no `{$`).
- **Replies (desktop):** `{status: success, saved, rejected, retry: [device timestamps]}`; `{status: failed, msg, code}` where code is forbidden / not_found / not_watched / not_started / ended / time_up / bad_school / upload (upload also carries `retry`). Legacy always replied `success`.
- **Admission:** `admission-cbt.php` set the JS `exam_id` to the applicant id, so captures were stored under admission exam id = applicant id. It also never sent the school DB, so `webcam-google-drive.php` wrote to whatever DB the session pointed at (the demo default for a logged-out applicant). It now sends the real exam id and `school_db`. The dispatcher accepts only a DB in the list, switches to it for the call, then switches back. This is why demo `admission_exam_webcams` is empty.
- **Clients (`cbt.php`, `cbt-theory.php`, `admission-cbt.php`, same block):**
  - Every snapshot goes into the existing `localStorage` `webcam` queue (same `[{ts: uri}]` format). Before, the first capture (taken on the Start click, before `set_start_date` creates the attempt) was uploaded at once.
  - Sending starts once `set_start_date` succeeds. One request is in flight at a time, 10 per batch, and the next batch follows on progress.
  - The captures sent are removed unless they come back in `retry`. Network failure, CSRF failure, `not_started` and other failures keep them. `ended` / `time_up` / `forbidden` / `not_found` / `not_watched` drop the batch, so a dead attempt does not resend forever.
  - Offline: captures are queued and sent on `confirmed-up`. `localStorage` errors (quota, corrupt JSON) are caught.
- **Also:** `cbt_ajax_drive_storage()`'s upload now captures what `insertFile()` prints on an API error (it returns null, so the upload fails cleanly instead of printing into the JSON). This affects theory photos and question images too. 4.6's `cbt_marking_webcams_clear` had brought `{$` back into SQL (a table map); it now uses literal statements too.
- **Checks:**
  - New `/tmp/cbt-parity/wc47/flow.php`, 84 / 84:
    - routing and CSRF;
    - every role against the school gate;
    - not_found / not_watched / ended / time_up / not_started on both contexts;
    - ten bad captures (paths, a cloud metadata URL, `file://`, `php://`, PNG, PNG bytes labelled JPEG, a fake JPEG header, bad base64, a raw data URI, an array, > 2 MB) rejected with no Drive call;
    - Drive receives the exact JPEG bytes; naming; no temp files left;
    - clock correction and clamping; batch limit; per-attempt cap;
    - Drive refusing all / some captures;
    - attempt finished during the upload (row not stored, upload deleted);
    - admission DB allow-list, switch and restore; the legacy wrong-id bug; adapter error capture;
    - static checks on both endpoints and all three clients;
    - real tables unchanged.
    Disabling the student check or the JPEG check fails 5 checks each.
  - New `wc47/jsqueue.js`, 54 / 54: runs each page's webcam block against stubs (queue before start, request shape, one request in flight, batches of ten, retry / Drive refusal / CSRF / not_started / ended / offline / corrupt queue).
  - cs42 now covers `webcam_capture`: static 348 / 0, live 1,332 blocked / 148 reach the handler, 1,915 / 0.
  - a22 cuts the webcam block from both renders and undoes the `set_start_date` lines (33 / 13 applied): 50 / 46 / 0.
  - se23 had reported 4 unexpected since 4.3 (the removed `.get_description` click on full pages, removed lines only); it now strips that block from the legacy render: 59 / 24 / 0.
  - rs46 60, ph45 52, a22 flow 46 / 0, t26 50, t27 55, au111 51 / 0, im112 23 / 0, mq14 165 / 134 / 0, m1 483 / 472 / 0, ad18 476 / 238 / 0, r24 76 / 381 / 12 / 0.
  - Live, refusal-only, through the real web stack, logged out, with a token from `private/exam.php`: admission with no attempt → `not_started`; a school DB not in the list → `bad_school`; no token → CSRF failure; school key while logged out → `forbidden`; legacy GET upload → empty 200. Real `exam_webcams` (19) and `admission_exam_webcams` (0) unchanged.
  - `php -l` and `node --check` clean; CRLF / LF kept.
- **Not covered live:** a real capture upload (needs a student login or a running admission attempt, and would write to Drive and the real tables). Task 4.4's smoke test should take one surveillance exam on desktop and check the captures in the surveillance sheet.

Facts established in 1.17 (S46):

- **Decisions (user):** the file is an optional picker inside the create sheet (not a second button or a switch). If the import fails, no bank is left behind (no "empty bank + retry"). The bank page's Import entry points (empty state, Manage bank item, toolbar) are unchanged.
- **Route:** the app create sheet posts the existing desktop key `process_ai_question_bank` to `cbt-center.php` (page `question_bank`, already CSRF-listed) → `cbt_qb_import_file`. That function already inserts the bank only after n8n extraction and validation succeed. App response: `question_bank_id` encrypted (as for `create_question_bank`), message "Bank created. N question(s) imported". Desktop response unchanged.
- **Engine:** `cbt_qb_import_file` returns `imported`. Its rollback was `DELETE FROM question_bank` only, but `import_questions_to_database` commits categories before it reports "No questions were imported", so those categories were left with no bank. The rollback now uses the new `cbt_qb_purge` (the multi-table delete from `cbt_qb_delete`, without the permission check), which `cbt_qb_delete` also uses now.
- **UI:**
  - `zap_cbt_render_bank_sheet(..., $can_import)` adds the section in create mode only. `cbt-center.php` passes `question_extractor_webhook_url() !== ''`, so it is hidden when the n8n key is missing.
  - The picker / facts / progress markup is now shared: `zap_cbt_import_picker($input_id, $type)`, also used by `#cbt-import-sheet`. With `$type = ''` (create sheet) the drop zone is a compact row, the facts appear only once a file is chosen, and the objective-only facts follow the Type select.
  - Choosing a file turns the button into "Create & import". While busy, the fields and close buttons are disabled and the `beforeunload` guard is on. On success: swal, then open the new bank. On failure: the error is scrolled into view in the sheet, or shown as an alert if the sheet was dismissed.
- **JS:** `zap-cbt-import.js` now exposes `window.ZapCbtImportUI(root, opts)` (file checks, chosen row, upload + easing extraction progress, busy state, one `beforeunload` guard for any `.cbt-import-ui.is-busy`). The bank-page sheet uses it with the same behaviour. `cbt-center.php` loads it **before** `zap-cbt-bank.js`.
- **CSS:** the import rules are scoped to `.cbt-import-ui` (on `#cbt-import-form` and the create-sheet section) instead of `.cbt-import-sheet`. New `cbt-import-drop--compact` and `.cbt-bank-import` rules sit in the same block.
- **Checks:**
  - New `/tmp/cbt-parity/bi113/run.php`, 19 / 19, on temp tables: CSRF (app + desktop), no file, bad type, > 10 MB, upload error, empty title, bad type value, duplicate, valid input stops at `move_uploaded_file`, desktop shape, forbidden class, student; no rows written; the importer's commit-then-fail case is fully purged; purge removes questions / options / categories; `delete_question_bank` still works.
  - `bi113/render.php` OK: the create sheet with and without import, the edit sheet with no import, and the theory / objective import sheets.
  - Regressions: im112 23 / 0, au111 51 / 0, qb110 cases OK 27, render OK.
  - Browser (app admin, 412 px):
    - Layout; the body scrolls with the footer visible.
    - Client type check; objective facts toggled by Type.
    - A **real n8n create-and-import** of a 3-question `.txt` (~15 s): bank opened with 3 questions, 1 category holding 2 of them, correct answers, 0 "need fixing".
    - The duplicate title showed its error in the sheet, and no bank was created.
    - Creating with no file works as before.
    - The bank-page Import sheet behaves the same.
    - Both throwaway banks were deleted through the app (rows verified gone, no orphan options).

Open items / blockers:

- `connect_to_school_database()` (`admin/includes/functions.php`) interpolates a request-supplied school id and the app ward pages that use it don't check the parent → school link (see 3.1). Outside CBT; CBT pages use `cbt_parent_select_ward_school` instead.

---

## 8. Session log

| Date | Session / agent | Tasks | Notes |
|---|---|---|---|
| 2026-10-04 | Planning (grill) | — | Decisions Q1–Q24 locked; plan written |
| 2026-10-04 | S1 (Phase 0 tracer) | 0.1–0.6 | Added `admin/includes/cbt_{access,question_bank_engine,desktop_render,ajax}.php`; `question_bank.php` listing now via dispatcher; `app/admin/cbt-center.php` + `partials/zap-cbt-helpers.php` + `/* ── zap-app: cbt ── */` CSS; nav tile live. Parity verified (see §7). Next: 1.1 |
| 2026-10-04 | S2 | 1.1 | `cbt_access.php`: context-aware admin set, form/principal/student predicates, page gates, list scopes (banks, exams), class/subclass/subject pickers (prepared), ownership checks for bank/question/exam/admission class, diagnostics + reset permissions. `cbt_qb_list` enforces the banks gate; `cbt-center.php` tabs follow gates. Parity 0 mismatches (see §7). Next: 1.2 (or 1.4 / 1.5, also unblocked) |
| 2026-10-04 | S3 | 1.2 | All remaining `question_bank.php` keys moved to `cbt_question_bank_engine.php` (create, inline edit, duplicate, delete, AI import) + `cbt_desktop_render.php` (class/subject options, duplicate modal, class-change payload) + `cbt_ajax.php` (`$CBT_PAGE` scoping, CSRF on writes, app JSON). Inline handlers removed from the page (CRLF kept). Ownership + type validation added; duplicate now remaps leads. Temp-table parity harness: 61 same / 11 intended / 0 unexpected (see §7). Next: 1.3 (n8n URL to config), or 1.4 / 1.5 |
| 2026-10-04 | S4 | 1.3 | n8n question-extractor URL moved to `n8n_question_extractor_url` in `private/includes/ask_zap.php` (`settings.php` untouched); `question_import_functions.php` reads it via `question_extractor_webhook_url()` → `azp_private_file()` (HTTPS only). No caller changes. Next: 1.4 (`manage_questions.php`) or 1.5 (`manage-exams.php`) |
| 2026-10-04 | S5 | 1.4 | `manage_questions.php` read keys → `cbt_q_list` / `cbt_q_get` / `cbt_ql_list` / `cbt_ql_options` / `cbt_q_import_file` + generated byte-identical renderers; question writes in `drive-ajax.php` (create / edit / delete question, 3 image deletes, category save) → `cbt_q_create` / `cbt_q_update` / `cbt_q_delete` / `cbt_q_clear_image` / `cbt_ql_save` behind a Drive storage adapter, with CSRF + `cbt_can_author_bank`. Inline handlers removed from both files. Parity 165 same / 134 intended / 0 unexpected (see §7). Next: 1.5 (`manage-exams.php`) |
| 2026-10-04 | S6 | 1.5 | New `admin/includes/cbt_exam_engine.php` (list / filters / pickers / topics / bank options / create with automatic selection / get / update / activation / delete, all prepared + gated + ownership-checked); generated byte-identical desktop renderers; `cbt_ajax.php` `manage_exams` branch with CSRF on every write and app JSON shapes. `manage-exams.php` inline handlers replaced by the dispatcher include (CRLF kept, 1810 → 1092 lines). Fixed the admin no-class filter bug; kept legacy selection algorithm. Parity 316 same / 118 intended / 0 unexpected (see §7). Next: 1.6 (`exam-questions.php`), or 1.9 (CBT Center Exams tab, now unblocked) |
| 2026-10-04 | S7 | 1.6 | `exam-questions.php` keys → new `cbt_exam_engine.php` functions (`cbt_exam_scope`, question list, assignable list, bank choices, edit data, assign / unassign; all prepared and gated by `cbt_can_manage_exam`) + generated byte-identical `cbt_desktop_render_eq_*` renderers + `cbt_ajax.php` `exam_questions` branch (CSRF on writes, app JSON). Inline handlers and the exam-name query removed (CRLF kept, 1632 → 1055 lines). Assign now validates class + subject and skips duplicates; the `get_question_bank` SQL injection is closed. Parity 277 same / 176 intended / 0 unexpected (see §7). Next: 1.7 (`cbt-preview.php` / `cbt-print.php`) or 1.8 (desktop admission authoring), both unblocked; 1.9 / 1.10 / 1.13 also open |
| 2026-10-04 | S8 | 1.7 | New `cbt_paper_get` (+ `cbt_paper_header` / `cbt_paper_questions`) in `cbt_exam_engine.php`: one prepared, permission-checked question-paper read for banks, exams and admission exams. New LF `cbt_desktop_render_paper.php` with spliced byte-identical preview / print renderers. `cbt-preview.php` (import key → dispatcher `cbt_preview` route) and `cbt-print.php` switched; inline SQL removed. Admission previews now mark the correct option. Full-page parity 934 same / 866 intended / 0 unexpected (see §7). Next: 1.8 (desktop admission authoring); 1.9 / 1.10 / 1.13 / 1.15 also open |
| 2026-10-04 | S9 | 1.8 | Admission authoring moved onto the engine: `cbt_adm_*` functions appended to `cbt_exam_engine.php` (admission context, all prepared, ownership-gated), new `cbt_marking_engine.php` for admission submissions (list / surveillance / approve), generated byte-identical `cbt_desktop_render_adm_*` renderers (questions-page modals reuse the `eq` ones), and three `cbt_ajax.php` routes with CSRF on writes and app JSON. `admission-exams.php`, `admission-exam-questions.php` and `admission-exam-submissions.php` switched (no SQL left, CRLF kept). Parity 478 same / 238 intended / 0 unexpected (see §7). Next: 1.16 (app admission pages) is now unblocked; 1.9 / 1.10 / 1.13 / 1.15 / M.1 also open |
| 2026-10-04 | S10 | 1.9 | CBT Center Exams tab (session-term filter, status strip, search, exam cards) and To mark tab (per-exam pending counts) + KPI hero row and To mark tab badge. Engine: `cbt_exam_list` rows gained window / session fields; new `cbt_marking_pending_summary` in `cbt_marking_engine.php`. Renderers in `zap-cbt-helpers.php`, CSS in the cbt block. Counts verified against the desktop "Not Marked" rule for 13 personas; desktop exam rows unchanged (see §7). Next: 1.10 (`cbt-question-bank.php`), or 1.13 / 1.15 / 1.16 / M.1 |
| 2026-10-04 | S11 | 1.10 | New `app/admin/cbt-question-bank.php` (Class + Subject toolbar chips, hero + KPIs, Questions / Categories tabs, filter chips, search, sanitized question cards with options and correct answer, "need fixing" detection, Manage bank sheet with Edit details / Delete). CBT Center: bank cards link to it, "New question bank" sheet. Engine: `cbt_qb_update` (atomic, 30-char title check), `cbt_q_bank_options`, `cbt_qb_exam_usage`, richer `cbt_qb_get` / `cbt_q_list` rows; dispatcher `update_question_bank` + encrypted id on app create. Shared `zap_cbt_rich` sanitizer, bank sheet renderer and `scripts/zap-cbt-bank.js`. Desktop output unchanged; 27 / 27 write checks, all banks render (see §7). Next: 1.11 (`zap-cbt-author.js`, question / category sheets), 1.12 import sheet, or 1.13 / 1.15 / 1.16 / M.1 |
| 2026-10-04 | S12 | 1.11 | New `app/admin/scripts/zap-cbt-author.js`: lazy Summernote (slim toolbar B/I/U, sup/sub, lists, image, expand), question sheet (category, marks, lettered options with answer toggle and per-option image), category sheet, card Edit / Delete, Drive image slots with client compression. Writes go through a guarded dispatcher include in `app/admin/google-ajax.php` (`drive_ajax` route, Drive adapter reused). `cbt_q_bank_options` returns `google_drive_id`; sanitizer keeps a safe style subset. 51 / 51 write checks, all banks render, desktop unchanged; browser round trip OK (see §7). Next: 1.12 import sheet, or 1.13 / 1.15 / 1.16 / M.1 |
| 2026-10-04 | S13 | 1.12 | "Import from file" sheet on `cbt-question-bank.php` (`zap_cbt_render_import_sheet` + new `scripts/zap-cbt-import.js`): file picker, client checks, upload + extraction progress, success reload. Dispatcher routes `process_ai_import_questions` from the app bank page to the existing handler; engine returns an `imported` count for a plain app message. Entry points (empty state, Manage bank sheet, toolbar "Import" for non-manager authors) hidden when the n8n URL is not configured. 23 / 23 route checks; real n8n import verified on a throwaway bank, then deleted (see §7). Next: 1.13 (`cbt-exam.php`), or 1.15 / 1.16 / M.1 |
| 2026-10-04 | S14 | 1.13 | New `app/admin/cbt-exam.php` (Class / Subject chips, hero + KPIs + banner, Settings card with Active switch, question cards with bank link and Remove, Manage exam sheet with Edit / Activate / Delete) and `scripts/zap-cbt-exam.js`: exam create sheet (CBT Center "New exam", manual or random from banks) and edit sheet with lazy Summernote instructions, plus the assign sheet (bank type, bank, select-all list). Engine: `cbt_exam_list` `exam_id` filter, richer `cbt_exam_question_list`, new `cbt_exam_question_options`. App pages route `cbt_route=exams` to `manage_exams`. 39 / 39 flow checks, all exams render, desktop parity unchanged, browser round trip create → assign → remove → delete OK (see §7). Next: 1.14 (`cbt-preview.php` app), or 1.15 / 1.16 / M.1 |
| 2026-10-04 | S15 | 1.14 | New `app/admin/cbt-preview.php` (exam / bank / admission exam paper via `cbt_paper_get`) on a shared taking shell: `zap_cbt_render_take_question` + helpers in `zap-cbt-helpers.php`, new `scripts/zap-cbt-take.js` (intro, one question per screen, Prev / Next, local timer, flags, answer-sheet grid, zoom, Student order shuffle, Show answers, local score at Finish) and a `cbt taking shell` CSS block that hides the app chrome. "Preview as a student" hero button on the exam and bank pages. All papers render for 5 personas, students get no answer markers; browser run-through OK (see §7). Next: 1.15 (print bootstrap), or 1.16 / M.1 |
| 2026-10-04 | S16 | 1.15 | App print: new `app/admin/cbt-print.php` + `includes/cbt-app-print-bootstrap.php` (session or signed token, role re-read from DB, `cbt_paper_get` permissions) + `includes/cbt-app-print-token.php` (HMAC-SHA256, 2 h, bound to user / paper / school DB — `encrypt()` is only base64, so the finance-style token was not reused). Desktop print markup moved byte-for-byte into shared `admin/includes/cbt_print_view.php`. "Print paper" `browser:` button on the exam and bank pages (new tab outside the WebView). Desktop 780 / 780 identical, 53 / 53 access checks, app paper = desktop paper 74 / 74, live session + token-only fetch OK (see §7). Next: 1.16 (app admission pages onto the engine), or M.1 |
| 2026-10-04 | S17 | 1.16 | App admission pages (`admission-exams.php`, `admission-exam-questions.php`, `admission-exam-submissions.php`) moved onto the engine via the dispatcher (`$CBT_SURFACE='app'`). No SQL left; lists are server-rendered by new `zap_cbt_render_adm_*` helpers, and pages reload after writes. Engine list gained `approval` / `revised_score`; `approve_score_modal` builds desktop markup only for desktop. New "Preview as an applicant" / "Print paper" hero actions; the hub Exams link follows `cbt_can_manage_admission_class`; preview failure redirect fixed. Questions / submissions pages got the missing hero and search styling. Render 817 / 817, flows 45 / 45, desktop parity unchanged, live checks OK. The assign / approve / surveillance sheets were already unstyled before this change and still need a design pass (see §7). Next: M.1 (desktop marking engine), or 2.1 |
| 2026-10-04 | S18 | D.0 | Added Phase 1D (admission popups redesign) with a tracer. Approve score popup on `admission-exam-submissions.php` moved from the unstyled `md-timeline-sheet` to the app bottom sheet with a shared `.md-adm-sheet` shell (admission tokens + pass / fail colours). Signature element: an editable score figure with steppers over a rail showing the pass mark, the marked score and the live revised score with a pass / below verdict; inline validation; "Approve 3.00" button. Pass mark from new `data-min_pass` on the card button; no engine change. Render 817 / 817, flows 45 / 45, live approve round trip and phone viewport OK (see §7). Next: D.1 (surveillance popup) or D.2 (assign popup); M.1 / 2.1 still open |
| 2026-10-04 | S19 | D.1 | Surveillance popup on `admission-exam-submissions.php` moved to the `.md-adm-sheet` bottom sheet. Two figures (fullscreen exits with pass / fail tone, capture count with cadence). Signature exam reel: time allowed, time used up to submission, one tick per capture, red ticks outside the attempt. Captures listed oldest first with thumbnail (placeholder on failure), clock time, offset into the exam and an Open link. The empty state explains whether surveillance was off. Engine: `cbt_adm_attempt_surveillance` adds the attempt `window`; the app `get_surveillance` adds `timeline` and per-capture `view` / `thumb` / `clock` / `offset`. Desktop output unchanged. surv 15 / 15, render 817 / 817, flows 45 / 45, desktop parity 476 / 238 / 0; live empty and stubbed populated states OK (see §7). A harness slip briefly wrote fixture rows to the empty demo webcam table; removed, and the fixture fixed. Next: D.2 (assign popup); M.1 / 2.1 still open |
| 2026-10-04 | S20 | D.2 | Assign questions popup on `admission-exam-questions.php` moved from the unstyled `md-timeline-sheet` to the `.md-adm-sheet` bottom sheet, reusing the `cbt-assign-sheet` select-all head, rows and count. Signature paper mix: the exam's question count plus a bar of subject shares, with the picked subject highlighted and the pending selection as a striped segment ("+2 → 17"). Subject radio chips with on-paper counts replace the subject select; the bank auto-picks when there is one; the button counts the selection; errors are inline. No engine change. New assign markup check 365 / 365, render 817 / 817, flows 45 / 45; live chain plus stubbed failure / success OK (see §7). Phase 1D complete. Next: M.1 (desktop marking engine) or 2.1 (attempt engine) |
| 2026-10-04 | S21 | M.1 | School submissions moved onto the marking engine: `cbt_submission_{exam_title,list,attempt_get,surveillance,approve}` (prepared, `cbt_can_manage_exam`-gated, attempt scoped to the page's exam) + shared `cbt_marking_check_revised` (admission approve uses it too), spliced byte-identical `cbt_desktop_render_submission_*` renderers, `exam_submissions` dispatcher route with CSRF on writes / modals and app JSON for M.3 (app surveillance builder shared with admission). `exam-submissions.php` has no SQL left (831 → 574 lines). Closed a SQL injection and missing CSRF / ownership on `approve_score`; retired the dead `delete_image` handler, which unlinked a client-supplied path. Parity 483 same / 472 intended / 0 unexpected, full page identical for permitted users, admission regressions unchanged (see §7). Next: M.2 (theory marking path) or M.3 (app `cbt-submissions.php`), or 2.1 |
| 2026-10-04 | S22 | M.2 | Staff theory marking (`cbt-marked-theory.php`) moved onto the marking engine: `cbt_theory_attempt_get`, `cbt_theory_marking_get` (reuses `cbt_paper_header` / `cbt_paper_questions`, answers and images scoped to the latest attempt), `cbt_theory_marking_save` (autosave) and `_finish` (complete: Σ scores → score / revised score), all prepared, `cbt_can_manage_exam`-gated, one transaction per write. New `marked_theory` dispatcher route with CSRF and legacy JSON shapes (+ app JSON for M.4); the page's markup loop is fed by `cbt_desktop_marked_theory_arrays`. Closed: no permission check at all (students could rescore any script), objective exams overwritable, scores above the mark, fatals on bad totals / missing arrays. Additive JS: finish failures now show their message. Parity 176 same / 267 intended / 27 either / 0 unexpected, full pages identical for permitted users; M.1 and admission regressions unchanged (see §7). Next: M.3 (app `cbt-submissions.php`) or M.4 (app `cbt-mark-theory.php`, now unblocked), or 2.1 |
| 2026-10-04 | S23 | M.3 | New app `cbt-submissions.php` (exam context toolbar; hero with a score spread of every marked attempt against the pass mark; Submitted / To mark / Approved strip with `?view=` deep links; search; cards with score figure + pass rail, end condition, time spent, answered count, Approved, fullscreen exits and "Taken in the app" chips) + `scripts/zap-cbt-submissions.js`. Approve / surveillance sheets reuse the admission sheet shells. Theory cards link to `cbt-mark-theory.php` once M.4 adds it. Reset moved onto the engine (`cbt_submission_reset`, dispatcher `reset_attempt`, reset roles only, prepared), because the app cannot reach `webcam-google-drive.php`; app approve does not clear webcam captures. Entry points: exam hero "Submissions · N", CBT Center To mark cards. `client=app` format for 2.6 fixed as `<cond>;client=app`. m3 harness OK, m1 / m2 / cc19 / ex113 / pv114 unchanged, live approve round trip OK (see §7). Next: M.4 (`cbt-mark-theory.php`), then M.5; 2.1 also open |
| 2026-10-04 | S24 | M.4 | New app `cbt-mark-theory.php` (Q23): one answer per screen in the taking shell, student script on ruled paper with a red hand-drawn score ring, score stepper / ticks capped at the mark, red-pen annotation (`contenteditable` + `execCommand`, stored as `corrected_text` through `zap_cbt_rich`), photos and figures in `ZapTimelineLightbox` with an "Open in Google Drive" link, script grid sheet, Next unmarked, completion sheet (out of, pass rail, unmarked warning) and Next script. Runs on the existing `marked_theory` route (app JSON, CSRF); autosave posts every question, holds on invalid scores, beacons on leave. Helpers `zap_cbt_mark_*` / `zap_cbt_render_mark_question`, `scripts/zap-cbt-mark.js`, new zapapp.css block. Submissions Mark script / Re-mark links and the CBT Center note switch on automatically. No engine or schema change. m4 harness OK, m2 / m3 / cc19 unchanged, live check on exams 26 and 23 with writes stubbed (see §7). Next: M.5 (CBT Center To mark tab lists real pending items), or 2.1 |
| 2026-10-05 | S25 | M.5 | CBT Center To mark tab lists each pending script: new `cbt_marking_pending_scripts` (same scope / rule as the summary, oldest first, answered / photo counts). Exam cards hold a queue of script rows (initials, class, answered or blank, photos, app icon, waiting-age pill amber 3 d / red 7 d) linking to `cbt-mark-theory.php?…&from=marking`, "Show N more" past four, "Oldest waiting", "x of y marked" rail, Submissions link; cards ordered by oldest script; banner "Mark oldest"; search matches students. Mark page with `from=marking` returns to the tab and its Next script follows the queue across exams. No schema change, desktop untouched. m5 harness OK (14 personas), cc19 / m3 / m4 OK, live check on the demo script (see §7). Phase 1.5 complete. Next: 2.1 (attempt engine) |
| 2026-10-05 | S26 | 2.1 | New `admin/includes/cbt_attempt_engine.php`: gate (`cbt_attempt_exam`), eligibility + window for new attempts, page data, start, started, objective finish (scoring), theory autosave and finish, all prepared with transactions and row locks. New `cbt_can_act_for_student` (student self, parent→ward, backend) and `parent_id` in `$ctx`. `take` dispatcher route with byte-identical desktop shapes, CSRF on every write (now including `set_start_date`), app JSON for 2.6. `cbt.php` / `cbt-theory.php` render from the engine via `cbt_desktop_take_arrays` (no SQL left); additive failure swals. Closed: an every-option scoring exploit, SQL injection via `cond` / `fullscreen`, attempts silently left unscored on an empty `fullscreen`, a fatal on question-less exams, theory answers rewritable after submission, any logged-in staff acting for any student, attempts on upcoming / inactive / other-class exams. Parity 52 same / 39 intended / 0 unexpected; m1 / m2 / m3 / m4 / m5 / cc19 unchanged (see §7). Next: 2.2 (`save_answer` + server-side scoring from stored rows), 2.3 (`student-exams.php` listing) or 2.4 (review data) |
| 2026-10-05 | S27 | 2.2 | New `save_answer` key on the `take` route (CSRF, both surfaces): one answer per request, objective choice replaced or cleared (legacy duplicates cleaned), theory text upserted, all locked and re-checked in a transaction. Server-side time limit: answers refused past start + duration + 120 s; a late finish scores / keeps only the stored answers and ends as `time_expired_after`. Objective finish now merges: posted answers replace stored ones per question, the rest keep their autosave; scoring counts one option per question. Bulk theory autosave shares the new gate. Desktop `cbt.php` autosaves on each choice / reset (one request in flight per question); `cbt-theory.php` unchanged. a22 parity 50 same / 46 intended / 0 unexpected, 46 / 46 flow checks, JS queue test OK; m1–m5 / cc19 unchanged (see §7). Next: 2.3 (`student-exams.php` listing), 2.4 (review data) or 2.6 (app `cbt-take.php`, now unblocked) |
| 2026-10-05 | S28 | 2.3 | `student-exams.php` listing moved onto the attempt engine: `cbt_student_exam_list` (latest attempt per exam, optional subject / topic filters), `cbt_student_exam_state` (status + action by the desktop rules, for 2.5), score visibility, description, subject / topic pickers and filter names, all prepared and gated by `cbt_can_act_for_student`. New `student_exams` dispatcher route (byte-identical desktop shapes, CSRF as legacy, app JSON) and `cbt_desktop_render_student_exam_*` renderers; the page has no SQL left (650 → 478 lines). Closed: SQL injection via the filters, no parent → ward check (any parent / logged-in staff could list any student), any exam description by id, fatals on a topic-only or undecryptable filter; fixed the malformed parent / backend CORRECTION link. se23 parity 59 same / 24 intended / 0 unexpected (full page + AJAX); a22 / m1–m5 / cc19 unchanged (see §7). Next: 2.4 (review data), 2.5 (app `student-exams.php`, now unblocked) or 2.6 (app `cbt-take.php`) |
| 2026-10-05 | S29 | 2.4 | Script review moved onto the marking engine: `cbt_review_get` (school, objective / theory) and `cbt_adm_review_get` (admission) return the header, student, latest attempt and paper questions with the student's choice, right / wrong / blank and marks awarded (objective) or answer + photos (theory), plus `score_visible`. All prepared. Staff who manage the exam see any attempt; students / parents only when the list offers CORRECTION (view answers on, exam closed, attempt scored); admission is staff only. `cbt-correction.php` / `cbt-correction-theory.php` render from the engine through `cbt_desktop_review_arrays` / `cbt_desktop_marked_theory_arrays` (no SQL left, markup unchanged). Closed: no permission check at all, an older attempt's answers leaking into the latest script. r24 full-page parity 76 same / 381 intended / 12 either / 0 unexpected, 44 / 44 engine checks; m1–m5 / cc19 / a22 / se23 unchanged (see §7). Next: 2.5 (app `student-exams.php`), 2.6 (app `cbt-take.php`) or 2.8 (app `cbt-review.php`, now unblocked) |
| 2026-10-05 | S30 | 2.5 | App `student-exams.php`: hero ticket for the next exam, Open / Upcoming / Done strip driven by `cbt_student_exam_state`, live countdowns (server clock, time left on in-progress attempts), search, Instructions sheet (sanitized). Surveillance exams show "Take this exam on a computer"; only the student can start (parents / backend read-only). Start / Resume / Review links switch on automatically once `cbt-take.php` (2.6) / `cbt-review.php` (2.8) exist; until then portal notes. Hidden scores show "Submitted" (intended). Engine: `class_name` / `other_name` on the student, `exam_description` on list rows (dropped from app JSON). se25 758 checks / 0 failed; se23 / r24 / a22 / m1–m5 / cc19 unchanged; visual check at 390 px via injection (no app student login) (see §7). Next: 2.6 (app `cbt-take.php`), 2.8 (`cbt-review.php`) or 2.9 (nav tile). |
| 2026-10-05 | S31 | 2.6 | New app `cbt-take.php` (students only, no camera exams) on the `take` route: intro / resume, server-driven clock (attempt deadline cut by the exam end), seeded per-student order, prefilled choices and plain-text theory answers, per-answer `save_answer` autosave with retry and beacons, leave counter → `fullscreen` on finish, submit sheet with a bubble tally, "Handed in" done card (score only when released), auto-submit at time up. Engine: `page_get` gains `exam`, theory finish takes optional exits (app only). Desktop submissions show an App tag for `;client=app`. t26 cases 66 / 66 and flow 50 / 50; all regressions unchanged. Live JS check pending (no student login). Next: 2.6 live check, then 2.7 (theory photos) or 2.8 (`cbt-review.php`). |
| 2026-10-05 | S32 | 2.7 (+ 2.6 live check) | Theory photos in app `cbt-take.php`: camera (`capture="environment"`) and gallery inputs, compression on the device (1600 px JPEG), one-at-a-time XHR uploads with a progress ring, Retry and offline re-queue, remove sheet, 6 per answer. New engine `cbt_attempt_photo_add` / `_remove` (gated, locked, Drive cleanup on failure, shared ids kept) and app-only `take_photo` route, reached through a guarded include in `google-ajax.php` (same Drive folder and table as desktop). Opening the camera is not counted as leaving; submit waits for photos. t27 flow 55 / 55 and cases 20 / 20; all regressions unchanged. Live check at 390 px with the network stubbed covered the 2.6 take mode and the 2.7 photo flow; three JS / CSS bugs found and fixed. Desktop theory-image keys still have no permission checks (Phase 4) (see §7). Next: 2.8 (`cbt-review.php`), 2.9 (nav tile) or 2.10 (preview on the real renderer). |
| 2026-10-05 | S33 | 2.8 | New app `cbt-review.php` (read-only, on `cbt_review_get`) + `scripts/zap-cbt-review.js` + `zap_cbt_review_*` helpers + zapapp.css "cbt script review" block. Marked-script look: cover with score ring and pass verdict rail, facts, a per-question strip that jumps to the card, Wrong / Not answered / Lost marks filters; objective cards with hand-drawn ticks / crosses, the right answer circled in red and a wrong choice struck through; theory cards with the teacher's corrected text (Show original), photos in the lightbox and a red score ring. Marks ink in as they scroll into view (none under reduced motion). `student_view_score = 0` hides every mark, total and verdict from students but keeps right / wrong and the right answers. Student Review links switch on automatically; objective submission cards gain "View script". rv28 2680 checks / 0 failed (540 cases, 139 renders); r24 / se25 / m3 / m4 / cc19 unchanged; live check at 390 px on three scripts, two layout fixes (see §7). Next: 2.9 (nav tile + Learners Center card), 2.10 (preview on the real renderer) or 2.11 (photo-only answered counts). |
| 2026-10-05 | S34 | 2.9 | Students get a "My exams" nav tile (after Learners Center) → `student-exams.php`. Learners Center: the student's card gains an exams row, a small hall ticket echoing the student-exams hero (stub count for the tab it opens: green open, amber in progress, ink upcoming, graphite done; next exam name, "Subject · Closes in / Time left / Opens in", camera note, other bucket counts; empty state). New `zap_cbt_lc_exams` / `zap_cbt_render_lc_exams` reuse the student-exams models, so counts and tab match; blocked / foreign students get no row; parents unchanged (3.3). New zapapp.css block. lc29 806 / 0 (every demo student + upcoming / closed / no-exam scenarios), full-page CLI render for 4 students + a parent, nav per role; se25 / rv28 unchanged; live at 390 px by injection (see §7). Next: 2.10 (preview on the real renderer), 2.11 (photo-only answered counts) or 3.1 (parent → ward checks). |
| 2026-10-05 | S35 | 2.10 | `cbt-preview.php` now builds the student's screen from the same pieces as `cbt-take.php`. New shared helpers in `zap-cbt-helpers.php` (`zap_cbt_take_expect`, `zap_cbt_render_take_nav_sheet`, `zap_cbt_render_take_submit_sheet`, `_photo_inputs`, `_zoom`) replace the inline markup on the take page; its output is unchanged apart from whitespace (150 renders compared). Preview gains the real theory photo tray (photos stay on the device and count as answered), Clear my answer, and the student's "Before you start" list for school exams without surveillance. Finish now opens the real submit sheet (tally and blank-question chips), then a done card with a "Preview" stamp, the local score against the exam's pass mark with the rail, Right / Wrong / Left blank rows, and a note when score release is off. Start again fully resets. "Student order" shows on objective papers only (theory is never shuffled for students). Fixed a 2.7 bug: Font Awesome's `.fa` rule made the placeholder cover every theory photo, for students too. pv210 380 pages / 3041 checks / 0 failed; take markup unchanged; pv114 / t26 / t27 / rv28 / lc29 / se25 / m4 unchanged; live at 390 px on exams 23, 29 and 30 (see §7). Next: 2.11 (photo-only answered counts) or 3.1 (parent → ward checks). |
| 2026-10-05 | S36 | 2.11 | Server-side "answered" now matches the app client: a theory question counts when its text has a non-space character or it has a photo (exam questions only, once per question); objective = a stored option. New shared subqueries `cbt_attempt_theory_answered_sql()` / `cbt_attempt_objective_answered_sql()` (fixed SQL on `exam_students AS es`) and `cbt_attempt_theory_answered()`. Used by theory `save_answer`, photo add / remove (additive `answered`), `cbt_marking_pending_scripts` (photo-only scripts no longer read "Blank script") and `cbt_submission_list` (new `answered` + `photos`; `count_options` and desktop output unchanged). App submission cards: theory shows "Answered X of N" + photos; objective (diagnostics) uses distinct answered questions. Review / marking already handled photos. an211 28 / 28; m1 / m2 / a22 / se23 / r24 / rv28 / t26 / t27 / m3 / m4 / m5 / cc19 unchanged (see §7). Phase 2 complete. Next: 3.1 (parent → ward checks). |
| 2026-10-05 | S37 | 3.1 | Parent → ward access in `cbt_access.php`: `cbt_parent_wards` (prepared port of the `exam-wards.php` list: activated wards in a class, by name then id), `cbt_parent_ward_schools` (schools linked in `parent_student_school`) and `cbt_parent_select_ward_school` (switch to a ward's school DB only when the parent is linked to it), with settings table names validated. `cbt_can_act_for_student` (2.1) stays the ownership check. Desktop `exam-wards.php` renders through the new byte-identical `cbt_desktop_render_parent_wards`; no SQL left. Found: the app ward pages' `connect_to_school_database()` takes an unchecked, injectable school id (open item, not CBT). wd31 45 / 45 pages (namesake order intended), 52 / 52 engine checks; se23 / se25 / lc29 / r24 unchanged (see §7). Next: 3.2 (`parent-wards.php?page=cbt` → ward → read-only `student-exams.php`, with `school_id` via `cbt_parent_select_ward_school`). |
| 2026-10-05 | S38 | 3.2 | `parent-wards.php?page=cbt`: one card per activated ward in a class, across every linked school, with Open / Upcoming / Done from `zap_cbt_lc_exams` (same counts and tab as the exams page) and "Check Exams" → `student-exams.php?student_id&school_id&view` (new `zap_cbt_ward_exams_url`). `student-exams.php` and `cbt-review.php` accept a parent's `school_id`, switch only through `cbt_parent_select_ward_school` (before the dispatcher), still check the ward with `cbt_can_act_for_student`, and return to the session DB before the chrome. Review links and back links carry the school; parent redirects / back go to `parent-wards.php?page=cbt`. Parents stay read-only (2.5). pw32 261 / 261 (8 parents incl. a two-school parent, cross-school / unlinked / wrong-school / junk / injection cases, review across schools); se25 758 / 0 and rv28 2680 / 0 after the intended redirect update; lc29 / wd31 unchanged; no live check (no app parent login) (see §7). Next: 3.3 (parent nav tile + Learners Center card). |
| 2026-10-05 | S39 | 3.3 | Parents get an "Online exams" nav tile (after Learners Center) → `parent-wards.php?page=cbt`. Learners Center: every parent ward card gains the 2.9 exams row, built in the ward's own school DB (linked schools from `cbt_parent_ward_schools`, DB restored afterwards) and linking to `student-exams.php?student_id&school_id&view` via `zap_cbt_ward_exams_url`, so counts and tab match the wards page and the exams page. `$lc_exams` is now keyed by school and student, shared with the student row. No new CSS / JS. lc33 195 / 195 (8 parents incl. a two-school parent, 4 students, staff, nav per role); lc29 806 / 0, pw32 261 / 261, se25 758 / 0; no live check (no app parent login) (see §7). Phase 3 complete. Next: 4.1 (grep the engines for `{$` inside SQL strings). |
| 2026-10-05 | S40 | 4.1 | No `{$` left inside SQL in the CBT engines. The only two were the settings table names in `cbt_parent_wards` / `cbt_parent_ward_schools` (`cbt_access.php`). They now go through the new `cbt_access_table_sql()` (whitelisted, backtick-quoted `db`.`table`) and are concatenated. Wider sweep clean: no `query()` calls, no bare `$var` in SQL, every concatenation is a `?` list, a fixed fragment or a literal choice. wd31 45 / 45 and 52 / 52, pw32 261 / 261, lc33 195 / 195 (see §7). Next: 4.2 (CSRF on every write action in `cbt_ajax.php`). |
| 2026-10-05 | S41 | 4.2 | Audit: every write key in every `cbt_ajax.php` handler already needed CSRF; no engine write is reachable outside the dispatcher. The per-handler lists are now one table, `cbt_ajax_csrf_keys()`, used by every handler (the `question_bank` route's own loop is gone). `cbt_ajax_csrf_ok()` is stricter than `Security::csrf_token_is_valid()`: an empty session token never matches, `hash_equals`, same-domain referer as before; `security.php` untouched. New cs42 harness (static key classification + 1,260 bad-token calls over 16 routes × 2 surfaces × 9 cases, no table changes, valid tokens still reach every handler) 1,762 / 1,762, and it catches both deliberate bugs. All parity / flow / render regressions unchanged. Found: `webcam-google-drive.php` attempt resets / webcam clears have CSRF but no role check → new task 4.6 (see §7). Next: 4.3 (remove dead inline handlers), 4.5 or 4.6. |
| 2026-10-05 | S42 | 4.3 | Removed dead code from 14 desktop CBT pages (deletions only, 437 lines). Removed: client calls to keys nothing answers (`view_activity`, `marked_script`, `get_staff_subjects`, `add_lead` ×3, `get_description`), the exam-submissions `#remove_image` click that posted `marked_activity_delete_image` to `drive-ajax.php`, the session → term pickers on both submissions pages, the uncalled `send_app_notifications()` ×2 and `finish_exam()` on `cbt-correction-theory.php`, an unused unscoped bank query on every `question_bank.php` render, and unused `myComparison` / `shuffle_assoc` / `$remove_instruction`. The dispatcher and engines are unchanged; site-wide layout boilerplate is left alone. New dh43 before / after run over ten harnesses (8,520 outputs): identical apart from the removed lines, clock-relative fixture dates / timer seconds, and the tie order of an untouched subject query (equal once parsed) (see §7). Next: 4.5 (desktop theory photos onto the engine), 4.6 (desktop attempt resets onto the engine) or 4.4 (WebView smoke test). |
| 2026-10-05 | S43 | 4.5 | Desktop theory photos now go through the engine: `upload_question_images` / `delete_theory_image` on `drive-ajax.php` route through the dispatcher to `cbt_attempt_photo_add` / `_remove` (new desktop-only `cbt_ajax_theory_photo_desktop`, CSRF table entry `theory_photo_desktop`, legacy response shapes kept). The inline handlers (no checks, interpolated SQL, delete by Drive id across all attempts) and the unused copies in `webcam-google-drive.php` are gone. Desktop now has the app's rules: only the student / their parent / backend, only on the running attempt, JPG / PNG, 5 MB, 6 per answer, delete scoped to the caller's own row. `cbt-theory.php` sends the token, exam and student, checks type and size, and shows failures. New ph45 flow 52 / 52; cs42 1,813 / 0 with the new handler; all parity / flow regressions clean. a22 and r24 now know about intended page edits (r24 had been flagging 4.3's dead-code removal on 26 theory pages) (see §7). Next: 4.6 (desktop attempt resets onto the engine) or 4.4 (WebView smoke test). |
| 2026-10-05 | S44 | 4.6 | Attempt resets and webcam clears are on the engine. Decision (user): captures are cleared after approval everywhere (school + admission, desktop + app). New `attempt_cleanup` dispatcher page on the Drive endpoints (desktop `webcam-google-drive.php`, app `google-ajax.php`): `clear_webcam_images` (school or admission, approved attempts, people who can manage the exam) and `cbt_reset_admission_exam` (new `cbt_adm_attempt_reset`, backend / admin / academic_admin). Desktop school reset now posts `reset_attempt` to its page. The inline handlers (no role check, `is_numeric` ids in SQL) and the reachable `drive-ajax.php` copies are gone. App admission reset / clear now work (they posted to the desktop endpoint); app school approve clears too. Drive refusals keep the row; shared ids keep the file. New rs46 flow 60 / 60; cs42 1,864 / 0; all parity / flow regressions unchanged. Found: webcam capture uploads read client-supplied paths and interpolate ids → new task 4.7 (see §7). Next: 4.7 (capture uploads onto the engine) or 4.4 (WebView smoke test). |
| 2026-10-05 | S45 | 4.7 | Webcam capture uploads are on the engine. All three desktop taking pages queue every capture and post it, after the attempt has started, to `webcam-google-drive.php` → dispatcher page `webcam_capture` (CSRF), 10 per request. School: only the signed-in student, on their own running surveillance attempt. Admission: the applicant's running surveillance attempt (no login exists; see §7), in a school DB from `$school_config['schools']` only. Captures must be JPEG data URIs, decoded in memory (nothing is read from a path or URL). Prepared SQL, a per-attempt cap, device clock correction; a finish during upload deletes the Drive file. Removed the inline handlers in both endpoints (`drive-ajax.php` keeps none); `cbt-theory.php` now posts to `webcam-google-drive.php`. Fixed `admission-cbt.php` sending the applicant id as the exam id, and never sending its school DB. The Drive adapter's upload no longer prints API errors into the JSON; `cbt_marking_webcams_clear` is back to literal SQL. New wc47 flow 84 / 84 and JS queue 54 / 54; cs42 1,915 / 0; a22 / se23 taught the intended page edits (se23 had flagged 4.3's removal since S42); all other regressions unchanged; live refusal-only checks OK (see §7). Phase 4 left: 4.4 (WebView smoke test, needs app logins). |
| 2026-10-05 | S46 | 1.17 | Optional "Import questions from file" on the CBT Center "New question bank" sheet (user: optional picker in the sheet; nothing created if the import fails; bank-page Import unchanged). The app posts the existing `process_ai_question_bank` (one request; the bank is inserted only after n8n succeeds). `cbt_qb_import_file` returns `imported`, and its rollback now purges categories and questions too (new `cbt_qb_purge`, shared with `cbt_qb_delete`): the importer can commit categories and then fail. Shared picker markup `zap_cbt_import_picker` + `window.ZapCbtImportUI` in `zap-cbt-import.js`; import CSS rescoped to `.cbt-import-ui`. New bi113 19 / 19 + render OK; im112 / au111 / qb110 unchanged; browser: real n8n create-and-import, duplicate error, plain create, bank-page Import sheet; throwaway banks deleted (see §7). Phase 4 left: 4.4. |
