# 0007. Staff check in on the school app

**Date**: 2026-10-31
**Updated**: 2026-11-02 (GPS wait: one watch until accuracy is within max, then mint that point. Confirm face: two challenge moves only.)
**Status**: In Progress
**Amends**: [0006](../0006-face-enrollment/index.md) nav group (adds Check in, Enrollments icon becomes `icon/newstudent.png`)

## Summary

Logged in staff, plus admin, director, and backend, open a Check in page on the school app. A shared gate stays signed in as an attendant. Each person waits while the phone watches GPS until the accuracy (how many meters the reading might be off) is within the school max, types a phone, then a live face is checked on the server against the enrolled portrait. There is no public anonymous page, no per phone cap, and no per IP cap. The live JPEG is never stored.

## Requirements

**User stories**:
- As a staff member (or an admin, director, or backend user running a gate), I want to clock enrolled staff in and out at school so presence is recorded without each person signing in as themselves.
- As a person in the queue, I want a success card with a masked name, time, and place so I know the clock worked, then the next person can start.
- As the school, I want the server to decide match, place, and time so a kiosk browser cannot fake a clock.

**Acceptance criteria** (the contract, each criterion is IDed and independently checkable):
- **AC-1**: A user whose session `table_name` is `staff`, `admin`, `director`, or `backend`, or whose `$info['status']` is `admin`, can open `app/admin/staff_attendance_checkin.php`. Any other logged in role that hits that URL is sent to the app home. Dead session GET uses `authenticate.php` (login redirect). Dead session AJAX is HTTP 401 `{status:error, msg:unauthorized}`. The attendant does not need a current enrollment. The person on the phone must be `staff.activation = 'activated'` with a current enrollment.
- **AC-2**: Staff (session `table_name` `staff`) get a Staff attendance nav group that contains only Check in. Admin, director, backend, and `$info['status']` `admin` keep that group and gain Check in as a fourth tile. Check in: `href` `staff_attendance_checkin.php`, icon `icon/clock-in.png`, subtitle Clock in and out at school. Enrollments switches to `icon/newstudent.png` and keeps subtitle Faces for check in. Locations and Settings stay as spec 0003. There is still no hub page and no Photos tile. This AC amends spec 0006 AC-2 group membership.
- **AC-3**: The page is a focused form (not Pattern C). Wrapper `md-page staff-attendance-checkin-page`. Full app chrome including bottom nav. Hide `.page-title-clear` from the html/body app class, same as other attendance pages. Title Check in. In and Out are two buttons in a timeline sort strip (`md-timeline-sort-strip` / `md-timeline-sort-stat`, `role="toolbar"`), always visible while GPS, phone, and camera run. Default tab is In. Out is hidden when printed `allow_checkout` is 0. Switching tabs drops the client token, closes the sheet, restores the phone field to its initial autofill (or empty), and starts GPS again. GET prints JSON for page JS: `csrf_token`, `max_gps_accuracy_m`, `allow_checkout`, `has_active_location`, `photo_configured`, `match_configured`, `school_ready`, `autofill_phone` (display string or empty).
- **AC-4**: After In or Out, the client calls `navigator.geolocation.watchPosition` with `enableHighAccuracy: true` and `maximumAge: 0`. Do not set the Geolocation `timeout` option. Store the return value as module `watchId` (`null` when idle). A page timer of 15000 ms starts when `watchPosition` is called. Clear that timer on a good fix, on `PERMISSION_DENIED`, on any reset that leaves GPS, and before starting a new watch. On each fix, ignore the callback if `watchId` is already `null` or `gpsGood` is already set. If `coords.accuracy` is a finite number and `<= max_gps_accuracy_m` (equal is allowed), call `clearWatch`, set `watchId` to `null`, store `gpsGood` as `{latitude, longitude, accuracy_m}` where `accuracy_m` is `Math.round(coords.accuracy)`, and continue. Do not store a coarse point. Missing `navigator.geolocation`, or `PERMISSION_DENIED`, shows `Location access is blocked. Check the browser permission.` and stays on GPS. Other watch errors keep watching until the wait budget ends. If the wait budget ends with no good fix, call `clearWatch`, set `watchId` to `null`, and show title `Location is too coarse` plus `Location accuracy is too low. Move closer to open sky and try again.` and stay on GPS. A tap on `#sac-gps` (including during an active watch) calls `clearWatch`, clears the timer, and starts a new watch with a fresh 15 s budget. If `has_active_location` is false, after a good GPS read show `Check in is not available.` and do not show the phone field. On phone submit, send the stored `gpsGood` point to mint. Do not call geolocation again at mint. If `gpsGood` is missing, stay on GPS and start a watch. Keep `gpsGood` on liveness Step Back (re-mint uses the same point). Clear `gpsGood` when GPS starts again (tab switch, idle, success reset, fail restart to GPS, phone Step Back). Call `clearWatch` and set `watchId` to `null` before a new watch, on tab switch, idle reset, success reset, Step Back to GPS, and when leaving the GPS step. Do not keep `watchPosition` running on the phone step, the liveness sheet, or the success card. Client `max_gps_accuracy_m` is `Number(page.max_gps_accuracy_m || 50)`. Settings already keep 10 to 200 on save.
- **AC-5**: Phone is always a typed field (`type="tel"`). When `table_name` is `staff` and `$info['staff_id']` is a positive int with a current enrollment, `autofill_phone` is `zap_display_ng_phone` of that row’s `phone_normalized` (attendance identity, not a later edit of `staff.phone_number`). Admin, director, and backend get an empty field. Unenrolled staff, missing `$info['staff_id']`, or `staff_id` 0 get an empty field. Mint POST `action=mint` with `csrf_token`, `phone`, `latitude`, `longitude`, `accuracy_m`. Client does not send `staff_id`, `location_id`, or intent. Invalid phone (empty `zap_normalize_ng_phone`) is HTTP 200 `{status:error, msg:'That phone number is not valid.'}` and logs `fail` / `invalid_phone` with `phone_normalized` NULL, `staff_id` NULL.
- **AC-6**: Current enrollment means `status = 'current'` and `current_phone` equals `phone_normalized` and `current_staff_id` equals `staff_id`. Pending or final `photo_object_key` both count. Mint check order, stop at the first fail, each fail still inserts an attempts row: (1) invalid phone (AC-5); (2) no current enrollment, or the `staff` row for that `staff_id` is missing: generic `We could not complete this.` and `fail` / `not_enrolled`, `staff_id` NULL; (3) `staff.activation` is not `activated`: same generic line and `fail` / `inactive_staff`, `staff_id` set; (4) accuracy worse than `max_gps_accuracy_m`: `bad_gps` specific msg from AC-4 (`accuracy_m` does not widen or shrink `radius_m`); (5) zero active locations: `Check in is not available.` and `fail` / `no_locations`; (6) geofence: `sac_distance_m` is haversine from POST latitude/longitude to that row’s `latitude` and `longitude`, Earth radius 6371000 m. Inside when `distance_m <= radius_m` (boundary counts). Several matches: smallest `distance_m`, tie lower `location_id`. None: `You are not inside an attendance place.` and `fail` / `outside_location`. Duplicate current phone (unique broken) uses the same generic line and `fail` / `duplicate_phone`, `staff_id` NULL, and is treated as step 2. Success inserts a token: `token_hash` SHA-256 hex of `bin2hex(random_bytes(32))`, GPS and `location_id` and `staff_id` and `phone_normalized` stored, `expires_at` = PHP Lagos now plus 10 minutes, `consumed_at` NULL. JSON `{status:success, csrf_token, token, expires_in:600, masked_name}` with `sac_mask_name` from AC-11. No `staff_id`, no `location_id`, no `location_name`. Mint success attempt: `outcome` `mint_ok`, `reason_code` `ok_mint` (new strings on existing VARCHAR columns, no ALTER), GPS, `location_id`, `staff_id`, phones set. Earlier unused tokens for that phone are left.
- **AC-7**: After mint, open a bottom sheet with the liveness module from spec 0004 (`app/admin/scripts/staff-attendance-liveness.js` absolute `/app/admin/scripts/` URL and `?v=` `filemtime` via `get_v()`). POST `challenges` on this check in URL calls `sap_pick_challenges`. Prompts, speech, cue tone, timeouts, camera blocked copy, and JPEG encode match spec 0004 AC-4. PHP does not score gestures. There is no file input. Step Back on the sheet returns to the phone field, drops the client token, and keeps the typed phone.
- **AC-8**: POST `action=consume` is multipart: `csrf_token`, `token` (64 hex), `intent` (`check_in` or `check_out`), file field `image`. Unknown `intent` is HTTP 200 `{status:error, msg:'We could not complete this.'}` and `fail` / `token_bad` with no consume. Missing image, `UPLOAD_ERR_*`, size 0, size over 5242880, or JPEG magic not `0xFF 0xD8 0xFF` is `That photo is not a valid JPEG.` and `fail` / `invalid_image`. PHP must not store that frame in B2. Host PHP upload limits below 5 MB use the same JPEG msg and `invalid_image`. Load token by `hash('sha256', $token)`. Unknown token: `fail` / `token_bad`, `staff_id` NULL, do not set `consumed_at` (no row). Used token: AC-11 replay or `token_used`. `expires_at` before PHP Lagos now is `This check in timed out. Start again.` and `fail` / `token_expired` (set `consumed_at`). Then `saf_detect` on `captured_bytes`. Zero faces: generic line, `fail` / `no_face`. Two or more: generic line, `fail` / `many_faces`. Then HEAD the current enrollment `photo_object_key` (pending or final). Missing or bad type or size: `That photo is missing.` and `fail` / `photo_missing`. Then `saf_verify` with `reference_url` (`sap_presign_get` 300 seconds) and `captured_bytes`. `match` false: generic line, `fail` / `no_match`. Spec 0005 typed statuses: `not_configured` msg and `fail` / `not_configured`; `timeout` msg and `fail` / `timeout`; `unavailable` msg and `fail` / `unavailable`; `no_face` on verify: generic line, `fail` / `no_face`; other `error`: spec 0005 unusable result msg and `fail` / `match_error`. `face_score` uses spec 0005 `similarity` (`round((float), 4)`). On any consume outcome except unknown token and lock not held, set `consumed_at` and insert an attempts row. `photo_object_key` on the attempt stays NULL. Templates are not read or written. New `reason_code` strings (`invalid_image`, `photo_missing`, `lock_busy`, `not_configured`, `timeout`, `unavailable`, `match_error`, `ok_mint`) and `outcome` `mint_ok` are VARCHAR values, not an ALTER.
- **AC-9**: `intent=check_in`: if that `staff_id` has any day with `check_out_at` IS NULL, do not INSERT, `fail` / `already_in`, msg `A check in has already been done. Check out first.` Else INSERT a day: `attend_date` and `check_in_at` from PHP Lagos now, `location_id` and `location_name` from the token’s place (name snapshot at consume), `face_score` from verify `similarity`, `is_late` 1 if TIME(`check_in_at`) is after settings `late_after` else 0. Unique `(staff_id, attend_date)` loser is `already_in` with that same msg. Do not show late or `face_score` in JSON.
- **AC-10**: `intent=check_out`: if settings `allow_checkout` is 0, `fail` / `checkout_off`, msg `Check out is turned off.` Latest open day is `check_out_at` IS NULL for that `staff_id`, ordered by `check_in_at` DESC, then `attendance_id` DESC, limit 1. No day row at all: `fail` / `no_checkin`. A day exists and the latest one is already closed: `fail` / `already_out`. Both use msg `There is no open check in to close.` Elapsed minutes are `(strtotime(now) - strtotime(check_in_at)) / 60` as a float in PHP. Fail `too_soon` when that value is less than or equal to `min_checkout_minutes` (INT, never null on settings `id = 1`). Msg `It is too soon to check out.` (30 means the 31st minute is first allowed). Else UPDATE that latest open row’s `check_out_at`. Check out never INSERTS a day. Success card `location_name` is that day row’s stored `location_name` (the original check in place), not a rename of the token’s place.
- **AC-11**: Consume success JSON: `{status:success, csrf_token, intent, masked_name, time, location_name, attend_date}`. `masked_name` is first letter of `first_name` uppercase, then `. `, then `last_name` (example `A. Okonkwo`). Empty first name uses last name only. Both empty uses `Staff`. `time` is PHP Lagos `j M Y, H:i`. Check in `location_name` is `locations.name` for `token.location_id` at consume (also stored on the new day). Check out `location_name` is the open day’s stored snapshot (AC-10). No `staff_id`, no `face_score`, no late, no portrait URL. Show that card about 4 seconds, then return to GPS on the current tab, drop the token, close the sheet, restore phone to initial autofill or empty. Replay: if `consumed_at` is set and equals that staff’s `check_in_at` or `check_out_at` (same PHP `$now` string written on the winning consume), return this success JSON built from that day row, ignore POST `intent`, and do not write again. If `consumed_at` is set and no clock column matches (a prior fail consume), `fail` / `token_used`, msg `This check in timed out. Start again.` Do not replay a fail as success.
- **AC-12**: Fail consume or fail mint shows the msg, then starts over from GPS on the current tab (drop token, close sheet, restore phone to initial autofill or empty), except GPS denied and coarse GPS stay on the GPS step (AC-4) and invalid phone stays on the phone field. Generic line `We could not complete this.` is required for `not_enrolled`, `duplicate_phone`, `inactive_staff`, `no_match`, `no_face`, `many_faces`. Never name another staff member.
- **AC-13**: If B2 is not configured or matching is not configured or `school_ready` is false, show the same banner sentences as spec 0006 AC-14 (`The photo store is not configured.` / `The matching service is not configured.` / `This school is missing from the switch list.` concatenate with a space when both store and matching apply). Do not mint and do not open the camera. Missing 0001 tables: `The attendance tables are missing.` hide mint and camera. Missing settings `id = 1`: `The settings row is missing.` hide mint and camera. These are not the fatal retry screen.
- **AC-14**: AJAX lives on `staff_attendance_checkin.php` via `admin/includes/staff_attendance_checkin_ajax.php`. Include the ajax file after `init.php` and before `authenticate.php`. Engine functions live in `admin/includes/staff_attendance_checkin_engine.php`, take `mysqli` plus a `$ctx` array, return arrays, never echo, never read `$_POST` or `$_GET`. Access is `staff_attendance_checkin_can_access` in `admin/includes/staff_attendance_access.php` (staff plus the spec 0006 admin set). Do not reuse `staff_attendance_can_access` alone (that helper has no staff). JSON uses `json_encode(..., JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)`. Success `{status:success}` plus a fresh `csrf_token`. CSRF is `$security->csrf_token_is_valid()` and `request_is_same_domain()` (scheme, host, port; prefer `HTTP_REFERER`; Origin when Referer is empty and Origin is not empty or the string `null`; do not read `X-Forwarded-Proto`). Page JS `fetch` uses `referrerPolicy: 'origin'`. Do not call `check_csrf_token()`. CSRF or wrong role AJAX is HTTP 403 `{status:error, msg:forbidden}`. Validation errors are HTTP 200 `{status:error, msg}`. `ip` is `$_SERVER['REMOTE_ADDR']` only. `user_agent` truncated to 512. There is no per phone rate limit and no per IP rate limit. Do not emit `reason_code` `rate_limited`. Never log image bytes, data URIs, `face_api_key`, raw tokens, or full `get_url`.
- **AC-15**: Consume takes `GET_LOCK` name `sac_` plus sanitized session `db_name` plus `_` plus `token.staff_id` (sanitize like spec 0006 AC-9) with timeout 40 seconds, then holds it through HEAD, `saf_detect`, `saf_verify`, and the day write. Release after COMMIT or rollback. If the lock is not held, `Please try again.` and `fail` / `lock_busy`, do not set `consumed_at`, still insert an attempts row. Unique `(staff_id, attend_date)` is the second lock. Location must still be `is_active = 1` at consume or `fail` / `outside_location` (set `consumed_at`). Current enrollment for `token.staff_id` must still exist or `fail` / `no_match` after setting `consumed_at`.
- **AC-16**: Idle 60 seconds with no tap and no successful GPS on the GPS or phone step resets to GPS on the current tab, drops the token, closes the sheet, restores phone to initial autofill or empty. Pause this timer while the liveness sheet is open (spec 0004 already times out a slow liveness). Step Back on phone returns to GPS (clear typed phone to initial autofill or empty, drop token). Chrome back leaves the page. Page JS is `app/admin/scripts/staff_attendance_checkin.js` with `?v=` `filemtime` via `get_v()`.
- **AC-17**: Engine tests live in `admin/includes/staff_attendance_checkin_engine_test.php` and run as `php admin/includes/staff_attendance_checkin_engine_test.php`. Cover at least: phone normalize and invalid phone attempt shape, mask name (initial, empty first, both empty), nearest fence plus tie on `location_id`, boundary `distance_m == radius_m` passes, accuracy equal to max passes and greater fails (radius unchanged by accuracy), mint JSON has `masked_name` and no `staff_id`, mint success attempt `mint_ok` / `ok_mint`, consume intent `check_in` refused when an open day exists, check out `no_checkin` vs `already_out`, `too_soon` at exactly `min_checkout_minutes`, checkout off, token replay success ignores intent, fail consume replay is `token_used`, detect then verify order (inject `$ctx['saf']` and `$ctx['sap']`), attempt `photo_object_key` NULL, no `rate_limited` path, lock name includes sanitized `db_name`, lock_busy does not set `consumed_at`. Page JS string tests: the file contains `watchPosition` and `clearWatch`; the file does not contain `getCurrentPosition`. Live B2 and live Node are not required.

