# 0004. Private staff attendance portraits in Backblaze B2

**Date**: 2026-10-27
**Updated**: 2026-10-28
**Status**: Accepted

**Page UI**: Retired by [0006](../0006-face-enrollment/index.md). This spec still owns private B2 storage (`sap_*`, key shape, SigV4, CORS). Capture, view, and delete for admins live on Enrollments. `staff_attendance_photos.php` redirects there.

## Summary

Admins capture a staff portrait after a browser liveness check (MediaPipe Face Landmarker, two moves the server picked). The browser sends one JPEG straight to a private Backblaze bucket (cloud object storage) using a short PUT URL the PHP server signed. The file sits under a school folder, named from the staff member. Admins view it only through a five minute GET URL. This slice stores enrollment portraits only. Challenge frames stay in the browser. Failed check in frames are not saved. Spec 0001 already has `photo_object_key`; spec 0006 writes that column. PHP does not score the gestures. The Photos page UI in AC-1 to AC-6 is historical; do not build it again.

## Requirements

**User stories**:
- As an admin, director, or backend user, I want to upload a staff portrait into private storage so later enrollment can point at it without a public file URL.
- As an admin, I want to open a short lived view of that portrait so I can check it without the bucket being public.
- As an admin, I want to delete a portrait from that school folder when it should no longer be kept.

