# 0006. Staff face enrollment on the school app

**Date**: 2026-10-27
**Updated**: 2026-10-31
**Status**: In Progress
**Amended**: 2026-10-31 (spec 0007: Staff attendance nav group adds Check in; Enrollments icon becomes `icon/newstudent.png`)

## Summary

Admins enroll school staff faces on the Enrollments page. Capture stores one JPEG in the private Backblaze bucket (cloud object storage) using the same signed PUT path as spec 0004. After a valid pending JPEG, the sheet saves without a consent tick or a phone field. Phone comes from `staff.phone_number`. `consent_at` is PHP Lagos now. Retake edits that same current row. Remove deletes the row and the portrait file. The Photos page is retired. MediaPipe in the browser requires a face during liveness. Test compares a live frame to that portrait on the server with Node verify. PHP does not call `saf_detect`.

## Requirements

**User stories**:
- As an admin, director, or backend user, I want a current school staff list with enrollment status so I can see who can check in.
- As that user, I want to capture one live face and save a current enrollment so check in has a portrait and the staff phone already on the staff row.
- As that user, I want to see each enrolled staff member’s portrait on that list and open it full size so I can browse faces by person, not by file name.
- As that user, I want to test the same server match check in will use, without GPS, so I trust the portrait before gate day.
- As that user, I want to retake or remove an enrollment so a wrong face or a leftover current row does not stay in force, and so Remove also deletes the portrait file.

