Roster & Classroom Management
Part of: Pickatale Master Build Wiki | Version: v1.7 | Last Updated: 2026-04-18
19. Roster and Classroom Management
Source: (D) | Status: Confirmed as spec
19.1 Create Class
| Field | Required | Notes |
|---|---|---|
| class_name | Yes | VARCHAR, teacher sets |
| year_level | Yes | INT 1β13 |
| curriculum_territory | No | defaults to school country |
Backend: INSERT classes (teacher_id, school_id, state=active)
19.2 Edit Class
Fields: class_name, year_level, curriculum_territory
Backend: UPDATE classes WHERE class_id AND teacher_id = requesting teacher
19.3 Archive Class
Backend:
- UPDATE
classes.state = 'archived' - All students with
class_idβ setstudents.class_id = null,students.state = 'inactive' - Learner Bot: stops nightly cycle for archived class students
- Data retained for 1 year before purge
Emits: ClassArchived event (payload: class_id, archived_at, student_count)
19.4 Add Child Manually
Entry: Teacher Portal β Class β Add Student β modal
Required fields: name, year_level (defaults to class year_level) Optional fields: language preference
Backend:
- Generate: username (name+3digits, unique), 4-digit PIN (bcrypt)
- INSERT
students(state=created, class_id, teacher_id, school_id, placement_test_completed=false) - POST to Learner Profile Service: create empty profile (learner_id = students.uuid)
- Return:
{username, pin}β pin shown once, never stored in plaintext
Constraint: Single-add flow must show pin in a dismissible modal with "Copy" + "Print Card" buttons, same as bulk import. This was previously missing β must build.
19.5 Bulk Import Students (CSV)
Entry: Teacher Portal β Class β Import Students β Upload CSV
See flow 17.5 for full spec.
19.6 Remove Child from Class
Backend:
- UPDATE
students.class_id = null,students.state = 'inactive' - Learner Bot: mark learner inactive (skip nightly cycle)
- Data retained (not deleted)
Note: Removing from class does NOT delete reading history, telemetry, or Learner Bot memories.
19.7 Reset Child PIN
Entry: Teacher Portal β Student row β Reset PIN
Backend:
- Generate new 4-digit PIN (bcrypt)
- UPDATE
students.pin_hash,students.failed_attempts = 0,students.locked = false - Return plaintext PIN shown once in modal
19.8 Print Login Cards
Entry: After import OR individual student row β Print Card
Output: PDF with: student name, username, 4-digit PIN (from one-time store), QR code (app.readingtester.com?user=username), school name, Pip logo, dashed border PIN retention: 10 minutes after generation only. After 10 min, PIN hash only exists β cannot recover. Teacher must reset if missed.
19.9 Assign Book to Class
Entry: Teacher Portal β Class β Assignments β Assign Book
Backend:
- INSERT
assignments(class_id, book_id, assigned_by=teacher_id, due_date optional, state=assigned) - For each student in class: INSERT
student_assignments(student_id, assignment_id, state=not_started) - Learner Bot: picks up assigned content on next nightly cycle
19.10 Move Child Between Classes
Entry: Teacher Portal β Student β Move to Class
Backend:
- UPDATE
students.class_id = new_class_id - Update
students.teacher_id = new_class.teacher_id - All historical data (telemetry, memories, reports) retained under same learner_id
- Learner Bot: continues with existing memory β no reset
19.11 Handle Duplicate Child Records
Detection: Same name + same class = warn on import Resolution:
- Admin tool: search by name, merge records (Section 27)
- Merge: keep lower-ID learner_id, migrate all telemetry + memories to it, archive duplicate
20. Parent-Teacher-Child Relationship Model
Source: (D) | Status: Confirmed as spec
Relationship Table
| Relationship | Allowed | How Established |
|---|---|---|
| Teacher β Child | 1 teacher per child at a time | Teacher imports/creates child |
| Parent β Child | 1 parent per child β v1 hard rule. Second parent blocked with CHILD_ALREADY_CLAIMED (DECISION-08 resolved) | Parent links via teacher invite (v1 flow F) or claim flow 20.1 |
| School β Teacher | 1 school per teacher | Registration or invite |
| School β Child | via teacher's class | Implicit |
| Parent β Teacher | read-only visibility | Not direct β both see child data |
Flow 20.1: Parent Links to Teacher-Created Child
v1 note: This is the canonical parent-link flow. Parent links to an existing school-created child account. This flow does not create a new student record. Parent cannot self-initiate β they must have an invite from teacher/school OR use this claim flow (which requires teacher approval).
UI Entry Point: Parent Portal onboarding wizard β "Link a child" β enter child's username
API Endpoints:
POST /api/v1/parent/find-childβ look up child by usernamePOST /api/v1/parent/claim-childβ submit claimPOST /api/v1/parent-claims/:id/approveβ teacher approvesPOST /api/v1/parent-claims/:id/rejectβ teacher rejects
Backend Actions:
Step 1 β Find child:
POST /api/v1/parent/find-child
Input: { "username": "sofia001" }
Backend: query students by username (case-insensitive)
Output: { "child_name": "Sofia", "class_name": "Year 3 Blue", "school_name": "..." }
Note: NO email, parent_id, teacher_id, or internal IDs returned
Step 2 β Submit claim:
POST /api/v1/parent/claim-child
Input: { "username": "sofia001" }
Backend:
1. Re-query students.id by username
2. Check students.parent_count < 2
3. Check no existing pending claim (same parent_id + student_id)
4. INSERT parent_claims (parent_id=session.user_id, student_id, state='pending', created_at=now())
5. INSERT teacher_notifications (teacher_id=students.teacher_id, type='parent_claim_request',
payload={child_name, parent_name, claim_id, approve_url})
If schools.auto_approve_parent_claims = true: skip steps 4β5, go directly to Step 3 approve
Step 3 β Teacher approves:
POST /api/v1/parent-claims/:id/approve
Backend:
1. Verify teacher owns student (students.teacher_id = session.user_id OR school_admin)
2. UPDATE parent_claims.state='approved', approved_at=now()
3. INSERT students_parents (student_id, parent_id, linked_at=now())
4. UPDATE students.parent_count = parent_count + 1
5. Send PARENT_CLAIM_APPROVED email to parent (name, child_name, portal_url)
6. INSERT email_log
7. Mark teacher_notification read
DB Writes: parent_claims, teacher_notifications, students_parents, students (parent_count), email_log
Events Emitted: none (email is notification mechanism)
Permission Check:
- Find-child: uc_session role=parent required
- Submit claim: uc_session role=parent required
- Approve/reject: uc_session role=teacher AND must own student (or school_admin)
Entitlement Check: none (claiming a child is free; entitlement affects what parent can see after linking)
Audit Log:
- INSERT audit_log (action='parent_claim_submitted', actor_id=parent_id, metadata={student_id})
- INSERT audit_log (action='parent_claim_approved', actor_id=teacher_id, metadata={claim_id, parent_id, student_id})
Success State: Parent Portal shows child under "My Children". Parent can view reading history, miles, digest.
Failure Paths:
| Condition | HTTP | Response |
|---|---|---|
| Username not found | 404 | "No student found with this username" |
| Child already has 2 parents | 409 | "This child already has the maximum number of linked parents. Contact the school." |
| Claim already pending for same pair | 409 | "You already have a pending request for this child." |
| Teacher rejects claim | β | PARENT_CLAIM_REJECTED email sent. Claim deleted. No recourse β contact school. |
| Auto-approve not set + teacher inactive | β | Claim stays pending indefinitely. Parent can view pending status in portal. Reminder cron after 7 days. |
Access Model
| Data | Teacher sees | Parent sees | Child sees |
|---|---|---|---|
| Reading session list | β All sessions | β Their child only | β |
| Reading level + history | β | β | β (shown as stars/level in app) |
| Vocabulary gaps | β | β (simplified) | β |
| Quiz scores | β All detail | β Summary only | β |
| Bot reports | β Full (curriculum language) | β Digest (warm tone) | β |
| Intervention alerts | β | β | β |
| Miles + Tokens | β | β | β |
| Other students in class | β | β | β |
30. Roster Lifecycle β Full Contracts
Source: (D) | Status: Confirmed as spec
Flow 30.1: Create Class
UI Entry Point: Teacher Portal β Classes β "New Class" button
API Endpoint: POST /api/v1/classes
Input:
{
"class_name": "string",
"year_level": 3,
"curriculum_territory": "England (optional)"
}
Backend Actions:
- Parse uc_session β verify role='teacher' or 'school_admin'.
- checkEntitlement(user_id) β free tier can create classes (no entitlement gate here).
- INSERT
classes(class_name, year_level, teacher_id=session.user_id, school_id=session.school_id, state='active', curriculum_territory).
DB Writes: classes
Permission Check: role must be teacher or school_admin
Entitlement Check: none (class creation is free)
Audit Log: INSERT audit_log (action='create_class', actor_id, metadata={class_id, class_name})
Success State: 201 {class_id: N, class_name, year_level}
Failure Paths: 401 no auth | 403 wrong role | 422 missing required fields
Flow 30.2: Add Student (Single)
UI Entry Point: Teacher Portal β Class β "Add Student" button β modal
API Endpoint: POST /api/v1/classes/:class_id/students
Input:
{
"name": "string",
"year_level": 3,
"language": "en (optional)"
}
Backend Actions:
- Verify teacher owns class_id.
- Generate username:
lowercase(firstname) + 3-digit-zero-padded-counter(unique within school). Retry if collision. - Generate 4-digit PIN (crypto random). bcrypt hash (cost 10).
- INSERT
students(name, username, pin_hash, class_id, teacher_id, school_id, state='created', placement_test_completed=false, year_level, language, uuid=UUID v4). - POST to Learner Profile Service:
POST /api/learner(learner_id=uuid, age derived from year_level, language). X-Internal-Key. - Store plaintext PIN in
pin_reveal_tokens(uuid β pin, expires_at=now()+10m). - Return:
{student_id, username, pin_token: uuid}.
Constraint: Plaintext PIN is NEVER stored in students. Only bcrypt hash. Plaintext returned once via pin_token.
DB Writes: students, pin_reveal_tokens
Permission Check: teacher must own class_id
Entitlement Check: none (student creation is free)
Audit Log: INSERT audit_log (action='add_student', actor_id, metadata={student_id, class_id})
Success State: 201 {student_id, username, pin_token}. Client calls GET /api/v1/pin/:pin_token to display PIN once.
Failure Paths: 403 not class owner | 409 username collision (auto-retry) | 502 Learner Profile unreachable (student still created, profile retry queued)
Flow 30.3: Bulk Import Students (CSV)
UI Entry Point: Teacher Portal β Class β "Import Students" β file upload
API Endpoint: POST /api/v1/classes/:class_id/students/import (multipart/form-data, field: roster)
CSV Format:
name,year_level
Sofia Anderson,3
James Chen,3
name: requiredyear_level: optional (defaults to class year_level)
Backend Actions:
- Verify teacher owns class_id.
- Parse CSV. Validate all rows. Return 422 with row errors if any required field missing.
- Detect duplicates within CSV and within class (same name): collect warnings, do not block.
- For each row: generate username + PIN (same logic as 30.2). INSERT
students. - POST to Learner Profile Service for each student (batched, max 50 per request).
- Store all plaintext PINs in
pin_reveal_tokens(expires_at=now()+10m). - Return: array of
{student_id, name, username, pin_token}.
DB Writes: students (N rows), pin_reveal_tokens (N rows)
Permission Check: teacher must own class_id
Entitlement Check: none
Audit Log: INSERT audit_log (action='bulk_import', actor_id, metadata={class_id, count})
Success State: 201 {imported: N, warnings: [...], students: [{name, username, pin_token}]}
Failure Paths: 422 CSV parse error | 403 not class owner | partial success not allowed (all or nothing)
Flow 30.4: Retrieve PIN (One-Time)
UI Entry Point: After student creation β modal shows PIN
API Endpoint: GET /api/v1/pin/:pin_token
Backend Actions:
- Query
pin_reveal_tokensby token. If not found β 404. - Check expires_at > now(). If expired β 410.
- Return plaintext PIN.
- DELETE
pin_reveal_tokensrow (single use).
DB Writes: pin_reveal_tokens (DELETE)
Permission Check: teacher must be authenticated (uc_session) β token alone not sufficient
Audit Log: INSERT audit_log (action='pin_revealed', actor_id, metadata={student_id})
Success State: 200 {pin: "4821"}. Client renders in modal with "Copy" + "Print Card" buttons.
Failure Paths: 404 token not found | 410 token expired (10 min window) β teacher must reset PIN
Flow 30.5: Print Login Cards
UI Entry Point: After import OR Student row β "Print Card" button
API Endpoint: POST /api/v1/classes/:class_id/login-cards
Input:
{
"students": [
{"student_id": 1, "pin_token": "uuid-from-create"}
]
}
Backend Actions:
- Verify teacher owns class_id.
- For each student: call
GET /api/v1/pin/:pin_tokeninternally. - Generate PDF (Puppeteer). Each card: student name, username, PIN, QR code (
app.readingtester.com?user=username), school name, Pip logo, dashed border. - Use
page.setContent(html)NOTpage.goto(file://)β prevents path exposure. - Return PDF binary.
Constraint: If pin_token expired β card shows "PIN Reset Required" for that student.
DB Writes: none
Permission Check: teacher must own class_id
Entitlement Check: none
Audit Log: INSERT audit_log (action='print_login_cards', actor_id, metadata={class_id, count})
Success State: 200 Content-Type: application/pdf
Flow 30.6: Reset Student PIN
UI Entry Point: Teacher Portal β Student row β "Reset PIN"
API Endpoint: POST /api/v1/students/:student_id/reset-pin
Backend Actions:
- Verify teacher owns student (students.teacher_id = session.user_id OR school_admin).
- Generate new 4-digit PIN (crypto random). bcrypt hash (cost 10).
- UPDATE
students.pin_hash, failed_attempts=0, locked=false. - INSERT
pin_reveal_tokens(student_id, pin=plaintext, expires_at=now()+10m). - Return
{pin_token: uuid}.
DB Writes: students, pin_reveal_tokens
Permission Check: teacher must own student
Entitlement Check: none
Audit Log: INSERT audit_log (action='reset_student_pin', actor_id, metadata={student_id})
Success State: 200 {pin_token}. Teacher calls GET /api/v1/pin/:pin_token to retrieve. Shown once.
Flow 30.7: Move Student Between Classes
UI Entry Point: Teacher Portal β Student β "Move to Class" β select class
API Endpoint: PATCH /api/v1/students/:student_id/move
Input: { "target_class_id": N }
Backend Actions:
- Verify actor owns source student AND target class (or is school_admin who has both).
- UPDATE
students.class_id=target_class_id, teacher_id=target_class.teacher_id. - All telemetry, memories, sessions, quiz results remain under same learner_id (no migration needed β UUID is universal key).
- Learner Bot: next nightly cycle picks up new class context automatically.
DB Writes: students
Permission Check: must own both source student and target class (OR school_admin)
Entitlement Check: none
Audit Log: INSERT audit_log (action='move_student', actor_id, metadata={student_id, from_class_id, to_class_id})
Success State: 200 {ok: true}
Flow 30.8: Archive Class
UI Entry Point: Teacher Portal β Class β Settings β "Archive Class"
API Endpoint: DELETE /api/v1/classes/:class_id (soft delete)
Backend Actions:
- Verify teacher owns class.
- UPDATE
classes.state='archived', archived_at=now(). - UPDATE
students SET class_id=null, state='inactive'WHERE class_id=target. - POST to Learner Bot:
PATCH /api/v1/learners/deactivate-class(class_id) β stops nightly cycles for all students in class. - Data retained: all telemetry, memories, sessions kept. Learner Bot memories not deleted.
DB Writes: classes, students
Events Emitted: ClassArchived {class_id, archived_at, student_count}
Permission Check: teacher must own class OR school_admin
Entitlement Check: none
Audit Log: INSERT audit_log (action='archive_class', actor_id, metadata={class_id, student_count})
Success State: 200 {ok: true, students_deactivated: N}
Failure Paths: 403 not owner | 404 class not found | 409 class already archived
Flow 30.9: Transfer Student Ownership
UI Entry Point: Admin tool OR Teacher Portal β Student β "Transfer to Teacher"
API Endpoint: PATCH /api/v1/students/:student_id/transfer
Input: { "new_teacher_id": N, "new_class_id": N }
Backend Actions:
- Verify actor is school_admin or platform_admin.
- Verify new_teacher_id is in same school (or admin override).
- UPDATE
students.teacher_id, class_id. - INSERT
audit_logwith full transfer record.
DB Writes: students
Permission Check: school_admin or platform_admin only
Audit Log: INSERT audit_log (action='transfer_student', actor_id, metadata={student_id, from_teacher_id, to_teacher_id})
Flow 30.10: Merge Duplicate Students
UI Entry Point: Admin tool β search duplicates β "Merge"
API Endpoint: POST /api/admin/students/merge
Input: { "source_id": "uuid (to discard)", "target_id": "uuid (to keep)" }
Backend Actions:
- Verify actor is platform_admin.
- In Telemetry DB: UPDATE
events.learner_id=target_idWHERE learner_id=source_id. - In Learner Bot DB: UPDATE
memories.learner_id=target_idWHERE learner_id=source_id. - In LRS DB: UPDATE
statements.actor_id=target_idWHERE actor_id=source_id. - In Teacher Portal DB: UPDATE
studentsfor target: merge miles, tokens. DELETE source student row. Set sourcestudents.state='archived'(keep for audit). - INSERT
audit_log(action='merge_students', actor_id, metadata={source_id, target_id}).
DB Writes: Telemetry, Learner Bot, LRS, Teacher Portal DBs
Permission Check: platform_admin only
Audit Log: mandatory
Failure Paths: If any DB write fails β full rollback. Merge is atomic.
31. Parent-Teacher-Child Relationship β Full Contracts
Source: (D) | Status: Confirmed as spec
31.1 Relationship Rules (Non-Negotiable)
| Rule | Value |
|---|---|
| Child belongs to at most one class at a time | TRUE |
| Child can have at most one active teacher at a time | TRUE (via class) |
| Child can have multiple parents linked | TRUE (up to 2) |
| Child logs in directly (username + PIN) | TRUE |
| Parent logs in as self (email + password), views child read-only | TRUE |
| Parent can switch into child account | FALSE |
| Teacher can view parent-linked data | FALSE (teacher sees learning data only) |
| Parent can see other students in class | FALSE |
31.2 Parent Claim Flow (Full Contract)
Trigger: Parent registers β onboarding wizard β "Link a child"
API Endpoints:
POST /api/v1/parent/find-childβ find by usernamePOST /api/v1/parent/claim-childβ submit claimPOST /api/v1/parent-claims/:id/approveβ teacher approvesPOST /api/v1/parent-claims/:id/rejectβ teacher rejects
Flow:
Step 1 β Parent finds child:
POST /api/v1/parent/find-child
Input: { "username": "sofia001" }
Auth: uc_session (role=parent)
Backend: query students by username. If not found β 404.
Output: { "child_name": "Sofia", "class_name": "Year 3 Blue", "school_name": "..." }
Note: NO PII returned beyond name + class + school
Step 2 β Parent submits claim:
POST /api/v1/parent/claim-child
Input: { "username": "sofia001" }
Backend:
1. Check students.parent_count < 2 (max 2 parents per child)
2. Check no existing pending claim for same parent+student
3. INSERT parent_claims (parent_id, student_id, state='pending', created_at)
4. INSERT teacher_notifications (teacher_id=students.teacher_id, type='parent_claim_request',
child_name, parent_name, action_url='/notifications/claims/:id')
Output: { "claim_id": N, "state": "pending" }
Audit: INSERT audit_log (action='parent_claim_submitted', metadata={parent_id, student_id})
Step 3 β Teacher approves:
POST /api/v1/parent-claims/:id/approve
Auth: uc_session (role=teacher, must own student)
Backend:
1. Verify teacher owns student
2. UPDATE parent_claims.state='approved'
3. INSERT students_parents (student_id, parent_id)
4. UPDATE students.parent_count = parent_count + 1
5. Send PARENT_CLAIM_APPROVED email to parent
6. Mark teacher_notification as read
Audit: INSERT audit_log (action='parent_claim_approved', actor_id=teacher_id, metadata={claim_id})
Failure Paths:
| Condition | Response |
|---|---|
| Username not found | 404 |
| Child already has 2 parents | 409 {error: 'max_parents_reached'} |
| Claim already pending for this pair | 409 |
| Teacher rejects claim | Claim deleted, parent notified via email (PARENT_CLAIM_REJECTED) |
31.3 Access Control by Relationship
What teacher sees: All learning data (telemetry, reports, vocab gaps, quiz scores, interventions, flags)
What parent sees [V1-PRODUCTION, DECISION-13 resolved]: Child's own data only:
- reading history (books, dates, duration)
- miles + tokens
- reading level (shown as "Reading at Grade N level")
- weekly/nightly digest (warm tone)
- NOT: raw quiz scores, intervention details, assignments, class roster, other students
[V1-PRODUCTION] DECISION-13 resolved: Child's own data only β books read, miles, reading level, recent activity. No class lists, no bot reasoning, no other students.
What child sees:
- Library + "Picked for You" recommendations
- Miles + tokens
- Current book progress
- NOT: their own reading level number, scores, intervention status
41. Tenant Model
Source: (D) | Status: Confirmed as spec. These rules are non-negotiable and must be enforced in code, not by convention.**
41.1 Tenant Boundaries
A tenant is a school (schools.id). All data created within a school is scoped to that school.
Individual parent accounts are NOT tenants. They are cross-tenant users who have read-only visibility into one school's student via parent claim.
| Entity | Tenant scope |
|---|---|
| Teacher | Belongs to one school at a time (users.school_id) |
| Class | Belongs to one school (classes.school_id) |
| Student | Belongs to one school (students.school_id) |
| Subscription | Belongs to one school (subscriptions.school_id) OR one user |
| Nightly report | Scoped by learner_id β student β school |
41.2 Isolation Rules
Every list/get endpoint that returns school-scoped data MUST filter by the requesting user's school_id:
BAD: SELECT * FROM students WHERE class_id = :class_id
GOOD: SELECT * FROM students WHERE class_id = :class_id AND school_id = :session_school_id
Platform admins are the only role that may query across school boundaries. Check role = 'platform_admin' before removing school filter.
41.3 Cross-Tenant Restrictions
| Operation | Allowed | Notes |
|---|---|---|
| Teacher reads another school's students | β | Hard 403 |
| School admin reads another school | β | Hard 403 |
| Parent reads another parent's child | β | Hard 403 |
| Platform admin reads any school | β | Audit logged |
| Platform admin modifies any school | β | Audit logged + reason required |
| Parent links child in another school | β | Via claim flow only β teacher approval required |
41.4 Transfer Rules
Student transfer between schools:
- Only platform_admin may transfer a student to a different school (different school_id)
- Teacher/school_admin may move students only within their own school (same school_id)
- On cross-school transfer:
- UPDATE students.school_id, class_id=null, teacher_id=null
- Learner Bot memory: NOT reset β memories follow learner_id
- Telemetry history: NOT reset
- Old school: loses visibility immediately
- New school: gains visibility on next class assignment
- audit_log: action='cross_school_transfer', mandatory
Teacher transfer between schools:
- school_admin deactivates membership at source school
- New school sends invite; teacher accepts
- Students from old school: teacher_id=null (orphaned β school_admin must reassign)