# 0005. Staff attendance face matching client

**Date**: 2026-10-27
**Updated**: 2026-10-28
**Status**: Accepted

## Summary

PHP talks to your hosted Node Face API (a small HTTP service that finds faces and compares two photos). Detect returns how many faces sit in one JPEG. Verify asks that service whether two JPEGs are the same person and stores the similarity number for later rows. The school Settings percent no longer decides match, so that Face group is removed from the form. This slice is an engine plus config, not a new page.

## Requirements

**User stories**:
- As a later caller (public check in), I want PHP to count faces in one JPEG so I can refuse zero faces or many faces on a path that cannot trust the browser.
- As a later check in builder, I want PHP to compare a live JPEG with the enrolled portrait so the server, not the browser, decides match.
- As an enrollment builder, I do not need Node detect. MediaPipe already requires a face in the oval. I still need Node verify for Test.
- As an admin, I want the Settings Face percent gone so I do not think a school knob decides match.

**Acceptance criteria** (the contract, each criterion is IDed and independently checkable):
- **AC-1**: Matching lives in `admin/includes/staff_attendance_face_engine.php`. Public functions are `saf_config`, `saf_detect`, and `saf_verify`. They take a `$ctx` array only (no `mysqli`). If `$ctx` is not an array, treat it as `[]`. Extra keys are ignored. They return arrays, never echo, never read `$_POST` or `$_GET`, and never call `error_log`. There is no `staff_attendance_face_ajax.php` and no new app page. Any later page that includes the engine may call it. Those later specs own their own access rules.
- **AC-2**: `saf_load_ask_zap` resolves `{DOCUMENT_ROOT}/private/includes/ask_zap.php` the same way Photos does (`$_SERVER['DOCUMENT_ROOT']`, then `require`). Empty `DOCUMENT_ROOT`, missing file, or a non array return is `not_configured`. Do not static cache. Complete config is a string `face_api_url` that is an `https` origin: lowercase the scheme only, scheme must equal `https`, non empty host, optional port, path empty or `/`, no query, no fragment, no userinfo. IP literals and `localhost` are allowed. A trailing slash is stripped. `face_api_timeout_seconds` is optional: missing, null, or `''` uses `20`. A present value must be an int `5` to `60`, or a trimmed string of digits that `intval`s to that range (`"20"` is ok, `"20.5"` and floats are `not_configured`). `face_api_key` is optional: omitted or a string. Trim a string; empty means no `Authorization` header; non empty sends `Authorization: Bearer <key>`. A non string key is `not_configured`. Incomplete config returns `{status:'not_configured', msg:'The matching service is not configured.'}` and no HTTP call. Complete config returns `{status:'ok', url, timeout_seconds, has_key}` where `has_key` is `strlen(trim(key)) > 0`. The returned array never includes the key string.
- **AC-3**: `saf_detect` POST JSON `{image}` to `{url}/api/multiple-faces`. `ctx` must have exactly one of `image_bytes` or `image_url` (AC-5 xor). On Node HTTP 200 with a usable `count`, return `{status:'ok', count, exactly_one, raw, msg:'', http_status, curl_errno}`. `count` is an integer `>= 0` at the JSON top level (fixture below). `exactly_one` is `true` only when `count === 1`. Zero faces and two or more faces are still `status:'ok'`. A JPEG with no face must be HTTP 200 with `count` 0 on this route (`/check verify` proves it; if live Node returns 400 here, stop and reopen this spec). `raw` is the decoded JSON object with top level keys `image`, `referenceImage`, `capturedImage`, `referenceImageUrl`, and `capturedImage` removed if present.
- **AC-4**: `saf_verify` POST JSON `{referenceImage, capturedImage}` to `{url}/api/compare-face`. `ctx` must have exactly one of `reference_bytes` or `reference_url`, and exactly one of `captured_bytes` or `captured_url`. On Node HTTP 200 with usable `match` and `similarity`, return `{status:'ok', match, similarity, raw, msg:'', http_status, curl_errno}`. `match` is the JSON boolean from Node, not a PHP compare against `staff_attendance_settings.face_match_threshold`. This engine does not read that column. `similarity` is the Node number rounded with PHP `round((float), 4)` and must lie in `0` to `1` inclusive (Node documents it as `1` minus distance). `raw` follows AC-3 stripping. There is no `side` field. Verify `no_face` means Node could not extract a face from at least one of the two images. Callers that need which image failed run `saf_detect` on that image first.
- **AC-5**: A side is set when `array_key_exists` on that key and the value is not `null`. Empty string, whitespace only url, and wrong types count as set, then fail. Both set, or neither set, is `bad_image`. Also `bad_image` before POST: bytes that are not a string, bytes that fail JPEG magic (`0xFF 0xD8 0xFF` at the start), bytes longer than `5242880`, bytes length `0`, url not a string, url empty after trim, url scheme not `https`, url with userinfo, url with whitespace, url longer than `2048` characters, a `data:` url in a `*_url` key, or `strlen(json_encode($payload)) > 9000000` after the POST array is built. URL sides skip JPEG magic. Query strings on caller urls are allowed (signed B2 GET urls need them). PHP does not download caller urls. Node fetches them. Two 5 MB JPEGs as data URIs will fail the 9 MB guard; spec 0004 captures at 720 px so real frames should fit. Msg is `That photo is not a valid JPEG.`
- **AC-6**: Typed statuses, no automatic retry, no fatal retry screen from this engine. Every detect and verify result includes `http_status` (int, `0` if no HTTP response) and `curl_errno` (int, `0` if none). `timeout` when cURL times out (`msg:'The matching service timed out.'`); `unavailable` on DNS failure, connect failure, TLS failure (including certificate mismatch), HTTP `3xx` (redirects are not followed), HTTP `401`, HTTP `403`, HTTP `5xx`, HTTP `429`, empty body, or a body that is not JSON (`msg:'The matching service did not respond.'`); `no_face` on verify HTTP `400` (`msg:'No face was detected.'`); `error` on detect HTTP `400`, other HTTP `4xx` except verify `400` and except `401`/`403`, HTTP 200 JSON missing required fields or wrong types, and anything else (`msg:'The matching service returned an unusable result.'`). Detect HTTP `400` is `error`, not `no_face`.
- **AC-7**: Detect required field `count`: top level, integer or numeric string, then `intval`, must be `>= 0`. Verify required fields: `match` must be a PHP boolean (JSON `true`/`false` only, not `1`/`0`); `similarity` must be numeric, finite (not `NAN` or `INF`), and in `0` to `1` inclusive after the float cast, then `round(..., 4)`. Missing, wrong types, or similarity outside that range on HTTP 200 are `error` (AC-6). This client does not read `distance` and does not recompute similarity.
- **AC-8**: Outbound cURL: `CURLOPT_SSL_VERIFYPEER` true, `CURLOPT_SSL_VERIFYHOST` `2`, `CURLOPT_FOLLOWLOCATION` false, `CURLOPT_TIMEOUT` from `timeout_seconds`, `CURLOPT_CONNECTTIMEOUT` `5`, `Content-Type: application/json`, `Accept: application/json`. POST body is `json_encode` of the payload (no `JSON_HEX_*` flags). Decode with `json_decode(..., true)`. User-Agent `ZAP-Face/1`. Never log images, data URIs, `Authorization`, `face_api_key`, or full urls (query strings can leak B2 signatures). Callers may log `status`, `http_status`, and `curl_errno` only.
- **AC-9**: This engine never `INSERT`s or `UPDATE`s `staff_attendance_*` tables. It does not write `staff_attendance_templates`. It does not call `GET /health`.
- **AC-10**: Settings GET removes the Face group (the `sas-group-face` block and the percent input). Attendance and Location groups stay. No replacement note. Save and Reset ignore posted `face_match_percent` (do not validate it, do not `UPDATE` `face_match_threshold`). Reset seed still holds `0.5000` in PHP for other fields, but the `UPDATE` list omits `face_match_threshold`. JSON from get, save, and reset still includes the stored `face_match_percent` and `face_match_threshold` so old JS does not break. Page JS stops sending `face_match_percent`. Roles, CSRF, and extra school copy stay as spec 0003.
- **AC-11**: JPEG only for bytes. PNG or other magic is `bad_image`. Callers that pass urls rely on Node. Paths `/api/multiple-faces` and `/api/compare-face` are hardcoded in the engine, joined to the origin with one slash.