**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_enrollments.php`. Any other logged in role that hits that URL is sent to the app home. The same role check applies to every enrollments AJAX action. Access is `staff_attendance_can_access` in `admin/includes/staff_attendance_access.php`.
- **AC-2**: Spec 0007 AC-2 owns Staff attendance nav group membership from 2026-10-31: Check in is a fourth tile, Enrollments uses `icon/newstudent.png`. Until 0007 ships, this page still added only three tiles for roles in AC-1: Locations, Settings, and Enrollments. There is no Photos tile. Enrollments: `href` `staff_attendance_enrollments.php`, subtitle Faces for check in. There is still no hub page. Do not add enroll actions to `staff_list.php`.
- **AC-3**: Enrollments uses zap-app Pattern C plus Pattern G chips. Hero overline is Staff attendance, title is Enrollments. School name in the hero comes from `$school_config['school_name']`. Stat is `enrolled_count` (`COUNT(*)` of enrollments with `status = current` in this database, including current rows whose staff is deactivated). That number can be higher than visible cards. It is not the page length. The page shows a skeleton, then loads over AJAX. Search is a name field. Chips are All, Not enrolled, Enrolled, Enrolled + tested. Server paging is 50 per page. Empty copy: if `q` is empty, chip is All, and `total_rows` is 0, show There is no one to enroll. Any other result with `total_rows` 0 shows No matching staff. Hide `.page-title-clear` from the html/body app class, same as other Pattern C attendance pages. After enroll, test, or remove, reload list with the same `q`, chip, and `page`. If that page then has zero rows and `page` > 1, request page 1. Dead session on GET is `authenticate.php` (login redirect). Dead session AJAX is HTTP 401; page JS sends them to login.
- **AC-4**: Each card shows `last_name` then a space then `first_name`, the derived status, and actions. Do not append `staff_id` to the visible name. Two people with the same name look identical; actions still use `staff_id`. No phone on the card. Not enrolled: generic avatar (`fa-user` in `.md-class-result-card__avatar` is fine), Enroll, no `portrait_url`, avatar is not a view control. Enrolled or Enrolled + tested: the avatar is an `img` whose `src` is `portrait_url` from list, `alt` is the staff name, when `portrait_url` is present. If `portrait_url` is missing or the image errors, fall back to the generic avatar. The thumb is display only and may expire after 300 seconds. Tapping the avatar (portrait or fallback) always POSTs `view` when `can_view` is true, so the sheet gets a fresh URL. Do not auto refresh list thumbs. An expired view sheet: close and tap again. Actions when enrolled: Retake, Test, Remove. Do not add a separate View button. There is no phone on the card and no phone field on the capture sheet.
- **AC-5**: List rows are `staff.activation = 'activated'` in the current school database only, ordered by `last_name`, `first_name`, `staff_id`. Deactivated staff are omitted even if a current enrollment still exists. Status is derived: no current row → `not_enrolled` / Not enrolled; current and `tested_at` NULL → `enrolled` / Enrolled; current and `tested_at` set → `tested` / Enrolled + tested. `q` is trimmed; if longer than 80 characters, cut to 80 (do not error). Escape `\`, `%`, and `_` in `q` then `LIKE` with `ESCAPE '\\'` against `CONCAT_WS(' ', last_name, first_name)`. A two word query can match a full name. `page` that is missing, non integer, or `< 1` becomes `1`. A page past the end returns `rows: []` with the same `total_rows`. `status` is `all` (default), `not_enrolled`, `enrolled`, or `tested`. Any other `status` value is treated as `all`.
- **AC-6**: Enroll and Retake open a bottom sheet with live video, face oval, and the liveness module from spec 0004. The `<video>` has `autoplay` `muted` `playsinline`. Preview is mirrored with CSS `transform: scaleX(-1)`; captured frames stay unmirrored. After 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. Load `app/admin/scripts/staff-attendance-liveness.js` with an absolute `/app/admin/scripts/` URL and `?v=` `filemtime` via `get_v()`. Dynamic `import()` must not use a path relative to the page JS. Prompts, speech, cue tone, timeouts, camera blocked copy, and JPEG encode match spec 0004 AC-4 (`getUserMedia` `{ video: { facingMode: 'user' } }`, long side at most 720 px, `toBlob` `image/jpeg` quality `0.85` once, max 5242880 bytes). There is no file input fallback. There is no consent copy and no phone field. Always capture again. An existing B2 file is not enough to skip the camera.
- **AC-7**: `mint` for this page builds a pending key `{school_id}/{sanitized_school_name}/enrollments/pending-{staff_id}.jpg` using `sap_school_id` and the folder sanitize from spec 0004 AC-7. It must not use the final `{Last}_{First}-{staff_id}.jpg` key on mint, so a failed enroll cannot overwrite the last good portrait. One pending key per `staff_id`. Two admins capturing the same person overwrite that object; last PUT wins, except the live pending guard below. Browser PUT uses that pending `put_url` for 120 seconds, same SigV4 rules as spec 0004. If a current enrollment for this `staff_id` stores `photo_object_key` equal to that pending key, refuse mint with `Please try again.` and do not sign a PUT. That stops a Retake from overwriting the only live portrait when copy has not finished. Staff id missing, not an integer, or not in this school is `That staff member was not found.` Not activated is `That staff member is not activated.` `confirm_face` inputs: `csrf_token`, `staff_id`, `object_key`. Same staff msgs as mint. PHP HEADs the pending object (same JPEG type and size rules as spec 0004 confirm; fail msg `That photo is not a valid JPEG.`). PHP does not mint a GET URL and does not call `saf_detect`. PHP does not download portrait bytes. A valid JPEG returns `{status:success, object_key}`. Missing pending object is `That photo is missing.` and the sheet goes back to Start. Store fail uses spec 0004 store msg. Client `object_key` must pass `sap_object_key_allowed` (spec 0004 AC-13: regex `^[0-9]+/[A-Za-z0-9_]+/enrollments/[A-Za-z0-9_.-]+\.jpg$`, starts with this `{school_id}/`, no `..`) and must equal the pending key for that `staff_id` (exact pending name). Pending names pass the regex. Exact pending name is this extra check, and only on client supplied keys (mint is server built; confirm_face and enroll check the posted key). Missing `object_key` or a mismatch is `That photo is not in this school folder.` and PHP must not sign or delete it.
- **AC-8**: After `confirm_face` success, the sheet POSTs `enroll` with no consent checkbox and no phone field. Duplicate phone closes the sheet and toasts the error. Invalid staff phone toasts `Fix this staff member's phone number on the staff record.` and closes the sheet. `consent_at` is a PHP `Africa/Lagos` `Y-m-d H:i:s` string bound into the INSERT or the retake UPDATE, never MySQL `NOW()`, never from the client. Browser liveness is UX only. PHP does not prove the person was live and does not count faces. An allowed session can PUT any JPEG to a valid pending URL. PHP HEAD is the server gate that the object exists and is a JPEG. MediaPipe in the oval is the live face gate.
- **AC-9**: POST `enroll` inputs: `csrf_token`, `staff_id`, `object_key`. Do not read POST `phone` or POST `consent`. Copy `zap_normalize_ng_phone` and `zap_display_ng_phone` into the enrollments engine (or a small shared include both this engine and `staff_list.php` can load). Do not `require` `staff_list.php`. Missing `staff_id`, a non integer, or a staff id not in this school is `That staff member was not found.` (no row, no attempt). Staff in this school with `activation` not `activated` is `That staff member is not activated.` (no row, no attempt). Phone is `staff.phone_number` run through `zap_normalize_ng_phone`. Empty result returns `Fix this staff member's phone number on the staff record.` (no row, no attempt). `mint` uses that same phone check so capture does not start when the staff phone will not save. `enroll` always HEADs the posted pending key again (same JPEG rules as AC-7). PHP does not call `saf_detect`. HEAD fail msgs follow AC-7 and send the sheet back to capture when the object is missing or not a JPEG. Lock name is `sae_` plus a sanitized session `db_name` plus `_` plus `staff_id`. Sanitize `db_name` the same way spec 0004 sanitizes a folder slug (trim, non `[A-Za-z0-9]` to `_`, trim `_`, cut to 40, empty becomes `school`). `GET_LOCK(that name, 10)` before the DB write only; if the lock is not held, return `Please try again.` and write nothing. Release after COMMIT or rollback, before any B2 copy, HEAD, delete, or sweep. All `DATETIME` values are PHP Lagos `Y-m-d H:i:s` strings bound into SQL, never `NOW()`. Write order: (1) one DB transaction that `SELECT`s a current row for this `staff_id`; if found, UPDATE that same row: `photo_object_key` = the pending key, `phone_normalized` and `current_phone` from the staff phone, `consent_at` now, `tested_at` NULL, `replaced_at` now, replaced actor from AC-15, `storage_type` `backblaze`; `enrolled_at` and enrolled actor stay; if no current row, INSERT current with pending key, `tested_at` NULL, `consent_at` and `enrolled_at` now, enrolled actor from AC-15, `storage_type` `backblaze`, replaced columns NULL; COMMIT; release the lock; (2) after the lock is released, `sap_object_key` using this staff member’s names from the DB at enroll time (not mint time), `sap_copy` pending to that final key, HEAD the final object, `UPDATE` the same row’s `photo_object_key` to the final key only when it still stores the pending key, `DeleteObject` pending, then sweep. If that UPDATE changes 0 rows (Remove or another retake won), delete the final object only (it is unreferenced), skip sweep, and leave JSON success. Do not delete pending; it may still be the live key. Sweep lists `Prefix` `{school_id}/` (paginate `IsTruncated` until done, cap 50 list pages then stop, still success) and deletes an object only when `basename` ends with `'-' . $staff_id . '.jpg'` and the key is not the keep key. Do not treat `-12.jpg` as a match for staff `2`. Copy or HEAD failure after COMMIT leaves `photo_object_key` as pending; skip sweep and skip DeleteObject of pending (pending is the live portrait). JSON is still success. Sweep runs only when copy and HEAD succeeded; keep key is the new final key. Sweep failure after a good copy is still success. Do not write `staff_attendance_templates`. JSON includes `replaced` (true when step 1 updated an existing current row). Toast `Enrollment replaced.` when `replaced` is true, else `Staff enrolled.`
- **AC-10**: After a valid staff phone, if INSERT or the retake UPDATE fails on `uq_current_phone` (MySQL 1062), roll the transaction back, do not leave a new current row for a first enroll, and do not change the existing current row on retake. A 1062 is a phone clash with someone else. Do not parse other index names. Insert `staff_attendance_attempts` with `outcome` `fail`, `reason_code` `duplicate_phone`, `staff_id` NULL, `phone_raw` `substr` of the trimmed `staff.phone_number` to 32 bytes, `phone_normalized` the 13 digits (always set here because invalid phones never reach this path), GPS and location columns NULL, `ip` from `$_SERVER['REMOTE_ADDR']` only (no `X-Forwarded-For`), `user_agent` from `$_SERVER['HTTP_USER_AGENT']` truncated to 512, `created_at` the PHP Lagos string. Return `This phone is already enrolled.` Do not name the other staff member, even if they are deactivated and hidden on this list. After rollback, delete the pending object only when no current row for this `staff_id` still stores that pending key. If a remaining current row’s `photo_object_key` equals the pending key (an earlier copy failure), leave the object. Leave any final portrait that already belonged to the other staff member. This slice does not add a way to find that holder.
- **AC-11**: Retake is the same `enroll` action. AC-9 UPDATEs the existing current row. It does not INSERT a second row. `tested_at` becomes NULL. Unique clash on the staff phone still follows AC-10. GET_LOCK is the concurrency control; do not retry the transaction in a loop.
- **AC-12**: POST `remove` inputs: `csrf_token`, `staff_id`. Confirm wording: `This staff member will not be able to check in until enrolled again. The portrait file will be deleted.` Missing `staff_id` or a non integer is `That staff member was not found.` If no current row, `{status:error, msg:'That staff member is not enrolled.'}` and no writes and no B2 calls. A current row for a deactivated staff member still runs (the list hides them; a crafted POST still deletes the row and the file). Success: `GET_LOCK` the same tenant scoped name as AC-9; DELETE the current row; release the lock; then delete B2 objects. Toast `Enrollment removed.` Delete order after the lock is released: if the deleted row’s `photo_object_key` is a nonempty string and passes `sap_object_key_allowed` (spec 0004 AC-13) for this school, `sap_delete` that key; then sweep with keep key empty so every object under `Prefix` `{school_id}/` whose `basename` ends with `'-' . $staff_id . '.jpg'` is deleted, including `pending-{staff_id}.jpg`. Cap sweep at 50 list pages then stop, still success. Do not treat `-12.jpg` as a match for staff `2`. Reuse `sae_sweep`. `remove` does not require matching config. If B2 config is incomplete or `school_id` cannot be resolved, still delete the row, skip B2 delete, and return success. DeleteObject or sweep failure after the row is gone is still success; the enrollment is gone; a leftover object is an orphan. Empty `photo_object_key` skips the first delete and still sweeps. Re-enroll after Remove INSERTs a new current row.
- **AC-13**: Test opens a bottom sheet with the same oval and liveness as AC-6. POST `test` is multipart: `csrf_token`, `staff_id`, file field `image`. Missing `image`, any `UPLOAD_ERR_*`, size 0, size over 5242880, or JPEG magic not `0xFF 0xD8 0xFF` returns `That photo is not a valid JPEG.` PHP must not store that frame in B2. If PHP `upload_max_filesize` or `post_max_size` is below 5 MB, that host limit wins and the same msg applies. Missing `staff_id` or a non integer is `That staff member was not found.` Load the current enrollment for that `staff_id`. If none, `That staff member is not enrolled.` A current row for a deactivated staff member still runs. HEAD `photo_object_key` first (pending or final, whatever the row stores). Missing or bad type or size is `That photo is missing.` Do not call verify. PHP does not call `saf_detect`. Then `saf_verify` with `reference_url` (`sap_presign_get` of that same key, `expires_in` 300 seconds, spec 0004 AC-10) and `captured_bytes`. `match` true: `UPDATE staff_attendance_enrollments SET tested_at = ? WHERE enrollment_id = ? AND status = 'current' AND photo_object_key = ?` using the loaded id and the `photo_object_key` loaded at the start of Test. If the UPDATE changes 0 rows, `That staff member is not enrolled.` (a Retake changed the portrait). If the row was already tested, this refreshes `tested_at`. Msg `Face test passed.` `match` false: leave `tested_at`, msg `Face test did not pass.` Do not show similarity. Do not insert an attempts row. Verify `no_face` uses `No face was detected.` Other typed engine statuses use spec 0005 msgs. Browser liveness is UX only; `tested_at` means an allowed admin posted a matching JPEG, not that PHP proved a live person.
- **AC-14**: Still show the list and status when B2 or matching is down. It is not the fatal retry screen. `remove` and `list` always run. School missing (`school_id` cannot be resolved from the switch list) wins over config msgs on every hidden action: `challenges`, `mint`, `confirm_face`, `enroll`, `test`, `view`. Msg `This school is missing from the switch list.` Enroll, Retake, mint, confirm_face, and challenges need B2 plus school only. Matching config is not required for those; Node down must not block enroll. Test needs B2 plus school plus matching. View needs B2 plus school. Hide the matching actions with `The matching service is not configured.` Hide store actions with `The photo store is not configured.` If both apply to Test, concatenate both sentences with a space. Those same msgs are HTTP 200 `{status:error, msg}` on the hidden actions. Banner on the page uses the same sentences. `can_enroll` and `can_retake` are true when enrolled state allows and B2 plus school are ready (matching not required). `can_test` also needs matching. `can_view` needs enrolled plus B2 plus school. `can_remove` stays when enrolled. `sap_school_id` / `sap_object_key` own school_id and slug, same as spec 0004. Duplicate switch list matches: first entry whose `db_name` equals the session, as spec 0004.
- **AC-15**: AJAX lives on `staff_attendance_enrollments.php` via `admin/includes/staff_attendance_enrollments_ajax.php`. Include the ajax file after `init.php` and before `authenticate.php`. Engine functions live in `admin/includes/staff_attendance_enrollments_engine.php`, take `mysqli` plus a `$ctx` array, return arrays, never echo, and never read `$_POST` or `$_GET`. Face functions stay `saf_*` with `$ctx` only. Photos helpers stay `sap_*` in `admin/includes/staff_attendance_photos_engine.php`. JSON uses `json_encode(..., JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)`. Success JSON uses `{status:success}`. Each JSON body includes a `csrf_token` (the client uses the latest completed response; it is not a one shot nonce that invalidates an in flight POST). The GET page prints `$security->create_csrf_token()` into page JS for the first `list` POST. During capture, do not fire a parallel `list` that would race the token. 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()`. `request_is_same_domain` compares scheme, host, and port. Scheme is `https` when `HTTPS` is on, else when `SERVER_PORT` is 443, else when `REQUEST_SCHEME` is `https`, else `http`. Do not read `X-Forwarded-Proto`. Prefer `HTTP_REFERER`; when it is empty, use `HTTP_ORIGIN` unless that value is empty or the string `null`. Page JS `fetch` uses `referrerPolicy: 'origin'`, so Origin is the fallback this page needs. This helper is shared with desktop `admin/` writes that already call it. 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']`. No extra school copy. `enrolled_actor_type` / `replaced_actor_type` is `$_SESSION['table_name']` when that value is `admin`, `director`, `backend`, or `staff`; if `table_name` is anything else and `$info['status']` is `admin`, store `admin`. For `admin`, `director`, `backend`, `owner`, or `account`, `enrolled_actor_id` / `replaced_actor_id` is `(int) $info['executive_id']` from the owner `executives` table when that key is set and > 0, else `(int) $_SESSION['user_id']` when that is > 0, else NULL. Do not read `$info['staff_id']` for those roles. For `staff`, the id is `(int) $info['staff_id']` when set and > 0, else session `user_id`. Page JS is `app/admin/scripts/staff_attendance_enrollments.js` with `?v=` `filemtime` via `get_v()`.
- **AC-16**: `challenges` and `mint` POST on this same enrollments URL and call `sap_pick_challenges` / `sae_pending_key` / `sap_presign_put`. Do not POST to `staff_attendance_photos.php`. `mint` staff msgs follow AC-7 (not found, not activated, live pending refuse). Capture stays disabled from mint until `confirm_face` returns (or until PUT retries are exhausted). Sheet actions stay disabled from tap until JSON returns. The settings style re-enable if the body is not JSON rule applies only to PHP AJAX, not to the B2 PUT. PUT not HTTP 200 retries that same `put_url` until `expires_in`, then `The photo did not upload.` Liveness timeouts and fail copy match spec 0004. `challenges` uses the same config hide as mint (AC-14: B2 plus school, matching not required).
- **AC-17**: Engine tests live in `admin/includes/staff_attendance_enrollments_engine_test.php` and run as `php admin/includes/staff_attendance_enrollments_engine_test.php`. Same origin cases live in `admin/includes/security_origin_test.php` (`php admin/includes/security_origin_test.php`): scheme mismatch, non default port, Origin when Referer is empty, and missing both headers. Cover at least: derived status, pending key shape, invalid staff phone, universal admin actor id from `executive_id`, duplicate_phone attempt shape (staff_id NULL), duplicate_phone does not delete pending when a current row still stores that key, retake keeps the same `enrollment_id` and stamps `replaced_at`, prefix refuse, JPEG magic on test bytes, write order that inserts with pending key, copy failure skips sweep, lock name includes sanitized `db_name`, mint refuses when current `photo_object_key` equals the pending key, `confirm_face` success on a valid JPEG without calling `saf_detect`, `view` uses the current row key and refuses a client `object_key`, `view` refuses another school prefix, list omits `portrait_url` and sets `can_view` false for `not_enrolled` and for an allowlist fail, list JSON has no `object_key` / `photo_object_key` field, `remove` DELETEs the row then calls `sap_delete` and sweep with empty keep key. Live B2 and live Node are not required. Inject fakes through optional `$ctx['sap']` and `$ctx['saf']` callables (same names as the real helpers); when omitted, call the real `sap_*` / `saf_*`. The engine source must not call `saf_detect`. Never log image bytes, data URIs, `face_api_key`, or full `put_url` / `get_url`.
- **AC-18**: POST `view` inputs: `csrf_token`, `staff_id`. PHP must not read `object_key` from the client. Missing `staff_id` or a non integer is `That staff member was not found.` Load the current enrollment for that `staff_id`. If none, `That staff member is not enrolled.` A current row for a deactivated staff member still runs. HEAD the row’s `photo_object_key` (pending or final). Missing or bad type or size is `That photo is missing.` A stored key that fails `sap_object_key_allowed` (spec 0004 AC-13) for this school is `That photo is not in this school folder.` and PHP must not sign or delete it. Then `sap_presign_get` for 300 seconds. Return `{status:success, get_url, expires_in:300}`. Never log `get_url`. The enrollments page includes a view `<dialog class="sap-sheet">` (copy the Photos portrait sheet markup). Keep the `.sap-sheet` rules already in `app/admin/styles/zapapp.css`. Do not delete those styles when retiring the Photos page. Set the sheet `img` `src` to `get_url`. Bytes do not stream through PHP. `view` writes no rows. Hide `view` when AC-14 hides it. List may include `portrait_url` and `portrait_expires_in` (300) for rows where `can_view` is true so the card thumb can render without a second round trip. List runs `sap_object_key_allowed` then `sap_presign_get` of `photo_object_key` without HEAD (speed). Allowlist fail or empty `photo_object_key` omits `portrait_url` and sets `can_view` false. `can_view` is true only when status is not `not_enrolled` and B2 is configured and school_id resolved and the stored key passed the allowlist. List JSON must not include `object_key` or `photo_object_key`.
- **AC-19**: `app/admin/staff_attendance_photos.php` is not an admin tool. Include the photos ajax file after `init.php` and before `authenticate.php` so dead session POST is JSON. GET for an AC-1 role redirects to `staff_attendance_enrollments.php` (HTTP 302). Any other logged in role is sent to the app home. POST status order: HTTP 401 `{status:error, msg:unauthorized}` if the session is dead; HTTP 403 `{status:error, msg:forbidden}` if CSRF or role fails; then HTTP 410 `{status:error, msg:'Use Enrollments.'}` for any POST (with or without `action`). That POST must not mint, confirm, delete, list, or view B2. Keep `admin/includes/staff_attendance_photos_engine.php` (`sap_*`) and `admin/includes/staff_attendance_photos_engine_test.php`. `app/admin/scripts/staff_attendance_photos.js` is unused and may be deleted. Spec 0004 storage contract (key shape, SigV4, CORS, `sap_*`) stays in force. Spec 0004 page UI, nav tile, and photos AJAX write path do not.

