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:
sectionsonStartDiagnosticResponse(POST /api/diagnostic-sessions/start),StartReDiagnosticResponse(POST /api/diagnostic-sessions/start/re-diagnostic) andDiagnosticStateResponse(GET /api/diagnostic-sessions/{id}/state):{ key, total }[]in form order, wherekeyis one oftext1,text2,words,spellingorroots. It is computed from the sitting’s pinned form plan. A sitting with no form plan returns[].state.sectionKeyon every response that carriesStudentSessionState(start, re-diagnostic start, state, resume, submit): the current question’s section, ornull.- 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.
getTeacherTaqyeemGET /api/teacher/classes/{classId}/taqyeem?window=(tagteacher-views, DTOTdvTaqyeem; TEACHER, the class must be held, not audited). One read for a class and a window (omitted = the class’s default window):windows,windowState(openTriggeror null,canOpen), the grade’sblueprint(statusok/not_screened/legacy_skill_cell/catalog_gapand itsmeasures),summary(enrolled,complete,remainingper cell),dayPlan(per cell:studentCount,minutesPerStudent,totalMinutes) andstudents[]with four cells —digital,oral_text,oral_familiar_words,oral_pseudowords— each{ status, attemptId, at, result, restarts }.statusisdone,in_progress,not_started,stopped,restarted_pendingorunavailable. Counts only, no class percentage; no class login code. 404 for a class the caller does not hold.voidTaqyeemAttemptPOST /api/teacher/classes/{classId}/taqyeem/void(tagmeasurement-void; TEACHER,RECORD_TEACHER_ACTION). Body{ kind, targetId, reason }:kinddigital_sitting/oral_text/oral_word_list,reasonstudent_not_ready/technical_interruption/started_by_mistake/other(no free text). 201 returns the void (academicYearandevidenceWindoware the attempt’s own,endedActiveSittingtrue when an open digital sitting was ended). The attempt stays stored and stops counting in decisions, trends and reads; 404 out of scope, 409already_voided, 422not_a_window_sitting. The replacement attempt links to its void server-side (supersedesVoidId); no client field.getStudentReadingSheetGET /api/classes/{classId}/students/{studentId}/reading-sheet.pdf?passageId=(tagreading-assessment; the class teacher, the child enrolled in the class). One child’s A4 text sheet asapplication/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:
getAdminAuditLogGET /api/admin/audit-log:eventType(and the rowkind) gainsmeasurement_void; void rows carry the optionalvoidKindandvoidReason,actorUserId= the teacher,targetStudentId,targetClassId, and the idvoid:<int>, whichgetAdminAuditEventGET /api/admin/audit-log/{id}also reads.markOrfErrorPOST /api/orf/assessments/{id}/markandstopOrfAssessmentPOST /api/orf/assessments/{id}/stop: new409(reasonvoided) for a voided sitting.getSkillStatusGET /api/sse/students/{id}/skill-status:adrRuleIdandssrRuleIdare nullable. Both are null (withdoNotDecideYettrue) 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 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
countsno longer carry ateaser_leadkey. - 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 bygetMeGET /api/auth/me.passkeyPromptSuppressedleavesUserPreferencesResponse(GET/PATCH /api/users/me/preferences) andPatchUserPreferencesBody. The body is strict, so a client that still sends the key gets400.- Migration
20261009140000_drop_passkey_prompt_suppresseddrops theuser_preferences.passkeyPromptSuppressedcolumn. - 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_startrate limit, thepnpm seed:teaserscript and the “Anonymous Teaser API” guide. - The
UserProfileunion no longer has aDEMO_ADMINmember, and the users routes no longer listDEMO_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_sessionandteaser_leadstay as they are, unwritten, until a separate decision on their rows. Adult erasure still deletes ateaser_leadrow 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.
listAdminWordListsGET /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 withassessmentsCount;cells= every (grade × skill × window) the grade rules screen, withmin/maxwords andpickedListId(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);skillswith their teacher labels.getAdminWordListGET /api/admin/word-lists/{id}— the list plusversions(sameexternalId, same scope).validateAdminWordListPOST /api/admin/word-lists/validate— dry run with the import’s own checks (validateWordList+ the in-scope duplicate / cross-grade checks, nowcheckWordListAgainstScope):{ ok, reject?, warnings }, nothing written.createAdminWordListPOST /api/admin/word-lists— version 1,draft;publish(default true) makes itoperationalin the same transaction.422with the validate shape on a reject;409 external_id_taken.patchAdminWordListPATCH /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 carriesmadeVersion.publishAdminWordList/retireAdminWordList/restoreAdminWordListPOST /api/admin/word-lists/{id}/publish|retire|restore—409 invalid_state | newer_version_operational | another_version_operational; retire returnsleavesCellEmpty.- Changed:
pickListsForWindow(the teacher’sGET /api/word-lists) ranks an organisation’s list before a global one at equal window rank. The admin DTO carriesreadingNoteAr, 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(organizationIdrequired,schoolId?,grade1–4,windowBOY|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 informPositionorder, where the break falls,configReleaseId), every question in the projection the student receives (ServedItem) plus anauthoringblock (the correct answer) andmeta(organizationId,updatedAtas the If-Match token, measure, position), the passages, the measures with their counts, andwarnings(no_operational_form,invalid_cellwithbuildMeasurePlan’s verbatim reason,provisional_scoring_table,legacy_cell,thin_pool,alternate_form,not_screened,unset). It writes nothing but the super-bypassaudit_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 (oneitem_revisionper moved question, before-image{ formPosition }).412 stalewhen a question changed since it was read,409 concurrent_move,422 not_a_permutation | cross_measure | item_not_in_form,404for a form outside the scope. The parked intermediate is positive (formPosition + 1000000) because theitem_bank_content_role_fieldscheck forbids a negative position.- Unchanged:
PATCH /api/items/{id}and the initial-screen blueprint routes; the page uses them as they are. Internal:servedItem/authoringItemmoved toitems/served-projection.tswith 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.
quickCodeis now two digits, 01–99, unique among a class’s active students (a class holds at most 99) and it never expires.quickCodeExpiresAtis 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.loginCodenow carriesloginCodeExpiresAt(30 days;ClassResponseand the login-cards response add it).POST /api/classes/{classId}/login-code/rotate(rotateClassLoginCode; body{ onlyIfExpired? };MANAGE_CLASS_STUDENTSorMANAGE_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 anAuthEventLogrow of kindclass_login_code_rotated(detail:manualorexpired_on_copy).onlyIfExpiredis 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-previewanswers410 CLASS_CODE_EXPIREDfor it, and the student page shows “this link ended, ask your teacher for the new one”. The directclassIdform 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/mereturnsfeatures.teacherV3: truefor 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 onestudent_data_access_logrow (export_pdf, one student, no subject id) before the body; a failed write answers500“Export could not be recorded” and no PDF. PARENT and ADMIN callers write nothing. - Preferences. The web app now reads
GET /api/users/me/preferencesonce 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_rulesengine_parametersrow and thetraining_level_policyrows (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-sessionsrows addid(the day’s id),targets(skillId,skillLabelAr,levelNumber,levelNameArper planned target, in plan order, at the level the day opened with),supportSteps(support steps fired that day),exitDone(the closing question was answered) andansweredCount(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) returnsid,date,timeZone,leveled,plannedExercises,questionsPerExercise,targets(each withquestionsAnswered,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,caporwindow_closed, null while open),feedbackandfeedbackAt. 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 is404; a child of the organization the teacher does not teach is403; atrainingSessionIdthat is not a uuid is422. - Session detail.
GET /api/reports/students/{id}/sessions/{kind}/{sessionId}addstrainingGroupson a leveled practice queue: answers grouped by training target (targetSkillId,targetLabelAr,levelNameAr— the level of the target’s newest answer), then byrole(ordinary,retry,support,exit, each withlabelAr,answered,correct). A support-step answer stays under the target it helped; its own skill is never a row inskills. Null for every other session. - Practice responses. The next-item, submit and state responses add
step(kindtarget,bridgeorexit;startsBridge;strategyPromptAr, or null). The submit’shintaddskind(item,light_hint,clearer_hintorgeneric) and the submit addsexplanation({ textAr }after a second wrong try, or null).trainingaddsphase(practice,exitordone) andexit(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_rulecatalog (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-onlytraining_bridge_episoderow, which records the step’s introduction prompt (strategyPromptArshows 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_levelcatalog (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 thetraining_progressionengine_parametersrow (provisional);leveledTrainingEnabled: falsekeeps new queues on the band path. - Levels.
GET /api/training/students/{studentId}/levelsreturns{ studentId, academicYear, levels }. Each row hasskillId,skillLabelAr,measureCode,level(number,nameAr),since(a school day,YYYY-MM-DD),startedFrom(screen,experience_typeorbacktrack),lastMove(directionup,downorrebaseline,at,fromLevel) or null, andopenAlerts(alertId,kind,acknowledged). The list is empty until a send started leveled training. A child the caller cannot reach is404; a persona withoutVIEW_OWN_CLASSESorRUN_BACKOFFICEis403. - Alerts.
GET /api/training/alertstakes exactly one ofclassIdorgroupId(else422 VALIDATION_ERROR) andstatus(open, the default, orall). 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,acknowledgedorresolved) andevidence(sessionsas date, counted and correct counts;daysAtLevel).kindisno_progress_at_level(the last 3 ended training days at the level, each with at least 3 counted answers, all below 60 %) orsupport_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 is404; a class not held or a group not owned is403 NOT_CLASS_TEACHER. - Acknowledge.
POST /api/training/alerts/{alertId}/acknowledgereturns{ 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.409withreasonalready_resolved(checked first) oralready_acknowledged;403when the teacher no longer reaches the child;404for an unknown or other organization’s alert. - The report’s training level.
MeasureResult.trainingLevel(inperSkillStatus[].measure,diagnostic[].measureand the session detail’smeasures) addssource:trainingis the child’s current stored level for that carrier in the row’s school year,screenis the level the screen starts training at when no training has started yet. - Notifications. Two new kinds,
training_no_progressandtraining_support_review_suggested(action_required, to the child’s class teachers, kept 30 days), withsourceEntityTypetraining_progress_alertand the alert id assourceEntityId. The payload carriesstudentId(the child’s user id as a numeric string, now also forskill_alert_assignedandstudent_alert_assigned),classId,studentName,alertId,skillId,skillLabelAr,levelNumber,levelNameArand, 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_routingrow 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.
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/assessmentstakeslast_word_index(0-based, the last word reached) andelapsed_seconds(1 up to the duration); an untimed list refuseslast_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,rateorlower_of) andprovisional.bandis now nullable: accuracy and rate are each banded against the oral cut table, the configured basis decides the oneband, 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 newword_list_window_mismatch(the list belongs to one screening window and the read would be filed under another). - Choosing a list.
GET /api/word-liststakesstudentIdand then returns only the lists of that child’s window (that window’s lists first, then lists for every window);404or403when the child is out of scope. Each list addstiming(timed,durationSeconds),wordsPerRow(5 when the list names none) andassessmentWindow. - The child’s sheet.
GET /api/word-lists/{id}/sheet.pdfreturns 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 infilenameand Arabic infilename*. A printed string that fails the language filter answers422withreason: language_safety_blocked. - Why an oral-reading level applies.
POST /api/orf/assessments/{id}/stopaddsrateCut(applied,withheld_in_windoworno_norm, always set) andprovisional.withheld_in_windowmeans 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}addsrateCut(null until stopped),accuracyBandandprovisional. 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_scoresrows 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 nocbm_scoresrow (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_needthrough the provisional ruleDDR-PRV-01(limitDTH-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 answersQ-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}takesmeasures(screen measure codes, in serve order) instead ofsubSkillIds; send exactly one of the two. The cell’ssubSkillIdsare derived from the measures’ carrier skills.422reasons:duplicate_measure,measure_not_in_grade,no_digital_measure,carrier_conflict,catalog_gap. A cell whose measures no operational screening form can serve publishes withmeasureWarnings(no_operational_form), or is422 no_operational_formwithstrict: true. A measure scored with a provisional table needsacknowledgeProvisional: true(422 provisional_scoring_table), and is422 provisional_not_publishable_globallyat global scope. Cells carrymeasures(null for a skill cell) andprovisional; the GET addsmeasureOptionsandforms; audit rows carrymeasures. - 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: trueafter the last question on a passage (kept by/stateand/resumeuntil the next answer). No operational form:firstItemId: nullwithreason: no_screening_form. The re-diagnostic start returns the samereasonand, in a measure window, re-sits the whole cell. - Re-diagnostic body.
POST /api/diagnostic-sessions/start/re-diagnosticno longer requiressubSkillIds. A measure window ignores the field and re-sits the whole cell; a skill window without it answers422 sub_skills_required. - Reports. New schema
MeasureResult(labelAr,raw,max,complete,incompleteReason+incompleteReasonAr,level,trainingLevel,provisional,sessionId). The student report’sperSkillStatus[].measureand the session results’diagnostic[].measurecarry it on a measure’s carrier row (null on a skill row). TheREAD_COMPtile gainscomposite(MeasureComposite, the comprehension score out of 12, for information only, never in a rollup). The session detail gainsmeasures, each withitems(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}/growthgainsmeasures: [{ 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-replayacceptspinnedItemBankIdandformServeto 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.
contentKindon import.POST /api/items/importtakescontentKind:item(default);content_bundle, a training or bridge delivery{ passages, items }that runs the passage gate and then the item gate (the ack addspassageImportBatchIdandpassageCounts);screening_form, one form imported as one unit (a refused row refuses the whole form and nothing is written, answered as200withformRejected: true; the ack addsscreeningFormId);scaffold_prompt;word_list.sourceDeclaredis required only for the item-bearing kinds (422without 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 rowpassageId,wordListIdandscaffoldPromptIdbesideitemBankId.rejectReasongains the contract reasons (form_item_count_mismatch,measure_not_in_grade,form_rejected, the threeprompt_*and fiveword_list_*reasons, and the rest listed in the schema).GET /api/items/importfilters bycontentKind(defaultitem). - Approval. A screening form is approved as one unit:
exceptionsanswers422withform_is_one_unit, items without a difficulty estimate are promoted with the form, and the earlier live version of itsform_idis retired. Word lists and prompts retire the earlier live version of theirlist_idorprompt_id. The response addsretired. - Item rows. A row with
content_role(screening,target_training,bridge_practice) carriesmeasure_code,item_pattern,form_id,form_position,training_levelandbridge_target_skill_id, checked against the released catalogs; the platform setscontent_purposeitself. 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) needsagreementSignoff(who checked Arabic agreement offline, and when; recorded in the batch report). Without it the answer is422with the issueagreement_signoff_required, and nothing is written. The decoded file is capped per kind (itemandcontent_bundle10 MiB,screening_form2 MiB,scaffold_promptandword_list1 MiB), else413. - Forms. The ack of every form carries
formProblems(missingPositions,duplicatePositions,countMismatch,measuresNotInGrade). A form missing a whole measure is refused, andcountMismatchnames the measure withgot: 0. - Scope and versions.
form_id,list_idandprompt_idare 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 answers409withnewer_version_operational. The same file sent by another organization is that organization’s own import, not an idempotent hit. - Authoring view.
GET /api/itemsandGET /api/items/{id}withview=authoringaddcontentRoleandscreeningFormId. A duplicate of a screening question answers422(form_item_not_duplicable); aPATCHthat takes a training or bridge item outside its contract answers422with the contract reason. - Oral accuracy. The most specific oral cut row wins. An empty row records the accuracy without a band (
accuracyBand: nullon the stop), andskippedReasonisnot_bandedwhen 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 withonBehalfOfTeacherUserId(a class’s teacher fromGET /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-Keyfor each distinct body it sends. Sending the identical body again (a network retry, or «إعادة الإرسال» for a child whose outcome isfailed) 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-trainingfollows the same rule, and its503withreason: failedis resent with the same key. - The errors the web names.
422window_already_over/window_too_short/until_time_required/on_behalf_required/on_behalf_not_a_teacher,409empty_target,403on_behalf_teacher_not_in_scope.422idempotency_key_reusedshows the calm retry line and forgets that key, so the next try sends a fresh one.429RATE_LIMITEDsays when to try again, fromRetry-After(in whole minutes, a minute when the header is missing or not exposed), never as a countdown. Anything else, including503TRAINING_UNAVAILABLE(training_window_catalog_unavailable, orfailedonassign-training), is one calm retry line. A child whose outcome issend_closed(or anassign-training409send_closed) is final: no «إعادة الإرسال», and when every child not opened issend_closedthe result offers a fresh send under a new key. On close,409already_closedand409nothing_openjust refresh the roster. - Hub rows and the roster. A training row with
subtype: "personal"opens/teacher/sessions/training/{dispatchId}(it may carryclassId: ""; the pill showsclassNameAr, orsupportGroupNameArfor a group send). Every close time renders asHH:MMin thetimeZoneits payload carries (Asia/Hebronfor a school in Palestine,Asia/Ammanotherwise); the confirm step’s estimate uses thetimeZoneofGET /api/training/window-options. The roster typesoutcomeas one of the seven stored outcomes ornull, neverfailed. - The window step and the confirm line. On the school’s clock (the window options’
timeZone), the school-day option is disabled onceschoolDayEndsAthas passed or fewer thanminMinutesremain, 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) on404training_not_open, and ends the day calmly on409training_closedorwindowClosed: true. A reload serves the question named byinFlightItemIdagain, and a reload afterwindowClosed: truelands on the closed screen. An answer queued offline that the server later refuses with409training_closedis dropped from the device and reported asoffline_queue_eventwithop: 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, optionalX-Idempotency-Key). Each child’s training is built from their own newest assessment this school year. The response lists every child’soutcome(opened_new_queue,opened_existing_queue,trained_today,no_assessment_yet,no_practiceable_subskills,deeper_assessment_pending,catalog_session_active) and is201even when none opened. A resend with the same key returns the original send (200,replayed: true). - Windows are configuration.
GET /api/training/window-optionslists them with their Arabic label, their numbers, the default, and whether a value is provisional. A bad choice is422with areason:unknown_window,until_time_required,window_already_over,window_too_short. - Practice serves only while the window is open.
GET …/current/next-itemandGET …/current/stateanswer404withreason: training_not_openoutside it. After the window closes, a question already shown may still be answered within a short grace: the submit response haswindowClosed: trueand no next question. Past the grace, a submit answers409training_closedand records nothing. Today’s «كيف كان التدريب؟» is still collected. Sessions from the library are not window-gated. - Removed field.
practiceSeedSessionIdis gone fromGET /api/student/next-actionandGET /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? };201returns{ queueId, itemCount, dispatchId, outcome, closesAt }; a second assign continues the training (201, no longer409 queue_already_active); the other outcomes are409reasons, now includingtrained_todayandcatalog_session_active.currentTrainingon the training-sessions read gainsclosesAt,open,windowLabelArandopenedBy: "staff". - The sessions hub.
GET /api/teacher/sessions?kind=trainingalso lists the sends (subtype: "personal", stateopenorclosed); the cursor now has three segments (older two-segment cursors still page).GET /api/training/dispatches/{dispatchId}/progressis the roster (statuses and counts only);POST …/closeends a send early for everyone (409 already_closedonce 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(schemaTrainingSendRecipientOutcome, withqueue: null, counted incounts.notOpened; never stored, so the roster showsoutcome: 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 answerssend_closedfor such a child (response-only likefailed; do not resend, open a new training), andassign-traininganswers409send_closed. Closing a send that opened nothing is409nothing_open. A key already used for a different request is422idempotency_key_reused, on the send and onassign-training;assign-trainingacceptsX-Idempotency-Keyand its replay is200. Every training time carries the school’stimeZone: the window options, the send’swindow, the roster’sdispatch,currentTraining, and theassign-trainingresponse.GET /api/practice/queues/current/statenamesinFlightItemId(the question served and not answered yet, ornull). The hub row gainssupportGroupNameAr(classNameAris""for a group send) and its subtitle shows the time an early-closed send actually ended. A broken window catalog is503TRAINING_UNAVAILABLEwithreason: training_window_catalog_unavailable, and anassign-trainingwhose row could not be written is503TRAINING_UNAVAILABLEwithreason: failed. Both send routes are rate limited:429RATE_LIMITEDpast 6 a minute, with aRetry-Afterheader.
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(tagteacher-views):getTeacherDifferentiation(GET /api/teacher/classes/{classId}/differentiation?sort=&domain=),getTeacherSkillPage(…/differentiation/{subSkillId}),getTeacherSkillGroupDetail(…/differentiation/{subSkillId}/groups/{groupKey}) andlistTeacherPlans(GET /api/teacher/plans?classId=). - In
wp-sbc-be.yaml:deliverSessionMixed(POST /api/sessions/{id}/deliver-mixed).
Changed:
POST /api/sessions/from-blueprinttakes an optionalassignmentContext { subSkillId, groupKeys? },targetStudentIdsandmediumByStudent, and its body is now strict (an unknown key → 422).- A v3 body answers
deliveryPlan,targetedCountanddeduplicated. 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}/acceptaddsevidenceWindowandnotEnrolled.PATCH /api/sessions/{id}/editis strict, and refuses dropping the anchor skill on a group-targeted draft (409anchor_skill_removed).distribute-online/generate-pdfsrefuse a session planned for another medium (409delivery_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-mixedserves 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
dataSufficiencyand the client shows counts only while it is not sufficient (D-R11). - The export is status words only, written after its
export_csvaccess-log row (fail-closed 500), limited to 20 per teacher per 15 minutes (429), capped atexportMaxRows(422, never truncated), refused 503 with no body when no language rule set is active. - A deeper check reads
approved_not_starteduntil its sitting’s first answer, thenin_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/megainslearningSupport(TEACHER: boolean; every other persona:null) andlastTeacherClassId(the class stored byPUT /api/users/me/last-classwhile the teacher still holds it, elsenull).GET /api/users/{id}(TEACHER) gainsprofile.learningSupport;PATCH /api/users/{id}acceptsteacher.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_viewaudit 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 R3breach=group(the caller’s support-group caseload). Rows carrysubSkillId,subSkillNameAr,heldForImplementationand, by kind,cbm {alertType, severity},planIdoractivation. - 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/meand the admin detail, never in a list; read by no decision./me.timeZoneis derived from the organisation’s country (JOAsia/Amman, PSAsia/Hebron). - Activation stamps the plan’s target sub-skill (
bundle_assignment.targetSubSkillId). The activity intake acceptsalert_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.formis optional and closed:form; 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 thecheat_sheet_downloadedevent 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/passagesrows carrypurpose(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 answers422unknown_passage.readingNoteAr(string ornull): the one Arabic line that tells the teacher how the last letter of each word is read, onGET /api/orf/passages,POST /api/orf/assessments/start,POST /api/reading-assessments/planned/{id}/startandGET /api/word-lists.nullwhen the content names no reading convention. For a grade that has oral texts,GET /api/orf/passageslists only those.- Import gate (server command
content:import, not an API route): oral texts (content:import oral-texts) are rejected withoral_text_invalidand the field when they break the oral-text rules; passagetextTypemust benarrativeorinformational; a pseudoword another grade’s list uses isword_list_duplicate; a letter with both shadda and sukun is refused; a duplicatemetadata.partner_item_idrejects a screening form, and a re-sent form reports a per-question version diff;--manifestreads 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_SPELLINGoptions 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/(new13-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 (
reasonCodechanges, for example toSEVERE_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 is409. 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 thebacktrackrow ofengine_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, ornullwhen 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
409fromPOST /api/backtrack/{id}/approvecarries a typedreason:not_recommendable,recommendation_expired(a suggestion left from another school year),approval_in_progress(another approve is opening it),session_already_activeorrediagnostic_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 staysrecommended, 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 anapprovedsuggestion can be declined. - The reason names every signal.
reasonArnow 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 newfiredSignalslists each rule that fired:{ ruleId, kind: opens | strengthens, signal, clauseAr, provisional }, the opening rule first.provisionalis 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
G1toG6, and two catch-all oral-rate rules of one version.config:publishalso refuses an unknown band or status value, an unknown or inactive skill id, ameasurethat 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") andclaimStale(true only for anapprovedrow whose claim expired; approve then re-claims it). Decline documents that it acceptsrecommendedandapproved. - The card on the child’s page (web). The card renders whatever the read returns: a suggestion to decide, an
approvedrow, an open sitting (from any school year: it still holds training back), a completed readout, or nothing (null). Approve is offered onrecommended, and on anapprovedrow only when its claim went stale (claimStale); decline onrecommendedandapproved. A row from an earlier school year than the child’s current class (academicYear) adds «فحص أعمق مفتوح من عام دراسي سابق — ينهيه الطالب أوّلًا.». Each approve409reasonhas its own calm line and the card is read again;approval_in_progressreads «يجري فتح الفحص الآن — حدّثي الصفحة بعد لحظة.». WithnoEligibleItem: 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.
triggeris now the rule that opened the check (TEXT_ACCURACY_SEVERE); older suggestions keepA/B. - Word lists and comprehension explain why.
reasonCodeisSEVERE_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). ShowreasonAr, 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 isnullwhen 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/startnow 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/probesresolves the cut of the probe’sassessmentWindow; 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.writtenistrue,projection.accuracyBandis set andprojection.rateBandisnull. 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"toapp_user,"StudentProfile"tostudent_profile,"ClassStudent"toclass_studentand"AuthEventLog"(with its monthly partitions) toauth_event_log. Every other table already used this style. Column names do not change. Userisapp_user, notuser.useris a reserved word in SQL: an unquotedSELECT count(*) FROM userreturns 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 Googlestateand 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).
- a skill moving from «متابعة» to «متمكّن» on new evidence (
- 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-01and the 40 % rate isTH-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_parameterstable. Values are unchanged. Deploy: runpnpm config:publishaftermigrate 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, inGET /api/templates. It needs a linked passage and a valid pairs key: the new requirementneedsMatchPairsruns the same rule as the import gate andPATCH /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/previewand the PATCH answer 422 with the Arabic reason). The template is identity on serving: the item type already serves thematch_pairsboard. 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-mainonce 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}/coursesandPOST /api/lms/previewanswer503 SSO_NOT_CONFIGUREDinstead 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_SECRETand 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 torevoked, so the admin reconnects it. Deploy: create the secretlms-token-keybefore 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/evidenceaccepts onlyBOY,MOYorEOYinwindow(any other text was stored as-is before; it is now a 400). The stored windows are the typed window values.GET /api/monitoring/alertsreturnsacademicYearfor each alert, andassessmentWindowIdisBOY,MOY,EOYor null.- Tier decisions record the movement they made.
GET /api/students/{id}/tier-decisionsreturnsfromTierandtoTierfor 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
fromTierandtoTierby 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 notBOY,MOYorEOYstops 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}/responsesanswers 404 whenactivityRunIdis 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 returnMSA/LEV. - Item import checks
dialect_tagsandevidence_type. Dialect tags must bemsa,levantineorlev(any case). The country markersPSandJOare dropped. Any other tag rejects the row asmissing_metadata, and so does anevidence_typethat is not an evidence type. Stored items were converted:["msa","PS"]became["msa"], and the six partner items that hadaudioas 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 strategypair_mapping): the child drags each word to its meaning. The key isinteractionKey = {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 andPATCH /api/items/{id}validate and store it canonical; like everyCOMPREHENSION_*row it needs a passage, a reading process and an answer location.GET /api/items/{id}?view=servedservesinteraction = {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 gainsparts: [{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 trycorrectAnswer.parts: [{part, answer}]lists only the parts still wrong. Both arenullfor single-answer items. The stored verdict and θ stay item-level. - The deeper sitting breaks only after a long passage: the session state’s
partSizeis replaced bybreakBefore: boolean—trueon 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 newstudent_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:purgenow deletes sessions that expired more thanAUTH_PURGE_INACTIVE_SESSION_DAYSago 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 newrateLimitWindowcount. - Two indexes that duplicated the unique index on the same column were dropped:
Session.tokenandUser.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/reviewwithdispositionType: escalatedopens a tier-review request in the same queue the principal and approver read (tier_review_request, with a decision packet).tierReviewRequestIdin the response is that request’s id. If the child already has a live review, the escalation links to it. The oldteacher_review_requesttable, 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_reviewandtier_review_due(never written) are removed fromrti_alert. - An exited plan shows its exit reason. The principal’s plan history (
GET /api/principal/students/{id}/plan?includeHistory=true) returnsexitReasonCodeand its label from the plan’s exit transition. Ninebundle_assignmentcolumns that were never written are dropped. The student report’sactivePlan.objectTypenow 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
recommendedSupportIntensityto match the resulting tier (universal,targetedorintensive). 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_yearrows 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. schoolYearStartsOnon a school:"MM-DD"between07-01and10-31on 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. rolloverDueon the active school year is true once any of the organisation’s schools has started its next year;rolloverDueSchoolIdsnames them. Promote the year (POST …/school-years/{yearId}/promote) to reopen screening.- The
startsOn/endsOnof aschool_yearrow 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-yearBOYscreen therefore read the first year’sBOYrows 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 example2026-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 stayscomplete(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 onstudent_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 isstatus_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-tenantsworks 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_jobandimport_row, with akindofroster_csvorlms_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_rowandlms_import_previeware 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: cronandautoApplied: 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
countsreport these asimport_row(previouslyroster_import_rowandlms_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 (
nullfor an Amal-team account); anyone else is 404. A student is refused with 422PERSONA_MISMATCHand the student route. Erasing your own account (409SELF_ACTION_FORBIDDEN) or the last active super admin (409LAST_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 aslastOrgAdmin: true. - One audit record (
person_erased) with the persona and counts per table, never the erased values. Running it again returnsalreadyErased: trueand 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 akindofdiagnostic,practiceorconfirmationand 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_queueandconfirmation_requestare 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 wasopenis nowactive,resolvedis nowcomplete. - 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.schoolYearIdis 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’sacademicYearlabel 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 astudent_grade_historyrow with the reason (correctionorimport) 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.schoolIdis 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_bankkeeps 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 toitem_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.versionnow reports the passage’s current content version. - Removed: 14
item_bankcolumns that nothing read (requiresInference,dokSmeConfirmed,posTags,lemmas,samerMaxLevel,camelsFeatureTags,nearDuplicateOf,rtiSensitive,reportable,soloSchemaVersion,version,deltaSe,infitMnsq,outfitMnsq) andpassages.version.
Database structure review — Phase 2, 2026-09-25
- One content-import model (P2d, migrations
20260926020000_content_importand20260926020100_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_logandcontent_import_approval, with acontentKindofitem,session_blueprintorpassage. 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 throughPOST /api/passageshave no batch, as before. - Phase 2e — erasing a student. New operation
POST /api/students/{studentId}/erase(tagerasure, slicewp-erasure.yaml). Migration20260926030000_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 (scopeall; no free text). The operation writes one audit record with counts per table and never the erased values. Running it again returnsalreadyErased: trueand 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 markedexcluded, and no count, θ or calibration reads them. diagnostic_sessionis renamedtheta_estimateand 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 insidestudent_task_result(which now holds only practice block results). Existing rows were moved intoitem_responseby 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,pauseMsandaudioReplayMsare 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_transitiontable, 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 lintlint: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_eventtable with a closed list of actions and a typed payload per action (new lintlint: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 andaudit_eventare 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 withpartitions: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’sacademicYearlabel 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/classesandPATCH /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 carryschoolYearId. - New:
GET /api/organizations/{orgId}/school-yearslists an organization’s years, newest first, with their class counts. - New:
POST /api/organizations/{orgId}/school-years/{yearId}/promoterolls the active year over to the next one (or totoLabel) 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 returnsalreadyPromoted: true. RequiresMANAGE_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:publishonce 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 byGET /api/sse/students/{id}/*no longer carryadrRuleVersion,ssrRuleVersion,ddrRuleVersion,ddrLcRuleVersion,patternDefRuleVersionorrbmRuleVersion; each row carriesconfigReleaseIdinstead, 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 fromGET /api/profile/{studentId}carriesconfigReleaseIdinstead ofprofileRuleSetVersion,cdpRuleSetVersion,pgaRuleSetVersion,narrativeTextSetVersionandaliasSetVersion. 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}returnsconfigReleaseIdinstead ofruleSetVersion, and workflow drafts carrypinnedClusterConfigReleaseIdinstead ofpinnedClusterRuleSetVersion(the teacher activation wizard is updated). Bundle recommendation and assignment rows no longer carryconfigVersions/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) returnsconfigReleaseIdon each XP transaction instead ofruleSetVersion; 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}returnsmonitoringPlan.configReleaseIdinstead ofrecipeVersion/criterionVersion, andGET /api/students/{id}/tier-decisionsreturnsconfigReleaseIdinstead ofmovementRuleVersionId. - 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}returnsscaffoldConfigReleaseIdinstead ofscaffoldRuleVersion. - 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). TheconfigReleaseIdcolumn 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/sessionno longer acceptsassemblyRuleVersion(the active release’s rule set is used) and assembly runs returnconfigReleaseId; QA runtime alerts returnqaConfigReleaseIdinstead ofqaChecksSetVersion; the delivery log returnsrenderConfigReleaseIdfor online serves, andrenderRuleVersionis 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 activeClassStudentrow 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 inrowsSkipped). 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:rebuildrebuilds it (it also runs nightly inpurge:nightlyso 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.idnow equalsUser.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 aBEFORE INSERTtrigger that keepsid = userIdfor 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.studentIdis a real foreign key on 53 measurement and decision tables,ON DELETE RESTRICT, added asNOT VALID+VALIDATEso 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/skillIdis 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 indocs/product/partner-inputs. A table comes back only together with its reader. - The connection pool is a number ops chooses.
PRISMA_CONNECTION_LIMITandPRISMA_POOL_TIMEOUTare stamped ontoDATABASE_URLper instance (4 and 20 s on main, 3 on demo). The Cloud SQL tier change and managed connection pooling are documented indeploy/cloud-run/DEPLOY.mdas 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
/trendsseries and the principal case-signal folds used to fetch every snapshot row for the roster and pick the newest in Node bycomputedAt. They now runDISTINCT ONwith 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 ownacademicYearcolumn rather than from when it was computed. - Two unindexed join keys fixed —
diagnostic_session.sessionId(the finish path) andClass.schoolId— plusrequestIdonuser_activity_eventfor log correlation. - The retention sweep exists.
pnpm auth:purgedeletes 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 trailauth_event_logis never touched);pnpm purge:nightlychains it with the existingactivity:purge. Neither had ever been scheduled; the Cloud Run Job + Cloud Scheduler commands are indeploy/cloud-run/DEPLOY.mdand 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()runsBEFORE UPDATE OR DELETEon 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 withappend-only: … is forbidden (V-6)— from Prisma, psql, the calibration service, anywhere. The single bypass isSET LOCAL amal.maintenance = 'on'inside a transaction (demo reset, one-shot migrations, test databases). The drift gate now also asserts all 39 triggers exist aftermigrate 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.sessionsPlannedanddosageBandare 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_snapshotandmacro_domain_status_snapshotgainedacademicYear(“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_responsewith everydiagnostic_sessionrow, but the engine also writes adiagnostic_sessionrow for each practice score. Sitting-bound rows (sessionIdset) 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 aftermigrate deploy:schema.prismamust equal the replayed migrations, and every raw-SQL object Prisma cannot model (32 partial/GIN indexes, 2 views, 18 CHECK constraints, registered inprisma/raw-sql-objects.json) must still exist. Building it revealed that migration20260624221545had really dropped the session-blueprint tag index in every environment; it is restored and now declared in the schema somigrate devstops 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-configpublishes the three screening windows and the G1 starter blueprint (11 phonological-awareness skills × BOY/MOY/EOY) at org scope.PUT /api/admin/diagnostic-blueprintnow rejects an inactive skill (422) and reports a thin item pool aswarnings[{skillId, servable, need}]on 200 — or 422 when the body carriesstrict: 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.coverageBasisfor 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-resolutionconfirmnow chainsrecommendBundleand returnsbundle {written, bundleId, objectType, actionCode, idempotent}. NewPOST /api/profile/{studentId}/recompute(recomputeProfile, ClassTeacher-scoped) is the manual re-drive. - Snapshot basis revisions (S2).
domain_status_snapshotandmacro_domain_status_snapshotgainbasisRevisionin 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 acceptssubSkillIds: []as an explicit “no universal screen for this grade × window” cell.GET /api/student/today-plangainsscreeningState: "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-configpublishes 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 tablepractice_experience_map— an on-track child gets a maintenance/enrichment queue instead of a 409, a gateway weakness leads, anddo_not_decide_yetmints nothing.practice_queue.experienceTyperecords 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. Migration20260923100000_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, orquickCode.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.cursorandlimitare rejected with 422, not ignored: an export is the whole filtered set. AboveexportMaxRows(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-authoreddiscussion_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 answersprompts: []withcatalogVersion: 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-templatesand/{templateCode}/downloadlist 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 reportsavailable: falsewith a null download path. Neither route writes an audit row — theStudentDataAccessLogperimeter 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), never0%: “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-substringswas path-scoped to one module, so every manager render and both shipped PDF renderers sat outside it. The path set now coversitems + 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:demonow 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, never0. - 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
quickCodeare all absent by construction. OneStudentDataAccessLogrow is written per student-level response, before the response; a cross-scope request returns 404 having written nothing.Organization.studentLevelVisibilityis a kill-switch, default on. - BE-3 — the first principal writes. A 26th permission code (
CREATE_PRINCIPAL_REQUEST),principal_requestwith 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 touchesstudent_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, tagprincipal-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 withcountsover six display bands (meets,approaching,below,severe,under_review,not_assessed), ameetsShareand the tile’s own worst-case-winsband;enrolledCount/assessedCount; the three usage mini-KPIs (teachersActive,studentsActive,plansOnPlan, withperiodFrom/periodToandpartialPeriod); thedataSufficiencyverdict (sufficient,weeksElapsed,requiredWeeks,studentCoverage,blockingReasons);attention(counts per alert family —[]until BE-1);queueCounts(nulluntil BE-1 and thedataRoomOverviewStripknob);wave2Tiles(present only withshowWave2Tiles). Query:window(BOY|MOY|EOY; omitted = the school’s current window from the screening calendar) andperiod(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
metaenvelope on every response of the module:{ assessmentWindow, computedAt, ruleVersion, configVersion, stale, languageRuleSetVersion }.staleis derived from data recency (the newest contributing snapshot older thansnapshotStaleMinutes, default 60), never from a refresh clock; no contributing row at all ⇒stale: truewithcomputedAtat the request instant.ruleVersion(fold rules) andconfigVersion(thresholds;0= the code defaults) are pinned separately so a replay can tell “the rules changed” from “the school retuned a knob” (V-6).languageRuleSetVersionisnullon aggregate reads. - The
Shareshape is the only percentage on a manager surface:{ numerator, denominator, value, shareOf },valuein[0, 1]andnullwhenever the denominator is 0 (rendered «—», never 0%);shareOf∈assessed_students|enrolled_students|measured_skills|active_plans|active_teachers|enrolled_students_active.Not_Assessed,PartialandContextualare 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.
403is only the permission gate. Every scope outcome is404: a school that is cross-tenant, cross-school, outside a MANAGER’sscopedSchoolIds, or simply absent is indistinguishable on the wire (existence non-disclosure), and a404writes no audit row. Do not read a403as “exists but forbidden” on any/api/principal/**route.422for an invalidwindow/period. - Three new tables.
student_data_access_logis 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 withsubjectStudentCount = 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);subjectStudentUserIdisUser.id;filtersis a closed key set and an unknown key is rejected; a search term is never stored, onlyqueryLength;requestIdties 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_configholds 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 insertsversion + 1and 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 hardcodedPRN_CONFIGbyte-for-byte, soconfigVersion = 0means “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_catalogis 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_logdoes not exist:runtime_audit_rowis the decision spine (nostudentIdcolumn) and is not the audit trail;student_data_access_logis. Theschool_dashboard_snapshotview named by 44 FR-PRN-11 was never built and is not built here: R0 folds the livemacro_domain_status_snapshotspine over one new partial covering index (DISTINCT ONand 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::resolveStudentScopestays frozen (it answersforbiddenfor principals and 29 write call sites depend on that); the module-localresolveSupervisorStudentScopedelegates to the shippedcanReachStudent, andlint:principal-scope-isolationenforces the boundary. - CI. A new job
wp-mgr-bewith its own Postgres 16 service runs three blocking steps:lint:mgr(single write path · scope isolation ·Shareconstructor, wherelint:principal-share-constructorrejects a raw ratio outsideengine/shares.ts· append-only audit tables · request-id on activity tables · tier-status single write path · no-LLM, withprincipal-viewsadded 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 totest:qa. Measured at BE-0 exit on a 750-student fixture (opt-inPERF=1harness, not in CI): R0 p95 202 ms against the 2 000 ms gate. - Known gaps.
attentionis[]andqueueCountsisnulluntil 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_ADMINreceives404on R0 (the shippedcanReachSchoolhas 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-isolationis 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 emitsdata-share-of="<numerator>/<denominator>"(ordata-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 insidepnpm --filter web lint, which thebuildjob 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>) andlint:no-inline-arabic(an Arabic literal inside the manager tree must come fromlib/manager-copy.ts,lib/status-display.tsorlib/arabic-count.ts; pre-existing files sit in a dated baseline). Root proxypnpm test:render:no-global-percentage. D-5 vocabulary lands platform-wide instatus-display.ts: theApproachingchip now reads «تحت المتابعة» andLISTEN_COMPreads «الفهم المسموع» (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 newkind: "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 ontoGET /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#implementationand/principal/review→/principal/data-roomare307redirects fromnext.config.ts(query preserved, kept for one release);/principal/growthand/principal/leaderboardkeep 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/principallanding 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 and Manager for the current screens, Managers & Principals for the roles and scopes, and the 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 atotalcount.GET /api/users/{id}andPATCH /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 composefullNameAron 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. AuthEventLogrows now carryactorUserIdand a closed-keyauditMetaon every admin-initiated write, next to #138’sdetailcolumn. Seven event kinds belong to this reconciliation (admin_password_reset,credential_email_failed,user_created,user_updated,user_suspended,user_reactivated,user_deleted);credential_emailedis #138’s and now also carriesactorUserId.
See the 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&limitreturns 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. OmittingclassIdmeans all the caller’s classes; a class the caller does not teach answers403. A running live session carriescounts: null, because per-student detail exists only once it has ended. A cursor this endpoint did not issue answers400instead of silently rewinding to page one.GET /api/sessions/{id}/progressis the per-recipient roster of a delivered generated session:completed,in_progress,not_started,stopped,skippedorexcluded, withitemsAnswered,itemsCorrect,itemsTotalandlastActivityAt. 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.409before delivery,404outside the caller’s organization.GET /api/sessions/{id}/recipients/{studentId}/responsesreturns 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 (chosenOptionIdsandcorrectOptionIds) 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.orderSensitivesays the sequence is the construct.reviewable: falsemarks 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}/progressmaps 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.completedAtis 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), ornotResendable(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.pdfandGET /api/classes/{classId}/screening-triggers/{id}/progress.pdfprint 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 answers503rather than an unbounded body.- Also:
GET /api/reading-assessments/batches/{id}rows carrywcpmandaccuracybeside the existingadministeredAt. 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 kindgenerated, 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_lineis 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-linesacceptscbmType(defaults toORF_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/startandPOST /api/cbm/probesnow answer400 cbm_skill_not_eligibleotherwise. 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.subSkillIdis a real column — it lived only insidebenchmarkValuesSnapshot.__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-nameviolation 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}/stopaccepts optionalerror_details[]({ word_index, group_index, part: "letter" | "mark", mark_index?, char }) next toerror_word_indices. Every entry must re-derive from the passage word with the item importer’s letter-group splitter (graphemeSplitVersionv1); any mismatch, a word outside the final misread set, or details without the final set →422and nothing is written. Rows land in the new append-onlyoral_reading_error_detailtable (migration20260909120000_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 theorfdetail) now lists ORF rows for real students — they were read byStudentProfile.idwhile the ORF writer keys them onUser.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 + 1and 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.stopis now one transaction (session + sub-word rows + bothcbm_scoresrows);result.scoredErrorWordIndicesstores the engine’s in-range set; a detail on a word marked beyond the last word reached is dropped with the mark (never a422); theorf_probe_eventtelemetry payload acceptsdetailCountandadministered.
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&schoolIdreturns the three effective windows at a scope (startsOn/endsOndates,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;422when 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 gainswindows(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) andPOST …/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 carriescalendarWindowso the UI can warn when the choice disagrees with the calendar.GET /api/student/today-plangainsscreening: { window, source } | null;nextActionis"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:screeningCycleis 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_triggeredis always explicit. The session row recordsscreeningSource(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=andGET /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 onetenantWhere(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/usersshows 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-logrows carrypublishedByName(the publisher’sfullNameArresolved at read time;nullonly when the user row is gone); the audit page shows the name next to the stablepublishedByid. -
Sign-out and every sign-in path clear the
amal_cyclecookie (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 withpath=/(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/usersand/admin/rolesneed 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/startgainsreasonnext tofirstItemId: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), ornullwhen 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 aplatform_alert_logrow naming organization, student, session, grade, window, school scope, blueprint source/size andreason, 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 onGET /api/qa/runtime-alerts?qaCheckId=QA_040for the organization’s admins.pnpm test:qa:040. GET /api/admin/diagnostic-blueprintgainsskillTaqyeemCount(per active skill, the number of operationalquick_checknon-training items — the pool the initial screen draws from),skillTaqyeemCountAudio(the same pool narrowed to items with audio, the grade-1 audio-first rule) andtaqyeemScreenSize(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:censusprints per-skill taqyeem (quick_check) / tadreeb (practice) / monitoring (probe) counts bucketed against the 3-item screen (--jsonfor machines). The 2026-09-08 snapshot is indocs/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/startnow 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 carriesnoEligibleItem: truewithfirstItemId: nullandstate.status: "abandoned"; the row is written with the newsessionEndReasonvalueno_eligible_item(migration20260907070000_session_end_reason_no_eligible_item).POST /api/diagnostic-sessions/{id}/finishon an abandoned sitting answers404, 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/usersfrom 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/usersanswers422when an ADMIN’sscopedisagrees with the organization binding (SUPERwith an organization, orORG/SCHOOLwithout one). PUT /api/admin/diagnostic-blueprint/{grade}/{cycle}now answers409(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 indexdiagnostic_blueprint_active_cell, migration20260907080000_diagnostic_blueprint_active_cell_unique).POST /api/diagnostic-sessions/start/re-diagnosticopens the sitting before archiving the student’s practice queues; when no item can be served the sitting is abandoned (no_eligible_item), the response carriesnoEligibleItem: truewitharchivedQueueCount: 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&schoolIdreturns the effective grid for that scope; each cell carriessource(global|org|school) andinherited.PUT /api/admin/diagnostic-blueprint/{grade}/{cycle}acceptsorganizationId/schoolIdin the body and publishes at that scope (each scope keeps its own version chain).GET /api/admin/diagnostic-blueprint/audit-logtakes the same filters and every row carriesorganizationId,schoolIdandscopeLevel.schoolIdrequiresorganizationId, the organization must exist, and the school must belong to it (422otherwise). - New
DELETE /api/admin/diagnostic-blueprint/{grade}/{cycle}?organizationId&schoolIdclears the override at that scope (the active row becomes inactive with no successor; history is kept) and returnseffective— the cell as that scope now resolves it from its parent scope, ornullwhen unset everywhere.404when 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-profilesnow require a platform super-admin (an account outside any organization). Every organization-admin persona holds theSUPER_ADMINpermission code, so the code alone was never a sufficient check; an in-organization admin now receives403, and the web pages render the “super-admins only” card. - Schema.
diagnostic_blueprintgains nullableorganizationId/schoolIdand a derivedscopeKey; uniqueness is per(scopeKey, gradeLevel, screeningCycle, version). Migration20260907001000_r14_diagnostic_blueprint_scopekeeps 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}/recommendationsreturns the TAW_01 recommendation rows (same shape as/api/workflow/recommendations/{classId};classIdcarries the group id) over the group’s members; an open acute alert on any of a member’s classes marks the rowreview_required.- Plan authority.
POST /api/workflow/activate(mode=individual) acceptsgroupIdinstead ofclassId(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 reachesPOST /api/students/{id}/activate-bundleand 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, →
409onPOST /api/workflow/activate(individual and bulk confirmed members),POST /api/cluster/bulk-activateandPOST /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 groupreview_requiredrows read the same set. The individual path records the chosendeliveryModein the decision log. Group activation leaves the teacher’s saved class-wizard draft in place. DELETE /api/schools/{id}answers409when a teacher is bound to the school throughTeacherProfile.schoolId(the FK is now RESTRICT).GET /api/users/me(TEACHER) carriesprofile.schoolId(nullable).POST /api/users(TEACHER) acceptsschoolId(must be in the organization; derived from the first class inclassIdswhen omitted) and stores it onTeacherProfile.schoolId; the roster staffschool_refsets 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-groupslist 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 (403otherwise).GET/PATCH /api/support-groups/{id}return the detail with active members (User.id + StudentProfile.id + class) and rename;archivedis leadership-only (403for the owner), and an archived group is out of its owner’s reach entirely.POST /api/support-groups/{id}/membersadds bystudentUserIds: 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,409on an archived group.DELETE /api/support-groups/{id}/members/{studentUserId}soft-removes.GET /api/support-groups/{id}/summary,/domain-trendand/alertsreturn 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-wideGET /api/monitoring/alertsandGET /api/cbm/alertsinboxes 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,studentsCSV files; students required) parses, validates and matches the roster and returns a staged batch —status: invalidwith row-numbered Arabic errors (nothing written), orstatus: previewedwith acreate/update/conflictaction per row.GET /api/roster-import/batcheslists batches;GET /api/roster-import/batches/{id}returns every row;GET /api/roster-import/batches/{id}/report.csvdownloads 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}/commitprovisions the batch in one transaction and returns one-time credentials (staff temporary passwords, student 4-digit PINs).409 stale_previewwhen another batch created the same refs first;410after the 24 h TTL;422forbatch_invalid/unresolved_conflicts/school_principal_conflict.- Identity model.
User.emailis now nullable (students have none; adults still require it), andUser,SchoolandClasscarry an org-scopedexternalRef— the school’s own id and the idempotency key for re-import. Four Arabic name columns (firstNameAr…familyNameAr) composefullNameAr.POST /api/usersaccepts a STUDENT withoutemail. - Credentials. Student 4-digit PINs now hash at
AUTH_STUDENT_PIN_BCRYPT_COST(default 8); adult passwords keepAUTH_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/passagescreates a draft passage; the server computeswordCountandsentenceCountfrom 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-MatchonupdatedAtreturns412on a stale precondition, and each save writes an append-onlypassage_revisionrow in the same transaction.GET /api/passages/{id}/revisionslists the edit history. There is no delete; retire a passage through itsstatus. - 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/itemswith ablankskeleton now accepts an optionalpassageId, 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-templateapplies one template (or clears it withnull) across 1..200 questions. Each question reports an independent outcome (updated,unchanged,incompatible,not_found, orconflict); one failure never aborts the rest. - Distractor labels.
GET /api/item-types/{code}/distractor-compatnow 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-itemresponse gains an optionalreasonfield, set wheneverdone: true:no_open_request(nothing owed),complete(the current set just finished), orno_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_logrow naming the organization, student, skill, window, and request (once per request), and notifies engineering. Visible viaGET /api/qa/runtime-alerts. - New maintenance command
confirmation:sweepmarks confirmation requests left open longer than 45 days asabandoned(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-itemserves the next confirmatory mini-set item for the student’s oldest owed confirmation (same difficulty band as the initial screen;done: truewhen none is owed or the set is complete).POST /api/confirmation/responsessubmits one confirmatory response (server-graded). The response body carries onlyneedMore— 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 fromamal-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-summaryandGET /api/orgs/{orgId}/implementation-summaryreturn the intervention-plan implementation (fidelity) rollup: active-plan counts bucketed intoadequate/insufficient/unknown, animplementationRatepercentage, 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 isnullwhen the denominator is zero so an all-unknown school never renders as 0%. School route gates onVIEW_SCHOOL; org route onVIEW_ORG.GET /api/schools/{schoolId}/fluency-summaryreturns 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 isnullwhen no cut resolves across the cohort. AbenchmarkNotereports cut resolvability. Gates onVIEW_SCHOOL.GET /api/principal/schools/{schoolId}/review-matrixclassifies 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 onVIEW_SCHOOL.- Org trends re-bucketed to assessment windows.
GET /api/orgs/{orgId}/trendsnow buckets its x-axis on the snapshotevidenceWindow(BOY/MOY/EOY) rather than the calendar month. TheOrgTrendsDTO.windowsarray changed fromstring[]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-pdfrenders 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 sameservedItem()output the student receives. - 2 new item types + serve interaction kinds.
TYPED_SPELLING(an on-screen Arabic keyboard, graded by the newtyped_exactstrategy) andMULTI_SLOT_CLOZE(a multi-gap fill over running text, graded by the newslot_vectorstrategy). 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, andtracerender 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 forsyllable_cut(which also fixes a wrong-field bug that left the cut row blank), a keyboard + target-word editor fortyped_spelling, and a base-letters + target-letter editor forharakat_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 writesinteractionConfig/interactionKeythrough the samePATCHchokepoint, 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-adminroutes, all true-super-only (skipTenantFilter):GET /api/admin/demo-tenantslists the registrant, status, expiry, last-used stat, and class/student counts;POST …/{id}/revokesoft-deletes the org, suspends its users, kills sessions, and marks the registration revoked;POST …/{id}/extendpushes the 30-day TTL or reactivates a TTL-lapsed tenant;POST …/{id}/reissuerotates 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-tenantswith 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_ADMINpersona (the 7thPersonaKey) 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
requireNotDemois replaced byrequireDemoOutboundBlock. 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/impersonateis a scoped act-as. ADEMO_ADMINmints 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 (aDemoImpersonationLogrow plus ademo_impersonation_startAuthEventLogrow), andGET /api/auth/menow returnsimpersonatedByfor 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/registeris a captcha-gated, rate-limited signup. It creates a pendingDemoRegistrationand enqueues background provisioning of an isolated, fully populated demo tenant, then returns{ registrationId }.GET /api/demo/register/{id}/statuspolls provisioning status. It returns the one-time welcome payload (login URL plus per-persona sandbox credentials, including the demo student’squickCodeandclassId) only while the status isreadyand 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-configis net-new. A student self-toggles their own 5 render-only accessibility fields (dyslexiaFont,highContrast,largerText,audioFirst,reducedMotion). The/mepath carries no student id, so there is nothing to tamper (IDOR-free); the StudentProfile is resolved server-side from the session. Only theSTUDENTrole may write, enforced by an in-handler role check becauseVIEW_OWN_PROFILEis held by every role and so the permission gate alone is not the restriction.GET /api/students/me/accessibility-configis 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, ornull. Anullmeans “unset”, so the client resolves it to the OS default; afalseis an explicit “off”. Do not treat a missing field asfalse. - Optimistic concurrency. Send the
accessibilityConfigVersionyou last read in anIf-Matchheader. A concurrent write that already bumped the version returns409, so re-GETand retry. OmitIf-Matchfor last-write-wins (safe for offline replay). - Field-level merge. A
PATCHbody 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_logrow recording who, when, which fields, and the resulting version. The table is append-only. - Render-only.
accessibilityConfigis 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 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/sessionsstarts 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}/finishscores the submitted responses on the real Rasch estimator and returns a one-sub-skill placement band (meets,approaching,below, orsevere) using the Mizan Arabic band labels, plus an Arabic disclaimer string.POST /api/teaser/leadscaptures 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. NoAuthorizationheader is required or accepted. - The session token issued at start must be passed to both
finishandleads. A bad token returns 401. A re-submit tofinishreturns 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) carryorganizationId = null(the global convention, same as the item bank).
The new routes appear in the 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
/demopersona-picker. A Server Component that readsGET /api/demo/accessat request time (the BE reads only theisDemo=trueorg) 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_demoflag cookie. The demo enter actions setamal_demo=1alongside the session cookies. ADemoBannermounted 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 rendersnullwithout 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=1with a dismissible notice.
The new route appears in the 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.isDemoflag. A boolean column marks the demo tenant. The seeder sets it totrueon the demo org; all other orgs default tofalse.DEMO_READ_ONLYguard. A newrequireNotDemomiddleware mounted on ~29 destructive and provisioning route groups (user CRUD, tenancy CRUD, LMS connect/sync, quickCode regeneration, password-reset mail, parent-notification dispatch). Returns 403DEMO_READ_ONLYfor anyisDemo=trueorganization. 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:demoprovisions 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.mdemitted todocs/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-consoleforapps/web. Rawconsole.*is rejected outside the logger sink. UsegetLoggerinstead.
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 → informationalteacher_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 onlysub_flag_indicator_state+sub_flag_indicator_occurrence+platform_alert_log. Enforced bylint:subflag-alert-no-mutation. AR_LETTER_CONFUSION_FLAGrenamed →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_FLAGis 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_banksoft 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
BundleObjectTypeenum (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_idis now nullable. The 7 non-bundle MAP rows (MAP-000/001/002/003/018/019/020) carryNULLin this field plus anobject_typeand arouting_state_or_action_code(partner decision D-015: non-bundles must not occupyrecommended_bundle_id). TheNO_BUNDLE_SYSTEM_GUARDsentinel-bundle row is retired; its function is now theROUTING_GUARDaction code withrouting_state = BLOCKED_MISSING_EVIDENCE.- Cumulative-cycle weight ranges replace per-session fixed splits (D-003). The old
sum==100 per session + anchor-strictly-largest CHECKconstraint 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) andLANGUAGE_MEANING_BUILDING(45-55% anchor / 20-30% one dynamic support / 20-30% contextual application). VOCAB_SYNTAX_MORPH_BUNDLEsplits into 3 variants (VOCAB_DOMINANT / SYNTAX_DOMINANT / MORPH_DOMINANT) underbundle_family_id = LINGUISTIC_FEATURE_FAMILY.TEXT_ACCESS_INTEGRATED_BUNDLE+COMPREHENSION_STRATEGY_BUNDLEreclassified asCOMPOSITE_SEQUENCE_PLAN;MIXED_READING_LANGUAGE_BUNDLEasCOMPOSITE_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 — nobundle_assignmentrow is written for these paths. - New
bundle_definitioncolumns: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_assignmentfields: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}/approveflips accepted rows → operational;GET /api/session-blueprints/{id}fetches one blueprint. - Teacher catalog —
GET /api/sessions/catalogbrowses/searches the operational catalog;GET /api/sessions/catalog/recommendedranks 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_yetstudents 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-onlinepushes per-student sessions to the practice queue;POST /api/sessions/{id}/generate-pdfsrenders 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}/auditread 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=bypassbusts the cache). RequiresMANAGE_ROSTER.POST /api/lms/:provider/mappings— save course → Amal class mappings. RequiresMANAGE_ROSTER.POST /api/lms/preview— generate a read-only import diff: identity matched by exact external-sub or verified email; returns per-rowaction: create | update | conflict | skipplus a summary. Zero writes toUser,ClassStudent,ClassTeacher, orParentStudent. 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, andUserExternalIdentity(source=roster_import)rows (enabling Google SSO for imported users). Returns{ created, updated, skipped, conflicts_remaining }. Idempotent onpreviewId. 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; returns202 { 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-useGoogleOAuthStaterow (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-wiredguard 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 newBenchmarkProfilemetadata 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…/resolveroute 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 newlint:kpi-alignment-onlyimport-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_concernfeeder whose rawweightedDeficitRate ≥ DTH-DEF-03(50%) with valid evidence escalates to Severe. Re-tuningDTH-DEF-03now shifts the Below↔Severe boundary, not only broad-concern classification. - Spec:
21-skill-status-engine.mdFR-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 theinvalid_due_to_implementation_gapverdict. - 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.mdFR-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/sessionsnow requires asessionType—TRAININGorINTERVENTION_PRACTICE. Any other value (e.g. an assessment context) is rejected422(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
WarmupGradeConfigrows carry machine-readable element counts (numberOfVisualElements,pairCount,movingObjectsCount,targetCount,distractorCount,pathNodesCount,crossingCount,occlusionCount,speedChangeCount) + atemplateContractId(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
BadgeDtonow carriesstudentBadgeIdandstatusfor teacher, principal, and admin callers. Both fields are absent for student-self and parent callers. Reflected inamal-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 withlimit/offset. A SUPER (null-org) admin sees all organizations. Reading as SUPER appends oneSuperAdminBypassLogrow (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 realdomain_status_snapshotrows: 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)./managerredirects 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-profilesnow accepts optionalcountry,skillId,assessmentType,gradeBand, andisActivefilters (AND-combined; an empty query returns all rows as before).GET /api/admin/benchmark-profile-changelognow accepts an optionalbenchmarkProfileIdto 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 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 nouserIdpath 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.
organizationIdis 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-Clientcontract. 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":"…"}. Aclient-contextmiddleware parses it intoc.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
clientdevice-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 sameitemBankId, 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-diagnosticopens 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/:sessionIdpairs the re-diagnostic against the most-recent preceding completed diagnostic and returns per-sub-skill{ priorStatus, currentStatus, statusTransition, growth }.growthis one ofimproved,declined,same, orindeterminate. 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 belowmd; 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-Clientheader + 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/sessionscreates a draft session with a numeric join code;POST …/startactivates it;GET …/summaryreturns 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 …/groupscreates student groups with a designated leader;PATCH …/groups/:groupId/leadertransfers leadership. - REST polling. Students and the teacher poll
GET …/pollandGET …/eventsfor state changes. Answers go toPOST …/responses;POST …/observationrecords a teacher note. - Playlist and control.
POST …/playlistloads the item sequence;POST …/controldrives play, pause, next, and end. Sessions can link to an ISM assembly run viaPOST …/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
MasteryEventSourceKindvalues — 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, grounded in
the shipped module code, give the RTI decision wave its API-tier coverage:
Intervention Fidelity Tracker (the escalation gate: rate, rating,
verdict, and escalationBlocked), Decision Packets (the assemble,
approve, and reject workflow that calls the RTI engine to move the tier), and
Student 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, Measurement & Determinism, The Decision Engine, Intervention Design, and Safety Rules.
- 16 new subsystem guides under Guides — 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 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
:parentIdin any path → no IDOR); each:childIdis checked against theParentStudentlink — 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_Assessedexcluded 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_reviewand 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-reviewlists 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/pdfprimitive (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/assembleopens 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}/approveroutes 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}/rejectrecords 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}/checkreturns 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, andescalationBlocked. 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 …/recomputeappends a fresh immutable snapshot (advisory-locked, idempotent);GET …/{bundleAssignmentId}reads the latest;GET /api/students/{id}/fidelity-trackerfeeds the teacher’s Fidelity tab;GET /api/fidelity/admin/low-fidelity-cohortlists 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-roomreturns 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/evidencepages 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-packetsopens 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 reportsinsufficientand 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 …/recommendationreturns the deterministic verdict;POST …/decisioncommits it through the single audited write path. - Fidelity before escalation.
TM_BLOCK_01runs beforeTM_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_yetblocks 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-onlycbm_scoresrow. - Aim line + 4-point trend.
POST /api/cbm/probeswrites 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/alertsthe 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 byPOST /api/admin/warmup/dry-run-replay). - Resumable + capped.
POST /api/warmup/sessions→…/events→…/complete, with…/resumerestoring the child at the exact exercise step. A silent 5-minute cap; a skip is penalty-free (no retry, nowarmup_abandonedflag). - 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/:diagnosticSessionIdbuilds a queue from the diagnostic;…/current/next-itempicks the most informative item at θ±0.5;…/submit-responsescores 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/:childIdreturns 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-reportcomposes 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 adataIncompletesection (a 200, not an error). - Per-class summary.
GET /api/reports/classes/:id/class-summaryreturns 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=managerswitches to aggregate-only (no student names or ids ever). - Band reference.
GET /api/reports/band-descriptionsreturns 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/scoretakes the student’s responses + a prior θ and returns{newTheta, newSE, calibrationVersion, nextItemId, calibrationProvisional}. Every scoring run is recorded as an append-onlyDiagnosticSession. Reads/writes are org-scoped; gated by theUSE_DIAGNOSTIC_ENGINEpermission, 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) soPOST /api/engine/replay/:sessionIdreproduces 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/checksreturns the 32-row registry;GET /api/qa/runtime-alertslists the routed runtime alerts for your organization (filter byseverity, time window, orqaCheckId);GET /api/admin/qa/audit-summaryrolls them up by check + severity. All requireVIEW_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/validaterunssanitize(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-profiledo_not_sayoverlay applies at render time. - Admin rule management.
GET /api/language-rulesreturns the active rule set (consumed at copy-load by the app + PDF renderer);POST /api/language-rulesinserts a new version (append-only — the prior row is deactivated, never edited in place);PATCH /api/language-rules/{id}/deactivateretires a row. - Controlled context flags only.
POST /api/students/{id}/context-flagattaches a controlled flag from the closed dictionary — no free-text body field exists; a blocked/forbidden flag returns422 context_flag_blockedand records a length-only security-audit entry (no text about a child is ever stored).GET /api/students/{id}/context-flagsreturns 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_assessedis 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/resolvewalks 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/activatewill not activate ado_not_decide_yetplan, 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/reviewresolves it by an explicit teacher disposition); aDATA_INCOMPLETEstudent 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-draftpersists an in-progress workflow (7-day), restored viaGET /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 stampsuserId/orgId/tsfrom 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/taskfilters 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 toGET /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_instancekeyed by a UUIDtaskInstanceId(the QR-worksheet join token), pinning its selection inputs;POST /api/admin/delivery/dry-run-replayre-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. EveryGET /api/organizations/…/{id}response includes the provisioning super-admin’screatedBy(a User id) — the value was already stored at creation, now it is exposed (FR-TEN-1). TheOrgResponseschema in the API reference is updated accordingly. -
Domain-status sentinel correctness.
GET /api/sse/students/{id}/domain-statusnow returns the storeddoNotDecideYetsentinel 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 ondoNotDecideYetbefore 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 the401(unauthenticated) response they already returned at runtime. -
Internal only.
gateway_priority_configgains apublished_ataudit column (migration20260620120000); spec-corpus reconciliations in11-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/evidencerecords 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/alertslists 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/:classIdgroups 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_yetstudent → 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-activaterequires 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/previewreturns the student’s current cluster + the per-tier scaffold distribution; no student-facing internals. - The
acute_regressionexclusion 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/recommendmaps 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-bundlerequires 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, andassignmentStatus(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|adminreturns the pre-rendered, language-safety-filtered text.audience=studentis 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-resolutionwith{ outcome: confirm }promotes it toactive. Exactly one active profile per window; every change is a new append-only row. - Fail-closed. If any contributing skill is
do_not_decide_yetor a required domain lacks coverage, no profile is written — no role can override. - Admin.
POST /api/admin/profile-catalog/publishruns the catalog-completeness gate (every primary has its CDP/PGA/SPOT coverage);POST /api/admin/profile-replayis 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-C06Synonym Awareness. TheVOC_RELconcept now covers both antonyms (VOCAB-C05) and synonyms (VOCAB-C06). - Sub-flag naming locked. The writing/spelling sub-flag is
WR_SP_ALERT(wasWRITING_SPELLING_ALERT);AR_LETTER_CONFUSION_FLAGis 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-statusand/domain-statusreturn the latest per-(subSkill|domain, evidenceWindow)snapshot, each carrying the matched rule ids + versions (for replay) and thedoNotDecideYetsentinel./pattern-classificationsand/behavior-logback 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_yetis 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) andPOST /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-Idis now accepted inbound. If you send anX-Request-Idheader 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-Idis 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_urlimport field +imageUrlcolumn for picture items, and thedistractor_typecontrolled vocabulary expanded 13 → 19 (addedagreement_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/:idis 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 inGET /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. - 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=authoringfor 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 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, Getting Started, and Errors. - tenancy — organizations, schools, classes (CRUD), organization rollup. → See Core Concepts and Manage classes & rosters.
- users — provisioning (discriminated
POST /api/users), persona profiles, class rosters, ClassTeacher assignment, principals, managers, parent ↔ child links, student quickCode regen. → See Provision users, Managers & principals, and Parent ↔ child links. - system — health check. → See the API Reference.