Skip to content

Feat/face2ai UI port - #1679

Open
DYAI2025 wants to merge 90 commits into
ageitgey:masterfrom
DYAI2025:feat/face2ai-ui-port
Open

Feat/face2ai UI port#1679
DYAI2025 wants to merge 90 commits into
ageitgey:masterfrom
DYAI2025:feat/face2ai-ui-port

Conversation

@DYAI2025

Copy link
Copy Markdown

No description provided.

BenPerro and others added 30 commits August 18, 2026 03:02
Teach both consumers of Face2AI's presence stream the new mood fields
(mood/valence/arousal on presence snapshots, SSE `mood` events) with
hedged wording only — a mood is how a face appears, never a fact.

Voice agent (apps/face2ai-agent):
- Presence gains mood/valence/arousal; `mood` events update the current
  presence (to_mood null ends it); a presence transition starts a fresh,
  mood-less presence; describe(language=) appends one hedged sentence
  ("Ben wirkt fröhlich (Valenz +0.6, Erregung +0.1) – nur ein Hinweis aus
  dem Gesichtsausdruck, keine Tatsache." / "… looks happy … not a fact.").
- build_instructions passes the configured language and adds a rule:
  mood hints are guesses, never facts, no psychoanalysis, never probe,
  never change how someone is greeted or treated. who_is_here carries it.
- Mood is deliberately NOT part of situation_key: it never rebuilds the
  prompt, never triggers a greeting, never gates anything.

Hermes plugin (apps/face2ai-hermes-plugin):
- Presence dataclass gains the fields (from_payload/to_dict → snapshot,
  dashboard API); PresenceStore.apply handles `mood` frames (no
  transition, returns None); describe() appends the same hedged sentence
  (de/en); _handle_frame persists on mood frames; SYSTEM_SECTION and the
  presence_now tool description hedge the hint.
- desktop/plugin.js: `mood` frames update latest.presence; the pane shows
  "wirkt fröhlich (Valenz +0.6, Erregung +0.1)" under the state line.

Tests: agent 25 → 30, plugin 11 → 14. READMEs: one mood paragraph each.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- ADR-003: local MediaPipe + EmotiEffLib behind ExpressionEngine, opt-in,
  hedged, never gating; honesty (no CASME micro-expressions), EU AI Act
  Art. 5(1)(f) note, mediapipe 0.10.21 pin + numpy override, M1 timings,
  review triggers; imentiv.ai rejected for the live loop.
- README: "Expression hints (opt-in)" section (install, fetch script, env,
  POST /api/expression, status fields, faces[].expression, Presence.mood,
  SSE mood semantics, M1 numbers) + ADR index.
- AGENTS: expression = hint/never gate, hedged wording tests, domain must
  not import mediapipe/emotiefflib, wire without landmarks/pixels, browser
  mood log vs server MoodTracker.
- VALIDATION: target-Mac expression gate (toggle, smile/frown, mood events,
  toggle-off, agent smoke "Wie wirke ich gerade?", Hermes /presence).
- UI_DIRECTION: expression tile + debounced "Mood" stream entries.
- ARCHITECTURE: Expression port in the diagram, mood.py, extension point.
- CI: no change in steps; comment records that the app-shell job runs the
  expression tests deliberately without the heavy extra.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chrome cached model.js heuristically (no Cache-Control) and paired it with a
fresh app.js after the redeploy: "does not provide an export named axisPercent"
broke the whole shell. Assets and index now send Cache-Control: no-cache; ETag
revalidation keeps 304s cheap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lback

Silences "coroutine '_consume_events' was never awaited" at every gateway start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ache note)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…review nits

- scripts/fetch-expression-models.sh now downloads BOTH assets (face_landmarker.task
  and emotiefflib's enet_b0_8_va_mtl.onnx into ~/.emotiefflib/, the exact cache path
  emotiefflib 1.1.1 resolves); atomic .part + mv, idempotent, prints both ls lines.