## Decision

**Chosen option**: Option 1: PHP cURL client to the hosted Node Face API, match from the API boolean, Settings Face group removed.

A silent engine in `admin/includes/` posts JSON to your origin. Detect uses `/api/multiple-faces`. Verify uses `/api/compare-face`. School percent is unused. Config keys live next to the B2 keys in `ask_zap.php`.

**Implementation skills**: `zap-app` (local, `.cursor/skills/zap-app/`)

## Feature design

**Data model sketch**:

No new MySQL table and no `ALTER`. Target config is extra keys on the PHP array in `private/includes/ask_zap.php`, shared by every school (same pattern as B2).

| Field | Type | Required | Notes |
|---|---|---|---|
| `face_api_url` | string | yes to call | `https` origin only. Example shape `https://node-face-api.abiahighcourt.org`. Strip trailing slash. No userinfo. IP and `localhost` allowed. |
| `face_api_timeout_seconds` | integer or digit string | no | `5` to `60`. Missing, null, or `''` uses `20`. `"20.5"` is `not_configured`. |
| `face_api_key` | string | no | Must be a string or omitted. Trim. Empty omits `Authorization`. Non empty sends Bearer. Never returned from `saf_config`. Never printed in this spec. |
| `staff_attendance_settings.face_match_threshold` | DECIMAL(8,4) NOT NULL | yes on the row | Spec 0001 column. Unused and deprecated for matching. This slice does not read or write it. Save and Reset skip it. Hidden on the Settings page. Missing settings row stays spec 0003 `missing_row`. |