**Acceptance criteria** (the contract, each criterion is IDed and independently checkable):
- **AC-1**: A user whose session `table_name` is `admin`, `director`, or `backend`, or whose `$info['status']` is `admin`, can open `app/admin/staff_attendance_photos.php`. Any other logged in role that hits that URL is sent to the app home. The same role check applies to every photos AJAX action. Access is `staff_attendance_can_access` in `admin/includes/staff_attendance_access.php`.
- **AC-2**: The Staff attendance nav group has three tiles, added only for roles in AC-1. Locations and Settings stay as spec 0003. Photos: `href` `staff_attendance_photos.php`, icon `icon/newstudent.png` (a distinct existing file so it does not reuse Settings `icon/staff.png`), subtitle Private enrollment portraits. There is still no hub page.
- **AC-3**: Photos uses zap-app Pattern C. Hero overline is Staff attendance, title is Photos. School name in the hero comes from `$school_config['school_name']`. Stat is the number of objects in this list payload (the current page, not a B2 total). The page shows a skeleton, then loads over AJAX. Each card shows file name, size, View, and Delete. Zero objects shows a prompt to capture the first portrait. After confirm or delete the list reloads over AJAX. If B2 config is missing or incomplete, the page shows `The photo store is not configured.` and hides capture, list, and buttons. It is not the fatal retry screen. If the current school_id cannot be resolved from the switch list, the same hide rule uses msg `This school is missing from the switch list.` Every AJAX action returns those same two messages as HTTP 200 `{status:error, msg}` and must not call B2 (no empty list prefix). A cURL timeout or non XML B2 body is HTTP 200 `{status:error, msg:'The photo store did not respond.'}`, not the fatal retry screen.
- **AC-4**: Capture is a live video with a face oval overlay (an ellipse guide, face inside the oval). The `<video>` has `autoplay` `muted` `playsinline`. The preview is mirrored with CSS `transform: scaleX(-1)`; captured frames stay unmirrored. There is no consent copy on this probe. After the admin chooses staff and taps Start, PHP action `challenges` picks two distinct moves from `blink`, `mouth`, `turn_left`, `turn_right` and returns `{status:success, challenges}`. It does not persist them. The browser module `app/admin/scripts/staff-attendance-liveness.js` (MediaPipe Face Landmarker from the jsDelivr CDN and Google model URL in that file) runs look straight, then those two moves, then look straight again. Each step updates the on-screen prompt and speaks that same line (`Look straight at the camera`, then the two move strings from `PROMPTS` in the module, then `Look straight at the camera again`) with `speechSynthesis` and a short cue tone. The Start tap unlocks audio so later async steps can speak. If speech is missing or blocked, the check still runs from the on-screen prompts. Capture error messages in this AC are spoken as well. PHP does not prove the gesture and does not receive challenge frames. After the last move succeeds, the page uses the final frame only as the enrollment JPEG (no extra Capture tap). Extra frames stay in the browser and are not uploaded. `getUserMedia` uses `{ video: { facingMode: 'user' } }`. The final frame is drawn to a canvas with long side at most 720 px, then `toBlob` as `image/jpeg` quality `0.85` once. Do not retry lower qualities. If the blob is over 5242880 bytes (5 MB), show an error and do not mint. If the browser refuses the camera, show `Camera access is blocked. Check the browser permission.` If the module fails to load, show `The face check did not load. Try again.` If `challenges` cannot return two valid moves, show `The face check could not start.` A liveness timeout shows `Too slow, please try again.` Other liveness fails show `Verification failed, try again.` There is no file input fallback. The capture path stays disabled from mint until confirm returns (or until PUT retries are exhausted). PUT HTTP 200 then `confirm` once. Any other PUT status retries that same `put_url` until `expires_in`, then shows `The photo did not upload.` The settings style re-enable if the body is not JSON rule applies only to PHP AJAX, not to the B2 PUT.
- **AC-5**: The page has a select of current school staff where `staff.activation = 'activated'`, ordered by `last_name` then `first_name`. Each option value is `staff_id`. Option label is `last_name` then a space then `first_name`. Do not append `staff_id` to the visible label. Two people with the same name look identical in the list. Mint still uses the option value. Mint refuses a `staff_id` that is missing, not in this school, or not activated. HTTP 200 `{status:error, msg}`.
- **AC-6**: AJAX lives on `staff_attendance_photos.php` via `admin/includes/staff_attendance_photos_ajax.php`. Actions are POST `list`, `challenges`, `mint`, `confirm`, `view`, `delete`. Include the ajax file after `init.php` and before `authenticate.php`. Engine functions live in `admin/includes/staff_attendance_photos_engine.php`, take `mysqli` plus a `$ctx` array, return arrays, never echo, and never read `$_POST` or `$_GET`. JSON uses `json_encode(..., JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)`. Success JSON uses `{status:success}` (same as Settings). Each JSON body includes a fresh `csrf_token`. The GET page prints `$security->create_csrf_token()` into page JS for the first `list` POST, then replaces it from each JSON body. Page JS is `app/admin/scripts/staff_attendance_photos.js` with `?v=` `filemtime` via `get_v()`. The liveness module is `app/admin/scripts/staff-attendance-liveness.js`, loaded with an absolute `/app/admin/scripts/` URL and the same `?v=` `filemtime` via `get_v()`. Dynamic `import()` must not use a path relative to the page JS file (that resolves to `scripts/scripts/`).
- **AC-7**: The object key is `{school_id}/{sanitized_school_name}/enrollments/{Last}_{First}-{staff_id}.jpg`. `school_id` is the switch list entry whose `db_name` equals `$_SESSION['db_name']`. Never take `school_id` from the client. `sanitized_school_name` comes from `$school_config['school_name']`. `Last` and `First` come from that staff row’s `last_name` and `first_name`. Sanitize each slug in this order: trim, replace each run of non `[A-Za-z0-9]` with `_`, trim `_`, cut to 40 characters, trim `_` again, then if empty use `school` (folder) or `staff` (a name part). The slash after `school_id` is required so school `4` cannot list school `42`. There is no mkdir. The first PUT to that key creates the prefix. On `confirm`, after the new portrait passes its `HEAD` check, PHP lists `Prefix` `{school_id}/` and deletes every other object whose file name matches `-{staff_id}.jpg` and is not the new key, so a later name edit does not leave a second file. `mint` only builds the key and signs the PUT, so an abandoned mint removes nothing and never takes away the last good portrait.
- **AC-8**: `mint` inputs: `csrf_token`, `staff_id`. Server builds the key in AC-7 and returns `{status:success, object_key, put_url, headers:{'Content-Type':'image/jpeg'}, expires_in:120}`. The browser `PUT`s the JPEG to `put_url` with `Content-Type: image/jpeg` only. PHP must not receive the file bytes. The PUT URL is path-style `https://{b2_s3_endpoint}/{b2_bucket}/{object_key}` and is valid for 120 seconds. SigV4 PUT uses payload `UNSIGNED-PAYLOAD`. Signed PUT headers are `host` and `content-type` only. Do not sign `Content-Length`. A later mint for the same staff (same names) returns the same key and overwrites the object. Never log `put_url` or the application key.
- **AC-9**: `confirm` inputs: `csrf_token`, `object_key`. PHP sends a SigV4 `HEAD` to B2 (not a browser call). Success requires the object exists, `Content-Type` starts with `image/jpeg`, and `Content-Length` is 1 to 5242880. Then `{status:success}`. Missing object is HTTP 200 `{status:error, msg:'That photo is missing.'}`. If the object exists but type or size fails, PHP `DeleteObject`s it, then returns HTTP 200 `{status:error, msg:'That photo is not a valid JPEG.'}`. When the check passes, `confirm` also runs the AC-7 old key purge, so other files for that `staff_id` are removed only after a valid new portrait is stored. A purge failure does not turn a good upload into an error. Confirm does not write `staff_attendance_enrollments` or `staff_attendance_attempts`.
- **AC-10**: `view` inputs: `csrf_token`, `object_key`. After the same HEAD existence check as confirm (type and size must still pass; do not delete on view fail), PHP returns `{status:success, get_url, expires_in:300}`. The GET URL is path-style `https://{b2_s3_endpoint}/{b2_bucket}/{object_key}`. SigV4 GET signs `host` only (never `content-type`). Payload for the GET presign is `UNSIGNED-PAYLOAD`. The page sets an `img` `src` to `get_url` (a sheet is fine). Bytes do not stream through PHP. Never log `get_url`.
- **AC-11**: `delete` inputs: `csrf_token`, `object_key`. Confirm wording: `This will remove the portrait from private storage.` PHP `HEAD`s first. Missing (HTTP 404) returns `That photo is missing.` and skips `DeleteObject`. HEAD 200 then `DeleteObject`, even if a later enrollment row might point at that key. Success toasts `Photo deleted.` and reloads list. Success after confirm toasts `Photo uploaded.`
- **AC-12**: `list` inputs: `csrf_token`, optional `cursor`. PHP `ListObjectsV2` with `Prefix` `{school_id}/`, `MaxKeys` 200. Omit `cursor` on the first page. When B2 returns `IsTruncated`, include `next_cursor` (the `NextContinuationToken`). A Next control posts that cursor. A First page control omits it. There is no Previous token. Hero count equals `objects.length` in that payload. Each object is `{object_key, file_name, size}`. `file_name` is the last path segment. `size` is B2 `Size` as an integer byte count. The UI may show KB for display, but JSON stays bytes. `list` also returns `staff[]` of `{staff_id, name}` where `name` is the AC-5 label. `cursor` is used only with the server prefix. Reject a cursor containing control bytes or longer than 4096 characters.
- **AC-13**: For `confirm`, `view`, and `delete`, `object_key` must match `^[0-9]+/[A-Za-z0-9_]+/enrollments/[A-Za-z0-9_.-]+\.jpg$`, must start with this school’s `{school_id}/`, and must not contain `..`. Anything else is HTTP 200 `{status:error, msg:'That photo is not in this school folder.'}` and PHP must not sign or delete it.
- **AC-14**: This slice never stores failed check in captures. It never writes `staff_attendance_attempts.photo_object_key`. There is no 7 day cleanup job here.
- **AC-15**: B2 settings are extra keys on the PHP array in `private/includes/ask_zap.php`: `b2_key_id`, `b2_application_key`, `b2_bucket`, `b2_region`, `b2_s3_endpoint`. The engine loads that file itself (same path pattern as Ask ZAP, not `azp_https_pair`). Config is complete only when every value is a non empty string, the endpoint is a host without a scheme (example `s3.eu-central-003.backblazeb2.com`), and the region is a non empty token. `/develop` fills real values with you. This spec must not print secret values. Incomplete config is AC-3.
- **AC-16**: Live session AJAX is `isset($_SESSION['authenticated']) || isset($_SESSION['zap_auth'])`; otherwise HTTP 401 `{status:error, msg:unauthorized}`. CSRF is `$security->csrf_token_is_valid()` and, when the method exists, `$security->request_is_same_domain()`. Do not call `check_csrf_token()`. CSRF failure and wrong role AJAX are HTTP 403 `{status:error, msg:forbidden}`. Validation errors are HTTP 200 `{status:error, msg}`. Current school reads use the mysqli database already selected by `$_SESSION['db_name']`.
- **AC-17**: You apply CORS on the bucket in the Backblaze console before verify. Allowed origins `*`. Allowed methods `GET`, `PUT`, `HEAD`. Allowed headers `content-type`, `authorization`, `x-amz-content-sha256`, `x-amz-date`, `x-amz-security-token`. Max age 3600 seconds. The app key does not call `PutBucketCors`. Browser POST to a presigned URL is not used (B2 does not support that).
- **AC-18**: Signer is a small PHP AWS SigV4 helper in the photos engine (or a sibling include it loads). No Composer package. Service name `s3`. Region from `b2_region`. Host from `b2_s3_endpoint`. Path-style URLs. Query style presign (`X-Amz-Algorithm`, `X-Amz-Credential`, `X-Amz-Date`, `X-Amz-Expires`, `X-Amz-SignedHeaders`, `X-Amz-Signature`). PHP talks to B2 with cURL, 10 second timeout, TLS verify on. PHP uses the app key for `HEAD`, `ListObjectsV2`, and `DeleteObject` (signed requests, not presigned URLs). Those PHP requests sign the SHA256 of an empty body, not `UNSIGNED-PAYLOAD`. Only browser PUT and browser GET use presigned URLs. Engine function names: `sap_config`, `sap_school_id`, `sap_object_key`, `sap_presign_put`, `sap_presign_get`, `sap_head`, `sap_list`, `sap_delete`, `sap_staff_options`, `sap_staff_label`, `sap_challenge_pool`, `sap_choose_challenges`, `sap_pick_challenges`. All `sap_*` stay silent and ignore superglobals.

