# Verify: staff check in · spec 0007 · updated 2026-11-02 (Confirm face two moves)
_Steps derived from spec 0007 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._

## UI / manual
- [ ] Sign in as staff, open `app/admin/staff_attendance_checkin.php`, stay on Check in. Sign in as a parent and hit the same URL, land on app home. → AC-1
- [ ] Dead session GET redirects to login via `authenticate.php`. Dead session POST `action=mint` returns HTTP 401 `{status:error, msg:unauthorized}`. CSRF fail returns HTTP 403 `{status:error, msg:forbidden}`. → AC-1, AC-14
- [ ] Staff nav group Staff attendance contains only Check in (`staff_attendance_checkin.php`, `icon/clock-in.png`, subtitle Clock in and out at school). Admin, director, backend, and `$info['status']` admin keep Locations, Settings, Enrollments, and gain Check in as a fourth tile. Enrollments uses `icon/newstudent.png`. No Photos tile. No hub page. → AC-2
- [ ] Page wrapper is `md-page staff-attendance-checkin-page`. Title Check in. Full chrome including bottom nav. `.page-title-clear` is hidden from the html/body app class. In and Out sit in `md-timeline-sort-strip` with `role="toolbar"`, always visible during GPS, phone, and camera. Default tab is In. Out is hidden when printed `allow_checkout` is 0. → AC-3
- [ ] GET page JSON includes `csrf_token`, `max_gps_accuracy_m`, `allow_checkout`, `has_active_location`, `photo_configured`, `match_configured`, `school_ready`, `autofill_phone`. → AC-3, value sourcing Page
- [ ] Autofill: enrolled staff with a positive `$info['staff_id']` sees `zap_display_ng_phone` of that enrollment `phone_normalized`. Admin, director, backend, unenrolled staff, missing `staff_id`, or `staff_id` 0 see empty. Phone is `type="tel"`. Client never posts `staff_id` or `location_id` on mint. → AC-5, value sourcing Page autofill
- [ ] Client calls `navigator.geolocation.watchPosition` (not `getCurrentPosition`) after In or Out, with a 15 s page timer. The first fix whose accuracy is within `max_gps_accuracy_m` is stored as `gpsGood` and `clearWatch` runs. Phone submit mints that stored point. There is no second geolocation read. Denied or missing geolocation shows `Location access is blocked. Check the browser permission.` and stays on GPS. Wait budget with no good fix shows title `Location is too coarse` plus `Location accuracy is too low. Move closer to open sky and try again.` and stays on GPS. Equal to max is allowed. A tap on `#sac-gps` starts another watch. If `has_active_location` is false after a good GPS read, show `Check in is not available.` and do not show the phone field. → AC-4
- [ ] Tab switch drops the client token, closes the sheet, restores the phone field to initial autofill or empty, and starts GPS again. → AC-3, AC-16
- [ ] Step Back on phone returns to GPS, restores autofill or empty, drops the token. Chrome back leaves the page. Idle 60 seconds with no tap and no successful GPS on GPS or phone resets the same way. Idle pauses while the liveness sheet is open. → AC-16
- [ ] After mint, the Confirm face sheet loads `staff-attendance-liveness.js` from `/app/admin/scripts/` with `?v=` `filemtime`. POST `challenges` returns two distinct moves. The sheet runs those two moves only (no look straight before, no look straight after). Progress chips are those two labels. `run` is called with `{ bookends: false }`. Consume JPEG is the last challenge frame. Step Back on the sheet returns to the phone field, drops the token, and keeps the typed phone. No file input. Enrollments still shows look straight bookends. → AC-7
- [ ] Happy path In: enrolled activated staff, GPS watch until accuracy is within max inside an active place, typed phone, mint uses the stored point (no second geolocation read), liveness, consume match. Success card shows masked name, `j M Y, H:i` time, location name, about 4 seconds, then GPS on the current tab. JSON has no `staff_id`, no `face_score`, no late, no portrait URL. A day row is inserted. Live JPEG is not in B2. → AC-4, AC-6, AC-8, AC-9, AC-11
- [ ] Check in while any open day exists (`check_out_at` IS NULL) returns `A check in has already been done. Check out first.` and does not insert a second day. Unique `(staff_id, attend_date)` loser uses the same msg. → AC-9, AC-15
- [ ] Out tab: `allow_checkout` 0 returns `Check out is turned off.` No day at all is `no_checkin`. Latest day already closed is `already_out`. Both use `There is no open check in to close.` Elapsed minutes equal to `min_checkout_minutes` is `It is too soon to check out.` Success updates the latest open row and shows that row’s stored `location_name`. → AC-10
- [ ] Replay a used success token returns the success JSON built from the day whose `check_in_at` or `check_out_at` equals `consumed_at`, ignores posted intent, and does not write again. A fail consume retry is `token_used` with `This check in timed out. Start again.` → AC-11
- [ ] Fail mint or consume shows the msg then GPS on the current tab, except GPS denied and coarse GPS stay on GPS, and invalid phone stays on the phone field. Generic `We could not complete this.` for `not_enrolled`, `duplicate_phone`, `inactive_staff`, `no_match`, `no_face`, `many_faces`. Never names another staff member. → AC-12
- [ ] Missing B2, missing matching, or school not on the switch list shows the spec 0006 banner sentences and does not mint or open the camera. Missing 0001 tables: `The attendance tables are missing.` Missing settings `id = 1`: `The settings row is missing.` These are not the fatal retry screen. → AC-13
- [ ] Fetch uses `referrerPolicy: 'origin'`. Page JS is `app/admin/scripts/staff_attendance_checkin.js` with `?v=` `filemtime`. Ajax is included after `init.php` and before `authenticate.php`. → AC-14, AC-16