## Decision

**Chosen option**: Option 1: Logged in attendant page, mint then consume, no rate limits. GPS amendment: watch until accuracy is within max, mint that one point.

A signed in staff member or admin runs the gate. Identity is the typed phone. GPS is watched once until `coords.accuracy` is within `max_gps_accuracy_m`, then that stored point is minted. Spec 0001 tokens, days, and attempts stay the store. The live frame is multipart bytes, detected and verified on the server, then discarded. In and Out are tabs. The server still enforces open day, wait minutes, and unique staff plus date.

**Implementation skills**: `zap-app` (local, `.cursor/skills/zap-app/`) · `frontend-design` (local, `.cursor/skills/frontend-design/`) · `b2-cloud-storage` (`backblaze-labs/claude-skill-b2-cloud-storage`, `.cursor/skills/b2-cloud-storage/`)

## Feature design

**Data model sketch**:

No `ALTER`. Target rows stay spec 0001 tables in the school database already selected by `$_SESSION['db_name']`. Ids that point at `staff.staff_id` or `staff_attendance_locations.location_id` are logical (no MySQL foreign keys). Writers supply `DATETIME` values already in `Africa/Lagos` from PHP. Do not use MySQL `NOW()`.

| Piece | Type | Required | Notes |
|---|---|---|---|
| `staff` | existing row | read | `staff_id`, `first_name`, `last_name`, `activation`. Unchanged. |
| `staff_attendance_enrollments` | existing current row | yes to mint | Lookup by `phone_normalized`. Consume reloads by `token.staff_id`. `tested_at` is ignored. |
| `phone_normalized` | CHAR(13) | yes on token | From `zap_normalize_ng_phone` of POST `phone`. |
| `staff_attendance_locations` | existing row | yes to mint | `is_active = 1`. Haversine in this engine. |
| `staff_attendance_settings` | one row `id = 1` | yes | `max_gps_accuracy_m`, `allow_checkout`, `min_checkout_minutes`, `late_after`. |
| `staff_attendance_tokens` | insert at mint | yes | `token_hash` CHAR(64) unique. Intent is not a column. |
| `staff_attendance_days` | insert or update at consume | on success | Unique `(staff_id, attend_date)`. Check out updates latest open row. |
| `staff_attendance_attempts` | insert only | every mint and consume | `photo_object_key` NULL. Extra strings: `outcome` `mint_ok`; `reason_code` `ok_mint`, `no_locations`, `invalid_image`, `photo_missing`, `lock_busy`, `not_configured`, `timeout`, `unavailable`, `match_error`. Never `rate_limited` on this page. |
| `staff_attendance_templates` | unused | no | Do not SELECT or write. |
| Live JPEG | request bytes | consume only | Multipart `image`. Discarded after detect and verify. |

