Participant Journey src/api/participants.py
A participant interacts with AGS through the PWA at /ags. They never see the researcher dashboard. Their entire journey — from first visit to session completion — is designed to be as low-friction as possible for someone who may never have used a smartphone app before.
Enrollment
+254… or equivalent), age group, digital literacy level, prior web use (yes/no), font size preference (normal / large), and a self-efficacy score. These feed directly into the readiness model. The phone number is the only thing they need to remember.consent_recorded = true is written to the database at this point. No telemetry row may be written before this — not even the self-efficacy score from step 3.AGS001, AGS002, and so on. This is generated by a PostgreSQL sequence (ags_participant_seq) so it is race-condition safe even under concurrent enrollments. Participants are encouraged to note it for reference, though they don't need it to return.localStorage. Why 30 days? Older participants may take days or weeks between sessions. Requiring re-login after two hours — standard for web apps — would mean they'd need to re-enter their phone number every time. Thirty days covers a study cohort comfortably while remaining recoverable: if the token expires, they just enter their phone again.Why phone number, not account credentials
The target population frequently doesn't remember passwords, email addresses, or usernames they set up months ago. A phone number is something they always have with them and already know by heart. There's no password to forget, no email to check, no account recovery flow.
PHONE_HMAC_SECRET before the database write. This is one-way: a phone cannot be recovered from its token. The PHONE_HMAC_SECRET must be a separate key from JWT_SECRET — if you rotate the JWT secret (e.g. after a security event), participants' phone-based reauth should not suddenly stop working.
Returning participants
The router guard checks the JWT expiry client-side before any screen renders. If the token has expired and a phone number is stored locally, it attempts a silent reauth — POST /v1/participants/reauth — in the background. The participant sees nothing; they go straight to service selection. Only if reauth fails (wrong phone, unknown number) do they see the sign-in form.
Session lifecycle
StudyConditionConfig, the task template is validated for schema_version, and a Redis context is initialised. The client never sends or sees the study condition.calibration_cleared = true and the full pipeline activates. Why 3? One tap is too noisy (nerves, unfamiliarity with the device). Three gives enough variance to establish a meaningful personal baseline without delaying the session./v1/sessions/{id}/events. The full pipeline runs synchronously (≤ 100ms): signal normalisation → readiness estimation → governance decision → adaptation content resolution. The adaptation decision is returned in the response. Database and Redis writes for history happen asynchronously off the critical path.GET /starting-scenario. The server computes a starting difficulty from the participant's persistent ability estimate, time since last session (gap decay), and today's calibration delta (IKI ratio, error rate, help rate during baseline). The starting difficulty is capped at D3 — D4/D5 risk scenarios are never the first episode. The sim receives an ags-next-scenario postMessage and loads the matching scenario.ags-practice-goal-complete), SessionView calls POST /episodes/complete. The server scores the episode (completion × 0.50 + independence × 0.25 + efficiency × 0.25, with governance penalties for worsened outcome or L4 HANDOFF). The ability estimate is updated via an ELO-inspired formula, and the next scenario is selected from the Zone of Proximal Development (±1 difficulty from current ability). If end-of-episode readiness was LOW or OVERLOAD (below 40), the next difficulty shifts down by 1. The sim receives the next ags-next-scenario. A transition screen appears showing the completion confirmation, then the next task's title and a plain-language difficulty signal ("↑ A bit more challenging" / "Similar difficulty" / "↓ A little easier"). The participant taps Continue, which reveals the scenario intro card for the next episode before the sim becomes interactive.session_done: true is returned. The survey is triggered. An optional survey asks about confidence, how the session felt, and perceived difficulty. Every item is skippable. The confidence score from this survey feeds into the next session's readiness model as the germane load component (GL). For the first session, GL is null and its weight redistributes across IL and EL.PATCH /v1/sessions/{id} writes ended_at in UTC. If the participant abandons, the abandonment step ID and reason are recorded. If they're offline when the session ends, the end event is queued locally and replayed when connectivity returns.Multi-episode session flow
- Session created → Redis context initialised → calibration window (3 events)
GET /starting-scenario→ ability estimate read → starting difficulty selected → sim loads Episode 1- Pipeline events fire normally throughout the episode (readiness, governance, adaptation all active)
- Sim fires
ags-practice-goal-complete→POST /episodes/complete→ performance scored → ability updated → next scenario selected - Sim loads Episode 2 via
ags-next-scenario - Repeat for Episode 3
session_done: true→ survey triggered →PATCH /sessions/{id}closes session
Offline queue
If the participant loses connectivity mid-session, events are queued in localStorage by a service-worker-backed queue. A banner notifies them they're offline and how many events are pending. When connectivity returns, the queue replays automatically. The API accepts queued events idempotently via client_event_id — the same event submitted twice produces only one row.
Token summary
| Token | TTL | Refresh | Env var |
|---|---|---|---|
| Participant JWT | 30 days | Silent — stored phone triggers reauth automatically | JWT_SECRET |
| Researcher JWT | 8 hours | Manual re-login | RESEARCHER_JWT_SECRET |