## Decision

**Chosen option**: Option 5: Enrollments owns people and portraits. The Photos page is retired. Capture, view, and delete live on the Enrollments roster. Spec 0004 `sap_*` helpers and B2 key shape stay.

Admins work from a staff status list. Capture always runs liveness. A pending B2 key holds the new JPEG until HEAD and save succeed. Test posts one JPEG to PHP and does not keep it. PHP does not call `saf_detect`. Templates stay unused. Remove deletes the file. There is no second camera page.

**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**:

One `ALTER` adds `storage_type`. Target rows stay spec 0001 tables in the school database already selected by `$_SESSION['db_name']`. Ids that point at `staff.staff_id` are logical (no MySQL foreign keys). Run `docs/specs/0006-face-enrollment/alter-storage-and-one-row.sql` on each school.

| Piece | Type | Required | Notes |
|---|---|---|---|
| `staff` | existing row | read only | `staff_id`, `first_name`, `last_name`, `phone_number`, `activation`. Unchanged. |
| `staff_attendance_enrollments` | existing row | yes to enroll | Insert current on first enroll. Retake UPDATEs that row. Remove DELETEs it. |
| `enrollment_id` | INT UNSIGNED | PK | Auto increment |
| `staff_id` | INT UNSIGNED | yes | Logical to `staff.staff_id` |
| `phone_normalized` | CHAR(13) | yes | `zap_normalize_ng_phone(staff.phone_number)` at enroll and retake |
| `status` | VARCHAR(16) | yes | `current` or `replaced` |
| `current_staff_id` | INT UNSIGNED NULL | yes when current | Copy of `staff_id` only while current |
| `current_phone` | CHAR(13) NULL | yes when current | Copy of `phone_normalized` only while current |
| `consent_at` | DATETIME | yes | Lagos now at insert and at retake |
| `photo_object_key` | VARCHAR(255) | yes | Pending key at COMMIT, then the final spec 0004 key after copy. Never a public URL. Readers (list thumb, view, Test, later check in) use whatever key the row stores. |
| `storage_type` | VARCHAR(32) | yes | `backblaze` for the current bucket. Default `backblaze`. Written on insert and retake. List, view, Test, and Remove do not branch on it. |
| `tested_at` | DATETIME NULL | no | Lagos now on Test match only. Cleared on retake. |
| `enrolled_at` | DATETIME | yes | Lagos now at first insert. Unchanged on retake. |
| `enrolled_actor_type` | VARCHAR(16) | yes | Session role at first insert |
| `enrolled_actor_id` | INT UNSIGNED NULL | no | `executives.executive_id` for Universal admin and other executive roles; `staff.staff_id` for staff |
| `replaced_at` | DATETIME NULL | no | Lagos now on retake of the same current row. NULL until the first retake. |
| `replaced_actor_type` | VARCHAR(16) NULL | no | Session role at last retake |
| `replaced_actor_id` | INT UNSIGNED NULL | no | Same id rule as enrolled actor, at last retake |
| Pending B2 object | JPEG | until save or fail | `.../enrollments/pending-{staff_id}.jpg` |
| Final B2 object | JPEG | after save | spec 0004 final key |
| `staff_attendance_attempts` | insert only | duplicate phone only | `fail` / `duplicate_phone`, `staff_id` NULL |
| `staff_attendance_templates` | unused | no | Do not insert or delete |