Relationships: one current enrollment per phone. One token to one staff and one location. One staff to many days, at most one row per Lagos date. Attempts optionally point at staff and location. Session user is the attendant, not stored on attempts (no column).

**State transitions**:
- Tab In or Out (client) → GPS watch (until accuracy is within max, or 15 s) → phone → minted token (unexpired, `consumed_at` NULL) → liveness sheet → consume sets `consumed_at` (success or fail) or expiry.
- Tab switch or idle or fail (except GPS stay, invalid phone stay) → `clearWatch`, drop client token; server row expires on its own.
- Day: none → check in INSERT (`check_out_at` NULL) → check out UPDATE sets `check_out_at`. A second check in while any open day exists is refused.
- Attempt: insert only.

**API surface**:

Page GET `app/admin/staff_attendance_checkin.php`. AJAX POST on that same URL. Node and B2 stay behind `saf_*` and `sap_*`.

| Action | Method | Key inputs | Key outputs | Auth | Key errors |
|---|---|---|---|---|---|
| Page | GET | session | form, In/Out strip, csrf, config JSON, autofill, liveness sheet mount | AC-1 | redirect home |
| challenges | POST | csrf_token | challenges[] of two distinct moves, csrf_token | AC-1 | 401, 403, config banners |
| mint | POST | csrf_token, phone, latitude, longitude, accuracy_m | token, expires_in 600, masked_name, csrf_token | AC-1 | invalid_phone, not_enrolled, inactive_staff, bad_gps, no_locations, outside_location |
| consume | POST multipart | csrf_token, token, intent, image | intent, masked_name, time, location_name, attend_date, csrf_token | AC-1 | token_bad, token_used, token_expired, invalid_image, no_face, many_faces, photo_missing, no_match, already_in, too_soon, checkout_off, no_checkin, already_out, lock_busy |

