# 0006 rationale. Staff face enrollment on the school app

## Context

> ⚠️ Premise note: The consent box starts ticked because using the service is treated as consent. Pre ticked consent is a known weak pattern for biometric data under NDPR style rules. This spec still records `consent_at` and blocks save if they untick. It does not prove informed opt in. Deactivated staff with a current enrollment are hidden, so this page cannot clear those rows from the list (a crafted Remove still works). Folding Photos away also hides any JPEG that was stored with no enrollment row. Remove now deletes the live file and cannot undo that from this app.

Staff attendance check in needs a current enrollment: one phone, one portrait, consent time, and later a test fact. Spec 0001 already has those columns. Spec 0004 already stores private JPEGs. Spec 0005 already counts faces (when a caller asks) and compares two images.

The first cut of this spec kept Photos as a file probe and Enrollments as the people list. That left two camera UIs. Photos could store a face with no consent and no row. Remove left the file in the bucket. You asked to put view and delete on Enrollments and to drop the Photos page, so an admin browses faces by staff member.

The general `staff_list.php` page is a wider staff tool with different roles. Attendance admin work already lives in a nav group. Putting status and capture on the general list would mix those access rules.

Not deciding this leaves check in with no current row to match, or leaves a second page that looks like enrollment but is not.

Forces: PHP 8 pages in `app/admin`, engines in `admin/includes/`, school database from the session, Tracer Bullet delivery, GA rigor, biometric portraits, unique current phone, unique current staff, no change to the `staff` table, no new Composer package.

## Options considered

### Option 1: New Enrollments page writing spec 0001 rows, reusing Photos capture and B2, MediaPipe in the booth, Node verify for Test, Photos page stays

A fourth attendance tile lists activated staff with derived status. Capture uses the existing oval and liveness module. Photos remains the file list, view, and delete path.

**Pros**:
- Matches the attendance nav family and leaves `staff_list.php` alone.
- Reuses signed storage and the match client instead of a second stack.
- Pending keys keep the last good portrait when enroll fails after PUT.

**Cons**:
- Two camera UIs.
- Photos can capture with no consent and no enrollment row.
- Remove does not delete the file.

### Option 2: Add status and actions onto `staff_list.php`

Enrollment hangs directly on the existing roster.

**Pros**:
- One staff list in the app.
- No extra tile.

**Cons**:
- `staff_list.php` is not gated by `staff_attendance_can_access`.
- Capture, B2, and matching would land in a large unrelated page.
- Scope asked not to change the `staff` table; stuffing this flow there still changes that page’s job.

### Option 3: Turn Photos into enrollment and drop a separate list

Object cards become staff cards. Capture already lives there.

**Pros**:
- One camera page.
- Fewer tiles.

**Cons**:
- Photos is a file probe (keys, sizes, delete). Enrollment is people, phone, consent, test, replace.
- File name cards do not show Not enrolled people who have no object yet.

### Option 4: Enroll from an existing Photos file, camera optional

Skip recapture when a JPEG already exists.

**Pros**:
- Faster when Photos already ran.
- Fewer PUT calls.

**Cons**:
- That file may be an old still with no liveness on this path.
- Consent would attach to a portrait the admin did not just see live.

### Option 5: Enrollments owns people and portraits. Photos page retired

Same write path as Option 1. Cards show the live portrait. Tap opens a signed view. Remove deletes the file. Photos GET redirects. Photos POST is 410. `sap_*` stays.

**Pros**:
- One admin path that actually enrolls.
- Browse by staff, not by file name.
- Delete matches the product ask: identity and file leave together.
- Spec 0004 storage helpers stay, so Test and later check in keep the same signer.

**Cons**:
- JPEGs stored on Photos with no enrollment row become invisible.
- Remove cannot restore a deleted object (B2 versioning is off).
- List JSON holds short GET URLs for thumbs.

## Rationale

Option 1 was the first cut: schema, private bucket, and match client exist; the missing piece was an admin people list. That cut kept Photos as the raw file tool on purpose so verify of storage could stay separate from verify of enrollment rows.

Amended 2026-10-28: you chose Option 5. The Photos camera was leftover probe UI. Admins who landed there could think they had enrolled someone. View and delete belong on the same roster that writes the row. Runner up was Option 3 (turn Photos into enrollment). That would hide staff who are not yet enrolled, because they have no object. A people list with a generic avatar for Not enrolled is the right browse.

A new page still keeps access on `staff_attendance_can_access` and keeps Pattern C with the other attendance tools. Pattern G chips carry the three statuses without storing a UI column. Always recapture so the save path always ran the camera. Browser liveness is still UX only. MediaPipe is the live face gate. PHP HEAD is the JPEG gate. Node verify is the Test gate. PHP does not call `saf_detect` on this page.