## Decision

**Chosen option**: Option 1: Private Backblaze B2 bucket over the S3 compatible API, browser PUT through a presigned URL, signed GET for view, school folder plus staff file name, Photos probe page.

No new MySQL table. Enrollment later writes `photo_object_key`. Failed attempt photos are out.

**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 new table and no `ALTER`. Target objects live in one private B2 bucket shared by all schools, isolated by key prefix.

| Piece | Type | Required | Notes |
|---|---|---|---|
| B2 object key | string, max 255 | yes | `{school_id}/{sanitized_school_name}/enrollments/{Last}_{First}-{staff_id}.jpg` |
| B2 object body | JPEG bytes | yes | 1 to 5242880 bytes after canvas (5 MB) |
| `enrollments.photo_object_key` | VARCHAR(255) | yes on that row | Spec 0001 column. This slice does not write it. |
| `attempts.photo_object_key` | VARCHAR(255) NULL | no | This slice never sets it. |
| ask_zap.php B2 keys | PHP array strings | yes to operate | `b2_key_id`, `b2_application_key`, `b2_bucket`, `b2_region`, `b2_s3_endpoint` |

Relationships: one school_id prefix to many objects. One activated staff_id to one current file (confirm deletes other `*-{staff_id}.jpg` keys under that prefix once the new portrait is stored). Replaced enrollment rows in spec 0001 may still hold an old key string; the bytes in B2 are the latest face for that staff_id.

