Appearance
ADR-007: Challenge Timing Recording and Validation
Status
Accepted
Context
Challenges involve two kinds of time data: admin-defined catalog metadata (estimates and optional schedule windows) and per-user progress timestamps logged as the user works through an attempt. Touli must record enough timing data for audit and operations without blocking task completion on user-entered durations. Over-estimation of total challenge time must be caught once, at challenge completion, before points are awarded.
This ADR complements ADR-006, which covers when points are awarded (challenge completion only, not per task).
Decision
Separate timing recording from timing validation.
1. Timing recording (server-logged, no user input)
Record timestamps automatically; do not validate user-entered values at the task level.
| Layer | Fields | When set |
|---|---|---|
Challenge attempt (user_challenges) | acceptedAt, completedAt | Join; successful challenge completion |
Task progress (user_task_progress) | startedAt, completedAt | First task on join; each task on complete; next task on unlock |
Complete Task (POST /challenges/:id/tasks/:taskId/complete) requires only verification evidence (photoUrl, optional description). It updates progress and logs completedAt (and backfills startedAt if missing). No minutes field and no time validation on this step.
2. Timing validation (user-entered, challenge completion only)
Complete Challenge (POST /challenges/:id/complete) is the only step that validates user-entered time.
Rule:
minutes <= challenge.estimatedTime- If
minutesexceedschallenge.estimatedTime, the submission is rejected withaccepted: false,reason: EXCEEDS_ESTIMATED_TIME, andestimatedTimein the response. The client must ask the user to enter a value at or below the estimate. - Rejection is an expected business outcome (HTTP 200), not a hard error. A rejected
challenge_submissionsrow is persisted with zero points. - Up to 3 rejected submissions per attempt (
MAX_FAILED_SUBMISSION_ATTEMPTS); on the 3rd rejection the attempt is locked (CHALLENGE_LOCKED). The user may rejoin to start a fresh attempt.
Server-logged join and task timestamps are not used for this validation check.
3. Catalog timing (admin-defined, separate from validation)
| Entity | Fields | Purpose |
|---|---|---|
challenges | estimatedTime (required), startsAt, endsAt (optional) | Challenge estimate used at completion validation; optional availability window |
challenge_tasks | description, estimatedTime, startsAt, endsAt (optional) | Optional per-task catalog copy and display/schedule metadata |
Only challenges.estimatedTime is enforced at completion. Optional task-level estimatedTime may be shown in API responses (task.estimatedTime ?? challenge.estimatedTime) but is not validated on Complete Task. Catalog startsAt / endsAt on challenges and tasks are stored for admin scheduling; sequential task unlock is not driven by those dates in the current implementation.
4. What we explicitly do not validate
- Per-task entered duration — no user time entry on Complete Task; no comparison of task elapsed time to estimate or to server clocks.
- Elapsed time since join — not compared to submitted minutes at challenge completion (only the challenge
estimatedTimeceiling applies). - Catalog schedule windows —
startsAt/endsAton tasks and challenges are not enforced on join, unlock, or complete in the current backend.
Consequences
- Mobile Complete Task stays simple: photo + optional note; no per-task minutes field.
- Mobile Complete Challenge must handle
EXCEEDS_ESTIMATED_TIME, displayestimatedTime, and show remaining attempts before lock. - Audit trail:
acceptedAt/completedAton the attempt andstartedAt/completedAtper task support operational review without affecting points logic. - Mis-reported total challenge time is corrected at completion (up to 3 tries), not during individual tasks.
- Catalog task/challenge schedule fields remain available for future gating or UI without changing the recording or validation model above.