Private engine names `/develop` should keep: `sac_page_config`, `sac_mint`, `sac_consume`, `sac_mask_name`, `sac_distance_m`, `sac_pick_location`, `sac_lock_name`, `sac_log_attempt`, `sac_replay`. Access `staff_attendance_checkin_can_access`. Reuse `sap_pick_challenges`, `sap_presign_get`, `sap_object_key_allowed`, `saf_detect`, `saf_verify`, `zap_normalize_ng_phone`, `zap_display_ng_phone` (copy or shared include, do not `require` `staff_list.php`). Optional `$ctx['sap']` and `$ctx['saf']` override those helpers in tests. Haversine uses Earth radius 6371000 meters.

**Value sourcing**:

| Action | Value produced / displayed | Source |
|---|---|---|
| Page | which school | `$_SESSION['db_name']` already selected |
| Page | who may open | `$_SESSION['table_name']` plus `$info['status']` via `staff_attendance_checkin_can_access` |
| Page | `max_gps_accuracy_m`, `allow_checkout` | settings `id = 1` |
| Page | `has_active_location` | `COUNT(*)` of locations with `is_active = 1` greater than 0 |
| Page | `photo_configured`, `match_configured` | spec 0004 / 0005 config helpers |
| Page | `school_ready` | `sap_school_id` resolved from the switch list, first `db_name` match |
| Page | `autofill_phone` | current enrollment `phone_normalized` for `$info['staff_id']` when `table_name` is `staff`, run through `zap_display_ng_phone`; else `''` |
| Page | In vs Out tab | client; Out hidden when `allow_checkout` is 0 |
| GPS | latitude, longitude, accuracy_m | First `watchPosition` fix whose `coords.accuracy` is `<= max_gps_accuracy_m`, stored as `gpsGood`. `accuracy_m` is `Math.round(coords.accuracy)`. Mint POST copies that stored point. No second geolocation read. |
| Mint | `phone_raw` | POST `phone`, trim, max 32 chars |
| Mint | `phone_normalized` | `zap_normalize_ng_phone` of that string |
| Mint | current enrollment | `status = 'current'` and shadow uniques set, `phone_normalized` match |
| Mint | `staff_id` | that enrollment’s `staff_id`; missing `staff` row is `not_enrolled` |
| Mint | `distance_m` | `sac_distance_m` haversine from POST lat/lon to `locations.latitude` / `longitude` |
| Mint | `location_id` | nearest containing active place (AC-6); `accuracy_m` does not change `radius_m` |
| Mint | success attempt | `outcome` `mint_ok`, `reason_code` `ok_mint`, GPS and phones copied from the mint POST / token |
| Mint | raw `token` | `bin2hex(random_bytes(32))` |
| Mint | `token_hash` | `hash('sha256', $rawHex)` |
| Mint | `expires_at` | PHP Lagos now plus 10 minutes |
| Mint | `masked_name` | `staff.first_name` / `last_name` via `sac_mask_name` |
| Mint | attempt `ip` / `user_agent` | `REMOTE_ADDR`, `HTTP_USER_AGENT` truncated 512 |
| Challenges | two moves | `sap_pick_challenges` |
| Consume | intent | POST `intent` `check_in` or `check_out` |
| Consume | GPS on attempt | copy from the token row, not a new client point |
| Consume | face count | `saf_detect` `exactly_one` |
| Consume | `match`, `similarity` | `saf_verify`; `match` is the Node boolean |
| Consume | `face_score` on the day | verify `similarity` rounded as spec 0005 |
| Consume | `is_late` | TIME(`check_in_at`) after settings `late_after` (`TIME NOT NULL` on `id = 1`) |
| Consume | `check_in_at` / `check_out_at` / `attend_date` | PHP `Africa/Lagos` now / date of that now |
| Consume | check in `location_name` | `locations.name` for `token.location_id` at consume, stored on the new day |
| Consume | check out `location_name` | existing open day’s `location_name` column |
| Consume | success `time` | PHP Lagos `j M Y, H:i` of the clock just written |
| Consume | success `attend_date` | the day row’s `attend_date` (`Y-m-d`) |
| Consume | latest open day | `check_out_at` IS NULL, `ORDER BY check_in_at DESC, attendance_id DESC LIMIT 1` |
| Consume | too_soon elapsed | `(strtotime(now) - strtotime(check_in_at)) / 60` compared to `min_checkout_minutes` |
| Replay | success JSON | day row whose `check_in_at` or `check_out_at` equals `token.consumed_at`; ignore POST intent |
| Replay | fail used token | `token_used`, no success JSON |
| Idle / reset | phone field | initial `autofill_phone` or empty, never the last stranger’s number |