Return values are not stored here:

| Value | Type | Stored by |
|---|---|---|
| `count`, `exactly_one` | int, bool | caller that asks (check in). Spec 0006 enrollment does not store a count. |
| `match`, `similarity` | bool, DECIMAL 4 places | later day and attempt rows |
| `raw` | array | caller must not persist portraits; this slice does not write it |
| `templates.template` | MEDIUMBLOB | not this slice |

Relationships: none. Config is not keyed to a school. Isolation stays the selected school database in later callers.

**State transitions**:
- Config: incomplete (`not_configured`, no POST) → complete (`status ok`, HTTP allowed).
- Detect: validate ctx → POST → `ok` with a count, or a typed failure.
- Verify: validate both sides → POST → `ok` with match and similarity, or `no_face`, or a typed failure.
- Settings Face group: present (spec 0003) → removed from the form. Column values stay as last written.

**API surface**:

Engine include via `zap_main_admin_include('staff_attendance_face_engine.php')`. No new HTTP route. Settings page and AJAX stay on `staff_attendance_settings.php`.

| Surface | Method | Key inputs | Key outputs | Auth | Key errors |
|---|---|---|---|---|---|
| `saf_config($ctx = [])` | PHP | none required (loads `ask_zap.php` itself) | `url`, `timeout_seconds`, `has_key` | none (file read) | `not_configured` |
| `saf_detect($ctx)` | PHP POST to Node | `image_bytes` xor `image_url` | `count`, `exactly_one`, `raw`, `http_status`, `curl_errno` | optional Bearer to Node | `not_configured`, `bad_image`, `timeout`, `unavailable`, `error` |
| `saf_verify($ctx)` | PHP POST to Node | `reference_bytes` xor `reference_url`; `captured_bytes` xor `captured_url` | `match`, `similarity`, `raw`, `http_status`, `curl_errno` | optional Bearer to Node | same as detect, plus `no_face` on HTTP 400 |
| Settings GET | HTTP GET | session | form without Face group | spec 0003 roles | unchanged redirect home |
| Settings `action=save` | HTTP POST | spec 0003 fields except percent is ignored | saved row still includes stored percent | spec 0003 | spec 0003, percent no longer validated |
| Settings `action=reset` | HTTP POST | csrf, optional `school_ids[]` | seed except `face_match_threshold` left as is | spec 0003 | spec 0003 |

