ADR-0042: Pickup Authorization Levels — N1 (enrollment) / N2 (phone OTP) / N3 (temporary, parent-present)
Status: accepted Date: 2026-09-09 Deciders: @barateza Tags: [checkin, pickup, guardian, lgpd, v1, d1, modo-facil, issue-758]
Context
The Modo Fácil reception screens 1.1.5 → 1.1.5.1 (issue #723) must let the reception desk register a new adult authorized to pick a child up while the child is already enrolled. The current flow (ADR-0036, issues #648/#649) is ADMIN-only and always requires a phone OTP; the reception works with students.add, standing up, under queue pressure.
docs/research/responsavel-recepcao-lgpd-2026.md (2026-09) concludes that neither "OTP always" nor "never OTP" is correct: LGPD art. 14 §5 requires reasonable efforts to verify who consents/authorizes, and the industry norm (COPPA "text-plus", Texas §744.2803 photo-at-the-act, NIST SP 800-63 IAL, IEEE P2089.3 trust levels) is verification in layers, by risk. Its §5 left four decisions to this issue (#758/B4): adopt the three levels, decide what the reception may operate, make the adult's birth date optional, and record the adult's own consent (the latter landed in issue #756).
Two levels already exist in code without being named: N1 (the enrollment responsável, auto-added to the closed list — issue #757) and N2 (the OTP-verified adult — issue #649). N3 (a temporary, same-day authorization granted by a verified guardian who is present) does not exist, and the current data model cannot express it: authorized_pickups.user_id is NOT NULL (every entry has an account) and there is no expiry column.
Options considered
(a) Extend authorized_pickups with user_id nullable, authorization_level, authorized_by, expires_at and an inline adult_name. Literal "one list", one gate query. But SQLite cannot drop NOT NULL in place: it needs a full table rebuild (create/copy/drop/rename + index recreation) on D1 — the riskiest migration class — and it permanently weakens the table's invariants (JOIN users reads, idx_authorized_pickups_active_unique, per-row LGPD redaction of the account's name/phone) for a row shape that is not an account-backed permission.
(b) New table pickup_authorizations for N3 grants, authorized_pickups untouched. Two read paths (durable list + active grants) meet in the service layer.
(c) Keep user_id NOT NULL and create a placeholder account (no OTP) for the N3 adult. No rebuild, no union — but it creates an unverified account for a temporary adult, contradicting LGPD art. 6-III minimization and the issue's "sem conta", and pollutes the parent portal with accounts nobody can claim.
Decision
Adopt (b). Three levels, explicitly modeled:
- N1 — Responsável legal (auto, at enrollment). Unchanged: issue #757 creates
users(RESPONSAVEL)+guardians+ an ACTIVEauthorized_pickupsentry during the reception matrícula (1.1.3). Enrollment is the moment of strongest proof (the guardian consents the LGPD acceptance in person). - N2 — New adult verified by phone (1.1.5). Keeps the existing OTP flow (
POST /students/:id/authorized-pickups/add→POST /authorized-pickups/verify-otp). Two changes:- the adult's birth date is optional (
birthDate), stored inusers.birth_datewhen given — minimization (art. 6-III): relationship + photo + phone already disambiguate homonyms, and the wireframe's mandatory field was the only reason it existed; - the route is gated by the
students.addpermission, not by the ADMIN role, so the reception can operate it (acceptance criterion 4).
- the adult's birth date is optional (
- N3 — Temporary authorization of the day (parent-present, no account). A verified N1/N2 guardian present at the desk authorizes a specific adult for a limited window. Recorded in the new table
pickup_authorizations(migration0012):authorized_by_pickup_idNOT NULL references the ACTIVEauthorized_pickupsentry of the authorizing guardian for that same child — the verification basis is structural, not asserted: without an ACTIVE N1/N2 entry there is no N3 (acceptance criterion 1);authorized_by(that entry'suser_id),created_by(the actor/receptionist) andrelationshipare the audit triple required by acceptance criterion 2;valid_from/valid_untilbound the grant. Default window = end of the current UTC day (mirrors the daily checkout token, ADR-0026/SDD-144, which is UTC-deterministic); an explicitvalidUntilsupports the event case, capped at 7 days — longer windows would be a durable permission created through a no-OTP path, which is exactly what N3 is not.statusisACTIVE | REVOKED— expiry is time-based, so nothing has to flip rows;- no OTP: the consenting guardian's verification is presential against already-verified data (the receptionist sees the archived guardian photo, #731) — the COPPA "text-plus" / Texas §744.2803 photograph-at-the-act analogue;
- the adult's phone is optional (minimization). When present it participates in the checkout suffix match; when absent the grant is not suffix-matchable and the release happens through the authorizing guardian (the normal parent-present case).
Gate integration. findAuthorizedPickupBySuffix and validatePickupAdultForStudent (checkinService.ts) consult active grants (status ACTIVE and valid_from <= now < valid_until) as a second source, so an N3 adult can be selected at check-in (1.1.1 dropdown) and matched at check-out while the window is open — and stops matching the moment it closes. Expired or revoked grants are inert everywhere.
Online-only. N3 grants are read and written against the Worker only: no Dexie table, no sync entity type. A temporary authorization is inherently a live, minutes-old record and v1 is online-only (ADR-0039) — an offline reception simply cannot create or honour one, and falls back to the durable closed list (whose entries are cached).
Permission plumbing. The registry gains a permission-based auth level (auth: "permission" + permission: "students.add"), mapped by router.ts to authAndPermission, so N2/N3 routes are reachable by every role that holds students.add (ADMIN, CADASTRO, ADMINISTRATIVO_KIDS) and invisible to CHAMADOR. The existing ADMIN-only list/deactivate/erase routes stay ADMIN-only.
Consequences
- The reception can register adults end-to-end (N2 with OTP, N3 parent-present) under
students.add; ADMIN remains required for the destructive operations on the closed list. authorized_pickupskeeps its invariant "every entry has an account" — every existing read, index and LGPD-redaction path stays valid, and no table rebuild is needed.- N3 grants are inert after
valid_untilwithout any job: expiry is evaluated at read and at the gate. The rows stay for audit (they carry only a name, an optional phone and relationships). - The "list" for the reception is two reads: the durable closed list (
GET /students/:id/authorized-pickups) plus the currently-valid grants (GET /students/:id/pickup-authorizations, ACTIVE and inside the window only — expired grants are never offered in the 1.1.1 dropdown). The frontend (#723) merges them. - Revocation is a soft, justified operation (
POST /pickup-authorizations/:id/revoke, eventAUTHORIZED_PICKUP/REVOKED), reusing the "deletions require justification" rule. Hard LGPD erasure of a grant row is deferred (same pattern aseraseAuthorizedPickup, which already redacts the sharedAUTHORIZED_PICKUPevent payloads): grants are day-scoped and minimal, and the per-row purge lands with the retention job. - Grants carry no uniqueness constraint: registering the same adult twice in the same window creates a second, harmless, audited grant that expires with the day (each grant is a separate act of authorization — unlike the closed list, where a duplicate would be a permission error).
- Snapshot semantics at the gate: an in-window grant keeps working even if the authorizing guardian's own closed-list entry is later deactivated — the grant is an independent record of an act that already happened, and its window closes on its own. The basis is enforced at creation (
authorized_by_pickup_idmust be ACTIVE then). Cancelling a grant early is the explicitrevokepath; cascading the base entry's deactivation into live grants is deliberately out of scope (it would need a rule for which of the child's grants to kill and an audit story). - The explicit deny-list / court-order restriction remains deferred (ADR-0036 §7); the level model does not block it.
- The OTP delivery gap (no SMS channel — code echoed only in
AUTH_MODE=dev) is pre-existing (#649) and unchanged by this ADR.
References
- Issue: #758 (B4) — parent #719 (SDD-241 Modo Fácil); frontend #723 (1.1.3/1.1.5)
- Research:
docs/research/responsavel-recepcao-lgpd-2026.md§4–§6 - ADR-0036 (closed guardian list), ADR-0026 (deterministic daily codes), SDD-144, SDD-241
- Migrations:
0012_pickup_authorizations.sql; tablesauthorized_pickups,pickup_otp_requests - Code:
workers/src/services/temporaryPickupService.ts,workers/src/services/authorizedPickupService.ts,workers/src/services/checkinService.ts,workers/src/routes/pickupAuthorizations.ts,workers/src/routes/authorizedPickups.ts,workers/src/router.ts,workers/src/routes/registry.ts