# Verify: face enrollment · spec 0006 · updated 2026-10-28
_Steps derived from spec 0006 acceptance criteria. `/check verify` runs these; `/test` locks the durable ones._

## UI / manual
- [ ] Sign in as admin, director, or backend (or staff with `$info['status']` admin). Open Menu. Staff attendance shows Locations, Settings, Photos, and Enrollments. Enrollments uses `staff_attendance_enrollments.php`, icon `icon/clock-in.png`, subtitle Faces for check in. Open Enrollments. A teacher session that hits that URL is sent to app home. → AC-1, AC-2
- [ ] Enrollments is Pattern C plus Pattern G chips. Overline Staff attendance, title Enrollments, school name from `$school_config['school_name']`. Hero stat is `enrolled_count` (`COUNT(*)` of `status = current`, including deactivated staff). Skeleton then AJAX list. Chips All, Not enrolled, Enrolled, Enrolled + tested. Search by name. Paging 50. Zero activated staff shows There is no one to enroll. Search or chip with zero rows shows No matching staff. → AC-3, value sourcing (hero school name, enrolled_count, page_size 50)
- [ ] Cards show `last_name` space `first_name` with no `staff_id` in the name, derived status, and actions. No portrait thumb. No phone on the card. Not enrolled: Enroll. Enrolled or Enrolled + tested: Retake, Test, Remove. Two people with the same name look identical; actions still use `staff_id`. → AC-4, AC-5, value sourcing (name, status)
- [ ] List rows are `staff.activation = 'activated'` in the current school only, ordered by `last_name`, `first_name`, `staff_id`. Deactivated staff are omitted even if a current enrollment exists. `q` trim, cut to 80, `LIKE` on `CONCAT_WS(' ', last_name, first_name)` with escaped `\`, `%`, `_`. A two word query can match a full name. Missing or non integer `page` becomes 1. A page past the end returns `rows: []` with the same `total_rows`. Unknown `status` is treated as `all`. → AC-5, value sourcing (q, page, tenant SQL)
- [ ] Enroll opens a bottom sheet with live video (`autoplay muted playsinline`), oval, and the Photos liveness module from `/app/admin/scripts/staff-attendance-liveness.js?v=`. Preview is mirrored; captured frames are not. After Start, `challenges` on this same URL returns two distinct moves. JPEG encode matches spec 0004 (user camera, long side 720, quality 0.85, max 5242880). No file input. No consent copy until `confirm_face` succeeds. Always capture again. → AC-6, AC-16, value sourcing (liveness module)
- [ ] `mint` builds `{school_id}/{slug}/enrollments/pending-{staff_id}.jpg`, not the final name. Browser PUT uses that `put_url` for 120 seconds. Capture stays disabled from mint until `confirm_face` returns or PUT retries are exhausted. PUT not HTTP 200 retries the same URL until `expires_in`, then `The photo did not upload.` → AC-7, AC-16, value sourcing (pending key, folder school_id)
- [ ] `confirm_face` HEADs the pending object and does not call `saf_detect`. A valid JPEG returns `object_key` and the sheet POSTs enroll with no consent tick and no phone field. Prefix mismatch: `That photo is not in this school folder.` with no sign or delete. Missing pending: `That photo is missing.` Bad type: `That photo is not a valid JPEG.` → AC-7, AC-8, value sourcing (live face presence)
- [ ] Invalid staff phone toasts `Fix this staff member's phone number on the staff record.` Duplicate phone closes the sheet and toasts `This phone is already enrolled.` without naming the other person. Success toasts `Staff enrolled.` or `Enrollment replaced.` and reloads list with the same `q`, chip, and `page`. → AC-8, AC-9, AC-10, value sourcing (enroll toast, duplicate msg)
- [ ] `enroll` writes `consent_at` and `enrolled_at` as PHP Lagos `Y-m-d H:i:s`, never `NOW()`. COMMIT uses the pending key, then `sap_copy` to the final Photos key, HEAD, UPDATE, delete pending, sweep `-{staff_id}.jpg` (do not treat `-12.jpg` as staff `2`). Copy fail after COMMIT still returns success with the pending key live. → AC-9, value sourcing (clocks, photo_object_key at COMMIT, final key, actor)
- [ ] Retake is the same `enroll` action. The new row has `tested_at` NULL. Remove confirm: `This staff member will not be able to check in until enrolled again. The portrait file stays on Photos.` Success marks the current row replaced without deleting B2 and toasts `Enrollment removed.` No current row: `That staff member is not enrolled.` → AC-11, AC-12, value sourcing (remove confirm, remove toast)
- [ ] Test opens the same oval and liveness. POST `test` is multipart `image`. Bad JPEG is `That photo is not a valid JPEG.` and is not stored. Not enrolled: `That staff member is not enrolled.` Missing portrait: `That photo is missing.` PHP does not call `saf_detect`. Match true: toast `Face test passed.` and list shows Enrolled + tested. Match false: `Face test did not pass.`, no similarity, `tested_at` unchanged. Verify `no_face`: `No face was detected.` → AC-13, value sourcing (test JPEG, reference image, test msgs, test match)
- [ ] Incomplete B2 or matching config still shows the list. Enroll, Retake, and Test are hidden. Banner shows `The photo store is not configured.` and or `The matching service is not configured.` (both sentences with a space if both). Remove stays. Missing switch list school uses `This school is missing from the switch list.` for capture actions (that msg wins). Those hidden actions return HTTP 200 `{status:error, msg}`. Not the fatal retry screen. → AC-14, value sourcing (config msgs)
- [ ] Dead session AJAX is HTTP 401; page JS sends them to login. CSRF fail and wrong role AJAX are HTTP 403 `forbidden`. Do not call `check_csrf_token()`. Each JSON body includes a rotated `csrf_token`. During capture, do not fire a parallel `list`. → AC-15, value sourcing (first list CSRF, JSON envelope)