Relationships: one staff member to at most one enrollment row. One current enrollment to one photo key. List status is derived at read time (never stored). Test similarity is displayed never; it is not stored. Portrait bytes live in B2, not in MySQL.

**State transitions**:
- None → current with pending key (valid pending JPEG, valid staff phone, unique phone, COMMIT). Then copy to final when B2 allows.
- Current → same current row updated (Retake). `tested_at` starts empty. `replaced_at` and replaced actor are set. `enrolled_at` stays. Sweep runs only after a successful copy, keep key = final.
- Current → gone (Remove DELETEs the row). Then delete the stored key and sweep remaining `-{staff_id}.jpg` objects, including pending.
- `tested_at` NULL → set (Test `match` true on that `enrollment_id`). Fail leaves it. Re-test refreshes it. Retake clears it on the same row.
- Pending object → deleted on duplicate_phone only when no current row still stores that key, after a successful copy to final, or on Remove sweep. Kept on copy failure after COMMIT until Remove or a later successful copy. Mint is refused while a current row stores the pending key.
- Liveness fail → no mint (client only, spec 0004 copy).
- PUT fail after retries → no `confirm_face`.
- View → no state change.

**API surface**:

Page GET `app/admin/staff_attendance_enrollments.php`. AJAX POST on that same URL. Browser PUT and GET stay on B2 presigned URLs. Photos page GET redirects; Photos POST is 410.

