# Rationale: Staff attendance face matching client

## Context

> ⚠️ Premise note: The Node Face API as documented has no required API key. An open compare route on the public internet is a known failure mode: anyone can POST staff portraits. You chose an optional Bearer header so today’s host still answers, and you accepted a later key or firewall. Do not treat empty `face_api_key` as finished production hardening. A second concern: spec 0001 said this spec would own `staff_attendance_templates` and the meaning of `face_match_threshold`. You chose not to store embeddings and not to apply the school percent. The templates table and the percent column stay, unused by this client. A third concern: sending biometric images to `abiahighcourt.org` (or whatever origin you configure) is a processor relationship. NDPR review is still open.

Staff attendance later must refuse a check in unless the live person matches the enrolled portrait. Spec 0001 already has `face_score` on days and attempts, reason codes `no_face`, `many_faces`, `no_match`, `low_score`, and a templates table. Spec 0003 stores `face_match_threshold` as a 0 to 1 decimal shown as percent. Spec 0004 stores enrollment JPEGs in a private B2 bucket and never sends those bytes through PHP on upload. The browser liveness check is UX only.

Forces: the app is PHP 8 on Apache with no Composer app; engines live in `admin/includes/` and return arrays; you already host a Node Face API with `/api/multiple-faces` and `/api/compare-face`; that API accepts an `https` url or a data URI; its compare route returns `{match, similarity}` and treats distance under `0.53` as match; PHP cURL with TLS verify is already used for B2; several gates at one school may call this at once; portraits must not appear in logs; tenant isolation is the school database, not a `school_id` column.

If we do not decide this, enrollment and check in will each invent a mapping, or the browser will be trusted to say match.

Compliance: these are face images. They leave the PHP host. Access rules for who may enroll or clock in belong to those specs. This engine has no HTTP wrapper of its own.

## Options considered

### Option 1: PHP cURL client to the hosted Node Face API

Config holds the origin. The engine posts JSON to `/api/multiple-faces` and `/api/compare-face`. Match is the API boolean. Similarity is stored for later rows. Settings Face percent is hidden.

**Pros**:
- Uses the service you already run. No new vendor bill and no PHP native Face library.
- Same outbound HTTP style as Photos (cURL, TLS on).
- Thin enough that enrollment can call it without a new page in this slice.

**Cons**:
- You depend on that host being up. Timeout and `unavailable` become check in failures.
- School percent cannot tighten match. The Node cutoff is global.
- Empty API key leaves the origin callable by others until you lock it.

### Option 2: Amazon Rekognition (or another cloud Face product) from PHP

PHP would call DetectFaces and CompareFaces at a public cloud vendor, with IAM keys.

**Pros**:
- Vendor SLA and auth are documented. You would not operate TensorFlow on a Node box.

**Cons**:
- New vendor, new bill, new key shape, and you already paid to host Node. Portraits would leave your hosts for that cloud region. Scope said keep mapping in config, not hardcode a vendor, but swapping later is still a spec. Extra cost for a problem you already solved.

### Option 3: Browser calls Node directly

The WebView would POST the live frame to Node and trust `match`.

**Pros**:
- PHP never sees JPEG bytes. No 9 MB JSON on Apache.

**Cons**:
- The browser check would become the decision. A modified client could skip Node. Scope forbids that. The API key would sit in page JS.

### Option 4: Match inside PHP with no HTTP

Load a Face library in PHP or shell out to a local Node process on the Apache host.

**Pros**:
- Images never leave the app server. No extra origin to protect.

**Cons**:
- No Composer Face stack exists here. TensorFlow on cPanel PHP is the hard path. You already run that work on a separate Node host.

## Rationale

The job is a server decision, not a new matcher product. You already host the matcher. Option 1 is the shortest real path through config, PHP, and that HTTP API, which is what Tracer Bullet wants for this slice.

Option 2 would ignore a running service. Option 3 would move the decision back to the client, which is the failure mode this feature exists to prevent. Option 4 would rebuild on Apache what the Node host already does.

Match from the API boolean follows the Node contract you run today (`distance` under `0.53`). Keeping `face_match_threshold` in MySQL without reading it is awkward, and it is still cheaper than pretending a hidden percent still clocks people. Removing the Face group is the honest UI. Returning similarity as 0 to 1 (Node’s `1` minus distance, four decimal places, out of range is `error`) keeps spec 0001 `face_score` useful for the report. Parse fixtures in this spec are the README bodies you confirmed, so `/develop` does not guess field paths.

Optional Bearer is a compromise with a sharp edge: ship the client against today’s open API, then set the key without a new PHP spec. Treat production with an empty key as unfinished.

Not writing templates means every verify resends two images. That is slower and sends more biometric bytes, and it avoids an API change the Node service does not offer (no embedding in the current JSON). Enrollment can reopen that when the API grows.

No references section: you asked to keep the spec clean. The Node route names in the engine come from the README of the service you named in chat, used as the parse contract in AC-3, AC-4, and AC-7.

Amended 2026-10-28: spec 0006 enrollment dropped `saf_detect`. MediaPipe already required a face in the oval, and Node detect on the stored JPEG often returned count 0 on that same portrait. Detect stays in this engine. Public check in should still call it, because that page is not an admin session and cannot trust the browser.
