User Sign-Up Flows & Role Relationships

Part of: Pickatale Master Build Wiki | Version: v1.8 | Last Updated: 2026-04-18

v1 Scope: Pickatale v1 is school-first only. B2C (parent self-registration, parent-created children, consumer subscriptions) is out of scope for v1. All child accounts originate from the school/teacher flow. Parents are linked observers, not primary account owners.


Who Are The Users?

Pickatale has five user roles. In v1, only the school-led entry path exists.

                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                        β”‚   platform_admin    β”‚  (Pickatale staff β€” CLI only)
                        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                   β”‚ provisions school
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚        school_admin         β”‚
                    β”‚   teacher.readingtester.com β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                   β”‚ invites teachers
                    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                    β”‚          teacher            β”‚
                    β”‚   teacher.readingtester.com β”‚
                    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           β”‚ creates / imports students
                    β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
                    β”‚    child    β”‚  ← canonical learner record (owned by school)
                    β”‚ app.*       β”‚
                    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜
                           β”‚ parent linked afterward
                    β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
                    β”‚   parent    β”‚  ← secondary linked observer (v1)
                    β”‚ parents.*   β”‚
                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The Only Entry Path in v1

Step Who Action
1 school_admin Creates school (self-register or provisioned by platform_admin)
2 school_admin Invites teachers by email
3 teacher Accepts invite; creates/imports students
4 student Receives school-issued username + PIN
5 parent Optionally linked afterward for read-only visibility

There is no B2C path in v1. No parent creates a child. No parent buys access.


Role Summary

Role Who they are Home portal Signs up via Auth method
platform_admin Pickatale staff admin.readingtester.com CLI only Email + password
school_admin School IT admin or head teacher teacher.readingtester.com Self-register OR provisioned Email + password
teacher Classroom teacher teacher.readingtester.com Invited by school_admin OR self-register Email + password
parent Child's parent/guardian parents.readingtester.com Invited by teacher/school after child exists Email + password
child Student age 4–13 app.readingtester.com Created by teacher only (school flow) Username + 4-digit PIN

Children never self-register. Parents never create child accounts.


The Relationship Model

Ownership Chain (v1 β€” school-first)

school  (tenant boundary)
  └── school_admin  (manages school, owns school record)
       └── teacher  (member of school, manages classes)
            └── class  (owned by teacher, scoped to school)
                 └── student/child  (canonical learner record, owned by school)
                      └── parent  (linked observer β€” read-only, scoped by school policy)

Ownership Rules

Data Owner Transfer authority
School record school_admin Platform admin only
Class Teacher who created it School admin can reassign
Student / learner record School (teacher manages) School admin can move between teachers; platform admin moves between schools
Parent→child link School grants; parent holds Teacher or school_admin can revoke
Reading progress Belongs to learner_id Follows child if school changes

The school tenant owns the learner record. Parents are linked observers, not owners.

Who Can See What

Actor Can see Cannot see
child Own content, own progress Any other user's data
teacher All students in their own classes Students in other teachers' classes
school_admin All students in their school Other schools
parent Own child's tracker data only (see Section 6) Class roster, other children, teacher data, assignments (unless school policy allows)
platform_admin Everything β€”

Account Center β€” Single Auth Hub for Adults

All adult sign-up, sign-in, and password reset lives at: account.readingtester.com

Children only touch app.readingtester.com.

Endpoint Method Purpose
/api/auth/register POST school_admin, teacher sign-up [V1-PRODUCTION]; parent link via invite [V1-PRODUCTION]; parent self-registration [LEGACY / DO NOT IMPLEMENT]
/api/auth/login POST All adult roles
/api/auth/logout POST Invalidate session
/api/auth/verify-email POST Email verification token
/api/auth/forgot-password POST Trigger reset email
/api/auth/reset-password POST Apply new password
/api/auth/invite-accept POST Teacher accepts school invite
/api/auth/parent-invite-accept POST Parent accepts parent-link invite
/api/auth/session GET Session validation (used by all services)

After login, uc_session cookie is set on .readingtester.com. Each portal reads cookie and routes by role:

Role Redirect
teacher / school_admin teacher.readingtester.com/dashboard
parent parents.readingtester.com/dashboard
platform_admin admin.readingtester.com
child Never uses Account Center

Child session cookie: reader_session, scoped to app.readingtester.com only.


Flow A: School Admin Registers (School Bootstrap)

This is the first user in a school. Everything depends on this.