Private helpers (names `/develop` should keep): `saf_load_ask_zap`, `saf_normalize_origin`, `saf_timeout_seconds`, `saf_headers`, `saf_jpeg_sniff`, `saf_side` (xor plus url checks), `saf_encode_image` (bytes → `data:image/jpeg;base64,...`; url → string), `saf_body_size_ok` (`strlen(json_encode($payload)) > 9000000`), `saf_post_json`, `saf_strip_raw`, `saf_parse_count`, `saf_parse_match`, `saf_parse_similarity`. Helpers stay silent and ignore superglobals.

Node parse fixtures (top level keys; `/develop` copies these into the engine test, no live Node required for parse):

Detect POST: `{ "image": "<data URI or https url>" }`

Detect HTTP 200:
```json
{ "count": 3, "detections": [ { "score": 0.99, "box": { "x": 42, "y": 58, "width": 120, "height": 110 } } ] }
```
`count` `0` with `"detections": []` is a no face success.

Verify POST: `{ "referenceImage": "<data URI or https url>", "capturedImage": "<data URI or https url>" }`

Verify HTTP 200:
```json
{ "match": true, "similarity": 0.82 }
```

Verify HTTP 400:
```json
{ "error": "Face not detected" }
```
Any HTTP 400 on compare maps to `no_face`; do not require that exact string.

**Value sourcing**:

| Action | Value produced / displayed | Source |
|---|---|---|
| `saf_config` | origin url | `ask_zap.php` `face_api_url` after normalize (Photos `DOCUMENT_ROOT` path) |
| `saf_config` | `timeout_seconds` | digit string or int `5..60`, else `20` when missing, null, or `''` |
| `saf_config` | `has_key` | `strlen(trim(face_api_key)) > 0` when the key is a string |
| `saf_config` | `not_configured` | missing file, non array, bad origin (including userinfo), bad timeout, non string key |
| `saf_detect` | POST `image` | `data:image/jpeg;base64,` plus `base64_encode(image_bytes)`, or the `image_url` string |
| `saf_detect` | `count` | Node JSON top level `count` (fixture) |
| `saf_detect` | `exactly_one` | PHP `count === 1` |
| `saf_detect` | `raw` | Node JSON minus the image keys in AC-3 |
| `saf_detect` / `saf_verify` | `http_status` | cURL `CURLINFO_HTTP_CODE`, or `0` |
| `saf_detect` / `saf_verify` | `curl_errno` | `curl_errno`, or `0` |
| `saf_verify` | POST `referenceImage`, `capturedImage` | same encode rule as detect, per side |
| `saf_verify` | `match` | Node JSON `match` boolean |
| `saf_verify` | `similarity` | Node JSON `similarity` in `0` to `1`, `round(float, 4)` |
| `saf_verify` | `no_face` | Node HTTP 400 on compare (either image; callers detect per side if they need which) |
| `saf_body_size_ok` | refuse POST | `strlen(json_encode($payload)) > 9000000` |
| Settings GET | Face group absent | this spec, edit `staff_attendance_settings.php` |
| Settings save/reset | `face_match_threshold` unchanged | `UPDATE` omits that column; posted percent ignored |
| Settings JSON percent | stored percent | `SELECT` `id = 1` as spec 0003 mapping `(int) round(threshold * 100)` |
| Caller later `face_score` | similarity | this engine’s `similarity`; check in writes the column |
| Template blob | not produced | enrollment or a later spec |

