Feat/face2ai UI port - #1679
Open
DYAI2025 wants to merge 90 commits into
Open
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.