- MediaPipeExpressionEngine.__init__ checks the emotiefflib file before constructing
  the recognizer and reports "missing EmotiEffLib model … (run scripts/…)" instead of
  letting emotiefflib download from GitHub; docstring says what is true (models load
  in __init__, offline). Test with HOME in a tmp dir + urlretrieve patched to raise.
- landmark_bbox(): empty landmark sets yield a degenerate box (never matched, indices
  kept); bbox/matching wrapped so a malformed landmark result no longer drops the
  emotion hint for every face (falls back to idx None).
- model.js formatAxis(): signed two-decimal axis label, -0.004 -> "+0.00" (no "-0.00");
  app.js uses it; JS test added.
- identity_service: comment on the intentionally non-strict zip.
- Drift guards: MOOD_WORDS de/en label sets == the 8 wire labels (agent + plugin tests).
- ADR-003 / README / plugin README: script fetches both assets, startup never touches
  the network; Face2AI persists nothing about expression, the Hermes plugin mirrors only
  the current presence snapshot (overwritten, no history).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… ADR-003

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…melines

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…odels, live affect

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bounded ring buffers (deque maxlen) for AffectSample / MoodTransition / ActionEvent;
samples additionally age-bounded relative to the newest sample; snapshot() answers a
[now - seconds, now] window (inclusive), optionally per identity; clear() empties all
three. In-memory only, never persisted, cleared on presence reset and restart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hysteresis state machine per action group (smile, frown, brow_raise,
brow_furrow, eye_squint, eyes_wide, nose_wrinkle, lip_press) over
Expression.blendshapes: on_threshold starts, off_threshold ends, min_frames
swallows single-frame spikes; presence-key change or unreadable frame drops
active actions without guessing an offset. Timing is frame-quantized
(~0.6 s at the browser loop) — expression dynamics, not micro-expressions.

Deviation from the plan sketch: only expression is None counts as
unreadable. An empty blendshapes dict is a compacted all-zero (neutral)
frame and must complete active actions — the plan's own
test_two_actions_can_overlap requires this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e prunes against newest seen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…line endpoint

Stage 2 wiring (plan Task 3): /api/recognize now feeds one frame through
_observe_expression — mood transition (published + recorded), the presence
carries the live smoothed valence/arousal from MoodTracker.affect() on every
frame (mood label keeps its hysteresis, the mood event keeps the frozen values),
an AffectSample is recorded per readable frame, and completed facial actions
from ActionTracker are published as SSE `action` and recorded. Actions/history
failures are warned once and never break recognition.

- main.py: app.state.actions = ActionTracker(...), app.state.history = AffectHistory(...)
- presence NO_SIGNAL crossing, POST /api/presence/reset (also clears the history)
  and the expression toggle-off drop active actions — offsets are never guessed
- new GET /api/expression/timeline?seconds=10..3600&identity_id= -> TimelineSnapshot
- SSE docstring + README: `action` event, timeline endpoint, live Presence.valence/arousal
- conftest: FakeExpressionEngine.script (per-call FIFO results)
- tests: action events + timeline over live uvicorn, live valence without mood,
  reset clears timeline + active actions, toggle-off drops actions, query validation,
  dynamics failure never breaks recognize (139 -> 146)
- mood.py/test_mood.py: Task 0 review nits (docstrings for live affect, arousal rounding note)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stage 2 SSE `action` (completed facial action: onset/apex/offset) must
never move the voice loop: run_presence_loop does not dispatch it, live or
replayed, memory stays untouched and the following heartbeat is still
handled. The loop already fell through for unknown events (31 -> 32 pass,
no code change); the test pins that behaviour and the README states it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Stage 2 / Task 4 — the browser shell consumes the server's expression
dynamics instead of re-deriving them:

- static/js/events.js (new): tiny same-origin EventSource client on
  `/api/events?role=browser`, handles only `mood` and `action`, returns
  close(); role=browser never counts as a voice agent.