| Action | Method | Key inputs | Key outputs | Auth | Key errors |
|---|---|---|---|---|---|
| Page | GET | session | Pattern C chrome, chips, search, list mount, view sheet, csrf_token | AC-1 | redirect home |
| list | POST | csrf_token, q (opt), status (opt), page (opt) | rows[], enrolled_count, page, page_size, total_rows, csrf_token, config flags | AC-1 | 401, 403 |
| challenges | POST | csrf_token | challenges[] of two distinct moves, csrf_token | AC-1 | 401, 403, config, missing school |
| mint | POST | csrf_token, staff_id | object_key (pending), put_url, headers, expires_in 120 | AC-1 | bad staff, inactive, live pending, config, missing school |
| Browser PUT | PUT | JPEG body, Content-Type image/jpeg | B2 200 | presigned URL | 403 expired or CORS; oversize |
| confirm_face | POST | csrf_token, staff_id, object_key | status success, object_key | AC-1 | missing object, bad JPEG, prefix fail |
| enroll | POST | csrf_token, staff_id, object_key | status success, replaced | AC-1 | invalid staff phone, inactive, missing object, bad JPEG, duplicate_phone, lock fail, prefix fail |
| view | POST | csrf_token, staff_id | get_url, expires_in 300 | AC-1 | not enrolled, missing photo, prefix fail, config, missing school |
| test | POST multipart | csrf_token, staff_id, image | status success, msg | AC-1 | not enrolled, bad JPEG, match false, verify no_face, missing photo, service |
| remove | POST | csrf_token, staff_id | status success | AC-1 | not enrolled |
| Browser GET | GET | signed query | JPEG bytes for thumb, view sheet, and verify reference | presigned URL | 403 expired |
| Photos GET | GET | session | 302 to enrollments | AC-1 | redirect home |
| Photos POST | POST | action | HTTP 410 Use Enrollments. | AC-1 | 401, 403, 410 |

