Skip to content

feat(visaged): show the user what the camera sees while enrolling - #115

Merged
ccross2 merged 1 commit into
mainfrom
feat/enrollment-preview-frames
Sep 16, 2026
Merged

ccross2 merged 1 commit into
mainfrom
feat/enrollment-preview-frames

Conversation

@ccross2

@ccross2 ccross2 commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

The problem

Enrollment is blind. The user is told to look at a lens, four captures happen, and nothing says whether they were too dark, off-centre or out of frame. When a capture fails there is no way to tell why, and the retry is the same guess again.

This adds a PreviewFrame signal carrying downscaled grayscale frames while an enrollment is running.

The shape, and why it is this shape

There is deliberately no method to ask for a camera frame. The only producer is an Enroll the caller started; frames are unicast to that caller's own bus name rather than broadcast; and the channel exists for exactly the duration of that call.

So the preview cannot be used as a general camera tap, and it adds no new privilege boundary — it inherits Enroll's existing root-only check rather than introducing a second one to keep in sync.

Frames are downscaled in the engine thread, before crossing any channel. There is no code path on which a full-resolution capture reaches the bus.

Dark frames are emitted too

The capture loop discards frames it judges too dark — and those are exactly the ones worth showing. "Too dark" is the most actionable thing a user can be told, and it is only knowable inside that loop. They arrive with is_dark set and no contrast enhancement, so a client can label them rather than silently showing nothing.

The authentication path runs the same code

capture_frames now delegates to capture_frames_observed with a no-op closure, so verify executes the identical function body. The two paths cannot drift apart because there is only one.

observe() must not block — it runs between buffer dequeues. Sends are try_send and dropped under backpressure: a slow or absent preview consumer must never slow down or fail an enrollment.

160px is a security parameter, not a tuning knob

The preview is allowed out of a biometric daemon precisely because it is too coarse to be a useful capture. That argument only holds while the number stays small, so it is pinned by a literal whose failure message says raising it needs a threat-model review.

That test exists because the obvious one does not work. Asserting longest_edge <= PREVIEW_MAX_EDGE compares the downscaler against the very constant at issue — it passes just as happily at 2000px, and the security argument would evaporate with nothing going red. Measured, not assumed: with the constant raised to 2000, five of the six tests still pass and only the literal pin fails.

Verified

Six hermetic tests. No hardware, no new dependencies. Proven to fail in both directions, against a positive control so an empty run cannot masquerade as a pass:

control result
unmodified 6 passed
PREVIEW_MAX_EDGE = 2000 1 failed — only the literal pin
downscaler returns source untouched 4 failed
restored 6 passed

Full repo gate, exit codes from unpiped runs: fmt --check 0, clippy --workspace --all-targets -D warnings 0, test --workspace 0 at 99 passed.

For a reviewer — three judgement calls, not facts

  1. Dark frames are deliberately emitted, so slightly more imagery crosses the bus than the strict minimum. The trade is that the user learns why a capture failed.
  2. 160px is my choice of "enough to frame a face, not enough to be a capture". Worth an opinion.
  3. verify now routes through capture_frames_observed. Behaviour is byte-identical — same body, no-op closure — but it is a change to the auth path's call graph and deserves an eye rather than my assurance.

Not included

No client consumes this yet. The ratatui and pure-Rust-GUI prototypes are the next piece and both read this same signal, which is the point — the comparison is between interaction models, with the data path held constant.

🤖 Generated with Claude Code

Enrollment is blind. The user is told to look at a lens, four captures happen,
and nothing says whether they were too dark, off-centre, or out of frame. When
a capture fails they are given no way to tell why, and the retry is the same
guess again.

This adds a PreviewFrame signal carrying downscaled grayscale frames while an
enrollment is running.

The shape, and why it is this shape

There is deliberately NO method to ask for a camera frame. The only producer is
an Enroll the caller started; the frames are unicast to that caller's own bus
name rather than broadcast; and the channel exists for exactly the duration of
that call. So a preview cannot be used as a general camera tap, and the feature
adds no new privilege boundary — it inherits Enroll's existing root-only check
rather than introducing a second one to keep in sync.

Frames are downscaled in the engine thread, before crossing any channel. There
is no code path on which a full-resolution capture reaches the bus.

Dark frames are emitted too

The capture loop discards frames it judges too dark, and those are exactly the
ones worth showing: "too dark" is the most actionable thing a user can be told,
and it is only knowable inside that loop. They arrive with is_dark set and no
contrast enhancement, so a client can label them rather than silently showing
nothing.

The auth path runs the same code

capture_frames now delegates to capture_frames_observed with a no-op closure, so
verify — the authentication path — executes the identical function body. The two
paths cannot drift apart, because there is only one.

observe() must not block: it runs between buffer dequeues. Sends are try_send
and dropped under backpressure. A slow or absent preview consumer must never
slow down or fail an enrollment.

160px is a security parameter, not a tuning knob

The preview is allowed out of a biometric daemon precisely because it is too
coarse to be a useful capture. That argument only holds while the number is
small, so the number is pinned by a literal with a message saying that raising
it needs a threat-model review.

That test exists because the obvious one does not work. Asserting
`longest_edge <= PREVIEW_MAX_EDGE` compares the downscaler against the very
constant at issue: it passes just as happily at 2000px, and the security
argument would evaporate with nothing going red. Measured, not assumed — with
the constant raised to 2000, five of the six tests still pass and only the
literal pin fails.

Verified

Six hermetic tests, no hardware, no new dependencies. Proven to fail in both
directions, against a positive control so an empty run cannot masquerade as a
pass:

  unmodified                        6 passed
  PREVIEW_MAX_EDGE = 2000           1 failed  (only the literal pin)
  downscaler returns source as-is   4 failed
  restored                          6 passed

Full repo gate, exit codes read from unpiped runs: fmt --check 0, clippy
--workspace --all-targets -D warnings 0, test --workspace 0 at 99 passed.

Signed-off-by: ccross <cescross2@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ccross2
ccross2 merged commit 7c9cbf9 into main Sep 16, 2026
5 checks passed
@ccross2
ccross2 deleted the feat/enrollment-preview-frames branch September 16, 2026 19:24
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.

1 participant