## Commands
- [ ] `php admin/includes/staff_attendance_checkin_engine_test.php` → `staff attendance check in engine ok` → AC-17
- [ ] `php admin/includes/staff_attendance_enrollments_engine_test.php` → still green after the nav tile count change → AC-2

## Value sourcing
- [ ] Page school is `$_SESSION['db_name']` already selected. Switch school and confirm mint writes only in that database. → Page which school
- [ ] Who may open is `$_SESSION['table_name']` plus `$info['status']` via `staff_attendance_checkin_can_access`. → Page who may open
- [ ] `max_gps_accuracy_m` and `allow_checkout` come from settings `id = 1`. Change Settings, reload Check in, confirm Out hide and accuracy gate. → Page settings
- [ ] `has_active_location` is COUNT of `is_active = 1` greater than 0. Turn off every location, confirm `Check in is not available.` → Page has_active_location
- [ ] `photo_configured` and `match_configured` come from spec 0004 / 0005 config helpers. → Page config flags
- [ ] `school_ready` is `sap_school_id` from the switch list, first `db_name` match. → Page school_ready
- [ ] Autofill is current enrollment `phone_normalized` for `$info['staff_id']` when `table_name` is `staff`, run through `zap_display_ng_phone`, not a later edit of `staff.phone_number`. → Page autofill_phone
- [ ] Mint `phone_normalized` is `zap_normalize_ng_phone` of POST `phone` (trim, max 32). Invalid is `invalid_phone` with both phones and `staff_id` NULL. → Mint phone
- [ ] Current enrollment is `status = 'current'` with shadow uniques set. Duplicate current phone is generic `duplicate_phone` with `staff_id` NULL. Missing staff row is `not_enrolled` with `staff_id` NULL. Inactive is generic `inactive_staff` with `staff_id` set. → Mint enrollment
- [ ] `distance_m` is haversine Earth radius 6371000 from POST lat/lon to `locations.latitude` / `longitude`. Inside when `distance_m <= radius_m`. Several matches: smallest `distance_m`, tie lower `location_id`. `accuracy_m` does not change `radius_m`. → Mint fence
- [ ] Mint success attempt is `mint_ok` / `ok_mint`. Raw token is `bin2hex(random_bytes(32))`. `token_hash` is SHA-256 of that hex. `expires_at` is PHP Lagos now plus 10 minutes. `masked_name` is `sac_mask_name` of `staff.first_name` / `last_name`. → Mint success
- [ ] Attempt `ip` is `REMOTE_ADDR` only. `user_agent` truncated to 512. No `rate_limited`. → Mint ip
- [ ] Challenges are `sap_pick_challenges`. → Challenges
- [ ] Consume intent is POST `intent` `check_in` or `check_out`. GPS on the attempt is copied from the token, not a new client point. → Consume intent / GPS
- [ ] Face count is `saf_detect` `exactly_one`. Match is the Node boolean from `saf_verify`. `face_score` on the day is verify `similarity` rounded 4 places. Not shown in JSON. → Consume face
- [ ] `is_late` is TIME(`check_in_at`) after settings `late_after`. Check in, check out, and `attend_date` are PHP `Africa/Lagos` now. Do not use MySQL `NOW()`. → Consume time
- [ ] Check in `location_name` is `locations.name` for `token.location_id` at consume, stored on the new day. Check out `location_name` is the open day’s stored snapshot. → Consume location_name
- [ ] Success `time` is PHP Lagos `j M Y, H:i`. Success `attend_date` is the day row `Y-m-d`. → Consume success card
- [ ] Latest open day is `check_out_at` IS NULL, `ORDER BY check_in_at DESC, attendance_id DESC LIMIT 1`. too_soon elapsed is `(strtotime(now) - strtotime(check_in_at)) / 60` compared to `min_checkout_minutes`. → Consume checkout
- [ ] Replay success JSON is the day row whose clock column equals `token.consumed_at`. Fail used token is `token_used`. → Replay
- [ ] Idle / reset phone field is initial `autofill_phone` or empty, never the last stranger’s number. → Idle

## Acceptance-criteria coverage
- AC-1 covered by staff vs parent GET, dead session GET and POST
- AC-2 covered by nav tiles and Enrollments icon
- AC-3 covered by page shell, strip, GET JSON, tab reset
- AC-4 covered by one GPS watch, denied, coarse, tap retry, no locations, mint uses stored point
- AC-5 covered by tel field, autofill, mint POST shape, invalid phone
- AC-6 covered by mint order, fence, token, mint_ok attempt
- AC-7 covered by liveness sheet and challenges
- AC-8 covered by consume multipart, detect then verify, no B2 JPEG
- AC-9 covered by already in and day INSERT
- AC-10 covered by checkout off, no_checkin, already_out, too_soon
- AC-11 covered by success card, replay, token_used
- AC-12 covered by fail restart rules and generic msgs
- AC-13 covered by config, tables, and settings banners
- AC-14 covered by ajax include order, CSRF, JSON flags, no rate limit
- AC-15 covered by GET_LOCK 40s, unique staff plus date, lock_busy
- AC-16 covered by idle, Step Back, page JS cache bust
- AC-17 covered by `php admin/includes/staff_attendance_checkin_engine_test.php`