`list` row shape: `{staff_id, name, status, can_enroll, can_retake, can_test, can_remove, can_view, portrait_url, portrait_expires_in}` where `status` is `not_enrolled` | `enrolled` | `tested`. `portrait_url` and `portrait_expires_in` are omitted or empty when `can_view` is false. `can_enroll` is true when status is `not_enrolled` and B2 is configured and school_id resolved (matching is not required). `can_retake` needs an enrolled row plus B2 plus school_id. `can_test` needs an enrolled row plus B2 plus school_id plus matching. `can_view` needs an enrolled row plus B2 plus school_id plus an allowlisted stored key. `can_remove` is true when status is not `not_enrolled` (Remove does not need B2 or matching). Page also returns `photo_configured`, `match_configured`, `school_ready` booleans so the UI can show AC-14 msgs. List JSON has no `object_key` and no `photo_object_key`.

Private engine names `/develop` should keep: `sae_list`, `sae_pending_key`, `sae_enroll`, `sae_remove`, `sae_test`, `sae_confirm_face`, `sae_derive_status`, `sae_actor`, `sae_phone_prefill`, `sae_config_flags`, `sae_sweep`. Add `sae_view`. Add `sae_lock_name` (sanitized session `db_name` plus staff_id). List SELECT must include `e.photo_object_key` so thumbs can be signed. Do not copy SigV4 into the enrollments engine. Optional `$ctx['sap']` and `$ctx['saf']` override those helpers in tests.

**Value sourcing**:

| Action | Value produced / displayed | Source |
|---|---|---|
| Page / list | who may open | `staff_attendance_can_access` as spec 0003 / 0004 |
| Page | school name in hero | `$school_config['school_name']` |
| Page | hero stat | `COUNT(*)` of enrollments with `status = current` in this database (includes deactivated staff) |
| list rows | staff_id, name | `staff` where `activation = 'activated'`, name `last_name` space `first_name`, order plus `staff_id` |
| list rows | status | derived from current enrollment plus `tested_at` (AC-5) |
| list | total_rows | COUNT of the same filter as the page (q, status chip, activated) |
| list | page_size | constant 50 |
| list | q | POST `q`, trimmed, cut to 80, `LIKE` on `CONCAT_WS(' ', last_name, first_name)` with escaped wildcards |
| list | page | POST `page`, missing or `< 1` or non integer → 1 |
| list rows | can_view | enrolled and `photo_configured` and `school_ready` and `sap_object_key_allowed` on the stored key |
| list rows | portrait_url | `sap_presign_get` of current `photo_object_key` after allowlist when can_view, else omitted |
| list rows | portrait_expires_in | 300 when portrait_url is set, else omitted |
| Folder school_id | integer in the key prefix | `sap_school_id` as spec 0004 (first switch list `db_name` match) |
| Pending key | object_key on mint | AC-7 from school_id, school slug, staff_id |
| enroll | replaced | true when a current row for this staff_id was updated in the same transaction |
| enroll | photo_object_key at COMMIT | pending key; final key after copy |
| Who enrolled | actor type | `$_SESSION['table_name']` per AC-15 |
| Who enrolled | actor id | `$info['executive_id']` for Universal admin and other executive roles; `$info['staff_id']` for staff; else session `user_id`; else NULL |
| Clocks | consent_at, enrolled_at, replaced_at, tested_at, attempts.created_at | PHP `date('Y-m-d H:i:s')` in `Africa/Lagos`, never `NOW()` |
| Test JPEG | captured_bytes | multipart field `image` |
| Reference image | verify `reference_url` | `sap_presign_get` of the row’s current `photo_object_key` after HEAD, expires_in 300 |
| View image | get_url | `sap_presign_get` of the row’s current `photo_object_key` after HEAD, expires_in 300 |
| View staff | which key | current enrollment for POST `staff_id`, never a client `object_key` |
| Lock | enroll and remove | `GET_LOCK('sae_' . sanitized session db_name . '_' . staff_id, 10)`, held for the DB write only |
| Final key | `photo_object_key` after copy | spec 0004 AC-7 via `sap_object_key` at enroll time, then `sap_copy` |
| Saved phone | `phone_normalized` | `staff.phone_number` through `zap_normalize_ng_phone` at enroll and retake |
| Storage | `storage_type` | constant `backblaze` until a later store is added |
| Live face presence | oval can capture | MediaPipe Face Landmarker in `staff-attendance-liveness.js` (spec 0004 module). PHP does not call `saf_detect`. |
| Test match | `tested_at` | set only when `saf_verify` `match` is true, UPDATE by `enrollment_id` |
| Duplicate clash | attempts row | spec 0001 admin enroll clash rule |
| Duplicate msg | `This phone is already enrolled.` | constant; other staff name is not loaded |
| Enroll toast | `Staff enrolled.` or `Enrollment replaced.` | constant via `notify({ text }, 'success')` |
| Remove confirm | dialog text | AC-12 constant |
| Remove toast | `Enrollment removed.` | constant via `notify({ text }, 'success')` |
| Remove B2 delete | which objects | replaced row `photo_object_key` then sweep suffix `-{staff_id}.jpg` with empty keep key |
| Test msgs | `Face test passed.` / `Face test did not pass.` | constants, no similarity |
| Config msgs | hide capture, test, view | school missing wins; B2 for enroll/view; B2 plus matching for test |
| Empty copy | which sentence | AC-3: All plus empty q plus total_rows 0 vs any other zero |
| Allowlist | stored or posted key | `sap_object_key_allowed`, spec 0004 AC-13 |
| Sweep cap | stop after | 50 `sap_list` pages, still success |
| First list CSRF | token on GET page | `$security->create_csrf_token()`, then each JSON `csrf_token` |
| Same origin | scheme plus host plus port | `HTTPS`, then port 443, then `REQUEST_SCHEME`, else `http`. Referer first, else Origin. Do not read `X-Forwarded-Proto`. |
| Tenant SQL | which staff and enrollments | mysqli already on `$_SESSION['db_name']` |
| JSON envelope | status, msg, csrf_token | same flags as spec 0003 / 0004 |
| Nav icon | tile image | `icon/clock-in.png` |
| Liveness module | Face Landmarker in the browser | spec 0004 module path |
| Copy pending to final | B2 object | `sap_copy` then HEAD of the destination |
| Photos GET | redirect target | `staff_attendance_enrollments.php` |
| Photos POST | 410 msg | constant `Use Enrollments.` |