**Key invariants**:
- Browser matching is UX only. PHP is the only caller of Node from this app.
- Match is Node’s boolean. PHP does not apply `face_match_threshold`.
- One origin for every school. Per school policy is not in this client.
- Bytes xor url per side. A key is set if it exists and is not `null`. Never both. Never neither.
- PHP does not fetch caller urls (avoids PHP SSRF).
- Callers that need `many_faces` or which image failed run `saf_detect` first. Verify does not return a `side`. Spec 0006 enrollment does not call `saf_detect`. Face presence there is MediaPipe plus JPEG HEAD. Public check in should still call detect then verify.
- This client does not prove the person is live. Browser liveness stays UX only. Replay of a still photo is a stated non goal.
- Two sequential calls may each wait up to `timeout_seconds`. The combined gate budget belongs to check in.
- No retry. Callers may call again.
- No attendance table writes.
- Secrets and image urls never go to logs from this engine. `http_status` and `curl_errno` are the only diagnostics.

**Security model**:
- The engine is not an HTTP endpoint. CSRF and roles belong to the page that includes it. Public check in may include it later.
- Optional Bearer to Node. Empty key is allowed so today’s open service still works. When you set a key, send it only as a header, never as a query string.
- TLS verify on. Redirects off so a `302` cannot forward the Bearer token.
- Caller urls must be `https` without userinfo. Node still fetches those urls (SSRF risk sits on the Node host). Enrollment and check in must pass JPEG bytes or a short B2 GET url from spec 0004, never a user typed url.
- Face images are biometric personal data. They leave PHP and go to the configured origin. NDPR review is not done here. Do not send images to any host except that origin.
- This slice does not rate limit. Check in owns phone and IP limits. Enrollment owns admin abuse controls.

**Configuration required**:
- `face_api_url` in `private/includes/ask_zap.php`: https origin of the Node Face API.
- `face_api_timeout_seconds` (optional): integer seconds `5` to `60`.
- `face_api_key` (optional): shared secret for `Authorization: Bearer`. Fill it when the Node host starts requiring auth. `/develop` fills real values with you. This spec must not print secret values.

**Critical test scenarios**:
- Happy path: config ok, detect a one face JPEG returns `count` 1 and `exactly_one` true, verify two matching JPEGs returns `match` true and a similarity number, verifies **AC-2**, **AC-3**, **AC-4**
- Failure case: missing url is `not_configured` with no HTTP; oversize or PNG bytes are `bad_image`; empty bytes plus a valid url is `bad_image` (both set); cURL timeout is `timeout`; TLS mismatch is `unavailable`; verify HTTP 400 is `no_face`; HTTP 200 `similarity` `1.2` is `error`; HTTP 401 is `unavailable`, verifies **AC-2**, **AC-5**, **AC-6**, **AC-7**
- Auth/permission: `saf_config` never returns the key; empty key omits Authorization; non empty key sends Bearer; engine has no public URL of its own, verifies **AC-1**, **AC-2**, **AC-8**
- Settings: Face group gone; save and reset leave `face_match_threshold` unchanged; JSON still includes stored percent, verifies **AC-10**
- No writes: calling detect and verify does not insert templates, attempts, or days, verifies **AC-9**

## Build plan

Tracer Bullet: first a real detect and a real compare through cURL to the configured origin, then thicken validation and typed errors, then hide the Settings Face group. No schema migration.

1. Add `face_api_url`, optional `face_api_timeout_seconds`, and optional `face_api_key` to `private/includes/ask_zap.php`. Implement `saf_config`, `saf_detect`, and `saf_verify` with cURL POST to `/api/multiple-faces` and `/api/compare-face`. Prove one live detect and one live compare against the configured origin, satisfies **AC-1**, **AC-2**, **AC-3**, **AC-4**, **AC-8**, **AC-11**
2. JPEG magic, xor sides (`array_key_exists`), url checks, 5 MB cap, `strlen(json_encode)` 9 MB guard, typed statuses (including verify HTTP 400 as `no_face`, HTTP 401 as `unavailable`, and 200 bad JSON as `error`), parse fixtures in `php admin/includes/staff_attendance_face_engine_test.php` with no live Node, no retry, no `error_log`, no table writes, satisfies **AC-5**, **AC-6**, **AC-7**, **AC-8**, **AC-9**
3. Remove the Settings Face group, stop posting percent, skip `face_match_threshold` on save and reset `UPDATE` (including extra schools), keep percent in JSON from `SELECT`, satisfies **AC-10**

## Consequences

**Positive**:
- Enrollment Test and check in get one PHP verify contract instead of inventing HTTP mapping. Detect stays in this engine for callers that need a server count.
- Match is decided on the server. The browser cannot override it.
- You reuse the Node service you already host. No Composer Face SDK on the PHP host.
- Settings no longer shows a percent that does not decide match.

**Negative / tradeoffs**:
- The school percent knob is dead. Tightening match means changing the Node cutoff (`0.53` distance today), not a per school setting.
- An empty `face_api_key` means anyone who can reach the origin can POST portraits. Protect the Node host or set a key before production.
- Node fetches caller `https` urls. Signed B2 GET urls and any other url you pass become visible to that host (and to its logs if it logs request bodies).
- Two 5 MB JPEGs as data URIs fail the 9 MB `strlen(json_encode)` guard. Holding those buffers plus base64 can use tens of MB of PHP memory. Real 720 px captures from spec 0004 should stay small.
- This client does not stop a photo of a photo. Server anti spoof is out of scope.
- Check in may wait up to two times `timeout_seconds` (detect then verify). Enrollment Test waits only on verify.
- `staff_attendance_templates` stays empty. Spec 0001 said this spec would own that blob. Compare sends two images every time instead of embeddings.
- Secrets stay in `ask_zap.php`, same tradeoff as B2 keys.
- Stored `face_match_percent` in Settings JSON is stale unused data. Do not show it on any new screen.

**Neutral**:
- Enrollment (spec 0006, amended 2026-10-28) calls `saf_verify` on Test only. It does not call `saf_detect`. Check in and the report still need their own specs to call these functions and write scores.
- `/api/looking-away` and `/api/detect-face` and `/api/check-face` are unused.
- Desktop `admin/` is still deferred.
- Spec 0003 AC-5, AC-6, AC-7, and AC-9 were amended to drop the Face group and to skip `face_match_threshold` on save and reset.

## Follow-up

- [x] Enrollment spec 0006 records consent and calls `saf_verify` for Test (face only, no location). It does not call `saf_detect`. Capture uses MediaPipe. `confirm_face` and `enroll` HEAD the pending JPEG. Pass JPEG bytes (Test) or a short B2 GET url (verify reference), never a user typed url. Templates stay unused.
- [ ] Staff check in spec calls `saf_detect` on the live frame, then `saf_verify` against the enrolled portrait (bytes or a short B2 GET url, never a user typed url). Do not skip detect on that public page just because enrollment skipped it. It writes `face_score` from `similarity`, maps typed statuses to `reason_code` (`no_face`, `many_faces`, `no_match`, and transport as a generic fail), owns rate limits, and owns the two call latency budget.
- [ ] Put a real `face_api_key` on the Node host and in `ask_zap.php` before production if the origin is reachable from the public internet.
- [ ] NDPR or other legal review of sending face images to the Node host is not done here.
- [x] Spec 0003 AC-5 Face group and percent save rules are amended by AC-10. `/sync` noted the `face_api_*` keys in `app/AGENTS.md`, without putting secret values in git docs.
- [ ] Desktop admin copy remains deferred.
- [ ] If the Node JSON field names change, update this engine. Do not hide a contract break behind `match: false`.

## Rationale

Reasoning and options: see [rationale.md](rationale.md).