**Key invariants**:
- Session user is never the clock identity. Phone is.
- Client never chooses `staff_id` or `location_id`.
- Intent lives on consume POST only. Tab switch must not consume the old token.
- A token is minted only after enrollment, accuracy, and an active containing place.
- Mint GPS is the stored `gpsGood` point from the GPS step, not a new client read.
- `watchPosition` runs only on the GPS step and is cleared as soon as a good fix arrives or the 15 s wait ends.
- `watchId` is `null` whenever a watch is not running. Late callbacks after `clearWatch` are ignored.
- Coarse fixes are never stored. `gpsGood` is set only on a fix within max.
- Consume always sets `consumed_at` except unknown token (`token_bad`) and lock not held (`lock_busy`).
- Replay is the only path that returns success for a used token. A fail consume is `token_used` on retry.
- GET_LOCK waits 40 seconds and is held through HEAD, detect, verify, and the day write.
- Unique `(staff_id, attend_date)` plus GET_LOCK make one success check in per staff per Lagos date under two busy gates.
- Check out updates the latest open day, which may be yesterday.
- Live JPEG never becomes a B2 object.
- `tested_at` does not gate mint.
- No phone bucket and no IP bucket.

**Security model**:
- Personal data: phone, GPS, IP, user agent, masked name, live face bytes in request memory, enrollment portrait via short GET URL. Treat as biometric plus contact data. Product bar is NDPR style. This is not a completed legal review.
- Not public. Login required. Students and parents are sent home.
- Tenant is the session school database.
- Mint `masked_name` confirms an enrolled phone to anyone who can open this page. Engineer accepted that leak. Generic msgs still hide not enrolled vs no match vs inactive.
- CSRF plus same origin on every POST.
- Do not read `X-Forwarded-For` or `X-Forwarded-Proto`.
- Readers of GPS, IP, user agent, and face scores: later admin report, not this page.
- GPS lat, lon, and `accuracy_m` are client supplied. The server only checks the number against `max_gps_accuracy_m` and the fence. A client can send a fake accuracy. That is inherent to browser geolocation on this page.