**Key invariants**:
- Browser matching and liveness are UX only. MediaPipe requires a face in the oval before capture. PHP HEAD decides the pending object is a JPEG. PHP `saf_verify` decides Test match. PHP does not call `saf_detect`.
- Mint for enrollment uses the pending key only. Refuse mint when a current row already stores that pending key. The DB row is committed before any overwrite of the final key.
- PHP never receives enrollment portrait bytes (mint, `confirm_face`, enroll, list, view, remove). Test is the one action that receives JPEG bytes, in memory only, never written to B2 or disk.
- `photo_object_key` on a current row may be the pending key until copy succeeds. Thumb, view, Test, and later check in must read the stored key, not assume the final name.
- Writers keep shadow uniques in sync with `status`. Retake UPDATEs the current row in one transaction. GET_LOCK is tenant scoped (`sae_` plus sanitized session `db_name` plus staff_id) and covers the DB write only, not B2.
- Check in identity is `enrollments.phone_normalized`. That value is copied from `staff.phone_number` at enroll and retake. Editing `staff.phone_number` does not move attendance identity until the next enroll or retake. Saving here does not update `staff`.
- Client never sends `db_name`, `school_id`, `face_count`, `tested_at`, actor fields, `enrollment_id`, or `object_key` on `view` / `list` / `remove`.
- Do not write templates. Do not write attempts except `duplicate_phone`.
- Do not call `check_csrf_token()`.
- Do not read `X-Forwarded-Proto` when building the same origin scheme. Origin is the fallback only when Referer is missing.
- Do not log images, data URIs, `Authorization`, `face_api_key`, or full presigned URLs.
- Unique `current_phone` is enforced by MySQL. After replacing this staff member’s current row, a 1062 is `duplicate_phone` (AC-10).
- Remove deletes B2 objects after COMMIT. A leftover file after a delete fail is an orphan with no UI.
- Extra school copy is out.
- Always recapture. Do not enroll from an existing final object without a new pending PUT.
- The Photos page must not mint or delete after this change.

**Security model**:
- Personal data: staff face portraits, enrollment phone, consent time, actor ids. Product bar is private B2 plus `consent_at` as enroll time. This is not a completed legal review. Scope still defers that review.
- Readers and writers: roles in AC-1, current school staff, 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.
- Prefix allowlist on every stored key used for view, thumb, delete, and on every client supplied `object_key` (mint/confirm/enroll only).
- Duplicate phone errors must not reveal the other enrolled person.
- Test JPEG is biometric. It goes to PHP then to the configured Face API origin only. It is not stored.
- View and list thumbs pass a short GET URL of the enrolled portrait to the browser. PHP must not log it. Keep GET expiry at 300 seconds.
- CSRF and same origin on PHP actions. Scheme, host, and port must match. Origin is used when Referer is missing. Do not trust `X-Forwarded-Proto` (same rule as AC-10 refusing `X-Forwarded-For` for attempt `ip`). Prepared statements for staff and enrollment SQL.
- Admin abuse control is disabled buttons until JSON returns, plus GET_LOCK on enroll and remove. No public rate limit on this page (session gated).
- Audit: actor columns on enroll and replace; `duplicate_phone` attempt row (those rows will sit next to check in fails in later reports). Test success only sets `tested_at`. Remove has no extra audit table.
- Browser liveness is not a control. PHP does not count faces on enroll. A session that can mint may PUT any JPEG. Test match is Node verify.
- Closing Photos POST (HTTP 410) stops a consent free capture path.

**Configuration required**:
None new. Reuse spec 0004 B2 keys and spec 0005 Face API keys in `private/includes/ask_zap.php`. Incomplete keys are AC-14.