**State transitions**:
- Choose staff → Start (unlocks audio) → `challenges` returns two moves → MediaPipe (look straight, move, move, look straight; each step spoken and shown) → final frame → mint (PUT URL, 120 s) → browser PUT HTTP 200 → confirm HEAD success, which then deletes other keys for that `staff_id`.
- Liveness timeout or fail → error on the page (spoken and shown), Start again, no mint.
- PUT not 200 → retry same URL until `expires_in` → `The photo did not upload.`
- Object exists → view (GET URL, 300 s) or delete (HEAD then DeleteObject) or mint again (overwrite, or a new name key whose purge runs on its confirm).
- Abandoned mint with no PUT: nothing is stored and no old file is removed, because the purge waits for confirm. No pending table.

**API surface**:

Page GET `app/admin/staff_attendance_photos.php`. AJAX POST on that same URL.

| Action | Method | Key inputs | Key outputs | Auth | Key errors |
|---|---|---|---|---|---|
| Page | GET | session | Pattern C chrome, camera, staff select, list mount, csrf_token | AC-1 | redirect home; hide actions if config missing |
| list | POST | csrf_token, cursor (opt) | objects[], staff[], next_cursor (opt), csrf_token | AC-1 | 401, 403, config missing, missing school, bad cursor, B2 timeout |
| challenges | POST | csrf_token | challenges[] of two distinct pool moves, csrf_token | AC-1 | 401, 403, config missing, missing school |
| mint | POST | csrf_token, staff_id | object_key, put_url, headers, expires_in 120 | AC-1 | bad staff_id, config missing, missing school, B2 timeout |
| Browser PUT | PUT | JPEG body, Content-Type image/jpeg | B2 200 | presigned URL | 403 expired or CORS; oversize |
| confirm | POST | csrf_token, object_key | status success | AC-1 | missing object, type or size (object deleted), prefix fail |
| view | POST | csrf_token, object_key | get_url, expires_in 300 | AC-1 | missing object, type or size, prefix fail |
| Browser GET | GET | signed query | JPEG bytes | presigned URL | 403 expired |
| delete | POST | csrf_token, object_key | status success | AC-1 | missing object (HEAD 404), prefix fail |

