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:

  1. UPDATE classes.state = 'archived'
  2. All students with class_id β†’ set students.class_id = null, students.state = 'inactive'
  3. Learner Bot: stops nightly cycle for archived class students
  4. 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:

  1. Generate: username (name+3digits, unique), 4-digit PIN (bcrypt)
  2. INSERT students (state=created, class_id, teacher_id, school_id, placement_test_completed=false)
  3. POST to Learner Profile Service: create empty profile (learner_id = students.uuid)
  4. 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:

  1. UPDATE students.class_id = null, students.state = 'inactive'
  2. Learner Bot: mark learner inactive (skip nightly cycle)
  3. 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:

  1. Generate new 4-digit PIN (bcrypt)
  2. UPDATE students.pin_hash, students.failed_attempts = 0, students.locked = false
  3. 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:

  1. INSERT assignments (class_id, book_id, assigned_by=teacher_id, due_date optional, state=assigned)
  2. For each student in class: INSERT student_assignments (student_id, assignment_id, state=not_started)
  3. Learner Bot: picks up assigned content on next nightly cycle

19.10 Move Child Between Classes

Entry: Teacher Portal β†’ Student β†’ Move to Class

Backend:

  1. UPDATE students.class_id = new_class_id
  2. Update students.teacher_id = new_class.teacher_id
  3. All historical data (telemetry, memories, reports) retained under same learner_id
  4. Learner Bot: continues with existing memory β€” no reset

19.11 Handle Duplicate Child Records

Detection: Same name + same class = warn on import Resolution:

  1. Admin tool: search by name, merge records (Section 27)
  2. 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

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:

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:

Entitlement Check: none (claiming a child is free; entitlement affects what parent can see after linking)

Audit Log:

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:

  1. Parse uc_session β†’ verify role='teacher' or 'school_admin'.
  2. checkEntitlement(user_id) β€” free tier can create classes (no entitlement gate here).
  3. 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:

  1. Verify teacher owns class_id.
  2. Generate username: lowercase(firstname) + 3-digit-zero-padded-counter (unique within school). Retry if collision.
  3. Generate 4-digit PIN (crypto random). bcrypt hash (cost 10).
  4. INSERT students (name, username, pin_hash, class_id, teacher_id, school_id, state='created', placement_test_completed=false, year_level, language, uuid=UUID v4).
  5. POST to Learner Profile Service: POST /api/learner (learner_id=uuid, age derived from year_level, language). X-Internal-Key.
  6. Store plaintext PIN in pin_reveal_tokens (uuid β†’ pin, expires_at=now()+10m).
  7. 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

Backend Actions:

  1. Verify teacher owns class_id.
  2. Parse CSV. Validate all rows. Return 422 with row errors if any required field missing.
  3. Detect duplicates within CSV and within class (same name): collect warnings, do not block.
  4. For each row: generate username + PIN (same logic as 30.2). INSERT students.
  5. POST to Learner Profile Service for each student (batched, max 50 per request).
  6. Store all plaintext PINs in pin_reveal_tokens (expires_at=now()+10m).
  7. 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:

  1. Query pin_reveal_tokens by token. If not found β†’ 404.
  2. Check expires_at > now(). If expired β†’ 410.
  3. Return plaintext PIN.
  4. DELETE pin_reveal_tokens row (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:

  1. Verify teacher owns class_id.
  2. For each student: call GET /api/v1/pin/:pin_token internally.
  3. Generate PDF (Puppeteer). Each card: student name, username, PIN, QR code (app.readingtester.com?user=username), school name, Pip logo, dashed border.
  4. Use page.setContent(html) NOT page.goto(file://) β€” prevents path exposure.
  5. 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:

  1. Verify teacher owns student (students.teacher_id = session.user_id OR school_admin).
  2. Generate new 4-digit PIN (crypto random). bcrypt hash (cost 10).
  3. UPDATE students.pin_hash, failed_attempts=0, locked=false.
  4. INSERT pin_reveal_tokens (student_id, pin=plaintext, expires_at=now()+10m).
  5. 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:

  1. Verify actor owns source student AND target class (or is school_admin who has both).
  2. UPDATE students.class_id=target_class_id, teacher_id=target_class.teacher_id.
  3. All telemetry, memories, sessions, quiz results remain under same learner_id (no migration needed β€” UUID is universal key).
  4. 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:

  1. Verify teacher owns class.
  2. UPDATE classes.state='archived', archived_at=now().
  3. UPDATE students SET class_id=null, state='inactive' WHERE class_id=target.
  4. POST to Learner Bot: PATCH /api/v1/learners/deactivate-class (class_id) β€” stops nightly cycles for all students in class.
  5. 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:

  1. Verify actor is school_admin or platform_admin.
  2. Verify new_teacher_id is in same school (or admin override).
  3. UPDATE students.teacher_id, class_id.
  4. INSERT audit_log with 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:

  1. Verify actor is platform_admin.
  2. In Telemetry DB: UPDATE events.learner_id=target_id WHERE learner_id=source_id.
  3. In Learner Bot DB: UPDATE memories.learner_id=target_id WHERE learner_id=source_id.
  4. In LRS DB: UPDATE statements.actor_id=target_id WHERE actor_id=source_id.
  5. In Teacher Portal DB: UPDATE students for target: merge miles, tokens. DELETE source student row. Set source students.state='archived' (keep for audit).
  6. 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:

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:

[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:



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:

Teacher transfer between schools: