# Rationale: Private staff attendance portraits

## Context

> ⚠️ Premise note: An application key was pasted into chat. Treat it as exposed. Rotate it in the Backblaze console before production, and never copy secret values into this spec. Storing that key in `private/includes/ask_zap.php` also fights the usual rule that secrets stay in environment variables. We still put the keys there because that is the file you named, and Ask ZAP already keeps secrets in that array. CORS allowed origins `*` is weaker than pinning this host. Short PUT expiry is the only brake. The scope row still mentions a 7 day cleanup of failed captures. You then chose not to store fail photos at all, so this spec drops that job rather than build a vacuum for rows we will not write.

Staff attendance later needs a private place for enrollment portraits. Spec 0001 already stores an opaque `photo_object_key` on enrollments and a nullable key on attempts. It forbids a public URL column. Locations and Settings are built. Enrollment and check in are not. Without a storage decision, those later slices would invent a bucket, a path layout, and whether the file goes through PHP.

Forces: the app is PHP 8 on Apache with no Composer app; biometric files must not sit in a public folder; the browser can PUT to S3 compatible APIs if CORS allows it; one bucket will hold every school; school names are readable but not unique and they change; staff names are readable but not unique; `staff_id` is unique inside a school database; the host already keeps other secrets in `private/includes/ask_zap.php`; you already created a Backblaze bucket and key for this job; B2 does not support browser POST to a presigned URL, only PUT.

If we do not decide this, `/develop` will either stream bytes through PHP (you forbade that) or pick a key layout that leaks one school into another.

Compliance: these are face portraits. Access stays on admin, director, and backend (plus staff sessions whose status is admin). Consent copy belongs to enrollment. This is not a finished NDPR review.

## Options considered

### Option 1: Backblaze B2 S3 API, browser presigned PUT, school_id folder, Photos probe page

PHP signs a PUT URL. The browser uploads JPEG bytes to B2. PHP HEADs to confirm, lists, and deletes with the app key. View is a five minute GET URL. A small admin page proves the path before enrollment exists.

**Pros**:
- Matches the S3 compatible API you asked for and keeps bytes off the app disk.
- Reuses Settings CSRF, roles, and zap-app Pattern C.
- No Composer SDK.

**Cons**:
- Needs CORS on the bucket and a SigV4 helper you must maintain.
- Secrets in `ask_zap.php` are still in tree.

### Option 2: PHP streams the file to B2

The browser POSTs the JPEG to Apache. PHP uses the app key and uploads from the server.

**Pros**:
- No CORS. Simpler browser code. The server sees the bytes and can sniff magic numbers before they leave the host.

**Cons**:
- You asked not to upload through the server. PHP memory and timeout become the cap. Biometric bytes transit the app host.

### Option 3: Google Drive, like CBT webcam captures

Reuse the existing Drive client and folder pattern.

**Pros**:
- Already in the stack. Some cleanup helpers exist.

**Cons**:
- Drive sharing is easy to get wrong for private faces. It is not the bucket you created. Presigned S3 PUT would not apply.

### Option 4: Files on local disk under `private/`

Save JPEGs next to other private images.

**Pros**:
- No third party. Fast to build.

**Cons**:
- Disk on this host, backups, and a public misconfig risk. No school folder in the B2 console you wanted.

## Rationale

Option 1 fits the forces you named: B2, presigned PUT, school folder, keys in `ask_zap.php`. Option 2 is simpler operationally and I would pick it if you had not required browser PUT. Option 3 and 4 ignore the bucket you already made.

Folder layout started as school name only. Names collide and rename. You then accepted school_id plus name. Listing with Prefix `4` (digits only) would also match school `42`. A terminator after the id fixes that: Prefix `4/` or Prefix `4-` both isolate school 4. `{id}-{name}` would still change the visible folder when the school is renamed (`4-OLD` and `4-NEW` sit as two root folders). The spec keeps `{school_id}/{slug}/` and lists `Prefix={school_id}/` so the B2 console has one folder `4`, with the readable name inside it, and a rename still groups old and new names under that same id folder.

You asked for `{staff_name}.jpg`. Two activated staff can share a name, so the file is `{Last}_{First}-{staff_id}.jpg`. Retake overwrites when the name is unchanged. A later name edit would otherwise leave a second file, so mint deletes other `*-{staff_id}.jpg` keys under that school prefix first. Spec 0001 replaced rows can still hold an old key string.

Fail photos were in the scope Done when line. You overrode that. Leaving attempt keys NULL is enough for spec 0001 AC-8. Building a 7 day deleter for objects we never write would be theatre.

A custom SigV4 helper is the honest fit for a repo with no Composer app. The AWS SDK would be a new platform choice, not a photos choice.

The Photos page is a probe with a real camera so Tracer Bullet has a path through camera, PHP, B2, and img tag. You later asked to hide `staff_id` on the staff select (name only; two people with the same name look the same) and to run MediaPipe Face Landmarker on this page. Eyes stay on the camera oval, so each step is spoken as well as shown (`speechSynthesis` plus a short cue, unlocked on Start). If speech is missing the check still runs from the on-screen prompt. The server still picks two distinct moves so the order is not fixed. PHP still does not prove the gesture and still does not receive challenge frames. Only the final look straight frame is the enrollment JPEG. Check in still owns tokens, uploaded frames, and a server live decision. Enrollment still owns consent and writing `photo_object_key`, and should reuse this camera UI (and the same spoken prompts in `staff-attendance-liveness.js`). Max size is 5 MB with one JPEG quality, not a quality squeeze down to fit 1 MB.