**Value sourcing**:

| Action | Value produced / displayed | Source |
|---|---|---|
| Page / list | who may open | `staff_attendance_can_access` as spec 0003 |
| Page | school name in hero | `$school_config['school_name']` |
| Page | hero stat | `count` of `objects` in this list JSON |
| Folder school_id | integer in the key prefix | `$school_config['schools'][id]['db_name']` equals `$_SESSION['db_name']` |
| Folder name slug | sanitized school name | `$school_config['school_name']` then AC-7 sanitize |
| Staff options | staff_id and display name | `staff` in the session database, `activation = 'activated'`, label `last_name first_name` with no `staff_id` in the text |
| Liveness moves | two distinct challenge ids | `sap_pick_challenges` from `blink`, `mouth`, `turn_left`, `turn_right`; not stored |
| First list CSRF | token on GET page | `$security->create_csrf_token()` printed into page JS, then each JSON `csrf_token` |
| Object key | full B2 key | AC-7 from school_id, slugs, and staff_id. Client cannot choose it on mint |
| Old keys for this staff | deleted on confirm | List `{school_id}/`, delete file names matching `-{staff_id}.jpg` except the new key |
| PUT URL | presigned PUT | Path-style SigV4, host plus content-type, UNSIGNED-PAYLOAD, expiry now plus 120 seconds (Unix seconds, not Lagos wall clock) |
| GET URL | presigned GET | Path-style SigV4, host only, UNSIGNED-PAYLOAD, expiry now plus 300 seconds |
| JSON success status | `success` | constant, same as Settings |
| JPEG bytes | camera frame | final MediaPipe frame, then canvas `toBlob` `image/jpeg` quality 0.85 after 720 px long side, after oval plus AC-4 liveness |
| Liveness module | Face Landmarker in the browser | `app/admin/scripts/staff-attendance-liveness.js` via absolute `/app/admin/scripts/` URL plus `get_v()` |
| Liveness spoken prompt | same string as the on-screen step | `speakPrompt` in that module via `speechSynthesis`, plus a short cue tone |
| Liveness audio unlock | Start tap | `unlockPromptAudio` on the user gesture before `challenges` returns |
| Liveness timeout msg | `Too slow, please try again.` | constant |
| Liveness load fail msg | `The face check did not load. Try again.` | constant |
| Liveness start fail msg | `The face check could not start.` | constant |
| Liveness other fail msg | `Verification failed, try again.` | constant |
| Confirm size and type | HEAD Content-Length and Content-Type | B2 HEAD on confirm and view |
| List objects | keys and sizes | B2 `ListObjectsV2` `Prefix` `{school_id}/` `MaxKeys` 200 |
| List file_name | last segment of object_key | PHP `basename` of the key |
| List cursor | next page | B2 `NextContinuationToken` when `IsTruncated` |
| Missing object msg | `That photo is missing.` | constant |
| Wrong prefix msg | `That photo is not in this school folder.` | constant |
| Config missing msg | `The photo store is not configured.` | constant |
| Camera blocked msg | `Camera access is blocked. Check the browser permission.` | constant |
| PUT fail msg | `The photo did not upload.` | constant after retries |
| Invalid JPEG msg | `That photo is not a valid JPEG.` | constant after confirm HEAD type or size fail |
| B2 timeout msg | `The photo store did not respond.` | constant after cURL timeout or non XML body |
| Missing school msg | `This school is missing from the switch list.` | constant |
| Delete confirm | `This will remove the portrait from private storage.` | constant |
| Upload toast | `Photo uploaded.` | constant via `notify({ text }, 'success')` |
| Delete toast | `Photo deleted.` | constant via `notify({ text }, 'success')` |
| B2 host | S3 API host | `b2_s3_endpoint` with `https://` prefixed by PHP |
| B2 region | SigV4 region | `b2_region` |
| B2 bucket | bucket name | `b2_bucket` |
| Credentials | key id and application key | `b2_key_id` and `b2_application_key` from ask_zap.php |
| Tenant SQL | which staff rows | mysqli already on `$_SESSION['db_name']` |
| JSON envelope | status, msg, csrf_token | same flags as spec 0003 |
| Live session | AJAX gate | `isset($_SESSION['authenticated']) \|\| isset($_SESSION['zap_auth'])` |
| CSRF | AJAX gate | `csrf_token_is_valid` plus same origin when present |

