---
title: "Changelog"
url: https://docs.growamal.com/en/changelog/
language: en
---

# Changelog

Hand-maintained for Wave 1, reverse-chronological. **A changelog entry is added in the same pull
request as any spec-affecting backend change.**

## Diagnostic sitting sections (WP-STT), 2026-10-15

**No operation added or removed.** Three diagnostic responses gain additive, response-only fields for the student section pills:

- **`sections`** on `StartDiagnosticResponse` (`POST /api/diagnostic-sessions/start`), `StartReDiagnosticResponse` (`POST /api/diagnostic-sessions/start/re-diagnostic`) and `DiagnosticStateResponse` (`GET /api/diagnostic-sessions/{id}/state`): `{ key, total }[]` in form order, where `key` is one of `text1`, `text2`, `words`, `spelling` or `roots`. It is computed from the sitting's pinned form plan. A sitting with no form plan returns `[]`.
- **`state.sectionKey`** on every response that carries `StudentSessionState` (start, re-diagnostic start, state, resume, submit): the current question's section, or `null`.
- Both are presentation only, never a score.

## «إجراء التقييم» — the teacher's assessment page and restarts, 2026-10-14

**Three new operations** (452 → **455 operations**). Migrations `20261014100000_measurement_void` and `20261014200000_skill_snapshot_no_evidence_row`. **Deploy:** run the two migrations; nothing to seed or publish.

- **`getTeacherTaqyeem`** `GET /api/teacher/classes/{classId}/taqyeem?window=` (tag `teacher-views`, DTO `TdvTaqyeem`; TEACHER, the class must be held, not audited). One read for a class and a window (omitted = the class's default window): `windows`, `windowState` (`openTrigger` or null, `canOpen`), the grade's `blueprint` (`status` `ok` / `not_screened` / `legacy_skill_cell` / `catalog_gap` and its `measures`), `summary` (`enrolled`, `complete`, `remaining` per cell), `dayPlan` (per cell: `studentCount`, `minutesPerStudent`, `totalMinutes`) and `students[]` with four cells — `digital`, `oral_text`, `oral_familiar_words`, `oral_pseudowords` — each `{ status, attemptId, at, result, restarts }`. `status` is `done`, `in_progress`, `not_started`, `stopped`, `restarted_pending` or `unavailable`. Counts only, no class percentage; no class login code. 404 for a class the caller does not hold.
- **`voidTaqyeemAttempt`** `POST /api/teacher/classes/{classId}/taqyeem/void` (tag `measurement-void`; TEACHER, `RECORD_TEACHER_ACTION`). Body `{ kind, targetId, reason }`: `kind` `digital_sitting` / `oral_text` / `oral_word_list`, `reason` `student_not_ready` / `technical_interruption` / `started_by_mistake` / `other` (no free text). 201 returns the void (`academicYear` and `evidenceWindow` are the attempt's own, `endedActiveSitting` true when an open digital sitting was ended). The attempt stays stored and stops counting in decisions, trends and reads; 404 out of scope, 409 `already_voided`, 422 `not_a_window_sitting`. The replacement attempt links to its void server-side (`supersedesVoidId`); no client field.
- **`getStudentReadingSheet`** `GET /api/classes/{classId}/students/{studentId}/reading-sheet.pdf?passageId=` (tag `reading-assessment`; the class teacher, the child enrolled in the class). One child's A4 text sheet as `application/pdf`, no QR; it **writes no batch, planned row or blob** (a super-admin bypass is audited, as on every route of the module). 404 out of scope.

**Changed shapes:**
- **`getAdminAuditLog`** `GET /api/admin/audit-log`: `eventType` (and the row `kind`) gains `measurement_void`; void rows carry the optional `voidKind` and `voidReason`, `actorUserId` = the teacher, `targetStudentId`, `targetClassId`, and the id `void:<int>`, which **`getAdminAuditEvent`** `GET /api/admin/audit-log/{id}` also reads.
- **`markOrfError`** `POST /api/orf/assessments/{id}/mark` and **`stopOrfAssessment`** `POST /api/orf/assessments/{id}/stop`: new `409` (reason `voided`) for a voided sitting.
- **`getSkillStatus`** `GET /api/sse/students/{id}/skill-status`: `adrRuleId` and `ssrRuleId` are nullable. Both are null (with `doNotDecideYet` true) only on the no-evidence row written when every attempt of the window was voided; read that skill as not assessed.

See the [API Reference](https://docs.growamal.com/en/api-reference/) for the full shapes.

## Teaser tables dropped, 2026-10-09

**No API change.** Migration `20261009220000_drop_teaser_tables` drops the tables `teaser_item_set`, `teaser_session` and `teaser_lead`, and the enum `TeaserSessionStatus`. They have not been written since the public teaser was retired earlier the same day.

- Adult erasure no longer deletes teaser leads, so its `counts` no longer carry a `teaser_lead` key.
- The rows are not kept. Leads with an email or WhatsApp number are dropped with the rest, with no export.

## Passkey enrollment prompt retired, 2026-10-09

**No operation added or removed (452 operations).** Two fields go, both of which only fed the post-login passkey enrollment prompt the web dropped:

- **`MeResponse.passkey`** (`{ registered, promptSuppressed }`) is no longer returned by `getMe` `GET /api/auth/me`.
- **`passkeyPromptSuppressed`** leaves `UserPreferencesResponse` (`GET` / `PATCH /api/users/me/preferences`) and `PatchUserPreferencesBody`. The body is strict, so a client that still sends the key gets `400`.
- **Migration** `20261009140000_drop_passkey_prompt_suppressed` drops the `user_preferences.passkeyPromptSuppressed` column.
- **Unchanged:** passkey sign-in and management (`/api/auth/webauthn/*`).

## Public teaser retired, demo leftovers removed, 2026-10-09

**Three operations removed** (tag `teaser`): `postTeaserSession` `POST /api/teaser/sessions`, `postTeaserFinish` `POST /api/teaser/sessions/{id}/finish` and `postTeaserLead` `POST /api/teaser/leads` now return `404`. The published total goes from 455 to **452 operations**. The landing page stopped showing the try-a-question widget when "/" moved to the home route, and nothing else called these routes.

- Removed with them: the `teaser_start` rate limit, the `pnpm seed:teaser` script and the "Anonymous Teaser API" guide.
- The `UserProfile` union no longer has a `DEMO_ADMIN` member, and the users routes no longer list `DEMO_ORG_NO_OUTBOUND`. Neither could be returned since the demo tenant was retired on 2026-09-23. The "Demo Tenant" guide is gone too.
- **No migration.** The tables `teaser_item_set`, `teaser_session` and `teaser_lead` stay as they are, unwritten, until a separate decision on their rows. Adult erasure still deletes a `teaser_lead` row that carries the person's address.

## Word-list builder — the oral lists per grade and window, 2026-10-08

**Eight new operations** (tag `admin-word-lists`; true super-admin only; every call writes the super-bypass `audit_event`). No migration.

- **`listAdminWordLists`** `GET /api/admin/word-lists?organizationId=` — every list in scope (global only when the query is absent; global + that organisation when given), all statuses and versions with `assessmentsCount`; `cells` = every (grade × skill × window) the grade rules screen, with `min` / `max` words and `pickedListId` (the list a teacher of that scope is served: the window's list first, then «ALL»; an organisation's list before a global one; then the newest version); `skills` with their teacher labels.
- **`getAdminWordList`** `GET /api/admin/word-lists/{id}` — the list plus `versions` (same `externalId`, same scope).
- **`validateAdminWordList`** `POST /api/admin/word-lists/validate` — dry run with the import's own checks (`validateWordList` + the in-scope duplicate / cross-grade checks, now `checkWordListAgainstScope`): `{ ok, reject?, warnings }`, nothing written.
- **`createAdminWordList`** `POST /api/admin/word-lists` — version 1, `draft`; `publish` (default true) makes it `operational` in the same transaction. `422` with the validate shape on a reject; `409 external_id_taken`.
- **`patchAdminWordList`** `PATCH /api/admin/word-lists/{id}` — a draft in place; an operational list → a **new operational version** with the source retired; a retired list → a new draft version. `ifVersion` → `412 stale`. Response carries `madeVersion`.
- **`publishAdminWordList` / `retireAdminWordList` / `restoreAdminWordList`** `POST /api/admin/word-lists/{id}/publish|retire|restore` — `409 invalid_state | newer_version_operational | another_version_operational`; retire returns `leavesCellEmpty`.
- **Changed:** `pickListsForWindow` (the teacher's `GET /api/word-lists`) ranks an organisation's list before a global one at equal window rank. The admin DTO carries `readingNoteAr`, the teacher's convention note.

## Taqyeem builder — preview and edit the sitting per organisation and school, 2026-10-08

**Two new operations** (`GET /api/admin/taqyeem/preview` and `PUT /api/admin/taqyeem/forms/{formId}/positions`; tag `admin-taqyeem`; true super-admin only). No migration.

- **`previewTaqyeem`** (`organizationId` required, `schoolId?`, `grade` 1–4, `window` BOY|MOY|EOY): the sitting a first-time child would sit in that scope — the cell with its source (`global | org | school`) and kind (`measures | skills | not_screened | unset`), the very plan a session start builds (the picked form, the questions in `formPosition` order, where the break falls, `configReleaseId`), every question in the projection the student receives (`ServedItem`) plus an `authoring` block (the correct answer) and `meta` (`organizationId`, `updatedAt` as the If-Match token, measure, position), the passages, the measures with their counts, and `warnings` (`no_operational_form`, `invalid_cell` with `buildMeasurePlan`'s verbatim reason, `provisional_scoring_table`, `legacy_cell`, `thin_pool`, `alternate_form`, `not_screened`, `unset`). It writes nothing but the super-bypass `audit_event`; `Cache-Control: no-store`. **Option** order in the preview uses a fixed shuffle key (`taqyeem-preview`) and so differs from what a given child sees; the questions and their sequence are identical.
- **`moveTaqyeemFormPositions`** (body `{ moves: [{ itemId, formPosition, ifMatch }] }`, 1–60): reorders a form's questions **inside one measure** (a permutation of that group's own positions only), in one transaction through the content-edit chokepoint (one `item_revision` per moved question, before-image `{ formPosition }`). `412 stale` when a question changed since it was read, `409 concurrent_move`, `422 not_a_permutation | cross_measure | item_not_in_form`, `404` for a form outside the scope. The parked intermediate is positive (`formPosition + 1000000`) because the `item_bank_content_role_fields` check forbids a negative position.
- **Unchanged:** `PATCH /api/items/{id}` and the initial-screen blueprint routes; the page uses them as they are. Internal: `servedItem` / `authoringItem` moved to `items/served-projection.ts` with byte-identical output.

## The class login link is valid for one year, 2026-10-06

**No new operation; no shape change.** `loginCodeExpiresAt` on `GET /api/classes`, `POST /api/classes`,
`GET /api/classes/{classId}/login-cards` and `POST /api/classes/{classId}/login-code/rotate` is now **365 days** after the
code was minted or rotated (was 30), so a link pushed to the lab computers lasts the school year. Class create, roster
import and the year rollover set the expiry from the same constant as a rotation. Migration
`20261006100000_class_code_ttl_365_days` gives the current code of every live class in its organization's active school
year a full year from deploy and moves the column default to 365 days. A code already replaced by a rotation, a deleted
class and a class of a closed year keep their old expiry, so an old link to one of them still ends with `410`. A teacher still renews the link by hand
when it leaks; the teacher panel now shows the end date while it is more than 30 days away.

## Student code is 2 digits and permanent; the class code expires, 2026-10-04

**One new operation; several change shape** (new `POST /api/classes/{classId}/login-code/rotate`; changed `POST /api/auth/student/quickcode`, `GET /api/auth/student/class-preview`, `GET /api/classes`, `GET /api/classes/{classId}/login-cards`, the student-code request and response bodies).

- **Student code.** `quickCode` is now **two digits, 01–99**, unique among a class's active students (a class holds at most 99) and **it never expires**. `quickCodeExpiresAt` is no longer returned or written. The same code is the home-login password. Every existing student is converted by a one-off script (`convert:student-codes-2-digit`); **every printed card must be reprinted**.
- **Class code.** `Class.loginCode` now carries `loginCodeExpiresAt` (30 days; `ClassResponse` and the login-cards response add it). `POST /api/classes/{classId}/login-code/rotate` (`rotateClassLoginCode`; body `{ onlyIfExpired? }`; `MANAGE_CLASS_STUDENTS` or `MANAGE_ORG`; 10 renewals per 15 minutes per user) issues a new code with a fresh expiry and **ends the old link at once**; each renewal writes an `AuthEventLog` row of kind `class_login_code_rotated` (`detail`: `manual` or `expired_on_copy`). `onlyIfExpired` is the copy-time auto-renewal and is a no-op on a live code.
- **Login.** An expired class code is refused like an unknown one (`401 QUICKCODE_INVALID_OR_EXPIRED`). `GET /api/auth/student/class-preview` answers `410 CLASS_CODE_EXPIRED` for it, and the student page shows "this link ended, ask your teacher for the new one". The direct `classId` form of the student login is refused in production. The adult/home login lock is now 10 failures per 15 minutes (was 5).

## Teacher experience v3 — Release 1, 2026-10-01

**No new operation; one changes behaviour.**

- **The flag.** Release 1 deploys `TEACHER_V3_ORGS="*"`: `GET /api/auth/me` returns `features.teacherV3: true` for every teacher in every organisation. No contract change; `""` is the rollback during this release.
- **Parent report.** A TEACHER's `POST /api/parent/pdf/{childId}` writes one `student_data_access_log` row (`export_pdf`, one student, no subject id) before the body; a failed write answers `500` "Export could not be recorded" and no PDF. PARENT and ADMIN callers write nothing.
- **Preferences.** The web app now reads `GET /api/users/me/preferences` once per session start for every adult role, and resets contrast and the reading font to the device default on sign-out. No contract change.

## The training day, 2026-10-01

**One new operation; five change shape** (new `GET /api/practice/students/{studentId}/training-sessions/{trainingSessionId}`; changed `GET /api/practice/students/{studentId}/training-sessions`, `GET /api/reports/students/{id}/sessions/{kind}/{sessionId}`, `GET /api/practice/queues/current/next-item`, `POST /api/practice/queues/current/submit-response`, `GET /api/practice/queues/current/state`; `openapi:check` regenerated, 394 paths). Migrations `20261001310000` and `20261001310100`. **Deploy:** run the migrations, `config:publish`, switch traffic, then `seed-main`. The day rules, the 19 bridge rules and the level policies' bridge columns are also written by the migrations, so the training day works before the seed runs.

- **The training day.** A leveled queue's day is planned once, when it opens (the first next-item or submit of the school day), and stored with its config release and the school's time zone: the day's targets (one by default, chosen by rotation; a second only to fill the day), the number of questions (3 exercises × 4, never more than the child's level can serve), the support-step budget, one closing question and a 17-minute active-time cap. The numbers are the `training_session_rules` `engine_parameters` row and the `training_level_policy` rows (provisional). The training date is the school's local day (Asia/Amman for JO, Asia/Hebron for PS).
- **Items left.** `AssignTrainingResponse.itemCount` (the send) and the student's "items left" now count every training target of the queue, each question once, plus one waiting for its second try. No shape change.
- **Training days list.** `GET /api/practice/students/{studentId}/training-sessions` rows add `id` (the day's id), `targets` (`skillId`, `skillLabelAr`, `levelNumber`, `levelNameAr` per planned target, in plan order, at the level the day opened with), `supportSteps` (support steps fired that day), `exitDone` (the closing question was answered) and `answeredCount` (the questions answered that day: the practice questions and the closing question, one per question, support-step answers not counted). The four are null on a day opened before training levels.
- **One training day.** `GET /api/practice/students/{studentId}/training-sessions/{trainingSessionId}` (`getStudentTrainingDay`) returns `id`, `date`, `timeZone`, `leveled`, `plannedExercises`, `questionsPerExercise`, `targets` (each with `questionsAnswered`, `firstTryCorrect`, `secondTries`, `hintedAnswers`, `supportAnswers`), `supportSteps` (`id`, the target, its level, `supportSkills`, `firedAt`, `answered`, `correct`, oldest first), `exit` (`planned`, `answered`, `correct`, or null), `ended`, `endReason` (`done`, `cap` or `window_closed`, null while open), `feedback` and `feedbackAt`. Reach: a teacher of one of the child's classes or the owner of a support group the child is in; staff organization-wide. An unknown day, another child's day or a child outside the caller's organization is `404`; a child of the organization the teacher does not teach is `403`; a `trainingSessionId` that is not a uuid is `422`.
- **Session detail.** `GET /api/reports/students/{id}/sessions/{kind}/{sessionId}` adds `trainingGroups` on a leveled practice queue: answers grouped by training target (`targetSkillId`, `targetLabelAr`, `levelNameAr` — the level of the target's newest answer), then by `role` (`ordinary`, `retry`, `support`, `exit`, each with `labelAr`, `answered`, `correct`). A support-step answer stays under the target it helped; its own skill is never a row in `skills`. Null for every other session.
- **Practice responses.** The next-item, submit and state responses add `step` (`kind` `target`, `bridge` or `exit`; `startsBridge`; `strategyPromptAr`, or null). The submit's `hint` adds `kind` (`item`, `light_hint`, `clearer_hint` or `generic`) and the submit adds `explanation` (`{ textAr }` after a second wrong try, or null). `training` adds `phase` (`practice`, `exit` or `done`) and `exit` (`planned`, `done`). No student payload carries a training level, a level name, an item pattern, a rule code, a support skill or a rank class.
- **Support steps.** When the child misses two different questions of the same measure and pattern on one day, the child gets a short support step from the `training_bridge_rule` catalog (19 rows, checked at publish), then returns to the target. No step at levels 3 and 4, one step at a time, within the day's budget. Each fired step is an append-only `training_bridge_episode` row, which records the step's introduction prompt (`strategyPromptAr` shows exactly that prompt). Support-step answers never move a level, never count toward the day's questions or the closing question, never raise a training alert and never write evidence or status.
- **Catalog practice queues.** A practice queue from the session library with several sub-skills now serves every sub-skill, not only the first.
- **The web app.** The child sees a short introduction before a support step, a clearer hint, a short explanation after a second wrong try, and «السؤال الأخير» in the step strip; the end-of-session mood check is removed. The teacher's «التدريب» card shows each day's skills and levels, the questions answered («أجاب عن n سؤالًا»), «خطوات مساعدة: n» and «سؤال الختام ✓», and the session detail groups a training day by target, each headed «skill · level». Three new child phrases need recordings (`bridge-intro`, `last-question`, `back-to-training`).

## Training levels and training alerts, 2026-10-01

**Three new operations; three change shape** (new `GET /api/training/students/{studentId}/levels`, `GET /api/training/alerts`, `POST /api/training/alerts/{alertId}/acknowledge`, tag `training-progress`; changed `GET /api/reports/students/{id}/sub-skill-report`, `GET /api/reports/students/{id}/session-results`, `GET /api/reports/students/{id}/sessions/{kind}/{sessionId}`; `openapi:check` regenerated, 393 paths). Migrations `20261001300000`, `20261001300100` and `20261001300200`. **Deploy:** run the migrations, `config:publish`, switch traffic, then `seed-main`. The rule rows are also written by the migrations, so training works before the seed runs.

- **Training levels.** A teacher's «تدريب» send now seeds a leveled queue: the child trains each target (a digital measure keyed by its carrier skill, or a single skill) at a level of the `training_level` catalog (1 تأسيس, 2 تعزيز, 3 تطبيق, 4 إثراء). The level starts from the newest screen this school year (or a deep check completed after it), moves up after at least 80 % correct over the last 8 counted answers across at least 2 training days, and moves down after 2 closed blocks of 8 in a row below 60 %. Counted answers are independent first tries without a hint, not excluded and not rapid. Nothing about levels reaches the student API. The numbers are the `training_progression` `engine_parameters` row (provisional); `leveledTrainingEnabled: false` keeps new queues on the band path.
- **Levels.** `GET /api/training/students/{studentId}/levels` returns `{ studentId, academicYear, levels }`. Each row has `skillId`, `skillLabelAr`, `measureCode`, `level` (`number`, `nameAr`), `since` (a school day, `YYYY-MM-DD`), `startedFrom` (`screen`, `experience_type` or `backtrack`), `lastMove` (`direction` `up`, `down` or `rebaseline`, `at`, `fromLevel`) or null, and `openAlerts` (`alertId`, `kind`, `acknowledged`). The list is empty until a send started leveled training. A child the caller cannot reach is `404`; a persona without `VIEW_OWN_CLASSES` or `RUN_BACKOFFICE` is `403`.
- **Alerts.** `GET /api/training/alerts` takes exactly one of `classId` or `groupId` (else `422 VALIDATION_ERROR`) and `status` (`open`, the default, or `all`). Rows are newest first over the scope's current roster: `alertId`, `studentId`, `fullNameAr`, `skillId`, `skillLabelAr`, `measureCode`, `levelNumber`, `levelNameAr`, `kind`, `titleAr`, `messageAr`, `raisedAt`, `acknowledgedAt`, `status` (`open`, `acknowledged` or `resolved`) and `evidence` (`sessions` as date, counted and correct counts; `daysAtLevel`). `kind` is `no_progress_at_level` (the last 3 ended training days at the level, each with at least 3 counted answers, all below 60 %) or `support_review_suggested` (21 days at level 1 with at least 3 training days, a suggestion only). An alert resolves when the level changes. An unknown or other organization's scope is `404`; a class not held or a group not owned is `403 NOT_CLASS_TEACHER`.
- **Acknowledge.** `POST /api/training/alerts/{alertId}/acknowledge` returns `{ alertId, acknowledgedAt }` and records who saw the alert, once. It changes nothing else: the alert stays open, the level stays and no mastery event is written. `409` with `reason` `already_resolved` (checked first) or `already_acknowledged`; `403` when the teacher no longer reaches the child; `404` for an unknown or other organization's alert.
- **The report's training level.** `MeasureResult.trainingLevel` (in `perSkillStatus[].measure`, `diagnostic[].measure` and the session detail's `measures`) adds `source`: `training` is the child's current stored level for that carrier in the row's school year, `screen` is the level the screen starts training at when no training has started yet.
- **Notifications.** Two new kinds, `training_no_progress` and `training_support_review_suggested` (`action_required`, to the child's class teachers, kept 30 days), with `sourceEntityType` `training_progress_alert` and the alert id as `sourceEntityId`. The payload carries `studentId` (the child's user id as a numeric string, now also for `skill_alert_assigned` and `student_alert_assigned`), `classId`, `studentName`, `alertId`, `skillId`, `skillLabelAr`, `levelNumber`, `levelNameAr` and, on the long stay, `daysAtLevel`. The notification list operations are unchanged.
- **Practice.** A re-served item gets its own two tries (the two-try rule is per serve, no longer per queue). The legacy band cuts come from the `practice_band_routing` row with unchanged values, and the legacy practice pool no longer offers screening form or bridge items.
- **The web app.** The student page shows the «مستويات التدريب» card, the alerts page shows «تنبيهات التدريب» with «اطّلعت», the two notification kinds link to the child's training levels, and the measure row says whether its training level is «حسب التدريب الحالي» or «حسب نتيجة التقييم». See [Using the app: teacher](https://docs.growamal.com/en/using-the-app/teacher/).

## Timed word lists and the Grade 2 oral-reading level, 2026-10-01

**One new operation; four change shape** (new `GET /api/word-lists/{id}/sheet.pdf`; changed `GET /api/word-lists`, `POST /api/word-lists/assessments`, `POST /api/orf/assessments/{id}/stop`, `GET /api/orf/assessments/{id}`; `openapi:check` regenerated, 390 paths). Migrations `20261001400000` (a one-shot data backfill) and `20261001410000`. **Deploy:** run the migrations, `config:publish`, switch traffic, then `seed-main` (the list timing comes only from the seed; until it runs the lists stay untimed and nothing breaks), then run the backfill statements once more for sittings stopped during the switch.

- **Timed lists.** A word list is read for the time its configuration names (60 seconds for both lists today). For a timed list `POST /api/word-lists/assessments` takes `last_word_index` (0-based, the last word reached) and `elapsed_seconds` (1 up to the duration); an untimed list refuses `last_word_index`. Attempted words are the last word plus one, marks past it are dropped, and words correct per minute use the actual seconds, so a child who finishes early is rated on the time they read.
- **The response.** Adds `timed`, `errors`, `wcpm`, `lastWordIndex`, `elapsedSeconds`, `accuracyBand`, `rateBand`, `bandBasis` (`accuracy`, `rate` or `lower_of`) and `provisional`. `band` is now nullable: accuracy and rate are each banded against the oral cut table, the configured basis decides the one `band`, and an empty cut records the read without a band (`projection.skippedReason: not_banded`; nothing enters the skill picture). Only a missing accuracy row falls back to the configured floors.
- **Refusals** (`422`, nothing written): `word_list_timing_required`, `word_list_elapsed_exceeds_duration`, `word_list_last_word_out_of_range`, `word_list_not_timed`, `word_list_grade_mismatch`, and the new `word_list_window_mismatch` (the list belongs to one screening window and the read would be filed under another).
- **Choosing a list.** `GET /api/word-lists` takes `studentId` and then returns only the lists of that child's window (that window's lists first, then lists for every window); `404` or `403` when the child is out of scope. Each list adds `timing` (`timed`, `durationSeconds`), `wordsPerRow` (5 when the list names none) and `assessmentWindow`.
- **The child's sheet.** `GET /api/word-lists/{id}/sheet.pdf` returns the A4 sheet the child reads from: the words in the same rows as the marking grid, right to left, with no numbers. The file name is ASCII in `filename` and Arabic in `filename*`. A printed string that fails the language filter answers `422` with `reason: language_safety_blocked`.
- **Why an oral-reading level applies.** `POST /api/orf/assessments/{id}/stop` adds `rateCut` (`applied`, `withheld_in_window` or `no_norm`, always set) and `provisional`. `withheld_in_window` means the window records the rate without a cut and the level is the accuracy band (`projection.accuracyBand`); today that is Grade 2 at the start and in the middle of the year. `GET /api/orf/assessments/{id}` adds `rateCut` (null until stopped), `accuracyBand` and `provisional`. All three are what the stop recorded, so a later configuration change never relabels an old sitting.
- **Fluency summaries count on-the-spot sittings.** The stop now writes its two `cbm_scores` rows under the window the start resolved, with the grade it resolved for, so the school and class fluency summaries, which read by window, include them. The backfill migration fills the window of earlier rows from the evidence the same stop filed. An unbound offline sitting records its read on the sitting only and writes no `cbm_scores` row (stopping one used to fail).

## A domain with one weak measured skill, 2026-10-01

**No new operations; response shapes unchanged.** The rule rows come only from the seed: run `seed-main` and `config:publish` after the deploy.

- A screening domain where few skills are measured (at most four) and exactly one of them is confirmed weak now reads `isolated_skill_need` through the provisional rule `DDR-PRV-01` (limit `DTH-PRV-01`), where no partner rule matched before and the domain had no status. A rule gap in one domain now defers only that domain, not the whole sitting. The rule stays provisional until the partner answers `Q-SSE-DDR-SOLE-1`.

## The screen by measures: one form, a level per measure, 2026-10-01

**No new operations; eleven change shape** (the four `/api/admin/diagnostic-blueprint` operations, `POST /api/diagnostic-sessions/start`, `POST /api/diagnostic-sessions/start/re-diagnostic`, `GET /api/reports/students/{id}/sub-skill-report`, `GET /api/reports/students/{id}/session-results`, `GET /api/reports/students/{id}/sessions/{kind}/{sessionId}`, `GET /api/parent/children/{childId}/growth`, `POST /api/admin/delivery/dry-run-replay`; `openapi:check` regenerated). Migrations `20261001210000`, `20261001211000`, `20261001220000`. **Deploy:** run the migrations, then `seed-main` (the scoring catalogs, the level rules and four language rows come only from the seed) and `config:publish`.

- **Measure cells.** `PUT /api/admin/diagnostic-blueprint/{grade}/{cycle}` takes `measures` (screen measure codes, in serve order) instead of `subSkillIds`; send exactly one of the two. The cell's `subSkillIds` are derived from the measures' carrier skills. `422` reasons: `duplicate_measure`, `measure_not_in_grade`, `no_digital_measure`, `carrier_conflict`, `catalog_gap`. A cell whose measures no operational screening form can serve publishes with `measureWarnings` (`no_operational_form`), or is `422 no_operational_form` with `strict: true`. A measure scored with a provisional table needs `acknowledgeProvisional: true` (`422 provisional_scoring_table`), and is `422 provisional_not_publishable_globally` at global scope. Cells carry `measures` (null for a skill cell) and `provisional`; the GET adds `measureOptions` and `forms`; audit rows carry `measures`.
- **The sitting.** A measure cell's sitting serves one screening form in its order, with the grade's time cap and, in Grades 3 and 4, `breakBefore: true` after the last question on a passage (kept by `/state` and `/resume` until the next answer). No operational form: `firstItemId: null` with `reason: no_screening_form`. The re-diagnostic start returns the same `reason` and, in a measure window, re-sits the whole cell.
- **Re-diagnostic body.** `POST /api/diagnostic-sessions/start/re-diagnostic` no longer requires `subSkillIds`. A measure window ignores the field and re-sits the whole cell; a skill window without it answers `422 sub_skills_required`.
- **Reports.** New schema `MeasureResult` (`labelAr`, `raw`, `max`, `complete`, `incompleteReason` + `incompleteReasonAr`, `level`, `trainingLevel`, `provisional`, `sessionId`). The student report's `perSkillStatus[].measure` and the session results' `diagnostic[].measure` carry it on a measure's carrier row (null on a skill row). The `READ_COMP` tile gains `composite` (`MeasureComposite`, the comprehension score out of 12, for information only, never in a rollup). The session detail gains `measures`, each with `items` (`MeasureItemReview`: position, pattern, skill, correct or not, the child's answer), never the question text or the correct option.
- **Parent.** `GET /api/parent/children/{childId}/growth` gains `measures: [{ labelAr, statusLabel }]` (`ParentMeasureDTO`): the latest screen's complete measures, each with one of four phrases, no numbers or codes. The parent PDF prints the same lines.
- **Replay.** `POST /api/admin/delivery/dry-run-replay` accepts `pinnedItemBankId` and `formServe` to reproduce a screening form serve.

## Partner content imports, 2026-10-01

**No new operations; seven change shape** (`POST /api/items/import`, `GET /api/items/import`, `GET /api/items/import/{batchId}`, `POST /api/items/import/{batchId}/approve`, `GET /api/items`, `GET /api/items/{id}`, `POST /api/orf/assessments/{id}/stop`; `openapi:check` regenerated). Migrations `20261001200000` through `20261001200800`. **Deploy:** run the migrations, then `seed-main` (the new catalog rows come only from the seed) and `config:publish`.

- **`contentKind` on import.** `POST /api/items/import` takes `contentKind`: `item` (default); `content_bundle`, a training or bridge delivery `{ passages, items }` that runs the passage gate and then the item gate (the ack adds `passageImportBatchId` and `passageCounts`); `screening_form`, one form imported as one unit (a refused row refuses the whole form and nothing is written, answered as `200` with `formRejected: true`; the ack adds `screeningFormId`); `scaffold_prompt`; `word_list`. `sourceDeclared` is required only for the item-bearing kinds (`422` without it).
- **Reports.** `GET /api/items/import/{batchId}` reports item, screening form, scaffold prompt and word list batches (never passage batches): `contentKind`, `sourceDeclared` (null for prompts and lists), and on each row `passageId`, `wordListId` and `scaffoldPromptId` beside `itemBankId`. `rejectReason` gains the contract reasons (`form_item_count_mismatch`, `measure_not_in_grade`, `form_rejected`, the three `prompt_*` and five `word_list_*` reasons, and the rest listed in the schema). `GET /api/items/import` filters by `contentKind` (default `item`).
- **Approval.** A screening form is approved as one unit: `exceptions` answers `422` with `form_is_one_unit`, items without a difficulty estimate are promoted with the form, and the earlier live version of its `form_id` is retired. Word lists and prompts retire the earlier live version of their `list_id` or `prompt_id`. The response adds `retired`.
- **Item rows.** A row with `content_role` (`screening`, `target_training`, `bridge_practice`) carries `measure_code`, `item_pattern`, `form_id`, `form_position`, `training_level` and `bridge_target_skill_id`, checked against the released catalogs; the platform sets `content_purpose` itself. The platform serves a screening question only inside its form, and a bridge item only from the bridge step, which is not live yet.
- **Sign-off and size.** An item-bearing import (`item`, `content_bundle`, `screening_form`) needs `agreementSignoff` (who checked Arabic agreement offline, and when; recorded in the batch report). Without it the answer is `422` with the issue `agreement_signoff_required`, and nothing is written. The decoded file is capped per kind (`item` and `content_bundle` 10 MiB, `screening_form` 2 MiB, `scaffold_prompt` and `word_list` 1 MiB), else `413`.
- **Forms.** The ack of every form carries `formProblems` (`missingPositions`, `duplicatePositions`, `countMismatch`, `measuresNotInGrade`). A form missing a whole measure is refused, and `countMismatch` names the measure with `got: 0`.
- **Scope and versions.** `form_id`, `list_id` and `prompt_id` are unique per organization (null is global), so approving an organization's version never retires the global one. Approving an older version while a newer one is live answers `409` with `newer_version_operational`. The same file sent by another organization is that organization's own import, not an idempotent hit.
- **Authoring view.** `GET /api/items` and `GET /api/items/{id}` with `view=authoring` add `contentRole` and `screeningFormId`. A duplicate of a screening question answers `422` (`form_item_not_duplicable`); a `PATCH` that takes a training or bridge item outside its contract answers `422` with the contract reason.
- **Oral accuracy.** The most specific oral cut row wins. An empty row records the accuracy without a band (`accuracyBand: null` on the stop), and `skippedReason` is `not_banded` when the rate has no band either. Only a missing row falls back to the 95 / 90 thresholds.

## The web app sends training, 2026-10-01

**No new operations.** This is the web client of the «تدريب» operations in the entry below.

- **Where a send starts.** The sessions hub («+ جلسة جديدة» → «تدريب اليوم», then the whole class, a support group or chosen children), the class board, the support-group header, and the student page («تعيين التدريب», through `assign-training`). Staff send from `/admin/training`, always with `onBehalfOfTeacherUserId` (a class's teacher from `GET /api/classes/{classId}/teachers`, or the group's owner).
- **One idempotency key per request body.** Within one dialog open, the web keeps one `X-Idempotency-Key` for each distinct body it sends. Sending the identical body again (a network retry, or «إعادة الإرسال» for a child whose outcome is `failed`) reuses that key, so the server replays the original send instead of opening twice. A changed body (another window, time, target or teacher) gets a new key. `assign-training` follows the same rule, and its `503` with `reason: failed` is resent with the same key.
- **The errors the web names.** `422` `window_already_over` / `window_too_short` / `until_time_required` / `on_behalf_required` / `on_behalf_not_a_teacher`, `409` `empty_target`, `403` `on_behalf_teacher_not_in_scope`. `422` `idempotency_key_reused` shows the calm retry line and forgets that key, so the next try sends a fresh one. `429` `RATE_LIMITED` says when to try again, from `Retry-After` (in whole minutes, a minute when the header is missing or not exposed), never as a countdown. Anything else, including `503` `TRAINING_UNAVAILABLE` (`training_window_catalog_unavailable`, or `failed` on `assign-training`), is one calm retry line. A child whose outcome is `send_closed` (or an `assign-training` `409` `send_closed`) is final: no «إعادة الإرسال», and when every child not opened is `send_closed` the result offers a fresh send under a new key. On close, `409` `already_closed` and `409` `nothing_open` just refresh the roster.
- **Hub rows and the roster.** A training row with `subtype: "personal"` opens `/teacher/sessions/training/{dispatchId}` (it may carry `classId: ""`; the pill shows `classNameAr`, or `supportGroupNameAr` for a group send). Every close time renders as `HH:MM` in the `timeZone` its payload carries (`Asia/Hebron` for a school in Palestine, `Asia/Amman` otherwise); the confirm step's estimate uses the `timeZone` of `GET /api/training/window-options`. The roster types `outcome` as one of the seven stored outcomes or `null`, never `failed`.
- **The window step and the confirm line.** On the school's clock (the window options' `timeZone`), the school-day option is disabled once `schoolDayEndsAt` has passed or fewer than `minMinutes` remain, with its reason, and a disabled default falls back to the lesson. The confirm line reports a send («سيُرسل التدريب إلى N طالبًا حتى HH:MM.»), not a promise that every child opens; the result carries the real split.
- **The hub.** A send that opened training for no child is not a list row (server rule); the web shows its outcome from the send or assign response it already has.
- **The student side.** The practice page never calls the removed seed route: it opens with `GET …/current/state`, shows «لا يوجد تدريب الآن» (with a «الرئيسية» button home) on `404` `training_not_open`, and ends the day calmly on `409` `training_closed` or `windowClosed: true`. A reload serves the question named by `inFlightItemId` again, and a reload after `windowClosed: true` lands on the closed screen. An answer queued offline that the server later refuses with `409` `training_closed` is dropped from the device and reported as `offline_queue_event` with `op: refused_window_closed`.

## Training opens only when a teacher sends it, for a window, 2026-10-01

**Four new operations** (`GET /api/training/window-options`, `POST /api/training/dispatches`, `POST /api/training/dispatches/{dispatchId}/close`, `GET /api/training/dispatches/{dispatchId}/progress`); **one removed** (`POST /api/practice/queues/seed/{diagnosticSessionId}` now answers 404); several shapes change (`openapi:check` regenerated). Migrations `20261001100000`–`20261001100200`. **Deploy:** before the school day — run the migrations, then `config:publish` straight away (the window options are read from the active release), then the rollout (`deploy/cloud-run/DEPLOY.md`).

- **No practice opens on its own.** A teacher sends training to a class, a support group she owns, or chosen children, for a window: this lesson, today until the end of the school day, or until a time she enters (`POST /api/training/dispatches`, optional `X-Idempotency-Key`). Each child's training is built from their own newest assessment this school year. The response lists every child's `outcome` (`opened_new_queue`, `opened_existing_queue`, `trained_today`, `no_assessment_yet`, `no_practiceable_subskills`, `deeper_assessment_pending`, `catalog_session_active`) and is `201` even when none opened. A resend with the same key returns the original send (`200`, `replayed: true`).
- **Windows are configuration.** `GET /api/training/window-options` lists them with their Arabic label, their numbers, the default, and whether a value is provisional. A bad choice is `422` with a `reason`: `unknown_window`, `until_time_required`, `window_already_over`, `window_too_short`.
- **Practice serves only while the window is open.** `GET …/current/next-item` and `GET …/current/state` answer `404` with `reason: training_not_open` outside it. After the window closes, a question already shown may still be answered within a short grace: the submit response has `windowClosed: true` and no next question. Past the grace, a submit answers `409` `training_closed` and records nothing. Today's «كيف كان التدريب؟» is still collected. Sessions from the library are not window-gated.
- **Removed field.** `practiceSeedSessionId` is gone from `GET /api/student/next-action` and `GET /api/student/today-plan`; practice is offered only while a window is open.
- **«تعيين التدريب»** (`POST /api/practice/students/{studentId}/assign-training`) is the one-child send: optional body `{ windowCode?, untilLocalTime?, onBehalfOfTeacherUserId? }`; `201` returns `{ queueId, itemCount, dispatchId, outcome, closesAt }`; a second assign continues the training (`201`, no longer `409 queue_already_active`); the other outcomes are `409` reasons, now including `trained_today` and `catalog_session_active`. `currentTraining` on the training-sessions read gains `closesAt`, `open`, `windowLabelAr` and `openedBy: "staff"`.
- **The sessions hub.** `GET /api/teacher/sessions?kind=training` also lists the sends (`subtype: "personal"`, state `open` or `closed`); the cursor now has three segments (older two-segment cursors still page). `GET /api/training/dispatches/{dispatchId}/progress` is the roster (statuses and counts only); `POST …/close` ends a send early for everyone (`409 already_closed` once ended).
- **Staff on behalf.** Staff (`RUN_BACKOFFICE` / super admin) must name the teacher (`onBehalfOfTeacherUserId`, who must hold the target). Each staff send writes one audit event, `training_activated_on_behalf`.
- **Moving over.** Trainings opened automatically before this release keep today's training until the organisation's next local midnight, then close. A teacher's first send that opens training for a child ends that child's grace sooner.
- **After review.** One child's error no longer fails the whole send: their outcome is `failed` (schema `TrainingSendRecipientOutcome`, with `queue: null`, counted in `counts.notOpened`; never stored, so the roster shows `outcome: null`), and a resend with the same key writes them while the send is open. A send whose window ended, or that was closed, never reopens: a same-key resend then answers `send_closed` for such a child (response-only like `failed`; do not resend, open a new training), and `assign-training` answers `409` `send_closed`. Closing a send that opened nothing is `409` `nothing_open`. A key already used for a different request is `422` `idempotency_key_reused`, on the send and on `assign-training`; `assign-training` accepts `X-Idempotency-Key` and its replay is `200`. Every training time carries the school's `timeZone`: the window options, the send's `window`, the roster's `dispatch`, `currentTraining`, and the `assign-training` response. `GET /api/practice/queues/current/state` names `inFlightItemId` (the question served and not answered yet, or `null`). The hub row gains `supportGroupNameAr` (`classNameAr` is `""` for a group send) and its subtitle shows the time an early-closed send actually ended. A broken window catalog is `503` `TRAINING_UNAVAILABLE` with `reason: training_window_catalog_unavailable`, and an `assign-training` whose row could not be written is `503` `TRAINING_UNAVAILABLE` with `reason: failed`. Both send routes are rate limited: `429` `RATE_LIMITED` past 6 a minute, with a `Retry-After` header.

## Teacher experience v3 — «التعليم المتمايز», «الخطط», skill-group assignment and «مختلط» delivery (S4), 2026-09-30

**5 new operations. `amal-api.yaml` is now 402 paths / 435 operations.**
- In `wp-tdv3.yaml` (tag `teacher-views`): `getTeacherDifferentiation` (`GET /api/teacher/classes/{classId}/differentiation?sort=&domain=`), `getTeacherSkillPage` (`…/differentiation/{subSkillId}`), `getTeacherSkillGroupDetail` (`…/differentiation/{subSkillId}/groups/{groupKey}`) and `listTeacherPlans` (`GET /api/teacher/plans?classId=`).
- In `wp-sbc-be.yaml`: `deliverSessionMixed` (`POST /api/sessions/{id}/deliver-mixed`).

**Changed:**
- `POST /api/sessions/from-blueprint` takes an optional `assignmentContext { subSkillId, groupKeys? }`, `targetStudentIds` and `mediumByStudent`, and its body is now strict (an unknown key → 422).
- A v3 body answers `deliveryPlan`, `targetedCount` and `deduplicated`. A body without the new fields answers byte-for-byte as before.
- Session creation is limited to 30 per teacher per 15 minutes (429 `RATE_LIMITED` + `retryAfter`).
- `POST /api/sessions/{id}/accept` adds `evidenceWindow` and `notEnrolled`.
- `PATCH /api/sessions/{id}/edit` is strict, and refuses dropping the anchor skill on a group-targeted draft (409 `anchor_skill_removed`).
- `distribute-online` / `generate-pdfs` refuse a session planned for another medium (409 `delivery_plan_mismatch`).
- The delivery mode enum gains `mixed`.

**Migrations:** `20261002100700_wp_tdv3_delivery_mode_mixed` (the enum value alone) and `20261002100800_wp_tdv3_session_target` (the target arrays, medium plan, fingerprint, per-recipient medium and resolved window, with 4 CHECKs and 2 indexes). Both are re-dated at the merge onto the synced parent.

- A group pick targets exactly the students of the chosen groups, as the skill page shows them. A stale group refuses the whole write (409 `group_stale`). Recipients at accept are the targets still enrolled; zero never widens to the class.
- The same click twice within 120 s returns the first session (`deduplicated: true`, with its real state).
- «مختلط» freezes each child's medium at accept; `deliver-mixed` serves both media in one action, and retrying it is safe.
- The session fan-out now reads the class's current assessment window and school year (before, it read BOY and could pick up a prior year's row).

## Teacher experience v3 — «التقييمات» backend (S3), 2026-09-30

**4 new operations. `amal-api.yaml` is now 397 paths / 430 operations** — `wp-tdv3.yaml` (tag `teacher-views`): `getTeacherAssessments` (`GET /api/teacher/classes/{classId}/assessments?window=`), `getTeacherAssessmentComparison` (`…/assessments/comparison`), `getTeacherAssessmentDetails` (`…/assessments/details?window=&domain=`), `exportTeacherAssessmentResults` (`…/assessments/export?window=`, `text/csv`). **Changed:** `GET /api/admin/audit-log` gains the kind `student_data_access` (the `student_data_access_log` rows — teacher, principal, manager and admin views and exports of named children, D-R12), the `access:<id>` deep link, three nullable row fields `actorPersona`, `subjectStudentCount`, `targetClassId`, and an `offset` cap of 2000 (422 above); `GET /api/admin/activity` lists the same rows as the security-view source `access`, with their `classId`.

- Window-bound: every figure is one window of this school year; no number combines windows; the comparison is status to status (transition counts), never an average.
- Every share is over assessed students with its counts; R7–R9 carry `dataSufficiency` and the client shows counts only while it is not sufficient (D-R11).
- The export is status words only, written after its `export_csv` access-log row (fail-closed 500), limited to 20 per teacher per 15 minutes (429), capped at `exportMaxRows` (422, never truncated), refused 503 with no body when no language rule set is active.
- A deeper check reads `approved_not_started` until its sitting's first answer, then `in_progress` (D-R14).

## Teacher experience v3 — the learning-support flag (S15), 2026-09-30

**No new operations** (`amal-api.yaml` path and operation counts unchanged). One migration:
`20261002100600_wp_tdv3_learning_support_flag` (`teacher_profile.learningSupport`, default false).

- `GET /api/auth/me` gains `learningSupport` (TEACHER: boolean; every other persona: `null`) and
  `lastTeacherClassId` (the class stored by `PUT /api/users/me/last-class` while the teacher still holds it, else `null`).
- `GET /api/users/{id}` (TEACHER) gains `profile.learningSupport`; `PATCH /api/users/{id}` accepts
  `teacher.learningSupport` (boolean; ADMIN only). The audit row records a change to the teacher's profile
  (`fields: ["teacher"]`), never the setting's name or value. Presentation only: no route's reach or permission
  reads it. Proven two ways: a toggle suite (flag off → on → off) over every read of the two slices a
  teacher uses today (`wp-tdv3`, `wp-support-groups`) shows identical status and body; every other route is guarded
  by a source scan that allows the field in five files only (none of them a scope or permission helper).

## Teacher experience v3 — backend foundations (S2), 2026-09-29

**7 new operations. `amal-api.yaml` is now 393 paths / 426 operations** — `wp-tdv3.yaml` (tag `teacher-views`): `getTeacherClasses`, `getTeacherClassHome`, `getTeacherClassNeeds`, `listTeacherNeeds`, `listTeacherStudents`; plus `putMyLastClass` (`PUT /api/users/me/last-class`) and `putMyTeacherGender` (`PUT /api/users/me/teacher-gender`), both `wp-a3.yaml`. **Changed:** `GET /api/auth/me` gains `features.teacherV3`, `teacherGender` and `timeZone`; `PATCH /api/users/{id}` accepts `teacher.gender` and the admin detail shows it; `POST /api/alerts/{id}/acknowledge` and `/dismiss` answer **409** when the alert is no longer open. Migrations `20261002100000_wp_tdv3_last_teacher_class` (`user_preferences.lastTeacherClassId`, nullable), `20261002100100_bundle_recommendation_current_idx`, `20261002100200_wp_tdv3_target_sub_skill`, `20261002100300_wp_tdv3_cbm_alert_open_series` (run the read-only pre-check in `deploy/cloud-run/DEPLOY.md` first) and `20261002100400_wp_tdv3_cbm_alert_trend_signal_at` and `20261002100500_wp_tdv3_teacher_gender` (`teacher_profile.gender`, nullable) — all after `main`'s `20260929050000_p3c5_snake_case_tables`, which production already runs.

- TEACHER only; 403 only from the permission gate, 404 for every scope miss. The v3 flag (`TEACHER_V3_ORGS`) changes presentation only — every `/api/teacher/*` route answers the same with it on or off.
- Every share is over assessed students; not assessed and under review are counts, never folded into 0.
- Reads that name a child reached only through a support group write one `student_list_view` audit row; a failed audit write fails the read.
- The needs reads take one family or a comma list (`family=plan_monitoring,cbm_alert`), a cursor bound to that set, and on R3b `reach=group` (the caller's support-group caseload). Rows carry `subSkillId`, `subSkillNameAr`, `heldForImplementation` and, by kind, `cbm {alertType, severity}`, `planId` or `activation`.
- **Fix: a stale acknowledge or dismiss rewrote a closed or deferred alert.** Both transitions now act only on an open alert; otherwise 409, nothing written, no teacher action logged.
- **Fix: one CBM alert per probe series.** An open CBM alert now names its series' sub-skill, so a second series of the same child no longer overwrites the first; RTI reads the signal the emitter wrote last (`trendSignalAt`), not the row's last update.
- **A teacher's form of address** (`FEMALE` / `MALE` / not set — the masculine default): set by the teacher (`PUT /api/users/me/teacher-gender`, self only, no audit row) or by an admin of the same organisation (`PATCH /api/users/{id}`; the audit row records a change to the teacher's profile, `fields: ["teacher"]`, never the setting's name or value); returned only on the caller's own `/me` and the admin detail, never in a list; read by no decision. `/me.timeZone` is derived from the organisation's country (JO `Asia/Amman`, PS `Asia/Hebron`).
- Activation stamps the plan's target sub-skill (`bundle_assignment.targetSubSkillId`). The activity intake accepts `alert_action.surface = "needs"` (it refused it, and a refused event drops its whole batch).

## The teacher cheat sheet in the teacher's form of address, 2026-09-27

**No new operations.** One optional query parameter on an existing one (WP-TDV3 S6c; slice `wp-onb-be.yaml`).

- **`GET /api/teacher/help/cheat-sheet.pdf?form=f|m`.** `form` is optional and closed: `f` or `m`; any other value → **422**. Without it the PDF is byte-for-byte the one served before, so existing callers are unchanged. With it, the nine lines that address or name the teacher (six second-person lines, the override label «تعديل المعلّم/المعلّمة للحزمة (خروج)» and the two page titles «دليل التهيئة السريع للمعلّم/للمعلّمة …») use that form. The gate (TEACHER), the scope and the `cheat_sheet_downloaded` event are unchanged.
- **Served teacher text is unchanged.** Help, monitoring, workflow and RTI messages keep their response shapes and stay authored in the feminine; the web renders them in the reader's form.

## Oral texts, reading notes and a stricter content intake, 2026-09-29

**No new operations.** New fields and one new query parameter (`openapi:check` regenerated). Migrations `20261001600000_ci_enum_values` and `20261001600100_ci_oral_text_reading_convention`. **Deploy:** run the migrations; no seed or `config:publish` step.

- **`GET /api/passages`** rows carry `purpose` (`comprehension` | `oral_text`), and the list takes an optional `?purpose=` filter. The filter only narrows the caller's usual scope (global + own-org passages); it never widens it. An oral text is read aloud and scored, never cited by a question: attaching one to a question answers `422` `unknown_passage`.
- **`readingNoteAr`** (string or `null`): the one Arabic line that tells the teacher how the last letter of each word is read, on `GET /api/orf/passages`, `POST /api/orf/assessments/start`, `POST /api/reading-assessments/planned/{id}/start` and `GET /api/word-lists`. `null` when the content names no reading convention. For a grade that has oral texts, `GET /api/orf/passages` lists only those.
- **Import gate** (server command `content:import`, not an API route): oral texts (`content:import oral-texts`) are rejected with `oral_text_invalid` and the field when they break the oral-text rules; passage `textType` must be `narrative` or `informational`; a pseudoword another grade's list uses is `word_list_duplicate`; a letter with both shadda and sukun is refused; a duplicate `metadata.partner_item_id` rejects a screening form, and a re-sent form reports a per-question version diff; `--manifest` reads the partner's delivery manifest; AI-drafted and synthetic batches are never approved from the command line.
- **Difficulty prior:** the formula version is now `v2` (the cognitive-demand term reads the contract's reading-process names). Stored values are not recomputed.
- **Serving:** `CHOOSE_CORRECT_SPELLING` options are never read aloud (the stem still is). Choice options stay shuffled per serve and are graded by option id.
- Guides: the partner contracts are in `content-pipeline/partner-handoff/` (new `13-oral-texts.md`).

## The deeper-check suggestion keeps up with the teacher's marking, 2026-09-27

**No new operations.** `GET /api/backtrack/students/{studentId}` gains `firedSignals`, and the status list gains `withdrawn` (this read never returns a withdrawn suggestion); the approve `409` body is now its own schema, `BacktrackApproveConflict`; `openapi:check` regenerated. Migrations `20261001050000` through `20261001050600` (row checks on two configuration tables, a reason clause on each trigger rule, the new status, the five trigger rules themselves, and the approve-claim time limit). **Deploy:** run the migrations, then `seed-main` and `config:publish`. The trigger rules now arrive with the migrations, so the check can open before `seed-main` runs; `seed-main` is still needed for the oral-rate rows, and `config:publish` refuses a catalog that fails the new checks. On a database the seed has not filled yet, the check of the rules' skill ids waits for `seed-main` (a logged warning) instead of refusing.

- **A suggestion the teacher has not answered updates with each marking.** A word list marked after the oral reading now strengthens its reason (`reasonCode` changes, for example to `SEVERE_TEXT_ACCURACY_WITH_DECODING_SIGNAL`). If a later reading brings accuracy back into the expected range, the suggestion is **withdrawn**: approving or declining the withdrawn one is `409`. If accuracy drops again in the same window, the suggestion comes back. Once the teacher approves or declines, later readings never change it.
- **Only this year's unanswered suggestion holds training back.** The «تدريب» send and «تعيين التدريب» wait (`deeper_assessment_pending`) for a suggestion still unanswered this school year, and for a check whose sitting is open, or that the teacher approved in the last 10 minutes (the approve is still opening it), in any year. A withdrawn suggestion, one left unanswered last school year, or an approval that stopped halfway more than 10 minutes ago no longer holds training back. The 10 minutes is the `backtrack` row of `engine_parameters`.
- **The read shows what holds training back.** `GET /api/backtrack/students/{studentId}` returns first the suggestion that holds the child's training back (an open check of an earlier year included). Only when nothing does, it returns this school year's latest suggestion, or `null` when there is none or it was withdrawn. A suggestion for a newer window withdraws the unanswered one of an older window of the same year.
- **Approving names its refusal.** A `409` from `POST /api/backtrack/{id}/approve` carries a typed `reason`: `not_recommendable`, `recommendation_expired` (a suggestion left from another school year), `approval_in_progress` (another approve is opening it), `session_already_active` or `rediagnostic_too_soon`. The suggestion is claimed before its sitting opens, and the sitting is bound to it in the same step that archives the child's earlier training. If the sitting cannot open, opens with nothing to serve (`noEligibleItem: true`), or fails on the way, the suggestion stays `recommended`, nothing is left open and the child's training is kept. A decline that lands at the same moment closes the sitting that was just opened. An approval that stopped halfway can be approved again after 10 minutes, and an `approved` suggestion can be declined.
- **The reason names every signal.** `reasonAr` now joins the accuracy clause with one clause for each word-list or comprehension signal that fired (it used to name one), and the comprehension clause no longer suggests a cause. The new `firedSignals` lists each rule that fired: `{ ruleId, kind: opens | strengthens, signal, clauseAr, provisional }`, the opening rule first. `provisional` is the value recorded when the suggestion was made. Suggestions from before the deeper-check update keep their old line and an empty list.
- **Safer configuration.** The database refuses a trigger rule missing the fields its source needs, an empty match list, a blank clause, a training start level below 1, an oral-rate rule whose grade band is not `G1` to `G6`, and two catch-all oral-rate rules of one version. `config:publish` also refuses an unknown band or status value, an unknown or inactive skill id, a `measure` that the rule's evidence type never records (an oral-reading rule reading the word-list measure, for example), and two active versions of one rule.
- **Switching the check off is configuration only.** Set every rule that opens the check inactive and run `config:publish`: the release publishes with a logged warning (`config_release.catalog_warning`), no new suggestion opens, and existing suggestions stay as they are. The unanswered ones keep holding training back until declined; `pnpm --filter server backtrack:withdraw-open --org <id>` withdraws them for one organization (it refuses while the check is still on).
- **Two DTO fields.** Every recommendation in the GET, approve and decline responses carries `academicYear` (the row's own school year, for example `"2026-2027"`) and `claimStale` (true only for an `approved` row whose claim expired; approve then re-claims it). Decline documents that it accepts `recommended` and `approved`.
- **The card on the child's page (web).** The card renders whatever the read returns: a suggestion to decide, an `approved` row, an open sitting (from any school year: it still holds training back), a completed readout, or nothing (`null`). Approve is offered on `recommended`, and on an `approved` row only when its claim went stale (`claimStale`); decline on `recommended` and `approved`. A row from an earlier school year than the child's current class (`academicYear`) adds «فحص أعمق مفتوح من عام دراسي سابق — ينهيه الطالب أوّلًا.». Each approve `409` `reason` has its own calm line and the card is read again; `approval_in_progress` reads «يجري فتح الفحص الآن — حدّثي الصفحة بعد لحظة.». With `noEligibleItem: true` (no question to serve, including an all-teacher-scored sequence) the card says «لا توجد أسئلة مناسبة الآن — يبقى الاقتراح قائمًا.» and keeps the approve button.

## When the deeper foundational check is suggested, 2026-09-27

**No new operations.** `GET /api/backtrack/students/{studentId}` changes shape additively: `trigger` and `reasonCode` are now strings instead of fixed lists, and a completed check's `readout` has a new `trainingRecommendation` field (`openapi:check` regenerated). Migration `20261001010000` (one new global config table, one new column on the sequence config). **Deploy:** run the migration, then `seed-main` and `config:publish`.

- **The check is suggested only when oral text reading accuracy is well below expected.** Reading slowly but accurately, weak comprehension on its own, or weak word lists on their own no longer suggest it. `trigger` is now the rule that opened the check (`TEXT_ACCURACY_SEVERE`); older suggestions keep `A` / `B`.
- **Word lists and comprehension explain why.** `reasonCode` is `SEVERE_TEXT_ACCURACY`, or the same with `_WITH_DECODING_SIGNAL` (non-word list), `_WITH_ACCESS_SIGNAL` (voweled real-word list) or `_WITH_COMPREHENSION` (broad comprehension weakness). Show `reasonAr`, not the code. These rules are configuration rows, so the list can grow.
- **Suggestions follow the teacher's marking.** Finishing an oral reading or a word list re-checks the suggestion straight away; nothing that depends on oral reading is suggested before the marking.
- **After the check: a training suggestion.** A completed check with a clear starting point returns `readout.trainingRecommendation` `{ skillId, skillNameAr, level }` — training on that skill from the first level, for the teacher to send. Nothing is started automatically. It is `null` when the check found no starting point or finished before this update.

## Oral-reading rate is judged against the window's cut, 2026-09-27

**No new operations; request and response shapes unchanged** (`openapi:check` regenerated: one field description). Migration `20261001000000` (one new global config table). **Deploy:** run the migration, then `seed-main` and `config:publish` so the two new config rows are live.

- **The rate cut follows the screening window.** `POST /api/orf/assessments/start` now resolves the cut of the window the sitting is filed under (the window open for the child today, else the last completed screen this school year): the start of the year uses the previous grade's end-of-year cut, the middle a provisional cut between the two, the end the grade's own cut. Before, every sitting was judged against the end-of-year cut. A sitting with no window to file under keeps the end-of-year cut, as before.
- **`POST /api/cbm/probes` resolves the cut of the probe's `assessmentWindow`**; a probe without one is unchanged.
- **Where a window has no rate cut** (Grade 2, start and middle of the year: the previous grade has no oral-reading norm), the stop records the rate as a raw number with no verdict and, as configured, still bands reading accuracy: `projection.written` is `true`, `projection.accuracyBand` is set and `projection.rateBand` is `null`. Which grade and window do this is a configuration row, not code; Grade 1 is unchanged. In those windows the reading accuracy and fluency area is decided on accuracy alone, so the reading profile still resolves.

## Reading and matching follow-ups, 2026-09-26

**No new operations; request and response shapes unchanged** (`openapi:check` clean). No migration.

- **A wrong first try on an ordering item no longer returns per-position verdicts.** The student screen brings every card back for the second try, so the verdicts were unused. Matching and multi-blank items still return them; the reveal after the second try is unchanged.
- **Fix: the break after a long passage followed the item's current text.** It now reads the version of the answered and the next item that the child was actually served, so re-editing an item mid-sitting cannot add or remove a break.
- **Question Studio: a new empty pair or extra meaning shows a calm note** («أكمل الصفوف الفارغة أو احذفها.») instead of a red error. Save stays off until the row is filled or removed; the red rules appear only for real mistakes such as a repeated meaning.
- **The studio's matching-key rule is checked against the server's.** The rule lives in one dependency-free server module, and a test runs both on the same cases.

## Rewards follow measured growth only, 2026-09-29

- **A rule change no longer earns a domain reward.** "Domain reached adequate" requires new evidence behind the domain, not only new calculations.
- **A plan completes only when it met its success criteria.** Choosing the "met criteria" exit reason on a plan that never reached the met-criteria review earns nothing.
- **One fluency reward per crossing.** A trend that crosses above the aim line earns one reward; more probes that keep it above earn no more. A later fall and a new crossing earns a new one.

## Every database table is named in snake_case, 2026-09-29

**No new operations; request and response shapes unchanged** (`openapi:check` clean). Migration `20260929050000`. **Deploy:** run the migration and the rollout back to back; any saved SQL that names a quoted PascalCase table must be re-pointed (`deploy/cloud-run/DEPLOY.md`).

- **The last 17 tables that still carried their code names are renamed**, for example `"User"` to `app_user`, `"StudentProfile"` to `student_profile`, `"ClassStudent"` to `class_student` and `"AuthEventLog"` (with its monthly partitions) to `auth_event_log`. Every other table already used this style. Column names do not change.
- **`User` is `app_user`, not `user`.** `user` is a reserved word in SQL: an unquoted `SELECT count(*) FROM user` returns one row, the name of the database role, instead of an error.
- **No data moves.** A rename changes only the catalog; row counts and contents were compared table by table before and after on a seeded database, and sign-in, a diagnostic sitting and the nightly jobs were run on the renamed one.

## Sign-in links and codes stored only as hashes; one principal pointer per school, 2026-09-29

**No new operations.** `GET /api/principals/{id}` and a principal's `GET /api/users/{id}` profile may now return `schoolId: null` (a principal who was deleted or erased no longer holds a school); every other shape is unchanged, and the school list still returns `principalUserId` (`openapi:check` regenerated). Migrations `20260929040000`–`20260929043000`. **Deploy:** run the read-only principal pre-check before migrating and the config-publish job after it (`deploy/cloud-run/DEPLOY.md`).

- **Every single-use sign-in value is stored only as a hash.** Password-reset links, magic sign-in links, passkey challenges, the Google sign-in state and the Google sign-in handoff code now live in one table and only their SHA-256 is kept. Before, the passkey `challengeId`, the Google `state` and the handoff `?code=` were stored as they were sent, so a copy of the database could finish someone's sign-in within the few minutes they were valid. Each value is still single-use and short-lived, and a value of one kind is refused as another. Links and codes issued before the update keep working after it.
- **Fix: a roster re-import could not move a principal to another school.** The import refused the whole batch as out of date. Each principal now has one school pointer; the move releases the old school.
- **One principal per school is enforced by the database on the principal's own record**, which the access checks always read. Deleting or erasing a principal releases the school; the principal's record (tier) stays.
- **No change for users:** the unused role table and the empty manager profile table are gone. A user's role is their persona, and a manager's school scope is kept as before.

## XP, badges and leaderboards now fill from real progress, 2026-09-29

**No new operations; request and response shapes unchanged** (`openapi:check` clean). No migration.

- **Fix: every student had zero XP and every leaderboard showed zeros.** The measured moments that earn a mastery event were never recorded. They are now, in the same database transaction as the decision itself:
  - a skill moving from «متابعة» to «متمكّن» on new evidence (`subskill_state_up`);
  - a skill improving from a weakness to «متابعة» (`skill_status_transition`);
  - a domain reaching «متمكّن» (`domain_status_escalation`);
  - a fluency trend above the goal line (`cbm_trend_above_goal`);
  - a practice difficulty level going up (`band_progression`);
  - a support plan that ends because it met its criteria (`bundle_completion`).
- **Never celebrated.** A first assessment (nothing to grow from yet), a decision that is still undecided or whose domain is only partly assessed, a trend window with an acute drop, a plan ended for another reason, and a re-decision over the same evidence mint nothing. Each moment is recorded once: repeating the same decision adds nothing.
- **Not recorded yet, on purpose.** A "bundle step met its goal" (plans have no steps in Wave 1) and a scaffold-level step-up (it only changes by a teacher override, not by a measurement).
- **Leaderboards** for a class, a teacher's classes and a school are read from these events when requested. Students still have no leaderboard.

## Rapid answers are detected from answer times, 2026-09-29

**No new operations; request and response shapes unchanged.** Migration `20260929020000_p3c2_rbm_detector`. **Deploy:** run the config-publish job after the migration and before the rollout (`deploy/cloud-run/DEPLOY.md`), or the new decision code stops on a release without the new threshold.

- **The behaviour flags now come from real answers.** An answer given in under 300 ms (time on the question minus paused time) is recorded once as a rapid answer (RBM-01), linked to that answer. When at least 40 % of a skill's timed answers in one sitting are rapid, the sitting is also recorded once as possible rapid guessing (RBM-02). Paper answers and answers with no measured time are never flagged. Before, nothing recorded these flags, so they never affected a decision.
- **What a flag changes.** A single rapid answer is information only: the status stays the same and the skill status snapshot lists the flag. A rapid rate on a weak gateway or Core skill does not confirm the weakness and opens no follow-up mini-set. The skill goes to a short validity review instead. A flag never changes a score and never creates a weakness.
- **Thresholds are configuration.** The 300 ms floor is the new threshold `TH-RAPID-ITEM-01` and the 40 % rate is `TH-RAPID-SS-01` (it had no value). Both are read from the active config release. The 300 ms floor is a working default until the partner confirms a number. The practice error-pattern engine uses the same floor instead of its own copy.
- **Re-running a decision does not duplicate flags.** Each flag is stored once per answer (or sitting), rule and threshold set. A changed threshold records new flags after it is published, and decisions read only the flags recorded under the current thresholds.

## Live lessons open their activities; unused tables removed; domain names follow the skills taxonomy, 2026-09-29

**Removed operations:** the personalized-projector routes (`POST .../personalized-projector/setup`, `GET .../answer-key`, `POST .../mark`, `POST .../commit`), `POST /api/sync/sessions/{id}/observation` and `GET /api/skill-dependencies`; `POST /api/sync/sessions` no longer takes `personalizedProjector`, the sync summary no longer lists observations, and `GET /api/sub-skills/{id}` no longer embeds `dependencies`. The web app called none of them (`openapi:check` regenerated). Migrations `20260929030000`–`20260929033000`.

- **Fix: students could not answer in a live lesson.** Starting a lesson now opens its first activity and each «next» opens the following one, and the poll stream names the activity each device answers. Before, no activity was ever opened, so the student screen had nothing to submit to.
- **The personalized projector and read-aloud observations move to Wave 2.** Their routes and three tables are removed.
- **Tables nobody used are gone:** group activity templates, session quality metadata, the always-empty skill-dependency map, leaderboard snapshots (leaderboards are counted when they are read), the error-category and evidence-rule catalogs (the 13 error categories are now checked on the micro-pattern table).
- **Warm-up readiness data is stored on the warm-up session itself.** It is written once, when the warm-up completes, and cannot be changed afterwards.
- **One table for engine settings.** The fidelity cuts, the off-track weeks, the fluency trend floor, the session recommender weights and the class-need shares now live in one versioned `engine_parameters` table. Values are unchanged. **Deploy:** run `pnpm config:publish` after `migrate deploy`.
- **Domain names match the skills taxonomy.** The principal and manager domain gauges now use the same Arabic domain names as the rest of the platform (for example «دقة وطلاقة القراءة» instead of «الطلاقة والدقة»). The earlier labels were sent to the partner for confirmation.
- **The skill-relationship map covers all 80 skills.** The two skills added after the partner's map (VOCAB-C06, MORPH-C01) are marked as not yet mapped by the partner. No relationship was invented for them.

## Question Studio authors word ↔ meaning matching, 2026-09-26

**No new operations; request and response shapes unchanged** (`openapi:check` clean). No migration.

- **New production template `COMPREHENSION_WORD_MATCHING`** (`productionKind {kind: "match_pairs"}`), pinned to the
  item type of the same name, in `GET /api/templates`. It needs a linked passage and a valid pairs key: the new
  requirement `needsMatchPairs` runs the same rule as the import gate and `PATCH /api/items/{id}` (2–6 pairs, distinct
  words and meanings, extra meanings distinct from the real ones, at most 8 meanings). A row that fails it cannot take
  the template (`POST /api/items/preview` and the PATCH answer 422 with the Arabic reason). The template is identity on
  serving: the item type already serves the `match_pairs` board. The explorer's match_pairs templates
  (`PAIR_DRAG_COLUMNS`, `DRAG_MATCH_BOARD`, `MATCH_LETTER_FORMS`, `RHYME_MATCH`) stay preview-only prototypes.
- The type is a fixed-derivation type: a production choice / multi-select template cannot be assigned to it, and the
  template backfill assigns only its own template.
- Question Studio edits the pairs and the extra meanings, blocks Save on the same rules, and grades the student's
  board in the preview. **Deploy:** run `seed-main` once so the template row (and P8's item-type row) reaches the
  catalog tables.

## Decisions read this school year's verdict; backdated probes keep their year; LMS key errors are a 503, 2026-09-28

**Three routes gain a documented 503** (`openapi:check` regenerated); no other request or response shape changes.

- **Fix: a tier move could use last year's monitoring verdict.** The tier-movement evidence, the RTI monitoring signal, the fidelity growth signal, the activation guardrail and the data-room dossier now read only the verdict of the student's current school year. Before, early in a new year they read last June's verdict next to this year's (still empty) probes. No verdict yet this year counts as "not decided yet".
- **Fix: a probe entered late was stamped with the new school year.** A progress-monitoring probe, fluency (ORF) reading or word-list reading dated before a promotion now keeps the school year of the class the student was in at that time, so it no longer leaks into the new year's first decision.
- **A missing LMS encryption key is reported clearly.** `POST /api/lms/connections/oneroster/upload`, `GET /api/lms/{provider}/courses` and `POST /api/lms/preview` answer `503 SSO_NOT_CONFIGURED` instead of a generic 500, and store nothing. A sync that cannot open the grant records a failed run (`token_key_missing`) and keeps the connection active.
- **Tier decision history has a stable order.** Decisions made in the same instant are listed newest first by the order they were recorded, the same way every time.

## Timestamps with time zones, one salted IP hash, LMS tokens encrypted, 2026-09-28

**No new operations; request and response shapes unchanged** (`openapi:check` clean). Migrations `20260928030000_p3b3_timestamptz`, `20260928031000_p3b3_ip_hash_single_helper` and `20260928032000_p3b3_lms_tokens_encrypted`.

- **Every stored time is an instant.** 62 columns on users, sessions, sign-in tokens, the auth event log, organisations, schools, classes, enrolments, LMS and teaser tables had no time zone. They are now `timestamptz`, converted as UTC, so every stored moment is unchanged. Before, a database session in another time zone read them shifted by its offset. The auth event log was rebuilt with the same monthly partitions.
- **Stored IP addresses are always salted hashes.** The auth event log and magic-link tokens kept an unsalted hash that can be reversed to the address, and the security audit kept the raw address. All of them now store the same salted hash as the contact form and the teaser. The salt is derived from `AUTH_JWT_SECRET` and is never the signing key itself. The old values were cleared, because they cannot be salted afterwards.
- **LMS connection tokens are encrypted.** Google Classroom access and refresh tokens and the OneRoster file set are encrypted with AES-256-GCM using the new key `LMS_TOKEN_KEY`. The database refuses a plain-text token. Without the key, connecting an LMS answers 503 and stores nothing. A connection that held plain-text tokens was reset to `revoked`, so the admin reconnects it. **Deploy:** create the secret `lms-token-key` before deploying (`deploy/cloud-run/DEPLOY.md`).

## Progress monitoring and fluency kept per school year; tier decisions record their movement, 2026-09-28

**No new operations.** Two responses gain fields and one request field is narrowed (`openapi:check` regenerated). Migration `20260928010000_p3b1_year_window_tier`.

- **Fix: a plan that ran across the summer decided the new year from last year's probes.** Monitoring evidence, monitoring verdicts and acute-regression alerts now carry the school year (for example `2026-2027`), taken from the student's class. A verdict reads only its own year's evidence, and a new year's recompute is a new verdict instead of last year's being returned as "already decided".
- **Fix: a new year's regression re-used last year's open alert.** An acute-regression alert follows the student into a new class, so a regression in the new year found last year's still-open alert for the same window and did not raise its own. One open alert is now kept per student, class, school year and window. Gates that ask "is any alert still open" are unchanged: an unreviewed alert from an earlier year still means review first.
- **Fix: the fluency summary could show last year's reading as this year's.** ORF probes (`cbm_scores`) carry their school year, and the class and school fluency summaries read only the probes of each class's own year.
- **`POST /api/monitoring/evidence` accepts only `BOY`, `MOY` or `EOY` in `window`** (any other text was stored as-is before; it is now a 400). The stored windows are the typed window values.
- **`GET /api/monitoring/alerts`** returns `academicYear` for each alert, and `assessmentWindowId` is `BOY`, `MOY`, `EOY` or null.
- **Tier decisions record the movement they made.** `GET /api/students/{id}/tier-decisions` returns `fromTier` and `toTier` for each decision, and the decision history in the data room shows them. They are written with the tier change itself, so an escalation at the top tier (no move) is no longer indistinguishable from a real move. The database refuses a new decision without them or with a movement that does not match its decision type.
- **Skill, domain and macro-domain snapshots must carry a school year.** The empty-string default is gone and the year format is checked.
- **Existing rows** were given the school year of the class the student was in when the row was written (the same rule as the 2026-09-26 change). Earlier tier decisions got `fromTier` and `toTier` by replaying each student's decisions in order; where the replay does not end at the student's current tier, they stay empty rather than guessed. A stored window that is not `BOY`, `MOY` or `EOY` stops the migration and names the value.

## Real references, audit logs locked by the database, one dialect vocabulary, 2026-09-28

**No new operations; response shapes unchanged** (`openapi:check` clean). Migrations `20260928020000_p3b2_soft_ref_fks`, `20260928020100_p3b2_append_only_triggers` and `20260928020200_p3b2_controlled_vocabularies`.

- **43 references that were plain ids are now foreign keys** (live-lesson sessions, participants, groups, runs and responses; principal requests; monitoring verdicts, fidelity snapshots, tier decisions, cluster pins, generated sessions, blueprints, assembly runs; oral-reading sittings and passages; evidence rows, trigger snapshots, XP reversals and the item-bank pointers). A row naming a student, session, plan or passage that does not exist, or another organization's school or live session, is refused.
- **Each ORF score row now points at the reading sitting it came from** (`cbm_scores.oralReadingAssessmentId`). Existing rows were linked by the migration.
- **`POST /api/sync/sessions/{id}/responses` answers 404 when `activityRunId` is not a run of that session.** It used to store any string.
- **22 more tables are append-only by database trigger** (auth and security logs, item and passage revisions, monitoring plans, program-modification markers, session logs, platform alerts, warm-up events, assembly runs, access and onboarding logs, the blob registry …). The erasure path opens the maintenance window only around the rows it scrubs.
- **One dialect vocabulary.** A student's home dialect is stored as `msa` / `levantine`, the values items and delivery already use. The users and roster-CSV API still accept and return `MSA` / `LEV`.
- **Item import checks `dialect_tags` and `evidence_type`.** Dialect tags must be `msa`, `levantine` or `lev` (any case). The country markers `PS` and `JO` are dropped. Any other tag rejects the row as `missing_metadata`, and so does an `evidence_type` that is not an evidence type. Stored items were converted: `["msa","PS"]` became `["msa"]`, and the six partner items that had `audio` as their evidence type now have none.

## Student journey v2 — reading beside its questions, word↔meaning matching, feedback part by part (2026-09-26)

Partner board D-02 (2026-09-26) and Mohammad's four decisions the same day. **No new operations.** Migrations
`20260928040000_practice_opened_by` and `20260928040100_comprehension_word_matching`.

- **New item type `COMPREHENSION_WORD_MATCHING`** (scoring strategy `pair_mapping`): the child drags each word to its
  meaning. The key is `interactionKey = {kind: "match_pairs", pairs: [{l, r}], extraRights?: [...]}` in text space
  (2–6 pairs, distinct words and meanings, extra meanings distinct from the real ones, 8 meanings at most). The import
  gate and `PATCH /api/items/{id}` validate and store it canonical; like every `COMPREHENSION_*` row it needs a
  passage, a reading process and an answer location. `GET /api/items/{id}?view=served` serves
  `interaction = {kind: "match_pairs", left, right}`: the words in authored order and the meanings in a seeded order
  that is stable per sitting and never aligned with the words. The answer is
  `{v: 1, kind: "match_pairs", pairs: [{l, r}]}`; it grades all-or-nothing (θ stays dichotomous).
- **Training feedback part by part** (`POST /api/practice/queues/current/submit-response`): the response gains
  `parts: [{part, correct}]` after a first wrong try on a multi-part item (a matching pair: part = the word; a
  multi-gap cloze slot: part = the 0-based slot; an ordering position: part = the 0-based position) — verdicts only,
  no answer. After a second wrong try `correctAnswer.parts: [{part, answer}]` lists only the parts still wrong.
  Both are `null` for single-answer items. The stored verdict and θ stay item-level.
- **The deeper sitting breaks only after a long passage:** the session state's `partSize` is replaced by
  `breakBefore: boolean` — `true` on the question that follows a reading passage of 80 words or more.
- **Which training is open:** `GET /api/practice/students/{id}/training-sessions` += `currentTraining`
  (`{openedAt, openedBy: "automatic" | "teacher"} | null`), from the new `student_session_practice.openedByUserId`
  (set when a teacher uses «تعيين التدريب»).

## Decision rules read from typed columns; expired sessions and rate-limit rows purged, 2026-09-27

**No new operations; request and response shapes unchanged** (`openapi:check` clean). Migrations `20260927030000_p3a3_typed_rule_columns` and `20260927030100_p3a3_drop_duplicate_auth_indexes`.

- **The skill-status rules no longer depend on JSON in a notes field.** The governance flag of ADR-GOV-01, the pattern OR-sets of the ADR / PAC / SSR rules, the CIS scope, maximum items and third-item condition, and the rule ids of the pattern, domain-status and macro-mapping rows are now typed columns. Before, a notes value that did not parse was treated as empty, so ADR-GOV-01 could silently become an ordinary rule. Notes are documentation only.
- **The profile rules are validated, not guessed.** Each rule's cross-domain pattern is a validated JSON column, and the anti-overlap decision, the grade list, the allowed modifiers and the resolved confidence are typed columns. An unknown confidence phrase used to become `moderate`, and a pattern that did not parse made the rule match nothing (or, for a primary rule, everyone). The seed now rejects such a row.
- **A missing or malformed rule value stops the recompute** with a named error (`SseRuleValueError`, `ProfileCatalogValueError`) instead of a default. Publishing a config release also refuses an invalid pattern. Releases published before this change still replay: their stored rows are converted on read, and malformed old content is an error.
- **Existing rows were converted by the migration** from the old text. The loaded rule sets are byte-identical to before, so every decision is unchanged.
- **The nightly `auth:purge` now deletes sessions that expired more than `AUTH_PURGE_INACTIVE_SESSION_DAYS` ago even if nobody logged out** (before, only revoked sessions were deleted, so their token hashes stayed forever). It also removes rate-limit rows that have been quiet past every limiter's window and are not locked. These rows are keyed by the email or IP someone typed, so the table grew without bound. The run reports a new `rateLimitWindow` count.
- Two indexes that duplicated the unique index on the same column were dropped: `Session.token` and `User.email`.

## RTI decision chain: signals that were read but never written, 2026-09-27

**No new operations; response shapes unchanged** (`openapi:check` clean). Several fields now carry a value where they were always empty. Migrations `20260927020000` through `20260927025000`.

- **A teacher's "confirm and escalate" now reaches the reviewer.** `POST /api/workflow/review` with `dispositionType: escalated` opens a tier-review request in the same queue the principal and approver read (`tier_review_request`, with a decision packet). `tierReviewRequestId` in the response is that request's id. If the child already has a live review, the escalation links to it. The old `teacher_review_request` table, which nothing read, is dropped.
- **A bundle session is refused while an acute regression is open.** The session assembler's gate now reads the open acute-regression alert (the one the teacher reviews). Before, it read an alert kind nothing ever wrote, so it never refused. The alert kinds `acute_regression_review` and `tier_review_due` (never written) are removed from `rti_alert`.
- **An exited plan shows its exit reason.** The principal's plan history (`GET /api/principal/students/{id}/plan?includeHistory=true`) returns `exitReasonCode` and its label from the plan's exit transition. Nine `bundle_assignment` columns that were never written are dropped. The student report's `activePlan.objectType` now comes from the bundle definition, so it is filled in.
- **The "data room required" class signal works.** A class counts a student when the student has an open tier-review or maintenance-review decision packet (draft or under review). Before, it read a column nothing wrote, so the signal never fired.
- **The tier badge's recommended support level is filled in.** Every tier decision sets `recommendedSupportIntensity` to match the resulting tier (`universal`, `targeted` or `intensive`). Existing rows were backfilled.
- **Monitoring verdicts are no longer duplicated.** Recomputing with the same evidence, recipe and rules returns the stored verdict instead of writing a new one. New evidence adds one new verdict. Recording one evidence row now writes one verdict, not two.
- **Fidelity and tracking settings are real configuration.** The fidelity cut-offs (0.80 / 0.60 / 0.40) and the tracking settings (off track after 3 weeks, a 6-week planning cycle, still owed by the partner as Q-FID-CYCLE) are now seeded rows in every config release. The loaders refuse to run without them instead of using hidden defaults. **Deploy:** run the config-publish job after the migrations.

## Each school sets when its school year starts; screening waits for the rollover, 2026-09-26

**Changed operations:** `POST /api/schools`, `PATCH /api/schools/{id}` and every school response carry `schoolYearStartsOn`; `GET /api/organizations/{orgId}/school-years` and the promotion response carry `rolloverDue` + `rolloverDueSchoolIds`; `POST /api/diagnostic-sessions/start` can answer 409 `year_not_rolled_over`; the student today-plan's `screeningState` and the ORF / word-list `projection.skippedReason` gain `year_not_rolled_over` (`openapi:check` clean). Migration `20260928050000_school_year_start_per_school`.

- **One school-year rule.** Screening counted a school year from 1 August while the `school_year` rows ran from 1 September, so during August the student side and the year rows disagreed about which year it was (a June end-of-year screen stopped counting on 1 August). A school year now starts on **1 September** everywhere unless the school sets its own start.
- **`schoolYearStartsOn`** on a school: `"MM-DD"` between `07-01` and `10-31` on the organisation's local calendar, `null` = 1 September. It decides "this school year" for screening (one screen per window per year) — a school that opens in late August (Jordan's public schools opened on 23 August 2026) should set it, otherwise its August screens count toward the previous year. A bad value is a 422.
- **Fix: a screen taken before the rollover was filed under last year.** When a screening window is open but the student's class still names a school year that is over by its school's start day, the screen is neither offered nor started (`year_not_rolled_over`, 409 on start — also when a teacher or admin names the window), and a teacher-scored oral reading or word list is saved but not added to the skill picture (`skippedReason: year_not_rolled_over`); the child sees the same calm waiting board as for any other "nothing to do today". Before, the sitting was stored under last year's label and, after the rollover, looked like the student had already been screened.
- **`rolloverDue`** on the active school year is true once any of the organisation's schools has started its next year; `rolloverDueSchoolIds` names them. Promote the year (`POST …/school-years/{yearId}/promote`) to reopen screening.
- The `startsOn` / `endsOn` of a `school_year` row stay the default 1 September → 31 August and are for display; a student with no class falls back to the default start on the local calendar (it was 1 August, in UTC).

## Screening evidence and profiles are kept per school year, 2026-09-26

**No new operations; request and response shapes unchanged** (`openapi:check` clean). Migration `20260927010000_p3a1_windowed_rows_academic_year`.

- **Fix: a returning student's new screen was decided partly from last year's.** The rows a skill status is computed from (screening answers per skill, oral-reading and word-list results, confirmation mini-set answers and outcomes, validity reviews, pattern classifications, sitting results) were stored per window (`BOY`, `MOY`, `EOY`) with no school year. A second-year `BOY` screen therefore read the first year's `BOY` rows too, counted last year's skills toward this year's domain coverage, and could pick up last year's unfinished mini-set. Each of these rows now carries its school year (for example `2026-2027`), taken from the student's class, and every decision reads only the current year's rows.
- **Fix: an unfinished sitting from last year blocked or was resumed as this year's.** Diagnostic sittings and owed confirmation mini-sets now carry the school year too, and "one open sitting per window" is per school year.
- **Fix: a profile could be recorded twice, or matched last year's.** The rule "one pending profile per student, school year, window, profile and rule set" is now enforced by the database; two simultaneous resolutions leave one row, and a new school year's resolution is a new pending profile rather than last year's.
- **Existing rows** were given the school year of the class the student was in at the time the row was written (else the organization's school year covering that date, else the calendar school year starting 1 August). Validity reviews shown in the teacher reports are the current school year's.

## Student journey v2 — the second task in parts, stars and badges, the sound check (2026-09-26)

Partner journey J-00 §0, §4, §8; decisions D-A and D-D. **The deeper sitting runs in parts:** every student session
state (`start` / `state` / `resume` / `responses`) now carries `partSize` — 6 for the deeper `d_triggered` sitting,
`null` otherwise; the runner shows a break screen after each full part (presentation only: serving, the stopping rule
and the time cap are unchanged). The today-plan's `second_assessment` card is `resume: false` until the first answer.
**Stars and badges (D-A):** `POST /api/practice/training/today/feedback` += `reward` (`stars`, `newBadge`; `null` on a
replay) and `GET /api/student/today-plan` += `rewards` (`stars`, `badges[]`) — one star per finished training day,
badges for effort (1 · 5 · 10 · 20 days) and consistency (3 days in one week); derived from `training_session`, never
XP, never ranked. **Activity:** new client kinds `student_login_completed` (`mode`, `msToLogin`, `failedAttempts`,
`helpOpened` — never what was typed) and `sound_check` (`heard`, `plays`); `next_action_result` += optional `task`.
Fixed: every home load's activity batch was rejected (the `task` key), and resuming a sitting from the home card no
longer triggers a hydration error that swallowed the child's first tap.

## Student journey v2 — the training day: two tries, «كيف كان التدريب؟», «تعيين التدريب» (2026-09-26)

Partner journey J-00 + the D-01 demo. Practice is now a **training day**: the thinking exercise, then exercises, then
the mandatory «كيف كان التدريب؟». **Two tries per question**: a wrong first try keeps the same question with the
chosen distractor's hint; the second try is recorded but **never scored** (θ, the 80/60 block routing and the
micro-pattern engine read the first try only); a second wrong try reveals the correct answer. One training a day;
no training while a deeper check (the S7 backtrack) is recommended, approved or open. The warm-up can no longer be
skipped and no longer comes before an assessment. API: `submit-response` += `outcome` (`first_correct` · `retry` ·
`second_correct` · `revealed`), `attempt`, `correctAnswer`, `training`; `next-item` += `retry`, `training`;
`queues/current/state` += `retryItemId`, `training` (a read — nothing is served); NEW `POST
/api/practice/training/today/feedback`, `GET /api/practice/students/{studentId}/training-sessions`, `POST
/api/practice/students/{studentId}/assign-training` (409 reasons `no_assessment_yet` · `no_practiceable_subskills` ·
`queue_already_active` · `deeper_assessment_pending`). New table `training_session` (FKs to `student_session` and
`StudentProfile`). The second try is stored as an `item_response` NOT pinned to the serve (one answer per serve stays
true for the first try). Fixed: the recordings manifest is revalidated on every load (a cached copy offered removed
recordings); the backtrack card's `plan_action` events were rejected by the strict enum.

## Practice queues and acute-regression alerts across a re-measure or a class move, 2026-09-26

**No new operations; response shapes unchanged.**

- **A re-diagnostic keeps a finished practice queue's history.** Opening a re-diagnostic still archives every earlier practice queue, but only an unfinished one (active or paused) becomes `abandoned`. A queue the child completed stays `complete` (archived); it is no longer rewritten.
- **An open acute-regression alert moves with the student.** When a student is enrolled in another class (a move, a roster import, the school-year promotion), their open alerts move to the new class. The new teacher sees them, and the same regression evaluated again in the new class finds the existing alert instead of opening a second one.

## Database structure review × Pilot-1: the two branches reconciled, 2026-09-26

**No new operations; request and response shapes unchanged** (`openapi:check` clean, 389 paths — the union of both branches). Migrations: four shims (`20260923125000`, `20260925045000`, `20260925055000`, `20260925090000`) that let Pilot-1's already-shipped migrations and the review's run in one timestamp order, two carry shims (`20260926010050`, `20260926050050`) and the reconcile pair `20260926110000` / `20260926110100`.

- **A basis revision still wins (D-SYNC-1).** The skill / domain / macro snapshot keys are `(student, skill|domain|tile, academic year, window, config fingerprint, basisRevision)`: the same basis under the same rules is idempotent, a changed basis (a confirmatory mini-set finalizing a paused skill, a fresh oral-reading probe in the same window) appends revision + 1. Every "latest" read orders by academic year, config release, **basis revision**, computed-at, id — the principal counts were rebuilt under that rule.
- **Pilot-1 data now lives in the consolidated tables.** The prompt level at each practice attempt (WP-S8) is on the answer (`item_response`); the practice experience type and the supersession pointer (WP-S3) are on `student_session_practice` (the experience type pins the config release it was read from); the backtrack recommendation (WP-S7) points at its sitting with a real foreign key and its history is `status_transition`; the word-list result (WP-S0) carries one student id and the config release its band came from. Existing rows were carried, not dropped.
- **The Pilot-1 catalogs are release-backed.** Ten partner-owned tables (required domains per grade, the Pilot-1 profile allow-list, the practice-experience map, oral-reading band rules, word-list measure config, support families, target-skill phrases, class-need thresholds, the backtrack sequence, dosage guidance) are part of every config release and are read through it.
- **The profile → RTI-alert chain runs for every evidence producer** (diagnostic, oral reading, word lists, the mini-set re-chain), not only the diagnostic finish.
- **`purge:demo-tenants` works on any schema version** (it reads the delete order from the live catalog) — run it before deploying, as WP-S10 requires.

## Database structure review — Phase 1C: one staging table for roster and LMS imports, 2026-09-26

**No new operations; request and response shapes unchanged** (`openapi:check` clean). Migrations `20260926100000_import_job` and `20260926100100_import_job_drop_legacy`.

- **Roster CSV batches and LMS import previews are now stored in one pair of tables, `import_job` and `import_row`**, with a `kind` of `roster_csv` or `lms_roster`. The migration copied every batch, preview and row, keeping batch and preview ids (and roster row ids), so an open import wizard keeps working. `roster_import_batch`, `roster_import_row` and `lms_import_preview` are dropped. The LMS sync-run history stays its own append-only log and now records which preview each run queued or committed.
- **Fix: re-committing an older LMS preview** returned the connection's most recent sync run instead of its own. It now returns the run of that preview.
- **Fix: committing the same LMS preview twice at the same moment** could provision it twice and record two sync runs. The second commit now waits and returns the first one's result.
- **Fix: an automatic nightly LMS sync** was recorded in the sync-run history as an admin's manual commit. It now shows `triggeredBy: cron` and `autoApplied: true`.
- **Fix: erasing a person** now also removes their rows from LMS previews that were already committed (before, only previews still awaiting review were cleaned). The erasure `counts` report these as `import_row` (previously `roster_import_row` and `lms_import_preview`).

## Database structure review — Phase 2e-2: erasing an adult account, 2026-09-25

**New operation** `POST /api/users/{userId}/erase` (tag `erasure`, slice `wp-erasure.yaml`). Migration `20260926070000_audit_action_person_erased`.

- **A true super admin can now erase a teacher, parent, principal, manager or admin in place**, the same way a student is erased. Names, e-mail, username, phone, staff reference, passkeys, Google and LMS links, notifications addressed to the person, and a parent's phone and links to their children are removed. The person's address is also removed wherever the sign-in history recorded it, including on another account's row (for example as the recipient of a child's login code). Class assignments are closed, support groups the person owns are archived, a principal's school pointer and a manager's school scope are cleared, and LMS connections they created are revoked (the stored tokens were theirs). Plans, decisions, meetings and requests they made stay under the same id, which now points at an anonymous account shown as «مستخدم محذوف».
- **Guard rails.** The request names the organization (`null` for an Amal-team account); anyone else is 404. A student is refused with 422 `PERSONA_MISMATCH` and the student route. Erasing your own account (409 `SELF_ACTION_FORBIDDEN`) or the last active super admin (409 `LAST_SUPER_ADMIN`) is refused; the admin suspend / delete and the erasure share one lock for that check. Erasing an organization's last active admin is allowed and reported as `lastOrgAdmin: true`.
- One audit record (`person_erased`) with the persona and counts per table, never the erased values. Running it again returns `alreadyErased: true` and changes nothing.
- **Student erasure fixes** (the two erasures now share one list of what identifies a person): a student's UI preferences (dyslexia font, contrast) and the sign-in throttle entry keyed by her username were left behind; both are now removed. The same shared list also removes sign-up and teaser records carrying the person's address.

## Database structure review — Phase 2: one student-session table, 2026-09-25

**No new operations; response shapes unchanged** (`openapi:check` green, no spec diff). Migrations `20260926050000_student_session` and `20260926050100_retire_session_tables`.

- **Diagnostic sittings, practice queues and confirmation mini-sets are now one table, `student_session`**, with a `kind` of `diagnostic`, `practice` or `confirmation` and one detail table per kind for the fields only that kind has. A re-diagnostic is a diagnostic sitting with a re-diagnostic flag, not a fourth kind, because it follows the same flow and the same "one open sitting per window" rule. The migration copied every sitting and queue with its id unchanged, so existing links to them still work; `assessment_session`, `practice_queue` and `confirmation_request` are dropped.
- **All kinds use the same status values** (`active`, `paused`, `complete`, `abandoned`) and the same start/end timestamps, and the assessment window is stored in one casing (`BOY`, `MOY`, `EOY`, uppercase). A confirmation that was `open` is now `active`, `resolved` is now `complete`.
- **A confirmation mini-set now has its own session.** Before, it had no session row: its served items and answers carried a made-up session name (`confirm-<number>`). Now they point at the confirmation's session like any other flow. Answers, served items, sitting results, practice block results and teacher-session recipients now have real foreign keys to the session they belong to, so an answer cannot point at a session that does not exist.
- **Fix: two open confirmations for the same skill.** Nothing prevented two open confirmation requests for the same student, skill and window. The database now allows only one, and opening one is serialized. The migration kept the newest of any duplicate pair open and closed the older one.
- **Fix: the one-active-practice-queue rule** now applies per organization and ignores archived queues.
- **Status history for every kind.** Practice-queue and confirmation status changes are now recorded in the status history table, as diagnostic sittings already were.

## Database structure review — Phase 2g: grades, classes and school years, 2026-09-25

**No new operations; response shapes unchanged** (`openapi:check` clean). Migrations
`20260926060000_class_school_year_required`, `20260926060100_class_id_composite_fks` and
`20260926060200_student_grade_history_every_change`.

- **Every class belongs to a school year.** `Class.schoolYearId` is now required. The migration rewrote non-standard
  labels ("2026/2027" and "2026/27" become "2026-2027"). A label that names no year takes the organization's active
  year. It created the missing years and linked every class to the year of its own organization and label. A class's
  `academicYear` label can no longer differ from its year's label, and a class can no longer point at another
  organization's year. The database rejects both.
- **Fix: an open acute-regression alert now follows the student.** An alert records the class it was raised in. The
  smart-grouping exclusion, the teacher's alert list (`GET /api/monitoring/alerts`) and the principal's
  immediate-review queue used that class to decide who sees the alert. So a student who changed class during an acute
  regression could be grouped with classmates in the new class (QA_004). The new teacher and the new school's
  principal did not see the alert, and the old ones kept seeing it. All three now use the student's current class.
- **Fix: school-year promotion advances each student by one grade from their own grade.** A class can hold several
  grades (FR-SES-7). The promotion used to give every student the class's next grade, so a Grade-1 student in a
  Grade-2 class became Grade 3. Now that student becomes Grade 2.
- **Every grade change is recorded.** A student's grade (`StudentProfile.gradeLevel`) is the student's own grade and
  is not derived from the class grade. Before, only a promotion left a record. Now a teacher's edit
  (`PATCH /api/students/{id}`), an admin edit (`PATCH /api/users/{id}`) and a roster or LMS re-import that overwrites
  the grade each add a `student_grade_history` row with the reason (`correction` or `import`) and who made the change.
  One function does all grade changes, and a new lint (`lint:student-grade-single-write-path`) rejects any other code
  that writes the grade.
- **Class ids on other tables must belong to the same organization.** Enrolments, teacher assignments, screening
  triggers, smart-group results and members, acute alerts, principal requests, workflow drafts, LMS course mappings and
  leaderboard snapshots can no longer name a class of another organization. The database enforces this. A group
  member's class must also be its group's class.
- **`school_dashboard_snapshot.schoolId` is removed.** Nothing read it. When a class moved to another school it kept
  pointing at the old school.

## Database structure review — Phase 2: versioned item content, 2026-09-25

**No new operations; response shapes unchanged.** Migrations `20260926040000_item_content` and `20260926040100_item_content_drop_legacy` (P2b).

- **A question's content is versioned.** Stems, options, the answer key, distractors, media, the interaction settings, the render template and the passage a question uses now live in `item_content`, one row per version that is never changed afterwards. `item_bank` keeps the question's classification, lifecycle and provenance, plus the number of its current version. A Question Studio edit adds a new version; it no longer rewrites the question in place.
- **A serve remembers the version it showed.** Every serve (online and paper) records the content version (`served_task_instance.contentVersion`). The answer is graded against that version, and the student's served view, the teacher's answer review and paper marking show that version, even when the question was edited after it was shown. Before, all of them read the latest content, so a key edited mid-session (or between printing and marking a paper sheet) could grade a correct answer as wrong.
- **Passages are versioned the same way** (`passage_content`). A question version points at the passage version it was written against. A passage edit adds a passage version and moves every question on that passage to a new version that points at it: new serves show the new text, and past serves keep the text they were shown.
- **Calibration.** Current calibration moved to `item_calibration_state`; the error-analysis tags moved to `item_error_target`. The scoring engine now reads the frozen calibration snapshots at the session's pinned calibration version, and uses a snapshot only for answers to the content version it was fitted on. An item recalibrated after the pin now scores with its earlier calibrated difficulty; before, it fell back to the prior difficulty. Practice item ordering uses the same resolver; before, it used any stored calibrated difficulty, even one that had not passed the 300-answer check. The calibration job only pools answers to each question's current version, records that version on each snapshot, and clears the stale flag when it recalibrates.
- **Question Studio edit history** (`GET /api/items/{id}/revisions`, `GET /api/passages/{id}/revisions`) returns the same fields. Each entry now also records the content version the edit created, and keeps the previous values of changed classification fields only, because the previous content version already holds the old content. `PassageDetail.version` now reports the passage's current content version.
- **Removed:** 14 `item_bank` columns that nothing read (`requiresInference`, `dokSmeConfirmed`, `posTags`, `lemmas`, `samerMaxLevel`, `camelsFeatureTags`, `nearDuplicateOf`, `rtiSensitive`, `reportable`, `soloSchemaVersion`, `version`, `deltaSe`, `infitMnsq`, `outfitMnsq`) and `passages.version`.

## Database structure review — Phase 2, 2026-09-25

- **One content-import model** (P2d, migrations `20260926020000_content_import` and `20260926020100_content_import_drop_legacy`). Item imports and session-blueprint imports used to keep two separate sets of batch, per-row log and SME-approval tables; they now share one set, `content_import_batch`, `content_import_log` and `content_import_approval`, with a `contentKind` of `item`, `session_blueprint` or `passage`. The migration copied every old row and kept every batch id, so items and blueprints still point at the batch that created them; the six old tables are dropped. The database now enforces the per-kind rules: a batch's file hash is unique within its kind (the same file sent to two import gates makes two imports), only item batches carry a declared source and a δ_prior formula version, each log row names at most one imported row of its batch's kind, and each gate can only record its own reject reasons. **No API change:** the items and session-blueprint import endpoints return the same responses; each one now only sees its own kind's batches, so another kind's batch id returns 404.
- **Passages have an import batch.** Passages loaded by the seed scripts and the demo provisioning go through a new passage import path: one batch per passages file, re-importing the same file changes nothing, an existing passage is left as it is, and a new passage records the batch that created it (`importBatchId`). Passages written by hand through `POST /api/passages` have no batch, as before.
- **Phase 2e — erasing a student. New operation** `POST /api/students/{studentId}/erase` (tag `erasure`, slice
  `wp-erasure.yaml`). Migration `20260926030000_audit_action_student_erased`. A true super admin can erase a
  student's identity in place when a family asks for it. Names, e-mail, username, phone, school reference, date of
  birth, login code, passkeys, Google and LMS links, and parent links are removed. Printable reading sheets (and the
  class zips that contain them), scanned pages and reading recordings are deleted from storage, and so are
  notifications about the child. Answers, scores, evidence and decisions are kept under the same id, which now
  points at an anonymous record shown as «طالب محذوف». The request names the organization (another
  organization's student returns 404) and takes an override-catalog reason code (scope `all`; no free text). The
  operation writes one audit record with counts per table and never the erased values. Running it again returns
  `alreadyErased: true` and changes nothing. It cannot be undone. Runbook: `docs/runbooks/erasure.md`.

## Database structure review — Phase 2: one answer table and a θ ledger, 2026-09-25

**No new operations; response shapes unchanged** (`amal-api.yaml` regenerated, no diff). Migrations `20260926010000_item_response` and `20260926010100_retire_answer_tables`.

- **Every student answer is now one row in `item_response`**, whatever the flow: diagnostic, practice, paper, confirmation, or the standalone engine endpoint. The row says where the answer came from (`source`), which sitting, queue or generated session it belongs to, and which serve it answered (at most one answer per serve). Answers whose audio or image failed to load are kept but marked `excluded`, and no count, θ or calibration reads them.
- **`diagnostic_session` is renamed `theta_estimate`** and points at the answer it scored. The answer and its θ are written in the same transaction, so there is never a θ without its answer or an answer without its θ. Before, practice wrote θ first and the answer in a second transaction, and a practice queue replaced in between left a θ with no answer.
- **Retired:** `practice_response`, `diagnostic_excluded_submit`, and the paper answers that were stored as JSON inside `student_task_result` (which now holds only practice block results). Existing rows were moved into `item_response` by the migration.
- **Counts that were wrong:** the teacher's per-student practice tally counted a duplicate answer to the same item twice, and listed paper answers as empty practice "blocks"; the student's today plan counted a failed-to-load item as answered; the calibration job never read paper answers and did read failed-to-load practice answers. All are fixed.
- `GET /api/engine/sessions/{id}` and `…/by-student/{studentId}` return the same fields as before; `inputResponses`, `pauseMs` and `audioReplayMs` are now read from the linked answer.

## Database structure review — Phase 1C: audit consolidation, 2026-09-25

**No new operations; response shapes unchanged** (three operation descriptions updated). Migration `20260925050000_audit_consolidation`.

- **One status history for five entities.** Profile assignments, bundle assignments, assessment sittings, practice bands and principal requests each kept their own copy of the same "from status, to status, who, why, when" table. They now share one append-only `status_transition` table, protected by the database append-only trigger (principal-request events were only lint-protected before). Every existing row was copied. Each entity's transitions can still come only from that entity's own writer (new lint `lint:status-transition-single-write-path`), and every transition now records the request that caused it.
- **One audit table for generic actor events.** Super-admin cross-tenant reads, demo persona impersonation and live-session leader changes now go to one append-only `audit_event` table with a closed list of actions and a typed payload per action (new lint `lint:audit-event-single-write-path`). Existing rows were copied with their ids, so admin audit-log and activity-feed links (`bypass:<id>`, `imp:<id>`) still open. The admin audit log, the activity feed and the demo observability return the same responses as before.
- **Tables with their own contract stay separate:** sign-in events, the principal and manager student-access log, blocked free-text attempts (which store the length, never the text), activity telemetry, teacher actions, context flags, decision audits, platform alerts, benchmark changes and accessibility-setting changes.
- **Monthly partitions for six event tables** (Phase 1C C9, migration `20260925060000_month_partitioning`). Activity telemetry, sign-in events, task-serve attempts, warm-up events, the student-access log and `audit_event` are now split by month (one partition per UTC month plus a catch-all). Every existing row was copied. The nightly job (`purge:nightly`) now starts with `partitions:ensure`, which keeps three months of partitions ready, so it must stay scheduled; the 90-day activity purge drops whole expired months instead of deleting row by row. The live-session event log is not partitioned: its per-session sequence must stay unique. No API change.

## Database structure review — Phase 1C: school years and the year rollover, 2026-09-25

**Two new operations** in `amal-api.yaml` (tag `tenancy`). Migration `20260925020000_school_year`.

- **The school year is now a record, not a free string.** Each organization has school years (`school_year`: a label such as "2026-2027", 1 September to 31 August, and a status of planned, active or closed), with at most one active year per organization, enforced by the database. Every class points at its year; the class's `academicYear` label is kept and always matches. Existing classes were linked to years created from their labels: the latest label per organization is active, older ones are closed.
- **Class writes validate the label.** `POST /api/classes` and `PATCH /api/classes/{id}` (and the roster CSV import) now reject a label whose years are not consecutive, such as "2026-2028", with 422. Class responses carry `schoolYearId`.
- **New:** `GET /api/organizations/{orgId}/school-years` lists an organization's years, newest first, with their class counts.
- **New:** `POST /api/organizations/{orgId}/school-years/{yearId}/promote` rolls the active year over to the next one (or to `toLabel`) in one step: the old year closes and the new one becomes active; each class gets a next-year class one grade up in the same school and section; every enrolled student moves with the same login code, their grade advances and the change is recorded in a new append-only grade history. **Teachers are not carried over:** the new classes start with no teacher. Grade-4 students leave the grade 1–4 scope; they are unenrolled and no class is created for them. Running it again for the same year does nothing and returns `alreadyPromoted: true`. Requires `MANAGE_SCHOOLS`; another organization's ids return 404.

## Database structure review — Phase 1B: config releases, 2026-09-24

**No new operations; `amal-api.yaml` unchanged.** Migration `20260924130000_config_release`.

- **Every partner catalog is now frozen into a config release.** Catalogs are re-seeded from the partner workbook by natural code, which used to overwrite the rules earlier decisions were computed from. The live tables still hold the current rules; a new publish step snapshots all 88 registered catalogs into content-addressed, append-only history, and only when something actually changed. Unchanged catalogs share one stored snapshot across releases, and rolling a catalog back makes a new active release that re-uses the older snapshot.
- **Every decision row records the release it was decided under.** Skill, domain and macro-domain status snapshots, pattern classifications, profile assignments, bundle recommendations and assignments, smart-cluster results, monitoring plans, CBM and acute-regression alerts, RTI alerts, tier decision records and decision packets carry a required `configReleaseId`. The database fills it with the active release, and refuses the insert when no release exists. Rows written before this migration point at release 1, an explicit baseline whose content was not captured.
- **Operators run `pnpm config:publish` once after deploying this migration** (and after any catalog change made outside the seed). The seed publishes automatically as its last step.
- The schema drift gate now also checks that every database function the schema depends on is present.
- **The skill-status engine now decides from the active release, not the live tables** (step 1B-2a). An edit to a live catalog that has not been published cannot change a decision; each skill, domain and macro snapshot records the release the engine actually read. The admin dry-run compares candidate rules against that release.
- **Every decision stage now reads the active release** (step 1B-2 stream A): student profiles, bundle recommendations and activations, smart clusters, the activation workflow, progress monitoring, RTI alerts and tier decisions, fluency trends, data-room packets and fidelity. Each pinned row records the release the stage actually read. Read-only listings and labels still show the current catalogs.
- **The skill-status engine's per-layer rule versions are retired** (step 1B-2 stream B, migration `20260924140000`). **API change:** the skill, domain, pattern and behavior rows returned by `GET /api/sse/students/{id}/*` no longer carry `adrRuleVersion`, `ssrRuleVersion`, `ddrRuleVersion`, `ddrLcRuleVersion`, `patternDefRuleVersion` or `rbmRuleVersion`; each row carries `configReleaseId` instead, the release that held every rule layer at once. Rule ids (which rule fired) are unchanged. Snapshots are now keyed by a fingerprint of exactly the engine's catalogs, so an unrelated catalog change no longer duplicates them, and any change to the engine's rules produces a fresh decision instead of silently keeping the old one. "Latest" everywhere means newest school year, then newest release, then newest computation.
- **Profile assignments drop their five catalog-version pins** (migration `20260924150000`). **API change:** the profile row from `GET /api/profile/{studentId}` carries `configReleaseId` instead of `profileRuleSetVersion`, `cdpRuleSetVersion`, `pgaRuleSetVersion`, `narrativeTextSetVersion` and `aliasSetVersion`. A change to any profile catalog now produces a fresh pending profile for the teacher to review; an unrelated catalog change does not.
- **Bundle recommendations, clusters and activation drafts move to the release** (migration `20260924160000`). Recommendations are keyed on a fingerprint of the six bundle catalogs; before, an edit to any bundle catalog other than the profile-to-bundle map was silently ignored for students who already had a recommendation. **API change:** `POST /api/cluster/recompute/{classId}` returns `configReleaseId` instead of `ruleSetVersion`, and workflow drafts carry `pinnedClusterConfigReleaseId` instead of `pinnedClusterRuleSetVersion` (the teacher activation wizard is updated). Bundle recommendation and assignment rows no longer carry `configVersions` / `ruleVersion`.
- **Gamification pins the release** (migration `20260924170000`). XP credits, badges, avatar tiers and streaks record the config release the XP rules and badge catalog were read from; a rescind's reversal keeps the release of the credit it undoes, and avatar/streak re-pin on every update. **API change:** the admin audit stream (`GET /api/admin/gamification/audit`) returns `configReleaseId` on each XP transaction instead of `ruleSetVersion`; the admin audit table shows it under «إصدار الإعدادات».
- **Monitoring, tier decisions, decision packets and fidelity pin the release** (migration `20260924180000`). Monitoring plans and status reviews, tier decision records, decision-packet workflows and fidelity rows drop their per-catalog version columns and record the config release instead. The fidelity rating cuts and the off-track tracking config are now part of every release, so an unpublished edit to them no longer changes a rating. **API change:** `GET /api/monitoring/status/{bundleAssignmentId}` returns `monitoringPlan.configReleaseId` instead of `recipeVersion` / `criterionVersion`, and `GET /api/students/{id}/tier-decisions` returns `configReleaseId` instead of `movementRuleVersionId`.
- **Scaffold tiers and served tasks pin the release** (migration `20260924190000`). A student's scaffold assignment records the release its starting tier was seeded from; a teacher override keeps it, because the new tier is the teacher's decision, recorded in the action log. Each served task copies its tier's release (empty when no tier applied: probes, bundle-free and paper serves). **API change:** `GET /api/delivery/log/{taskInstanceId}` returns `scaffoldConfigReleaseId` instead of `scaffoldRuleVersion`.
- **Narrative render audits and sub-flag indicators pin the release** (migration `20260924200000`). Each per-audience narrative render records the release its text set came from instead of a text-set version. The training-time sub-flag ladder now reads the error-pattern and distractor catalogs from the active release, so an unpublished pattern edit no longer changes which errors count; the indicator state re-pins at every counted step. No API change.
- **Every decision row names its release explicitly, and decisions can no longer read the live catalog tables** (migrations `20260924210000`, `20260924220000`). The `configReleaseId` column has no database default any more: a write that forgets its release is rejected instead of silently taking the active one. Catalog loaders default to the active release, and a new lint (`lint:config-read-from-release`) rejects live catalog reads outside presentation, validation and tooling. Editing language rules in the admin screen now publishes a release, so the edit applies immediately as before. **API changes:** `POST /api/assembly/session` no longer accepts `assemblyRuleVersion` (the active release's rule set is used) and assembly runs return `configReleaseId`; QA runtime alerts return `qaConfigReleaseId` instead of `qaChecksSetVersion`; the delivery log returns `renderConfigReleaseId` for online serves, and `renderRuleVersion` is set only for paper serves.
- **A student is active in one class at a time, enforced by the database** (Phase 1C group C8, migration `20260925030000`). A partial unique index allows one active `ClassStudent` row per student; the migration stops and lists the students if any already hold two. All four ways of provisioning people (admin create, LMS import, roster CSV import, demo seed) now go through one writer with one identity rule: a person is matched inside the organisation only, and an email owned by another organisation is never linked. **Behaviour change:** an LMS import that lists an already-enrolled student under another mapped class now moves the student (the old enrolment closes as transferred) instead of enrolling them in both, and an LMS row mapped to a class outside the organisation is skipped (counted in `rowsSkipped`). No request or response shape changed.
- **Principal dashboards read maintained status counts** (Phase 1C C6, migration `20260925040000_school_dashboard_snapshot`). A new table, `school_dashboard_snapshot`, keeps a per-class count of students for each tile, status, window and school year. The macro recompute rewrites it in the same transaction, `pnpm dashboard:rebuild` rebuilds it (it also runs nightly in `purge:nightly` so enrolment changes are picked up), and the migration backfills it. The school dashboard's tiles and attention counts, the attention page, and the class pages' «last assessed» date now read these counts. Before, every request folded every student's snapshot history. The numbers are the same, with one exception: a returning student's row from last year no longer stands in for this year's window. No API change.

## Database structure review — Phase 1A: one student id, real foreign keys, 2026-09-24

**No new operations; `amal-api.yaml` unchanged.** Migrations `20260924100000_student_id_unification`, `20260924110000_student_fks`, `20260924120000_subskill_fks`.

- **`StudentProfile.id` now equals `User.id`, by construction.** Two independent integer sequences for one student were the platform's recurring bug class: a missed hop between them read another child's data instead of failing. The migration rewrote every profile-keyed column to the user id (two-step, so no unique index ever collided) and installed a `BEFORE INSERT` trigger that keeps `id = userId` for every future profile; an update that would break the equality is rejected. No API contract changed — every route still takes the same number it took before, it is just the only number now.
- **`studentId` is a real foreign key on 53 measurement and decision tables**, `ON DELETE RESTRICT`, added as `NOT VALID` + `VALIDATE` so production gains them without a long lock. A fabricated or orphaned student id now fails at insert, and a student cannot be deleted from under their measurement history (erasure remains anonymise-in-place).
- **`subSkillId` / `skillId` is a real foreign key on 25 evidence, serving and calibration columns** to the skills catalogue, nullable ones included. An evidence row for a skill that does not exist can no longer be written and silently ignored.
- Test suites that deliberately proved the old two-space hop now assert the unified invariant instead; fixtures that used made-up ids were given real profiles.

## Database structure review — Phase 0 items 9–10: dead tables dropped, the connection pool bounded, 2026-09-23

**No new operations; `amal-api.yaml` unchanged.** Migration `20260923170000_drop_dead_config_tables`.

- **20 tables gone (275 → 255 models).** Nineteen partner-config mirrors that were never seeded and never read (assessment cycle/type dictionaries, evidence-weight and decision-use rules, tier exit criteria, maintenance / progress-monitoring / CBM / insufficient-progress rules, data-room review, teacher activation and permission matrix, task rendering, safe language, guardrail QA registry, versioning rule, workbook change log, controlled-value dictionary, standards mapping) plus `trend_verdict` (zero writers, zero readers), three enums only they used, two audit columns that were never written, and one unused view. The rules those sheets describe are enforced in code; the sheets stay the spec in `docs/product/partner-inputs`. A table comes back only together with its reader.
- **The connection pool is a number ops chooses.** `PRISMA_CONNECTION_LIMIT` and `PRISMA_POOL_TIMEOUT` are stamped onto `DATABASE_URL` per instance (4 and 20 s on main, 3 on demo). The Cloud SQL tier change and managed connection pooling are documented in `deploy/cloud-run/DEPLOY.md` as the steps before onboarding beyond the pilot.

## Database structure review — Phase 0 items 7–8: current-row reads and the retention sweep, 2026-09-23

**No new operations; `amal-api.yaml` unchanged.** Migration `20260923160000_current_row_indexes`.

- **"Latest" is decided in SQL.** The teacher class report (skill grid, macro tiles, domain sparklines), the manager `/trends` series and the principal case-signal folds used to fetch every snapshot row for the roster and pick the newest in Node by `computedAt`. They now run `DISTINCT ON` with the deterministic recency rule — current academic year first, then rule version, seed version, computedAt, id — on new current-row indexes, so a re-run under a newer rule version can no longer be out-ranked by a stale row, rows transferred scale with the roster instead of its history, and the trends bucket year comes from the snapshot's own `academicYear` column rather than from when it was computed.
- **Two unindexed join keys fixed** — `diagnostic_session.sessionId` (the finish path) and `Class.schoolId` — plus `requestId` on `user_activity_event` for log correlation.
- **The retention sweep exists.** `pnpm auth:purge` deletes consumed or 7-days-expired reset/magic-link tokens, passkey challenges, OAuth states and handoffs, expired LMS preview blobs, and sessions inactive for 30 days (the audit trail `auth_event_log` is never touched); `pnpm purge:nightly` chains it with the existing `activity:purge`. Neither had ever been scheduled; the Cloud Run Job + Cloud Scheduler commands are in `deploy/cloud-run/DEPLOY.md` and must be created per environment.

## Database structure review — Phase 0 items 5–6: DB-enforced append-only, the post-diagnostic decision chain, the fidelity denominator, 2026-09-23

**No new operations; `amal-api.yaml` unchanged.** Migrations `20260923140000_append_only_triggers`, `20260923150000_planned_cycle_weeks`.

- **Append-only is now enforced by Postgres, not by a source lint.** `amal_append_only()` runs `BEFORE UPDATE OR DELETE` on 39 measurement-spine tables (mastery events, XP, the three status snapshots, evidence rows, θ audit rows, responses, CBM scores, tier decision records, runtime audit, the history tables, calibration snapshots). Any UPDATE or DELETE fails with `append-only: … is forbidden (V-6)` — from Prisma, psql, the calibration service, anywhere. The single bypass is `SET LOCAL amal.maintenance = 'on'` inside a transaction (demo reset, one-shot migrations, test databases). The drift gate now also asserts all 39 triggers exist after `migrate deploy`.
- **A diagnostic finish now continues into a profile and an RTI evaluation.** After the snapshot bridge lands, the server recomputes the student's profile assignment for that window and evaluates RTI Layer-B alerts — both were previously reachable only from the demo seeder, so no real student ever had a profile and the bundle-recommendation route had nothing to read. The bundle recommendation itself is still the teacher's action (V-12).
- **Fidelity has a denominator.** `bundle_assignment.sessionsPlanned` and `dosageBand` are stamped by every activation path (individual, bulk cluster, activation workflow) from the bundle's catalog band: the band's lower weekly frequency («3–5x/week» → 3) × the planning cycle (`InterventionTrackingConfig.plannedCycleWeeks`, default 6, partner-owed). Before this the rate was always computed against 0 → `unknown` → every tier escalation was blocked by TM_BLOCK_01.

## Database structure review — Phase 0 blocker fixes, 2026-09-23

Four correctness fixes from the full schema review (`docs/product/db-structure-review-2026-09-23.md`). **No new operations; `amal-api.yaml` unchanged.** Migrations `20260923120000_restore_session_blueprint_tags_gin`, `20260923130000_snapshot_academic_year`.

- **Status snapshots are keyed by academic year.** `skill_status_snapshot`, `domain_status_snapshot` and `macro_domain_status_snapshot` gained `academicYear` ("2026-2027") inside their idempotency keys. Before, a returning student's second-year BOY under the same rule version collided with the year-1 row and was dropped as "idempotent" with only a log line — year-2 screening never updated any status. The year is resolved from the student's active class enrollment; existing rows were backfilled the same way. Read routes are unchanged: "latest" still wins, it is simply no longer blocked.
- **Calibration no longer double-counts practice.** The offline Rasch loader unioned `practice_response` with every `diagnostic_session` row, but the engine also writes a `diagnostic_session` row for each practice score. Sitting-bound rows (`sessionId` set) are now the only diagnostic source. **Every δ published before this date was fit on duplicated practice data and will be re-minted at the next calibration run** (a new version, per V-6 — no in-place change).
- **LMS import can no longer link a user across organizations.** The commit step matched by global email; an email already owned by another org's user was silently attached to the importing org's classes. The match is now org-scoped; such a row is skipped and counted in `rowsSkipped`.
- **Schema drift gate in CI (`pnpm db:check-drift`).** Runs after `migrate deploy`: `schema.prisma` must equal the replayed migrations, and every raw-SQL object Prisma cannot model (32 partial/GIN indexes, 2 views, 18 CHECK constraints, registered in `prisma/raw-sql-objects.json`) must still exist. Building it revealed that migration `20260624221545` had really dropped the session-blueprint tag index in every environment; it is restored and now declared in the schema so `migrate dev` stops proposing the drop.

## Pilot-1 proof run on a real stack — seven defects fixed the same day (2026-09-24)

`pnpm --filter server seed:pilot-proof` stands up a proof tenant (org · Grade-1 class with a login code · teacher with a password · 6 children with quick codes · MOY open today); the full loop was then driven in three browsers: screen → confirmatory mini-sets → profile → teacher confirm → recommendation → cluster → activation → class-need cards. Report: `docs/superpowers/proof-runs/2026-09-24-pilot1-proof-run.md`. Fixed: (1) a warm-up skipped from its start screen trapped the child (never recorded, the hand-off routed back to the warm-up); (2) **every read defaulted to a fixed BOY window** — a Grade-1 child whose first screen is MOY rendered as "not assessed" with a data-incomplete profile (`resolveEvidenceWindow`: open window today → last completed screen → BOY, shared by reads and the ORF/word-list bridges); (3) **nothing routed a child to the confirmatory mini-set** — `next-action`/`today-plan` gained `confirmation` (before a new screen and before practice), the home shows «أسئلة صغيرة للتأكّد», the diagnostic finish consults next-action; (4) a deep link to a student page read «الطالب #id»; (5) each mini-set re-chain left **two pending profile rows** — a fresh resolution now supersedes the earlier pending one; (6) the plan card's «نقاط القوّة» borrowed the bundle's candidate bridge (listening) — rows now carry the data-supported `assetBridgeDomain` (null = «لا توجد أدلّة كافية…»); (7) the cluster card names the members' target skills instead of a generic anchor label. Open: OQ-23 (PA-07 partial-gateway PAC gap), OQ-24 (≥ 6 items per grade × skill × band), OQ-25 (session blueprint catalog). API: `NextAction` enum += `confirmation`; `RecommendationRowDTO.assetBridgeDomain`.

## Strict blueprint publish in the admin UI, the placeholder-cell banner, and the word-list marking screen (WP-S0 T0.4 FE + T0.6 FE), 2026-09-24

**Admin blueprint editor.** «حفظ» now sends `strict: true`, so a skill whose servable pool is below the 3-item screen (or an inactive skill) is **refused** with the skill named — each `details[].issue` of the 422 is rendered inline in Arabic under the picker («المفردات السياقية (VOCAB-C03): 2 من 3 أسئلة تقييم للصفّ 1 — أقلّ من حجم المسح.»; an unrecognised issue falls back to the server text) and nothing is published. Scripts and the API default stay lenient (`warnings[]`, now typed on `PutBlueprintResult`). A seeded cell carrying `notes: "placeholder — partnerPending OQ-1"` is badged **«قائمة مؤقتة»** on the grid and explained in the dialog (T0.5 fold). Web: `lib/admin-diagnostic-blueprint-api.ts` (`strict?`, `warnings?`, `isPlaceholderCell`), `cell-editor-dialog.tsx` (`publishIssueAr`).

**Teacher word-list marking.** A fourth kind card **«قائمة الكلمات»** on the class measure page: real words (`DWR-C01`) or pseudowords (`DWR-C02`) for the child's own grade, the 20 words as tappable chips (a tap marks a misread word; coral + strike-through), an optional elapsed-seconds field, «تسجيل القائمة» → `POST /api/word-lists/assessments` with the misread indices → the result reads **correct / attempted** plus the projection state in growth language (in the window · no window open · will retry). The band and the percent never appear on the teacher surface (V-3). No list for (grade × skill) reads as a content gap («المحتوى بانتظار الشريك»), never another grade's list. Web: `lib/word-list-api.ts`, `measure/_components/word-list-marking.tsx`, `assessment-kind-cards.tsx`. Mock BE gained the two routes + 4 fixture lists (grade 4 deliberately none). No API change.

## Counterfactual replay harness — and the allow-list gap it found (WP-S9), 2026-09-24

`pnpm --filter server replay:profiles -- --org <id> --window BOY [--allowlist rows.json] [--required-domains rows.json] [--out file.csv]` resolves every student with domain snapshots in the window with the ACTIVE profile catalog and with the proposed allow-list / required-domains rows, and prints who changes (per-student CSV with ids only + a distribution summary). Zero writes by construction — the engine is pure and the reader read-only; the tests assert the assignment count is unchanged. **The first replay found a real gap:** under the Pilot-1 allow-list an anti-overlap (AO) row could still FORCE a primary outside the list — the accuracy pattern elected `ACCURACY_GAP` via AO-02 although Grade 1 admits only ON_TRACK / MONITORING_PROFILE / FOUNDATIONAL_DECODING_GAP — because the list filtered CDP candidates only. The tie-break now admits only an allowed decision profile (the AO match is still recorded for replay), so the pattern degrades like a filtered candidate; regression test proven red → green. No API change.

## Dosage as guidance (support level × grade band × skill area), the teacher's schedule kept apart, and the prompt level at every practice attempt (WP-S8, partner round 2 §2.5 + round-4 C4; spec 2026-09-23 §23–24), 2026-09-24

**Dosage.** The partner's three bands are now a config row, not constants: `dosage_recommendation` (4 rows — TIER_1 × Grades 1–2 × أساسيات القراءة 2–5 min × 4–5/week · TIER_1 5–10 × 2–3 · TIER_2 10–15 × 3–4 · TIER_3 20–30 × 3–4; OQ-11 tunable) resolved by the most specific row (skill area > grade band > ANY). At activation the single assignment writer stores the resolved guidance as `bundle_assignment.recommendedDosage` (with its source row for replay); the teacher's own schedule (`PATCH /api/assignments/{id}/schedule`, OVR_SCHEDULE with a catalog reason) lands in `scheduledDosage` — the closed numeric form (sessions/week · minutes · total sessions) — and the two are never merged. The recommendations rows gain `dosageAr` («١٠–١٥ دقيقة × ٣–٤ مرّات أسبوعيًّا», resolved per child from support level × grade × family) and the fidelity tracker feed gains `dosageAr` + `scheduledDosageAr`. Teacher UI: the plan card's «الجدولة المقترحة» reads the guidance; the plan page's fidelity card shows the teacher's schedule first with «(الموصى به: …)» beside it when they differ. **Prompt level at response.** `practice_response` now records, on every attempt, `scaffoldLevelAtResponse` (`none` · `hint:LOW|MID|HIGH` · `reveal` reserved), `hintCount` and `independenceLevel` (`independent` → `reduced` → `supported`), derived under the practice lock from the prior attempts on the same item (a wrong answer served a SOLO hint), and `attemptN` now really increments on a correction (it was a constant 1). Nothing consumes these for a mastery reading in Pilot 1 (OQ-20: collect only). **T8.3 (authored difficulty levels 1–4) stays blocked on OQ-16** — the band string remains the level. **No new operation; `amal-api.yaml` stays 385 paths.** Migrations `20260925160000_s8_dosage`, `20260925161000_s8_scaffold_at_response`. Verified in three browsers against the mock backend.

## The foundational backtrack — triggers A/B, teacher approval, a targeted sitting, the breakpoint readout (WP-S7, partner round 2 §1 + §3.3; round-4 A3/B6/B7/C7), 2026-09-24

Pilot 1 has ONE deeper-assessment route: the foundational-reading backtrack (sound → letter/sound → syllable → word). New module `backtrack` (**3 operations; `amal-api.yaml` is now 385 paths**). **Triggers** are evaluated after every profile recompute (the diagnostic bridge's step G; an oral-reading probe drives the same chain) over tokens the engine already produced — never a numeric line: **A** = the accuracy/fluency tile Below/Severe AND decoding indicated (accuracy band below expected, or "accurate but markedly slow" = the S4 rate band `markedly_below`, the 2-SD stand-in until OQ-15; the 25 % rule is gone); **B** = the READ_COMP domain broadly weak (`coherent_domain_weakness` / `broad_related_domain_concern`, never an isolated skill) AND access not clearly adequate. Comprehension alone never triggers; "two domains below" is not a rule. A trigger writes ONE `backtrack_recommendation` per (student, window, academic year) — an existing row of any status is never duplicated, so the teacher's decision stands. **`GET /api/backtrack/students/{studentId}`** returns it with a growth-language reason and the four steps (Arabic skill names). **`POST /api/backtrack/{id}/approve`** (teacher-of-student) opens the child's targeted `d_triggered` sitting FIRST (the 14-day floor / one-active guard may refuse → 409, row untouched), then moves `recommended → approved → sitting_open` with two history rows; the sitting carries the sequence's DIGITAL checkpoints only (the 20-word lists are teacher-scored, never served on a screen) and the child simply finds it in the day's path — the word "backtrack" never reaches a student. **`POST /api/backtrack/{id}/decline {reasonCode}`** requires a BASE override code (`applicableScope = all`; 400 otherwise) and opens nothing. On the sitting's finish the **breakpoint readout** reads the sitting's OWN checkpoint results (all correct → adequate · at least half → monitor · else weak; backtrack evidence is never projected onto the screening snapshots) and names the FIRST weak step only when every earlier step was measured — a hole before it yields «تعذّر تحديد نقطة البدء — خطوات غير مقيسة: …» (OQ-3 honesty), never a guess. Config `backtrack_sequence` (FOUNDATIONAL_READING v1; the letter/sound and word steps await partner content, OQ-3); runtime `backtrack_recommendation` (whitelisted-mutable through one writer with an allowed-transition table) + `backtrack_recommendation_history` (append-only, lint-listed); FKs Restrict to the override catalog. Migration `20260925150000_s7_backtrack`. Teacher UI: the student page gains the **deeper foundational check** card — reason, the four-step walk, approve / decline (catalog reason, no free text), the open-sitting state, and once complete the readout line, the step reads and one click to practice on the breakpoint skill. Verified in three browsers against the mock backend; RTL audit applied.

## Class-wide need detection + the class-need card (WP-S6, partner round 2 §1 + developer spec 2026-09-23 §25–26), 2026-09-24

Whole-class support is an assignment target, not a plan object, so the class summary now says WHERE a shared need exists. **`GET /api/reports/classes/{id}/class-summary`** (and the support-group summary) gains **`classWideNeeds {skillNeeds[], domainConcerns[], thresholds}`** for both audiences — counts and shares of STUDENTS only, never a score, never a student identity. A **skill need** fires when ≥ 40 % of the students assessed on the same skill are weak (`weak ÷ assessed`; a child under review or `do_not_decide_yet` is in neither count — `perSkill` rows now carry `doNotDecideYetCount`; a دعم مكثّف child is in both, OQ-4) and is **actionable**: it carries the target-skill phrase (`focusAr`, from `skill_focus_phrase`) and a `priority` — `high` for a gateway skill in Grades 1–2 (an ordering weight, never an override). A **domain concern** fires when ≥ 50 % of the students assessed on a macro tile are Below/Severe and is **informational** (`actionable: false`) with the tile's feeder skills to inspect first — the partner's inspect-first rule; no domain intervention is ever proposed. "Weak" is the set of statuses the practice router sends to remedial practice (`practice_experience_map`), so the practice seed and the detector never disagree. Thresholds are a config row (`class_need_threshold`: `skillShare 0.40 · domainShare 0.50 · gatewayBoostGrades [1,2] · countIntensiveInDenominator true`; version echoed in the DTO). Teacher UI: the class board shows the **class-need card** — «\{share\}٪ من الصفّ يحتاج دعمًا في: \{المهارة المستهدفة\}» with one click to the sessions hub pre-filtered by that skill (`?subSkillId=`) and «عرض الطلاب»; a domain concern lists the skills to inspect and has no assign action; the card is names-free and hidden when there is nothing to say. Two guards came out of the browser check: the sessions hub resolves the Arabic name of a deep-linked skill outside the class-grade list, and the shared `Select` renders its placeholder — never the raw token — for a value with no matching option. **No new operation; `amal-api.yaml` stays 382 paths / 57 slices.** Migration `20260925140000_s6_class_need_threshold`. Verified in three browsers against the mock backend.

## The teacher layer: six support families, the target-skill line, a visible support level, the plan card, a one-question review, delivery as scope × medium (WP-S5, partner round 2 §2 + developer spec 2026-09-23 §4–6 / §11.3 / §17), 2026-09-24

The teacher now decides from one line per child — **«المجال»** (one of the six support families, the same string as the tile and the report), **«المهارة المستهدفة»** (the target skill's phrase) and **«مستوى الدعم»** (دعم عام / دعم موجّه / دعم مكثّف — cumulative, separate from the performance status; Tier 1/2/3 never appears) — instead of a bundle name and a tier code. **`GET /api/workflow/recommendations/{classId}`** rows gain `familyNameAr`, `focusAr` and `supportLevelAr`. The recommendation's anchor slot holds the bundle's *selector* (D-006..009), so the target skill is resolved from the child's own weakest status inside the family shown (`bundle/engine/target-skill.ts`; deterministic: severity, then skill id); an oral-reading plan states its target from the two-carrier profile — accuracy / rate / both (spec §11.3) — never from one skill's phrase. **`GET /api/students/{id}/tier`** gains `supportLevelAr` (the server owns the three strings, `rti/support-level.ts`; the web mirrors them and a test on each side asserts they are identical; manager chips read the same words). **`GET /api/profile/{id}`** gains `oralReadingProfile` (`on_track · accuracy_need · rate_need · accuracy_and_rate_need · insufficient` — a secure single carrier never claims "on track", GSR-05) + `oralReadingProfileAr` + `oralReadingFocusAr`. **`GET /api/bundles/{id}/definition`** gains `family {familyId, nameAr, successStatementAr}` — the review card's one question. New global config: `bundle_family` (6 rows — the 18→6 map; a cross-domain plan shows the family of its anchor skill's domain, no seventh family) and `skill_focus_phrase` (80 active rows = 25 partner phrases + the two developer-spec oral-reading phrases on FL-C01 / FL-D01 pending OQ-22 + 53 taxonomy fallbacks; later batches land via `pnpm --filter server import:focus-phrases --file <json> [--dry-run]`, which rejects a banned term or a phrase over six words; `COMP-A03/A05/C09` stay on the fallback, OQ-2). Delivery is expressed as **scope × medium** over the nine delivery options (`bundle/engine/delivery-axes.ts`, fail-closed on an unmapped id; the pair resolves back to the stored option inside the bundle's compatible set). Teacher UI: the recommendations panel filters by family and headlines the three lines (the bundle name is a detail under the reason); the student hub gives the support level its own line; the activation review shows the **plan card** (strengths · current needs · family + target skill · activities · scope · medium · support level · schedule · progress) with the internal recipe — bundle, anchor, supporting threads, bridge, dosage split, rule chain — behind «التفاصيل» and never in the default DOM; the plan page gains the **review card** (the family's success statement; the engine's verdict pre-selects نعم / جزئيًا / لا; an override needs a catalog reason; `do_not_decide_yet` cannot confirm; `acute_regression` locks it); the inactive **listening** domain renders «غير مُقيَّم في هذه المرحلة» on every surface, never a result, even when a row exists. **No new operation; `amal-api.yaml` stays 382 paths / 57 WP slices.** Migrations `20260925120000_s5_bundle_family`, `20260925130000_s5_skill_focus_phrase`. Verified in three browsers (built-in, Playwright, Chrome DevTools) against the mock backend; RTL audit applied (Eastern-digit ranges, Arabic agreement, one spelling of «موجّه»).

## Teacher-scored 20-word lists enter the engine as item-set measures (WP-S0 T0.6, partner spec 2026-09-23 §9), 2026-09-23

The universal screen's real-word (`DWR-C01`) and pseudoword (`DWR-C02`) measures are **20-word lists read aloud and scored by the teacher**, not three digital questions — and the engine used to fold any multi-item result into an n/3 pattern (18/20 = 90 % read as "2/3", borderline, and opened a mini-set). New module `word-list-measure`: **`GET /api/word-lists`** (the lists for a grade) and **`POST /api/word-lists/assessments`** (the misread set → correct / attempted / percent → a band against the grade expectation → one `screening_score` evidence row with `value.kind = ITEM_SET_MEASURE` → the same chain as a diagnostic: skill → DWR domain → macro → profile). The child's digital sitting now **skips** teacher-scored skills named in the blueprint cell (the cell keeps them, so the domain reads partial until the teacher records the list) and blueprint publish no longer demands a digital pool for them. A list is served only for the child's own grade (422); a missing list is a content gap (404); no open window → recorded, not projected. Bands 90 / 75 are an engineering stand-in until the partner's cut points (OQ-19); every row carries `standIn`. Config `word_list_measure_config` (2 rows); content `word_list` (partner-owed); runtime `word_list_assessment` (append-only, academic year, real FKs — db-review lessons 1 / 9). Engine: `set_*` band tokens + ADR-SET-01..03 + PAC-SET-01; the band vocabulary enum is now shared (`MeasureBand`). **2 new operations; `amal-api.yaml` is now 382 paths / 57 WP slices.** Migration `20260925110000_s0_6_word_list_measures` (the S4 migration moved to `20260925100000` so both sort after the database-review branch's `20260923…`/`20260924…` migrations — reconciliation notes in `docs/superpowers/seams/db-review-sync-2026-09-23.md`).

## Oral reading enters the skill-status engine (WP-S4, partner round 4 B5/B6), 2026-09-23

A stopped oral-reading sitting (`POST /api/orf/assessments/{id}/stop`) now projects **two measures — reading accuracy and reading fluency/rate — each as its own skill status**, never averaged and never converted to an n/3 pattern. Each measure is banded against the grade norm (`expected · below_expected · markedly_below`) and written as an `oral_reading_benchmark_probe` evidence row on its carrier skill (accuracy → `FL-C01`, rate → `FL-D01`), then runs the same chain a diagnostic does: skill snapshots → the `FL_ACC` domain once (the four-combination read: both within range → adequate; one measure below expected → that measure is the concern; both below → the family; any measure markedly below → gateway-driven concern) → macro tile → profile (pending teacher review). Until the partner norm table (OQ-15) lands, the seeded benchmark verdict and the 95 / 90 accuracy floors stand in — global config `orf_measure_band_rule` (7 rows), version-pinned on every row. `Not_Assessed` (no norm, not-applicable window, unbound sitting) writes nothing; a re-probe in the same window supersedes (skill snapshots gain `basisRevision`, append-only). The stop response gains `projection {written, window, accuracyBand, rateBand, skippedReason}`; teacher-facing fluency stays WCPM. **Two bugs fixed:** the Palestinian ORF norm rows (`ORF_WITH_DIACRITICS`) were unreachable — every PS verdict was `Not_Assessed`; and `gradeBand` is now derived from the student profile when a client omits it (the web ORF page sends none), on the start and the probe route. **Policy flip:** `Accuracy_Fluency` is now a required domain for Grades 2–4 — a G2–4 profile without an oral-reading result this window is DATA_INCOMPLETE. **No new operation; `amal-api.yaml` stays 380 paths / 56 slices.** Migration `20260923130000_s4_orf_measure_bands`. Engine: 3 ADR-ORF + 1 PAC-ORF + 2 DDR-FL rule rows; deferred domain passes now carry a derived pattern (DDR-04/05/06/07 were unreachable from a diagnostic).

## Contact requests replace the demo signup (marketing lead capture), 2026-09-23

The marketing "request a demo" forms (the header/hero modal, `/signup`, `/summit`, the landing concepts) now **`POST /api/contact-requests`** — anonymous, Turnstile-gated, rate-limited (5/IP/hr, 3/email/hr, IP kept only as a salted hash). Nothing is provisioned: the request is stored in `contact_request` first, then the team inbox (`CONTACT_INBOX`, else the `MAIL_FROM` address, else `SMTP_USER`) gets a notice and the requester an Arabic-first acknowledgement — a mail failure is logged, never shown to the prospect. **`GET /api/admin/contact-requests`** (true super-admin) lists the newest rows. **2 new operations; `amal-api.yaml` is now 380 paths / 56 WP slices.** Migration `20260923120000_contact_requests`. Client-side the flow is form → "received" (no polling, no credential reveal), `lib/demo-site.ts` and the second-deployment branches (`IS_DEMO`, `DEMO_REDIRECT_URL`, `MAIN_SITE_URL`) are gone, and the components are renamed `contact-request/*`.

## Demo tenant retired (WP-S10), 2026-09-23

The self-serve demo product is gone: the `demo` and `demo-admin` modules (registration, provisioning, approval gate, expiry sweep, act-as impersonation, observability), the `DEMO_ADMIN` persona and its 16-code role, the demo-tenant read-only/outbound-block/simulation-org middleware on every route that carried it, the `impersonatedBy` field on `GET /api/auth/me`, the `impersonation` activity source, the `DEMO_*` error codes, the demo welcome / request-received emails, `seed:demo`, and the separate `app-demo` deployment (Cloud Run service, compose services, Caddy hosts, `growamal_demo` database). **12 operations removed; `amal-api.yaml` is now 378 paths / 55 WP slices.** Migration `20260923110000_s10_retire_demo_tenant` drops `demo_registration(_event)`, `demo_config`, `DemoImpersonationLog`, `session.impersonatedByUserId`, `organization.isDemo`, the `DEMO_ADMIN` role row and the enum value — and **refuses to run while demo rows exist**: operators run `pnpm --filter server purge:demo-tenants --confirm` (dry-run by default) on each database first. Client-side: `/demo`, `/demo-login`, `/demo-admin`, `/admin/demo-tenants` and the impersonation banner are removed; the `demoMode` provisioning props and the `amal_demo` cookie are gone. The marketing "request a demo" forms still post to the removed `/api/demo/register` and are the next decision (lead capture vs. removal).

## Pilot-1 simplification (WP-S0 · S1 · S2): day-zero config, engine gates, and the closed taqyeem → profile → support chain, 2026-09-23

Everything a first school needs for "assess → skill status → simple profile → support", built so the fuller V5 profile/bundle model turns on by configuration later. **1 new operation. `amal-api.yaml` is now 393 paths / 428 operations.** Migrations `20260922100000_s1_coverage_basis`, `20260922101000_s1_required_domains_by_grade`, `20260922102000_s1_pilot_profile_allowlist`, `20260922103000_s2_snapshot_basis_revision`. Plan: `docs/superpowers/plans/2026-09-22-pilot1-simplification-build-plan.md`.

- **Day-zero config (S0).** `pnpm seed:pilot-config` publishes the three screening windows and the G1 starter blueprint (11 phonological-awareness skills × BOY/MOY/EOY) at org scope. `PUT /api/admin/diagnostic-blueprint` now **rejects an inactive skill (422)** and reports a thin item pool as `warnings[{skillId, servable, need}]` on 200 — or 422 when the body carries `strict: true` (the admin UI will send it; scripts stay lenient).
- **Blueprint-relative coverage (S1).** Domain coverage was measured against every active skill in the domain, so a G1 child sitting an 11-skill blueprint stayed "partial" forever. The denominator is now the published blueprint cell for the child's grade × window (school → org → global), then the skill grade band, then the whole domain; the choice is persisted as `domain_status_snapshot.coverageBasis` for replay.
- **Required domains per grade + Pilot-1 profile allow-list (S1).** Two global config tables (`required_domain_by_grade`, `pilot_profile_allowlist`). A required domain that is *absent* this window halts the profile as DATA_INCOMPLETE (it used to be silently ON_TRACK). Pilot 1 surfaces only ON_TRACK · MONITORING_PROFILE · FOUNDATIONAL_DECODING_GAP (+ two modifiers); no row for a grade = fail-open, so the full V5 engine remains reachable and tested.
- **The chain is closed (S2).** After a diagnostic finishes: skills → each affected domain **once** (after every sibling skill has landed — previously the version-pinned domain row froze at the first sibling's 1/N coverage) → macro → **`recomputeProfile`** (pending teacher review). **`POST /api/profile/{studentId}/review-resolution` `confirm` now chains `recommendBundle`** and returns `bundle {written, bundleId, objectType, actionCode, idempotent}`. New **`POST /api/profile/{studentId}/recompute`** (`recomputeProfile`, ClassTeacher-scoped) is the manual re-drive.
- **Snapshot basis revisions (S2).** `domain_status_snapshot` and `macro_domain_status_snapshot` gain `basisRevision` in their unique keys. When the set of feeder snapshots changes at the same rule version — a confirmatory mini-set resolving a paused gateway skill — the writer **appends** revision+1 instead of a no-op; the earlier row stays as history (B.7). Mini-set completion then re-chains the profile, so a child moves DATA_INCOMPLETE → pending FOUNDATIONAL_DECODING_GAP with no manual step.
- **Explicit "no screen" cells (T0.5, partner round 4 A1 — Grade 1 sits no BOY screen).** `PUT /api/admin/diagnostic-blueprint/{grade}/{cycle}` now accepts `subSkillIds: []` as an explicit "no universal screen for this grade × window" cell. `GET /api/student/today-plan` gains `screeningState: "not_screened"`: the open window is skipped calmly for that grade (the home shows "nothing open", never "no questions ready"), and no content-gap alert fires. A *missing* cell is unchanged — it stays the loud QA_040 path, so a forgotten cell is never read as a decision. `seed:pilot-config` publishes the G1 BOY empty cell always and the G1 MOY/EOY placeholder lists only to a demo org or with `--allow-placeholder`.
- **Training from measured skills (WP-S3, partner round 4 B3/B4).** The practice seed is now a **status → experience-type router** (`remedial · reinforcement · maintenance · enrichment · collect_evidence`) over a new global config table `practice_experience_map` — an on-track child gets a maintenance/enrichment queue instead of a 409, a gateway weakness leads, and `do_not_decide_yet` mints nothing. `practice_queue.experienceType` records what the anchor opened. **Serving is at-grade only, everywhere** (measurement and practice, the blueprint pool count too): no fallback to another grade — an eligible skill with no at-grade practice item raises the new **QA_041** (`platform_alert_log`, deduped per grade × skill per day; the registry is now 41 checks) and the seed moves to the next eligible skill in the same grade. A **teacher-distributed session supersedes** the child's diagnostic-seeded queue (`supersededByQueueId`) instead of being skipped; teacher-vs-teacher still skips. Practice serves run **under the child's live bundle** when a scaffold-tier row exists for the skill (audit row carries the tier), tier-agnostic otherwise. Migration `20260923100000_s3_practice_experience_map`. No route change.
- **Unchanged invariants.** No LLM in any measurement step (V-5); no single student percentage (V-3); append-only measurement tables (B.7); the teacher decides at the same point she already did (V-12).

## Manager experience (WP-MGR) BE-4: the meeting form, the class summary, the students export, and the two partner-owed catalogues, 2026-09-19

The server-rendered artifacts a data-review meeting is actually run from, plus the catalogue contracts for the content the partner still owes. **6 new operations. `amal-api.yaml` is now 392 paths.** No migration — every table this slice writes to already shipped in BE-0/BE-3.

- **`GET /api/principal/students/{studentId}/meeting-form.pdf`** (`principalStudentMeetingFormPdf`) renders the partner's «نموذج مناقشة الحالات الطلابية» pre-filled with this child's status-level data — tile bands, the selected domain's skills, the active plan, the latest monitoring verdicts, the open requests — followed by **blank ruled areas**. The discussion and the decisions are written on paper and never return to Amal: there is no free-text field on this surface to receive them (V-12 / B.24), and the paper form is what replaces one. Carries only the FR-MGR-44 allow-list: no θ, standard error, δ, item response, evidence row, scaffold tier, XP, date of birth, parent contact, or `quickCode`.
- **`GET /api/principal/classes/{classId}/summary.pdf`** (`principalClassSummaryPdf`) is the B-03 class status page on one sheet: class info, the dominant-pattern card, the per-skill table with its full band distribution, and the discussion prompts. Aggregate-only — it names no child.
- **`GET /api/principal/classes/{classId}/students/export`** (`principalClassStudentsExport`) serves **exactly the rows the class table would render for the same filter object**, as CSV (UTF-8 with a BOM, CRLF, every field quoted — the BOM is what stops Excel on Windows reading Arabic names as mojibake) or a landscape PDF table. It calls the screen's own builder rather than a second query, so "export what I am looking at" is literally the same computation. `cursor` and `limit` are **rejected with 422**, not ignored: an export is the whole filtered set. Above `exportMaxRows` (default 500) the caller is asked to narrow the filter and told both numbers — never handed a silently truncated roster.
- **`GET /api/principal/classes/{classId}/discussion-prompts`** (`principalClassDiscussionPrompts`) reads «أسئلة للمناقشة» from the closed, partner-authored `discussion_prompt_catalog`, keyed (macro tile × pattern type). **Never generated** (V-5 — Teacher AI is Wave 2). The catalogue ships empty pending Q-MGR-10, so today it answers `prompts: []` with `catalogVersion: null`, which is the empty-state contract the client renders «قريبًا» from — not an error, and never a cue to substitute generated text.
- **`GET /api/principal/meeting-templates`** and **`/{templateCode}/download`** list and serve the four static partner templates (Q-MGR-9). Availability is probed from disk, so the partner drop flips the card live with no code change; until then every row reports `available: false` with a null download path. **Neither route writes an audit row** — the `StudentDataAccessLog` perimeter is student-identifying responses, and a blank template names no child. The same document *pre-filled* (the meeting form above) writes exactly one. That asymmetry is asserted by a row-count deep-equal across a template download.
- **Percent policy on paper (FR-MGR-43).** A PDF is a rendered surface, so every share printed on one carries its counts — `12 من 23 (52%)` — and a zero denominator prints `— (0 من 0)`, never `0%`: "nobody has been measured" and "nobody met the bar" are different claims. There is no cross-skill or cross-domain rollup anywhere on these sheets.
- **The Q15 framework-label gate was widened.** `test:render:no-framework-substrings` was path-scoped to one module, so every manager render and both shipped PDF renderers sat outside it. The path set now covers `items + principal-views + parent-pdf + board-pdf`, and the new test derives its banned set from the shipped language-rule seed rather than a second hand-maintained copy.
- **Demo tenant.** `seed:demo` now populates the manager surface: four grades with at least two classes each in the hero school, both Data Room queue families, two support groups, an active and an inactive teacher, an upcoming meeting with its agenda snapshot, a past meeting, one request per status, and the edge states the screens must prove (a child with no assessed tile, a skill awaiting data, a visible not-assessed remainder, a recorded context flag).

## Manager experience (WP-MGR) BE-1 · BE-2 · BE-3 · FE-2 · FE-7: the five aggregate screens, the student-level reads, principal requests and meetings, and the flavour storyboard, 2026-09-19

The rest of the manager-experience redesign on top of the BE-0/FE-0/FE-1 foundation below. **28 new operations. `amal-api.yaml` is now 386 paths / 421 operations** (from 359 / 392 on `main`). Migrations `20260918124000_wp_mgr_be_meetings_and_requests`, `20260919120000_wp_mgr_be_student_visibility`.

- **BE-1 — aggregate reads.** Fourteen routes behind the manager screens: the grade, class and support-cohort folds, the attention signals, the Data Room queues, the platform-usage KPIs and scoped search. Every share carries the counts it was computed from and names its denominator; a count that has not been measured reports `null`, never `0`.
- **BE-2 — student-level reads (decision D-1).** Eight audited routes put a child's name and status-level data in front of a supervisor for the first time, plus the student halves of search, the attention list and the Data Room queues. The response shape is a **constructed allow-list**, never a subtraction: θ, standard error, δ_prior, item responses, evidence rows, decision-packet internals, per-student scaffold tier, XP, date of birth, parent contact and `quickCode` are all absent by construction. One `StudentDataAccessLog` row is written per student-level response, before the response; a cross-scope request returns **404 having written nothing**. `Organization.studentLevelVisibility` is a kill-switch, default on.
- **BE-3 — the first principal writes.** A 26th permission code (`CREATE_PRINCIPAL_REQUEST`), `principal_request` with its append-only status spine, data-review meetings with an immutable agenda snapshot, three notification kinds, and a seam that opens a tier review in-process. **No principal route touches `student_tier_status`** — a tier change is a *request*, never a write (B.20).
- **FE-2 — the five aggregate screens.** School dashboard, grade board, class-board top half, support groups and platform usage, in the partner's layout and colour.
- **FE-7 — the flavour storyboard.** Four treatments of one mock school at `/principal/storyboard/*`, for partner review. Mocked data only; no endpoint is read and every action control is inert.

## Manager experience (WP-MGR) BE-0 · FE-0 · FE-1: `principal-views` module and the batched school dashboard, student-data access log, versioned school config, the partner's sidebar and header, the percent gate, 2026-09-18

Backend + principal/manager shell. The first three slices of the manager-experience redesign (PRD `prds-v2/40-teacher-experience/45-manager-experience-redesign.md`; design `docs/design/wp-mgr-be/` + `docs/design/wp-mgr-fe/`). No principal-facing screen has content yet *at these three slices*: BE-0 is the foundation read, FE-0 the rendering primitives and lints, FE-1 the shell. (The screens land in FE-2 — see the entry above.) **1 new operation. `amal-api.yaml` is now 360 paths / 393 operations.** Migrations `20260918120000_wp_mgr_be_config_and_catalog`, `20260918122000_wp_mgr_be_access_log`, `20260918123000_wp_mgr_be_read_indexes`.

- **`GET /api/principal/schools/{schoolId}/dashboard`** (`principalSchoolDashboard`, tag `principal-views`; `VIEW_SCHOOL` / `VIEW_ORG` / `RUN_BACKOFFICE`) is **one batched school fold** per `(schoolId, window, period)`, folded in memory from a single read set: there is no per-class loop, and the query-count guard below proves it. The body carries the five Wave-1 macro tiles, each with `counts` over six display bands (`meets`, `approaching`, `below`, `severe`, `under_review`, `not_assessed`), a `meetsShare` and the tile's own worst-case-wins `band`; `enrolledCount` / `assessedCount`; the three usage mini-KPIs (`teachersActive`, `studentsActive`, `plansOnPlan`, with `periodFrom` / `periodTo` and `partialPeriod`); the `dataSufficiency` verdict (`sufficient`, `weeksElapsed`, `requiredWeeks`, `studentCoverage`, `blockingReasons`); `attention` (counts per alert family — `[]` until BE-1); `queueCounts` (`null` until BE-1 and the `dataRoomOverviewStrip` knob); `wave2Tiles` (present only with `showWave2Tiles`). Query: `window` (`BOY` | `MOY` | `EOY`; omitted = the school's current window from the screening calendar) and `period` (`this_month` | `last_30_days` | `last_90_days`, capped at the 90-day telemetry horizon). `Cache-Control: private, max-age=60`. **No student identifier leaves this response**, so it writes no access-log row.
- **The `meta` envelope** on every response of the module: `{ assessmentWindow, computedAt, ruleVersion, configVersion, stale, languageRuleSetVersion }`. `stale` is derived from data recency (the newest contributing snapshot older than `snapshotStaleMinutes`, default 60), never from a refresh clock; no contributing row at all ⇒ `stale: true` with `computedAt` at the request instant. `ruleVersion` (fold rules) and `configVersion` (thresholds; `0` = the code defaults) are pinned separately so a replay can tell "the rules changed" from "the school retuned a knob" (V-6). `languageRuleSetVersion` is `null` on aggregate reads.
- **The `Share` shape** is the only percentage on a manager surface: `{ numerator, denominator, value, shareOf }`, `value` in `[0, 1]` and **`null` whenever the denominator is 0** (rendered «—», never 0%); `shareOf` ∈ `assessed_students` | `enrolled_students` | `measured_skills` | `active_plans` | `active_teachers` | `enrolled_students_active`. `Not_Assessed`, `Partial` and `Contextual` are shown as their own bands and excluded from every denominator, never folded into a 0. There is no "overall literacy" field anywhere in the DTO (V-3).
- **Errors — read this before integrating.** `403` is only the permission gate. **Every scope outcome is `404`**: a school that is cross-tenant, cross-school, outside a MANAGER's `scopedSchoolIds`, or simply absent is indistinguishable on the wire (existence non-disclosure), and a `404` writes no audit row. Do not read a `403` as "exists but forbidden" on any `/api/principal/**` route. `422` for an invalid `window` / `period`.
- **Three new tables.** `student_data_access_log` is the audit trail behind D-1 (principals see student names and status, read-only): **one row per student-level HTTP response** (a 25-row roster is one row with `subjectStudentCount = 25`; a tab switch is its own request and so its own row), **written before the response is served** (a failed write fails the request, so no student-level body is ever served unaudited); `subjectStudentUserId` is `User.id`; `filters` is a closed key set and an unknown key is rejected; a search term is never stored, only `queryLength`; `requestId` ties the row to the HTTP trace. Append-only by CI lint (`lint:no-update-on-audit-tables`) and a single write path (`lint:principal-views-single-write-path` → `repository/access-log-writer.ts`), which is a lint and a convention, not a database guarantee. BE-0 ships and tests the writer; the student-level routes that call it arrive in BE-2. `school_literacy_index_config` holds the manager-surface thresholds, **17 knobs** (`minimumWeeks`, `studentCoverageThreshold`, `gaugeBandCuts`, `classConcernShare`, `classMinAssessed`, `classPacketCount`, `dataIncompleteDays`, `partialVerdictStreak`, `usageInactiveDays`, `groupSlowShare`, `minComparablePoints`, `snapshotStaleMinutes`, `exportMaxRows`, `persistQueueSnapshots`, `showWave2Tiles`, `dataRoomOverviewStrip`, `privacyFooterKey`), **publish-then-freeze**: an edit inserts `version + 1` and deactivates the prior row, and a partial unique index guarantees exactly one active row per `(organizationId, schoolId)`; resolution is school row → org row → the code defaults, which reproduce the previous hardcoded `PRN_CONFIG` byte-for-byte, so `configVersion = 0` means "unchanged behaviour". The seed creates one active org-level default row for every organization that has none. Every threshold is partner-owed (Q-MGR-4 / Q-MGR-5); the defaults are the PRD's working values. `discussion_prompt_catalog` is global partner-authored config, publish-then-freeze, with **zero rows** in Wave 1 (Q-MGR-10); the seed pins the count at 0 so the partner's drop is a visible, deliberate change; the UI shows «قريبًا» and nothing is generated (V-5).
- **Design corrections worth an integrator's attention.** The PRD's `runtime_audit_log` does not exist: `runtime_audit_row` is the decision spine (no `studentId` column) and is not the audit trail; `student_data_access_log` is. The `school_dashboard_snapshot` view named by 44 FR-PRN-11 was never built and is not built here: R0 folds the live `macro_domain_status_snapshot` spine over one new partial covering index (`DISTINCT ON` and a materialized view are the measured escalation ladder, Wave 2). 44's "403 for out of scope" sentence is amended to the 404 rule above. `lib/scope.ts::resolveStudentScope` stays frozen (it answers `forbidden` for principals and 29 write call sites depend on that); the module-local `resolveSupervisorStudentScope` delegates to the shipped `canReachStudent`, and `lint:principal-scope-isolation` enforces the boundary.
- **CI.** A new job **`wp-mgr-be`** with its own Postgres 16 service runs three blocking steps: `lint:mgr` (single write path · scope isolation · `Share` constructor, where `lint:principal-share-constructor` rejects a raw ratio outside `engine/shares.ts` · append-only audit tables · request-id on activity tables · tier-status single write path · no-LLM, with `principal-views` added to that lint's covered set), the **per-route query-count guard** (`test:mgr:query-count`: R0's query count, pinned at 21, must be identical on a 4-class and a 30-class fixture, because a fast N+1 on a small fixture reads green to a latency check), and the module suite (`test:principal-views`). The same three commands are appended to `test:qa`. Measured at BE-0 exit on a 750-student fixture (opt-in `PERF=1` harness, not in CI): R0 p95 202 ms against the 2 000 ms gate.
- **Known gaps.** `attention` is `[]` and `queueCounts` is `null` until BE-1 ships the alert families and the queue engine (a zero-count family is omitted by contract, so "not built" and "not firing" render the same). `DEMO_ADMIN` receives `404` on R0 (the shipped `canReachSchool` has no DEMO_ADMIN branch: fail-closed, pinned by a test, resolved with BE-4's demo seed), so the demo tenant cannot show the new dashboard yet. The admin audit page does not yet list principal accesses (BE-4). `pnpm test:tenant-isolation` is still not a CI step; it becomes a blocking one before BE-2 merges.
- **Web — primitives and lints (FE-0).** `ShareValue` (`components/viz/share-value.tsx`) is the only component that prints a `%` on a principal or manager surface: it emits `data-share-of="<numerator>/<denominator>"` (or `data-rate-of="<key>"` for a figure with no count pair, such as plan fidelity) plus a visually-hidden «n من d …» phrase, and renders «—» with no attribute for a zero denominator. Two new lints run inside `pnpm --filter web lint`, which the `build` job runs: `lint:no-global-percentage` (AST-based: it judges rendered text positions and calls to percent formatters, not CSS values, the modulo operator or comments; escape hatch `// eslint-percent-allow: <reason>`) and `lint:no-inline-arabic` (an Arabic literal inside the manager tree must come from `lib/manager-copy.ts`, `lib/status-display.ts` or `lib/arabic-count.ts`; pre-existing files sit in a dated baseline). Root proxy `pnpm test:render:no-global-percentage`. D-5 vocabulary lands platform-wide in `status-display.ts`: the `Approaching` chip now reads «تحت المتابعة» and `LISTEN_COMP` reads «الفهم المسموع» (teacher surfaces included).
- **Web — shell (FE-1).** The principal sidebar is the partner's six + three: `/principal` · `/principal/grades` · `/principal/students` · `/principal/support-groups` · `/principal/usage` · `/principal/data-room`, then «الإعدادات» · «مركز المساعدة» · «تسجيل الخروج» (the last is a new `kind: "action"` nav row that opens the account dialog rather than an href) and one motto block. The manager sidebar keeps «لوحة المؤسسة» (`/manager`) and adds school-scoped shortcuts on `/manager/orgs/:orgId/schools/:schoolId/{grades,students,support-groups,usage}` (resolved from the pathname or the last-visited school) plus the org-level `/manager/data-room`. The header on every principal/manager screen shows school + academic year, the scoped search (debounced onto `GET /api/principal/search`, a BE-1 route, so today it degrades to «البحث غير متاح حاليًا.»), the bell with its unread count and the user chip with a menu chevron; pages below the landing get a home-icon breadcrumb and, where the design shows one, a back link. `/principal/implementation` → `/principal/usage#implementation` and `/principal/review` → `/principal/data-room` are `307` redirects from `next.config.ts` (query preserved, kept for one release); `/principal/growth` and `/principal/leaderboard` keep their pages and only leave the sidebar (D-8). Twelve principal and five manager routes render an honest «قيد الإنشاء — يصل مع الشريحة التالية» placeholder with the real title and breadcrumb; the `/principal` landing is still the existing dashboard until FE-2 renders the partner's layout on R0. An out-of-scope page renders a 404 card («هذه الصفحة غير متاحة ضمن نطاقك»), never a 403.

See [Principal](https://docs.growamal.com/en/using-the-app/principal/) and [Manager](https://docs.growamal.com/en/using-the-app/manager/) for the current screens, [Managers & Principals](https://docs.growamal.com/en/guides/managers-and-principals/) for the roles and scopes, and the [API Reference](https://docs.growamal.com/en/api-reference/) for the full `MgrSchoolDashboard`, `MgrShare` and `MgrMeta` shapes.

## Admin user management: cascading review, full CRUD, and password reset with email delivery, 2026-09-16 (reconciled; originally 2026-09-07)

**`amal-api.yaml` is now 352 paths / 385 operations.**

This entry is rewritten to describe the surface after reconciling the 2026-09-07 design onto #138 ("onboard the first customer", 2026-09-09). Ledger: `docs/superpowers/specs/2026-09-16-admin-user-management-reconciliation.md`.

- **`GET /api/users`** (`listUsers`) cascading filters: organization (platform super admin only), school, persona, class, status, and search, with offset pagination and a `total` count.
- **`GET /api/users/{id}`** and **`PATCH /api/users/{id}`** (`getUser` / `patchUser`) are now one merged shape. The detail and the edit both carry #138's identity fields, the four Arabic name parts that compose `fullNameAr` on the server, plus email (the adult login identifier), phone, and username, next to status (suspend / reactivate) and a persona sub-object: a student's class move, a teacher's class set and school binding, a principal's school reassignment, a manager's scoped schools, or an admin's specialist seat.
- **`DELETE /api/users/{id}`** (`deleteUser`) soft-deletes: sessions and tokens are revoked, the persona cascade runs, the email is tombstoned so the address can be reused, and the last active super admin can never be removed.
- **`POST /api/users/{id}/password/reset`** (`resetUserPassword`) is the per-user set-or-reset action: generate a credential or type one, with a one-time reveal and optional delivery to the assigned or an alternate address. A student's credential is a single 4-digit code, the same number that opens the class door and the home login, so resetting it rotates both together.
- Bulk delivery is unchanged and stays #138's **`POST /api/users/credentials/send`**: an admin selects several accounts and emails them their sign-in details, auto (resend for students, reset for adults) or reset (rotate everyone). The two delivery paths coexist by design: the row action above is the per-user reset, the toolbar action is this bulk send.
- `AuthEventLog` rows now carry `actorUserId` and a closed-key `auditMeta` on every admin-initiated write, next to #138's `detail` column. Seven event kinds belong to this reconciliation (`admin_password_reset`, `credential_email_failed`, `user_created`, `user_updated`, `user_suspended`, `user_reactivated`, `user_deleted`); `credential_emailed` is #138's and now also carries `actorUserId`.

See the [API Reference](https://docs.growamal.com/en/api-reference/) for the full request/response shapes.

## Teacher sessions hub: one list for every session kind, completion rosters, per-child answer review, re-send, printable roster, 2026-09-11

Backend + teacher. A teacher used to create and follow sessions in four disconnected places, and none of them answered "who finished, and what did each child answer". `/teacher/sessions` is now one **list** across her classes with four tabs (تدريب = a generated session, تقييم = a class screening or one re-measure, قراءة = a planned reading batch, حصّة مباشرة = a live session), and each row opens a roster. Everything shown was already on disk, so there is no new table and no migration. **7 new operations. `amal-api.yaml` is now 355 paths / 386 operations (counted after merging the ORF sub-word marks release).**

- **`GET /api/teacher/sessions?kind=training|screening|reading|live&classId&cursor&limit`** returns one kind per call, newest first, with an opaque `(createdAt, id)` cursor and a per-page count block `{ total, completed, inProgress, notStarted, other }`. These are counts, never a percentage. Omitting `classId` means all the caller's classes; a class the caller does not teach answers `403`. A running live session carries `counts: null`, because per-student detail exists only once it has ended. A cursor this endpoint did not issue answers `400` instead of silently rewinding to page one.
- **`GET /api/sessions/{id}/progress`** is the per-recipient roster of a **delivered** generated session: `completed`, `in_progress`, `not_started`, `stopped`, `skipped` or `excluded`, with `itemsAnswered`, `itemsCorrect`, `itemsTotal` and `lastActivityAt`. Online rows derive from the practice queue plus the append-only response ledger (the last attempt per item wins, and a row that failed to load its media is not an answer); printable rows derive from the marks the teacher entered. `409` before delivery, `404` outside the caller's organization.
- **`GET /api/sessions/{id}/recipients/{studentId}/responses`** returns one child's item-level answers: the stem, the options, what was chosen, what the key is, and the stored error type. Chosen and correct come back as lists of option ids (`chosenOptionIds` and `correctOptionIds`) resolved through the grader's own option mapping, so a multi-select or an ordered item carries all of its values, and a legacy answer stored as option text lands on the same id. `orderSensitive` says the sequence is the construct. `reviewable: false` marks the interaction kinds graded from the server-side key (typed spelling, tile blend, syllable cut, multi-slot cloze, word ordering), which the UI shows as "not reviewable here" instead of a blank row.
- **`GET /api/classes/{classId}/screening-triggers/{id}/progress`** maps the class's active roster against the sittings opened under one trigger. The latest sitting per child wins, and a child who left the class is excluded from both the roster and the tab's counts. `completedAt` is the sitting's finish time only, never a fallback to its start time.
- **`POST /api/sessions/{id}/resend`** (body `{ studentIds }`, up to 200) re-delivers a delivered session to named recipients through the same per-recipient path the first delivery used. Every requested id comes back in exactly one bucket: `queued`, `stillBlocked` (the child still has another practice session open), `alreadyWaiting` (already holds this session), or `notResendable` (a finished or abandoned queue, an id that is not an eligible recipient, or a printable copy with no PDF yet). Printable recipients get their existing per-child PDF back for re-download, and nothing is regenerated. The call is idempotent, and it is not an override: it retries delivery and never changes a decision.
- **`GET /api/sessions/{id}/progress.pdf`** and **`GET /api/classes/{classId}/screening-triggers/{id}/progress.pdf`** print the roster (A4, right to left: name, status word, correct/total). Cells are single-line, so the page count follows the row count, and a render over 500 KB answers `503` rather than an unbounded body.
- **Also:** `GET /api/reading-assessments/batches/{id}` rows carry `wcpm` and `accuracy` beside the existing `administeredAt`. Both stay null until the reading has been stopped and scored, so an administered row whose reading is still open carries nulls. A child's own session history gains the kind `generated`, a session her teacher printed for her; an online delivery is not listed twice, because it already appears as practice.
- **Teacher pages:** the sessions hub with kind tabs and class chips; screening, reading and live detail rosters; a completion roster on a delivered session (filter, select non-completers, re-send, print); and a per-child answer review that marks the chosen and the correct option side by side. Counts only, and growth language throughout: «توقّف مؤقتًا» for a stopped session, «لديه تدريب جارٍ» for a child who already had one open.

## Per-modality goal lines, CBM-eligibility guard, Arabic not-found, 2026-09-11

Backend + web. Five follow-ups from the ORF sub-word-marks review. **No new operation; `amal-api.yaml` stays at 348 paths / 379 operations.** Migration `20260911120000_orf_sub_skill_and_aim_line_cbm_type`.

- **`aim_line` is now per-modality** — the key is `(student, sub-skill, cbmType, version)`. A trend series was already per-modality but the goal line it is compared against was not, and activation is first-wins, so three accuracy probes (a 0-100 percent) could mint the baseline the words-per-minute trend then read as its goal, pinning that series permanently below goal. Existing rows are backfilled from the modality of their own baseline cohort. `POST /api/cbm/aim-lines` accepts `cbmType` (defaults to `ORF_WCPM`).
- **A CBM/ORF measurement may only be filed under a CBM-eligible sub-skill** (the four FL-ACC rows, `skills_master_table.cbmEligible`). `POST /api/orf/assessments/start` and `POST /api/cbm/probes` now answer `400 cbm_skill_not_eligible` otherwise. Before this, a hand-made deep link could file an oral-reading measurement under a comprehension sub-skill, where it silently joined the school fluency rollup and moved the median. The unbound offline sitting (`subSkillId: null`) is unaffected.
- **`oral_reading_assessment.subSkillId` is a real column** — it lived only inside `benchmarkValuesSnapshot.__subSkillId`, invisible to SQL and to every reader but the stop handler. Backfilled; the snapshot pin is still read as a fallback for sittings opened earlier.
- **Arabic not-found pages** for the root, the adult shell and the student world. Every one of the 63 `notFound()` call sites previously fell through to Next's built-in English page.
- **Shared dialog accessibility on Safari/WebKit** — Base UI's focus sentinels render as nameless ARIA commands there (an axe `aria-command-name` violation on every open dialog), and because WebKit does not focus a button on tap, closing a dialog returned focus to the first tabbable element on the page instead of the opener. Both fixed in the primitive, so all 50 dialog call sites benefit.

## ORF sub-word error marks: which letter or diacritic was misread, 2026-09-09

Backend + teacher. While scoring an oral-reading measurement the teacher can open «تفصيل» on a marked word and select the exact letter(s) or diacritic(s) the child missed; the selection is stored with the sitting and shown on the result view, on a read-only page per past measurement, and in the profile's fluency column. Observation only — WCPM, accuracy, CAMELS cards, trend and RTI are unchanged. **1 new operation. `amal-api.yaml` is now 348 paths / 379 operations.**

- **`POST /api/orf/assessments/{id}/stop`** accepts optional `error_details[]` (`{ word_index, group_index, part: "letter" | "mark", mark_index?, char }`) next to `error_word_indices`. Every entry must re-derive from the passage word with the item importer's letter-group splitter (`graphemeSplitVersion` v1); any mismatch, a word outside the final misread set, or details without the final set → `422` and nothing is written. Rows land in the new append-only `oral_reading_error_detail` table (migration `20260909120000_orf_error_detail`).
- **`GET /api/orf/assessments/{id}`** returns one sitting read-only: the passage grid, `errorWordIndices` (the scored set; legacy rows fall back to the distinct tap-log indices), `errorDetails`, and null numbers until stopped. Teacher scope (ClassTeacher / group reach) → `403`; other org → `404`.
- **Fix:** `GET /api/reports/students/{id}/sessions?kind=orf` (and the `orf` detail) now lists ORF rows for real students — they were read by `StudentProfile.id` while the ORF writer keys them on `User.id`.
- **Teacher UI:** «تفصيل» button on marked words (during the read and in the last-word step); the dialog; the «الكلمات المقروءة خطأً» card on the result; `/teacher/students/{id}/orf/{assessmentId}` linked from «قياسات القراءة الأخيرة»; the profile fluency panel's «آخر قياس» block.
- **Scoring correction (pre-merge review):** the ORF scorer now counts on the **0-based** word grid every seeded passage uses — `words_attempted = last_word_index + 1` and index 0 (the first word) is a legitimate error. Before, a misread first word was never counted and a full read was one word short. `stop` is now one transaction (session + sub-word rows + both `cbm_scores` rows); `result.scoredErrorWordIndices` stores the engine's in-range set; a detail on a word marked beyond the last word reached is dropped with the mark (never a `422`); the `orf_probe_event` telemetry payload accepts `detailCount` and `administered`.

## Screening windows: admin calendar per organization/school + teacher class trigger, 2026-09-09

Backend + admin + teacher + student. A child is offered — and may start — the diagnostic only inside an open **screening window** (BOY / MOY / EOY). The window comes from a class trigger the teacher opened, else from the calendar the admin set (school → organization → global, per window). Nothing open → no diagnostic. The app no longer reads the `amal_cycle` cookie (only the mocked Playwright stack does, behind `E2E_MOCK_SEAM=1`). **6 new operations. `amal-api.yaml` is now 347 paths / 378 operations.**

- **`GET /api/admin/screening-windows?organizationId&schoolId`** returns the three effective windows at a scope (`startsOn` / `endsOn` dates, `source`, `inherited`; null dates = disabled). **`PUT /api/admin/screening-windows/{window}`** publishes a date range at the body's scope (supersede-and-version, `409 concurrent_publish`; `422` when the start is after the end, when the range overlaps a sibling window at the same scope, or for an invalid scope). **`DELETE /api/admin/screening-windows/{window}?…`** clears the override. True super-admin, like the blueprint routes. The blueprint audit log gains `windows` (the date history).
- **`GET /api/classes/{classId}/screening-triggers`**, **`POST …/screening-triggers`** (`{ window, startsOn, endsOn }`, at most 31 days, one open trigger per window → `409 trigger_already_open`) and **`POST …/screening-triggers/{id}/close`** — any active teacher of the class opens or closes a screening for her class; principals and admins read. The open response carries `calendarWindow` so the UI can warn when the choice disagrees with the calendar.
- **`GET /api/student/today-plan`** gains `screening: { window, source } | null`; `nextAction` is `"diagnostic"` only when a window is open that the child has not completed this school year (1 Aug → 31 Jul), or a sitting is in progress.
- **`POST /api/diagnostic-sessions/start`**: `screeningCycle` is now optional. Omitted → the server resolves the open window (class trigger first, then the calendar by scope). A STUDENT may only name a window that is open for them today (`409 window_mismatch`); nothing open → `409 no_open_window` (no sitting is created). Teachers and admins may name a window explicitly; `d_triggered` is always explicit. The session row records `screeningSource` (`class_trigger` | `calendar` | `explicit`).
- **Admin page:** a «مواعيد التقييم» strip above the blueprint grid — three cards with dates, inheritance chips and a date-range editor. **Teacher class page:** an «التقييم المفتوح» panel with «افتح تقييمًا» (window + dates, defaults today → +6 days) and «إغلاق». **Student:** a calm «لا يوجد تقييم مفتوح الآن» screen when nothing is open.
- **Rollout:** no window is open anywhere until an admin sets dates or a teacher opens a trigger — set the dates for your organizations right after deploying. The demo provisioner seeds an open BOY window at the demo organization. Migration `20260909100000_screening_windows`.

## Tenant pin on the schools and classes lists; cross-tenant user list by organization; audit log names the publisher; persona labels unified, 2026-09-09

One tenant-isolation fix plus admin-page follow-ups from the end-to-end matrix. **No new operation; `amal-api.yaml` stays at 341 paths / 370 operations.**

- **`GET /api/schools?organizationId=` and `GET /api/classes?organizationId=`** no longer let an organization-bound caller read another tenant: the parameter is honoured for the platform super-admin only (and bypass-logged); a tenant caller stays pinned to its own organization and the parameter is ignored. Before this fix the caller-supplied organization overrode the tenant pin, so an organization admin could list another tenant's schools and classes, including class login codes. Every list handler now goes through one `tenantWhere(c, { organizationId })` policy.

- **`GET /api/users?organizationId=`** narrows the platform super-admin's cross-tenant list to one organization (bypass-logged); a tenant caller stays scoped to its own organization and the parameter is ignored. `/admin/users` shows an organization column and filter for the platform super-admin (identity-based, not inferred from the number of organizations); a malformed `?org=` is ignored instead of failing the page.
- **`GET /api/admin/diagnostic-blueprint/audit-log`** rows carry `publishedByName` (the publisher's `fullNameAr` resolved at read time; `null` only when the user row is gone); the audit page shows the name next to the stable `publishedBy` id.
- **Sign-out and every sign-in path clear the `amal_cycle` cookie** (password, student, magic link, Google SSO, act-as, demo exit), so a shared classroom device never hands one child's screening-cycle selector to the next. Any future setter must write it with `path=/` (`lib/cycle-cookie.ts`).
- **One Arabic label per persona** across the app shell, the roles page, the create dialog, the filters, the activity views and the Arabic portal (previously four drifting copies; the organization manager reads «مدير المؤسسة» everywhere). The shell now uses the same indefinite forms as the admin pages (e.g. «معلّم» instead of «المعلّم» in the settings and act-as banner); the visual-regression baselines for `/admin/users` and `/admin/roles` need a refresh (`pnpm --filter web visual:update`).

## Taqyeem test-cycle fixes: start `reason`, QA_040 start-gap alert, admin taqyeem pool count, content census, 2026-09-08

Backend + admin grid + content. Follow-ups from the 2026-09-08 blueprint enable/disable test cycle. **No new routes; `amal-api.yaml` stays at 341 paths / 370 operations** (two additive response fields).

- **`POST /api/diagnostic-sessions/start`** gains **`reason`** next to `firstItemId`: `no_blueprint` (no cell resolved for the student's grade × window at any scope), `no_eligible_item` (a cell exists but none of its skills has a servable quick-check item), or `null` when an item was served. The child experience is unchanged (the sitting is abandoned at start and the calm no-content screen shows); the field is for operators and the client's diagnostics.
- **QA_040 — diagnostic start with nothing to serve.** New registry check (registry 39 → 40, severity `high`): an empty start writes a `platform_alert_log` row naming organization, student, session, grade, window, school scope, blueprint source/size and `reason`, and notifies engineering — one row per (school scope × grade × window × reason) per day, so a class retrying «ابدأ» on one unset cell is one alert; never surfaced on the teacher dashboard. Listed on `GET /api/qa/runtime-alerts?qaCheckId=QA_040` for the organization's admins. `pnpm test:qa:040`.
- **`GET /api/admin/diagnostic-blueprint`** gains **`skillTaqyeemCount`** (per active skill, the number of operational `quick_check` non-training items — the pool the initial screen draws from), **`skillTaqyeemCountAudio`** (the same pool narrowed to items with audio, the grade-1 audio-first rule) and **`taqyeemScreenSize`** (the server's per-skill screen size, 3). The admin dialog badges a skill below the screen size as «n من 3 أسئلة تقييم», using the audio count for grade-1 cells (the existing «لا يوجد محتوى بعد» badge counted any operational item, so a skill with practice items only never warned).
- **`pnpm --filter server content:census`** prints per-skill taqyeem (quick_check) / tadreeb (practice) / monitoring (probe) counts bucketed against the 3-item screen (`--json` for machines). The 2026-09-08 snapshot is in `docs/product/taqyeem-content-census-2026-09-08.md`.
- **Content.** The 15 partner-pilot picture items (DWR-D01, PA-13 option pictures, SENT-A01 scenes) that pointed at flat placeholder files now ship real illustrations at the same paths; no data change.

## A diagnostic start with no servable item is abandoned, never completed; admin provisioning fixes, 2026-09-07

Backend + admin pages, from the end-to-end matrix that exercised the scoped blueprint through the real UI. **No new operation; `amal-api.yaml` stays at 341 paths / 370 operations.**

- **`POST /api/diagnostic-sessions/start`** now abandons a sitting whose resolved blueprint has no servable item (a grade-1 audio gap, or a cell naming skills with no authored content): the response carries `noEligibleItem: true` with `firstItemId: null` and `state.status: "abandoned"`; the row is written with the new `sessionEndReason` value `no_eligible_item` (migration `20260907070000_session_end_reason_no_eligible_item`). `POST /api/diagnostic-sessions/{id}/finish` on an abandoned sitting answers `404`, so a client can no longer turn a zero-evidence start into a completed screening. A later start for the same student and cycle is not blocked by the abandoned row. The student app shows a calm "no questions ready yet" state instead of a celebration.
- **`POST /api/users` from the admin dialog:** a STUDENT may be created without email and password; the server then mints the username and 4-digit PIN and the dialog reveals them once. «مسؤول النظام» with an organization is created as an organization-scoped admin (`scope: ORG`); leaving the organization empty is the explicit platform-wide super-admin.
- **Admin pages:** the create dialogs (user, school, class, organization) reset when reopened; the class list shows each class's 6-digit login code. The «مسؤول النظام» organization select has an explicit "no organization — platform admin" option, and `POST /api/users` answers `422` when an ADMIN's `scope` disagrees with the organization binding (`SUPER` with an organization, or `ORG` / `SCHOOL` without one).
- **`PUT /api/admin/diagnostic-blueprint/{grade}/{cycle}`** now answers `409` (`reason: concurrent_publish`) when another publish of the same cell landed first: one active row per scope × grade × cycle is enforced by the database (partial unique index `diagnostic_blueprint_active_cell`, migration `20260907080000_diagnostic_blueprint_active_cell_unique`).
- **`POST /api/diagnostic-sessions/start/re-diagnostic`** opens the sitting before archiving the student's practice queues; when no item can be served the sitting is abandoned (`no_eligible_item`), the response carries `noEligibleItem: true` with `archivedQueueCount: 0`, and the practice plan is left untouched.

## Taqyeem blueprint per organization and school; true super-admin gate on the backoffice, 2026-09-07

Backend + admin grid. The initial-diagnostic (taqyeem) skill blueprint — which sub-skills the first sitting covers per grade × screening window — can now be set for the whole platform, for one organization, or for one school. **1 new operation. `amal-api.yaml` is now 341 paths / 370 operations.**

- **Scope on every blueprint route.** `GET /api/admin/diagnostic-blueprint?organizationId&schoolId` returns the *effective* grid for that scope; each cell carries `source` (`global` | `org` | `school`) and `inherited`. `PUT /api/admin/diagnostic-blueprint/{grade}/{cycle}` accepts `organizationId` / `schoolId` in the body and publishes at that scope (each scope keeps its own version chain). `GET /api/admin/diagnostic-blueprint/audit-log` takes the same filters and every row carries `organizationId`, `schoolId` and `scopeLevel`. `schoolId` requires `organizationId`, the organization must exist, and the school must belong to it (`422` otherwise).
- **New `DELETE /api/admin/diagnostic-blueprint/{grade}/{cycle}?organizationId&schoolId`** clears the override at that scope (the active row becomes inactive with no successor; history is kept) and returns `effective` — the cell as that scope now resolves it from its parent scope, or `null` when unset everywhere. `404` when nothing is active at that scope.
- **Resolution at `POST /api/diagnostic-sessions/start`:** the school of the student's active enrollment → the student's organization → global. The first active row wins and replaces the whole cell (no merge). The resolved list is still copied onto the session row, so a later blueprint change never alters a sitting that already started.
- **True super-admin gate.** The blueprint routes and the four benchmark-profile admin routes under `/api/admin/benchmark-profiles` now require a platform super-admin (an account outside any organization). Every organization-admin persona holds the `SUPER_ADMIN` permission code, so the code alone was never a sufficient check; an in-organization admin now receives `403`, and the web pages render the "super-admins only" card.
- **Schema.** `diagnostic_blueprint` gains nullable `organizationId` / `schoolId` and a derived `scopeKey`; uniqueness is per `(scopeKey, gradeLevel, screeningCycle, version)`. Migration `20260907001000_r14_diagnostic_blueprint_scope` keeps every existing row as global. The demo provisioner now seeds its default cells at the demo organization's scope, never globally.
- **Not changed:** the engine's per-domain coverage floor. Publishing validates skill ids and scope only, so a narrow per-school cell can still resolve a student to the incomplete-data profile for that domain (the coverage denominator is a pending partner decision).

## Support groups follow-ups: plan authority, teacher school binding, group recommendations, 2026-09-07

**1 new operation. `amal-api.yaml` is now 341 paths / 369 operations.**

- **`GET /api/support-groups/{id}/recommendations`** returns the TAW_01 recommendation rows (same shape as `/api/workflow/recommendations/{classId}`; `classId` carries the group id) over the group's members; an open acute alert on any of a member's classes marks the row `review_required`.
- **Plan authority.** `POST /api/workflow/activate` (`mode=individual`) accepts `groupId` instead of `classId` (exactly one of the two): the caller must own the non-archived group, the student must be an active member, and the plan is keyed on the student's own class (a class-less member → 422). A group owner also reaches `POST /api/students/{id}/activate-bundle` and the workflow review / exit for her members (decision: a support teacher manages her members like a class teacher). The class wizard's bulk activation and drafts stay class-keyed.
- **Guardrail 2 is now server-side on every activation path:** any open (unreviewed) acute-regression alert for the student, whatever class or window it was raised in, → `409` on `POST /api/workflow/activate` (individual and bulk confirmed members), `POST /api/cluster/bulk-activate` and `POST /api/students/{id}/activate-bundle` (FR-TDS-18) — for a new plan only; a routing outcome (non-bundle recommendation) is never blocked. The class and group `review_required` rows read the same set. The individual path records the chosen `deliveryMode` in the decision log. Group activation leaves the teacher's saved class-wizard draft in place.
- **`DELETE /api/schools/{id}`** answers `409` when a teacher is bound to the school through `TeacherProfile.schoolId` (the FK is now RESTRICT).
- **`GET /api/users/me` (TEACHER)** carries `profile.schoolId` (nullable).
- **`POST /api/users` (TEACHER)** accepts `schoolId` (must be in the organization; derived from the first class in `classIds` when omitted) and stores it on `TeacherProfile.schoolId`; the roster staff `school_ref` sets it too, and existing single-school teachers were backfilled. `GET /api/support-groups/owners?schoolId=` keys on it first.
- Internal: one shared per-student scope primitive (`resolveStudentScope`) replaces six module-private copies; the container entrypoint no longer prints the database URL.

## Support groups: a teacher's own pull-out group across classes, 2026-09-06

Backend + teacher/principal/admin pages (the org admin creates the teacher on `/admin/users`, then her group on `/admin/support-groups`). **11 new operations. `amal-api.yaml` is now 340 paths / 368 operations.**

- **`GET` / `POST /api/support-groups`** list the caller's reachable groups (`?includeArchived=true`, ignored for teachers) and create one; **`GET /api/support-groups/owners?schoolId=`** lists the teachers eligible to own a group in that school (school-bound: linked to the school or not yet linked anywhere; names + class counts, no emails). A TEACHER may only create a group she owns in a school where she teaches; a PRINCIPAL / MANAGER / ADMIN names the owner, who must be that school's (`403` otherwise).
- **`GET` / `PATCH /api/support-groups/{id}`** return the detail with active members (User.id + StudentProfile.id + class) and rename; `archived` is leadership-only (`403` for the owner), and an archived group is out of its owner's reach entirely. **`POST /api/support-groups/{id}/members`** adds by `studentUserIds`: every student must be reachable by the actor's ENROLLMENT (a teacher from her own classes, a principal from her school; never through another owned group); batched, `409` on an archived group. **`DELETE /api/support-groups/{id}/members/{studentUserId}`** soft-removes.
- **`GET /api/support-groups/{id}/summary`**, **`/domain-trend`** and **`/alerts`** return the class DTOs (`ClassSummaryDTO`, `DomainTrendDTO`, `ClassAlertsResponse`) computed over the members, so the group dashboard reuses the class board unchanged.
- **Reach.** A TEACHER reaches a student through class enrollment **or** an active membership in a non-archived group she owns, and every per-student module honours that fallback (including workflow review / exit and the cluster view). Group reach is pedagogical only: the student-account writes (`PATCH /api/students/{id}`, quick-code regen, password reset) stay with the class teacher and leadership. The teacher-wide **`GET /api/monitoring/alerts`** and **`GET /api/cbm/alerts`** inboxes now cover classes ∪ owned-group members. Class enrollment and homeroom boards are untouched.

## Roster CSV import: schools, classes, staff and students from four files, 2026-09-02

Backend + admin wizard for the first-customer onboarding path. **6 new operations. `amal-api.yaml` is now 332 paths / 357 operations.**

- **`POST /api/roster-import/batches`** (multipart: `schools`, `classes`, `staff`, `students` CSV files; students required) parses, validates and matches the roster and returns a staged batch — `status: invalid` with row-numbered Arabic errors (nothing written), or `status: previewed` with a `create` / `update` / `conflict` action per row. **`GET /api/roster-import/batches`** lists batches; **`GET /api/roster-import/batches/{id}`** returns every row; **`GET /api/roster-import/batches/{id}/report.csv`** downloads the error report (UTF-8 with BOM).
- **`PATCH /api/roster-import/batches/{id}/rows/{rowId}`** records a conflict resolution (`accept_csv`, `keep_amal`, `skip_row`) — a conflict is a match by normalized Arabic name only, never auto-merged.
- **`POST /api/roster-import/batches/{id}/commit`** provisions the batch in one transaction and returns one-time credentials (staff temporary passwords, student 4-digit PINs). `409 stale_preview` when another batch created the same refs first; `410` after the 24 h TTL; `422` for `batch_invalid` / `unresolved_conflicts` / `school_principal_conflict`.
- **Identity model.** `User.email` is now nullable (students have none; adults still require it), and `User`, `School` and `Class` carry an org-scoped `externalRef` — the school's own id and the idempotency key for re-import. Four Arabic name columns (`firstNameAr` … `familyNameAr`) compose `fullNameAr`. `POST /api/users` accepts a STUDENT without `email`.
- **Credentials.** Student 4-digit PINs now hash at `AUTH_STUDENT_PIN_BCRYPT_COST` (default 8); adult passwords keep `AUTH_BCRYPT_COST`.

## Question Studio P2: passage CRUD, bulk template, distractor labels, 2026-07-15

Backend for the second Question Studio slice. **5 new operations. `amal-api.yaml` is now 326 paths / 351 operations.**

- **Passage authoring.** `POST /api/passages` creates a draft passage; the server computes `wordCount` and `sentenceCount` from the body and leaves the readability metrics null (the offline pipeline scores those later). `GET /api/passages/{id}` returns the authorable fields, the read-only metrics, and the linked-questions list with short stem excerpts (404 for a passage outside the caller's read scope). `PATCH /api/passages/{id}` is the passage content-edit chokepoint: `If-Match` on `updatedAt` returns `412` on a stale precondition, and each save writes an append-only `passage_revision` row in the same transaction. `GET /api/passages/{id}/revisions` lists the edit history. There is no delete; retire a passage through its `status`.
- **Body edit consequences.** Changing the body text recomputes the counts, marks the readability metrics stale (`metricsStaleAt`), and flags the calibration of every linked operational or calibrated question as stale.
- **Comprehension blank-create.** `POST /api/items` with a `blank` skeleton now accepts an optional `passageId`, so a comprehension question can start from a blank draft on a chosen passage. Attaching a passage to a question is validated against the caller's read scope, so a passage from another tenant resolves as unknown.
- **Bulk template assignment.** `POST /api/items/bulk-template` applies one template (or clears it with `null`) across 1..200 questions. Each question reports an independent outcome (`updated`, `unchanged`, `incompatible`, `not_found`, or `conflict`); one failure never aborts the rest.
- **Distractor labels.** `GET /api/item-types/{code}/distractor-compat` now returns each legal distractor type as an object with an Arabic label and a family, instead of a bare code string.

## Confirmation content-gap handling: `reason` field, QA_039 alert, stale-request sweep, 2026-07-12

Backend only; one additive response field, no new routes. When a confirmatory mini-set is owed but its
same-difficulty item pool is empty, the platform never substitutes a different item. Previously that state was
indistinguishable on the wire from a completed set, so the owed confirmation sat open silently.

- **`GET /api/confirmation/next-item`** response gains an optional **`reason`** field, set whenever `done: true`:
  `no_open_request` (nothing owed), `complete` (the current set just finished), or `no_eligible_item` (a
  confirmation IS owed but no suitable item exists yet). The student experience is unchanged either way; the
  field exists for operational and teacher-side consumers. The serve also now skips past a content-blocked
  confirmation to the student's next servable one, so one missing pool no longer stalls the whole queue.
- New registry check **QA_039** (severity high): the empty-pool event writes a `platform_alert_log` row naming
  the organization, student, skill, window, and request (once per request), and notifies engineering. Visible
  via `GET /api/qa/runtime-alerts`.
- New maintenance command `confirmation:sweep` marks confirmation requests left open longer than 45 days as
  `abandoned` (bookkeeping only; no skill decision is ever derived from a missing mini-set).

## Decision-rule R13 cascade: 3-item screen, related-skill families, confirmatory mini-set, validity review, 2026-07-12

Backend + two new student-facing endpoints. The skill-decision engine now implements the partner's locked answers
to the five decision-rule coverage questions. The diagnostic serves exactly 3 items per sub-skill (clean 0/3..3/3
buckets; sufficiency floor 5→3). A weak skill's family siblings are read to interpret it as isolated/inconsistent/
broad, but only with ≥2 measured siblings (else single-skill), and always advisory — never changing the skill's own
result. A lone gateway 0/3 opens a same-difficulty confirmatory mini-set, and a weak gateway with a significant
behaviour signal routes to a short validity review instead of confirming.

- **`GET /api/confirmation/next-item`** serves the next confirmatory mini-set item for the student's oldest owed
  confirmation (same difficulty band as the initial screen; `done: true` when none is owed or the set is complete).
- **`POST /api/confirmation/responses`** submits one confirmatory response (server-graded). The response body carries
  **only** `needMore` — never a score or status (the child never sees an internal result). When the 2–3 item set
  completes, the engine records the outcome, finalises the skill, and closes the confirmation.
- New migrations: `related_skill_family`, `confirmation_request`, `skill_validity_review`. No existing route changed
  shape; the two new routes are self-describing in the API reference (auto-rendered from `amal-api.yaml`).

## Principal + Manager supervisory dashboards: implementation, fluency, and review-matrix reads, 2026-07-11

Backend plus principal/manager frontend. Four new aggregate-only read endpoints power the reshaped
supervisory dashboards, the org trends axis moves to assessment windows, and the board PDF grows to a
seven-section school report. Every new endpoint is read-only, gated for Principal, Manager, or Admin,
and returns class or school names with counts only. No student name, id, or per-student row ever leaves
these surfaces.

- **`GET /api/schools/{schoolId}/implementation-summary`** and
  **`GET /api/orgs/{orgId}/implementation-summary`** return the intervention-plan implementation
  (fidelity) rollup: active-plan counts bucketed into `adequate` / `insufficient` / `unknown`, an
  `implementationRate` percentage, the top three issue codes, and a per-class (school route) or
  per-school (org route) breakdown. The rate is a fidelity metric over PLANS, not the banned single
  global literacy percentage, and is `null` when the denominator is zero so an all-unknown school never
  renders as 0%. School route gates on `VIEW_SCHOOL`; org route on `VIEW_ORG`.
- **`GET /api/schools/{schoolId}/fluency-summary`** returns the school and per-class oral-reading
  figures for the resolved assessment window: assessed-student count, median words-correct-per-minute,
  median accuracy, and a below-benchmark count. A student whose grade cut is a partner sentinel
  (`NOT_APPLICABLE` / `OPTIONAL_BASELINE_NO_CUT`) or is unresolved is excluded from the below-benchmark
  count, which is `null` when no cut resolves across the cohort. A `benchmarkNote` reports cut
  resolvability. Gates on `VIEW_SCHOOL`.
- **`GET /api/principal/schools/{schoolId}/review-matrix`** classifies each class into one of five
  closed quadrants (`insufficient_evidence`, `implementation_gap`, `good_impl_low_growth`,
  `good_impl_good_growth`, `needs_monitoring`) with a one-to-one suggested action. Insufficient evidence
  wins first (a class with no comparable window pair is never judged), then the fidelity gate (an
  undelivered plan's outcomes are not read as evidence about the plan), then effectiveness. This is a
  meeting-agenda aid, not a verdict. Gates on `VIEW_SCHOOL`.
- **Org trends re-bucketed to assessment windows.** `GET /api/orgs/{orgId}/trends` now buckets its
  x-axis on the snapshot `evidenceWindow` (BOY/MOY/EOY) rather than the calendar month. The
  `OrgTrendsDTO.windows` array changed from `string[]` to `{ bucketId, window, yearLabel }[]`, and each
  trend point carries the same three fields; the axis sorts chronologically (year, then BOY→MOY→EOY).
  Consumers that read the old `"YYYY-MM"` window strings must adapt.
- **Board PDF is now a seven-section school report.** `GET /api/schools/{schoolId}/board-pdf` renders
  school summary, the three assessment windows, macro-domain distribution, reading accuracy and fluency,
  class comparison, and support-plan implementation. Each section reads its sibling module fail-soft: an
  unavailable source degrades to a single calm «لم يُقيَّم بعد» line, never a fabricated number, and the
  whole-PDF fail-closed posture (render error, timeout, or oversize returns 503) is unchanged.

Implements FR-FID (fidelity / B.21 escalation gate), the GSR-01 insufficient-evidence rule, FR-CBM
windowed benchmarks, and the macro-domain rollup (`docs/product/prds-v2/30-intervention-and-monitoring/35-fidelity-tracker.md`,
`33-fluency-measurement.md`, and `docs/product/prds-v2/60-supporting-features/69-reporting-apis.md`). Two
decisions remain partner-owed: the Arabic wording for the five review-matrix quadrants and actions is
DRAFT, and the percent-display convention for the fidelity rate awaits confirmation.

**Operation count: +4 (2 implementation-summary, 1 fluency-summary, 1 review-matrix). The running
counter in earlier entries had drifted behind intervening work; the authoritative `amal-api.yaml` is now
305 paths and 328 operations.**

## Question Studio: student render engines + structured key editors (QS-P2/P3), 2026-07-09

Admin authoring UI plus 2 new item types. The Question Studio at `/admin/questions` now previews far
more question types exactly as the student sees them, adds structured answer-key editors for
compose/typed/harakat questions, and groups the skill picker by grade. No new API operations: the two
new item types are `item_type_catalog` rows served through the existing item routes, and every key
editor writes through the existing `PATCH /api/items/{id}` chokepoint. The published API surface is
unchanged.

- **9 render engines promoted to live student production.** `chip_fill` (in-context gap fill),
  `order_builder` → `order_all` (reorder), `tap_boundary` → `syllable_cut`, `tap_tokens` →
  `token_flow`, `letter_hunt` → `scatter`, `diacritic_pick` → `harakat_word`, `anagram` →
  `tile_blend`, plus the 2 brand-new interaction kinds below. Each is a real served projection, not a
  preview mock: the admin WYSIWYG mounts the same `servedItem()` output the student receives.
- **2 new item types + serve interaction kinds.** `TYPED_SPELLING` (an on-screen Arabic keyboard,
  graded by the new `typed_exact` strategy) and `MULTI_SLOT_CLOZE` (a multi-gap fill over running
  text, graded by the new `slot_vector` strategy). Both are end-to-end: itemType + scoring strategy +
  serve projection + answer kind + grader + renderer + cache-resume. The new served interaction kinds
  join the existing set behind the allow-listed, no-leak serve projection.
- **7 preview-only prototype ports.** `open_text`, `match_pairs`, `group_sort`, `stepwise`,
  `passage_set`, `tap_region`, and `trace` render inside the admin preview with a sample-data banner.
  They are authoring-preview only (no student serving, no grader) pending later production decisions.
- **5 structured answer-key editors** replace the old read-only «مفتاح الإجابة» summary in the
  question editor: rack + sequence editors for `tile_blend`/`word_order`, a cut-position scissors
  editor for `syllable_cut` (which also fixes a wrong-field bug that left the cut row blank), a
  keyboard + target-word editor for `typed_spelling`, and a base-letters + target-letter editor for
  `harakat_word`. A Tier-B experimental editor authors the four preview-port shapes (pairs / bins /
  steps / regions) so a prototype renders real data instead of the sample. Every editor writes
  `interactionConfig` / `interactionKey` through the same `PATCH` chokepoint, with the append-only
  revision history, gate-parity validation, and the V-12 config/key denylist partition unchanged.
- **Grade-aware skill dropdown.** The «المهارة» list in the question editor is now grouped by the
  item's grade (skills already used at that grade lead the list) and never hard-hides any skill.

Design: `docs/design/qs-p2-engine-ports.md` + `docs/design/qs-p3-key-editors.md`. Migrations add the
`TYPED_SPELLING` / `MULTI_SLOT_CLOZE` item types and the `typed_exact` / `slot_vector` scoring
strategies.

## Super-Admin Demo-Tenant Management (WP-DEMO-ADMIN-MGMT), 2026-06-28

Backend plus admin frontend. The system super-admin surface to oversee every self-serve demo tenant.
**4 new operations.**

- New `demo-admin` routes, all true-super-only (`skipTenantFilter`): `GET /api/admin/demo-tenants`
  lists the registrant, status, expiry, last-used stat, and class/student counts; `POST …/{id}/revoke`
  soft-deletes the org, suspends its users, kills sessions, and marks the registration revoked;
  `POST …/{id}/extend` pushes the 30-day TTL or reactivates a TTL-lapsed tenant; `POST …/{id}/reissue`
  rotates the temp credentials and returns the welcome payload once (no plaintext read-at-rest, no email
  to resend).
- `requirePermission("SUPER_ADMIN")` is necessary but not sufficient, since every ADMIN persona holds the
  code. An in-handler true-super check returns 403 for any in-org admin, proven by a named tenant-isolation
  test.
- Every credential-lifecycle and demo-management action is append-only audited: `demo_revoke`,
  `demo_extend`, `temp_credential_issued`.
- A successful login into a demo org (password or quickCode) bumps the registration `lastAccessedAt`, so the
  management list last-used stat stays live.
- Admin frontend: a super-admin page at `/admin/demo-tenants` with per-row revoke, extend, and view-credentials.

Implements spec sections 5.7 and 5.8 from
`docs/superpowers/specs/2026-06-27-in-house-demo-signup-design.md` (resolves OI-2: the surface lives under
`admin/`). The aggregate API surface grows to 278 paths.

## Demo Persona, Sandbox Guard and Act-As (WP-DEMO-ROLE), 2026-06-28

Backend. Makes a provisioned demo tenant fully explorable and safely writable. **1 new operation.**

- New `DEMO_ADMIN` persona (the 7th `PersonaKey`) with a curated 13 of 25 permission set. It holds every read
  code plus the in-tenant pedagogical writes, and none of the outbound, provisioning, global, or
  cross-tenant codes. The 25-code closed-enum invariant is preserved.
- The blanket demo write-guard `requireNotDemo` is replaced by `requireDemoOutboundBlock`. It blocks only
  outbound routes (LMS), structurally destructive routes (organization and school deletion, organization
  creation), and global or cross-tenant config writes (item-bank import, approve, promote, retire, plus
  benchmark-profile and diagnostic-blueprint writes). It lifts every in-tenant pedagogical and roster write,
  including class deletion, which is recoverable and stays inside the disposable sandbox. Reads are never
  blocked.
- `POST /api/demo/impersonate` is a scoped act-as. A `DEMO_ADMIN` mints a real per-persona token for one
  seeded user of the requested persona, confined to the caller's own demo org (a target outside the org is
  unreachable and returns 404). The minted token carries the target persona's real permissions. Every
  issuance is append-only audited (a `DemoImpersonationLog` row plus a `demo_impersonation_start`
  `AuthEventLog` row), and `GET /api/auth/me` now returns `impersonatedBy` for the "exploring as a persona"
  banner.

Implements spec §5.6 from `docs/superpowers/specs/2026-06-27-in-house-demo-signup-design.md` (resolves OI-1
and OI-4). **Operation count: +1. `amal-api.yaml` is now 274 paths and 293 operations.**

## Self-Serve Demo Signup (WP-DEMO-PROVISION-BE), 2026-06-28

Backend. Public self-serve demo signup with per-prospect tenant provisioning. **2 new operations.**

- `POST /api/demo/register` is a captcha-gated, rate-limited signup. It creates a pending `DemoRegistration`
  and enqueues background provisioning of an isolated, fully populated demo tenant, then returns
  `{ registrationId }`.
- `GET /api/demo/register/{id}/status` polls provisioning status. It returns the one-time welcome payload
  (login URL plus per-persona sandbox credentials, including the demo student's `quickCode` and `classId`)
  only while the status is `ready` and the payload has not been consumed.

Provisioning re-uses the demo seed pipeline (the seed library moved to `src/modules/demo/seed/`) against a
fresh org with per-tenant email namespacing, then mints forced-change temporary credentials. It is idempotent
and retry-safe. A `demo:sweep` CLI handles 30-day expiry. Implements spec §5.3/§5.4/§5.5/§5.8 from
`docs/superpowers/specs/2026-06-27-in-house-demo-signup-design.md`.

**Operation count: +2. `amal-api.yaml` is now 273 paths / 292 operations.**

## Captcha Verification Seam (WP-CAPTCHA), 2026-06-28

Infrastructure. Added server-side captcha verification seam using Cloudflare Turnstile. No new API
operations. The `TURNSTILE_SECRET` env variable is optional in development (no-op verifier accepts all
tokens) and required in production (startup fails loudly if unset, preventing the gate from silently
vanishing). The mandatory verify-before-write call site arrives in WP-DEMO-PROVISION-BE.

Implements spec §5.2 / decision D7 from `docs/superpowers/specs/2026-06-27-in-house-demo-signup-design.md`.

**Operation count unchanged: 285 operations in `amal-api.yaml` (no routes added).**

## Per-Student Accessibility Config (WP-PSA-BE), 2026-06-27

Backend. The student-controlled accessibility path becomes real and measurement-safe. **2 new operations**
(one new path with `PATCH` plus `GET`) bring the published total to **285 operations** in `amal-api.yaml`.

- **`PATCH /api/students/me/accessibility-config`** is net-new. A student self-toggles their own 5 render-only
  accessibility fields (`dyslexiaFont`, `highContrast`, `largerText`, `audioFirst`, `reducedMotion`). The
  `/me` path carries no student id, so there is nothing to tamper (IDOR-free); the StudentProfile is resolved
  server-side from the session. Only the `STUDENT` role may write, enforced by an in-handler role check
  because `VIEW_OWN_PROFILE` is held by every role and so the permission gate alone is not the restriction.
- **`GET /api/students/me/accessibility-config`** is the student hydrate source: the current config plus its
  version.
- **`PATCH /api/students/{id}/accessibility-config`**, the existing teacher and parent route, now supports
  field-level merge, optimistic concurrency, and audit.

**Key facts for API consumers:**

- **Tri-state response.** Each of the 5 fields is `true`, `false`, or `null`. A `null` means "unset", so the
  client resolves it to the OS default; a `false` is an explicit "off". Do not treat a missing field as
  `false`.
- **Optimistic concurrency.** Send the `accessibilityConfigVersion` you last read in an `If-Match` header. A
  concurrent write that already bumped the version returns `409`, so re-`GET` and retry. Omit `If-Match` for
  last-write-wins (safe for offline replay).
- **Field-level merge.** A `PATCH` body changes only the keys it contains; other keys are preserved with no
  whole-object clobber. An identical-value write is a no-op (no version bump, no audit row).
- **Audit.** Every effective write appends a `student_accessibility_config_log` row recording who, when,
  which fields, and the resulting version. The table is append-only.
- **Render-only.** `accessibilityConfig` is never read by any measurement or decision module, so a
  student-set field can never influence item selection or ability estimates.

The new routes appear in the [API Reference](https://docs.growamal.com/en/api-reference/) after `openapi:generate`.

## Interactive Teaser API (WP-11-BE) — 2026-06-25

Backend. Three new anonymous (no auth, no account required) routes under `/api/teaser/*` make the "try the diagnostic" campaign feature real. **3 new operations** bring the published total to **262 operations** at the time of this entry. Subsequent work packages added further routes; the current total is **283 operations** (verified against `amal-api.yaml`).

- **`POST /api/teaser/sessions`** starts a teaser for a grade (1-4). Rate-limited at 3 sessions per IP per hour. Returns a session token and 10 curated items with answer keys stripped. A grade with no active item set returns 503.
- **`POST /api/teaser/sessions/{id}/finish`** scores the submitted responses on the real Rasch estimator and returns a one-sub-skill placement band (`meets`, `approaching`, `below`, or `severe`) using the Mizan Arabic band labels, plus an Arabic disclaimer string.
- **`POST /api/teaser/leads`** captures an opt-in email or WhatsApp contact. One lead row per session (upsert), so a visitor who updates their contact details does not flood the table.

**Key facts for API consumers:**

- All three routes carry `security: []` in the OpenAPI spec. No `Authorization` header is required or accepted.
- The session token issued at start must be passed to both `finish` and `leads`. A bad token returns 401. A re-submit to `finish` returns 409.
- The band is derived from fixed grade-neutral logit thresholds, not from per-country benchmark profiles. An anonymous visitor has no country, and the teaser is an explicitly disclaimed sample.
- No writes to any measurement, mastery, or evidence table occur anywhere in the teaser path. The teaser is analytically isolated from the production diagnostic pipeline.
- Client IP is stored only as a salted hash for rate-limiting and funnel analytics. Raw IP is never persisted.
- The new Prisma tables (`teaser_session`, `teaser_lead`, `teaser_item_set`) carry `organizationId = null` (the global convention, same as the item bank).

The new routes appear in the [API Reference](https://docs.growamal.com/en/api-reference/) after `openapi:generate`.

**Open items:** grades 1 and 3 currently return 503 because the seed corpus does not yet have a sub-skill with 10 qualifying items at those grades. The FE (WP-11-FE) should handle 503 gracefully.

Spec: `docs/superpowers/specs/2026-06-25-wp11-teaser-design.md` (FR-6.3). Migrations: `wp11_teaser` + `wp11_teaser_lead_unique`.

## Demo Tenant Walkthrough (WP-12-FE) — 2026-06-25

**1 new operation** — `GET /api/demo/access` (public, no auth required) — ships alongside the
frontend. Delivers the public, one-click demo entry that sits on top of the WP-12-BE demo tenant.

**What changed:**

- **Public `/demo` persona-picker.** A Server Component that reads `GET /api/demo/access` at request time (the BE reads only the `isDemo=true` org) and renders one card per adult persona plus a student card with a per-grade quickCode picker. "Enter as X" logs the visitor straight into the demo tenant — no signup.
- **`amal_demo` flag cookie.** The demo enter actions set `amal_demo=1` alongside the session cookies. A `DemoBanner` mounted in both shells (adult AppShell + Student World) reads it and shows a persistent "you are in demo mode" strip with "switch persona" (back to `/demo`) and "exit" (clears the session) controls. Real users never see it (the banner renders `null` without the flag).
- **Credentials panel.** A collapsible panel lists the shared adult password, per-persona emails, and per-grade student quickCodes with copy-to-clipboard — so a presenter can mirror the demo on a second device.
- **Hero CTA + unavailable/error states.** The marketing hero gains a "جرّب العرض التجريبي" CTA. When no demo tenant is configured the page shows a calm "unavailable" panel with the booking CTA; a failed enter redirects to `/demo?error=1` with a dismissible notice.

The new route appears in the [API Reference](https://docs.growamal.com/en/api-reference/) after `openapi:generate`. Runbook: `wp-12-fe.md`. E2E: `apps/web/e2e/demo-flow.spec.ts`. A11y: `accessibility.spec.ts` ("demo page: WCAG 2.1 AA").

## Demo Tenant Infrastructure (WP-12-BE) — 2026-06-25

Backend only — no new API operations (the demo guard is middleware, not a route). Delivers a self-contained, sales-ready demo tenant that prospective schools can walk through across all six personas without being able to permanently break it. API Reference is unchanged.

**What changed:**

- **`Organization.isDemo` flag.** A boolean column marks the demo tenant. The seeder sets it to `true` on the demo org; all other orgs default to `false`.
- **`DEMO_READ_ONLY` guard.** A new `requireNotDemo` middleware mounted on ~29 destructive and provisioning route groups (user CRUD, tenancy CRUD, LMS connect/sync, quickCode regeneration, password-reset mail, parent-notification dispatch). Returns **403 `DEMO_READ_ONLY`** for any `isDemo=true` organization. The full interactive RTI loop — diagnostics, practice, warm-up sessions, teacher decisions, monitoring evidence, context flags, and all reads — is **not blocked**. Visitors can drive the complete loop.
- **Idempotent demo seeder.** `pnpm --filter server seed:demo` provisions one demo org (Jordan) with one school, three classes, and 60 students across Grades 1–4, plus all six adult personas with deterministic credentials (`*@amal-demo.jo` / `AmalDemo!2026`). A hero teacher is assigned to two classes to demonstrate the multi-class picker.
- **Real loop state.** The seeder drives the actual RTI pipeline for a representative student subset — diagnostic finish → skill-status engine → macro snapshots → profile resolution → bundle recommendations. Dashboards show real data in motion, not fabricated numbers.
- **Idempotency + reset.** A re-run wipes visitor-accumulated runtime state (sessions, snapshots, profiles, bundles, monitoring rows, context flags, notifications) and re-derives the baseline. People rows are upserted on natural keys so login credentials remain stable across resets.
- **`DEMO-GUIDE.md`** emitted to `docs/demo/` on every seed run: credential table, per-persona page tour, sync class join code.

**Deferred:** nightly cron hookup (documented in the runbook, not wired); public `/demo` entry page and in-app reseed webhook (WP-12-FE, later pass).

Spec: `2026-06-25-wp-12-be-demo-tenant-design.md`. Build plan: §WP-12-BE FR-5.1/5.2. Test plan: `wp-12-be.test-plan.md`. Runbook: `wp-12-be.md`. Migration: `Organization.isDemo`. No new routes.

## Browser Logger + Ship-to-Server Batching (WP-LOG-FE) — 2026-06-25

Frontend. The web app now has a canonical browser logger that matches the backend logger contract, plus a batch worker that ships client WARN and ERROR logs to the existing `POST /api/logs/client` endpoint. No new API routes (the endpoint shipped with WP-LOG-BE), so the API Reference is unchanged.

**What changed:**

- **`getLogger(module)` wrapper** with the same four levels (`debug`, `info`, `warn`, `error`) and the same structured envelope as the backend logger. In development every level prints to the console only. In production builds DEBUG and INFO stay console-only while WARN and ERROR also ship to the backend (FR-LOG-6).
- **Batch worker.** WARN and ERROR rows buffer and flush when the buffer reaches 50 rows or after 5 seconds, whichever comes first. A 429 rate-limit response backs off three times (500, 1000, 2000 ms) and then drops the batch with one local breadcrumb. A network failure keeps the rows for a later retry. Buffered rows also flush when the tab is hidden or closed.
- **Client-side redaction.** The backend closed list of secret and PII keys is mirrored on the client and applied to log extras before anything reaches the console or the network, so a secret never leaves the browser. Email is masked, phone and other secrets are censored.
- **`lint:no-console` for `apps/web`.** Raw `console.*` is rejected outside the logger sink. Use `getLogger` instead.

Spec: `81-logging-and-observability.md` (FR-LOG-6). Test plan: `wp-log-fe.test-plan.md`. No migration.

## Sub-flag Stateful Training-Time Pattern Engine (R12 Q4) — 2026-06-23

Backend + spec. The 8 Arabic-literacy sub-flags are now a two-phase, stateful pattern engine powered by a per-(student × micro-pattern) indicator state machine.

**What changed:**

- **Two-phase model.** Assessment phase is unchanged — a confirmed weakness flows straight to profile/bundle with no error-count alert. Training phase (new) runs a per-(student × `micro_pattern_id`) indicator ladder: L1 (1 valid occurrence → `candidate`, silent); L2 (≥2 linked occurrences in 1 session + ≥2 independent items → `automatic_support_active`, no teacher alert); L3 (≥3 occurrences across ≥2 sessions + auto-support delivered + pattern persists → informational `teacher_alert_active`; never waits for 4 sessions).
- **Error-linkage rule (all 5 must hold).** Two wrong answers count as the same pattern only if: same/linked skill (Q7 map) + same `micro_pattern_id` / `error_family` + 2 independent analysis-valid items + items designed to detect the pattern + distractor reveals error type. Same domain alone is insufficient.
- **V-12 invariant — L3 is informational only.** The L3 teacher alert does NOT change score / `skill_status` / `domain_status` / profile / RTI level / Tier (`scoreImpact = "none"`). Writes only `sub_flag_indicator_state` + `sub_flag_indicator_occurrence` + `platform_alert_log`. Enforced by `lint:subflag-alert-no-mutation`.
- **`AR_LETTER_CONFUSION_FLAG` renamed → `AR_ORTHOGRAPHIC_CONFUSION_FLAG`.** Clarifies that this flag covers visual/orthographic letter confusion only (DWR-D04/D05), not auditory phoneme confusion (PA-07). `AR_SHADDA_FLAG` is disabled this phase (`enabled=false`).
- **New global catalog tables:** `error_category` (13 rows), `error_micro_pattern` (8 rows), `distractor_error_map` (6 rows), `evidence_error_rule` (EV01..EV06).
- **New tenant-scoped runtime tables:** `sub_flag_indicator_state` (whitelisted-mutable ladder) + `sub_flag_indicator_occurrence` (append-only occurrence history).
- **5 nullable `item_bank` soft fields:** `microPatternId / errorFamilyId / confusionFamilyId / distractorErrorCode / designedToDetectPatternId` (populated only on pattern-probe items).

**Partner-pending:** `evidence_validity` rapid-guess threshold (EV06 — 300 ms soft floor until RBM-08 lands); L2 concrete auto-support artifact; dedicated micro-pattern rows for `AR_SHORT_VOWEL_FLAG` / `VOCAB_ALERT` / `WR_SP_ALERT` (currently fire at category level).

Spec: `23-student-profiles.md` §4b + FR-SPR-4. Migration: `r12_q4_subflag_engine`. Source: partner master-answers Q4 (lines 728-943) + File-2 v2 §14/15/17/23.

## Intervention Bundle Restructure — 14 bundles + 7 non-bundle objects (R12 Tier-D / Q11) — 2026-06-22

**Breaking.** Partner Q11 FINAL (`Q11_Arabic_Intervention_Bundles_Comprehensive_Audit_v1.0.0_2026-06-20`) restructures the closed bundle catalog. The bundle engine, TAW, and all consumers that read `recommended_bundle_id` are affected.

**What changed:**

- **14 intervention bundles + 7 reclassified non-bundle objects** (was 19 bundles). The 7 objects are now typed via the new `BundleObjectType` enum (9 values: `INTERVENTION_BUNDLE / COMPOSITE_SEQUENCE_PLAN / COMPOSITE_INTERVENTION_BUNDLE / SUPPORT_MODULE / DECISION_CLARIFICATION_PROCESS / NON_INTERVENTION_OPTION / LIGHT_SUPPORT_ACTION / ROUTING_GUARD / FUTURE_INACTIVE_SUPPORT_OBJECT`).
- **`recommended_bundle_id` is now nullable.** The 7 non-bundle MAP rows (MAP-000/001/002/003/018/019/020) carry `NULL` in this field plus an `object_type` and a `routing_state_or_action_code` (partner decision D-015: non-bundles must not occupy `recommended_bundle_id`). The `NO_BUNDLE_SYSTEM_GUARD` sentinel-bundle row is retired; its function is now the `ROUTING_GUARD` action code with `routing_state = BLOCKED_MISSING_EVIDENCE`.
- **Cumulative-cycle weight ranges replace per-session fixed splits (D-003).** The old `sum==100 per session + anchor-strictly-largest CHECK` constraint is dropped. Dosage is now described as allocation ranges across an intervention cycle. Two authoritative master recipes ship (Q11 sheet 07): `FLUENCY_WITH_MEANING` (10-15% prep / 60-70% guided repeated reading with accuracy feedback embedded / 15-25% meaning check + transfer) and `LANGUAGE_MEANING_BUILDING` (45-55% anchor / 20-30% one dynamic support / 20-30% contextual application).
- **`VOCAB_SYNTAX_MORPH_BUNDLE` splits into 3 variants** (`VOCAB_DOMINANT / SYNTAX_DOMINANT / MORPH_DOMINANT`) under `bundle_family_id = LINGUISTIC_FEATURE_FAMILY`.
- **`TEXT_ACCESS_INTEGRATED_BUNDLE` + `COMPREHENSION_STRATEGY_BUNDLE`** reclassified as `COMPOSITE_SEQUENCE_PLAN`; **`MIXED_READING_LANGUAGE_BUNDLE`** as `COMPOSITE_INTERVENTION_BUNDLE`.
- **TAW handles 4 non-bundle outcomes** (`NON_INTERVENTION_OPTION / LIGHT_SUPPORT_ACTION / DECISION_CLARIFICATION_PROCESS / ROUTING_GUARD`) by recording a routing/action code — no `bundle_assignment` row is written for these paths.
- **New `bundle_definition` columns:** `object_type, bundle_family_id, variant_code, runtime_status, anchor_selector, max_supporting_skills, asset_bridge_rule, grade_target_bridge_rule, immediate_progression_target, cycle_allocation_model`.
- **New `bundle_assignment` fields:** `object_type, family, variant, cycle_id, session_role, selected_anchor_skill_id, immediate_progression_target, grade_target_skill_id, relationship_map_version`. Nine session roles defined: `EXPLICIT_TEACHING / GUIDED_PRACTICE / RETRIEVAL_REVIEW / CONTEXT_APPLICATION / GRADE_TARGET_BRIDGE / SCAFFOLD_FADING / NEAR_TRANSFER / PROGRESS_CHECK / MAINTENANCE`.
- **ISC-015 (`ARABIC_FEATURE`)** retired. Transfer split (proximal/near/far) added to remaining ISC rows (V5-012).
- **6 new QA checks QA_033–038**: one-primary-per-cycle, active-phase-only, no-pseudo-bundle-routing, no-ORF-error-inference, no-inactive-feature-routing, content-suitability.

Migrations: `r12_d1_bundle_object_types` + `r12_d2_isc_transfer_split`. Spec: `31-intervention-bundles.md` (FR-IBC-1/2/3/4/9). Source: Q11 sheets 02/03/04/07/09/12 + decisions D-001..D-015.

## Session-Blueprint Catalog (WP-SBC-BE) — 2026-06-22

Backend + spec. The offline-authored **session-blueprint catalog** end-to-end, LLM-free. **17 new
operations** under the `session-catalog` tag (the published total is now **240 operations**).

- **Backoffice** — `POST/GET /api/session-blueprints/import` (+ `/{batchId}`) import a blueprint batch
  through the validation gate (completeness → FK validity → bank-coverage floor → language-safety →
  dedup; single write path, idempotent on file hash); `POST /api/session-blueprints/{batchId}/approve`
  flips accepted rows → operational; `GET /api/session-blueprints/{id}` fetches one blueprint.
- **Teacher catalog** — `GET /api/sessions/catalog` browses/searches the operational catalog;
  `GET /api/sessions/catalog/recommended` ranks blueprints by an RTI need-signal (read from the same
  stored rows the student loop personalizes from; weights from version-pinned config;
  `do_not_decide_yet` students contribute 0).
- **Wizard** — `POST /api/sessions/from-blueprint`, `PATCH /api/sessions/{id}/edit`,
  `POST /api/sessions/{id}/accept` (per-student ISM fan-out over the class roster),
  `POST /api/sessions/{id}/cancel`, `POST /api/sessions/{id}/recipients/{studentId}/include`
  (re-include a student the fan-out skipped — engine-halt students are skipped + audited, never dropped).
- **Delivery** — `POST /api/sessions/{id}/distribute-online` pushes per-student sessions to the practice
  queue; `POST /api/sessions/{id}/generate-pdfs` renders per-student printable PDFs + QR (each QR = a
  served-task-instance join token); `GET /api/sessions/recently-generated`, `GET /api/sessions/{id}`,
  `GET /api/sessions/{id}/audit` read the generated sessions + audit trail.

Implements FR-SBC-1/2/3 + FR-SGE-5/6/7/12/13/14/15/16/17. Verified: no-LLM + single-write-path +
append-only lints green; `openapi:check` + `openapi:lint` green; sample-blueprint seed produces 4
operational blueprints, 0 rejected. FE ships separately (WP-SGE-FE).

## Admin Import Review Wizard UI (WP-LMS-FE) — 2026-06-22

Front-end only — no new API operations (all routes shipped with WP-LMS-BE). Delivers the 5-stage RTL admin wizard (`/admin/lms-import`): Connect → Map → Review → Confirm → Done. Implements FR-LMS-13 (full wizard flow with 24-hour state restore on page refresh) and FR-LMS-14 (editable Primary / Secondary / Support role selector per teacher row on multi-teacher courses). Includes connection list, Sync now button, sync-run history table, Disconnect, and Reconnect for revoked connections.

## LMS Roster Import — Google Classroom + OneRoster (WP-LMS-BE) — 2026-06-22

**11 new operations** under `/api/lms/*` bring the published total to **221**.

- **`POST /api/lms/connections/:provider/oauth-start`** — begin Google Classroom OAuth; returns a redirect URL for the admin's browser.
- **`GET /api/lms/connections/:provider/oauth-callback`** — OAuth callback; validates state, stores token, redirects to the wizard.
- **`POST /api/lms/connections/:provider/token`** — submit a OneRoster CSV file set or REST credentials to create an active connection; no OAuth round-trip needed.
- **`GET /api/lms/connections`** — list all connections for the org with status and last-sync timestamp.
- **`DELETE /api/lms/connections/:id`** — disconnect and halt scheduled sync.
- **`GET /api/lms/:provider/courses`** — list courses available through the connection (server-cached 5 min; `?cache=bypass` busts the cache). Requires `MANAGE_ROSTER`.
- **`POST /api/lms/:provider/mappings`** — save course → Amal class mappings. Requires `MANAGE_ROSTER`.
- **`POST /api/lms/preview`** — generate a read-only import diff: identity matched by exact external-sub or verified email; returns per-row `action: create | update | conflict | skip` plus a summary. Zero writes to `User`, `ClassStudent`, `ClassTeacher`, or `ParentStudent`. Preview TTL is 24 hours.
- **`PATCH /api/lms/preview/:previewId/rows/:rowId`** — save a conflict resolution (`accept_lms | keep_amal | skip_row`) for one row.
- **`POST /api/lms/commit/:previewId`** — commit the preview in a single Postgres transaction: new users, class enrollments, teacher assignments, login codes, and `UserExternalIdentity(source=roster_import)` rows (enabling Google SSO for imported users). Returns `{ created, updated, skipped, conflicts_remaining }`. Idempotent on `previewId`. Returns 422 if any conflict row is unresolved.
- **`GET /api/lms/sync-runs`** — paginated sync-run history for the org.
- **`POST /api/lms/connections/:id/sync-now`** — enqueue a manual sync immediately; returns `202 { syncRunId }`.

All routes require `requireAuth`. Connection-management routes require `MANAGE_LMS_CONNECTIONS`; roster routes require `MANAGE_ROSTER`. Non-owner or cross-org resources return **404** — the other resource's existence is not disclosed.

**Multi-teacher support (B.4).** When an LMS course lists multiple teachers, commit writes one `ClassTeacher` row per teacher (PRIMARY first, SECONDARY for the rest). `Class.teacherId` is never written.

**Token revocation.** Any provider 401/403 sets `connectionStatus=revoked`, halts future cron runs, dispatches `lms_connection_revoked` to all Org Admins, and appends a `LmsSyncRun{status:error}` row. Admin reconnects from the Connections page to restore.

**Guardian import gate (Q-LMS-5 🔴).** Google Classroom guardian emails are gated OFF (`LMS_GUARDIAN_IMPORT_ENABLED=false`) until the partner Privacy review clears. OneRoster has no Wave-1 guardian import.

## Google Login — Google SSO (WP-SSO-BE) — 2026-06-22

Google OAuth 2.0 server-side authorization-code + PKCE login is now available for adult users (teachers, admins, principals) and roster-linked students. **3 new operations** bring the published total to **206**.

- **`GET /api/auth/google/start`** — returns a Google OAuth redirect URL. Creates a single-use `GoogleOAuthState` row (10-min TTL) that carries the CSRF state and PKCE code verifier.
- **`GET /api/auth/google/callback`** — receives the OAuth authorization code from Google, validates the state, verifies the ID token, resolves identity by the two-step priority chain (sub → verified email), and redirects to the FE with a 60-second single-use handoff `?code=`. Tokens never appear in the redirect URL.
- **`POST /api/auth/google/exchange`** — exchanges the handoff code for a standard `{ accessToken, refreshToken, tokenType }` pair. The code is marked consumed on first use; replayed or expired codes return 401.

Students sign in via Google only when an explicit `roster_import` `UserExternalIdentity` link has been created by an admin (no self-provisioning). `Organization.allowedGoogleDomains` gates which Google Workspace `hd` domains are accepted; an empty list means any Google account is accepted. null-org SUPER admins bypass the domain gate.

## Sub-flag rename — `AR_LETTER_CONFUSION_FLAG` → `AR_ORTHOGRAPHIC_CONFUSION_FLAG` (R12 / Q4) — 2026-06-22

The Arabic-confusion sub-flag is renamed so the enum label matches its already-confirmed semantics (the 2026-06-19 partner confirmation scoped it to **visual/orthographic** confusion only — never auditory). Data-preserving `ALTER TYPE "SubFlag" RENAME VALUE` migration (`r12_rename_orthographic_flag`); existing rows keep their value under the new label. No behavior change — derivation, scope, and downstream profile routing are unchanged. (The broader Q4 stateful sub-flag engine — micro-pattern linkage + the L1/L2/L3 ladder — remains a separate, scheduled work item.)

## Skill-Relationship Map (R12 Tier-E / Q7) — 2026-06-21

Backend + spec. The partner Q7 skill-relationship map ships as an **advisory-only** global config sub-schema. **195 relationships + 413 runtime links** seeded across **11 new GLOBAL config tables** (`skill_rel_type / subtype / runtime_use / trigger / type_runtime_map / applicability / group / skill_relationship / runtime_link / skill_coverage / qa_rule`), behind a seed-time 22-rule import gate.

- **NO_BLOCK / measurement_effect = NONE.** The map is documentation-grade only — it is **NOT wired into item selection, scoring, or any decision path**. The `lint:skill-rel-not-wired` guard fails the build if any decision module imports it.
- Spec: `docs/product/prds-v2/10-skills-and-standards/12-skill-relationship-map.md`. Source: partner Q7. Migration: `r12_e_skill_relationship_map`.

## Skills — MORPH-C01 + CBM eligibility (R12 Tier-A / Q9) — 2026-06-21

Backend + spec. The Skills_Master_Table grows from **79 → 80 skills**: `MORPH-C01` Singular–Plural Recognition is added per the partner Q9 memo. CBM eligibility is now an explicit per-skill property — eligibility is restricted to **ORF-text-only**, so `DWR-A03` / `DWR-A05` are NOT cbm-eligible.

- The seed asserts `skills_master_table = 80` (76 active = 80 − LISTEN×3 − WR-SP-F03). Spec: `11-skills-taxonomy.md`. Source: partner Q9 memo 2026-06-21.

## Standards — Windowed ORF benchmarks (R12 Tier-A / Q8) — 2026-06-21

Backend + spec. The benchmark layer gains an optional **assessment-window** resolution dimension and ORF-applicability sentinels.

- **`AssessmentWindow {BOY/MOY/EOY}`** + 5 new `BenchmarkProfile` metadata columns (`benchmarkStatus`, `sourceStrength`, `requiresRecalibration`, `minimumAccuracyPct`, `orfApplicability`). Real partner ORF cuts extracted: **JO 46 / PSE 33**. The seed now produces **55 benchmark rows** (18 window-agnostic partner + 24 windowed-ORF + 13 `__GLOBAL__` fallback).
- **EOY-representative window-fallback** in resolution (`findWithWindowFallback`); the HTTP `…/resolve` route is window-blind today (Tier B follow-up) and resolves window-agnostic. **NOT_APPLICABLE / OPTIONAL_BASELINE_NO_CUT** ORF sentinels (G1 + G2 BOY = not_applicable) return a typed not-applicable sentinel — never a Meets/Below verdict.
- Spec: `12-benchmarks-and-country-config.md` §FR-STD-7/8. Migration: `r12_a_windowed_orf_benchmarks`.

## Standards — Per-grade Curriculum-KPI dictionary, alignment-only (R12 Tier-A / Q9) — 2026-06-21

Backend + spec. **51 per-grade Curriculum KPIs** (G1:12 / G2:13 / G3:13 / G4:13) seed into 3 new GLOBAL tables (`curriculum_kpi_dictionary`, `curriculum_kpi_to_skill_map`, `curriculum_kpi_country_map`).

- **Alignment-only governance invariant.** Every row carries `decisionWeight = 0` (DB CHECK) + `runtimeRole = ALIGNMENT_ONLY`. The new `lint:kpi-alignment-only` import-graph guard bans any decision-engine module from reading the 3 tables (V-3). Inactive + LISTEN skills are skipped from the skill-map.
- Spec: `12-benchmarks-and-country-config.md` §FR-STD-9/10. Migration: `r12_a_curriculum_kpi_dictionary`.

## Decision engine — Path-B deficit→Severe escalation (R12 Tier-B / Q3) — 2026-06-21

Backend + spec. The macro-rollup (L5 DSM) gains a **second** Severe-escalation path. Severe is never a direct DSM emission, never count-based, never percentile-based — it is produced ONLY via two escalation paths:

- **Path A** (existing) — gateway severity ≥ `DTH-SEV-01` (DSM-04).
- **Path B** (NEW) — a `coherent_domain_weakness` / `broad_related_domain_concern` / `gateway_driven_domain_concern` feeder whose raw `weightedDeficitRate ≥ DTH-DEF-03` (50%) with valid evidence escalates to Severe. Re-tuning `DTH-DEF-03` now shifts the Below↔Severe boundary, not only broad-concern classification.
- Spec: `21-skill-status-engine.md` FR-SSE-11 + `22-macro-domain-rollup.md` §4. Source: partner Q3 R12.

## Intervention fidelity — off-track lifecycle + de-escalation block (R12 Tier-B / Q2) — 2026-06-21

Backend + spec. The fidelity layer gains an off-track lifecycle and tightens the movement gate.

- **Off-track lifecycle.** A plan with **3 instructional weeks of no delivery** enters an off-track state tracked on the new `intervention_track_status`. Below-80% fidelity yields the `invalid_due_to_implementation_gap` verdict.
- **TM_BLOCK_01 also blocks de-escalation** (not only escalation) on low/unknown fidelity — "plan not done" can no longer move a child's Tier in either direction.
- Spec: `35-fidelity-tracker.md` + `34-rti-alerts.md`. Migration: `r12_b_intervention_track_status`.

## CBM — 3-comparable-point trend floor + comparability metadata (R12 Tier-B / Q5) — 2026-06-21

**Breaking partner-final change.** The CBM trend floor drops from **4 → 3 comparable points** (`GrowthTrendRule.minimumComparablePointsForStrongTrend`, default 3, config-driven + V-6-pinned) — this **SUPERSEDES the v5 4-point lock**. Below the floor → `insufficient_points`.

- **§6 comparability metadata** added per-probe on `cbm_scores` (`comparabilityGroupId`, `probeType`, `assessmentWindow`, `difficultyBand`, `diacriticsLevel`, `timingSeconds` = 60, `administrationMode` = TEACHER_LED). The GSR-04 comparability key now forbids one trend line across a word-list vs connected text, or a diacritized vs non-diacritized text.
- Invariants preserved: no single-point decision; 3 points alone never auto-fail a plan nor auto-move a Tier (GSR + off-track/fidelity gates). The new config row `growth_trend_rule` (value=3) seeds with the catalog.
- Spec: `33-fluency-measurement.md` FR-CBM-4 + §6. Migration: `r12_b_cbm_trend_floor`.

## Cognitive Warm-Up — training-only, 60s (R12 Tier-C / Q10) — 2026-06-21

Breaking partner-final change to the warm-up module (`61-cognitive-warmup.md`). Three behavior changes to the `warmup` API:

- **`POST /api/warmup/sessions` now requires a `sessionType`** — `TRAINING` or `INTERVENTION_PRACTICE`. Any other value (e.g. an assessment context) is rejected `422` (fail-closed). The warm-up never runs before BOY/MOY/EOY/Screening/ORF/CBM/confirmatory.
- **Fixed 60-second duration** — the prior 3–5 minute multi-phase plan is collapsed to a single bounded 60s unit; the engine auto-completes at the 60s cap (`MAX_WARMUP_MS = 60_000`). No countdown is shown to the child.
- **COG-05 (Matching) + COG-06 (Visual Tracking) final per-grade configs delivered + active** — 8 new `WarmupGradeConfig` rows carry machine-readable element counts (`numberOfVisualElements`, `pairCount`, `movingObjectsCount`, `targetCount`, `distractorCount`, `pathNodesCount`, `crossingCount`, `occlusionCount`, `speedChangeCount`) + a `templateContractId` (`TPL-COG05-*`/`TPL-COG06-*`). The catalog now seeds 32 active configs (was 24 + 8 inactive).

Unchanged: the warm-up still never feeds θ / mastery / XP (G-ISO); `scored=false` / `decision_weight=0` / not counted in language dosage. Migration `r12_c_warmup_60s` adds the `WarmupSessionType` enum, the `sessionType` column on `warmup_session`, and the COG-05/06 element-count columns on `warmup_grade_config`.

## Gamification UI (WP-14-FE) — 2026-06-21

Front-end only, no new backend routes. Consumes the shipped WP-14-BE `gamification` and `leaderboard` APIs. Six surfaces ship across four personas.

- **Student self-profile (S1)** (`/student/me`): the student sees only their own XP balance, avatar tier, current and longest streak, and a 15-slot badge wall (earned in color, locked as silhouettes with a hint). The student DTO carries no peer data and no leaderboard route exists under the student app, so no peer-comparison string can appear.
- **Badge-unlock celebration (S2)**: a non-blocking overlay announces each newly unlocked badge (Arabic badge name, animated award icon). The overlay compares freshly fetched badge codes against the last-seen set in browser cache, so it fires once per unlock and does not repeat after a page refresh (Q-SYNC-10).
- **Class growth board (T1)** (`/teacher/class/[classId]/leaderboard`): shows per-student mastery-event and band-progression counts for the teacher's class, ordered by growth, with a per-student source-kind breakdown. The header and "support candidates" label come from the backend (`framing.topLabelAr` / `framing.bottomLabelAr`); the frontend never supplies ranking strings. View (by mastery events / by band progression) and time window (7 / 14 / 30 days) are stored in the URL.
- **Multi-class growth board (T2)** (`/teacher/leaderboard`): the same table aggregated across all the teacher's classes, with per-class roll-up chips that open T1.
- **School growth board (P1)** (`/principal/leaderboard`): same surface scoped to every class in the school. Principal persona only; a 403 is returned for any other role.
- **Badge rescind (T3)**: launched from the student badge list in the existing teacher student-detail route. The teacher picks the single controlled code (`Gam_Award_Invalid_05`); no free-text field exists. Rescinded badges stay in the list with a struck "أُلغيت" chip (V-6 audit trail, not hidden).
- **Admin audit + replay panel (A1)** (`/admin/gamification`): query by student and date range to read the mastery-event, XP-transaction, and badge streams, then trigger a V-6 replay-drift check. The check re-derives balances and badges from the immutable ledger and reports any field that differs from the current value.
- **Additive BE field (OQ-RESCIND-ID resolved).** The profile `BadgeDto` now carries `studentBadgeId` and `status` for teacher, principal, and admin callers. Both fields are absent for student-self and parent callers. Reflected in `amal-api.yaml` (auto-rendered by the portal API reference). Operation count unchanged: **202**.

Verified: Playwright e2e across phone, tablet, laptop, and projector widths, covering growth framing, no peer-ranking strings on student surfaces, the rescind code gate, and the replay-drift check. AC-GAM-2 import-graph guard and string-token snapshot tests green.

## Teacher + Manager Settings (WP-SETTINGS-FE) — 2026-06-21

Front-end only — no backend or spec change. Closes the two dead **الإعدادات** nav routes
(`/teacher/settings` + `/manager/settings`) with thin-but-real account/preferences pages on the
Adult AppShell. Every section is backed by a shipped API or a device-local preference; nothing is
invented.

- **Account** (read-only): name, role, email, organization, and class count (teachers) from
  `GET /api/auth/me`. No password/credential entry and no destructive account actions — a
  "contact your org admin" note instead.
- **Accessibility**: the same high-contrast + dyslexia-font device toggles offered in the header,
  surfaced as a settings section. Both surfaces drive one shared device-local preference, so toggling
  in either keeps them in sync.
- **Notifications**: read-only unread count from `GET /api/notifications/unread-count`, with a note
  that notifications appear from the header bell. There is **no notification-preferences write
  endpoint**, so no preferences are invented — the section is read-only state.
- **Sign out**: `POST /api/auth/logout`, then returns to the public landing — the first sign-out
  surface in the web app.
- **Flags (BE gaps):** no notification-preferences write endpoint; no app-language/i18n toggle
  mechanism (so no Language section was built); the identity payload carries no login-code field.

Verified: type-check, lint, and production build all clean (both routes present); Playwright e2e 4/4
across laptop and tablet, asserting the account + accessibility + notifications sections render, the
accessibility toggle works from settings, and zero console errors. Arabic-first RTL, light theme.

## Teacher Secondary Routes (WP-TEACHSEC) — 2026-06-21

Front-end only — no backend or spec change. Closes the three dead teacher nav routes by adding the **cross-class** entry points: the "all my classes at once" lens the class-first views can't give. Each consumes shipped, ClassTeacher-scoped APIs.

- **My Students** (`/teacher/students`): every student across all the teacher's classes in one scannable roster — name, class, worst-domain status word, support-intensity badge, open-alert flag — sorted needs-attention first, with a per-class facet filter. Each row opens the existing student profile.
- **Progress** (`/teacher/progress`): the open progress signals across the teacher's classes — sudden-drop review prompts (framed for review, never as failure) and reading-fluency trend signals — each joined to a student and deep-linking to the plan review.
- **Support Plans** (`/teacher/plans`): every active support plan across the teacher's classes, with its fidelity rating, recommendation, and the "complete the dose first" gate badge, opening the existing plan detail.
- **Teacher gates upheld.** No single overall percentage anywhere (status words and counts only); growth language throughout; only the teacher's own classes and students appear. Composed front-end by fanning out the shipped per-class reads (no aggregator endpoint exists yet).

Verified: Playwright e2e 6/6 across laptop and tablet, asserting cross-class aggregation, no-percentage, review language, the fidelity gate, and zero console errors per route; existing teacher specs unaffected.

## Admin Provisioning UI (WP-ADMINFE) — 2026-06-21

Front-end only, no backend or spec change. It consumes the shipped WP-A2 (tenancy) and WP-A3 (users) APIs. Three admin surfaces land on the Adult AppShell, giving an admin everything needed to stand up an organization's structure and people.

- **Organizations** (`/admin/organizations`): a single drill-down page — organizations → schools → classes — via in-page disclosure. Create and edit happen in dialog modals at every level. Organizations soft-delete; schools and classes delete only when empty, and a non-empty delete surfaces clear "move the members first" guidance rather than a destructive cascade.
- **Users** (`/admin/users`): a filterable user list (by role) plus create, with the form adapting to the chosen role (student, teacher, parent, principal, manager, admin). Creating a student reveals their one-time login code once. Teacher rows can be assigned to a class via the multi-class assignment junction.
- **Roles** (`/admin/roles`): a read-only role × permission matrix, mirrored from the platform's role definitions for reference. It is static in Wave 1 and not editable from the interface.
- **Arabic-first throughout.** Every choice that must be one of a fixed set (country, grade, role, tier, dialect) is a closed dropdown, never free text. Light theme, modals over slide-overs.

Verified: Playwright e2e (organizations drill + create, users list/filter/create, roles matrix) at laptop and tablet widths, with zero console errors.

## Accessibility Toggles — high-contrast + dyslexia font (WP-A11YTOG) — 2026-06-21

Front-end only, no backend change. Two device-local accessibility toggles satisfy 74-accessibility FR-ACC-4 (high-contrast) and FR-ACC-9 (dyslexia-friendly Arabic font), exposed from an accessibility menu in the Adult AppShell header.

- **High-contrast mode** flips one document attribute (`<html data-contrast="high">`) that redefines the status-chip palette tokens to ≥7:1 contrast pairs — every chip, badge, border, and muted text recolors at once, no per-component work. Charts/chips can add non-color pattern encoding (hatch / dotted / cross) so color-blind users read the same signal.
- **Dyslexia-friendly font** swaps the Arabic face and increases word-spacing and line-height. It deliberately does not add letter-spacing, which would break Arabic cursive letter-joining. The exact partner-approved typeface is partner-owed (🚩 OQ-ACC-5) and swaps via a single CSS variable with no code change.
- **Persistence with no flash.** Both preferences are stored per-device and restored by a synchronous boot script before the page paints, so they survive reloads without a flash of the default theme. On a shared classroom device a teacher or parent enables them once; the immersive Student World inherits both automatically (a dedicated in-student menu is deferred).

Verified: Playwright e2e — enabling high-contrast recolors a status chip and re-passes axe-core with zero WCAG-AA violations; the dyslexia toggle changes the font and spacing; both persist across reload. Full 75-test accessibility suite green across phone / tablet / laptop / projector with no regression.

## Admin Audit-Log Viewer (WP-AUDITLOG) — 2026-06-21

**1 new operation** under the new `audit` tag, bringing the published total to **203 operations**.

- **`GET /api/admin/audit-log`** — unified read-only list of platform security events, reverse-chronological and paginated. Admin-only (`VIEW_QA_REGISTRY`); tenant-scoped per source table. The response includes event type, actor ID, timestamp, and value-length indicators only; no IP addresses, user agents, or attempted values. Filterable by event type, actor, and date range; paginated with `limit` / `offset`. A SUPER (null-org) admin sees all organizations. Reading as SUPER appends one `SuperAdminBypassLog` row (the SUPER read itself is audited); a normal admin read appends none.

The companion admin viewer FE page at `/admin/audit-log` adds a filter bar, detail dialog, and pagination.

## Principal Dashboard Domain KPIs + Growth (WP-PRN-COMPLETE) — 2026-06-21

No new routes; the three previously-stub principal endpoints now return real data. Operation count unchanged: **203 operations**.

- **`GET /api/principal/schools/{id}/overview`** — Curriculum-KPI gauges from real `domain_status_snapshot` rows: per-domain student counts at each status level, plus a School Literacy Health Index derived from those counts. No student IDs or names in the response; no percentages (V-3).
- **`GET /api/principal/schools/{id}/heatmap`** — class × KPI matrix with Arabic-label-only columns; not-assessed entries excluded from counts.
- **`GET /api/principal/schools/{id}/growth`** — per-window status counts plus window-over-window movement (toward / away / held). Non-graded cohort excluded from movement. Byte-identical on repeat calls (V-6).

## Teacher Activation Workflow UI (WP-TAW-FE) — 2026-06-21

Front-end only, no backend or spec change. The decide-then-intervene wizard that surfaces the WP-TAW-BE workflow API for teachers. Five stages take a teacher from reading per-student recommendations through confirming active plans; a monitoring view follows each active assignment.

- **Activation wizard** (`/teacher/class/[classId]/activate`): five sequential stages: Recommendations Panel (per-student bundle recommendation with a rule-written reason and a documented strength), Smart Grouping (cluster students who can share one plan), Scaffold Distribution (support-level mix, teacher-adjustable per student), Activation Review (delivery, dosage, task type, and a one-time explanation of why this recommendation applies), and Confirmation (bulk or per-member). Draft state persists across sessions (7-day server TTL).
- **Plan review and monitoring** (`/teacher/class/[classId]/plans/[assignmentId]`): fidelity card, monitoring widget, and post-activation decision flows (maintain / de-escalate / escalate / block on fidelity).
- **Three non-bypassable guardrails.** Insufficient evidence: activation is disabled; the only option is "request more data." Sudden drop detected: a review modal opens; the plan is neither auto-paused nor auto-failed. No settled profile: the student is excluded from grouping. Each one requires an explicit teacher action before the wizard continues.
- **Teacher decides at every step (V-12).** The teacher makes every decision. Reason fields use the 9-code controlled dropdown, not free text. Internal labels, tier identifiers, and scores do not appear on any student-visible surface.

Verified: Playwright e2e 16/16 across phone, tablet, laptop, and projector widths, exercising all five wizard stages and three guardrail paths.

## Synchronous Lesson Mode UI (WP-18-FE) — 2026-06-21

Front-end only — no backend or spec change. It consumes the shipped WP-18-BE `sync` API. Three live
surfaces land on the WP-RBL shells.

- **Teacher controller** at `/teacher/sync` (Adult AppShell): create a draft session for a class →
  get a numeric class code to read aloud → start (language-safety gate) → drive the playlist with
  idempotent control commands (advance / pause / resume / skip / end). The live view shows
  **aggregate counts only** (`#joined` / `#responded` / `#idle`) — never per-student detail
  (FR-SYNC-9).
- **Student responder** at `/student/sync` (Student World, immersive): join via the numeric class
  code (rostered-only), see the current step, answer on the device. **Browser-cache restore**
  (Q-SYNC-10) resumes the exact live step after a refresh / lock / app-switch / wifi-drop.
- **Projector / shared screen** at `/projector/sync` (full-bleed, ungated): the shared prompt, the
  giant join code, and aggregate counts only — no per-student internals (FR-CLIENT-10).
- **Group mode**: leader-only consensus submit; non-leaders watch (submit disabled).
- **REST polling only** (Q-SYNC-1 / B.8) — no WebSocket / SSE / WebRTC. Power-save backoff doubles the
  interval on empty polls (cap 10s) and force-polls on tab re-focus.
- **Deferred**: personalized-projector (FR-SYNC-PRJ-1..5) and read-aloud observation (FR-SYNC-12) —
  a later pass; their setup/commit BE seams return 503 until session-gen + scan ship.

## SSE to snapshot projection bridge (WP-SSE-SNAPSHOT-BRIDGE), 2026-06-21

No new routes. The diagnostic finish now feeds the decision pipeline, so the macro status surfaces stop
reading an empty table.

- A finished diagnostic writes per sub-skill screening evidence, runs the skill status engine, and writes the
  six macro tiles plus the GLOBAL meta row. Reporting, board PDF, multi-school, and parent surfaces return the
  student's real tiles.
- Every snapshot is append-only, idempotent on a version-pinned key, and frozen to the session finish time for
  deterministic replay. A re-finish writes nothing new.
- All macro, skill, and domain snapshots key on the student profile id. The reporting, board, multi-school,
  parent, and profile readers were aligned to that id.
- The macro snapshot table keeps its shipped column names. The one schema change is a per-window version-pinned
  uniqueness key.

## Principal Dashboard (WP-PRN-FE) — 2026-06-21

Front-end only — no backend or spec change. The PRINCIPAL persona now has a school-health
dashboard at `/principal`, on the Adult AppShell. It composes shipped aggregate APIs; there is
no aggregator endpoint yet, so the FE binds two reads directly.

- **School discovery** via `GET /api/schools`, which already scopes a principal to their own school
  (TENANCY-C2). One school → land directly on the dashboard; multiple (manager-equivalent) → the
  School Picker at `/principal/schools`.
- **Live data** from `GET /api/schools/{schoolId}/overview` (WP-10-BE) — real per-class macro
  counts, the school-wide macro distribution, total students, and snapshot freshness. The DTO is
  aggregate-only (class-granularity floor, PII-suppressed at the BE schema); **no student-level
  rows** ever reach the page.
- **Curriculum-KPI gauges** render the real Arabic KPI labels from
  `GET /api/principal/schools/{id}/overview` (WP-STD-BE). Wave-1 the per-measure aggregation is not
  wired yet, so counts arrive zeroed and each gauge shows a quiet "awaiting data accumulation"
  pending state — structurally complete, zero fabricated numbers.
- **No single global percentage** (V-3) — every surface is a status word + integer tally. The
  heatmap, School Literacy Health Index, and data-sufficiency gauge are flagged as coming-soon
  pending the WP-PRN-BE aggregation pass. The Ministry report stub stays unsurfaced.

## Manager Multi-School Dashboard (WP-MSA-FE) — 2026-06-21

Front-end only — no backend or spec change. The multi-school **Manager** persona now has an
org-level dashboard on the Adult AppShell, consuming the shipped WP-MSA-BE org routes.

- Three views under `/manager/orgs/[orgId]`: **Overview** (per-school rollup cards + org totals),
  **Comparison** (a macro-tile × school status-count matrix), and **Trends** (a line chart of
  needs-support counts per school over month windows). `/manager` redirects to the resolved org.
- **Aggregate-only** — only school names + status-bucket counts render; no student or teacher names
  or rows on any surface (the BE DTOs omit student PII at the schema layer).
- **Neutral framing** — the comparison is side-by-side status counts with schools ordered
  alphabetically, never a ranking/leaderboard and never a composite score.
- Counts only (no percentage, V-3); status words reuse the shipped display maps; zero
  international-framework terms (Q15). The trends chart is a self-contained inline SVG (no new
  dependency); line colors encode school identity, not severity.

## Benchmark Profile Backoffice (WP-STD-ADMIN) — 2026-06-20

No new routes — two existing super-admin reads gained optional filters; the swap is reused untouched.
A super-admin/partner backoffice for **browsing, swapping, and auditing** benchmark profiles, on the
Adult AppShell.

- `GET /api/admin/benchmark-profiles` now accepts optional `country`, `skillId`, `assessmentType`,
  `gradeBand`, and `isActive` filters (AND-combined; an empty query returns all rows as before).
- `GET /api/admin/benchmark-profile-changelog` now accepts an optional `benchmarkProfileId` to read one
  profile's audit history (absent → the full log, as before).
- The version **swap** reuses the shipped advisory-locked write path — no second writer was added, so the
  single-write-path lint stays green.
- All routes stay SUPER_ADMIN-gated. The backoffice renders Arabic curriculum labels only; engine-internal
  yardsticks never appear in user-facing chrome (Q15).

## Cognitive Warm-Up student UI (WP-WARM-FE) — 2026-06-20

Front-end only — no backend or spec change. The 3–5 min non-linguistic warm-up now has a student
screen at `/student/warmup`, built on the Student World shell. It consumes the existing WP-WARM-BE
`warmup` API.

- **Data-driven renderer.** One registry picks the right React renderer from the exercise data
  (`select` / `positions` / `buckets` / `go_nogo`) — there is no separate screen per exercise type.
- **Calm by design.** Wordless shapes only, no countdown, a penalty-free "skip to the task" path,
  and no score or ranking. The warm-up never feeds the measurement engine.
- **Resumes on disruption.** A refresh / lock / app-switch restores the child at the exact exercise
  step from browser cache.

## Teacher Class Summary + Re-diagnostic (WP-07-FE / WP-08-FE) — 2026-06-20

No new backend routes — both are FE surfaces consuming shipped APIs (WP-06-BE reporting reads + WP-08-BE
re-diagnostic).

- **Class Summary** (`/teacher/class/[classId]/summary`) — the reporting complement to the operational Class
  Dashboard. Shows support-tier composition, the reused 6-tile macro-domain grid, a per-skill status
  distribution table (intervention counts per skill), and a due-for-re-check list. Every figure is a status
  word × count — never a single overall percentage (V-3).
- **Re-diagnostic** — a teacher trigger on the Student Profile opens a re-measure sitting
  (`POST /api/diagnostic-sessions/start/re-diagnostic`; the ≥14-day gate is backend-enforced) and a comparison
  view (`GET /api/reports/students/{id}/re-diagnostic-comparison/{sessionId}`) shows each sub-skill's prior →
  current status with a growth direction. Growth language only — no raw ability score reaches any caller; a
  decline is framed for review, never as a failure (V-12).

## Real Rasch calibration (WP-CAL) — 2026-06-20

No new routes. The diagnostic engine's ability estimate (θ) is now produced by a real,
deterministic 1PL Rasch EAP scorer instead of the closed-form mock. The `EngineImplementation`
value `rasch_1pl_v1` is the default; the mock stays selectable for replaying older sessions.

- **Two-track difficulty.** An item uses its measured difficulty once it has 300+ real responses,
  and the deterministic estimate (`delta_prior`) until then. Sessions pin a frozen calibration
  version, so a difficulty never shifts mid-session (V-6).
- **Offline calibration pipeline** (`services/calibration/`, Python + girth) fits 1PL difficulties
  per sub-skill and mints a new versioned snapshot each run; validated to recover known difficulties
  on synthetic data. No real pilot responses exist yet, so it ships built and ready, not yet run.
- **New guide:** [Calibration Runbook](https://docs.growamal.com/en/guides/calibration-runbook/) covers the data-readiness
  check, the dry run, the real run, reading the fit statistics, and re-adjusting difficulty as more
  responses land.

## Unified Notification Center (WP-NOTIF-BE) — 2026-06-20

**5 new routes** (`notifications` tag). The single cross-persona in-app inbox that replaces the six-plus
scattered alert surfaces a teacher faced on Day 1. In-app only at Wave 1; email and per-kind opt-in move to
WP-NOTIF-EMAIL.

- **Recipient-scoped, no IDOR.** `GET /api/notifications`, `GET /api/notifications/unread-count`,
  `POST /api/notifications/{id}/read`, `POST /api/notifications/{id}/dismiss`,
  `POST /api/notifications/mark-all-read`. Each route derives the recipient from the auth token (there is no
  `userId` path parameter). A caller reads and mutates only their own rows; a non-owned id returns 404, not
  403, so the existence of another inbox's id is never disclosed.
- **Tenant and cross-persona isolated.** `organizationId` is denormalized on every row; a teacher never reads
  another school's notifications, nor a parent's.
- **Internal `notifications.dispatch()` seam** (no HTTP surface) is the sole write path: per-kind Zod payload
  validation (Q18, no free text), Arabic template render, language-safety filter on the rendered title and
  body (a hard block, no fallback string), org and persona resolution, then insert. No LLM (V-5).
- **9 Wave-1 kinds.** Rows are createdAt-append-only (only the delivery state mutates); a dismissed
  notification never resurfaces in any list.
- Producers adopt the seam in their own change; this ships the seam plus tests. Operation count is now **202**.

## Client-aware backend (WP-CLIENT-CTX-BE) — 2026-06-20

**No new routes** — one middleware + a pure asset-tier selector + a telemetry field + the isolation lint
(`83-client-and-device-support` FR-CLIENT-8/9, hard rule B.27).

- **The `X-Client` contract.** The frontend sends one zod-validated capability header —
  `X-Client: {"class":"phone|tablet|desktop|projector","standalone":bool,"bandwidth":"slow|normal|fast|unknown","reducedData":bool,"viewport":"xs..xl","appVersion":"…"}`.
  A `client-context` middleware parses it into `c.var.client`. **Capability-only, no PII** (extra/unknown keys are
  rejected). **Fail-open** — an absent or malformed header falls back to safe defaults (`desktop` / `unknown`),
  never an error.
- **Telemetry.** Request logs now carry an optional `client` device-class field (additive, backward-compatible).
- **Adaptive asset tiers (FR-CLIENT-9).** The task serve attaches `presentation.assetTier` (`{ audio: lo|hi,
  image: 1x|2x }`) chosen from the client capabilities — light tier by default. This changes the bytes served,
  **never the item**: the same `itemBankId`, options, correct answer, and audit row regardless of device.
- **Determinism guard (B.27 / V-6).** A CI lint, `lint:client-context-isolation`, fails the build if the
  client/device context is read inside any measurement, decision, or authorization path. A cross-client
  determinism test proves identical input on two device classes yields a byte-identical measurement.
- **🚩 Partner-owed (June-25):** the real per-tier asset URLs + exact bitrate/resolution thresholds arrive with
  the audio/illustration pipeline (OQ-CLIENT-2); the selected tier is annotated now for the FE/CDN to consume.

## Re-diagnostic Re-Measure (WP-08-BE) — 2026-06-20

**2 new routes** (`re-diagnostic` tag). The platform can open a focused re-measure diagnostic after
an intervention cycle and compare results against the prior diagnostic session.

- **14-day gate.** `POST /api/diagnostic-sessions/start/re-diagnostic` opens a re-measure sitting.
  Returns 409 if the prior completed diagnostic is fewer than 14 days old.
- **Queue archival.** Opening a re-diagnostic archives the student's active practice queue so the
  student starts fresh after the re-measure rather than picking up mid-queue.
- **Side-by-side comparison.** `GET /api/reports/students/:id/re-diagnostic-comparison/:sessionId`
  pairs the re-diagnostic against the most-recent preceding completed diagnostic and returns per-sub-skill
  `{ priorStatus, currentStatus, statusTransition, growth }`. `growth` is one of `improved`,
  `declined`, `same`, or `indeterminate`. No raw θ leaves the server (FR-RPT-3/9 no-leak).
- Authz: teacher (ClassTeacher own-class), admin, or `RUN_BACKOFFICE`; cross-org returns 404.

## Client & device infrastructure — PWA + capability layer (WP-CLIENT-INFRA-FE) — 2026-06-20

The frontend half of the client/device support contract (hard rule B.27 · spec
`83-client-and-device-support`). Solved once at the platform layer so every screen — shipped and
future — inherits responsive + offline behavior across the four pilot device classes (phone,
tablet/iPad, desktop/laptop, classroom projector).

- **One capability layer, never UA.** A single provider resolves the device class from viewport +
  capability (pointer, reduced-data, standalone, bandwidth) and exposes it via `useClient()`. Layout
  stays CSS-breakpoint-driven; the User-Agent string is never read to branch a layout.
- **Canonical breakpoints** defined once (`sm 480 / md 600 / lg 1024 / xl 1280`), so the device
  matrix is the single source of truth for widths. The Adult AppShell collapses sidebar → bottom-tab
  below `md`; Student World is full-bleed; the projector shows no per-student internals (no-leak).
- **Installable PWA.** Web app manifest ("أمل", maskable icons, RTL, light theme) + a service worker
  that precaches the app shell and serves it offline, with a **non-silent** "update available" prompt
  (never a surprise reload). The SW caches only content-hashed static assets and never touches the
  in-flight student-session cache.
- **Offline answers, never lost.** A student who loses connectivity mid-practice keeps answering;
  responses queue locally and replay on reconnect with their idempotency key, so the server records
  each once (no double-credit). Teacher decisioning and any measurement write stay online-only with a
  calm "no connection" state — never a fake success. The server is always authoritative.
- **`X-Client` header + perf budgets.** Every request carries a capability-only descriptor (no PII)
  for telemetry + future adaptive assets; it never influences a measurement (determinism, V-6).
  Lighthouse-CI enforces a JS-byte ceiling + Time-to-Interactive on a throttled low-end profile (the
  pilot device is the gate). A 4-viewport CI device matrix runs on every frontend work package.
- 🚩 **Partner-owed (June-25):** the pilot-school minimum device spec (sets the real performance floor
  + iOS-Safari PWA constraints) and the asset-tier thresholds.

## Session Assembly engine (WP-ISM) — 2026-06-20

**3 new routes** (`ism` tag). The Integrated Scaffolding Model ("المايسترو") — the orchestrator that composes one
theme-cohesive practice session from the shipped operational item bank: one theme, one Sentence Growth Unit
ladder, skills interleaved on one word family.

- **Selects, never generates.** ISM picks complete, calibrated, SME-approved bank items only — no runtime
  generation, no LLM (V-5). The diagnostic NEVER calls it (V-3); it stays pure max-information CAT.
- **Deterministic (V-6).** Same student + theme + seed + item-bank snapshot ⇒ byte-identical session + the same
  run id. A bank change mints a new run; history is never mutated.
- **Two modes, one endpoint.** Generic-practice (flat sub-skill list) or bundle-context (anchor + ≤2 supporting
  + asset bridge, weighted by the Threaded Path Model with the anchor share always largest).
- **Fail-safe.** A 4-step relaxation ladder (widen band → semantic-field → drop growth climb → fail-soft) marks a
  short session starved instead of padding it. Every 4th practice session yields to a CBM probe.
- **Practice access is never blocked.** Only bundle-driven RTI sessions gate (409) on insufficient evidence /
  acute regression; the teacher/parent sees a growth-language script, never the code.
- New tables: `assembly_run` (append-only audit/replay) + `assembly_rule_set` (versioned config). FSRS mix,
  dialect handling, and probe-prepend are flagged partner-owed (June-25).

## Synchronous Lesson Mode (WP-18-BE) — 2026-06-20

**17 new routes** (`sync` tag). The backend for teacher-led synchronous lessons where a teacher
projects a live session and students answer on their own devices, over REST polling with no WebSocket
or SSE dependency (Q-SYNC-1 LOCK for pilot-school hardware constraints).

- **Session lifecycle.** `POST /api/sync/sessions` creates a draft session with a numeric join code;
  `POST …/start` activates it; `GET …/summary` returns aggregate or per-student results after the
  session ends.
- **Three modes.** Whole-class, group-response, and personalized-projector. The personalized-projector
  path pushes a different item to each student's device while a common slide remains on the shared
  screen. Routes: `…/personalized-projector/setup`, `…/answer-key`, `…/mark`, `…/commit`.
- **Leader-only groups.** `POST …/groups` creates student groups with a designated leader;
  `PATCH …/groups/:groupId/leader` transfers leadership.
- **REST polling.** Students and the teacher poll `GET …/poll` and `GET …/events` for state changes.
  Answers go to `POST …/responses`; `POST …/observation` records a teacher note.
- **Playlist and control.** `POST …/playlist` loads the item sequence; `POST …/control` drives play,
  pause, next, and end. Sessions can link to an ISM assembly run via `POST …/ism-run`.

## Gamification backend (WP-14-BE) — 2026-06-20

**7 new routes** (`gamification` + `leaderboard` tags). The learning-aligned gamification engine: the
mastery-event ledger (8 closed source kinds), derived XP, badges, a cosmetic avatar, soft streaks, the
teacher/principal leaderboards, the badge rescind path, and admin audit/replay.

- **Every reward traces to a measured mastery event (V-8).** XP/badges are minted only from one of the 8
  `MasteryEventSourceKind` values — session count, time-on-app, and login streaks can never mint anything. A CI
  lint asserts every badge award rule cites a measured source.
- **No student-facing leaderboard, ever.** Leaderboards are teacher (multi-class) + principal (school) only; a
  student token gets 403 on every leaderboard route. Self-XP shows own data only — no peer comparison or rank.
- **Idempotent + replayable.** Emission is idempotent on the natural key; XP balance is derived from the
  append-only ledger (never stored); an admin replay re-derives balance/badges/avatar and diffs for zero drift.
- **Rescind = one controlled code, no free text.** A teacher rescinds via the single 9-code
  `Gam_Award_Invalid_05`; a compensating reversal is appended; the original award row is preserved.
- Working defaults (XP values, the 200/session cap, avatar/streak thresholds, the 15-badge Arabic catalogue) are
  seeded + versioned and flagged partner-owed for the June-25 session — none invented.

## Decision-loop guides — fidelity, decision packets, data room — 2026-06-20

No API change. Three new developer guides under [Guides](https://docs.growamal.com/en/guides/fidelity-tracker/), grounded in
the shipped module code, give the RTI decision wave its API-tier coverage:
[Intervention Fidelity Tracker](https://docs.growamal.com/en/guides/fidelity-tracker/) (the escalation gate: rate, rating,
verdict, and `escalationBlocked`), [Decision Packets](https://docs.growamal.com/en/guides/decision-packets/) (the assemble,
approve, and reject workflow that calls the RTI engine to move the tier), and
[Student Data Room](https://docs.growamal.com/en/guides/data-room/) (the dossier + evidence index + packet shell). Each
documents its real routes, permissions, and field shapes, with the fail-closed, append-only,
version-pinned, controlled-reason, and never-student-facing invariants. English + Arabic parity.

## Platform Guide — external documentation portal — 2026-06-20

No API change. A second documentation flavor for a non-developer audience, served under the
`/external` path prefix. The navbar carries two tabs, **Developer Docs** and **Platform Guide**, and
each portal shows only its own menu (the sidebar switches per portal). Developer-doc URLs are
unchanged.

- **27 deep pages** in English and Arabic, grounded in the actual module code, organized into six
  sections: Measurement & Decisions, Intervention & Teaching, Monitoring & Response, Reports &
  Dashboards, Platform Foundations, plus Overview, Trust & Privacy, Roadmap, and an FAQ. Each page
  explains a real capability end to end with its true rules and thresholds, in plain product language
  (no endpoints, schemas, or internal identifiers).
- Growth language only: no clinical or program labels, no single overall score, and an honest split
  between what is available now and what is planned.
- Both portals share one app, theme, and search; full EN/AR parity (60/60) holds.

## Portal — Phase 1 comprehensive guides — 2026-06-20

No API change. A documentation-only pass that brings the portal current with the ~21 RTI-engine work
packages shipped since the portal was first built. The provisioning docs previously stood alone;
the measurement-led RTI loop now has full conceptual coverage.

- **New "How Phase 1 Works" concept section** — [The RTI Loop](https://docs.growamal.com/en/how-it-works/),
  [Measurement & Determinism](https://docs.growamal.com/en/how-it-works/measurement/),
  [The Decision Engine](https://docs.growamal.com/en/how-it-works/decision-engine/),
  [Intervention Design](https://docs.growamal.com/en/how-it-works/intervention/), and [Safety Rules](https://docs.growamal.com/en/how-it-works/safety/).
- **16 new subsystem guides** under [Guides](https://docs.growamal.com/en/guides/diagnostic-and-practice/) — diagnostic &
  practice, skills taxonomy, skill-status engine, student profiles, intervention bundles, smart
  clustering, teacher activation, progress monitoring, CBM & ORF fluency, RTI decisions (preview),
  standards & benchmarks, task delivery, reporting & parent PDF, cognitive warm-up, language safety,
  and system QA checks.
- **New [Glossary](https://docs.growamal.com/en/glossary/)** and three stale-page fixes (Overview, Errors decision-engine
  "safe-stop" outcomes, Managers & Principals rollup now populated).
- Every page ships in **English + Arabic** (33/33 parity); all internal links resolve.

## Parent Web Portal — 2026-06-20

Four new read-only operations under a new `parent-portal` tag (the count moves to **171**) — an authenticated
parent's view of their own children.

- `GET /api/parent/children` (linked children), `…/:childId/diagnostics` (status-only history),
  `…/:childId/growth` (macro-domain status trajectory, Arabic labels only), `…/:childId/recommendations`
  (≤5 at-home suggestions from the D2 pre-generated narrative — never an LLM at runtime).
- **Own-child only.** The parent is the authenticated user (no `:parentId` in any path → no IDOR); each
  `:childId` is checked against the `ParentStudent` link — an unlinked in-org child → **403**, a
  cross-org/non-existent child → **404**. Multi-child parents see all their linked children.
- **No-leak by construction (two belts).** A typed allow-list DTO + the canonical parent-stricter string
  filter — no θ, no numbers, no percentages, no internal labels ever reach a parent (a child with a real
  θ/profile/percentage in their data shows none of it). Fail-closed (503) if the filter rules are missing.
- **No WhatsApp digest, no phone storage** (D1) — every route is GET, read-only; the portal stores nothing
  about the parent.

## Multi-School Admin Overview — 2026-06-20

Three new operations under a new `multi-school` tag (the count moves to **167**) — an org-level rollup for a
network manager, built on top of the school-level board view.

- **`GET /api/orgs/:orgId/overview`** — a rollup card per school (name + total students + class count +
  how many need support). **`GET /api/orgs/:orgId/comparisons`** — the 6 reading-domain tiles × N schools,
  side-by-side, as status **counts**. **`GET /api/orgs/:orgId/trends`** — an org time-series, one line per
  school × tile, over month windows.
- The comparison is a **neutral status rollup, not a ranking** — no best/worst ordering, no school
  leaderboard. Cross-Cutting shows the "coming soon" placeholder.
- **Aggregate only, by construction.** It shows **class names** but **never** a student name or id in any of
  the three responses — the same lint guard (extended to this module) + tests that seed a recognizable name
  and assert its absence. No θ, no single overall percentage (V-3); `Not_Assessed` excluded from rollups.
- A manager sees **all** their org's schools by default (no partial-scoping UI in Wave 1); a cross-org id
  returns 404. Manager / Admin only — never teacher- or student-facing.
- Reuses the shipped school-aggregate engine + readers (no new tables, no measurement logic). Batched reads
  meet the ≤5 s budget for a 10-school org.

## Parent Narrative Coverage (AI-generated, provisional) — 2026-06-20

One new admin operation (the count moves to **164**) plus a content + data change so the parent report and
portal show a written growth explanation for **every** profile, not just the two partner-authored ones.

- **56 Arabic parent narratives** (the 12 primary + 5 modifier profiles × grade bands) are generated from
  the profile specs and seeded as a provisional layer. Each is stamped `ai_generated` + `needs_sme_review`
  and passes the parent-stricter filter with zero changes — no numbers, no θ, no internal labels, growth
  language only (the same V-12/V-3 discipline as everything parent-facing).
- **They ship live, flagged** (Mohammad's call): a parent sees the explanation now; the partner reviews and
  replaces each at the June-25 working session. Only the **parent** render is overridden — the teacher,
  admin, and student narratives stay partner-authored. The safe "not enough data yet" page remains for
  genuine no-profile cases, not for "narrative not written yet."
- `GET /api/admin/profile-narratives/pending-review` lists the provisional rows for the SME review queue
  (RUN_BACKOFFICE only).

## School Overview + Board PDF — 2026-06-20

Two new operations under a new `board-pdf` tag (the count moves to **163**) — a school-level view for
principals and network managers.

- **School overview JSON** + a **one-page A4 Arabic-RTL board PDF** for a board/management meeting:
  per-class macro-status counts + the school-wide distribution across the 6 reading domains (+ the
  "coming soon" Cross-Cutting placeholder).
- **Aggregate only, by construction.** It shows **class names** (so a manager can act) but **never** a
  student name or id — a dedicated lint guards every read path, and a test seeds a recognizable name and
  asserts it's absent from both the PDF bytes and the JSON. No θ, no single overall percentage (V-3).
- Reuses the shipped reporting readers + the **shared `lib/pdf` primitive** (embedded Arabic font, V-6
  byte-determinism) that the parent PDF now also builds on — one PDF recipe, two layouts.
- Manager / Principal / Admin only (a principal sees their own school; a manager sees their org's schools);
  never teacher- or student-facing. ≤500 KB, ≤30 s, fail-closed.

## v5 vocabulary alignment — 2026-06-20

**No behavior change.** The RTI decision layer now stores the partner's canonical v5 status-code names directly,
removing an interim translation layer. Data-sufficiency reads `sufficient / insufficient / contextual`; tier
decisions `escalate / de_escalate / maintain / defer`; RTI alerts the 5-kind catalog `Skill_Alert / Student_Alert /
Intensive_Review / acute_regression_review / tier_review_due`; the approval role set adds `specialist`; the
decision packet reads `draft / under_review / approved / rejected / deferred`. Only the spelling of the stored
tokens changed — decision logic, guardrails, and determinism (V-6) are untouched, and the OpenAPI schemas reflect
the v5 names.

## Decision Packet Workflow — 2026-06-20

Four new operations under the new `decision-packet` tag (the count moves to **161**). The closing piece of the
RTI decision wave: the review-and-approval workflow that turns a tier-movement recommendation into an
audited decision — and only then moves the child's tier.

- **Assemble.** `POST /api/decision-packet-workflows/assemble` opens a rich decision packet (evidence snapshotted
  by reference, every rule/config version pinned for replay), computes the deterministic tier recommendation, and
  records a workflow row awaiting review.
- **Review + approve.** `GET /api/decision-packet-workflows/{id}` is the reviewer's surface; `POST …/{id}/approve`
  routes the recommendation through the approval gate — a Tier-3 move requires the high-intensity specialist
  (admin-seated for Wave 1) — and on approval **executes the tier movement through the RTI engine's single
  tier-write path**, then closes the packet. `POST …/{id}/reject` records a controlled-reason decline; no movement.
- **No movement without approval; the decision reason is a controlled code, never free text.** A sentinel
  "need more evidence" halt, an unapproved Tier-3 move, or an incomplete dossier all block the movement
  (fail-closed). A closed packet + an applied decision are immutable forever (replay reproduces them).
- **This workflow adds NO tier write of its own** — it calls the RTI engine to move the tier and the Data Room to
  open/close the packet. Append-only, tenant-isolated, version-pinned (V-6), and never shown to a student.

## Intervention Fidelity Tracker — 2026-06-20

Five new operations under the new `fidelity` tag (the count moves to **152**). The gate that distinguishes
"the plan didn't work" from "the plan wasn't done," so the platform never escalates a child whose intervention
was never actually run.

- **The escalation gate.** `GET /api/fidelity/{bundleAssignmentId}/check` returns the synchronous verdict the
  RTI engine calls before any Tier 2 → Tier 3 move: the fidelity rate (sessions done ÷ planned), a 5-value
  rating, a 4-state verdict, and `escalationBlocked`. Faithful delivery (≥80%) with weak growth MAY escalate;
  under-80% with weak growth is BLOCKED; no session data is "cannot evaluate yet" — never a guessed pass.
- **Recompute + tracker + cohort.** `POST …/recompute` appends a fresh immutable snapshot (advisory-locked,
  idempotent); `GET …/{bundleAssignmentId}` reads the latest; `GET /api/students/{id}/fidelity-tracker` feeds
  the teacher's Fidelity tab; `GET /api/fidelity/admin/low-fidelity-cohort` lists the at-risk plans (admin).
- **A growth-language paragraph.** Each verdict auto-drafts a short Arabic review-meeting paragraph — rule-
  written, never AI — filtered for clinical/framework/deficit language.
- **Append-only + version-pinned (V-6); teacher-scoped (B.4); tenant-isolated; never shown to a student.** The
  persisted verdict is what the RTI fidelity gate now reads — no guessed up-move can slip past a plan that
  wasn't delivered.

## Student Data Room — 2026-06-20

Five new operations under the new `data-room` tag. The Data Room is the *decision-documentation layer*: it
assembles a child's evidence into a reviewable dossier so that big decisions are documented, evidence-based,
and replayable years later. It never decides — the rules still decide; the Data Room makes them show their work.

- **One dossier per child.** `GET …/data-room` returns the eight summary rollups (benchmark, CBM trend,
  practice, paper checks, maintenance, error patterns, fidelity, behavior flags) + a data-sufficiency status.
  The summaries are system-rendered rollups of referenced evidence — never free text, never an input to a rule.
- **Evidence is indexed, never copied.** `GET …/data-room/evidence` pages the evidence index; every row points
  back to its real source record. Invalid-administration evidence stays indexed (nothing is deleted) but is
  excluded from the sufficiency count.
- **Decision-packet shell.** `POST …/decision-packets` opens a packet, snapshots the evidence *by reference*,
  and pins the rule versions it read — so the packet replays byte-for-byte years later. A second open of the
  same review type returns 409; a closed packet is immutable forever.
- **Weak data blocks a strong decision.** When a skill is `do_not_decide_yet`, or the evidence is below the
  comparable floor, the dossier reports `insufficient` and recommends the *next data action* — never a movement.
- **No tier movement here.** The Data Room reads and composes the tier-decision record; it never writes one.
  The only tier write-path stays in the RTI engine.
- **Students never see any of this** — there is no student endpoint; teacher (their own classes) / admin only.

## RTI Decision Engine — 2026-06-20

Ten new operations under the new `rti` tag. This is the central decision layer: it raises the right hand on
the teacher's dashboard, and it is the single gate every RTI Tier change goes through.

- **Two alert layers.** Layer A is the per-skill state (`on_track` / `monitoring` / `maintenance`, math-driven
  from the skill-status engine); Layer B is the per-student alert — `Skill_Alert` (a single-skill nudge),
  `Student_Alert` (this student needs a Targeted Support Plan), `Intensive_Review` (the deepest review). At
  most one alert is open per student; a more-severe alert auto-closes the lower one.
- **The 4 tier-movement rules.** Every tier change runs `TM_ESC_01` (escalate) / `TM_MAINT_01` (maintain) /
  `TM_DEC_01` (de-escalate) / `TM_BLOCK_01` (block on low fidelity). The engine *recommends*; the teacher
  *approves*; the specialist (admin-seated Wave-1) gates TIER_3. `GET …/recommendation` returns the
  deterministic verdict; `POST …/decision` commits it through the single audited write path.
- **Fidelity before escalation.** `TM_BLOCK_01` runs before `TM_ESC_01`: "the plan didn't work" and "the plan
  wasn't done" are different verdicts. Until the fidelity tracker ships, escalation fails closed (blocked) —
  never a guessed up-move.
- **`do_not_decide_yet` blocks everything.** When the evidence isn't sufficient, no alert opens and no tier
  moves — for any role, no override. The teacher sees a quiet "need more evidence" hint, never a red alarm.
- **No direct tier set, ever.** A tier changes only through the gated decision path that writes an append-only
  decision record in the same transaction. Dismissals use a controlled reason code — never free text. Students
  never see any of this.

## CBM / ORF Progress-Monitoring Engine — 2026-06-20

Ten new operations under two new tags — `orf` + `cbm` (the count moves to **129**). The platform now
measures reading fluency and watches it improve over time.

- **Teacher-scored ORF, no AI.** `POST /api/orf/assessments/{start, :id/mark, :id/stop}` records a 60-second
  oral read by teacher error-taps (no speech recognition, no LLM; audio archive-only) → words-correct-per-
  minute + accuracy → an append-only `cbm_scores` row.
- **Aim line + 4-point trend.** `POST /api/cbm/probes` writes a probe; the aim line activates after three
  baseline probes (median baseline), and the last four comparable points yield a typed trend
  (`trend_above_goal` / `trend_below_goal` / `trend_mixed` / `insufficient_points`). `GET /api/cbm/trend/...`
  returns the series + aim line; `GET /api/cbm/alerts` the open alerts.
- **Emit-only (V-12).** The engine emits the signal + a raw alert; it does not apply the fidelity gate or
  write the teacher message (those are WP-RTI-BE + the fidelity tracker, which read the alert behind an
  ≥80% gate). Acute regression raises the signal, never auto-fails. Dismissal uses a controlled reason code —
  never free text.
- **Missing norms are honest.** Grades with no benchmark norm yet (G1/G4 partner-owed; G2–3 seeded) score and
  trend on raw WCPM but report "not assessed" rather than judging against an invented cut.

## Cognitive Warm-Up Engine — 2026-06-20

Seven new operations under the new `warmup` tag; the count moves to **119**. The backend for the 3–5 minute,
rule-based, deterministic, **non-linguistic** warm-up a child plays before a session (shapes, colors, paths —
never words, letters, or numbers).

- **Deterministic, seeded generation.** 10 Tier-1 activity schemas + 24 per-grade configs through one central
  generator; the same `(template, grade, difficulty, seed)` always yields the identical exercise (V-6
  replayable, validated by `POST /api/admin/warmup/dry-run-replay`).
- **Resumable + capped.** `POST /api/warmup/sessions` → `…/events` → `…/complete`, with `…/resume` restoring
  the child at the exact exercise step. A silent 5-minute cap; **a skip is penalty-free** (no retry, no
  `warmup_abandoned` flag).
- **Isolated from measurement — by construction.** The warm-up never touches θ, never emits a mastery event,
  never mints XP, and has no foreign key into any measurement/decision table. A CI import-graph guard + a
  schema-FK test prove it; telemetry is a readiness signal stored in the warm-up's own tables only.
- Teacher/admin-gated, ClassTeacher-scoped; no student endpoint (the exercise is handed to the renderer).
- **Partner-owed (June-25):** COG-05 (Matching) + COG-06 (Visual Tracking) per-grade configs ship inactive;
  per-grade bounds + the SVG asset library are transcribed working defaults.

## Student Diagnostic + Practice APIs — 2026-06-20

Ten new operations under two new tags — `diagnostic-session` (6) + `practice` (4); the count moves to
**112**. This is the student side of the RTI loop: a child sits a diagnostic, then practices adaptively.

- **Diagnostic (WP-04).** `POST /api/diagnostic-sessions/start` → serve item → `…/responses` → `…/finish`,
  per grade (Grade-1 audio-first, keyed on the student's own grade — never the class's). 24-hour save/resume
  (a signed state token whose trust root is a server-side ownership re-check), a 15-minute active-time cap
  (pauses + audio replays don't count). The **server grades each answer and chains θ itself** — a student
  cannot fake correctness or steer their own score. Scoring runs through the engine; items through
  task-delivery.
- **Practice + adaptive CAT (WP-05).** `POST /api/practice/queues/seed/:diagnosticSessionId` builds a queue
  from the diagnostic; `…/current/next-item` picks the most informative item at θ±0.5; `…/submit-response`
  scores it. **Per-block auto-difficulty (80/60) is on**, adjusting a **durable per-(student × sub-skill)
  difficulty band that carries across sessions** (a student resumes at the level they reached). SOLO hint on
  a wrong answer.
- **Student-safe by construction.** Every student-facing response is allow-list serialized — no θ /
  status-label / profile / bundle / scaffold-tier / accuracy ever reaches the student. Student-own-scoped
  (a student can only touch its own session); a teacher reads session summaries for its own class only.
- The browser cache + 5-disruption restore is a later front-end change; the backend ships + tests the
  resume contract. Error-category vocabulary + the exact 80/60 thresholds are partner-owed (June-25),
  built behind documented working defaults.

## Parent PDF Service — 2026-06-20

One new operation under the new `parent-pdf` tag (the count moves to **113**). A teacher can export a
parent-facing growth report as an A4 Arabic right-to-left PDF — **read-only composition, no measurement or
decision logic, no writes, no new table.**

- **The endpoint.** `POST /api/parent/pdf/:childId` returns the generated PDF directly (A4, Arabic RTL, body
  font ≥ 12 pt, ≤ 500 KB, well under 30 s). Teacher- or admin-triggered; never student-facing.
- **Strictest safety in the product.** Every string passes the **parent-stricter filter** + a typed field
  strip: parents see **no numbers, no percentages, no scores, no internal codes** — growth language only
  (V-3 / V-12).
- **Fail-closed.** No resolved report → an explicit "needs more data" page, never a fabricated profile. No
  active safety rule set → 503 rather than ship unfiltered text.
- **Arabic that travels.** The Arabic font (Noto Naskh, embedded) ships inside the file, so it renders
  correctly when shared (e.g. via WhatsApp) regardless of the recipient's device fonts. Deterministic content
  (V-6); no LLM (V-5).

## Sub-Skill Report APIs — 2026-06-20

Three new operations under the new `reporting` tag (the count moves to **112**). The read-side surface that
turns persisted engine outputs into teacher/admin-facing reports — **read-only composition, no measurement
or decision logic, no writes.**

- **Per-student report.** `GET /api/reports/students/:id/sub-skill-report` composes five sections in one
  payload: the educational profile (pre-written teacher narrative), the macro-domain tiles (plus GLOBAL meta),
  the per-skill statuses **sorted "needs intervention" first**, the active intervention plan, and any open
  alerts. A student with no resolved profile returns a `dataIncomplete` section (a 200, not an error).
- **Per-class summary.** `GET /api/reports/classes/:id/class-summary` returns per-skill count distributions
  and the class macro status (worst-case-wins, never an average). A teacher sees per-student rows for their
  own class; `?audience=manager` switches to **aggregate-only** (no student names or ids ever).
- **Band reference.** `GET /api/reports/band-descriptions` returns the 5-band Mizan catalog (cached 24h);
  Arabic labels are **working defaults pending partner sign-off**.
- **Safety.** No global percentage anywhere (V-3); every string passes the language-safety filter (V-12);
  every query is org-scoped (cross-org → 404); no LLM (V-5). Teacher (ClassTeacher-scoped) + admin only.

## Diagnostic Engine (θ-scoring) — 2026-06-20

Four new operations under the new `engine` tag (the count moves to **96**). This is the measurement core:
it turns a student's diagnostic answers into an ability estimate (θ) and picks the next best question.

- **Score.** `POST /api/engine/score` takes the student's responses + a prior θ and returns `{newTheta, newSE,
  calibrationVersion, nextItemId, calibrationProvisional}`. Every scoring run is recorded as an append-only
  `DiagnosticSession`. Reads/writes are org-scoped; gated by the `USE_DIAGNOSTIC_ENGINE` permission, and a
  student token can only score its own responses.
- **Deterministic + replayable (V-6).** Same inputs → byte-identical θ to 4 decimal places. Each session
  freezes the exact item difficulties it used (`resolvedDeltas`) so `POST /api/engine/replay/:sessionId`
  reproduces the original score forever — even after the calibration pipeline later updates those difficulties.
- **Two-track difficulty.** A question's *measured* difficulty is trusted once it has ≥300 real answers (and
  matches the session's pinned calibration version); otherwise the deterministic *estimate* is used and the row
  is flagged provisional.
- **No LLM, ever (V-5).** The whole scoring path is a pure deterministic function — covered by the no-LLM CI
  lint and an integration spy. Insufficient data → `newTheta:null` (a 200, the engine's "don't decide yet").
- **Reads:** `GET /api/engine/sessions/:id` + `GET /api/engine/sessions/by-student/:studentId`. The Wave-1
  score is a **deterministic placeholder**, not real Rasch psychometrics — the real statistical engine swaps in
  later (calibration pipeline) with zero contract change.

## System QA Checks Registry — 2026-06-20

Four operations under the new `qa` tag (the count moves to **92**). A 32-check registry records the
cross-cutting safety invariants the decision, intervention, and content engines already enforce — this layer
**registers and routes** them; it adds no new check logic.

- **Observability for admins/engineering only.** `GET /api/qa/checks` returns the 32-row registry;
  `GET /api/qa/runtime-alerts` lists the routed runtime alerts for your organization (filter by `severity`,
  time window, or `qaCheckId`); `GET /api/admin/qa/audit-summary` rolls them up by check + severity. All
  require `VIEW_QA_REGISTRY` (or super-admin/backoffice) — **never reachable by a teacher or student token**.
- **Deterministic routing.** A runtime check failure is routed purely by its registered severity — critical
  surfaces to the teacher dashboard + pings the alert seam, high pings engineering, medium records the alert,
  low logs only. Every routed alert pins the registry + rule version for replay.
- **No new alert sink.** Routed alerts append to the existing platform alert log (append-only); the QA read
  never returns non-QA alert rows.
- **New — `POST /api/admin/qa/checks-set` (registry version-publish).** Super-admin / backoffice only. Mirrors
  the bundle and decision-engine config-publish: it takes a candidate check-set with partner approval and an
  acknowledged diff, runs a completeness gate (all 32 checks present, each command runnable, each Arabic message
  safe), and returns the prospective new version — rejecting gaps, missing approval, or an unacknowledged diff.
- **New — QA_032 over-assessment guard.** A medium-severity *alert* (never a block) that flags when a student
  is measured too often in a short window — the probe is always recorded; the alert just nudges the teacher to
  teach before testing again. The over-testing threshold and window are partner-tunable (working defaults
  pending the June partner session).

## Language-Safety Layer — 2026-06-20

Six new operations under the new `language-safety` tag (the count moves to **102**). The deterministic,
rule-based rewriting layer that sits in front of every string the platform renders, saves, or prints — it
converts clinical/deficit phrasing to growth language, strips international-framework labels, and applies a
stricter filter for parent audiences. It is **never** a language model (V-5) and never changes a measurement
value — only how it is described (V-3/V-12).

- **Validate any string.** `POST /api/language-rules/validate` runs `sanitize(text, appliesTo, profileKey?)`
  and returns the safe text + the rules that fired + the rule-set version (V-6 audit replay). Audience tiers:
  `ui / reports / parent / parent_stricter / teacher_note / student / admin`. The parent-stricter tier also
  blocks numeric scores and internal IDs; a per-profile `do_not_say` overlay applies at render time.
- **Admin rule management.** `GET /api/language-rules` returns the active rule set (consumed at copy-load by the
  app + PDF renderer); `POST /api/language-rules` inserts a new version (append-only — the prior row is
  deactivated, never edited in place); `PATCH /api/language-rules/{id}/deactivate` retires a row.
- **Controlled context flags only.** `POST /api/students/{id}/context-flag` attaches a controlled flag from the
  closed dictionary — **no free-text body field exists**; a blocked/forbidden flag returns `422
  context_flag_blocked` and records a length-only security-audit entry (no text about a child is ever stored).
  `GET /api/students/{id}/context-flags` returns the recorded flags + their safe labels (teacher/admin only).
- **Fail-closed.** A render that cannot load an active rule set refuses rather than emit unfiltered text.

## Standards Spine + Dynamic Benchmark Management — 2026-06-20

Eight new operations under the new `standards` tag (the spec now serves **110** operations). This is how a
raw score becomes "on track / approaching / below / severe" — and how those thresholds are managed without
touching code.

- **One verdict vocabulary, never a single score (V-3).** Every measure resolves to one of `meets /
  approaching / below / severe / not_assessed`. `not_assessed` is never treated as zero and is excluded from
  every rollup. There is no "overall reading score" anywhere.
- **Thresholds live in data, versioned, swappable.** `GET /api/standards/benchmark-profiles/resolve` walks a
  deterministic 3-step chain (exact → country-default → global fallback) and never returns null — a seeded
  global row guarantees a resolution. Super-admins manage the rows under `/api/admin/benchmark-profiles`; an
  activate is an atomic swap (the old version deactivates, the new one activates, both audited).
- **History never shifts (V-6).** A session pins its benchmark versions at the start; a later swap does not
  change in-flight verdicts, and every stored result re-derives its original status forever.
- **Zero framework labels (FR-STD-1).** International yardsticks (used only to calibrate the Arabic
  thresholds) are stripped at the serialization layer — they never reach a teacher, principal, parent, or
  student surface. The principal dashboard speaks only Curriculum-KPI Arabic labels.
- **Principal reads + a Ministry-report stub.** `GET /api/principal/schools/:id/{overview,heatmap}` return
  Curriculum-KPI gauges + a computed School Literacy Health Index (no PII). The one-click Ministry report is
  registered as a Wave-1 contract stub (`501`); the PDF lands in Wave 2.

Benchmark numbers are partner-owned (extracted from the v3.3 workbook — Jordan + Palestine); the global
fallback rows + Curriculum-KPI label wording are flagged working defaults pending the June-25 partner session.
Migration: `20260620150000_wp_std_be`. PRD: 12-benchmarks-and-country-config (FR-STD-1..6).

## Teacher Activation Workflow — 2026-06-20

Seven new operations under the new `workflow` tag (the count moves to **88**). A 17-step workflow walks a
teacher through review → group → activate → monitor → decide, composing the bundle, cluster, and monitoring
engines into one guided flow.

- **The teacher decides; the system blocks unsafe moves (V-12).** `POST /api/workflow/activate` will not
  activate a `do_not_decide_yet` plan, and an invalid lifecycle move is rejected (409). The RTI tier is never
  changed by any workflow action.
- **Guardrails at the boundary.** Insufficient evidence → a prompt, not an activation; an open acute-regression
  alert forces the review path (`POST /api/workflow/review` resolves it by an explicit teacher disposition);
  a `DATA_INCOMPLETE` student is held out of bulk activation.
- **Per-member adjustments are deliberate.** Any scaffold-tier / delivery / schedule change off the
  recommendation requires explicit confirmation (else 409) and a controlled reason code — never free text.
- **No new data path.** Workflow effects route through the existing activation / cluster / monitoring write
  paths; the triggering workflow step is stamped on the assignment history. `POST /api/workflow/save-draft`
  persists an in-progress workflow (7-day), restored via `GET /api/workflow/draft/:teacherId/:classId`.
- **Also:** the acute-regression alert response no longer includes `contextFlagId` — a dismissal's context
  flag is recorded only in the context-flag log (the single sanctioned home).

## Logging & Observability — 2026-06-20

Two new operations under the new `logging` tag (the count moves to **81**). Completes the platform
logging contract on top of the logger core + request correlation shipped earlier.

- **`POST /api/logs/client`** — the browser ships its WARN/ERROR logs to the server (warn/error only;
  rate-limited to 1 batch per user per 5s). The server stamps `userId`/`orgId`/`ts` from the authenticated
  identity — never from the request body — so a client cannot mislabel who or which tenant a line belongs to.
  The intake endpoint deliberately does not log itself (infinite-loop guard).
- **`GET /api/health/log-level`** — an unauthenticated ops probe returning the active global log level and
  any per-module overrides, so operators can confirm what a deployment is running without a redeploy.
- **No schema change.** Logging is a process concern; client-log persistence is a Wave-2 question.

## Task Delivery Service — 2026-06-20

Four new operations under the new `delivery` tag (the count moves to **79**). This is what happens at
student time: the platform picks the right **pre-authored** item and serves it **exactly as stored** — it
never composes content at runtime.

- **Select + serve.** `POST /api/delivery/task` filters the operational item bank on
  `(sub-skill × scaffold tier × task_mode × difficulty × dialect)`, picks deterministically, and returns
  the stored item. It is the single serve chokepoint every consumer (diagnostic, practice, probes, ISM)
  calls per task.
- **No improvisation.** An empty candidate pool returns `200 { served: null, log.reason: "no_eligible_item" }`
  — never an error, never a silent substitute. The caller decides the UX; the gap aggregates to
  `GET /api/admin/delivery/gap-summary` (by sub-skill × tier × mode) for content-volume planning.
- **Tier + mode are filters, not render switches.** Per-tier variants are distinct stored rows; probes
  ignore tier and show no hints or mid-probe feedback (probe integrity). The student never sees a
  profile/bundle/tier label.
- **Deterministic + auditable.** Every serve writes an append-only `served_task_instance` keyed by a UUID
  `taskInstanceId` (the QR-worksheet join token), pinning its selection inputs; `POST
  /api/admin/delivery/dry-run-replay` re-asserts the same pick (V-6) and writes nothing. No LLM on the
  serve path (V-5).
- The recently-served window (Wave-1 default 7 days) and per-mode feedback copy are partner-owed
  (June-25); teacher-enable lower-tier substitution is deferred (default is `no_eligible_item`).

## Progress Monitoring Rules — 2026-06-20

Three new operations under the new `monitoring` tag (the count moves to **75**). The platform now tracks
whether an intervention is working and raises a review when a student regresses.

## Spec reconciliation + fixes — 2026-06-20

Maintenance pass — no new operations (the count stays at **75**). Two consumer-visible API changes plus
spec-corpus and test corrections.

- **Organizations now return `createdBy`.** Every `GET /api/organizations` / `…/{id}` response includes the
  provisioning super-admin's `createdBy` (a User id) — the value was already stored at creation, now it is
  exposed (FR-TEN-1). The `OrgResponse` schema in the API reference is updated accordingly.
- **Domain-status sentinel correctness.** `GET /api/sse/students/{id}/domain-status` now returns the **stored**
  `doNotDecideYet` sentinel instead of re-deriving it at read time, honoring the FR-SSE-16 "stored, not
  re-derived" consumer contract (it already behaved this way on the skill-status endpoint). Consumers must
  still branch on `doNotDecideYet` before acting on a domain status.
- **Taxonomy routes document `401`.** The four taxonomy reads (`GET /api/sub-skills`, `…/{id}`,
  `/api/domains`, `/api/skill-dependencies`) now declare the `401` (unauthenticated) response they already
  returned at runtime.
- **Internal only.** `gateway_priority_config` gains a `published_at` audit column (migration
  `20260620120000`); spec-corpus reconciliations in `11-skills-taxonomy` (15 active item types, the 4-route
  taxonomy API surface, SENT/SYN count 12, plain-view DDL), two corrected FR-ID cross-references, and added
  test coverage for the request-id and append-only / context-flag lint fail-branches.

- **Record progress evidence.** `POST /api/monitoring/evidence` records the non-CBM evidence types (quick
  check, rubric, teacher observation, worksheet, digital trend) with administration conditions; the record is
  append-only and triggers a deterministic recompute pinned to the rule version (V-6).
- **Five safety rules govern every decision.** Insufficient or non-comparable evidence → `do_not_decide_yet`;
  no single-point decisions; only comparable probes are compared. **Acute regression (≥20% drop over two
  comparable sessions) raises an Immediate Review — never auto-fail, auto-exit, or an RTI-tier change** (the
  teacher decides).
- **Status + alerts.** `GET /api/monitoring/status/{bundleAssignmentId}` returns the current verdict + the
  pinned rule versions; `GET /api/monitoring/alerts` lists open acute-regression alerts. An alert resolves
  only by an explicit teacher disposition via a controlled reason code — never free text.
- **Smart Cluster safety seam closed.** Cluster now reads the real acute-regression alert (was fail-open), so
  a regressing student is routed to individual review and kept out of group clusters.

## Smart Cluster Sequencing + Bulk Activation — 2026-06-19

Three new operations under the new `cluster` tag (the count moves to **72**). Teachers group students who
need the same support into small clusters and activate the whole group in one confirmed action.

- **Deterministic clustering.** `POST /api/cluster/recompute/:classId` groups a class on a 5-key match
  (bundle + anchor + scaffold tier + compatible delivery mode + sufficient data); Level 1 + Level 2 merge
  for teacher-led delivery only; minimum group size 2 (school knob 3–5). Results are append-only and pinned
  to the sequencing-rule version (V-6 deterministic), 60-day retention.
- **Safety exclusions are hard.** A `DATA_INCOMPLETE` / `do_not_decide_yet` student → a more-data-needed
  list; an acute-regression student → individual review. Neither is ever clustered or bulk-activated.
- **Bulk activation (V-12, reuses the single write path).** `POST /api/cluster/bulk-activate` requires
  explicit teacher confirmation and creates each member's assignment through the same writer individual
  activation uses (stamped with the cluster id); all-or-nothing (an excluded member → 422), idempotent,
  409 on a stale cluster. Differentiation lives only in each student's scaffold assignment.
- **Preview.** `GET /api/cluster/student/:studentId/preview` returns the student's current cluster + the
  per-tier scaffold distribution; **no student-facing internals**.
- The `acute_regression` exclusion reads a fail-open seam until WP-PROG-MON-BE produces the signal; the
  nightly recompute cron is a seam (the idempotent recompute function ships now).

## Intervention Bundle Catalog — 2026-06-19

The bundle recommender ships. Eleven new operations under `/api/bundles`, `/api/assignments`,
`/api/students/:id`, and `/api/admin/bundle-*` (the count moves to **69**). It turns a student's profile
into one recommended intervention bundle from a closed 20-bundle catalog, assigns scaffold support, and
hands the teacher a one-tap activation.

- **Deterministic recommendation.** `POST /api/bundles/recommend` maps the active profile (primary code +
  driver/modifier) through the 21-row profile→bundle map to exactly one bundle — anchor skill + 1–2
  supporting threads + a data-supported asset bridge, anchor always the largest dosage share.
- **A bundle is a recommendation, never automatic (V-12).** `POST /api/students/:id/activate-bundle`
  requires the teacher; the 14-status assignment lifecycle is append-only + history-shadowed. Adjustments
  (`PATCH …/scaffold-assignment | …/delivery | …/schedule`, `POST …/context-flag`) use controlled reason
  codes only — never free text — and **RTI tier can never be changed by any adjustment**.
- **Fail-closed.** No active profile / DATA_INCOMPLETE / `do_not_decide_yet` ⇒ `blocked_insufficient_evidence`
  + a recommended next data action, never a bundle. No role can override.
- **Student never sees** a bundle id/name, profile code, or scaffold-tier label — stripped at serialization.
- **Admin.** `POST /api/admin/bundle-config-sets` (publish + completeness gate); `POST /api/admin/bundle-replay`
  (V-6 dry-run diff, zero writes). The Smart Cluster + bulk-activation endpoints arrive with WP-CLUSTER-BE.

## Student Profile Resolution — 2026-06-19

The profile engine ships. Six new operations under `/api/profile` + `/api/admin/profile-*` (the count
moves to **58**). It composes the Skill-Status Engine's outputs into one named educational profile per
student per assessment window, with rule-written Arabic narratives per audience.

- **Read a student's profile.** `GET /api/profile/{studentId}?window=` returns the active OR pending
  assignment — primary code, drivers, modifiers, confidence, the rule-version pins, and `assignmentStatus`
  (so a client can render the pending → confirm flow). When evidence is incomplete it returns a structured
  DATA_INCOMPLETE guard (200), not a profile.
- **Narratives are per-audience and safe.** `GET /api/profile/{studentId}/narrative?audience=teacher|parent|admin`
  returns the pre-rendered, language-safety-filtered text. **`audience=student` is rejected (422)** — a
  student never receives a profile code or any numeric score; the parent narrative carries no internal IDs.
- **Teacher decides (V-12).** A fresh resolution starts `pending_teacher_review`;
  `POST /api/profile/{studentId}/review-resolution` with `{ outcome: confirm }` promotes it to `active`.
  Exactly one active profile per window; every change is a new append-only row.
- **Fail-closed.** If any contributing skill is `do_not_decide_yet` or a required domain lacks coverage,
  no profile is written — no role can override.
- **Admin.** `POST /api/admin/profile-catalog/publish` runs the catalog-completeness gate (every primary
  has its CDP/PGA/SPOT coverage); `POST /api/admin/profile-replay` is a V-6 dry-run diff (zero writes).

## Skills taxonomy: VOCAB-C06 added (79 skills) — 2026-06-19

Partner confirmation of the sub-skills ID mapping. The skill catalogue grows from 78 to **79**:
`GET /api/sub-skills` now returns `VOCAB-C06` Synonym Awareness (فهم المرادفات) in the VOCAB domain.
No new operations (count unchanged at **52**).

- **New skill `VOCAB-C06` Synonym Awareness.** The `VOC_REL` concept now covers both antonyms
  (`VOCAB-C05`) and synonyms (`VOCAB-C06`).
- **Sub-flag naming locked.** The writing/spelling sub-flag is `WR_SP_ALERT` (was
  `WRITING_SPELLING_ALERT`); `AR_LETTER_CONFUSION_FLAG` is scoped to visual/orthographic confusion only.
- **Arabic-feature scope (current phase).** Madd / short-vowel signals come only from written/visual
  items (never oral-reading error type); hamza / shadda / tanween open no bundle or alert this phase.

## Skill-Status Engine — 2026-06-19

The decision engine ships. Seven new operations under `/api/sse` (the count moves to **52**): five
read-only consumer endpoints and two admin endpoints.

- **Read a student's interpretive status.** `GET /api/sse/students/{id}/skill-status` and
  `/domain-status` return the latest per-`(subSkill|domain, evidenceWindow)` snapshot, each carrying the
  matched rule ids + versions (for replay) and the `doNotDecideYet` sentinel. `/pattern-classifications`
  and `/behavior-log` back the "why this status" explainer + the teacher review surface. All tenant-scoped,
  read-only, `VIEW_STUDENT_PROGRESS` — **no status value is ever exposed to a student**.
- **`do_not_decide_yet` is a hard stop.** When evidence is thin, split, or low-quality the engine refuses
  to commit and emits the sentinel; consumers must short-circuit (no profile, no bundle, no alert, no
  mastery event) — and no role can override it.
- **Status is computed server-side, never on demand.** There is no client "compute now" endpoint; status
  recomputes when new evidence arrives. Reads return the stored snapshot — consumers never re-derive.
- **Admin (rule publishing).** `POST /api/sse/admin/rules/{layer}/publish` (schema + Arabic-accuracy
  carve-out + overlapping-row validation + dry-run diff, partner-approval gated) and
  `POST /api/sse/admin/dry-run-replay`. Publishing a new rule version never retroactively changes existing
  snapshots — they stay pinned to their compute-time version.

## Request correlation id — 2026-06-19

Every response now carries an `X-Request-Id` correlation id, and the server accepts one inbound. No
new operations; the count is unchanged (**45**).

- **`X-Request-Id` is now accepted inbound.** If you send an `X-Request-Id` header matching
  `^[A-Za-z0-9_-]{8,128}$`, the server honors it verbatim on the response; otherwise it generates one
  (`req_<hex>`). Send your own to correlate a client trace with the server's logs for that request.
- **`X-Request-Id` is CORS-exposed.** Browser clients on an allowed origin can now read the header off
  the response (`Access-Control-Expose-Headers: X-Request-Id`) — log it client-side and quote it in a
  bug report.
- **Internal (no contract change):** the id auto-binds to every server log line and is persisted on
  the activity/audit rows a request produces (auth events, super-admin bypass, item-import + δ_prior
  logs), so support can pivot a reported id → server logs → DB changes for one request.

## Content — 2026-06-19

Item-bank content + schema additions ahead of pilot. No new **published** operations; the count is
unchanged (**45**) — the item-bank/import surfaces remain pre-publication.

- **Seed content corpus.** A validated offline-authored item bank now exists — 645 items + 2 reading
  passages across all 7 digital domains, every item-eligible skill at 8–12 items, all passing the
  import gate. Items are `source='offline_generated'`, `status='draft'` (never served until SME
  approval). Difficulty priors are fully real (SAMER v2 lexicon + CAMeL).
- **Schema additions (item bank).** A new optional `image_url` import field + `imageUrl` column for
  picture items, and the `distractor_type` controlled vocabulary expanded **13 → 19** (added
  `agreement_error`, `wrong_part_of_speech`, `wrong_function_word`, `in_text_wrong_detail`,
  `wrong_syllable_count`, `wrong_sound_position`) with the import compatibility matrix extended to
  match. No change to any published route, request shape, or response shape.

## Maintenance — 2026-06-18

Routine-driven correctness pass — closed a batch of code-review and spec-drift findings. No new
operations; the count is unchanged (**45**).

- **Class deletion is now a soft-delete.** `DELETE /api/classes/:id` is unchanged for callers (same
  request + same `{ ok: true }` response), but the class and its teacher/student assignments are now
  preserved for audit rather than removed. A deleted class no longer appears in `GET /api/classes`,
  `GET /api/classes/:id` (404), the organization rollup, or roster/assignment lookups. Deleting a
  class with **active** students is still rejected (409). → See [Core Concepts](https://docs.growamal.com/en/core-concepts/).
- **Internal hardening (no API-contract change):** tighter manager/teacher scope enforcement on
  class, student, and principal access; rate-limit lockout-escalation correctness; item-import
  integrity (atomic write, per-row validation, global-item write protection); and log-field
  redaction. Error codes, request shapes, and response shapes are unchanged.

## Sub-skills taxonomy — 2026-06-18

The Arabic-literacy skills taxonomy ships as global reference data — **4 read operations** under the
new `sub-skills` tag, raising the published total to **45 operations**.

- **`GET /api/sub-skills`** — paginated list of all 78 sub-skills with domain, grade range,
  assessment type, and gateway/key-skill flags. (raised to 79 by the VOCAB-C06 entry above)
- **`GET /api/sub-skills/{id}`** — single sub-skill row by CUID.
- **`GET /api/domains`** — all 9 Arabic-literacy domains (Phonological Awareness, Decoding, Fluency,
  Comprehension, Vocabulary, and more).
- **`GET /api/skill-dependencies`** — prerequisite links between skills.

All four endpoints require authentication and tenant context; the `VIEW_OWN_PROFILE` permission is
held by every persona (Teacher, Admin, Principal, Manager, Parent, Student), so the taxonomy is
available to all authenticated users.

## Item bank foundation — 2026-06-18

The item-bank API surface is available — **12 operations** under the `items` tag. These are
**pre-publication** routes gated to `RUN_BACKOFFICE` (backoffice/SME-only); they do not affect the
published-operation count of **45**.

- **Read** — `GET /api/items`, `GET /api/items/{id}` (`?view=authoring` for the full authoring row),
  `GET /api/item-types`, `GET /api/item-types/{code}/distractor-compat`.
- **Import pipeline** — `POST /api/items/import`, `GET /api/items/import`,
  `GET /api/items/import/{batchId}`, `POST /api/items/import/{batchId}/approve`.
- **Lifecycle** — `POST /api/items/{id}/sme-approve`, `POST /api/items/{id}/promote`,
  `POST /api/items/{id}/retire`.
- **Difficulty priors** — `GET /api/expected-difficulty/log` (deterministic δ_prior audit trail).

## 1.0.0-phase0

**Initial portal.** Documents WP-A1 authentication + WP-A2 tenancy + WP-A3 user management — **[41
operations](https://docs.growamal.com/en/api-reference/)** across the tags `auth`, `tenancy`, `users`, and `system`.

- **auth** — register (bootstrap-only), login, refresh (rotating), `me`, logout, password reset +
  confirm, student quickCode login. → See [Authentication](https://docs.growamal.com/en/authentication/), [Getting Started](https://docs.growamal.com/en/getting-started/), and [Errors](https://docs.growamal.com/en/errors/).
- **tenancy** — organizations, schools, classes (CRUD), organization rollup. → See [Core Concepts](https://docs.growamal.com/en/core-concepts/) and [Manage classes & rosters](https://docs.growamal.com/en/guides/classes-and-rosters/).
- **users** — provisioning (discriminated `POST /api/users`), persona profiles, class rosters,
  ClassTeacher assignment, principals, managers, parent ↔ child links, student quickCode regen. → See [Provision users](https://docs.growamal.com/en/guides/provision-users/), [Managers & principals](https://docs.growamal.com/en/guides/managers-and-principals/), and [Parent ↔ child links](https://docs.growamal.com/en/guides/parent-child-links/).
- **system** — health check. → See the [API Reference](https://docs.growamal.com/en/api-reference/).