Trigger: School purchases Pickatale or starts a trial.

UI Entry Point: account.readingtester.com/register β†’ "I'm setting up my school"

API Endpoint: POST /api/auth/register

{
  "name": "Sarah Hill",
  "email": "sarah@greenwood.edu",
  "password": "β€’β€’β€’β€’β€’β€’β€’β€’",
  "role": "school_admin",
  "school_name": "Greenwood Primary School",
  "country": "GB"
}

Backend Actions:

  1. Validate (Zod). 422 on failure.
  2. Check email uniqueness β†’ 409 if exists.
  3. bcrypt hash password (cost 12).
  4. INSERT schools (name, country, state='pending').
  5. INSERT users (name, email, password_hash, role='school_admin', school_id, state='pending_verification').
  6. UPDATE schools.admin_user_id = users.id.
  7. INSERT subscriptions (school_id, state='trialing', tier='trial', trial_ends_at=now()+14d).
  8. Generate verification token β†’ INSERT email_verification_tokens.
  9. Send VERIFY_EMAIL β†’ INSERT email_log, audit_log.

After email verified:

Failure Paths:

Condition HTTP Error
Email exists 409 "Account exists. Sign in."
School name blank 422 "School name is required"
Password too weak 422 Rules listed

Flow B: Teacher Invited by School Admin

UI Entry Point: teacher.readingtester.com/settings/team β†’ Invite β†’ enter email(s)

API Endpoint: POST /api/v1/schools/:school_id/invites

{ "email": "james@greenwood.edu", "role": "teacher" }

Backend Actions:

  1. Verify requestor is school_admin of this school β†’ 403 if not.
  2. Check email uniqueness within school β†’ 409 if duplicate.
  3. INSERT invites (school_id, email, role='teacher', token_hash, expires_at=now()+7d, invited_by).
  4. Send TEACHER_INVITE email (invite URL: account.readingtester.com/invite?token=).
  5. INSERT email_log, audit_log (action='invite_sent').

Teacher accepts (POST /api/auth/invite-accept { token, name, password }):

  1. Validate token (exists, not expired, not used).
  2. INSERT users (role='teacher', school_id=invite.school_id, state='active') β€” no email verification needed.
  3. INSERT memberships (user_id, school_id, role='teacher').
  4. Mark invite used. INSERT audit_log.
  5. Set uc_session β†’ redirect to teacher.readingtester.com/dashboard.

Failure Paths:

Condition HTTP Error
Token expired (>7d) 410 "Invite expired. Ask admin to resend."
Token already used 410 "Already used. Try signing in."
Email already registered 409 "Account exists. Sign in."

Flow C: Teacher Self-Registers (No Invite)

Same as Flow A but with role: "teacher" and no school created initially.

After email verification β†’ Teacher Onboarding:

  1. "Do you belong to a school?" β†’ Yes / No
  2. Yes β†’ search existing schools β†’ if found, send join request to school_admin β†’ teacher waits for approval. If not found β†’ create school (teacher becomes school_admin).
  3. No β†’ teacher may register individually (school_id optional). Teacher still holds one teacher_licenses row. Classes and students function identically.

Flow D: Teacher Creates / Imports Students

This is how child accounts are born in v1.

Single student

UI Entry Point: teacher.readingtester.com/class/:id β†’ "Add Student"

API Endpoint: POST /api/v1/classes/:class_id/students

{ "name": "Tommy Anderson", "year_level": 3, "parent_email": "tom.parent@email.com" }

Backend Actions:

  1. Verify teacher owns the class β†’ 403 if not.
  2. Generate username (firstname + 3-digit suffix, unique). Generate 4-digit PIN.
  3. INSERT students (name, username, pin_hash=bcrypt(pin), class_id, teacher_id, school_id, year_level, state='created').
  4. INSERT audit_log.
  5. Return { username, pin } in response β€” shown once in modal, not stored in plain text.
  6. If parent_email provided: INSERT parent_invite_queue (student_id, parent_email, state='pending') β€” sent on school policy trigger.

Credentials display: Teacher sees username + PIN once. Print/copy prompt shown. After dismiss: never shown again (only reset available).

Bulk import via CSV

UI Entry Point: "Import Students" β†’ upload CSV

Columns: name, year_level, parent_email (optional)

Backend:

  1. Parse CSV β†’ validate each row.
  2. Bulk INSERT students (same logic as single, username/PIN generated per row).
  3. Return credential sheet (all username + PIN pairs).
  4. Print Login Cards button activates β†’ printLoginCards(students, className, 'app.readingtester.com').
  5. If parent_email column present β†’ queue parent invites.