**Key invariants**:
- Browser never receives `b2_application_key` or `b2_key_id`.
- PHP never receives portrait bytes on mint, confirm, view, or delete.
- Confirm deletes other `*-{staff_id}.jpg` keys under `{school_id}/` after the new portrait passes the HEAD check. The new portrait has already overwritten the current name key, so a failed PUT never removes the last good file. History of old faces is not kept in B2.
- List prefix is `{school_id}/` so a school rename still finds `{id}/{old_slug}/...` and `{id}/{new_slug}/...`.
- AJAX never lists or signs with an empty prefix.
- Engine never echoes and never reads superglobals.
- Client never sends `db_name` or `school_id`.
- Do not call `check_csrf_token()`.
- Do not write enrollment or attempt rows in this slice.
- Do not log presigned URLs or the application key. `object_key` on error is allowed.
- PHP AJAX buttons disable until JSON returns, and re-enable if that PHP fetch fails or the body is not JSON. Capture stays disabled across mint, PUT, and confirm as in AC-4.
- PHP does not prove liveness on this slice. Challenge frames are not uploaded and not stored.
- Liveness prompts are spoken and shown. Speech failure does not fail the check.
- The visible staff label never includes `staff_id`.

**Security model**:
- Personal data: staff face portraits. Product bar is NDPR style private storage and short lived view URLs. This is not a completed legal review.
- Readers and writers: roles in AC-1, current school staff list, current school B2 prefix. Students, parents, and other staff: page redirect home, AJAX 403.
- Public check in is not in this slice and must not mint PUT URLs here.
- Bucket stays private (`allPrivate`). Object URLs without a signature must not work.
- CORS origins `*` was chosen for this slice. A leaked PUT URL during 120 seconds can be used from any origin. Short expiry is the mitigation.
- CSRF and same origin on PHP actions. Prefix allowlist on every client supplied key.
- Prepared statements for the staff SELECT.
- Compliance: biometric photos. Consent wording is owned by the enrollment spec, not this probe.

**Configuration required**:
- `b2_key_id`: Backblaze application key id (S3 access key id)
- `b2_application_key`: Backblaze application key (S3 secret)
- `b2_bucket`: bucket name (the attendance passport bucket)
- `b2_region`: S3 region token for SigV4
- `b2_s3_endpoint`: S3 host without scheme
- Bucket CORS as AC-17, applied in the B2 console before verify
- Prerequisite: `/develop` fills the five keys in `private/includes/ask_zap.php` with you. Rotate the application key that was pasted in chat before treating this as production.