## Commands
- [ ] `php admin/includes/staff_attendance_enrollments_engine_test.php` → `staff attendance enrollments engine ok` covering derived status, pending key shape, invalid phone, consent false, duplicate_phone attempt shape (`staff_id` NULL), replace shadow columns, prefix refuse, JPEG magic, write order with pending key, `confirm_face` without `saf_detect`. Engine source does not call `saf_detect`. → AC-17
- [ ] Engine functions take mysqli plus `$ctx`, return arrays, never echo, never read `$_POST` or `$_GET`. Optional `$ctx['sap']` and `$ctx['saf']` override helpers. `sap_copy` lives in the photos engine. GET_LOCK name is `sae_{staff_id}`. → AC-9, AC-15, AC-17
- [ ] Duplicate phone INSERT 1062 rolls back, writes `staff_attendance_attempts` with `outcome` fail, `reason_code` duplicate_phone, `staff_id` NULL, `phone_normalized` 13 digits, `ip` from `REMOTE_ADDR` only, `user_agent` truncated to 512. Deletes pending. Leaves the other person's portrait. → AC-10, value sourcing (duplicate clash)
- [ ] Actor type is `$_SESSION['table_name']` when admin, director, backend, or staff; else admin when `$info['status']` is admin. Actor id is `$info['staff_id']` if set and > 0, else NULL. → AC-15, value sourcing (who enrolled)
- [ ] Never log image bytes, data URIs, `face_api_key`, or full `put_url` / `get_url`. Do not write `staff_attendance_templates`. → AC-15, AC-17, key invariants

## Value sourcing (edges)
- [ ] Hero school name follows `$school_config['school_name']` on a school switch, not a hardcoded label. → value sourcing
- [ ] Hero stat counts current enrollments including deactivated staff, not the visible card count. → value sourcing
- [ ] Phone saved on enroll is POST `phone` through `zap_normalize_ng_phone`, not `staff.phone_number`. Prefill uses display of `staff.phone_number` only when that value normalizes. → value sourcing
- [ ] Test verify uses a signed GET of the row’s stored `photo_object_key` (pending or final). → value sourcing
- [ ] Clocks stay PHP `Africa/Lagos` strings when you enroll, replace, remove, test, or log duplicate_phone. → value sourcing

## Acceptance-criteria coverage
- AC-1 page and AJAX access · AC-2 nav tile · AC-3 Pattern C plus chips, search, paging, empty copy · AC-4 card fields and actions · AC-5 activated list, derived status, q, page · AC-6 capture oval and liveness · AC-7 pending mint, confirm_face JPEG HEAD · AC-8 consent and phone · AC-9 enroll write order and copy · AC-10 duplicate_phone · AC-11 retake · AC-12 remove · AC-13 test verify · AC-14 config hide · AC-15 ajax, csrf, actor, JS · AC-16 challenges and mint on this URL · AC-17 engine tests · AC-18 view and thumbs · AC-19 Photos retired

