Language Safety
Every string Amal renders, saves, or prints passes through the language-safety layer. This module enforces two absolute commitments: growth language in all user-facing copy, and no free text about students ever stored on the server.
Why this exists
Platform architecture V-12 states: “Teacher decides; system proposes; growth language only.” Two concrete problems drive the design:
- Deficit/clinical language harms children. Words that label or stigmatize undermine self-concept. Every rendered string must describe growth and next steps, never a deficit.
- International framework labels are for internal calibration, not user-facing copy. Terms like PIRLS, EGRA, GPF, and Lexile (and their Arabic equivalents) appear in the research literature Amal’s partner draws from, but they must never appear in copy a teacher, parent, or student sees. The Q15 hard rule bans them from all user-facing surfaces.
The safety layer also owns the only legal path for a teacher to attach context to a student session: a controlled closed-dictionary context flag, never free text.
The rule pipeline
When the server needs to render a string for a given audience, it calls sanitize(text, activeRules, { appliesTo }). Rules are loaded from the database and applied in a stable order:
| Rule type | What it does |
|---|---|
clinical_term | Replaces or blocks clinical/diagnostic vocabulary |
international_framework_label | Strips labels such as PIRLS, EGRA, GPF, Lexile (and Arabic equivalents) from user-facing copy; these terms are blocked here because they are the blocked list; see [Callout below] |
categorical_phrasing | Softens categorical deficit phrasing to growth framing |
cbm_alert_script | Applies CBM-specific growth-language scripts |
parent_stricter_guardrail | GL-001..GL-005 guardrails (parent tier, see below) |
profile_do_not_say | Per-profile banned phrases loaded at render time |
numeric_score_block | Strips raw numeric scores from parent-tier copy |
internal_label_block | Strips internal profile/bundle codes from parent-tier copy |
Each rule has a severity (block / soften / strip) and an appliesTo audience set (ui / reports / parent / parent_stricter / teacher_note / student / admin).
Why the framework labels (PIRLS, EGRA, GPF, Lexile…) are mentioned here: this page is describing what the filter BLOCKS. Naming them is correct here; it is the only place they belong. On any teacher/parent/student-facing surface they must never appear.
Audience tiers
The filter has four audience tiers that stack on each other:
| Tier | Who | Additional restrictions |
|---|---|---|
ui / reports / teacher_note / admin | Teacher, admin | Full clinical-term and framework-label filter; categorical softening |
parent | Parent | All of the above plus parent-specific growth scripts |
parent_stricter | Parent (reports, PDF) | All of the above, PLUS: no numeric scores, no internal profile/bundle codes, no internal labels, plus the 5 GL guardrails |
student | Student | Separate filter: no peer comparisons, no labels, supportive-only feedback |
The 5 GL guardrails (GL-001..GL-005)
These are specific Arabic deficit phrases identified by the partner that are categorically banned from parent and student copy. Each is a substring ban with a required replacement or omission. The rules are in the LanguageRule table with ruleType=parent_stricter_guardrail.
Per-profile do_not_say
Each primary profile has a do_not_say list in the Profile Grade Application (PGA) matrix. At render time, the language-safety engine loads the active set for the student’s profile and adds those phrases as an overlay filter. This ensures the SPOT narrative templates never accidentally use a phrase that contradicts the profile’s growth framing.
Append-only versioning
Rules are never edited in place. To change a rule:
- POST a new rule (a new row is inserted with a new
version). - PATCH the old rule to
deactivateit (PATCH /api/language-rules/{id}/deactivate).
The old version’s row remains permanently for audit. Active rules are loaded fail-closed: if no active rule set exists when the engine tries to render, it throws rather than emit unfiltered text (V-6 + V-3 scientific honesty).
Context flags: the only way to attach context to a student
There is no free-text field on any student record. V-12 and hard rule B.24 prohibit it. The only way a teacher can attach session or instructional context to a student is by selecting a flag from the closed ContextFlagDictionary.
The 7 allowed flag IDs:
| Flag ID | When to use |
|---|---|
CTX_SESSION_INTERRUPTED | The session was interrupted before natural completion |
CTX_TECHNICAL_ISSUE | A device or connectivity problem affected the session |
CTX_AUDIO_PROBLEM | Audio playback was impaired |
CTX_GROUP_NOT_COMPLETED | A group activity could not be finished |
CTX_STUDENT_ABSENT | Student was absent during a scheduled probe |
CTX_NEEDED_REPETITION | Student needed repeated instruction beyond normal |
CTX_PAPER_CHECK_ADMIN | A paper-based check was administered (alias CTX_PAPER_CHECK_ADMINISTERED) |
Three additional flag IDs are explicitly blocked (CTX_FREE_TEXT_NOTE, CTX_MEDICAL_NOTE, CTX_PSYCHOLOGICAL_NOTE). Attempting to submit one of these returns 422 context_flag_blocked; the system writes a length-only row to security_audit_log (the flag value itself is never stored).
Writing a context flag
curl -X POST https://localhost:3000/api/students/{studentId}/context-flag \
-H "Authorization: Bearer <accessToken>" \
-H "Content-Type: application/json" \
-d '{ "flagId": "CTX_SESSION_INTERRUPTED", "relatedEvidenceId": 42 }'Requires SELECT_CONTEXT_FLAG. Returns { studentId, contextFlagLogId, flagId }.
Reading context flags
curl https://localhost:3000/api/students/{studentId}/context-flags \
-H "Authorization: Bearer <accessToken>"Requires VIEW_OWN_CLASSES or RUN_BACKOFFICE.
Teacher notes are print-only
When a teacher wants to annotate a student record informally, the platform supports print-only teacher notes. These notes exist only in the browser and on paper: they are never sent to the server, never stored, and never appear in any API response. This is a V-12 privacy commitment, not a technical limitation to be worked around.
Language-rule management (admin/backoffice)
| Endpoint | Permission | What it does |
|---|---|---|
GET /api/language-rules | VIEW_OWN_CLASSES or RUN_BACKOFFICE | List all rules (active and superseded) |
POST /api/language-rules | RUN_BACKOFFICE | Publish a new rule version |
PATCH /api/language-rules/{id}/deactivate | RUN_BACKOFFICE | Deactivate an old rule version |
POST /api/language-rules/validate | VIEW_OWN_CLASSES or RUN_BACKOFFICE | Dry-run a string against active rules without persisting |
In-process use
Consumers that need the filter at write time (the teacher-AI chokepoint, alert text generation) import sanitize and loadActiveRules directly from the language-safety module. No HTTP hop is needed.
Related pages
- System QA Checks: QA_022 (sensitive-language check) and QA_023 (parent-report safety check) are entries in the registry that reference the enforcement here
- Safety Rules: the broader set of cross-cutting safety rules
- Student Profiles: SPOT narrative templates and the per-profile
do_not_saymechanism