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:
- Validate (Zod). 422 on failure.
- Check email uniqueness β 409 if exists.
- bcrypt hash password (cost 12).
- INSERT
schools(name, country, state='pending'). - INSERT
users(name, email, password_hash, role='school_admin', school_id, state='pending_verification'). - UPDATE
schools.admin_user_id = users.id. - INSERT
subscriptions(school_id, state='trialing', tier='trial', trial_ends_at=now()+14d). - Generate verification token β INSERT
email_verification_tokens. - Send VERIFY_EMAIL β INSERT
email_log,audit_log.
After email verified:
users.state = 'active',schools.state = 'active'- Redirect β School Onboarding Wizard (
teacher.readingtester.com/setup):- Confirm school details, year groups, curriculum territory
- Accept DPA (Data Processing Agreement) β required before any student data stored
- Optional: invite teachers
- "Go to Dashboard"
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:
- Verify requestor is school_admin of this school β 403 if not.
- Check email uniqueness within school β 409 if duplicate.
- INSERT
invites(school_id, email, role='teacher', token_hash, expires_at=now()+7d, invited_by). - Send TEACHER_INVITE email (invite URL:
account.readingtester.com/invite?token=). - INSERT
email_log,audit_log(action='invite_sent').
Teacher accepts (POST /api/auth/invite-accept { token, name, password }):
- Validate token (exists, not expired, not used).
- INSERT
users(role='teacher', school_id=invite.school_id, state='active') β no email verification needed. - INSERT
memberships(user_id, school_id, role='teacher'). - Mark invite used. INSERT
audit_log. - Set
uc_sessionβ redirect toteacher.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:
- "Do you belong to a school?" β Yes / No
- 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).
- No β teacher may register individually (school_id optional). Teacher still holds one
teacher_licensesrow. 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:
- Verify teacher owns the class β 403 if not.
- Generate username (firstname + 3-digit suffix, unique). Generate 4-digit PIN.
- INSERT
students(name, username, pin_hash=bcrypt(pin), class_id, teacher_id, school_id, year_level, state='created'). - INSERT
audit_log. - Return
{ username, pin }in response β shown once in modal, not stored in plain text. - 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:
- Parse CSV β validate each row.
- Bulk INSERT students (same logic as single, username/PIN generated per row).
- Return credential sheet (all username + PIN pairs).
- Print Login Cards button activates β
printLoginCards(students, className, 'app.readingtester.com'). - 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):
- Look up
studentsby username. - Check
students.lockedβ 423 if true ("Account locked. Ask your teacher."). - bcrypt compare PIN β 401 on mismatch. Increment
failed_attempts. Lock after 5. - On success:
failed_attempts = 0. Setreader_sessioncookie. INSERTaudit_log.
First-time gate: students.placement_test_completed = false β redirect to /placement-test.
Placement test flow:
- Adaptive passages + comprehension questions (4β8 minutes).
- On complete:
POST /api/placement-result { learner_id, fk_level, raw_scores }. - INSERT
placement_results. UPDATEstudents.placement_test_completed = true. - Fire to Learner Bot:
POST /api/v1/bot/:learner_id/placement-result(X-Internal-Key). - Redirect to
/library.
Subsequent logins: Skip placement. Direct to /library.
Flow F: Parent Link Flow (v1 β Observer Access Only)
Context: The child account already exists (created by teacher). Parent links to it for read-only visibility.
Who can initiate the parent invite:
- Teacher (from student record in class view)
- School admin (from school-wide student list)
- System (if parent_email was in import CSV β auto-queued, triggered by school policy)
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:
- Verify requestor owns student (teacher_id or school_admin for same school) β 403.
- Check if parent already linked (students.parent_id not null) β if linked: re-send access link.
- Generate invite token (UUID v4). INSERT
parent_invites(student_id, parent_email, token_hash, expires_at=now()+7d, invited_by=teacher_id). - Send PARENT_INVITE email (child name, school name, sign-up/link URL).
- 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:
- Validate token (exists, not expired, not used).
- If new account: INSERT
users(role='parent', state='active') β email pre-verified via invite. - If existing account: verify session matches invite email β 403 if mismatch.
- UPDATE
students.parent_id = users.id. - Mark invite used. INSERT
audit_log(action='parent_linked'). - Set
uc_sessionβ redirect toparents.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.
| Decision | V1 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 reset | NOT 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": "..." }
- Look up user. If not found β return 200 (prevent enumeration).
- If found: INSERT
password_reset_tokens(expires_at=now()+1h). - Send PASSWORD_RESET email. INSERT
email_log,audit_log.
Reset (POST /api/auth/reset-password { token, new_password }):
- Validate token. Validate password strength. bcrypt hash.
- UPDATE
users.password_hash. Mark token used. - DELETE all sessions for user. INSERT
audit_log. - 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:
- Generate new 4-digit PIN.
- bcrypt hash. UPDATE
students.pin_hash. Resetstudents.failed_attempts = 0,students.locked = false. - INSERT
pin_reveal_tokens(plain_pin_encrypted=AES256(pin), expires_at=now()+10m). - Return
{ new_pin: "4782" }β shown ONCE in modal. Not stored in plain text after 10m. - 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 |