Flow E: Child First Login β†’ Placement Test

Trigger: Child enters username + PIN at app.readingtester.com/login.

Backend (child auth):

  1. Look up students by username.
  2. Check students.locked β†’ 423 if true ("Account locked. Ask your teacher.").
  3. bcrypt compare PIN β†’ 401 on mismatch. Increment failed_attempts. Lock after 5.
  4. On success: failed_attempts = 0. Set reader_session cookie. INSERT audit_log.

First-time gate: students.placement_test_completed = false β†’ redirect to /placement-test.

Placement test flow:

  1. Adaptive passages + comprehension questions (4–8 minutes).
  2. On complete: POST /api/placement-result { learner_id, fk_level, raw_scores }.
  3. INSERT placement_results. UPDATE students.placement_test_completed = true.
  4. Fire to Learner Bot: POST /api/v1/bot/:learner_id/placement-result (X-Internal-Key).
  5. Redirect to /library.

Subsequent logins: Skip placement. Direct to /library.


Context: The child account already exists (created by teacher). Parent links to it for read-only visibility.

Who can initiate the parent invite:

Step 1 β€” Teacher/school sends parent invite:

UI Entry Point: teacher.readingtester.com/class/:id/students/:student_id β†’ "Invite Parent"

API Endpoint: POST /api/v1/students/:student_id/parent-invite

{ "parent_email": "maria@gmail.com" }

Backend:

  1. Verify requestor owns student (teacher_id or school_admin for same school) β†’ 403.
  2. Check if parent already linked (students.parent_id not null) β€” if linked: re-send access link.
  3. Generate invite token (UUID v4). INSERT parent_invites (student_id, parent_email, token_hash, expires_at=now()+7d, invited_by=teacher_id).
  4. Send PARENT_INVITE email (child name, school name, sign-up/link URL).
  5. INSERT email_log, audit_log (action='parent_invite_sent').

Step 2 β€” Parent accepts invite:

URL: account.readingtester.com/parent-invite?token=UUID

If parent has no account β†’ show registration form (name + password, email pre-filled from invite).

API Endpoint: POST /api/auth/parent-invite-accept

{
  "token": "UUID",
  "name": "Maria Santos",
  "password": "β€’β€’β€’β€’β€’β€’β€’β€’"   // only if new account
}

Backend:

  1. Validate token (exists, not expired, not used).
  2. If new account: INSERT users (role='parent', state='active') β€” email pre-verified via invite.
  3. If existing account: verify session matches invite email β†’ 403 if mismatch.
  4. UPDATE students.parent_id = users.id.
  5. Mark invite used. INSERT audit_log (action='parent_linked').
  6. Set uc_session β†’ redirect to parents.readingtester.com/child/:learner_id.

Critical rule: this flow attaches parent to the existing child record. It never creates a new student row.

Step 3 β€” Parent views tracker:

Parent lands on parents.readingtester.com. Sees only linked children. Read-only.

Failure Paths:

Condition HTTP Error
Token expired 410 "Link expired. Ask the teacher to resend."
Token already used 410 "Already linked. Sign in to view."
Email mismatch 403 "This link was sent to a different email address."
Child already has a different parent linked 409 "Contact school admin."

Parent Permissions in v1

Defined decisions (Sig, 2026-04-18):