Approve is the Enroll save. Spec 0001 has no pending status. A second approver or a new column would reopen the data model.

The pending key is the implementation call that makes “delete the new object, keep the old portrait” true on a failed enroll. Copy after COMMIT is the extra cost. Inserting with the pending key first means a database fail cannot clobber a still current final portrait. GET_LOCK per staff_id is the concurrency control instead of a retry loop.

`confirm_face` still exists so enroll runs only after the pending JPEG HEADs as valid. `enroll` HEADs again. The sheet payload returns `object_key`. Phone is read from `staff.phone_number`, not from the sheet.

View takes `staff_id` only. A client supplied `object_key` on view would let an allowed session sign any key in the school prefix. List signs thumbs without HEAD so a page of 50 does not wait on 50 B2 HEAD round trips. The sheet `view` action still HEADs, matching spec 0004 AC-10.

Remove deletes after COMMIT so identity is gone even if DeleteObject fails. Sweep with an empty keep key is the same helper enroll already uses. Runner up was delete only the stored key and leave pending files. That would leave `pending-{staff_id}.jpg` after Remove.

Photos GET redirects so old bookmarks land on Enrollments. Photos POST is 410 so old page JS cannot mint a consent free portrait. The photos engine stays because enrollments already call `sap_*`. Deleting that file would copy SigV4 into a second home.

Amended 2026-10-28 (earlier): Node detect on the stored JPEG was a second detector after MediaPipe. Live Node often returned count 0 on a portrait the oval had already accepted. You chose to drop `saf_detect` from enroll, `confirm_face`, and Test, and to keep `saf_verify` for Test. Detect stays in spec 0005 for public check in.

Test posts bytes because a test frame must not become the enrollment portrait and should not linger as a B2 object. Runner up was a temp B2 key plus two GET URLs. That is more moving parts for a one shot admin check.

Default ticked consent was the first product call. Amended 2026-10-30: you dropped the consent tick and the phone field from the Enroll face sheet. Phone for check in is copied from `staff.phone_number`. `consent_at` is PHP Lagos now. That still does not prove informed opt in.

Amended 2026-10-30: Retake used to mark the old row replaced and INSERT a new current row. You asked for one row per staff member. Retake now UPDATEs that row and stamps `replaced_at` plus replaced actor. Remove DELETEs the row. Universal admin actor ids come from `executives.executive_id` (session `table_name` stays `admin`). `storage_type` is `backblaze` until another store is added.

Amended 2026-10-31: Enrollments `fetch` sends `referrerPolicy: 'origin'`, so Referer can be origin only or missing and Origin must be accepted. Matching host alone was too weak (scheme and port must match too). Reading `X-Forwarded-Proto` would help a TLS proxy that hides `HTTPS`, but a client can set that header. Attempts already refuse `X-Forwarded-For` (AC-10). Same origin stays on that rule: trust the server request, not a forwarded header. Runner up was a dedicated same origin spec. The enrollments page is why the helper changed, so AC-15 is the home. The helper is still shared with desktop `admin/` writes. That blast radius is accepted because this cPanel host already looks like `https` without a forwarded header.

Templates stay unused because spec 0005 compares JPEGs. Writing an empty or invented blob would fake a biometric store we do not have.

No extra school copy: `staff_id` and photo prefixes are per school database. Copying them would enroll the wrong people.

Thumb on the card, not a View button: the roster is how you browse. Extra buttons next to Retake, Test, and Remove would crowd the Pattern C card. The thumb is display only. Tap always calls `view` for a fresh URL.

Cross check 2026-10-28 (Sonnet): lock names are tenant scoped with sanitized session `db_name` so two schools cannot block the same staff_id. The lock covers the DB write only. Sweep runs only after a successful copy, keep key = final, so a failed copy cannot delete the live pending file. Duplicate phone deletes pending only when this staff_id no longer stores that key. Mint is refused while the current row points at pending. `sap_object_key_allowed` is spec 0004 AC-13. List allowlists before it signs. Enroll does not need Node. School missing wins on every hidden action. Photos POST is 401 then 403 then 410. Sweep stays on `{school_id}/` with a 50 page cap. Unique final keys (no copy) would reopen spec 0004 and were left out.

## Design signature

Enrolled cards show the live face in the existing Pattern C avatar slot, so the list is a roster you can scan by person. Not enrolled keeps the generic user icon. The memorable moment is still the oval on the same sheet as save, now without a consent tick or a phone field. Chips for Not enrolled / Enrolled / Enrolled + tested are the scan layer. Do not invent a new palette. zap-app Pattern C plus G owns structure. frontend-design owns the portrait in the avatar slot, not a new hero style.