**Configuration required**:
None new. Reuse spec 0004 B2 keys and spec 0005 Face API keys in `private/includes/ask_zap.php`.

**Critical test scenarios** (each maps to an acceptance criterion in ## Requirements):
- Happy path: logged in staff, In tab, GPS watch until accuracy is within max inside a place, typed enrolled phone, mint uses the stored point (no second geolocation read), liveness, consume match, day row plus success card, verifies **AC-3**, **AC-4**, **AC-5**, **AC-6**, **AC-7**, **AC-8**, **AC-9**, **AC-11**
- Failure case: Check in while an open day exists returns the already in warning and no second day row; two gates racing unique plus lock, verifies **AC-9**, **AC-11**, **AC-15**
- Auth/permission: a parent GET is sent home; dead session POST is HTTP 401; staff can open Check in; a student cannot, verifies **AC-1**, **AC-14**
- Matching down: banner, no mint, verifies **AC-13**
- Token retry after a lost success JSON replays the card, verifies **AC-11**
- No rate limit: a sixth mint for the same phone inside 10 minutes still runs (not `rate_limited`), verifies **AC-14**

## Build plan

Tracer Bullet: each slice is a real path through storage, server, and UI, just narrow. No schema migration.

1. [x] Page shell, access helper, nav tiles (staff group plus admin fourth tile and Enrollments icon), config JSON, In/Out strip, GPS then phone fields, satisfies **AC-1**, **AC-2**, **AC-3**, **AC-4**, **AC-13**, **AC-16**
2. [x] Mint into `staff_attendance_tokens` plus attempts (phone, GPS from the client, nearest fence, masked name, no rate limit), satisfies **AC-5**, **AC-6**, **AC-12**, **AC-14**, **AC-17**
3. [x] Liveness sheet, consume check in (multipart, detect then verify, day INSERT, success card, 4 second reset), satisfies **AC-7**, **AC-8**, **AC-9**, **AC-11**
4. [x] Check out, already in, too soon, checkout off, lock, replay, idle, engine tests, satisfies **AC-10**, **AC-11**, **AC-12**, **AC-15**, **AC-16**, **AC-17**
5. [x] One GPS watch until accuracy is within max, reuse that point at mint, `clearWatch` on leave, update the JS string tests and `verify.md`, satisfies **AC-4**, **AC-12**, **AC-16**, **AC-17**

## Consequences

**Positive**:
- A shared gate can clock many people without a public anonymous URL.
- Two busy devices cannot double clock the same staff date.
- Live frames never sit in B2.
- A kiosk on one IP is not queued by a cap.
- Watching until accuracy is within max uses the later GPS lock instead of the first coarse wifi or cell fix.
- One stored point at mint avoids a second `getCurrentPosition` that can fail after the first read passed.

**Negative / tradeoffs**:
- The kiosk dies when the attendant session expires.
- Anyone with this page can learn which phones are enrolled (mint returns a masked name).
- No rate limit means a stolen staff session can hammer Node until the host stops it.
- Explicit In and Out can disagree with “the server would have picked the other direction”; the server refuses rather than switching.
- Fail photos are still absent, so the later report cannot show a fail frame.
- Staff autofill on an attendant who is enrolled shows that attendant’s number after every reset; the next person must overwrite it.
- A person who walks after a good GPS fix still has up to the idle 60 s on the phone step before GPS runs again. The token stores that earlier point.
- A watch that never gets a fine enough fix in 15 s shows coarse even when the device might lock a second later. A tap on the GPS panel starts another watch.

**Neutral**:
- `reason_code` `rate_limited` stays in spec 0001 and is unused here.
- Student Event clock (`events.php`) stays a separate tile.
- Spec 0006 Enrollments icon changes when this ships.

## Follow-up

- [ ] Attendance report (scope feature 8) still needs its own spec. It will not have fail JPEGs from this page.
- [ ] Live check in board (scope feature 9) still needs its own spec.
- [ ] Kiosk session keep alive: PHP session expiry will stop a gate that stays open all morning. Decide a keep alive or a dedicated kiosk login later.
- [ ] A public logged out check in page is out of this spec. Do not add one without a new decision.
- [ ] Spec 0001 offered an attempts `(ip, created_at)` index for an IP cap. Not added here.
- [ ] NDPR or other legal review of live face bytes in request memory is not done here.
- [ ] Matching service API key on the public Node origin remains a spec 0005 follow-up.

## Rationale

Reasoning and options: see [rationale.md](rationale.md).