Permission v1 Decision
Create a child independently ❌ No
Buy consumer access ❌ No (v1 school-first: teacher-licensed, no consumer billing)
Own the primary learner record ❌ No β€” school owns it
Move child outside school structure ❌ No
Receive parent invite βœ… Yes (from teacher/school)
Create own adult account βœ… Yes (via invite flow only)
View child's tracker/progress βœ… Yes (read-only)
Receive notifications βœ… Yes
Reset child PIN Parent can access portal before child reads β€” Yes (DECISION-07 resolved, V1-PRODUCTION)
Approve/deny school requests ❌ No (school doesn't ask parent for approval)

Parent Decisions β€” Resolved for v1 [V1-PRODUCTION]

All decisions below are final. Claude Code must implement exactly these rules. Do not invent alternatives.

DecisionV1 Rule (FINAL)Tag
DECISION-07: Can parent access portal before child reads?YES β€” parent can log in and see child profile immediately after linking, even with no reading history.[V1-PRODUCTION]
DECISION-08: Multiple parents per child?NO β€” one parent link per child. Second invite blocked: error CHILD_ALREADY_CLAIMED.[V1-PRODUCTION]
DECISION-09: Parent can remove themselves?NO β€” only school_admin or platform_admin can unlink a parent. Parent cannot self-remove.[V1-PRODUCTION]
Parent PIN resetNOT IN V1 β€” parent cannot reset child PIN. Only teacher or school_admin can. Do not build this endpoint.[V1-DEFERRED β€” DO NOT IMPLEMENT]
DECISION-11: Notify parent when child completes book?YES β€” push/email notification on book completion. Opt-out in parent settings.[V1-PRODUCTION]
DECISION-12: Parent sees class info?NO β€” parent sees only their own child's data. No class lists, no other students, no teacher contact details exposed.[V1-PRODUCTION]
DECISION-13: Scope of parent-visible data?Child's own data only: books read, miles, reading level, recent activity. No vocab gap details, no bot reasoning, no other students.[V1-PRODUCTION]

Flow G: Adult Password Reset

UI Entry Point: account.readingtester.com/forgot-password

API Endpoint: POST /api/auth/forgot-password { "email": "..." }

  1. Look up user. If not found β†’ return 200 (prevent enumeration).
  2. If found: INSERT password_reset_tokens (expires_at=now()+1h).
  3. Send PASSWORD_RESET email. INSERT email_log, audit_log.

Reset (POST /api/auth/reset-password { token, new_password }):

  1. Validate token. Validate password strength. bcrypt hash.
  2. UPDATE users.password_hash. Mark token used.
  3. DELETE all sessions for user. INSERT audit_log.
  4. Set new uc_session β†’ dashboard.

Flow H: Child PIN Reset

Children have no email. Only an adult can reset.

In v1, PIN reset is teacher-only by default.

UI Entry Point: teacher.readingtester.com/class/:id/students β†’ student β†’ "Reset PIN"

API Endpoint: POST /api/v1/students/:student_id/reset-pin

Auth: Teacher must own student (same school_id, student in teacher's class) β†’ 403.

Backend:

  1. Generate new 4-digit PIN.
  2. bcrypt hash. UPDATE students.pin_hash. Reset students.failed_attempts = 0, students.locked = false.
  3. INSERT pin_reveal_tokens (plain_pin_encrypted=AES256(pin), expires_at=now()+10m).
  4. Return { new_pin: "4782" } β€” shown ONCE in modal. Not stored in plain text after 10m.
  5. INSERT audit_log (action='reset_student_pin').

Parent PIN reset: Not built in v1. Teacher or school_admin can reset child PIN. Parent cannot.


Entitlement in v1 [V1-PRODUCTION]

Entitlement is teacher-licensed. Each teacher holds one subscription covering all their classes. Entitlement resolves from the teacher, not the school.

function checkEntitlement(ctx: { user_id?, school_id?, student_id? }):
  // Called on every feature-gated endpoint β€” server-side only
  1. Check entitlement_grants WHERE (user_id OR school_id) AND expires_at > now()
     β†’ If found: return { tier: 'full' }
  2. Check teacher_licenses WHERE teacher_id = resolveTeacher(ctx)
       AND state IN ('trialing', 'active', 'past_due' within grace)
     β†’ If found: return { tier: 'full' }
  3. Default: return { tier: 'free' }

Resolving child entitlement: child β†’ class_membership β†’ class β†’ teacher β†’ teacher_licenses.state. The child's entitlement equals the tier of their teacher's license.

Canonical tiers: free, trial, teacher_paid, enterprise, gifted. Tiers parent_paid, school_paid, class_paid do not exist in v1 and must not be built.

Entitlement failure fallback: On Account Center timeout (>2s) β†’ resolve to free. INSERT audit_log (action=entitlement_check_failed). Child can still read 50 free books. Learner Bot paused. Authoritative contract in Page 04, Section 29.3.


Summary: Sign-Up Entry Points (v1)

Role Entry URL Creates what First landing
school_admin account.readingtester.com/register schools + users + subscriptions School setup wizard
teacher (invited) account.readingtester.com/invite?token= users + memberships Teacher dashboard
teacher (self) account.readingtester.com/register users (school attached via search/create) School search
parent account.readingtester.com/parent-invite?token= users (via invite only) Parent tracker
child Created by teacher β€” no self-register students app.readingtester.com (PIN login)
platform_admin CLI only users (role=platform_admin) admin.readingtester.com