- app.js: mood + action entries in the event stream come from the server
  (MoodTracker hysteresis / ActionTracker onset-apex-offset are the single
  source of truth); the client-side `trackMood` debounce is removed. Entries
  log only while expression is on; wording hedged ("Ben looks happy.",
  "Ben: brief smile (0.9 s)"); one "Live events unavailable" entry on the
  first SSE error (EventSource reconnects by itself). No debounce beyond
  the server's; MAX_EVENTS stays 8.
- Tile sparkline: inline <svg role="img"> (`valenceSpark`/`valenceLine`)
  built from this page's OWN valence readings (`pushSample` /
  `sparklinePoints`, 120 samples), hidden until 2 samples, cleared with
  the tile.
- model.js: `describeAction` (en/de, "brief" ≤ 1 s, "held" ≥ 5 s, unknown
  action never throws), `formatDuration`, `pushSample`, `sparklinePoints`;
  `trackMood` removed.
- tests: model.test.mjs 20 → 22 (trackMood tests dropped, action /
  duration / sparkline added); test_static.py 20 → 24 (sparkline inline
  + hidden + accessible, events.js in the import graph and network-free,
  action wording hedged — no "is smiling", no "detected", no
  "micro-expression" — and trackMood gone from the bundle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Hermes plugin half of expression stage 2:

- presence.py: PresenceStore keeps the last 50 `mood` and 30 `action` SSE
  frames as raw wire dicts (under the lock, apply() returns None for both);
  snapshot() adds "moods" (last 20) and "actions" (last 10). ACTION_WORDS
  de/en + action_sentence(): "kurzes Lächeln (0.9 s)" / "brow raise (2.3 s)"
  / "anhaltendes Lächeln (6.0 s)" — brief ≤ 1 s, held ≥ 5 s, unknown labels
  hedged, non-numeric duration → no parentheses, never raises. describe()
  unchanged: actions are not spoken into the LLM context.
- __init__.py: persist on `action` frames (no presence_changed, no
  announcement); /presence appends "Zuletzt gezeigt: …" (last 3 actions);
  presence_now description mentions the history.
- dashboard/plugin_api.py: GET /timeline proxies Face2AI's
  /api/expression/timeline (seconds default 600 clamped 10..3600, optional
  identity_id, 3 s timeout; on error the same shape with empty lists).
- desktop/plugin.js: ACTION_LABELS + actionLabel(), moods/actions from the
  socket, refresh() also fetches /timeline?seconds=600, Pane gets a plain-SVG
  valence Sparkline (240×28, zero line, filtered to the current identity),
  "Stimmung zuletzt", "Ausdruck zuletzt", hedge tooltips and the ~0.6 s
  resolution hint; publish() shape {latest, history, moods, actions, timeline}.
- README: history/timeline paragraph (bounded, in memory, mirrored snapshot,
  no long-term storage, actions not in agent context), privacy note extended.
- tests: 15 → 20 (+2 /timeline API tests that skip without fastapi+httpx).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y identity filter, toggle-off clears affect

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ADR-004 records Stage 2 as built: ActionTracker (8 blendshape groups,
hysteresis 0.35/0.2, min_frames 2, speech articulators and blinks
deliberately excluded), AffectHistory (bounded in-memory ring buffers,
cleared on presence reset and restart), live affect on Presence while
MoodTransition stays frozen at commit, SSE `action`,
GET /api/expression/timeline, the browser's SSE client plus its local
sparkline, the Hermes plugin's history/sparkline, and the voice agent
ignoring `action` by design. Honesty section states the ~1.7 fps loop
(~0.6 s resolution), that CASME-sense micro-expressions are out of
scope, the dataset bias, the talking artefacts and the shared UNKNOWN:
presence key; the EU AI Act Art. 5(1)(f) note is carried forward.

AGENTS.md: actions/timeline are hints that gate nothing, nothing is
persisted, action wording rules ("brief smile", never "smiled
because…"), consumers must not turn an `action` into behaviour; the
stale Stage 1 claims are corrected (trackMood is gone,
Presence.valence/arousal are the live affect).

VALIDATION.md gains a runnable Expression dynamics gate; UI_DIRECTION
describes the sparkline and the calm, hedged, 8-entry evidence layer;
ARCHITECTURE and architecture-decision.json pick up the two services,
the timeline endpoint and ADR-004; README loses its stale debounce
sentence and gains the four new settings.

Measured for these docs: app pytest 152 passed, node --test 22 passed,
agent pytest 32 passed, plugin pytest 20 passed / 1 skipped (22 with
fastapi+httpx). No code changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Browser (b27ef09):
- an SSE reconnect replays up to 200 buffered events; mood/action entries are now
  dropped unless fresh (`isFreshEntry`, 10 s) instead of being logged with the
  current clock, and the same action label is displayed at most every 5 s
  (`allowActionEntry`) so a talking face cannot flush the 8-slot evidence log.
  Deliberate deviation from Task 4's "no debounce beyond the server's": these are
  display rules, nothing is re-derived — the server stays the source of truth
  (recorded in ADR-004 decision 5, AGENTS.md and UI_DIRECTION.md).
- `pushSample` no longer hangs on a negative `max` (splice, not a shift loop);
  `sparklinePoints` skips junk instead of emitting NaN coordinates and tolerates a
  non-array; `describeAction`/`describeExpression` survive `lang === '__proto__'`.
- the brief/held qualifier follows the *printed* duration in all three wording
  tables (model.js, presence.py, desktop plugin.js): 4999 ms reads "held smile
  (5.0 s)", never a bare "smile (5.0 s)" next to "held smile (5.0 s)".
- events.js reports a missing EventSource as `{unsupported: true}` so the shell
  stops promising a reconnect that cannot happen; sparkline aria-label no longer
  claims a time span the index-based x-axis does not carry.
- new tests/js/events.test.mjs drives the client against a fake EventSource
  (subscribed URL, parsing, close(), unsupported branch); test_static.py drops the
  source-shape regex it replaces and the vocabulary tripwire is renamed to what it
  measures — the behavioural guard lives in model.test.mjs.

Plugin (f8bd9d2):
- `POST /api/presence/reset` now publishes SSE `timeline_cleared` when the clear
  dropped something, and the plugin (gateway store + desktop pane) forgets on it.
  The user's "forget" was stopping at the tunnel: a NO_SIGNAL transition is not the
  signal, since an ordinary presence expiry publishes one and keeps the history.
  `AffectHistory.clear()` returns the dropped count; the agent ignores the frame.
- `snapshot()` copies each mood/action dict, so a caller can no longer write back
  into the store's ring buffer; the pane's disposer drops the history too.
- the pane fetches /timeline on its own >= 20 s cadence (not the 4 s presence poll:
  a 10 min window is up to 2000 samples over the tunnel) and passes identity_id, so
  Face2AI filters; without a known person only unattributed samples are drawn.
- non-finite duration/valence no longer print as "(nan s)"/"+nan"; language=None no
  longer raises; /presence prints local wall time like describe() and omits empty
  parentheses; whitespace identity_id is not a filter; the system section hedges
  facial actions, which presence_now hands over as raw dicts.
- new tests/test_desktop_plugin.py greps plugin.js for JSX: `node --check
  plugin.js` does NOT reject JSX here (module-syntax detection swallows it,
  measured on Node v24.16.0) — the .mjs-copy form does; noted in the plan/README.

Gates: app 153 passed, js 31 pass, agent 32 passed, plugin 26 passed 1 skipped
(30 with fastapi+httpx), compileall/node --check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…west sample

A mood end (toggle-off, expiry, reset) and a completed action are recorded after
the last frame, so anchoring `now` on the newest sample cut them out of
GET /api/expression/timeline: measured the ring buffer holding
[(None,'Happiness'), ('Happiness',None)] while the snapshot returned only the
first. The consumer that mirrors this as "mood history" (Hermes pane) therefore
never saw a mood end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e timeline

Two API-level tests that the earlier fix (bc05d60, `AffectHistory.snapshot()`
anchoring `now` on the newest entry across all three buffers) made possible:
a mood end has no frame of its own, so before that fix it was recorded but
invisible through `GET /api/expression/timeline`.

- toggle-off: `POST /api/expression {"enabled": false}` closes the committed
  mood in the history, not only on the wire.
- expiry: the lazy `GET /api/presence` expiry does the same (own uvicorn with
  a short `presence_stale_seconds`, which the shared `live` fixture cannot
  express).

Both are mutation-verified: dropping the matching
`_history(request).record_mood(mood_ended)` in `routes.py` fails exactly the
corresponding test and nothing else.

Extracts the uvicorn boilerplate the `live` fixture and the stale-presence
test already shared into a `_live_server` contextmanager (it also gains a
`finally`, so a failing test no longer leaks the server thread).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ooldown

Four browser-shell defects from the boundary-contracts plan (§7), each with a
test that fails without the fix.

Consent was sticky: name and consent were cleared only on the enrollment success
path, so a dialog dismissed with Escape after a failure reopened with the
previous person's name and a pre-ticked biometric consent box. One `resetEnrollForm()`
now owns name, consent, the error banner and the frozen frame, and runs on every
close path.

The frame and its event could drift apart: `tick()` published `state.lastBlob`
before awaiting `api.recognize` while the event was assigned after, so a LEARN
click during an in-flight request froze frame N+1 with frame N's event. They are
published as one `state.latest = { blob, event }` in `handleRecognition`, and the
dialog freezes that pair.

The greeting cooldown was a single last-identity slot, which two enrolled people
bypassed entirely (each arrival reset the other). It is now a Map per identity,
bounded to 32 entries, least-recently-greeted evicted first.

events.js could go permanently deaf: per the EventSource spec a non-2xx status or
a wrong content-type fails the connection and leaves readyState CLOSED, and only a
dropped connection reconnects by itself — the file's comment claimed otherwise. It
now re-subscribes on a closed connection with bounded backoff and reports which
failure happened; the Context card shows the stream state. Reasoned from the spec
and pinned against a fake EventSource, not confirmed in a browser session.

Tests: 35 node --test (was 31), 27 test_static (was 24). The DOM-bound rules in
app.js are pinned as source assertions in test_static.py and say so; everything
extractable (per-identity cooldown, stream status wording) is unit-tested in model.js.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ess from a script

Task 6 of docs/plans/2026-08-19-boundary-contracts.md. Two of these three items close
confirmed handover findings; the launcher is the user's explicit request and not a
consequence of the review's root cause. ADR-005 says which is which instead of presenting
the launcher as a root-cause fix.

REVERSES A RECORDED DECISION. The project CLAUDE.md said: "`apps/face2ai/uv.lock` is
untracked (never committed); `uv sync` regenerates it." The lock is tracked from now on,
for a specific reason rather than a general preference: it is the only artifact in this
repository that pins the native stack exactly — dlib 20.0.1 with a sha256 — while the
fork's setup.py asks only for `dlib>=19.7`, and dlib is the library behind the confirmed
re-entrancy defect (two threads on one frame report [1, 78, 193, 456] faces and abort the
interpreter). The sibling voice agent already tracks its lock; the product did not, so no
second party could reproduce this environment. Verified fresh at this commit:
`uv lock --project apps/face2ai --check` exits 0.

A committed lockfile enforces nothing on its own. The owner is `uv sync --frozen` in the
app-shell CI job, and that change is NOT in this commit — .github/workflows/face2ai.yml is
owned by another task in flight. ADR-005 records it as the open follow-up. The wrapper
CLAUDE.md (outside this repository) has been updated in place.

README: the environment surface was 9 of 18 variables, and FACE2AI_HOST, FACE2AI_PORT and
FACE2AI_MATCH_TOLERANCE were documented only in a wrapper directory that is in no
repository at all. All 18 are now in one table with default, range, and whether anything
enforces that range — enumerated from config.py, not from the plan. Measured: 14 numeric
fields, 10 range-checked in __post_init__; Settings(port=70000), match_tolerance=-1,
max_frame_bytes=0 and greeting_cooldown_seconds=-5 are all accepted today, so those four
ranges are marked unenforced rather than written as if they held. The table itself still
has no executable owner; the README says so and names the test that would be one.

scripts/face2ai-service.sh {start|stop|status}: the testable unit the macOS launcher will
be a thin shell over — the applet gets no product logic. start probes /healthz and refuses
to start a second instance, launches detached with log and pid under $FACE2AI_DATA_DIR, and
waits for /healthz or exits non-zero with the log tail after stopping what it started. stop
SIGTERMs the process and its descendants (uv run forks, so the launched pid is not always
the server pid), escalates to SIGKILL after a bound, and is a clean no-op when nothing runs.
status prints the /healthz JSON and exits non-zero when down.

Exercised on port 8815 with an isolated data dir (8765 untouched): start -> status -> start
again (refused, no second process) -> stop -> status (down, exit 1) -> stop (no-op, exit 0),
pid file gone, no listener left. Two branches measured separately: with one SSE subscriber
attached the current process ignores SIGTERM and only the SIGKILL escalation frees the port
(independently reproducing plan §3); and stop against a process it did not start refuses and
leaves it alive — deliberate, because port 8765 on this machine carries a backend, a voice
agent and an SSH tunnel. `bash -n` passes.

The .app bundle is deliberately not built here and osacompile was not run; ADR-005 carries
the recipe and keeps the manual checklist open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With one SSE client attached — the normal case, since the browser opens a stream on page
load — SIGTERM x3 and SIGINT x2 left the server running; only SIGKILL worked.

uvicorn's Server.shutdown() awaits _wait_tasks_to_complete() *before* lifespan.shutdown()
(uvicorn 0.48 server.py:271-301), so no FastAPI lifespan hook can end the wait it is meant
to end. The owner of "this process stops when asked" is therefore a uvicorn.Server subclass:
Face2AIServer closes the event broker first, which lets every parked SSE stream return.

- IdentityEventBroker.close(): one None sentinel per subscription, handed over with
  loop.call_soon_threadsafe under the publish lock (a direct put_nowait from another thread
  does not wake a parked getter). subscribe() after close hands out a queue that already
  holds the sentinel, so a request arriving during shutdown cannot re-pin the process;
  publish() after close is a no-op.
- The SSE loop returns on the sentinel and, in the asyncio.TimeoutError branch, on
  broker.closed — wait_for cancelling a woken get() can drop the item, which would make the
  test flaky rather than failing.
- run() builds uvicorn.Config(create_app, factory=True, timeout_graceful_shutdown=10) and
  serves it with Face2AIServer; the timeout stays a backstop, never the mechanism. It also
  calls logging.basicConfig honouring FACE2AI_LOG_LEVEL, so the startup INFO lines are
  visible at all.
- The module-level `app = create_app()` is gone: importing face2ai_app.main now costs 0.21 s
  instead of seconds and no longer binds a real engine into every importer, including the
  test suite. VALIDATION.md's runtime smoke moves to the shipped `face2ai` entry point and
  says why the raw uvicorn form does not own its shutdown.

Measured with the real entry point, one raw SSE socket attached, timeout_graceful_shutdown=30:
SIGTERM exits in 0.24 s (rc -15), SIGINT in 0.35 s. With broker.close() removed the same
scenario takes 30.23 s — the new test fails, so it discriminates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four obligations every recognition adapter owes the application now live in
`ports/recognition.py` as prose and in `tests/test_port_conformance.py` as a suite
that runs them against every adapter present in the environment.

The defect they were written for, measured on this Mac: `FaceRecognitionEngine.detect`
called `face_recognition`'s process-global dlib singletons with no lock while
`api/routes.py` runs it in a 40-worker threadpool. Cold interpreter, 4 threads x 24
calls on one frame: SIGSEGV in 7 of 10 runs (`exit=-11`). A module-level
`threading.Lock` held across `face_locations` + `face_encodings`: 0 of 10.

One correction to the plan's recipe, measured: taking the serial baseline *before*
the concurrent load warms dlib's lazy first-call state and the unlocked adapter then
survived 5 of 5 runs. The baseline is therefore taken after the load, and the child
script says so — the obvious order would have shipped a green test over a crashing
adapter.

`decode_frame(image_bytes, max_pixels)` is now the one owner of two rules that had
drifted into two copies a day apart: the pixel budget is checked against the image
header *before* any pixel is decoded (`Image.open` is lazy), and `exif_transpose`
puts every box and encoding in the space the browser draws in. Both the recognition
and the expression adapter use it; a rotated-with-EXIF photo went from 0 faces to 1.

`IdentityStore` closes its error set: an `OSError` was reaching the API as a raw
`IsADirectoryError`/HTTP 500 that blamed the request for an infrastructure fault. It
is now `IdentityStoreUnavailable` -> 503, distinct from `IdentityStoreCorrupted`.

The `recognition-contract` CI job installs `--extra recognition` and sets
`FACE2AI_CONFORMANCE_REQUIRE_REAL=1`, so the suite cannot be green only against a
double that satisfies every obligation by construction.

Cost of the lock, two clients posting a 640 px JPEG at the shipped 450 ms cadence
against a probe server (180 s, ~790 requests each run): p50 188.3 -> 170.1 ms,
p95 217.5 -> 330.6 ms. p95 stays under the 450 ms loop interval at two tabs. At four
tabs it does not (p50 620.7, p95 642.4 ms) — the concurrency budget deferred in the
plan's section 9 is now a measured number, not a worry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…shape

Four knobs carried a documented range that nothing enforced: `Settings(port=70000)`,
`match_tolerance=-1`, `max_frame_bytes=0` and `greeting_cooldown_seconds=-5` were all
accepted (measured at 3857adc: 15 numeric fields, 11 range-checked in `__post_init__`,
4 not). `match_tolerance` is the knob that decides who you are. They were unchecked
because validation was added per-commit, each commit range-checking only the knobs it
introduced -- the pattern this plan's §1 measured.

`test_every_numeric_setting_is_validated` is the owner that stops the next occurrence:
every `int`/`float` field of `Settings` must be mentioned in `__post_init__`. Proven to
discriminate -- adding an unbounded `party_mirror_confetti_count: int` fails it by name,
bounding the same field turns it green. It matches the annotation with a word-boundary
regex rather than the plan's literal `f.type in ("int","float")`, because with
`from __future__ import annotations` the latter silently misses `int | None` (measured:
plan predicate False, shipped predicate True on the same field).

`test_every_setting_is_documented` is its sibling for the README table, which that table
had itself nominated: every `FACE2AI_*` literal in `config.py` needs a row.

`IdentityRecord.encodings` was a bare `list[list[float]]` while `DetectedFace.encoding`
was 128 floats, so a hand-edited identities.json loaded happily and then made every
`/api/recognize` raise `ValueError` inside `math.dist` -- an HTTP 500 contradicting the
documented "corrupt store -> 503" contract. One `Encoding` alias now owns the shape for
both, so the store's existing `ValidationError -> IdentityStoreCorrupted` mapping covers
it; a route-level test pins the 503.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`Presence.stale` could never be `True` on the wire: `snapshot()` computed it
against the same threshold `_expired()` uses, and every reader calls `expire()`
first — so a presence old enough to be stale was already `NO_SIGNAL`, whose
`stale` is `False` by construction. Two consumers printed a "no fresh frames
lately" honesty line that could not fire.

Remove the field from the wire instead of inventing a second threshold.
`observed_at` is already on the wire and `expire()`
(`FACE2AI_PRESENCE_STALE_SECONDS`) stays the single server-side freshness rule;
a consumer that wants a freshness line owns its own budget — which the one
consumer that does own a clock, the plugin's `context_line(max_age_seconds=30)`,
already did correctly. Deleted from `Presence`, from `PresenceTracker`
(`snapshot()` loses its now-unused `now` parameter with it), from the voice
agent (field, `from_payload`, `situation_key`, the English honesty line), from
the Hermes plugin (field, `from_payload`, the German honesty line, both
`plugin.js` sites) and from the documented contract; the wire key-set tests pin
it, a source guard pins the unbuilt desktop half.

Also: the plugin's live `/presence` branch returned `{source, presence,
events_url}` while both fallback branches return `connected`, and `plugin.js`
replaces its `latest` wholesale on every 4 s poll — so the desktop chip read
"Face2AI nicht verbunden" after every *successful* poll and only flipped back on
the next SSE frame. All three branches now answer `connected`.

Gates: app 204 passed · JS 35/35 · agent 32 passed · plugin 27 passed 1 skipped
(32 with fastapi+httpx) · node --check and compileall exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Greeting ownership was handed over by a query parameter with no origin check, so
any page that reached the port could subscribe as ?role=agent. Measured against a
running server before the fix:

  before: agent_connected = False   subscribers = 0
  cross-origin SSE response status line: HTTP/1.1 200 OK
    (Origin: https://evil.example, Sec-Fetch-Site: cross-site)
  after : agent_connected = True    subscribers = 1

The browser shell then goes deliberately silent ("greeting left to the voice
agent"), so a background tab could switch off the product's headline behaviour —
and this port is reverse-tunnelled to a VPS, so "loopback, therefore safe" is the
wrong frame.

GET /api/events?role=agent is now refused with 403 when the request carries a
Sec-Fetch-Site header whose value is not same-origin. Browsers always attach that
header and a page cannot forge it; the voice agent and the Hermes plugin drive
httpx and never send it, so an absent header stays allowed and neither is
affected. 403 rather than 401: the request is understood and refused on origin,
and no credential the caller could add would change that. The check runs before
the StreamingResponse is built — once it is returned the 200 is committed and the
generator that registers the subscription has no way back.

Same server after the fix, same script: HTTP/1.1 403 Forbidden, agent_connected
stays False, subscribers stays 0. Live cross-check: the httpx agent (no header)
gets 200 + event: hello + agent_connected True; a same-origin browser request
gets 200; a cross-site request with a non-privileged role is still served, since
the wire carries states, names and counts only.

Tests (6 new, both directions): cross-site / same-site / none are refused and do
not flip agent_connected; a header-less httpx request subscribes as the agent;
same-origin browser-shaped requests are accepted for role=browser and role=agent.
Both mutants die — removing the call fails the three refusal cases, dropping the
header-absent exemption fails the real-agent case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
build-macos-app.sh compiles the ADR-005 applet stay-open (osacompile -s) over
scripts/face2ai-service.sh. Verified end to end on this Mac: `open -a Face2AI`
brings the backend up and opens the UI, the applet stays in the Dock, and Quit
stops the backend (0 s), removes the pid file and frees the port. A one-shot
applet would have fired `on quit` the moment `on run` returned and stopped the
server it had just started, so the build script verifies the flag instead of
trusting it.

The plan is committed with it: a review found 45 defects (34 confirmed by an
independent verifier), and three plan auditors then falsified revision 1's root
cause and proved its headline fix inert. Revision 2 records both, so the next
reader can check the claims instead of believing them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants