# 0003. Staff attendance settings

**Date**: 2026-10-27
**Status**: Accepted
**Amended**: 2026-10-27 (spec 0005 removed the Face group; save and reset skip `face_match_threshold`)
**Amended**: 2026-10-31 (spec 0006 owns Staff attendance nav group membership: Locations, Settings, and Enrollments)

## Summary

Admins open Settings from the Staff attendance nav group, then save the current school’s knobs: late clock, GPS accuracy cap, check out, and wait minutes. Spec 0005 removed the Face percent control; the Node service decides match. Spec 0006 owns group membership (Locations, Settings, and Enrollments). The remaining values live in the one settings row spec 0001 already created. Check in later reads them on the server. This page never ships them to a public screen.

## Requirements

**User stories**:
- As an admin, director, or backend user, I want a Settings tile in the Staff attendance nav group so I can open the knobs page directly.
- As an admin, director, or backend user, I want to save late time, GPS cap, check out, and wait minutes for the current school so later check in uses those rules.
- As an admin of several schools, I want to copy the same knobs into schools I pick, and to reset those schools to the original defaults, so I do not retype policy per branch.

**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_settings.php`. Any other logged in role that hits that URL is sent to the app home. The same role check applies to every settings AJAX action. Access is the shared helper `staff_attendance_can_access` in `admin/includes/staff_attendance_access.php`. `sal_can_access` in the locations engine calls that helper so locations keep the same rule.
- **AC-2**: The Staff attendance nav group is added only for roles in AC-1. Spec 0006 AC-2 owns group membership: three tiles (Locations, Settings, Enrollments) and no Photos tile. This spec still owns the Locations and Settings tiles: Locations `href` `staff_attendance_locations.php`, icon `icon/attendance.png`, subtitle Named places and geofence radius. Settings `href` `staff_attendance_settings.php`, icon `icon/staff.png`, subtitle Late time, GPS, check out, face match. That Settings subtitle still includes "face match" (Follow-up). `zap_nav_related_bases` maps `staff_attendance_locations.php` to `staff_attendance_location_edit.php` so Locations stays selected on edit.
- **AC-3**: There is no hub page. Locations and Settings are reached from their tiles in the Staff attendance nav group (AC-2). Enrollments is spec 0006.
- **AC-4**: Location edit backs to `staff_attendance_locations.php`. The locations list toolbar back stays a history control (`data-back-button`).
- **AC-5**: Settings chrome is a back toolbar (history, `data-back-button`), title Settings, then `updated_at` as Lagos `j M Y, H:i` (example `27 Oct 2026, 14:32`). Then two groups: Attendance (`late_after` labeled Late after (Africa/Lagos), allow check out switch, min minutes) and Location (max GPS meters). There is no Face group and no percent input (amended by spec 0005). Help under Late after: Check in after this Lagos time is marked late, it still records. Help under GPS: Worse than this many meters is refused, equal is allowed. Help under check out: Off means every success is a check in. Help under minutes: 0 means no wait. Branch picker when the switch list has more than one school. Save and Reset. First paint fills from `SELECT` `id = 1`. Missing 0001 tables shows `The attendance tables are missing.` Missing `id = 1` shows `The settings row is missing.` Both hide Save and Reset. It is not the fatal retry screen.
- **AC-6**: Save is POST `action=save`. Fields: `csrf_token`, `max_gps_accuracy_m`, `allow_checkout`, `min_checkout_minutes`, `late_after`, optional `school_ids[]`. Posted `face_match_percent` is ignored: silent success, no warning, no error, do not validate it, do not `UPDATE` `face_match_threshold`. Current school always writes via the session database. Save and Reset disable until JSON returns, and re-enable if fetch fails or the body is not JSON. Stay on Settings. Success toast is `notify({ text: 'Settings saved.' }, 'success')` (same helper as locations). JSON returns the saved row from a `SELECT` of `id = 1` after the `UPDATE` returns, including stored integer percent and `face_match_threshold` (so old JS does not break), other stored fields, formatted `updated_at`, `csrf_token`, and `branches[]` of `{school_id, school_name, status, msg}` where `status` is `ok`, `missing_tables`, `missing_row`, or `error`. Top level percent and `face_match_threshold` are always the current school's row. A stored threshold outside 0 to 1 is still mapped with `(int) round((float) $threshold * 100)`: do not clamp, do not rewrite the column. Extra school failures also set `$_SESSION['sas_branch_flash']` and show on this page, then clear. Current school success still toasts even when an extra school fails. If the current school write fails, stay on the page with `msg`, write no extras, skip the toast, and `notify({ text: msg }, 'error')`.
- **AC-7**: PHP refuses (does not clamp) and writes nothing in that school unless the raw POST strings match: GPS `^[1-9][0-9]*$` then the int is 10 to 200; minutes `^(0|[1-9][0-9]*)$` then the int is 0 to 480; `allow_checkout` is exactly `0` or `1` (empty is an error, not off). `late_after` matches hour 0 to 23 (one or two digits) and minutes 00 to 59, optional seconds 00 to 59; store `sprintf('%02d:%02d:00', $h, $m)` with no time zone conversion. Do not validate posted `face_match_percent`. HTTP 200 `{status:error, msg}`. Before every `UPDATE`, `SELECT` `id = 1`. Zero rows is `missing_row` with msg `The settings row is missing.` Do not use `affected_rows` (a same value save is success). Missing tables msg is `The attendance tables are missing.` Both are HTTP 200 `{status:error, msg}` on the current school. Two overlapping saves: last `UPDATE` wins.
- **AC-8**: Allow check out is a switch. Seed and load use the column. When the switch is on, only the visible minutes input has `name=min_checkout_minutes`. When the switch is off, the visible minutes control stays on screen but is disabled and has no `name`, and a hidden input `name=min_checkout_minutes` holds the last value. Never both named at once. That posted value is still saved.
- **AC-9**: Reset asks: This will set this school (and any checked schools) back to the original defaults. POST `action=reset` with `csrf_token` and optional `school_ids[]`. Server ignores posted field values, `SELECT`s `id = 1`, then `UPDATE`s that row to the spec 0001 seed for GPS, checkout, minutes, and late after (`max_gps_accuracy_m` 50, `allow_checkout` 1, `min_checkout_minutes` 30, `late_after` `08:00:00`) plus PHP `Africa/Lagos` `updated_at`. The `UPDATE` list omits `face_match_threshold` (the column stays as last written). PHP seed may still hold `0.5000` for that field, but it is not written. The confirm wording stays as this sentence; it does not mention that the face column is skipped. JSON after reset is the same shape as save: a `SELECT` of `id = 1` after the `UPDATE` returns, so percent is the stored column, not the seed. Current school always. Extra schools that are checked, same tenant rules as Save. Reset disables until JSON returns (re-enable on fetch fail). Stay. Toast `notify({ text: 'Settings reset to defaults.' }, 'success')`. Flash extras with `sas_branch_flash`. Same missing row and missing tables rules as Save.
- **AC-10**: Extra schools follow locations: picker only when `$school_config['schools']` has more than one school. Current school is matched by `$_SESSION['db_name']`, always written, not sent as an extra. Other boxes start unchecked. Being on that list is enough authority. Skip any `school_id` that equals the current school. If any posted `school_id` is not on the list, abort before any write, HTTP 200 `{status:error, msg: That school is not on your switch list.}`. Client never sends `db_name`. Extra writes `SELECT` then `UPDATE` `id = 1` in that school database (no name match). If the current write fails, skip extras. Partial extra success is kept. Restore the session database in a `finally` after every extra attempt. Missing 0001 tables or missing `id = 1` on a branch fails that school only: `branches[].status` is `missing_tables` or `missing_row`.
- **AC-11**: flatpickr 4.6.13 loads from jsDelivr (`flatpickr.min.js` and `flatpickr.min.css`) with an SRI hash taken from jsDelivr at build time. Options: `enableTime` true, `noCalendar` true, `time_24hr` true, `minuteIncrement` 1, `dateFormat` `H:i` (posts `HH:MM`, not a date), bound to the native `input type=time` `step=60`. If the script fails, the native input stays usable and Save still works.
- **AC-12**: Current school reads and writes use the mysqli database already selected by `$_SESSION['db_name']`. AJAX is included after `init.php` and before `authenticate.php`. A live session is `isset($_SESSION['authenticated']) || isset($_SESSION['zap_auth'])`; otherwise HTTP 401 `{status:error, msg:unauthorized}` (not an HTML login page). CSRF is `$security->csrf_token_is_valid()` and, when the method exists, `$security->request_is_same_domain()`. Do not call `check_csrf_token()` (that helper exits HTTP 200). CSRF failure is HTTP 403 `{status:error, msg:forbidden}`. Wrong role AJAX is HTTP 403 `{status:error, msg:forbidden}`. Each JSON response includes a fresh `csrf_token`; page JS replaces the in memory token. Validation errors are HTTP 200 `{status:error, msg}`. Engine functions take `mysqli` plus a `$ctx` array, return arrays, never echo, and never read `$_POST` or `$_GET`. JSON uses `json_encode(..., JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)`.

## Decision

**Chosen option**: Option 1: Settings form on the 0001 row, AJAX save and reset, optional extra school `UPDATE`, flatpickr with a native time fallback.

No new table and no `ALTER`. Public check in is not in this slice. Settings is a tile in the Staff attendance nav group. Spec 0006 owns the third tile (Enrollments). Settings stay on the page after save.

**Implementation skills**: `zap-app` (local, `.cursor/skills/zap-app/`) · `frontend-design` (local, `.cursor/skills/frontend-design/`)

## Feature design

**Data model sketch**:

No new table and no `ALTER`. Target is `staff_attendance_settings` in each school database, as in spec 0001. One row, `id = 1`.

| Field | Type | Required | Notes |
|---|---|---|---|
| id | TINYINT UNSIGNED PK | yes | Always 1. Writers only `UPDATE` this row. |
| face_match_threshold | DECIMAL(8,4) | yes | Stored 0.0000 to 1.0000. Unused for matching after spec 0005. Form does not show it. JSON still includes `(int) round(threshold * 100)`. Seed 0.5000. Save and Reset skip this column. |
| max_gps_accuracy_m | INT | yes | PHP range 10 to 200. Seed 50. |
| allow_checkout | TINYINT(1) | yes | 0 or 1. Seed 1. |
| min_checkout_minutes | INT | yes | PHP range 0 to 480. Seed 30. Still stored when checkout is off. |
| late_after | TIME | yes | Stored `HH:MM:00`. Seed `08:00:00`. Africa/Lagos wall clock, not converted. |
| updated_at | DATETIME | yes | PHP `Africa/Lagos` on every save and reset. |

Relationships: none in this slice. Later check in reads this row. Tenant is the selected school database. Extra school copies are the same columns on that school’s own `id = 1`. They are not linked after write.

Percent mapping: JSON still maps `(int) round((float) $threshold * 100)` from the stored column after the `UPDATE` `SELECT` (0.5000 becomes 50). Do not clamp an out of range stored value. Save and Reset do not write `face_match_threshold`. Posted `face_match_percent` is ignored (silent success).

**State transitions**:
- Load: `SELECT` `id = 1`. Zero rows is an error, not an insert.
- Save: `SELECT` `id = 1`, then `UPDATE` GPS, checkout, minutes, late after, and `updated_at`. Never write `face_match_threshold`. Never `INSERT`. Zero rows is `missing_row`, not a no-op `UPDATE`.
- Reset: `SELECT` `id = 1`, then `UPDATE` that row to the 0001 seed for those same columns and new `updated_at`. The `UPDATE` omits `face_match_threshold`. Never `INSERT`.
- Extra school: same `SELECT` then `UPDATE` on that database’s `id = 1`.

**API surface**:

Pages boot from `app/admin/includes/authenticate.php`. Settings AJAX is included from the settings page via `zap_main_admin_include('staff_attendance_settings_ajax.php')`. The dispatcher reads `$_POST['action']`. Engines live in `admin/includes/staff_attendance_settings_engine.php`. Shared access lives in `admin/includes/staff_attendance_access.php`.

| Endpoint | Method | Key inputs | Key outputs | Auth | Key errors |
|---|---|---|---|---|---|
| app/admin/staff_attendance_settings.php | GET | none | form filled from `id = 1`, formatted `updated_at`, branch picker when 2+ schools, CSRF in page JS | roles in AC-1 | other roles redirected home; missing tables or missing row: in page error, Save and Reset hidden |
| app/admin/staff_attendance_locations.php | GET | none | existing list; toolbar back stays history (`data-back-button`) | roles in AC-1 | other roles redirected home (unchanged) |
| action=save | POST | csrf_token, max_gps_accuracy_m (digit string), allow_checkout `0` or `1`, min_checkout_minutes (digit string), late_after (`H:MM` or `HH:MM` or with seconds), school_ids[] (opt); posted face_match_percent is ignored | saved row (stored percent from SELECT, stored fields, formatted updated_at), branches[] of {school_id, school_name, status, msg}, csrf_token | roles in AC-1 | 403 csrf or role `{status:error, msg:forbidden}`; 401 `{status:error, msg:unauthorized}`; validation 200 error; missing_tables; missing_row; off-list school 200 `That school is not on your switch list.` |
| action=reset | POST | csrf_token, school_ids[] (opt) | saved seed row in the same shape as save, branches[], csrf_token | roles in AC-1 | 403; 401; missing_tables; missing_row; off-list school 200 |

**Value sourcing**:

| Action | Value produced / displayed | Source |
|---|---|---|
| Settings GET | who may open it | `staff_attendance_can_access`: `$_SESSION['table_name']` in admin, director, backend, or `$info['status']` is admin |
| Nav Locations tile | href, icon, subtitle | `staff_attendance_locations.php`, `icon/attendance.png`, Named places and geofence radius |
| Nav Settings tile | href, icon, subtitle | `staff_attendance_settings.php`, `icon/staff.png`, Late time, GPS, check out, face match (still includes face match; Follow-up) |
| Nav Enrollments tile | href, icon, subtitle | spec 0006 AC-2 |
| Nav related | child pages | `zap_nav_related_bases` key `staff_attendance_locations.php` → `staff_attendance_location_edit.php` |
| Locations list back | href | history (`data-back-button`) |
| Location edit back | href | constant `staff_attendance_locations.php` (unchanged) |
| Settings GET | CSRF token | `$security->create_csrf_token()` |
| Settings GET | form fields | `SELECT` `staff_attendance_settings` `WHERE id = 1` |
| Settings GET | percent in JSON only | `(int) round((float) $threshold * 100)` from `SELECT` `id = 1`; the form has no percent input. Do not clamp. |
| Settings GET | late_after input | column `late_after`, displayed `HH:MM` |
| Settings GET | GPS, minutes, checkout | columns `max_gps_accuracy_m`, `min_checkout_minutes`, `allow_checkout` |
| Settings GET | updated_at text | column `updated_at`, PHP `date('j M Y, H:i', strtotime($row['updated_at']))` with boot timezone `Africa/Lagos` |
| Settings GET | help lines | constants in AC-5 |
| Settings GET | branch picker rows | `$school_config['schools']` (`school_id`, `school_name`, `db_name`); hide when count is 1 or current school is not on the list |
| Settings GET | current school checkbox | matched by `$_SESSION['db_name']`; always checked and not sent as an extra write |
| Settings GET | extra school flash | `$_SESSION['sas_branch_flash']` set by save or reset, shown once then cleared |
| Settings GET | missing tables or row | engine: table missing (`The attendance tables are missing.`), or `SELECT` returns zero rows (`The settings row is missing.`) |
| Save | face_match_threshold | left unchanged; posted `face_match_percent` ignored (silent success, no warning) |
| Save | JSON saved row | `SELECT` `id = 1` after the `UPDATE` returns (current school). Top level percent is that row. |
| Save | GPS stored | POST matching `^[1-9][0-9]*$`, then `(int)` in 10 to 200 |
| Save | minutes stored | POST matching `^(0|[1-9][0-9]*)$`, then `(int)` in 0 to 480 |
| Save | checkout stored | POST exactly `0` or `1` |
| Save | late_after stored | POST hour 0 to 23 (one or two digits) and minutes 00 to 59, optional seconds ignored; `sprintf('%02d:%02d:00', $h, $m)` |
| Save | updated_at | PHP `date('Y-m-d H:i:s')`, app boot timezone `Africa/Lagos` |
| Save | success toast | constant `Settings saved.` via `notify({ text }, 'success')` |
| Save | error toast | JSON `msg` via `notify({ text }, 'error')` |
| Save extra school | which databases | POST `school_ids[]` intersected with `$school_config['schools']`; drop the current school id |
| Save extra school | off-list | any posted id not in that map: abort, no write, msg `That school is not on your switch list.` |
| Save extra school | write | `SELECT` then `UPDATE` `id = 1` with the same knobs and `updated_at` |
| Save extra school | restore tenant | `select_db($_SESSION['db_name'])` in `finally` after each extra attempt |
| Save extra school | skip extras | current school save returned error |
| Save extra school | branches[].status | `ok`, `missing_tables`, `missing_row`, or `error` |
| Reset | knobs written | constants from spec 0001 seed for GPS, checkout, minutes, and late after, not from POST fields. `face_match_threshold` is omitted from the `UPDATE` |
| Reset | JSON percent | `SELECT` `id = 1` after the `UPDATE` returns, same as save. Not the PHP seed `0.5000`. Confirm wording unchanged. |
| Reset | extras | same picker and restore rules as Save |
| Reset | success toast | constant `Settings reset to defaults.` |
| Confirm reset | wording | AC-9 string |
| JSON envelope | status, msg, csrf_token | `{status, msg, csrf_token}` plus row fields; flags `JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT` |
| Live session AJAX | test | `isset($_SESSION['authenticated']) || isset($_SESSION['zap_auth'])` |
| CSRF AJAX | test | `$security->csrf_token_is_valid()` and `request_is_same_domain()` when that method exists |
| Wrong role page | redirect | `Location` to `/app/admin/` |
| Wrong role AJAX | body | `{status:error, msg:forbidden}` HTTP 403 |
| Dead session AJAX | body | `{status:error, msg:unauthorized}` HTTP 401 |
| CSRF fail AJAX | body | `{status:error, msg:forbidden}` HTTP 403 |
| flatpickr assets | script and CSS | jsDelivr `flatpickr@4.6.13` dist files plus SRI hash copied from jsDelivr at build |
| flatpickr posted time | input value | `dateFormat` `H:i` so the field posts `HH:MM` |

**Key invariants**:
- Engine never echoes and never reads superglobals.
- Writers only `UPDATE` `id = 1`. Never insert a second settings row. Never insert to heal a missing row.
- Radius style refuse, not clamp, for every numeric range. Cast with `(int)` only after the raw string matches the AC-7 pattern. Do not use `affected_rows` to detect a missing row.
- `late_after` is a Lagos wall clock. Do not convert from the browser time zone. flatpickr must post `HH:MM` (`dateFormat` `H:i`), never a date plus time.
- Check in at any hour still records. This slice does not add open or close hours. `is_late` is computed later at consume, from the settings row at insert time (spec 0001).
- The public check in page is not in this slice. When it exists, it `SELECT`s `id = 1` on the server. It must not read these knobs from the client.
- Extra school writes restore the session database in `finally`, including on exception.
- Client never sends `db_name`.
- Save and Reset disable until the response so a double tap cannot fire two overlapping extras. Re-enable on fetch fail or a non JSON body.
- Only one control is named `min_checkout_minutes` at a time (visible when checkout is on, hidden when off).
- `sal_can_access` stays as a one line wrapper around `staff_attendance_can_access`.

**Security model**:
- Personal data: school policy numbers and a late clock. Not staff faces, not GPS traces, not phones. Product bar is the same tenant isolation as the rest of the app, not a finished NDPR review.
- Readers and writers: roles in AC-1, current school for load, save, reset. Extra updates only for `school_id`s on that user’s switch list.
- Students, parents, and other staff: page redirect home, AJAX 403.
- CSRF on save and reset via `csrf_token_is_valid` plus same origin, not `check_csrf_token`.
- Prepared statements for every SQL call.
- JSON uses `JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT`.
- No new secrets. flatpickr is a public CDN. No API key.
- No settings change audit log (no actor column in 0001). Last `UPDATE` wins.

**Configuration required**:
None. flatpickr is a public CDN. No env var.

**Critical test scenarios** (each maps to an acceptance criterion in ## Requirements):
- Happy path: admin opens Settings from the Staff attendance nav group, changes late after and GPS, saves, stays on the page with a toast and a new `updated_at`; Face group is absent; `face_match_threshold` is unchanged; JSON still includes stored percent; location edit back returns to the locations list, verifies **AC-1**, **AC-2**, **AC-3**, **AC-4**, **AC-5**, **AC-6**, **AC-12**
- Failure case: save with GPS 5 is refused and the row is unchanged; reset after confirm writes GPS 50, checkout on, minutes 30, and 08:00, and leaves `face_match_threshold` as it was; checkout off still saves the previous minutes; extra school missing tables flashes `missing_tables` and the current school keep its write, verifies **AC-7**, **AC-8**, **AC-9**, **AC-10**
- Auth/permission: a school_id not on the switch list is refused; after an extra write, mysqli is back on the session database, verifies **AC-10**, **AC-12**
- Time picker: with flatpickr loaded, Save posts `08:30` and stores `08:30:00`; with the script blocked, the native time input still saves `08:30:00`; empty checkout is refused, verifies **AC-11**, **AC-7**

## Build plan

Tracer Bullet: first a real load and save of the current school from a Settings page you can reach from the Staff attendance nav group, then thicken rules, the time picker, reset, and branch copies. No schema migration.

1. [x] Shared access helper, Locations and Settings tiles in the Staff attendance nav group, location edit back href, settings GET first paint, engine get plus save for the current school, AJAX `save`, JSON envelope and CSRF, stay and toast, satisfies **AC-1**, **AC-2**, **AC-3**, **AC-4**, **AC-5**, **AC-6**, **AC-12**
2. [x] Ranges and refuse, checkout switch with hidden minutes, `late_after` parse as Lagos wall clock, missing tables and missing row on GET and save, `updated_at` text, help lines, satisfies **AC-5**, **AC-7**, **AC-8**
3. [x] flatpickr 4.6.13 from jsDelivr with SRI, `dateFormat` `H:i`, native `type=time` fallback, satisfies **AC-11**
4. [x] Branch picker, extra school `UPDATE` `id = 1`, reset to seed with confirm, flash of partial failures, `finally` restore session database, satisfies **AC-9**, **AC-10**, **AC-12**
5. [x] Spec 0005 removed the Face group, stopped posting percent, and skipped `face_match_threshold` on save and reset `UPDATE` (including extra schools), satisfies **AC-5**, **AC-6**, **AC-7**, **AC-9** as amended
6. [x] Spec 0006 added the Enrollments tile and owns group membership; this spec still owns Locations and Settings tile copy, satisfies **AC-2**, **AC-3** as amended

## Consequences

**Positive**:
- Check in later has a real admin write path for the 0001 knobs, on the app stack the team already ships.
- Locations and Settings sit as tiles in the Staff attendance nav group (Enrollments is spec 0006).
- JSON still carries the stored percent so old JS does not break; the form no longer shows it.
- Reset is a retry safe `UPDATE` of known constants, except `face_match_threshold` which stays as last written.

**Negative / tradeoffs**:
- Extra school writes overwrite that campus’s whole policy row. There is no name match and no “only if still defaults” guard. A checked box on Reset is especially sharp.
- The school percent knob is dead. Match is the Node boolean (spec 0005). The unused `face_match_threshold` column remains so this slice needs no `ALTER`.
- Last `UPDATE` wins. Two admins can overwrite each other with no actor log.
- flatpickr is a second CDN (after Leaflet) for one field. Native `type=time` already works; the library is extra network and an SRI pin to refresh later.
- Report and the live board still add their own tiles later. Spec 0006 already added Enrollments.

**Neutral**:
- Desktop `admin/` is still deferred.
- Check in consume, `is_late`, GPS refuse, and checkout wait are not implemented here. This spec only stores the knobs and states the rules in help text.
- Spec 0002’s nav stays a Locations tile; Settings stays a tile in the same Staff attendance group. Spec 0006 added Enrollments.
- Agent Skills and MCP servers stay declined for this repo.

## Follow-up

- [ ] Staff check in spec owns reading `id = 1` on the server for GPS cap, checkout, wait minutes, and `is_late` versus `late_after`. The client must not supply those knobs.
- [x] Face matching spec owns match. Spec 0005 uses the Node boolean, hides the Face group, and leaves `face_match_threshold` unused.
- [ ] Settings nav subtitle still says "face match" (AC-2). Spec 0005 did not change the tile text. Spec 0006 added the Enrollments tile and left this phrase. Drop it when you next touch the nav if you want the tile to match the form.
- [x] Enrollment tile shipped in spec 0006. Attendance report and the live board add their own tiles when those specs ship.
- [ ] Desktop admin copy remains deferred.
- [ ] No settings change audit in this slice. Add actor columns later if you need who saved.
- [ ] NDPR or other legal review is not done here (these knobs are not biometric).
- [x] `/sync` can note the shared `staff_attendance_can_access` helper in `app/AGENTS.md` after this ships.

## Rationale

Reasoning and options: see [rationale.md](rationale.md).