## Update 2026-10-28 (portrait view, B2 remove, Photos retired)
Earlier bullets that mention a Photos tile, no portrait thumb, or Remove leaving the file on Photos are superseded by this section.

### UI / manual
- [ ] Menu Staff attendance has three tiles only: Locations, Settings, Enrollments. There is no Photos tile. Enrollments subtitle is Faces for check in, icon `icon/clock-in.png`. → AC-2
- [ ] Enrolled or Enrolled + tested cards show an `img` thumb from list `portrait_url`, `alt` is the staff name. Not enrolled shows a generic avatar and Enroll, with no `portrait_url` and `can_view` false. Tapping the enrolled avatar POSTs `view` (no client `object_key`) and opens the portrait sheet with a fresh GET URL. An expired sheet: close and tap again. Image error falls back to the generic avatar. → AC-3, AC-4, AC-18, value sourcing (portrait_url, can_view, view image)
- [ ] POST `view` with a missing staff id is `That staff member was not found.` No current row is `That staff member is not enrolled.` A stored key that fails the allowlist is `That photo is not in this school folder.` and PHP does not sign it. Missing object is `That photo is missing.` Success JSON is `{status:success, get_url, expires_in:300}`. List JSON has no `object_key` or `photo_object_key`. → AC-18
- [ ] Matching down still allows Enroll and Retake when B2 and school are ready. Test is hidden with `The matching service is not configured.` View is hidden when B2 or school is down. If both store and matching are down, Test concatenates both sentences with a space. School missing wins: `This school is missing from the switch list.` → AC-14
- [ ] Remove confirm: `This staff member will not be able to check in until enrolled again. The portrait file will be deleted.` After success the current row is replaced, the stored B2 object is deleted, remaining `-{staff_id}.jpg` keys are swept (including pending), and the toast is `Enrollment removed.` Incomplete B2 still replaces the row and skips B2. → AC-12, value sourcing (remove confirm, remove toast, remove B2 delete)
- [ ] Open `staff_attendance_photos.php` as an allowed role: HTTP 302 to `staff_attendance_enrollments.php`. Another logged in role is sent home. → AC-19

### Commands
- [ ] `php admin/includes/staff_attendance_enrollments_engine_test.php` → `staff attendance enrollments engine ok` also covering: lock name includes sanitized `db_name`, mint refuse when current key equals pending, duplicate_phone does not delete pending when a current row still stores that key, copy failure skips sweep, `view` uses the current row key and ignores a client `object_key`, `view` refuses another school prefix, list omits `portrait_url` and sets `can_view` false for `not_enrolled` and allowlist fail, list JSON has no `object_key` / `photo_object_key`, `remove` marks replaced then `sap_delete` and sweep with empty keep key. → AC-17
- [ ] GET_LOCK name is `sae_` plus sanitized session `db_name` plus `_` plus `staff_id`. The lock is released before B2 copy, delete, or sweep. → AC-9, AC-12, AC-17
- [ ] `php admin/includes/staff_attendance_photos_engine_test.php` → photos engine still ok; Photos POST is HTTP 410 `Use Enrollments.`; unused `staff_attendance_photos.js` is gone. → AC-19
- [ ] POST `staff_attendance_photos.php` as an allowed live session with valid CSRF: HTTP 410 `{status:error, msg:'Use Enrollments.'}` and no mint, confirm, delete, list, or view. Dead session POST is 401. CSRF or role fail is 403. → AC-19

### Value sourcing (edges)
- [ ] List thumb and view sheet both read the stored `photo_object_key` (pending or final), never a client `object_key`. → value sourcing
- [ ] `portrait_expires_in` is 300 when `portrait_url` is set, else omitted. → value sourcing
- [ ] Sweep keep key is the new final key after a good copy, and empty on Remove. Cap 50 list pages then stop, still success. Do not treat `-12.jpg` as staff `2`. → value sourcing
- [ ] Mint refuse when current `photo_object_key` equals the pending key uses `Please try again.` and does not sign a PUT. → AC-7, value sourcing (pending key)