**Critical test scenarios** (each maps to an acceptance criterion in ## Requirements):
- Happy path: allowed role opens Photos from the nav tile, picks an activated staff member (label has no `staff_id`), taps Start, `challenges` returns two distinct moves, MediaPipe completes look straight then those moves then look straight, each step is spoken and shown, the final frame is minted, the browser PUTs the JPEG to B2 with HTTP 200, confirm HEADs success, list shows the file, view shows the image from a signed GET, verifies **AC-1**, **AC-2**, **AC-3**, **AC-4**, **AC-5**, **AC-6**, **AC-8**, **AC-9**, **AC-10**, **AC-12**, **AC-18**
- Failure case: confirm without PUT returns `That photo is missing.`; a key from another school_id prefix is refused; camera deny shows the blocked message; incomplete ask_zap keys hide actions and AJAX returns that msg; delete of a missing key does not toast deleted, verifies **AC-3**, **AC-4**, **AC-9**, **AC-11**, **AC-13**, **AC-15**
- Auth/permission: a teacher session is sent home; dead session POST returns HTTP 401 JSON; a PUT of the file through PHP is not implemented, verifies **AC-1**, **AC-8**, **AC-16**
- Retake and paging: second mint for the same staff overwrites the same key; mint plus confirm after a staff name edit leaves one file; list pages of 200 with Next and First page; delete HEADs then removes, verifies **AC-7**, **AC-8**, **AC-11**, **AC-12**

## Build plan

Tracer Bullet: first a real camera frame that lands in B2 through a presigned PUT and comes back through a signed GET, then thicken list, paging, delete, nav, and fail states. No schema migration.

1. [x] Add the five keys to `private/includes/ask_zap.php`, load them in the engine, SigV4 helper, mint plus browser PUT plus confirm HEAD plus view on the Photos page with camera and staff select, satisfies **AC-4**, **AC-5**, **AC-7**, **AC-8**, **AC-9**, **AC-10**, **AC-15**, **AC-18**
2. [x] Pattern C list, paging of 200, delete, nav tile, access helper, AJAX envelope and CSRF, satisfies **AC-1**, **AC-2**, **AC-3**, **AC-6**, **AC-11**, **AC-12**, **AC-16**
3. [x] Prefix allowlist, missing config on page and AJAX, camera blocked, PUT retry then fail message, name-edit purge, empty list, CORS checklist for you, satisfies **AC-3**, **AC-4**, **AC-7**, **AC-13**, **AC-14**, **AC-17**
4. [x] Staff labels hide `staff_id`, server picks two liveness moves, MediaPipe module on Photos, spoken plus on-screen prompts, one final portrait still PUT to B2, satisfies **AC-4**, **AC-5**, **AC-6**, **AC-18**

## Consequences

**Positive**:
- Portrait bytes skip PHP, so the app server does not hold biometric files on disk.
- Admins can prove upload, view, and delete before enrollment exists.
- School folders stay readable in the B2 console, and `school_id` keeps them unique across rename and across schools numbered 4 and 42.
- Spec 0001 columns stay valid: this slice simply never fills attempt photo keys.

**Negative / tradeoffs**:
- Secrets live in `private/includes/ask_zap.php`, which is the existing Ask ZAP pattern, not a true environment variable and not outside the web tree. Anyone with repo or host read access gets the application key.
- CORS `*` plus a leaked PUT URL is a 120 second write window from any origin.
- Retake overwrites when the name is unchanged. A name edit plus a completed capture (mint, PUT, confirm) deletes the old file name. A replaced enrollment row can still hold an old key string.
- The probe can delete an object that a later enrollment row still names. View then shows missing.
- Paging has Next and First page only. Hero count is the current page length, not the bucket total.
- You must set CORS in the B2 console. `/check verify` cannot pass browser PUT until that is done.
- Two activated staff with the same name look identical in the select. The option value still separates them.
- MediaPipe loads from jsDelivr and Google storage. Capture needs that network path. PHP still does not score the face.

**Neutral**:
- Enrollment, check in, and the report still need their own specs to bind `photo_object_key`, consent, and fail handling.
- No 7 day cleanup job, because fail photos are not stored.
- Desktop `admin/` is still deferred.
- The B2 CLI skill is for agents. The app uses PHP SigV4, not the `b2` CLI at runtime.

## Follow-up

- [x] Enrollment spec writes `enrollments.photo_object_key` after the same mint, PUT, confirm path, records consent, and deletes the B2 object on Remove. Capture, view, and delete live on Enrollments. The Photos page is retired. Check in still owns tokens, uploaded evidence frames, and a server decision that the person is live. See spec 0006.
- [ ] Staff check in spec must not mint fail photo PUTs. Leave `attempts.photo_object_key` NULL.
- [ ] Attendance report spec views enrollment portraits through the same signed GET helper, and treats a missing object as expired style empty, not a broken image.
- [ ] Spec 0003 AC-2 still says two nav tiles. This spec adds the third. `/sync` can line those sentences up after this ships.
- [ ] `b2-cloud-storage` is installed at `.cursor/skills/b2-cloud-storage/` and is not in `AGENTS.md` yet. Root currently records declined Agent Skills. `/sync` should note this one skill for attendance storage, without turning skills back on for the whole repo.
- [ ] Rotate the Backblaze application key that was pasted in chat, then put the new values only in `ask_zap.php`.
- [ ] NDPR or other legal review of face portraits is not done here.
- [ ] Desktop admin copy remains deferred.
- [ ] If the bucket has versioning on, overwrite keeps old versions and costs more. Leave versioning off unless you later choose it.

## Rationale

Reasoning and options: see [rationale.md](rationale.md).