**Critical test scenarios** (each maps to an acceptance criterion in ## Requirements):
- Happy path: allowed role opens Enrollments, searches an activated staff member, captures with liveness, PUT pending, `confirm_face` HEADs a valid JPEG, `enroll` commits current with the pending key then copies to final, list shows Enrolled with a portrait thumb, tap opens the view sheet, Test match sets Enrolled + tested, verifies **AC-1**, **AC-2**, **AC-3**, **AC-4**, **AC-6**, **AC-7**, **AC-8**, **AC-9**, **AC-13**, **AC-16**, **AC-18**
- Failure case: a missing or non JPEG pending object returns the AC-7 msg and writes no row; invalid staff phone writes nothing; duplicate phone logs `duplicate_phone` with `staff_id` NULL and does not name the other person; Test mismatch leaves `tested_at` NULL; Test verify `no_face` uses `No face was detected.`; view of a missing object uses `That photo is missing.`; view ignores a posted `object_key`, verifies **AC-7**, **AC-9**, **AC-10**, **AC-13**, **AC-18**
- Auth/permission: a teacher session is sent home; dead session POST returns HTTP 401 JSON; a key from another school prefix is refused; deactivated staff do not appear and mint/enroll refuse them; Photos GET for an allowed role lands on Enrollments; Photos POST is HTTP 410; same origin accepts Origin when Referer is empty and refuses a scheme mismatch, verifies **AC-1**, **AC-5**, **AC-9**, **AC-15**, **AC-19**
- Replace and remove: Retake UPDATEs the same current row, stamps `replaced_at`, and clears tested; Remove DELETEs the row then deletes the stored portrait and other `-{staff_id}.jpg` keys; check in identity is gone, verifies **AC-11**, **AC-12**

## Build plan

Tracer Bullet: the enroll write path already exists. Thicken view and delete on that same page, then cut the Photos page off. No schema migration.

1. [x] Page shell, nav tile, access, list AJAX with derived status, search, chips, paging 50, skeleton, hero `enrolled_count`, empty copy, satisfies **AC-1**, **AC-2**, **AC-3**, **AC-4**, **AC-5**, **AC-15**
2. [x] Capture sheet: `challenges`, pending `mint`, browser PUT, `confirm_face` HEAD JPEG gate, satisfies **AC-6**, **AC-7**, **AC-8**, **AC-14**, **AC-16**
3. [x] `enroll` GET_LOCK, insert current with the pending key, then copy to final, duplicate_phone attempt, inactive and invalid phone refuses, satisfies **AC-9**, **AC-10**, **AC-17**
4. [x] Retake as replace transaction, Remove with confirm copy, race on existing current treated as replace, satisfies **AC-11**, **AC-12**
9. [x] Drop consent tick and phone field; copy staff phone; stamp `consent_at` now; Universal admin actor id from `executives`; retake UPDATEs one row; Remove DELETEs; `storage_type` `backblaze`, satisfies **AC-8**, **AC-9**, **AC-11**, **AC-12**, **AC-15**, **AC-17**
5. [x] Test multipart verify, `tested_at` on match only, no `saf_detect`, config hide of capture actions, CSRF and log rules, engine test file, satisfies **AC-13**, **AC-14**, **AC-15**, **AC-17**
6. [x] Portrait thumbs on enrolled cards, `sae_view`, view sheet in enrollments.php, keep `.sap-sheet` in zapapp.css, list allowlist then `portrait_url` / `can_view`, enroll without matching, hide view when store or school is down, satisfies **AC-3**, **AC-4**, **AC-14**, **AC-15**, **AC-18**
7. [x] Remove deletes the stored key then sweeps with empty keep key, new confirm copy, tenant scoped lock around DB only, skip sweep on copy fail, duplicate phone pending guard, mint refuse when current key is pending, engine tests, satisfies **AC-7**, **AC-9**, **AC-10**, **AC-12**, **AC-17**
8. [x] Nav drops the Photos tile (three tiles), `staff_attendance_photos.php` GET redirects, POST returns 410, unused Photos JS may be deleted, satisfies **AC-2**, **AC-19**
10. [x] Same origin compares scheme, host, and port; Origin is the fallback when Referer is missing; do not read `X-Forwarded-Proto`; `security_origin_test.php` covers the direct cases, satisfies **AC-15**

## Migration plan

**Strategy**: big bang on one host for this schema tweak. Add `storage_type` with a default, then delete extra enrollment rows, then ship the PHP that UPDATEs on retake.

**Phases**:
1. Run `docs/specs/0006-face-enrollment/alter-storage-and-one-row.sql` on each school database (`ADD COLUMN` then delete non current extras).
2. Deploy the enrollments engine that copies staff phone, stamps executive actor ids, UPDATEs on retake, and DELETEs on Remove.

**Rollback**: restore the previous PHP. The extra `storage_type` column can stay. Deleted replaced history rows are not restored.

**Risks**: if you deploy PHP before the ALTER, INSERT and retake fail until `storage_type` exists. Deleted history cannot be undone. Portrait files deleted in production are not recoverable from this app (B2 versioning is off).

## Consequences

**Positive**:
- Check in later can look up one current enrollment by phone and compare against a private portrait captured through the oval.
- One admin path for capture, browse, and delete. No second camera that skips consent.
- Pending keys protect the last good portrait when enroll fails after PUT. The DB row commits before the final key is overwritten, so an insert fail cannot replace a still current portrait.
- Test uses the same `saf_verify` check in will use, without GPS and without an attempts row.
- Remove actually frees the biometric file.

**Negative / tradeoffs**:
- There is no consent checkbox. `consent_at` is the enroll or retake clock, not a proven informed opt in. The deferred NDPR review still sits on the scope list.
- Deactivated staff disappear from this list even if they still have a current enrollment. This page cannot remove those leftovers. Check in can still fail them as `inactive_staff`. A crafted Remove POST can still delete their row and file.
- Test sends a live JPEG through PHP, which breaks the rule that PHP never sees face bytes, for that one action only.
- A client that skips the oval can still PUT a JPEG with no face. PHP HEAD will accept it if the object is a JPEG.
- CopyObject adds a photos engine helper. If copy fails after COMMIT, the current row still points at pending and check in must accept that key.
- Duplicate phone blocks two current enrollments on one number, with no UI to name who holds it.
- A replaced row’s old `photo_object_key` may point at bytes already overwritten. Portrait history is not kept in B2. Remove after a retake deletes the live file, not a history.
- JPEGs stored from the old Photos page with no enrollment row have no admin UI after cutover.
- List payloads include short GET URLs. A leaked list JSON is a five minute window on those portraits.
- Same origin scheme is guessed from `HTTPS`, then port 443, then `REQUEST_SCHEME`. A host behind a TLS proxy that leaves those unset would fail CSRF on this page and on other desktop POSTs that share the helper. This cPanel host sets `HTTPS`. Do not paper over that with a client supplied `X-Forwarded-Proto`.

**Neutral**:
- `staff_attendance_templates` remains empty. Check in compares JPEGs.
- Desktop `admin/` copy is still deferred.
- No extra school copy.
- Function prefix `sae_*`. The photos engine stays the owner of SigV4. The face engine stays the owner of Node HTTP.
- Spec 0004 remains Accepted for storage. Its page UI is retired by this spec.

## Follow-up

- [ ] Staff check in spec must verify against the current row’s `photo_object_key`, which may still be a pending key if copy failed. Public check in should still call `saf_detect` then `saf_verify`. Do not copy this enrollment skip of detect onto that public page.
- [ ] A way to list deactivated staff who still have `status = current`, so an admin can Remove them from this module.
- [ ] Leftover B2 objects with no current enrollment (old Photos captures, failed deletes) have no UI. A later cleanup job or a one day lifecycle on `enrollments/pending-` can cover them.
- [ ] Spec 0001 follow-up said the matching spec would write templates. Spec 0005 and this spec leave that table unused until the API returns an embedding you want to store.
- [ ] NDPR or other legal review of face portraits, `consent_at` as enroll time, and test frames through PHP is not done here.
- [ ] Desktop admin copy remains deferred.
- [ ] Settings nav subtitle still says face match (spec 0003 follow-up). Optional while touching the nav.
- [ ] `b2-cloud-storage` is installed at `.cursor/skills/b2-cloud-storage/` and is not in `AGENTS.md` yet. Nested `app/AGENTS.md` already names B2 keys. `/sync` can line that up without turning skills on for the whole repo.
- [ ] If a later host terminates TLS on a proxy that leaves `HTTPS`, `SERVER_PORT`, and `REQUEST_SCHEME` unset, do not add `X-Forwarded-Proto` here. Write a trusted hop spec first. This host does not need it.

## Rationale

Reasoning and options: see [rationale.md](rationale.md).
