Acceptance Criteria
Part of: Pickatale Master Build Wiki | Version: v1.7 | Last Updated: 2026-04-18
37. Acceptance Criteria
Source: (D) | Status: Confirmed as build gates. Each flow MUST pass all criteria before marked complete.**
Format: GIVEN [state] WHEN [action] THEN [result]
AC-01: Teacher Signup
GIVEN a new user visits account.readingtester.com/register with role=teacher
WHEN they submit a valid name, email, password
THEN:
- HTTP 201 returned
usersrow created with state=pending_verificationsubscriptionsrow created with state=trialing, trial_ends_at=now()+14d- VERIFY_EMAIL email sent (SendGrid, INSERT email_log)
- audit_log row inserted: action=
register
GIVEN the teacher clicks the verification link
WHEN token is valid and not expired
THEN:
users.email_verified=true, state=active- uc_session cookie set
- WELCOME_TEACHER email sent
- Redirect to /onboarding
- audit_log row inserted: action=
email_verified
FAILURE: Duplicate email β HTTP 409. Token expired β HTTP 410 + resend button shown.
AC-02: Class Creation
GIVEN a teacher is authenticated (uc_session role=teacher)
WHEN they POST /api/v1/classes with class_name and year_level
THEN:
- HTTP 201 returned
classesrow created (state=active, teacher_id=session.user_id)- audit_log row inserted: action=
create_class - Class appears in Teacher Portal class list immediately
FAILURE: Missing class_name β HTTP 422. Not authenticated β HTTP 401.
AC-03: Child Login (Username + PIN)
GIVEN a child account exists (students.state != archived, locked=false)
WHEN child submits correct username + PIN at app.readingtester.com
THEN:
- HTTP 200 returned
sessionsrow created (role=child, learner_id=students.uuid, expires=now()+24h)- uc_session cookie set
- audit_log row inserted: action=
child_login - If placement_test_completed=false β redirect to /placement-test
- If placement_test_completed=true β redirect to /library
FAILURE: Wrong PIN 5Γ β students.locked=true, teacher_notifications INSERT, HTTP 423. Account archived β HTTP 403.
AC-04: Reading Session (Book Open β Session End)
GIVEN a child is authenticated and selects a book
WHEN the book is opened
THEN:
reading_sessionsrow created (state=open, book_id, learner_id, started_at)- Telemetry event
book_openedfired to POST /api/v1/events
WHEN the child reads and turns pages
THEN:
- Telemetry event
page_turnedfired per page (page_number, time_on_page_ms) - Word count accumulated in client state
WHEN child taps a word
THEN:
- Telemetry event
word_tappedfired (word, page_number) - TTS spoken for tapped word (Finger-Follow mode)
WHEN last page reached and session_ended fires
THEN:
reading_sessions.state=completed, words_read, pages_read updatedstudents.milesincremented (words_read / 100)students.tokensupdated if miles threshold crossed (every 10 miles)- Telemetry fires POST /api/v1/telemetry/vocab-taps (5s timeout, fire-and-forget)
- Telemetry fires POST /api/v1/telemetry/session-summary (5s timeout, fire-and-forget)
- HTTP 200 returned to client
FAILURE (offline): Events queued in IndexedDB via Service Worker. Auto-replay on reconnect (72h window). Session remains open until session_ended received or 24h cron force-abandons it.
AC-05: Telemetry Capture
GIVEN a reading session is active
WHEN POST /api/v1/events is called with event_type, session_id, learner_id, payload
THEN:
- HTTP 200 returned
eventsrow inserted in Telemetry DB- Event deduplicated by event_id (UUID, 7-day server-side retention check)
GIVEN the session ends
WHEN POST /api/v1/telemetry/session-summary is called
THEN:
- Telemetry queries page_turned events for session_id
- avg_time_per_page_ms calculated
- slow_pages identified (time > 2Γ average)
- POST /api/v1/bot/:learner_id/session-summary fired to Learner Bot (X-Internal-Key, 5s timeout)
- Learner Bot inserts
reading_patternmemory (confidence 0.90 if pages_read/total > 0.8, else 0.60)
FAILURE: If Learner Bot unreachable β log error, continue (fire-and-forget). No retry.
AC-06: Nightly Report Generation
GIVEN a learner has active subscription (checkEntitlement β full)
WHEN Learner Bot nightly cron fires at 04:00 UTC
THEN:
- Bot queries learner memories, vocab_gaps, assessment_results, curriculum_state
- Bot selects 1β3 curriculum gaps (ranked by confidence and recency)
- Bot calls Adaptive Engine for recommended book level
- Bot generates teacher_report (curriculum language, evidence list, FK progression, reasoning)
- Bot generates parent_digest (warm tone, celebration, one home tip)
- Both stored in
nightly_reportstable (learner_id, report_type, content_json, generated_at) - If avg quiz score < 65% on 2+ quizzes: INSERT teacher_notifications (type=low_score)
- If no sessions in 4+ days: INSERT teacher_notifications (type=inactive)
GIVEN teacher opens Teacher Portal learner view
WHEN GET /api/v1/bot/:learner_id/status is called
THEN:
- HTTP 200 returned
- Response includes: reading_level, books_read, total_miles, latest_report, readingLevelHistory, vocabGaps, interventionFlags
FAILURE (learner on free tier): Bot skips. No report generated. Teacher Portal shows "Upgrade to view reports".
AC-07: Entitlement Enforcement
GIVEN a child's school subscription has expired
WHEN child opens a book (GET /api/books/:id/pages)
THEN:
- Server calls checkEntitlement(school_id)
- Returns tier=
free - If book index > 50 β HTTP 403
{error: 'entitlement_required', upgrade_url: '...'} - Client shows "Your school's subscription has expired. Ask your teacher." β does NOT show billing details to child
GIVEN a child is on free tier
WHEN they attempt to start a comprehension quiz
THEN:
- Server calls checkEntitlement β free
- HTTP 403
{error: 'feature_requires_subscription'} - Client shows lock icon on quiz button
GIVEN a teacher's subscription transitions to past_due (payment failed)
WHEN Stripe fires invoice.payment_failed webhook
THEN:
- UPDATE subscriptions.state=
past_due, grace_ends_at=now()+7d - RENEWAL_FAILED email sent to subscription owner
- All features remain accessible (grace period active)
- Teacher Portal shows yellow banner: "Payment failed. Update billing to avoid interruption."
- audit_log INSERT: action=
stripe_webhook, metadata={event: invoice.payment_failed}
GIVEN grace period expires (grace_ends_at < now())
WHEN expiry cron runs (every hour)
THEN:
- UPDATE subscriptions.state=
expired - Learner Bot: skip all learners in school on next nightly run
- Teacher Portal: red banner "Subscription expired"
- All full-tier features locked (free tier applies)
- audit_log INSERT: action=
subscription_expired
47. Acceptance Criteria β Extended
Source: (D) | Status: Confirmed as build gates. Extends Section 37.**
AC-08: Billing Failure Flow
GIVEN a teacher has an active subscription (state=active)
WHEN Stripe fires invoice.payment_failed
THEN:
- Stripe-Signature header validated. If invalid β 400, do nothing.
- UPDATE subscriptions.state='past_due', grace_ends_at=now()+7d
- RENEWAL_FAILED email sent (INSERT email_log)
- Teacher Portal shows yellow banner: "Payment failed. Update billing within 7 days."
- All features remain accessible.
- INSERT audit_log action='stripe_webhook'
- HTTP 200 returned to Stripe.
GIVEN grace period has expired (grace_ends_at < now(), detected by hourly cron)
WHEN expiry cron runs
THEN:
- UPDATE subscriptions.state='expired'
- All full-tier features locked for school.
- Learner Bot: skip all learners in school on nightly run.
- Teacher Portal: red banner, all AI features show lock.
- SCHOOL_SUBSCRIPTION_EXPIRED email sent.
- INSERT audit_log action='subscription_expired'.
- INSERT cron_runs record with items_ok count.
FAILURE: If Stripe webhook signature invalid β 400, INSERT audit_log action='stripe_webhook_invalid'. No state change.
AC-09: Admin β Impersonate User
GIVEN a platform_admin is authenticated
WHEN POST /api/admin/users/:id/impersonate with reason="investigating missing report"
THEN:
- Target user is NOT a platform_admin (else 403).
- INSERT audit_log (action='impersonation_started', actor_id=admin, target_id=user).
- New session created (expires in 1h, impersonator_id stored).
- uc_session cookie replaced with impersonated session.
- Client renders orange "Impersonating [Name]" banner.
- All subsequent actions logged with impersonator_id in metadata.
GIVEN admin clicks "End Impersonation"
WHEN DELETE /api/admin/impersonate
THEN:
- Impersonated session invalidated.
- INSERT audit_log (action='impersonation_ended').
- Admin's own session restored.
- Orange banner removed.
FAILURE: Admin attempts to impersonate another platform_admin β 403. Token expires without end call β session auto-invalidated at 1h by expiry cron.
AC-10: Admin β Grant Entitlement
GIVEN a platform_admin grants full entitlement to a school
WHEN POST /api/admin/entitlement/grant {school_id, tier:'full', expires_at: null}
THEN:
- INSERT entitlement_grants (school_id, granted_by=admin_id, tier='full', expires_at=null).
- INSERT audit_log (action='entitlement_grant_created').
- POST /api/internal/invalidate-entitlement (X-Internal-Key) broadcast to all services.
- All services clear cached entitlement for school within 60 seconds.
- All teachers/students in school now resolve tier='full'.
GIVEN admin revokes the grant
WHEN DELETE /api/admin/entitlement/grant/:id
THEN:
- DELETE entitlement_grants row.
- INSERT audit_log (action='entitlement_grant_revoked').
- Cache invalidation broadcast.
- School falls back to subscriptions table for tier resolution.
AC-11: GDPR Deletion β Child Account
GIVEN a parent or school_admin submits a GDPR deletion request
WHEN POST /api/gdpr/delete {learner_id: "uuid"}
THEN:
- INSERT gdpr_requests (type='deletion', state='pending').
- Immediate: UPDATE students.state='archived'. Child cannot log in.
- Async job (runs within 30 days):
- Telemetry events deleted.
- Learner Bot memories/reports deleted.
- Reading sessions deleted.
- LRS statements deleted.
- users row anonymised (name='Deleted User', email='{uuid}@deleted.invalid').
- audit_log PII fields stripped (but rows retained).
- UPDATE gdpr_requests.state='complete'.
- INSERT audit_log (action='data_deletion_completed').
FAILURE: If any service deletion fails β log to gdpr_requests.error_log. Manual resolution required. Do NOT mark complete until all services confirm.
AC-12: Offline Reading Session Replay
GIVEN a child is reading offline (no network)
WHEN they turn pages and tap words
THEN:
- Service Worker intercepts all POST /api/v1/events calls.
- Events queued in IndexedDB under tag 'telemetry-queue'.
- Client continues reading without interruption.
- Miles accumulated locally (in localStorage).
WHEN network restores
THEN:
- workbox-background-sync replays all queued events to Telemetry Service.
- Events arrive with client_ts (original timestamps). Server accepts out-of-order.
- Server deduplicates by event_id (UUID). Duplicate inserts silently ignored.
- If session_ended was queued: triggers vocab-taps and session-summary to Learner Bot.
- reading_sessions.state updated to 'completed' when session_ended processed.
FAILURE: If events not replayed within 72h (workbox limit): events lost. reading_session remains 'open'. Session-abandon cron marks it 'abandoned' after 24h last_event_at. No data lost from other sessions.
AC-13: First Child Login (Placement Test β Library)
GIVEN a child account has state='created' and placement_test_completed=false
WHEN child logs in with correct username + PIN
THEN:
- Session created (role=child, learner_id=students.uuid).
- Client redirects to /placement-test (NOT library).
- Placement test presented (adaptive reading passages, MCQ comprehension).
- On completion: POST /api/placement-result {learner_id, fk_level, raw_scores}.
- INSERT placement_results (learner_id, fk_level).
- UPDATE students.placement_test_completed=true.
- POST /api/v1/bot/:learner_id/placement-result (Learner Bot, X-Internal-Key).
- Learner Bot stores reading_level memory (confidence=1.0).
- Client redirects to /library.
GIVEN child has placement_test_completed=true
WHEN child logs in
THEN: Direct redirect to /library. Placement test NOT shown again.
FAILURE: Placement test API fails mid-way β allow retry from question 1. Do not partial-save. fk_level only written on full completion.
End of document. Version v1.7 β 2026-04-18 19:15 UTC