ADR-0026: Deterministic Daily Codes for Offline Check-in/Check-out
- Status: Accepted
- Date: 2026-07-16
- Deciders: User + agent grill session
- Issues: #144
Context
Issue #144 introduces a check-in/check-out flow with QR codes printed on thermal labels at the church reception desk. The check-in replaces the existing classroom attendance marking (Chamador → receptionist). Each check-in generates a random code that appears on the label and on a parent tear-off stub; at check-out, the receptionist verifies the stub code before releasing the child.
The target church (IJCP) routinely experiences internet outages. Check-in must continue to function when the Cloudflare Worker backend is unreachable. However, the admission code must still be unique per child, per session, per day — two children cannot receive the same code, even if two receptionists are checking in simultaneously from different machines.
Design constraints from the grill session
- Per-session check-in (not per-day). Children attending two Sunday sessions have two independent check-in records.
- Per-session daily code (not per-day). Each session gets a different code.
- QR on the label encodes
{studentId, sessionId, dailyCode}as JSON. - Parent stub shows the code in plain text (no QR).
- Check-out verification: scan QR + type code from parent stub → system validates match.
- Wrong code attempts are audited per-attempt (event-sourced).
- Church configurable: pick-up mode, code attempt limit, emergency override policy.
- Permanent student ID QR ("carteirinha") is a separate future feature.
Decision
Daily codes are deterministic, derived via HMAC, not randomly generated and stored.
daily_code = base32( HMAC-SHA256(church_secret, student_id + session_id + date) )[0:6]The church_secret is a per-church random string stored in church_settings.checkin_secret. The client fetches it once when online and caches it in the browser. Both client and server can compute the same code independently, without a code storage table.
Key properties
| Property | Value |
|---|---|
| Algorithm | HMAC-SHA256, truncated to base32 first 6 chars (uppercase, excluding 0/O/1/I/L/8/B) |
| Secret | Per-church, stored in church_settings.checkin_secret |
| Deterministic inputs | student_id + session_id + date (YYYY-MM-DD) |
| Code length | 6 characters, ~729M combinations (30^6 with ambiguous chars excluded) |
| Expiry | Implicit — date changes, code changes |
| Offline support | Client caches secret; computes code locally with no server call |
| Collision risk | Zero — deterministic, no randomness |
| Security model | Code = possession token. Knowing the derivation scheme doesn't help without the secret. Predictable only to someone with the secret. |
Why not randomly generated codes in D1?
- Offline impossible. Code uniqueness requires a central authority (D1). Without connectivity, two clients could generate the same random code.
- Storage cost. A separate
daily_codestable with one row per child × session × day, pre-generated or created at check-in time, with expiry logic. - Label reprint. If a code is generated server-side and the printer jams, the code already exists — reprinting is safe. But the whole flow depends on a round-trip to D1.
Deterministic codes eliminate the storage problem and work offline with a cached secret. The trade-off is that code disclosure + secret compromise allows an attacker to derive any child's code for any day. We accept this because the threat model is a church reception desk, not a financial system.
Consequences
Positive
- Offline check-in works. The client caches the secret once; all code generation is local.
- No daily_codes table. No pre-generation, no expiry cron, no cleanup.
- Label reprint is trivial. Same inputs → same code. Just re-print.
- Codes are not stored in the database. Even if
check_in_eventsis compromised, the codes are only present for completed CHECK_IN rows — an attacker can't predict future codes without the secret.
Negative
- Secret rotation invalidates all active codes. If the admin rotates the secret, all labels for the current day must be reprinted. Rotation should be a rare, deliberate operation.
- Secret cached client-side. A compromised browser could leak the secret. Mitigated by the fact that the secret is church-specific and only useful within that church's check-in context.
- No per-code expiry. The code is valid for the entire day. There is no "this code was used at 9:00, can't be re-used at 10:00" — but the partial unique index on (student_id, session_id) prevents double check-in.
Alternatives Considered
A — Random codes stored in D1 (daily_codes table)
Pre-generate codes for each child × session at start of day. Check-in activates a pre-existing code. Requires D1 connectivity. Rejected because offline is a hard requirement.
B — Random codes generated client-side with sync
Client generates random 6-char code, syncs to D1. On collision, server regenerates and client reprints. Complex, error-prone, and the label reprint on collision is a bad UX. Rejected.
C — Sequential codes (per-session counter)
CODE-001, CODE-002 per session. Predictable and guessable — a parent could derive their neighbor's code. Rejected.
References
- Issue #144 — Check-in/Check-out with QR codes
- workers/CONTEXT.md — Domain glossary
- app/CONTEXT.md — Frontend domain model
- Requirement IDs: to be linked from SDD