Skip to content

feat: manual face review mode — audit any person's faces without a scan - #838

Merged
Deeds67 merged 37 commits into
feat/face-review-unifiedfrom
feat/face-manual-review
Jul 26, 2026
Merged

feat: manual face review mode — audit any person's faces without a scan#838
Deeds67 merged 37 commits into
feat/face-review-unifiedfrom
feat/face-manual-review

Conversation

@Deeds67

@Deeds67 Deeds67 commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Adds a second entry path to the admin face cleanup console: pick any person and audit all of their faces, with no scan required. Builds on #834's verdict layer and is based on feat/face-review-unified (that PR is untouched).

Spec: docs/superpowers/specs/2026-07-23-manual-face-review-mode-design.md

Why

The console is scan-driven end to end. Open a person the scan did not flag and you get "no flagged faces" — there is no way to say "I know this person's cluster is dirty, show me everything."

The model

Manual mode introduces no new table, column, or status. It writes exactly the rows guided writes, so a decision made either way is durable and honoured by both engines:

Action Writes
Move to person asset_face.personId + face_identity_face.source='manual' + drain
Lock source='manual' + drain
Unknown person new person, faces moved with manual links
Not a face deletedAt + personId=NULL + link deleted + drain
Keep (default) nothing

stay is deliberately absent: it writes a negative verdict against the suspected owner, which does not exist without a scan.

Server

Relaxes guard E15 so lock/unknown/detach accept any face on the person; stay keeps the gate.

Lifting it for lock was not merely permissive — it removed a live guard. getScanFlaggedFacesForPersons re-validates personId/deletedAt/isVisible at snapshot-read time, so a foreign id could never previously reach the write path. With the gate dropped and no replacement, resolveFaces was verified to return locked: 1 for a face on another person, for another user's person, and for a soft-deleted face — silently re-pointing them via replaceFaceIdentities' unscoped ON CONFLICT DO UPDATE; a nonexistent id crashed with an unhandled FK violation. getEligibleFaceIdsForPerson replaces that guard explicitly.

Also adds GET admin/face-repair/person/:personId — the manual page has no scan to read personName/ownerId from, and ownerId is what scopes the move-picker.

Web

A separate manual review page rather than a mode flag on the guided one. The guided view-model cannot serve manual mode: suspectedOwnerId is required, every face initialises to 'owner', and buildResolveRequest dereferences it — so an untouched manual review would POST a move for every face to an undefined destination. It also has no neutral state (six terminal states with a tested tally invariant) and rebuilds from a $derived, which would wipe staged decisions on every page load. Forking leaves all nine guided specs untouched.

New surfaces: mode chooser (equal-weight cards, five scan states, manual card disabled mid-scan because resolveFaces 409s), people browser, manual review page, and its own view-model + help modal.

Verification

  • Server: lint, prettier (whole package), tsc all clean; 5388 unit tests pass; medium suite green except one pre-existing flaky sync test (sync-album-user, fails in isolation too, unrelated).
  • Web: tsc clean, 0 lint errors, 4123 tests pass.
  • e2e: the manual flow and cross-engine invariant were executed against a locally rebuilt stack and pass, with DB assertions confirming move/lock/detach wrote the right rows — and that a kept face was left source='ml', i.e. not mass-stamped.

Known issue for CI to arbitrate

The pre-existing e2e test "admin renders face crops for a cluster owned by a different user" fails locally in the full serial run only when the two manual-review tests are declared — despite them running strictly after it. It passes in isolation, with --grep-invert, and with two trivial filler tests in the same position. No collection-time side effects exist and no route shadowing; the admin thumbnail path is untouched. Flagging rather than hiding it.

Deeds67 added 16 commits July 23, 2026 22:24
Adds a second entry path to the admin face cleanup console: pick any
person and audit all of their faces, with no scan required. Reuses the
existing tile grid, bulk-select-then-apply interaction, and verdict
writes, so a manual decision is indistinguishable from a guided one.

Server work is narrow: relax guard E15 for lock/unknown/detach (keeping
stay scan-only, since it needs a suspected owner), add the eligibility
check that snapshot membership was implicitly providing, and add an
admin person-metadata endpoint for the name/ownerId the review page
currently reads off the scan.
Three independent verification passes against the code disproved the
first draft's central premise and five other claims.

The big one: manual mode cannot reuse the guided review page behind a
mode flag. FlaggedFace.suspectedOwnerId is required, every face defaults
to the 'owner' state, and buildResolveRequest dereferences it - so an
untouched manual review would POST moveToPerson with an undefined
destination for every face. There is also no neutral state (six terminal
states with a tested tally invariant), and the model is $derived over
the face array, so paging would wipe staged decisions.

The design now forks the review UI: a separate manual page under
/people/[personId] with its own view-model (keep default, stable across
appends, never emits stay), leaving all nine guided web specs untouched.

Also corrected: lock is a PRIMARY KEY upsert with DO UPDATE that can
steal a face from another identity (not an idempotent no-op via a plain
unique index - that claim came from a stale comment referencing the
long-gone insertLocks); FaceRepairRepository has no @GenerateSql so no
mise sql regen; the navbar needs no repointing; resolveFaces has no unit
coverage, only medium; and relaxing unknown converts one 400 into a
success with unknown:0.
…ning states

Pierre's review of the chooser design: a first-time admin has never
scanned, so the page needs a distinct first-visit presentation, and the
two modes should carry equal weight since some admins will live in
manual review rather than triaging scans.

Equal-weight cards with no recommended badge. First visit gets an
explanatory header and advertises that manual review needs no scan —
it is fully usable on a brand-new instance, which the returning
status-board layout would otherwise obscure. Adds the full card state
matrix (never-scanned / running / flagged / zero-flagged / failed),
including the manual card being disabled during a scan, which is the UI
half of the resolveFaces 409 guard and prevents staging dozens of
decisions only to lose them to a conflict.

Slices renumbered 1-26 to cover the two chooser presentations and the
remaining card states separately.
Folds the manual review page design into 6.4. The key point is a visual
inversion that falls out of the data model: guided stamps every tile
because every face holds one of six terminal states, while manual
defaults to keep, so tiles start clean and colour appears only where the
admin acted. keep needs no colour token - absence signals it, which
satisfies the existing rule that state is never encoded in colour alone.

Two interaction problems get resolved. Selection cannot claim the whole
cluster over server paging, so it always means loaded faces (Select all
loaded (N), showing N of M) and whole-cluster work goes through the
server-enumerated entireCluster with its own destination picker. And a
keep default needs an undo, so Unmark returns a selection to keep -
guided never needed one because every face is already stamped.

Slices consolidated 26 -> 12 on one test: can it ship alone without
leaving the tree broken or dead? The route move now carries its own e2e
repair (the move alone leaves CI red), the endpoint carries its SDK
regen (no caller otherwise), the eligibility read joins the lock slice
it exists for, and the chooser's three slices collapse into one
component with one state machine. The pure view-model stays separate
despite being small - highest logic risk on the branch.
…lagged ones

Manual review needs to lock a face that no scan flagged, so lock is
dropped from the E15 flagged-snapshot gate. stay keeps that gate: it
writes a negative verdict against the face's SUSPECTED owner, read from
the snapshot via a non-null assertion, so without a snapshot row there
is no owner to record against.

Lifting the gate for lock is not merely permissive - it removes a live
guard. getScanFlaggedFacesForPersons INNER JOINs asset_face and
re-validates personId/deletedAt/isVisible at snapshot-read time, so
today a foreign or deleted id can never reach the write path. Dropping
the gate without a replacement was verified to be exploitable: with lock
removed and no eligibility check, resolveFaces returned locked:1 for a
face on another person, for another USER's person, and for a
soft-deleted face - silently re-pointing them onto this person's
identity through replaceFaceIdentities' unscoped ON CONFLICT DO UPDATE.
A nonexistent id crashed with an unhandled FK violation rather than a
400.

getEligibleFaceIdsForPerson replaces that guard explicitly, mirroring
getClusterFacePage's predicate so lockable == listed on the manual
review page. It is advisory - the write-time guards remain
authoritative - and exists so an inapplicable lock is a 400 rather than
a silent no-op or a crash.

Tests: 66/66 in the resolve medium spec, incl. the cross-tenant arm.
…nly flagged ones

Completes the E15 relaxation: stay is now the ONLY snapshot-gated
bucket. It keeps that gate because it writes a negative verdict against
the face's SUSPECTED owner, read from the snapshot via a non-null
assertion - with no snapshot row that yields undefined and a 500/FK
violation.

Unlike lock, detach and unknown need no eligibility read: both are
already person-scoped at the write layer (detachFaces filters personId
and keys its identity-strip on the RETURNING output; reattributeFaces
filters still-on-source). That claim is now pinned by test rather than
trusted - a foreign face passed to detach is an inert no-op, left
completely untouched.

Behaviour change (spec 5.4): a stale unknown id - a face that left this
person since the scan - previously 400'd via the gate. It now returns
success with unknown: 0, truthfully reporting zero parks, and the
freshly created cluster is cleaned up rather than orphaned.

Tests: 72/72 in the resolve medium spec, 6/6 cross-flow.
GET admin/face-repair/person/:personId returns
{id,name,ownerId,faceCount,thumbnailFaceId}, admin-gated, 404 on
unknown.

The manual review page cannot get these from anywhere else. The guided
page reads personName and ownerId off getLatestScan(), and ownerId is
what scopes the move-picker - so with no scan the picker cannot open at
all. The user-scoped GET /people/:id does not admin-bypass for a person
the admin does not own, the same gap already fixed once for face
thumbnails.

faceCount reuses searchOwnerPeople's exact join conditions so the count
agrees between the browser grid and the review page header; a mismatch
there would read as a bug. The name is returned raw - an unnamed person
yields an empty name and the client applies its own fallback rather than
the server inventing a display string.

Ships with the client regen because the endpoint has no caller until
then. No @GenerateSql was added (this repository carries none) so no
mise sql regeneration; mise.lock verified unchanged.

Tests: controller 76/76, metadata medium 6/6, tsc clean.
…cleanup/scan

Frees /admin/face-cleanup for the mode chooser. Pure relocation of the
ten-file dashboard cluster via git mv - no behaviour change - plus a
temporary 307 redirect at the old path so this lands without a dead
entry point. The chooser replaces that redirect next.

The navbar is deliberately NOT repointed: NavbarItem highlights via
pathname.startsWith(href), so pointing at /admin/face-cleanup keeps it
active across /scan, /people and the chooser. Repointing it would be
the regression, not the fix.

Four e2e assertions broke on the move. Three are repoints; Playwright
globs match the whole URL, so '**/admin/face-cleanup' does not match
/scan. The fourth needed a real fix rather than a repoint: the
post-apply drain check asserts a person's name is ABSENT, which passes
vacuously on any page that never lists people - or on one that simply
has not finished loading. It now waits for the Eligible faces stat,
which renders unconditionally once a scan is completed and is therefore
independent of the drained-vs-not condition under test.

Tests: 117/117 across the nine guided specs from their new locations,
tsc clean.
Replaces slice 4's temporary redirect. Two EQUAL-WEIGHT cards with no
recommended badge on either: we do not know which mode a given admin
lives in, and privileging guided would penalise anyone who spends their
time in manual review.

Two distinct presentations rather than one degraded into the other.
First visit reframes the difference as readiness, not importance -
guided says it needs a scan first, manual advertises that it needs no
scan and can start right away. That line matters: manual review is fully
functional on a brand-new instance, and the returning status-board
layout would otherwise hide that behind an empty guided card, leaving a
first-time admin thinking the feature does nothing until a scan
finishes.

The manual card is disabled while a scan runs. This is the UI half of
resolveFaces' 409 guard: without it an admin can stage dozens of
decisions across a server-paged cluster and lose them all on Apply.
Disabled means genuinely unactivatable - no href and a real
<button disabled>, so keyboard cannot reach it either - not just
opacity. Only pending/running block it; failed and zero-flagged leave
manual reachable.

Visual vocabulary is reused verbatim from the dashboard - card shell,
dot+label+tabular-nums stat rhythm, semantic colours - so this reads as
the same product rather than a bolted-on page.

Tests: 13 new chooser cases, 130/130 across the face-cleanup suite,
tsc clean.
Builds the destination the chooser's manual card links to. Zero new
server endpoints: the owner list reuses searchUsersAdmin (the same call
the guided dashboard already makes) and the people grid reuses
getFaceRepairOwnerPeople, whose query parameter is optional - so
omitting it lists all of an owner's people, paginated.

Face crops go through the admin-gated, join-free face-thumbnail route,
NOT the user-scoped /people/:id/thumbnail, which 404s for people the
admin does not own. A null thumbnailFaceId renders a real placeholder
rather than a broken image.

Switching owner resets the page and clears the list, with a request
token guarding against a stale response clobbering a newer selection -
otherwise rows from two owners interleave. Pagination appends rather
than replaces.

Four states are kept distinct: this owner has no people, no results for
this query, load failed, and no users at all. Conflating empty with
error was defect D17 on the guided page.

No client-side filtering is applied to what the endpoint returns; that
passthrough is pinned by test so a later server-side change surfaces
here rather than being silently masked.

Tests: 14 new cases, 144/144 across the face-cleanup suite, tsc clean.
A separate pure model rather than a reuse of the guided one, because
the guided model cannot express manual review's semantics and two of
its properties are actively dangerous here.

No neutral state: guided's FaceState is six TERMINAL states and every
face initialises to 'owner', with buildResolveRequest dereferencing
suspectedOwnerId for it. Pointed at a scan-free cluster an untouched
review would POST a move for every face to an undefined destination.
Manual defaults to keep, which produces no bucket entry at all, and an
all-keep review builds to null rather than an empty request - so a
disabled Apply cannot be bypassed and the server never sees the empty
resolve it would 400.

State survives paging: guided rebuilds its model from a $derived over
the face array, so appending a page discards staged decisions. This
model owns its list and exposes appendFaces, which preserves both
states and selection and is idempotent on assetFaceId. Manual is
server-paged, so that defect would have been hit on the first Load
more.

stay is hardcoded [] on every request - manual can never emit it, since
it needs a suspected owner. Two @ts-expect-error guards make owner/stay
a compile error rather than a convention; they are load-bearing, since
tsc reports an unused directive if the type ever stops rejecting them.

Colour/icon tokens are imported from the guided model so one glyph means
one thing across both pages; keep deliberately has no token - it is
signalled by absence.

Tests: 26 cases, 170/170 across the face-cleanup suite, tsc clean.
…faces

Opens any person with no scan in existence and lists ALL of their
faces, via the scan-free cluster endpoint with an empty excludeFaceIds.

Implements the visual inversion this page is built around: guided
stamps every tile because every face holds one of six terminal states,
so its grid is a wall of colour to audit. Manual defaults to keep, so a
tile renders a clean crop with no badge and no ribbon, and colour
appears only where the admin acted. keep needs no colour token - it is
signalled by absence, which keeps the existing rule that state is never
encoded in colour alone without inventing a seventh swatch.

Selection is honest about server paging: it always means LOADED faces,
labelled Select all loaded (N) with showing N of M in the header. An
unqualified select-all would either lie about a cluster of thousands or
force loading all of it; whole-cluster work goes through the
server-enumerated entireCluster instead (slice 10).

Load more appends through the model rather than re-creating it, so
staged marks and selection survive - the defect that made reusing the
guided model impossible, and the first thing a paged cluster would have
hit.

Person name and ownerId resolve from the URL param through the metadata
endpoint rather than navigation state, so refresh and deep links work.
Empty and load-error are kept distinct (conflating them was D17 on the
guided page), and a person the scan flagged shows no flagged badging -
manual ignores scan state entirely.

Tests: 15 new cases, 185/185 across the face-cleanup suite, tsc clean.
The footer dock: Move to person (via the reused PersonPicker, scoped by
the ownerId the metadata endpoint supplies), Lock, Unknown, Not a face,
and Unmark - plus the staged-work tally and Apply.

Unmark exists because manual defaults to keep. Guided never needed one:
every face there is already stamped, so the admin only ever swaps one
stamp for another. Here marking is a deliberate act, so mis-marking
needs reversing without discarding every other staged decision via
Reset.

stay and owner are not offered and can never be emitted - both need a
suspected owner. The payload assertion pins stay: [] explicitly across
mixed buckets.

Apply is disabled while everything is keep, because buildResolveRequest
returns null and an all-keep POST would be the empty resolve the server
400s. Not a face keeps guided's destructive confirm, and declining it
posts nothing.

A 409 - a scan starting mid-review - surfaces without discarding staged
work. That conflict is what the chooser's disabled-manual card exists to
prevent; if it happens anyway the page must not compound it by throwing
away decisions staged across a paged cluster.

Tests drive the real UI rather than the model. 16 new cases, 201/201
across the face-cleanup suite, guided's 73 specs untouched, tsc clean.
Move entire cluster uses the server-enumerated entireCluster rather than
selection, which is the whole reason it belongs here: on a server-paged
page selection can only ever cover LOADED faces, so whole-cluster work
must not route through it. Unlike guided - where entireCluster rides the
scan's suspected owner - manual has none, so it requires an explicit
destination via PersonPicker, and it posts with no per-face buckets
since the server rejects combining them. It carries its own confirm
because it moves faces the admin has never seen, which is both the point
and the risk.

Manual gets its own help modal rather than extending guided's, whose
names all six actions test is load-bearing and stays untouched. The
modal leads with the thing most likely to confuse someone arriving from
guided: keep writes nothing. It also states that Not a face is the
irreversible one and sits beside Unknown, which means the opposite - bin
this crop versus this is a real person I cannot name.

Swatches are asserted against the tile tokens so an explanation ties
back to the button and the tile it describes, and keep renders no
swatch at all - absence is how the default is signalled.

Tests: 21 new cases, 222/222 across the face-cleanup suite, guided's
specs untouched, tsc clean.
…ariant

Two tests. The first drives the whole manual path against a person with
NO scan in existence: chooser to people browser to review page, listing
all faces rather than a flagged subset, then move, lock and not-a-face
through the real UI, asserting durable DB rows rather than UI text.

The second is the point of the feature: a later scan must honour a
manual decision exactly as it honours a guided one - the locked face is
not re-flagged, the detached face stays gone, the moved face is not
re-proposed.

Two seeding traps handled. utils.createFace links faces with
source='manual', which the verdict layer excludes from flagging, so
every face here is downgraded to 'ml' or it would not behave like a
machine-clustered one; and face_search rows are required because both
the cluster listing and lock eligibility inner-join it.

The cross-engine test seeds ONE multi-person scan rather than two
sequential ones: two scans stamped the same now() make getLatestScan
nondeterministic, which would let the invariant assertions pass
vacuously instead of for the right reason.
Adds a Two ways to clean up comparison and a Manual review section to
the admin face cleanup page.

The two points worth documenting explicitly are the ones an admin
cannot infer: that faces you leave alone are deliberately NOT recorded
(marking everything you glanced at as human-verified would stop future
scans flagging it and hide real mistakes, so Lock is the opt-in), and
that Select all covers the faces currently loaded rather than the whole
cluster - with Move entire cluster as the server-resolved alternative.

Also notes that manual review is unavailable during a scan, so the
behaviour reads as deliberate rather than broken.
@github-actions github-actions Bot added documentation Improvements or additions to documentation 🗄️server 🖥️web labels Jul 24, 2026
@Deeds67 Deeds67 added the changelog:feat Feature change for changelog label Jul 24, 2026
Deeds67 added 10 commits July 24, 2026 02:32
A Record<..., string> annotation widened each value to string, and $t
only accepts known translation keys, so the tally lookup stopped
type-checking. satisfies keeps the exhaustiveness check on the keys
while preserving the literal value types.

Caught only by CI's check-svelte: that task reports 0 FILES locally in
this worktree - even when invoked through mise exactly as CI does - so
neither tsc nor the local svelte-check surfaced it.
satisfies was not enough: indexing an object of whole translation keys
still yields string at the call site, and $t accepts a template-literal
type but not string. Matches the guided dock's idiom exactly -
$t(`admin.face_cleanup_review_tally_${...}`) - by mapping only the key
SUFFIX, with move reusing guided's existing 'other' key rather than
adding a near-duplicate.

Only CI's check-svelte catches this; that task scans 0 files locally
even when invoked through mise as CI does.
Rework the two-door chooser at /admin/face-cleanup into a considered design: a 48px icon tile anchors each mode, guided keeps the primary accent while manual gets a distinct teal identity (neither marked recommended), and each card carries a tinted status well that recolours across all five scan states with the live count as a hero number. Actions become full-width and foot-aligned, with a soft corner glow, inset ring, and hover lift for depth.

Logic, copy, i18n keys, data-testids and the genuinely-disabled manual guard (§7) are unchanged; all 13 chooser page-spec tests still pass, eslint/prettier/svelte-check clean.
Replace the "Load more" buttons on the manual people browser and the
selected-person review grid with scroll-driven pagination, so both lists
grow as the admin scrolls instead of dead-ending on a button.

Adds a reusable InfiniteScrollSentinel primitive (a sentinel-only port of
people-grid's IntersectionObserver + visibility-fallback logic; people-grid
itself isn't a drop-in where items aren't keyed by `id`). The review grid
keeps appending through vm.appendFaces, so staged marks and selection still
survive each page load.
…ection UI

Compose ConfidentLane (approve-all + spot-check exclude) and ReviewFirstLane
(clickable rows) in the scan page, driven by the exclusion-set triage model.
Removes the 3-step checklist, 5 stat cards, filter tabs, and the selection bar;
the two non-actionable totals become quiet footnotes. Deletes ScanChecklist,
FaceCleanupTable, and the old face-cleanup selection model, and prunes their
now-dead i18n keys.
Deeds67 added 11 commits July 24, 2026 23:08
…moved stat card

The two-lane scan redesign removed the "Eligible faces" stat card the X1 test
used as its "completed snapshot loaded" signal; use the header summary line
("… flagged faces across … people"), which renders on any completed scan.
…rflow

Collapse the wrapping multi-pill reason column to a single primary reason
(bad-target wins, else the first) plus a "+N", narrow the flagged column, and
share one absolute right-slot between the chevron (default) and the hover
dismiss so the row can no longer widen past the container.
The routing buttons were text-xs with 13px icons on the dark dock and read as
small/hidden. Enlarge them (text-sm, 16px icons, roomier padding, a defining
inset ring, rounded-lg), tint the destructive "not a face" red to set it apart
from the routine routes, and bump the selected-count and Clear.
@Deeds67 Deeds67 added the rc Auto-build a release-candidate server image and post it on the PR label Jul 25, 2026
@Deeds67
Deeds67 merged commit 1009885 into feat/face-review-unified Jul 26, 2026
51 checks passed
Deeds67 added a commit that referenced this pull request Jul 26, 2026
…an (#838)

* docs: design spec for manual face review mode

Adds a second entry path to the admin face cleanup console: pick any
person and audit all of their faces, with no scan required. Reuses the
existing tile grid, bulk-select-then-apply interaction, and verdict
writes, so a manual decision is indistinguishable from a guided one.

Server work is narrow: relax guard E15 for lock/unknown/detach (keeping
stay scan-only, since it needs a suspected owner), add the eligibility
check that snapshot membership was implicitly providing, and add an
admin person-metadata endpoint for the name/ownerId the review page
currently reads off the scan.

* docs: revise manual face review spec after verification pass

Three independent verification passes against the code disproved the
first draft's central premise and five other claims.

The big one: manual mode cannot reuse the guided review page behind a
mode flag. FlaggedFace.suspectedOwnerId is required, every face defaults
to the 'owner' state, and buildResolveRequest dereferences it - so an
untouched manual review would POST moveToPerson with an undefined
destination for every face. There is also no neutral state (six terminal
states with a tested tally invariant), and the model is $derived over
the face array, so paging would wipe staged decisions.

The design now forks the review UI: a separate manual page under
/people/[personId] with its own view-model (keep default, stable across
appends, never emits stay), leaving all nine guided web specs untouched.

Also corrected: lock is a PRIMARY KEY upsert with DO UPDATE that can
steal a face from another identity (not an idempotent no-op via a plain
unique index - that claim came from a stale comment referencing the
long-gone insertLocks); FaceRepairRepository has no @GenerateSql so no
mise sql regen; the navbar needs no repointing; resolveFaces has no unit
coverage, only medium; and relaxing unknown converts one 400 into a
success with unknown:0.

* docs: chooser page design — equal-weight cards, first-visit and returning states

Pierre's review of the chooser design: a first-time admin has never
scanned, so the page needs a distinct first-visit presentation, and the
two modes should carry equal weight since some admins will live in
manual review rather than triaging scans.

Equal-weight cards with no recommended badge. First visit gets an
explanatory header and advertises that manual review needs no scan —
it is fully usable on a brand-new instance, which the returning
status-board layout would otherwise obscure. Adds the full card state
matrix (never-scanned / running / flagged / zero-flagged / failed),
including the manual card being disabled during a scan, which is the UI
half of the resolveFaces 409 guard and prevents staging dozens of
decisions only to lose them to a conflict.

Slices renumbered 1-26 to cover the two chooser presentations and the
remaining card states separately.

* docs: manual review page design; consolidate 26 slices into 12

Folds the manual review page design into 6.4. The key point is a visual
inversion that falls out of the data model: guided stamps every tile
because every face holds one of six terminal states, while manual
defaults to keep, so tiles start clean and colour appears only where the
admin acted. keep needs no colour token - absence signals it, which
satisfies the existing rule that state is never encoded in colour alone.

Two interaction problems get resolved. Selection cannot claim the whole
cluster over server paging, so it always means loaded faces (Select all
loaded (N), showing N of M) and whole-cluster work goes through the
server-enumerated entireCluster with its own destination picker. And a
keep default needs an undo, so Unmark returns a selection to keep -
guided never needed one because every face is already stamped.

Slices consolidated 26 -> 12 on one test: can it ship alone without
leaving the tree broken or dead? The route move now carries its own e2e
repair (the move alone leaves CI red), the endpoint carries its SDK
regen (no caller otherwise), the eligibility read joins the lock slice
it exists for, and the chooser's three slices collapse into one
component with one state machine. The pure view-model stays separate
despite being small - highest logic risk on the branch.

* feat(server): allow locking any eligible face on a person, not only flagged ones

Manual review needs to lock a face that no scan flagged, so lock is
dropped from the E15 flagged-snapshot gate. stay keeps that gate: it
writes a negative verdict against the face's SUSPECTED owner, read from
the snapshot via a non-null assertion, so without a snapshot row there
is no owner to record against.

Lifting the gate for lock is not merely permissive - it removes a live
guard. getScanFlaggedFacesForPersons INNER JOINs asset_face and
re-validates personId/deletedAt/isVisible at snapshot-read time, so
today a foreign or deleted id can never reach the write path. Dropping
the gate without a replacement was verified to be exploitable: with lock
removed and no eligibility check, resolveFaces returned locked:1 for a
face on another person, for another USER's person, and for a
soft-deleted face - silently re-pointing them onto this person's
identity through replaceFaceIdentities' unscoped ON CONFLICT DO UPDATE.
A nonexistent id crashed with an unhandled FK violation rather than a
400.

getEligibleFaceIdsForPerson replaces that guard explicitly, mirroring
getClusterFacePage's predicate so lockable == listed on the manual
review page. It is advisory - the write-time guards remain
authoritative - and exists so an inapplicable lock is a 400 rather than
a silent no-op or a crash.

Tests: 66/66 in the resolve medium spec, incl. the cross-tenant arm.

* feat(server): allow detach and unknown on any face of a person, not only flagged ones

Completes the E15 relaxation: stay is now the ONLY snapshot-gated
bucket. It keeps that gate because it writes a negative verdict against
the face's SUSPECTED owner, read from the snapshot via a non-null
assertion - with no snapshot row that yields undefined and a 500/FK
violation.

Unlike lock, detach and unknown need no eligibility read: both are
already person-scoped at the write layer (detachFaces filters personId
and keys its identity-strip on the RETURNING output; reattributeFaces
filters still-on-source). That claim is now pinned by test rather than
trusted - a foreign face passed to detach is an inert no-op, left
completely untouched.

Behaviour change (spec 5.4): a stale unknown id - a face that left this
person since the scan - previously 400'd via the gate. It now returns
success with unknown: 0, truthfully reporting zero parks, and the
freshly created cluster is cleaned up rather than orphaned.

Tests: 72/72 in the resolve medium spec, 6/6 cross-flow.

* feat(server): add admin person metadata endpoint for manual face review

GET admin/face-repair/person/:personId returns
{id,name,ownerId,faceCount,thumbnailFaceId}, admin-gated, 404 on
unknown.

The manual review page cannot get these from anywhere else. The guided
page reads personName and ownerId off getLatestScan(), and ownerId is
what scopes the move-picker - so with no scan the picker cannot open at
all. The user-scoped GET /people/:id does not admin-bypass for a person
the admin does not own, the same gap already fixed once for face
thumbnails.

faceCount reuses searchOwnerPeople's exact join conditions so the count
agrees between the browser grid and the review page header; a mismatch
there would read as a bug. The name is returned raw - an unnamed person
yields an empty name and the client applies its own fallback rather than
the server inventing a display string.

Ships with the client regen because the endpoint has no caller until
then. No @GenerateSql was added (this repository carries none) so no
mise sql regeneration; mise.lock verified unchanged.

Tests: controller 76/76, metadata medium 6/6, tsc clean.

* refactor(web): move the guided face-cleanup dashboard to /admin/face-cleanup/scan

Frees /admin/face-cleanup for the mode chooser. Pure relocation of the
ten-file dashboard cluster via git mv - no behaviour change - plus a
temporary 307 redirect at the old path so this lands without a dead
entry point. The chooser replaces that redirect next.

The navbar is deliberately NOT repointed: NavbarItem highlights via
pathname.startsWith(href), so pointing at /admin/face-cleanup keeps it
active across /scan, /people and the chooser. Repointing it would be
the regression, not the fix.

Four e2e assertions broke on the move. Three are repoints; Playwright
globs match the whole URL, so '**/admin/face-cleanup' does not match
/scan. The fourth needed a real fix rather than a repoint: the
post-apply drain check asserts a person's name is ABSENT, which passes
vacuously on any page that never lists people - or on one that simply
has not finished loading. It now waits for the Eligible faces stat,
which renders unconditionally once a scan is completed and is therefore
independent of the drained-vs-not condition under test.

Tests: 117/117 across the nine guided specs from their new locations,
tsc clean.

* feat(web): add the face-cleanup mode chooser

Replaces slice 4's temporary redirect. Two EQUAL-WEIGHT cards with no
recommended badge on either: we do not know which mode a given admin
lives in, and privileging guided would penalise anyone who spends their
time in manual review.

Two distinct presentations rather than one degraded into the other.
First visit reframes the difference as readiness, not importance -
guided says it needs a scan first, manual advertises that it needs no
scan and can start right away. That line matters: manual review is fully
functional on a brand-new instance, and the returning status-board
layout would otherwise hide that behind an empty guided card, leaving a
first-time admin thinking the feature does nothing until a scan
finishes.

The manual card is disabled while a scan runs. This is the UI half of
resolveFaces' 409 guard: without it an admin can stage dozens of
decisions across a server-paged cluster and lose them all on Apply.
Disabled means genuinely unactivatable - no href and a real
<button disabled>, so keyboard cannot reach it either - not just
opacity. Only pending/running block it; failed and zero-flagged leave
manual reachable.

Visual vocabulary is reused verbatim from the dashboard - card shell,
dot+label+tabular-nums stat rhythm, semantic colours - so this reads as
the same product rather than a bolted-on page.

Tests: 13 new chooser cases, 130/130 across the face-cleanup suite,
tsc clean.

* feat(web): add the manual face-cleanup people browser

Builds the destination the chooser's manual card links to. Zero new
server endpoints: the owner list reuses searchUsersAdmin (the same call
the guided dashboard already makes) and the people grid reuses
getFaceRepairOwnerPeople, whose query parameter is optional - so
omitting it lists all of an owner's people, paginated.

Face crops go through the admin-gated, join-free face-thumbnail route,
NOT the user-scoped /people/:id/thumbnail, which 404s for people the
admin does not own. A null thumbnailFaceId renders a real placeholder
rather than a broken image.

Switching owner resets the page and clears the list, with a request
token guarding against a stale response clobbering a newer selection -
otherwise rows from two owners interleave. Pagination appends rather
than replaces.

Four states are kept distinct: this owner has no people, no results for
this query, load failed, and no users at all. Conflating empty with
error was defect D17 on the guided page.

No client-side filtering is applied to what the endpoint returns; that
passthrough is pinned by test so a later server-side change surfaces
here rather than being silently masked.

Tests: 14 new cases, 144/144 across the face-cleanup suite, tsc clean.

* feat(web): add the manual face-review view-model

A separate pure model rather than a reuse of the guided one, because
the guided model cannot express manual review's semantics and two of
its properties are actively dangerous here.

No neutral state: guided's FaceState is six TERMINAL states and every
face initialises to 'owner', with buildResolveRequest dereferencing
suspectedOwnerId for it. Pointed at a scan-free cluster an untouched
review would POST a move for every face to an undefined destination.
Manual defaults to keep, which produces no bucket entry at all, and an
all-keep review builds to null rather than an empty request - so a
disabled Apply cannot be bypassed and the server never sees the empty
resolve it would 400.

State survives paging: guided rebuilds its model from a $derived over
the face array, so appending a page discards staged decisions. This
model owns its list and exposes appendFaces, which preserves both
states and selection and is idempotent on assetFaceId. Manual is
server-paged, so that defect would have been hit on the first Load
more.

stay is hardcoded [] on every request - manual can never emit it, since
it needs a suspected owner. Two @ts-expect-error guards make owner/stay
a compile error rather than a convention; they are load-bearing, since
tsc reports an unused directive if the type ever stops rejecting them.

Colour/icon tokens are imported from the guided model so one glyph means
one thing across both pages; keep deliberately has no token - it is
signalled by absence.

Tests: 26 cases, 170/170 across the face-cleanup suite, tsc clean.

* feat(web): add the manual face-review page with server-paged cluster faces

Opens any person with no scan in existence and lists ALL of their
faces, via the scan-free cluster endpoint with an empty excludeFaceIds.

Implements the visual inversion this page is built around: guided
stamps every tile because every face holds one of six terminal states,
so its grid is a wall of colour to audit. Manual defaults to keep, so a
tile renders a clean crop with no badge and no ribbon, and colour
appears only where the admin acted. keep needs no colour token - it is
signalled by absence, which keeps the existing rule that state is never
encoded in colour alone without inventing a seventh swatch.

Selection is honest about server paging: it always means LOADED faces,
labelled Select all loaded (N) with showing N of M in the header. An
unqualified select-all would either lie about a cluster of thousands or
force loading all of it; whole-cluster work goes through the
server-enumerated entireCluster instead (slice 10).

Load more appends through the model rather than re-creating it, so
staged marks and selection survive - the defect that made reusing the
guided model impossible, and the first thing a paged cluster would have
hit.

Person name and ownerId resolve from the URL param through the metadata
endpoint rather than navigation state, so refresh and deep links work.
Empty and load-error are kept distinct (conflating them was D17 on the
guided page), and a person the scan flagged shows no flagged badging -
manual ignores scan state entirely.

Tests: 15 new cases, 185/185 across the face-cleanup suite, tsc clean.

* feat(web): add manual review bulk actions and apply

The footer dock: Move to person (via the reused PersonPicker, scoped by
the ownerId the metadata endpoint supplies), Lock, Unknown, Not a face,
and Unmark - plus the staged-work tally and Apply.

Unmark exists because manual defaults to keep. Guided never needed one:
every face there is already stamped, so the admin only ever swaps one
stamp for another. Here marking is a deliberate act, so mis-marking
needs reversing without discarding every other staged decision via
Reset.

stay and owner are not offered and can never be emitted - both need a
suspected owner. The payload assertion pins stay: [] explicitly across
mixed buckets.

Apply is disabled while everything is keep, because buildResolveRequest
returns null and an all-keep POST would be the empty resolve the server
400s. Not a face keeps guided's destructive confirm, and declining it
posts nothing.

A 409 - a scan starting mid-review - surfaces without discarding staged
work. That conflict is what the chooser's disabled-manual card exists to
prevent; if it happens anyway the page must not compound it by throwing
away decisions staged across a paged cluster.

Tests drive the real UI rather than the model. 16 new cases, 201/201
across the face-cleanup suite, guided's 73 specs untouched, tsc clean.

* feat(web): add entire-cluster move and the manual actions help modal

Move entire cluster uses the server-enumerated entireCluster rather than
selection, which is the whole reason it belongs here: on a server-paged
page selection can only ever cover LOADED faces, so whole-cluster work
must not route through it. Unlike guided - where entireCluster rides the
scan's suspected owner - manual has none, so it requires an explicit
destination via PersonPicker, and it posts with no per-face buckets
since the server rejects combining them. It carries its own confirm
because it moves faces the admin has never seen, which is both the point
and the risk.

Manual gets its own help modal rather than extending guided's, whose
names all six actions test is load-bearing and stays untouched. The
modal leads with the thing most likely to confuse someone arriving from
guided: keep writes nothing. It also states that Not a face is the
irreversible one and sits beside Unknown, which means the opposite - bin
this crop versus this is a real person I cannot name.

Swatches are asserted against the tile tokens so an explanation ties
back to the button and the tile it describes, and keep renders no
swatch at all - absence is how the default is signalled.

Tests: 21 new cases, 222/222 across the face-cleanup suite, guided's
specs untouched, tsc clean.

* test(e2e): cover the manual face-review flow and its cross-engine invariant

Two tests. The first drives the whole manual path against a person with
NO scan in existence: chooser to people browser to review page, listing
all faces rather than a flagged subset, then move, lock and not-a-face
through the real UI, asserting durable DB rows rather than UI text.

The second is the point of the feature: a later scan must honour a
manual decision exactly as it honours a guided one - the locked face is
not re-flagged, the detached face stays gone, the moved face is not
re-proposed.

Two seeding traps handled. utils.createFace links faces with
source='manual', which the verdict layer excludes from flagging, so
every face here is downgraded to 'ml' or it would not behave like a
machine-clustered one; and face_search rows are required because both
the cluster listing and lock eligibility inner-join it.

The cross-engine test seeds ONE multi-person scan rather than two
sequential ones: two scans stamped the same now() make getLatestScan
nondeterministic, which would let the invariant assertions pass
vacuously instead of for the right reason.

* docs: document guided and manual face cleanup modes

Adds a Two ways to clean up comparison and a Manual review section to
the admin face cleanup page.

The two points worth documenting explicitly are the ones an admin
cannot infer: that faces you leave alone are deliberately NOT recorded
(marking everything you glanced at as human-verified would stop future
scans flagging it and hide real mistakes, so Lock is the opt-in), and
that Select all covers the faces currently loaded rather than the whole
cluster - with Move entire cluster as the server-resolved alternative.

Also notes that manual review is unavailable during a scan, so the
behaviour reads as deliberate rather than broken.

* fix(web): keep translation-key literal types in the manual review tally

A Record<..., string> annotation widened each value to string, and $t
only accepts known translation keys, so the tally lookup stopped
type-checking. satisfies keeps the exhaustiveness check on the keys
while preserving the literal value types.

Caught only by CI's check-svelte: that task reports 0 FILES locally in
this worktree - even when invoked through mise exactly as CI does - so
neither tsc nor the local svelte-check surfaced it.

* fix(web): build the manual tally translation key with a template literal

satisfies was not enough: indexing an object of whole translation keys
still yields string at the call site, and $t accepts a template-literal
type but not string. Matches the guided dock's idiom exactly -
$t(`admin.face_cleanup_review_tally_${...}`) - by mapping only the key
SUFFIX, with move reusing guided's existing 'other' key rather than
adding a near-duplicate.

Only CI's check-svelte catches this; that task scans 0 files locally
even when invoked through mise as CI does.

* style(web): redesign the face-cleanup mode chooser

Rework the two-door chooser at /admin/face-cleanup into a considered design: a 48px icon tile anchors each mode, guided keeps the primary accent while manual gets a distinct teal identity (neither marked recommended), and each card carries a tinted status well that recolours across all five scan states with the live count as a hero number. Actions become full-width and foot-aligned, with a soft corner glow, inset ring, and hover lift for depth.

Logic, copy, i18n keys, data-testids and the genuinely-disabled manual guard (§7) are unchanged; all 13 chooser page-spec tests still pass, eslint/prettier/svelte-check clean.

* feat(web): infinite-scroll the manual face-review lists

Replace the "Load more" buttons on the manual people browser and the
selected-person review grid with scroll-driven pagination, so both lists
grow as the admin scrolls instead of dead-ending on a button.

Adds a reusable InfiniteScrollSentinel primitive (a sentinel-only port of
people-grid's IntersectionObserver + visibility-fallback logic; people-grid
itself isn't a drop-in where items aren't keyed by `id`). The review grid
keeps appending through vm.appendFaces, so staged marks and selection still
survive each page load.

* docs: spec the face-cleanup scan two-lane triage redesign

* docs: sliced impl-loop plan for the scan two-lane redesign

* feat(web): add exclusion-set selection model for scan triage

* feat(web): add ConfidentLane with approve-all + spot-check exclude

* feat(web): add ReviewFirstLane clickable review list

* feat(web): two-lane triage scan page; drop checklist/table/filter/selection UI

Compose ConfidentLane (approve-all + spot-check exclude) and ReviewFirstLane
(clickable rows) in the scan page, driven by the exclusion-set triage model.
Removes the 3-step checklist, 5 stat cards, filter tabs, and the selection bar;
the two non-actionable totals become quiet footnotes. Deletes ScanChecklist,
FaceCleanupTable, and the old face-cleanup selection model, and prunes their
now-dead i18n keys.

* docs: note the scan-redesign plan corrections applied during impl-loop

* test(e2e): key the scan-drain wait off the header summary, not the removed stat card

The two-lane scan redesign removed the "Eligible faces" stat card the X1 test
used as its "completed snapshot loaded" signal; use the header summary line
("… flagged faces across … people"), which renders on any completed scan.

* fix(web): tidy the review-lane rows — one primary reason pill, no overflow

Collapse the wrapping multi-pill reason column to a single primary reason
(bad-target wins, else the first) plus a "+N", narrow the flagged column, and
share one absolute right-slot between the chevron (default) and the hover
dismiss so the row can no longer widen past the container.

* feat(web): enlarge the guided-review bulk action bar

The routing buttons were text-xs with 13px icons on the dark dock and read as
small/hidden. Enlarge them (text-sm, 16px icons, roomier padding, a defining
inset ring, rounded-lg), tint the destructive "not a face" red to set it apart
from the routine routes, and bump the selected-count and Clear.

* docs: spec for face-cleanup console UX fixes (clickable auto-fix chips, review-lane alignment)

* docs: implementation plan for face-cleanup console UX fixes

* docs: add column-content regression guard to the face-cleanup UX plan

* fix(web): align the review-lane columns and give them headings

* style(web): prettier fallout on the review-lane changes

* feat(web): make auto-fix spot-check chips open the per-cluster review page

* fix(web): persist confident-lane spot-check exclusions across chip click-through
Deeds67 added a commit that referenced this pull request Jul 30, 2026
…an (#838)

* docs: design spec for manual face review mode

Adds a second entry path to the admin face cleanup console: pick any
person and audit all of their faces, with no scan required. Reuses the
existing tile grid, bulk-select-then-apply interaction, and verdict
writes, so a manual decision is indistinguishable from a guided one.

Server work is narrow: relax guard E15 for lock/unknown/detach (keeping
stay scan-only, since it needs a suspected owner), add the eligibility
check that snapshot membership was implicitly providing, and add an
admin person-metadata endpoint for the name/ownerId the review page
currently reads off the scan.

* docs: revise manual face review spec after verification pass

Three independent verification passes against the code disproved the
first draft's central premise and five other claims.

The big one: manual mode cannot reuse the guided review page behind a
mode flag. FlaggedFace.suspectedOwnerId is required, every face defaults
to the 'owner' state, and buildResolveRequest dereferences it - so an
untouched manual review would POST moveToPerson with an undefined
destination for every face. There is also no neutral state (six terminal
states with a tested tally invariant), and the model is $derived over
the face array, so paging would wipe staged decisions.

The design now forks the review UI: a separate manual page under
/people/[personId] with its own view-model (keep default, stable across
appends, never emits stay), leaving all nine guided web specs untouched.

Also corrected: lock is a PRIMARY KEY upsert with DO UPDATE that can
steal a face from another identity (not an idempotent no-op via a plain
unique index - that claim came from a stale comment referencing the
long-gone insertLocks); FaceRepairRepository has no @GenerateSql so no
mise sql regen; the navbar needs no repointing; resolveFaces has no unit
coverage, only medium; and relaxing unknown converts one 400 into a
success with unknown:0.

* docs: chooser page design — equal-weight cards, first-visit and returning states

Pierre's review of the chooser design: a first-time admin has never
scanned, so the page needs a distinct first-visit presentation, and the
two modes should carry equal weight since some admins will live in
manual review rather than triaging scans.

Equal-weight cards with no recommended badge. First visit gets an
explanatory header and advertises that manual review needs no scan —
it is fully usable on a brand-new instance, which the returning
status-board layout would otherwise obscure. Adds the full card state
matrix (never-scanned / running / flagged / zero-flagged / failed),
including the manual card being disabled during a scan, which is the UI
half of the resolveFaces 409 guard and prevents staging dozens of
decisions only to lose them to a conflict.

Slices renumbered 1-26 to cover the two chooser presentations and the
remaining card states separately.

* docs: manual review page design; consolidate 26 slices into 12

Folds the manual review page design into 6.4. The key point is a visual
inversion that falls out of the data model: guided stamps every tile
because every face holds one of six terminal states, while manual
defaults to keep, so tiles start clean and colour appears only where the
admin acted. keep needs no colour token - absence signals it, which
satisfies the existing rule that state is never encoded in colour alone.

Two interaction problems get resolved. Selection cannot claim the whole
cluster over server paging, so it always means loaded faces (Select all
loaded (N), showing N of M) and whole-cluster work goes through the
server-enumerated entireCluster with its own destination picker. And a
keep default needs an undo, so Unmark returns a selection to keep -
guided never needed one because every face is already stamped.

Slices consolidated 26 -> 12 on one test: can it ship alone without
leaving the tree broken or dead? The route move now carries its own e2e
repair (the move alone leaves CI red), the endpoint carries its SDK
regen (no caller otherwise), the eligibility read joins the lock slice
it exists for, and the chooser's three slices collapse into one
component with one state machine. The pure view-model stays separate
despite being small - highest logic risk on the branch.

* feat(server): allow locking any eligible face on a person, not only flagged ones

Manual review needs to lock a face that no scan flagged, so lock is
dropped from the E15 flagged-snapshot gate. stay keeps that gate: it
writes a negative verdict against the face's SUSPECTED owner, read from
the snapshot via a non-null assertion, so without a snapshot row there
is no owner to record against.

Lifting the gate for lock is not merely permissive - it removes a live
guard. getScanFlaggedFacesForPersons INNER JOINs asset_face and
re-validates personId/deletedAt/isVisible at snapshot-read time, so
today a foreign or deleted id can never reach the write path. Dropping
the gate without a replacement was verified to be exploitable: with lock
removed and no eligibility check, resolveFaces returned locked:1 for a
face on another person, for another USER's person, and for a
soft-deleted face - silently re-pointing them onto this person's
identity through replaceFaceIdentities' unscoped ON CONFLICT DO UPDATE.
A nonexistent id crashed with an unhandled FK violation rather than a
400.

getEligibleFaceIdsForPerson replaces that guard explicitly, mirroring
getClusterFacePage's predicate so lockable == listed on the manual
review page. It is advisory - the write-time guards remain
authoritative - and exists so an inapplicable lock is a 400 rather than
a silent no-op or a crash.

Tests: 66/66 in the resolve medium spec, incl. the cross-tenant arm.

* feat(server): allow detach and unknown on any face of a person, not only flagged ones

Completes the E15 relaxation: stay is now the ONLY snapshot-gated
bucket. It keeps that gate because it writes a negative verdict against
the face's SUSPECTED owner, read from the snapshot via a non-null
assertion - with no snapshot row that yields undefined and a 500/FK
violation.

Unlike lock, detach and unknown need no eligibility read: both are
already person-scoped at the write layer (detachFaces filters personId
and keys its identity-strip on the RETURNING output; reattributeFaces
filters still-on-source). That claim is now pinned by test rather than
trusted - a foreign face passed to detach is an inert no-op, left
completely untouched.

Behaviour change (spec 5.4): a stale unknown id - a face that left this
person since the scan - previously 400'd via the gate. It now returns
success with unknown: 0, truthfully reporting zero parks, and the
freshly created cluster is cleaned up rather than orphaned.

Tests: 72/72 in the resolve medium spec, 6/6 cross-flow.

* feat(server): add admin person metadata endpoint for manual face review

GET admin/face-repair/person/:personId returns
{id,name,ownerId,faceCount,thumbnailFaceId}, admin-gated, 404 on
unknown.

The manual review page cannot get these from anywhere else. The guided
page reads personName and ownerId off getLatestScan(), and ownerId is
what scopes the move-picker - so with no scan the picker cannot open at
all. The user-scoped GET /people/:id does not admin-bypass for a person
the admin does not own, the same gap already fixed once for face
thumbnails.

faceCount reuses searchOwnerPeople's exact join conditions so the count
agrees between the browser grid and the review page header; a mismatch
there would read as a bug. The name is returned raw - an unnamed person
yields an empty name and the client applies its own fallback rather than
the server inventing a display string.

Ships with the client regen because the endpoint has no caller until
then. No @GenerateSql was added (this repository carries none) so no
mise sql regeneration; mise.lock verified unchanged.

Tests: controller 76/76, metadata medium 6/6, tsc clean.

* refactor(web): move the guided face-cleanup dashboard to /admin/face-cleanup/scan

Frees /admin/face-cleanup for the mode chooser. Pure relocation of the
ten-file dashboard cluster via git mv - no behaviour change - plus a
temporary 307 redirect at the old path so this lands without a dead
entry point. The chooser replaces that redirect next.

The navbar is deliberately NOT repointed: NavbarItem highlights via
pathname.startsWith(href), so pointing at /admin/face-cleanup keeps it
active across /scan, /people and the chooser. Repointing it would be
the regression, not the fix.

Four e2e assertions broke on the move. Three are repoints; Playwright
globs match the whole URL, so '**/admin/face-cleanup' does not match
/scan. The fourth needed a real fix rather than a repoint: the
post-apply drain check asserts a person's name is ABSENT, which passes
vacuously on any page that never lists people - or on one that simply
has not finished loading. It now waits for the Eligible faces stat,
which renders unconditionally once a scan is completed and is therefore
independent of the drained-vs-not condition under test.

Tests: 117/117 across the nine guided specs from their new locations,
tsc clean.

* feat(web): add the face-cleanup mode chooser

Replaces slice 4's temporary redirect. Two EQUAL-WEIGHT cards with no
recommended badge on either: we do not know which mode a given admin
lives in, and privileging guided would penalise anyone who spends their
time in manual review.

Two distinct presentations rather than one degraded into the other.
First visit reframes the difference as readiness, not importance -
guided says it needs a scan first, manual advertises that it needs no
scan and can start right away. That line matters: manual review is fully
functional on a brand-new instance, and the returning status-board
layout would otherwise hide that behind an empty guided card, leaving a
first-time admin thinking the feature does nothing until a scan
finishes.

The manual card is disabled while a scan runs. This is the UI half of
resolveFaces' 409 guard: without it an admin can stage dozens of
decisions across a server-paged cluster and lose them all on Apply.
Disabled means genuinely unactivatable - no href and a real
<button disabled>, so keyboard cannot reach it either - not just
opacity. Only pending/running block it; failed and zero-flagged leave
manual reachable.

Visual vocabulary is reused verbatim from the dashboard - card shell,
dot+label+tabular-nums stat rhythm, semantic colours - so this reads as
the same product rather than a bolted-on page.

Tests: 13 new chooser cases, 130/130 across the face-cleanup suite,
tsc clean.

* feat(web): add the manual face-cleanup people browser

Builds the destination the chooser's manual card links to. Zero new
server endpoints: the owner list reuses searchUsersAdmin (the same call
the guided dashboard already makes) and the people grid reuses
getFaceRepairOwnerPeople, whose query parameter is optional - so
omitting it lists all of an owner's people, paginated.

Face crops go through the admin-gated, join-free face-thumbnail route,
NOT the user-scoped /people/:id/thumbnail, which 404s for people the
admin does not own. A null thumbnailFaceId renders a real placeholder
rather than a broken image.

Switching owner resets the page and clears the list, with a request
token guarding against a stale response clobbering a newer selection -
otherwise rows from two owners interleave. Pagination appends rather
than replaces.

Four states are kept distinct: this owner has no people, no results for
this query, load failed, and no users at all. Conflating empty with
error was defect D17 on the guided page.

No client-side filtering is applied to what the endpoint returns; that
passthrough is pinned by test so a later server-side change surfaces
here rather than being silently masked.

Tests: 14 new cases, 144/144 across the face-cleanup suite, tsc clean.

* feat(web): add the manual face-review view-model

A separate pure model rather than a reuse of the guided one, because
the guided model cannot express manual review's semantics and two of
its properties are actively dangerous here.

No neutral state: guided's FaceState is six TERMINAL states and every
face initialises to 'owner', with buildResolveRequest dereferencing
suspectedOwnerId for it. Pointed at a scan-free cluster an untouched
review would POST a move for every face to an undefined destination.
Manual defaults to keep, which produces no bucket entry at all, and an
all-keep review builds to null rather than an empty request - so a
disabled Apply cannot be bypassed and the server never sees the empty
resolve it would 400.

State survives paging: guided rebuilds its model from a $derived over
the face array, so appending a page discards staged decisions. This
model owns its list and exposes appendFaces, which preserves both
states and selection and is idempotent on assetFaceId. Manual is
server-paged, so that defect would have been hit on the first Load
more.

stay is hardcoded [] on every request - manual can never emit it, since
it needs a suspected owner. Two @ts-expect-error guards make owner/stay
a compile error rather than a convention; they are load-bearing, since
tsc reports an unused directive if the type ever stops rejecting them.

Colour/icon tokens are imported from the guided model so one glyph means
one thing across both pages; keep deliberately has no token - it is
signalled by absence.

Tests: 26 cases, 170/170 across the face-cleanup suite, tsc clean.

* feat(web): add the manual face-review page with server-paged cluster faces

Opens any person with no scan in existence and lists ALL of their
faces, via the scan-free cluster endpoint with an empty excludeFaceIds.

Implements the visual inversion this page is built around: guided
stamps every tile because every face holds one of six terminal states,
so its grid is a wall of colour to audit. Manual defaults to keep, so a
tile renders a clean crop with no badge and no ribbon, and colour
appears only where the admin acted. keep needs no colour token - it is
signalled by absence, which keeps the existing rule that state is never
encoded in colour alone without inventing a seventh swatch.

Selection is honest about server paging: it always means LOADED faces,
labelled Select all loaded (N) with showing N of M in the header. An
unqualified select-all would either lie about a cluster of thousands or
force loading all of it; whole-cluster work goes through the
server-enumerated entireCluster instead (slice 10).

Load more appends through the model rather than re-creating it, so
staged marks and selection survive - the defect that made reusing the
guided model impossible, and the first thing a paged cluster would have
hit.

Person name and ownerId resolve from the URL param through the metadata
endpoint rather than navigation state, so refresh and deep links work.
Empty and load-error are kept distinct (conflating them was D17 on the
guided page), and a person the scan flagged shows no flagged badging -
manual ignores scan state entirely.

Tests: 15 new cases, 185/185 across the face-cleanup suite, tsc clean.

* feat(web): add manual review bulk actions and apply

The footer dock: Move to person (via the reused PersonPicker, scoped by
the ownerId the metadata endpoint supplies), Lock, Unknown, Not a face,
and Unmark - plus the staged-work tally and Apply.

Unmark exists because manual defaults to keep. Guided never needed one:
every face there is already stamped, so the admin only ever swaps one
stamp for another. Here marking is a deliberate act, so mis-marking
needs reversing without discarding every other staged decision via
Reset.

stay and owner are not offered and can never be emitted - both need a
suspected owner. The payload assertion pins stay: [] explicitly across
mixed buckets.

Apply is disabled while everything is keep, because buildResolveRequest
returns null and an all-keep POST would be the empty resolve the server
400s. Not a face keeps guided's destructive confirm, and declining it
posts nothing.

A 409 - a scan starting mid-review - surfaces without discarding staged
work. That conflict is what the chooser's disabled-manual card exists to
prevent; if it happens anyway the page must not compound it by throwing
away decisions staged across a paged cluster.

Tests drive the real UI rather than the model. 16 new cases, 201/201
across the face-cleanup suite, guided's 73 specs untouched, tsc clean.

* feat(web): add entire-cluster move and the manual actions help modal

Move entire cluster uses the server-enumerated entireCluster rather than
selection, which is the whole reason it belongs here: on a server-paged
page selection can only ever cover LOADED faces, so whole-cluster work
must not route through it. Unlike guided - where entireCluster rides the
scan's suspected owner - manual has none, so it requires an explicit
destination via PersonPicker, and it posts with no per-face buckets
since the server rejects combining them. It carries its own confirm
because it moves faces the admin has never seen, which is both the point
and the risk.

Manual gets its own help modal rather than extending guided's, whose
names all six actions test is load-bearing and stays untouched. The
modal leads with the thing most likely to confuse someone arriving from
guided: keep writes nothing. It also states that Not a face is the
irreversible one and sits beside Unknown, which means the opposite - bin
this crop versus this is a real person I cannot name.

Swatches are asserted against the tile tokens so an explanation ties
back to the button and the tile it describes, and keep renders no
swatch at all - absence is how the default is signalled.

Tests: 21 new cases, 222/222 across the face-cleanup suite, guided's
specs untouched, tsc clean.

* test(e2e): cover the manual face-review flow and its cross-engine invariant

Two tests. The first drives the whole manual path against a person with
NO scan in existence: chooser to people browser to review page, listing
all faces rather than a flagged subset, then move, lock and not-a-face
through the real UI, asserting durable DB rows rather than UI text.

The second is the point of the feature: a later scan must honour a
manual decision exactly as it honours a guided one - the locked face is
not re-flagged, the detached face stays gone, the moved face is not
re-proposed.

Two seeding traps handled. utils.createFace links faces with
source='manual', which the verdict layer excludes from flagging, so
every face here is downgraded to 'ml' or it would not behave like a
machine-clustered one; and face_search rows are required because both
the cluster listing and lock eligibility inner-join it.

The cross-engine test seeds ONE multi-person scan rather than two
sequential ones: two scans stamped the same now() make getLatestScan
nondeterministic, which would let the invariant assertions pass
vacuously instead of for the right reason.

* docs: document guided and manual face cleanup modes

Adds a Two ways to clean up comparison and a Manual review section to
the admin face cleanup page.

The two points worth documenting explicitly are the ones an admin
cannot infer: that faces you leave alone are deliberately NOT recorded
(marking everything you glanced at as human-verified would stop future
scans flagging it and hide real mistakes, so Lock is the opt-in), and
that Select all covers the faces currently loaded rather than the whole
cluster - with Move entire cluster as the server-resolved alternative.

Also notes that manual review is unavailable during a scan, so the
behaviour reads as deliberate rather than broken.

* fix(web): keep translation-key literal types in the manual review tally

A Record<..., string> annotation widened each value to string, and $t
only accepts known translation keys, so the tally lookup stopped
type-checking. satisfies keeps the exhaustiveness check on the keys
while preserving the literal value types.

Caught only by CI's check-svelte: that task reports 0 FILES locally in
this worktree - even when invoked through mise exactly as CI does - so
neither tsc nor the local svelte-check surfaced it.

* fix(web): build the manual tally translation key with a template literal

satisfies was not enough: indexing an object of whole translation keys
still yields string at the call site, and $t accepts a template-literal
type but not string. Matches the guided dock's idiom exactly -
$t(`admin.face_cleanup_review_tally_${...}`) - by mapping only the key
SUFFIX, with move reusing guided's existing 'other' key rather than
adding a near-duplicate.

Only CI's check-svelte catches this; that task scans 0 files locally
even when invoked through mise as CI does.

* style(web): redesign the face-cleanup mode chooser

Rework the two-door chooser at /admin/face-cleanup into a considered design: a 48px icon tile anchors each mode, guided keeps the primary accent while manual gets a distinct teal identity (neither marked recommended), and each card carries a tinted status well that recolours across all five scan states with the live count as a hero number. Actions become full-width and foot-aligned, with a soft corner glow, inset ring, and hover lift for depth.

Logic, copy, i18n keys, data-testids and the genuinely-disabled manual guard (§7) are unchanged; all 13 chooser page-spec tests still pass, eslint/prettier/svelte-check clean.

* feat(web): infinite-scroll the manual face-review lists

Replace the "Load more" buttons on the manual people browser and the
selected-person review grid with scroll-driven pagination, so both lists
grow as the admin scrolls instead of dead-ending on a button.

Adds a reusable InfiniteScrollSentinel primitive (a sentinel-only port of
people-grid's IntersectionObserver + visibility-fallback logic; people-grid
itself isn't a drop-in where items aren't keyed by `id`). The review grid
keeps appending through vm.appendFaces, so staged marks and selection still
survive each page load.

* docs: spec the face-cleanup scan two-lane triage redesign

* docs: sliced impl-loop plan for the scan two-lane redesign

* feat(web): add exclusion-set selection model for scan triage

* feat(web): add ConfidentLane with approve-all + spot-check exclude

* feat(web): add ReviewFirstLane clickable review list

* feat(web): two-lane triage scan page; drop checklist/table/filter/selection UI

Compose ConfidentLane (approve-all + spot-check exclude) and ReviewFirstLane
(clickable rows) in the scan page, driven by the exclusion-set triage model.
Removes the 3-step checklist, 5 stat cards, filter tabs, and the selection bar;
the two non-actionable totals become quiet footnotes. Deletes ScanChecklist,
FaceCleanupTable, and the old face-cleanup selection model, and prunes their
now-dead i18n keys.

* docs: note the scan-redesign plan corrections applied during impl-loop

* test(e2e): key the scan-drain wait off the header summary, not the removed stat card

The two-lane scan redesign removed the "Eligible faces" stat card the X1 test
used as its "completed snapshot loaded" signal; use the header summary line
("… flagged faces across … people"), which renders on any completed scan.

* fix(web): tidy the review-lane rows — one primary reason pill, no overflow

Collapse the wrapping multi-pill reason column to a single primary reason
(bad-target wins, else the first) plus a "+N", narrow the flagged column, and
share one absolute right-slot between the chevron (default) and the hover
dismiss so the row can no longer widen past the container.

* feat(web): enlarge the guided-review bulk action bar

The routing buttons were text-xs with 13px icons on the dark dock and read as
small/hidden. Enlarge them (text-sm, 16px icons, roomier padding, a defining
inset ring, rounded-lg), tint the destructive "not a face" red to set it apart
from the routine routes, and bump the selected-count and Clear.

* docs: spec for face-cleanup console UX fixes (clickable auto-fix chips, review-lane alignment)

* docs: implementation plan for face-cleanup console UX fixes

* docs: add column-content regression guard to the face-cleanup UX plan

* fix(web): align the review-lane columns and give them headings

* style(web): prettier fallout on the review-lane changes

* feat(web): make auto-fix spot-check chips open the per-cluster review page

* fix(web): persist confident-lane spot-check exclusions across chip click-through
Deeds67 added a commit that referenced this pull request Aug 2, 2026
…an (#838)

* docs: design spec for manual face review mode

Adds a second entry path to the admin face cleanup console: pick any
person and audit all of their faces, with no scan required. Reuses the
existing tile grid, bulk-select-then-apply interaction, and verdict
writes, so a manual decision is indistinguishable from a guided one.

Server work is narrow: relax guard E15 for lock/unknown/detach (keeping
stay scan-only, since it needs a suspected owner), add the eligibility
check that snapshot membership was implicitly providing, and add an
admin person-metadata endpoint for the name/ownerId the review page
currently reads off the scan.

* docs: revise manual face review spec after verification pass

Three independent verification passes against the code disproved the
first draft's central premise and five other claims.

The big one: manual mode cannot reuse the guided review page behind a
mode flag. FlaggedFace.suspectedOwnerId is required, every face defaults
to the 'owner' state, and buildResolveRequest dereferences it - so an
untouched manual review would POST moveToPerson with an undefined
destination for every face. There is also no neutral state (six terminal
states with a tested tally invariant), and the model is $derived over
the face array, so paging would wipe staged decisions.

The design now forks the review UI: a separate manual page under
/people/[personId] with its own view-model (keep default, stable across
appends, never emits stay), leaving all nine guided web specs untouched.

Also corrected: lock is a PRIMARY KEY upsert with DO UPDATE that can
steal a face from another identity (not an idempotent no-op via a plain
unique index - that claim came from a stale comment referencing the
long-gone insertLocks); FaceRepairRepository has no @GenerateSql so no
mise sql regen; the navbar needs no repointing; resolveFaces has no unit
coverage, only medium; and relaxing unknown converts one 400 into a
success with unknown:0.

* docs: chooser page design — equal-weight cards, first-visit and returning states

Pierre's review of the chooser design: a first-time admin has never
scanned, so the page needs a distinct first-visit presentation, and the
two modes should carry equal weight since some admins will live in
manual review rather than triaging scans.

Equal-weight cards with no recommended badge. First visit gets an
explanatory header and advertises that manual review needs no scan —
it is fully usable on a brand-new instance, which the returning
status-board layout would otherwise obscure. Adds the full card state
matrix (never-scanned / running / flagged / zero-flagged / failed),
including the manual card being disabled during a scan, which is the UI
half of the resolveFaces 409 guard and prevents staging dozens of
decisions only to lose them to a conflict.

Slices renumbered 1-26 to cover the two chooser presentations and the
remaining card states separately.

* docs: manual review page design; consolidate 26 slices into 12

Folds the manual review page design into 6.4. The key point is a visual
inversion that falls out of the data model: guided stamps every tile
because every face holds one of six terminal states, while manual
defaults to keep, so tiles start clean and colour appears only where the
admin acted. keep needs no colour token - absence signals it, which
satisfies the existing rule that state is never encoded in colour alone.

Two interaction problems get resolved. Selection cannot claim the whole
cluster over server paging, so it always means loaded faces (Select all
loaded (N), showing N of M) and whole-cluster work goes through the
server-enumerated entireCluster with its own destination picker. And a
keep default needs an undo, so Unmark returns a selection to keep -
guided never needed one because every face is already stamped.

Slices consolidated 26 -> 12 on one test: can it ship alone without
leaving the tree broken or dead? The route move now carries its own e2e
repair (the move alone leaves CI red), the endpoint carries its SDK
regen (no caller otherwise), the eligibility read joins the lock slice
it exists for, and the chooser's three slices collapse into one
component with one state machine. The pure view-model stays separate
despite being small - highest logic risk on the branch.

* feat(server): allow locking any eligible face on a person, not only flagged ones

Manual review needs to lock a face that no scan flagged, so lock is
dropped from the E15 flagged-snapshot gate. stay keeps that gate: it
writes a negative verdict against the face's SUSPECTED owner, read from
the snapshot via a non-null assertion, so without a snapshot row there
is no owner to record against.

Lifting the gate for lock is not merely permissive - it removes a live
guard. getScanFlaggedFacesForPersons INNER JOINs asset_face and
re-validates personId/deletedAt/isVisible at snapshot-read time, so
today a foreign or deleted id can never reach the write path. Dropping
the gate without a replacement was verified to be exploitable: with lock
removed and no eligibility check, resolveFaces returned locked:1 for a
face on another person, for another USER's person, and for a
soft-deleted face - silently re-pointing them onto this person's
identity through replaceFaceIdentities' unscoped ON CONFLICT DO UPDATE.
A nonexistent id crashed with an unhandled FK violation rather than a
400.

getEligibleFaceIdsForPerson replaces that guard explicitly, mirroring
getClusterFacePage's predicate so lockable == listed on the manual
review page. It is advisory - the write-time guards remain
authoritative - and exists so an inapplicable lock is a 400 rather than
a silent no-op or a crash.

Tests: 66/66 in the resolve medium spec, incl. the cross-tenant arm.

* feat(server): allow detach and unknown on any face of a person, not only flagged ones

Completes the E15 relaxation: stay is now the ONLY snapshot-gated
bucket. It keeps that gate because it writes a negative verdict against
the face's SUSPECTED owner, read from the snapshot via a non-null
assertion - with no snapshot row that yields undefined and a 500/FK
violation.

Unlike lock, detach and unknown need no eligibility read: both are
already person-scoped at the write layer (detachFaces filters personId
and keys its identity-strip on the RETURNING output; reattributeFaces
filters still-on-source). That claim is now pinned by test rather than
trusted - a foreign face passed to detach is an inert no-op, left
completely untouched.

Behaviour change (spec 5.4): a stale unknown id - a face that left this
person since the scan - previously 400'd via the gate. It now returns
success with unknown: 0, truthfully reporting zero parks, and the
freshly created cluster is cleaned up rather than orphaned.

Tests: 72/72 in the resolve medium spec, 6/6 cross-flow.

* feat(server): add admin person metadata endpoint for manual face review

GET admin/face-repair/person/:personId returns
{id,name,ownerId,faceCount,thumbnailFaceId}, admin-gated, 404 on
unknown.

The manual review page cannot get these from anywhere else. The guided
page reads personName and ownerId off getLatestScan(), and ownerId is
what scopes the move-picker - so with no scan the picker cannot open at
all. The user-scoped GET /people/:id does not admin-bypass for a person
the admin does not own, the same gap already fixed once for face
thumbnails.

faceCount reuses searchOwnerPeople's exact join conditions so the count
agrees between the browser grid and the review page header; a mismatch
there would read as a bug. The name is returned raw - an unnamed person
yields an empty name and the client applies its own fallback rather than
the server inventing a display string.

Ships with the client regen because the endpoint has no caller until
then. No @GenerateSql was added (this repository carries none) so no
mise sql regeneration; mise.lock verified unchanged.

Tests: controller 76/76, metadata medium 6/6, tsc clean.

* refactor(web): move the guided face-cleanup dashboard to /admin/face-cleanup/scan

Frees /admin/face-cleanup for the mode chooser. Pure relocation of the
ten-file dashboard cluster via git mv - no behaviour change - plus a
temporary 307 redirect at the old path so this lands without a dead
entry point. The chooser replaces that redirect next.

The navbar is deliberately NOT repointed: NavbarItem highlights via
pathname.startsWith(href), so pointing at /admin/face-cleanup keeps it
active across /scan, /people and the chooser. Repointing it would be
the regression, not the fix.

Four e2e assertions broke on the move. Three are repoints; Playwright
globs match the whole URL, so '**/admin/face-cleanup' does not match
/scan. The fourth needed a real fix rather than a repoint: the
post-apply drain check asserts a person's name is ABSENT, which passes
vacuously on any page that never lists people - or on one that simply
has not finished loading. It now waits for the Eligible faces stat,
which renders unconditionally once a scan is completed and is therefore
independent of the drained-vs-not condition under test.

Tests: 117/117 across the nine guided specs from their new locations,
tsc clean.

* feat(web): add the face-cleanup mode chooser

Replaces slice 4's temporary redirect. Two EQUAL-WEIGHT cards with no
recommended badge on either: we do not know which mode a given admin
lives in, and privileging guided would penalise anyone who spends their
time in manual review.

Two distinct presentations rather than one degraded into the other.
First visit reframes the difference as readiness, not importance -
guided says it needs a scan first, manual advertises that it needs no
scan and can start right away. That line matters: manual review is fully
functional on a brand-new instance, and the returning status-board
layout would otherwise hide that behind an empty guided card, leaving a
first-time admin thinking the feature does nothing until a scan
finishes.

The manual card is disabled while a scan runs. This is the UI half of
resolveFaces' 409 guard: without it an admin can stage dozens of
decisions across a server-paged cluster and lose them all on Apply.
Disabled means genuinely unactivatable - no href and a real
<button disabled>, so keyboard cannot reach it either - not just
opacity. Only pending/running block it; failed and zero-flagged leave
manual reachable.

Visual vocabulary is reused verbatim from the dashboard - card shell,
dot+label+tabular-nums stat rhythm, semantic colours - so this reads as
the same product rather than a bolted-on page.

Tests: 13 new chooser cases, 130/130 across the face-cleanup suite,
tsc clean.

* feat(web): add the manual face-cleanup people browser

Builds the destination the chooser's manual card links to. Zero new
server endpoints: the owner list reuses searchUsersAdmin (the same call
the guided dashboard already makes) and the people grid reuses
getFaceRepairOwnerPeople, whose query parameter is optional - so
omitting it lists all of an owner's people, paginated.

Face crops go through the admin-gated, join-free face-thumbnail route,
NOT the user-scoped /people/:id/thumbnail, which 404s for people the
admin does not own. A null thumbnailFaceId renders a real placeholder
rather than a broken image.

Switching owner resets the page and clears the list, with a request
token guarding against a stale response clobbering a newer selection -
otherwise rows from two owners interleave. Pagination appends rather
than replaces.

Four states are kept distinct: this owner has no people, no results for
this query, load failed, and no users at all. Conflating empty with
error was defect D17 on the guided page.

No client-side filtering is applied to what the endpoint returns; that
passthrough is pinned by test so a later server-side change surfaces
here rather than being silently masked.

Tests: 14 new cases, 144/144 across the face-cleanup suite, tsc clean.

* feat(web): add the manual face-review view-model

A separate pure model rather than a reuse of the guided one, because
the guided model cannot express manual review's semantics and two of
its properties are actively dangerous here.

No neutral state: guided's FaceState is six TERMINAL states and every
face initialises to 'owner', with buildResolveRequest dereferencing
suspectedOwnerId for it. Pointed at a scan-free cluster an untouched
review would POST a move for every face to an undefined destination.
Manual defaults to keep, which produces no bucket entry at all, and an
all-keep review builds to null rather than an empty request - so a
disabled Apply cannot be bypassed and the server never sees the empty
resolve it would 400.

State survives paging: guided rebuilds its model from a $derived over
the face array, so appending a page discards staged decisions. This
model owns its list and exposes appendFaces, which preserves both
states and selection and is idempotent on assetFaceId. Manual is
server-paged, so that defect would have been hit on the first Load
more.

stay is hardcoded [] on every request - manual can never emit it, since
it needs a suspected owner. Two @ts-expect-error guards make owner/stay
a compile error rather than a convention; they are load-bearing, since
tsc reports an unused directive if the type ever stops rejecting them.

Colour/icon tokens are imported from the guided model so one glyph means
one thing across both pages; keep deliberately has no token - it is
signalled by absence.

Tests: 26 cases, 170/170 across the face-cleanup suite, tsc clean.

* feat(web): add the manual face-review page with server-paged cluster faces

Opens any person with no scan in existence and lists ALL of their
faces, via the scan-free cluster endpoint with an empty excludeFaceIds.

Implements the visual inversion this page is built around: guided
stamps every tile because every face holds one of six terminal states,
so its grid is a wall of colour to audit. Manual defaults to keep, so a
tile renders a clean crop with no badge and no ribbon, and colour
appears only where the admin acted. keep needs no colour token - it is
signalled by absence, which keeps the existing rule that state is never
encoded in colour alone without inventing a seventh swatch.

Selection is honest about server paging: it always means LOADED faces,
labelled Select all loaded (N) with showing N of M in the header. An
unqualified select-all would either lie about a cluster of thousands or
force loading all of it; whole-cluster work goes through the
server-enumerated entireCluster instead (slice 10).

Load more appends through the model rather than re-creating it, so
staged marks and selection survive - the defect that made reusing the
guided model impossible, and the first thing a paged cluster would have
hit.

Person name and ownerId resolve from the URL param through the metadata
endpoint rather than navigation state, so refresh and deep links work.
Empty and load-error are kept distinct (conflating them was D17 on the
guided page), and a person the scan flagged shows no flagged badging -
manual ignores scan state entirely.

Tests: 15 new cases, 185/185 across the face-cleanup suite, tsc clean.

* feat(web): add manual review bulk actions and apply

The footer dock: Move to person (via the reused PersonPicker, scoped by
the ownerId the metadata endpoint supplies), Lock, Unknown, Not a face,
and Unmark - plus the staged-work tally and Apply.

Unmark exists because manual defaults to keep. Guided never needed one:
every face there is already stamped, so the admin only ever swaps one
stamp for another. Here marking is a deliberate act, so mis-marking
needs reversing without discarding every other staged decision via
Reset.

stay and owner are not offered and can never be emitted - both need a
suspected owner. The payload assertion pins stay: [] explicitly across
mixed buckets.

Apply is disabled while everything is keep, because buildResolveRequest
returns null and an all-keep POST would be the empty resolve the server
400s. Not a face keeps guided's destructive confirm, and declining it
posts nothing.

A 409 - a scan starting mid-review - surfaces without discarding staged
work. That conflict is what the chooser's disabled-manual card exists to
prevent; if it happens anyway the page must not compound it by throwing
away decisions staged across a paged cluster.

Tests drive the real UI rather than the model. 16 new cases, 201/201
across the face-cleanup suite, guided's 73 specs untouched, tsc clean.

* feat(web): add entire-cluster move and the manual actions help modal

Move entire cluster uses the server-enumerated entireCluster rather than
selection, which is the whole reason it belongs here: on a server-paged
page selection can only ever cover LOADED faces, so whole-cluster work
must not route through it. Unlike guided - where entireCluster rides the
scan's suspected owner - manual has none, so it requires an explicit
destination via PersonPicker, and it posts with no per-face buckets
since the server rejects combining them. It carries its own confirm
because it moves faces the admin has never seen, which is both the point
and the risk.

Manual gets its own help modal rather than extending guided's, whose
names all six actions test is load-bearing and stays untouched. The
modal leads with the thing most likely to confuse someone arriving from
guided: keep writes nothing. It also states that Not a face is the
irreversible one and sits beside Unknown, which means the opposite - bin
this crop versus this is a real person I cannot name.

Swatches are asserted against the tile tokens so an explanation ties
back to the button and the tile it describes, and keep renders no
swatch at all - absence is how the default is signalled.

Tests: 21 new cases, 222/222 across the face-cleanup suite, guided's
specs untouched, tsc clean.

* test(e2e): cover the manual face-review flow and its cross-engine invariant

Two tests. The first drives the whole manual path against a person with
NO scan in existence: chooser to people browser to review page, listing
all faces rather than a flagged subset, then move, lock and not-a-face
through the real UI, asserting durable DB rows rather than UI text.

The second is the point of the feature: a later scan must honour a
manual decision exactly as it honours a guided one - the locked face is
not re-flagged, the detached face stays gone, the moved face is not
re-proposed.

Two seeding traps handled. utils.createFace links faces with
source='manual', which the verdict layer excludes from flagging, so
every face here is downgraded to 'ml' or it would not behave like a
machine-clustered one; and face_search rows are required because both
the cluster listing and lock eligibility inner-join it.

The cross-engine test seeds ONE multi-person scan rather than two
sequential ones: two scans stamped the same now() make getLatestScan
nondeterministic, which would let the invariant assertions pass
vacuously instead of for the right reason.

* docs: document guided and manual face cleanup modes

Adds a Two ways to clean up comparison and a Manual review section to
the admin face cleanup page.

The two points worth documenting explicitly are the ones an admin
cannot infer: that faces you leave alone are deliberately NOT recorded
(marking everything you glanced at as human-verified would stop future
scans flagging it and hide real mistakes, so Lock is the opt-in), and
that Select all covers the faces currently loaded rather than the whole
cluster - with Move entire cluster as the server-resolved alternative.

Also notes that manual review is unavailable during a scan, so the
behaviour reads as deliberate rather than broken.

* fix(web): keep translation-key literal types in the manual review tally

A Record<..., string> annotation widened each value to string, and $t
only accepts known translation keys, so the tally lookup stopped
type-checking. satisfies keeps the exhaustiveness check on the keys
while preserving the literal value types.

Caught only by CI's check-svelte: that task reports 0 FILES locally in
this worktree - even when invoked through mise exactly as CI does - so
neither tsc nor the local svelte-check surfaced it.

* fix(web): build the manual tally translation key with a template literal

satisfies was not enough: indexing an object of whole translation keys
still yields string at the call site, and $t accepts a template-literal
type but not string. Matches the guided dock's idiom exactly -
$t(`admin.face_cleanup_review_tally_${...}`) - by mapping only the key
SUFFIX, with move reusing guided's existing 'other' key rather than
adding a near-duplicate.

Only CI's check-svelte catches this; that task scans 0 files locally
even when invoked through mise as CI does.

* style(web): redesign the face-cleanup mode chooser

Rework the two-door chooser at /admin/face-cleanup into a considered design: a 48px icon tile anchors each mode, guided keeps the primary accent while manual gets a distinct teal identity (neither marked recommended), and each card carries a tinted status well that recolours across all five scan states with the live count as a hero number. Actions become full-width and foot-aligned, with a soft corner glow, inset ring, and hover lift for depth.

Logic, copy, i18n keys, data-testids and the genuinely-disabled manual guard (§7) are unchanged; all 13 chooser page-spec tests still pass, eslint/prettier/svelte-check clean.

* feat(web): infinite-scroll the manual face-review lists

Replace the "Load more" buttons on the manual people browser and the
selected-person review grid with scroll-driven pagination, so both lists
grow as the admin scrolls instead of dead-ending on a button.

Adds a reusable InfiniteScrollSentinel primitive (a sentinel-only port of
people-grid's IntersectionObserver + visibility-fallback logic; people-grid
itself isn't a drop-in where items aren't keyed by `id`). The review grid
keeps appending through vm.appendFaces, so staged marks and selection still
survive each page load.

* docs: spec the face-cleanup scan two-lane triage redesign

* docs: sliced impl-loop plan for the scan two-lane redesign

* feat(web): add exclusion-set selection model for scan triage

* feat(web): add ConfidentLane with approve-all + spot-check exclude

* feat(web): add ReviewFirstLane clickable review list

* feat(web): two-lane triage scan page; drop checklist/table/filter/selection UI

Compose ConfidentLane (approve-all + spot-check exclude) and ReviewFirstLane
(clickable rows) in the scan page, driven by the exclusion-set triage model.
Removes the 3-step checklist, 5 stat cards, filter tabs, and the selection bar;
the two non-actionable totals become quiet footnotes. Deletes ScanChecklist,
FaceCleanupTable, and the old face-cleanup selection model, and prunes their
now-dead i18n keys.

* docs: note the scan-redesign plan corrections applied during impl-loop

* test(e2e): key the scan-drain wait off the header summary, not the removed stat card

The two-lane scan redesign removed the "Eligible faces" stat card the X1 test
used as its "completed snapshot loaded" signal; use the header summary line
("… flagged faces across … people"), which renders on any completed scan.

* fix(web): tidy the review-lane rows — one primary reason pill, no overflow

Collapse the wrapping multi-pill reason column to a single primary reason
(bad-target wins, else the first) plus a "+N", narrow the flagged column, and
share one absolute right-slot between the chevron (default) and the hover
dismiss so the row can no longer widen past the container.

* feat(web): enlarge the guided-review bulk action bar

The routing buttons were text-xs with 13px icons on the dark dock and read as
small/hidden. Enlarge them (text-sm, 16px icons, roomier padding, a defining
inset ring, rounded-lg), tint the destructive "not a face" red to set it apart
from the routine routes, and bump the selected-count and Clear.

* docs: spec for face-cleanup console UX fixes (clickable auto-fix chips, review-lane alignment)

* docs: implementation plan for face-cleanup console UX fixes

* docs: add column-content regression guard to the face-cleanup UX plan

* fix(web): align the review-lane columns and give them headings

* style(web): prettier fallout on the review-lane changes

* feat(web): make auto-fix spot-check chips open the per-cluster review page

* fix(web): persist confident-lane spot-check exclusions across chip click-through
Deeds67 added a commit that referenced this pull request Aug 5, 2026
…an (#838)

* docs: design spec for manual face review mode

Adds a second entry path to the admin face cleanup console: pick any
person and audit all of their faces, with no scan required. Reuses the
existing tile grid, bulk-select-then-apply interaction, and verdict
writes, so a manual decision is indistinguishable from a guided one.

Server work is narrow: relax guard E15 for lock/unknown/detach (keeping
stay scan-only, since it needs a suspected owner), add the eligibility
check that snapshot membership was implicitly providing, and add an
admin person-metadata endpoint for the name/ownerId the review page
currently reads off the scan.

* docs: revise manual face review spec after verification pass

Three independent verification passes against the code disproved the
first draft's central premise and five other claims.

The big one: manual mode cannot reuse the guided review page behind a
mode flag. FlaggedFace.suspectedOwnerId is required, every face defaults
to the 'owner' state, and buildResolveRequest dereferences it - so an
untouched manual review would POST moveToPerson with an undefined
destination for every face. There is also no neutral state (six terminal
states with a tested tally invariant), and the model is $derived over
the face array, so paging would wipe staged decisions.

The design now forks the review UI: a separate manual page under
/people/[personId] with its own view-model (keep default, stable across
appends, never emits stay), leaving all nine guided web specs untouched.

Also corrected: lock is a PRIMARY KEY upsert with DO UPDATE that can
steal a face from another identity (not an idempotent no-op via a plain
unique index - that claim came from a stale comment referencing the
long-gone insertLocks); FaceRepairRepository has no @GenerateSql so no
mise sql regen; the navbar needs no repointing; resolveFaces has no unit
coverage, only medium; and relaxing unknown converts one 400 into a
success with unknown:0.

* docs: chooser page design — equal-weight cards, first-visit and returning states

Pierre's review of the chooser design: a first-time admin has never
scanned, so the page needs a distinct first-visit presentation, and the
two modes should carry equal weight since some admins will live in
manual review rather than triaging scans.

Equal-weight cards with no recommended badge. First visit gets an
explanatory header and advertises that manual review needs no scan —
it is fully usable on a brand-new instance, which the returning
status-board layout would otherwise obscure. Adds the full card state
matrix (never-scanned / running / flagged / zero-flagged / failed),
including the manual card being disabled during a scan, which is the UI
half of the resolveFaces 409 guard and prevents staging dozens of
decisions only to lose them to a conflict.

Slices renumbered 1-26 to cover the two chooser presentations and the
remaining card states separately.

* docs: manual review page design; consolidate 26 slices into 12

Folds the manual review page design into 6.4. The key point is a visual
inversion that falls out of the data model: guided stamps every tile
because every face holds one of six terminal states, while manual
defaults to keep, so tiles start clean and colour appears only where the
admin acted. keep needs no colour token - absence signals it, which
satisfies the existing rule that state is never encoded in colour alone.

Two interaction problems get resolved. Selection cannot claim the whole
cluster over server paging, so it always means loaded faces (Select all
loaded (N), showing N of M) and whole-cluster work goes through the
server-enumerated entireCluster with its own destination picker. And a
keep default needs an undo, so Unmark returns a selection to keep -
guided never needed one because every face is already stamped.

Slices consolidated 26 -> 12 on one test: can it ship alone without
leaving the tree broken or dead? The route move now carries its own e2e
repair (the move alone leaves CI red), the endpoint carries its SDK
regen (no caller otherwise), the eligibility read joins the lock slice
it exists for, and the chooser's three slices collapse into one
component with one state machine. The pure view-model stays separate
despite being small - highest logic risk on the branch.

* feat(server): allow locking any eligible face on a person, not only flagged ones

Manual review needs to lock a face that no scan flagged, so lock is
dropped from the E15 flagged-snapshot gate. stay keeps that gate: it
writes a negative verdict against the face's SUSPECTED owner, read from
the snapshot via a non-null assertion, so without a snapshot row there
is no owner to record against.

Lifting the gate for lock is not merely permissive - it removes a live
guard. getScanFlaggedFacesForPersons INNER JOINs asset_face and
re-validates personId/deletedAt/isVisible at snapshot-read time, so
today a foreign or deleted id can never reach the write path. Dropping
the gate without a replacement was verified to be exploitable: with lock
removed and no eligibility check, resolveFaces returned locked:1 for a
face on another person, for another USER's person, and for a
soft-deleted face - silently re-pointing them onto this person's
identity through replaceFaceIdentities' unscoped ON CONFLICT DO UPDATE.
A nonexistent id crashed with an unhandled FK violation rather than a
400.

getEligibleFaceIdsForPerson replaces that guard explicitly, mirroring
getClusterFacePage's predicate so lockable == listed on the manual
review page. It is advisory - the write-time guards remain
authoritative - and exists so an inapplicable lock is a 400 rather than
a silent no-op or a crash.

Tests: 66/66 in the resolve medium spec, incl. the cross-tenant arm.

* feat(server): allow detach and unknown on any face of a person, not only flagged ones

Completes the E15 relaxation: stay is now the ONLY snapshot-gated
bucket. It keeps that gate because it writes a negative verdict against
the face's SUSPECTED owner, read from the snapshot via a non-null
assertion - with no snapshot row that yields undefined and a 500/FK
violation.

Unlike lock, detach and unknown need no eligibility read: both are
already person-scoped at the write layer (detachFaces filters personId
and keys its identity-strip on the RETURNING output; reattributeFaces
filters still-on-source). That claim is now pinned by test rather than
trusted - a foreign face passed to detach is an inert no-op, left
completely untouched.

Behaviour change (spec 5.4): a stale unknown id - a face that left this
person since the scan - previously 400'd via the gate. It now returns
success with unknown: 0, truthfully reporting zero parks, and the
freshly created cluster is cleaned up rather than orphaned.

Tests: 72/72 in the resolve medium spec, 6/6 cross-flow.

* feat(server): add admin person metadata endpoint for manual face review

GET admin/face-repair/person/:personId returns
{id,name,ownerId,faceCount,thumbnailFaceId}, admin-gated, 404 on
unknown.

The manual review page cannot get these from anywhere else. The guided
page reads personName and ownerId off getLatestScan(), and ownerId is
what scopes the move-picker - so with no scan the picker cannot open at
all. The user-scoped GET /people/:id does not admin-bypass for a person
the admin does not own, the same gap already fixed once for face
thumbnails.

faceCount reuses searchOwnerPeople's exact join conditions so the count
agrees between the browser grid and the review page header; a mismatch
there would read as a bug. The name is returned raw - an unnamed person
yields an empty name and the client applies its own fallback rather than
the server inventing a display string.

Ships with the client regen because the endpoint has no caller until
then. No @GenerateSql was added (this repository carries none) so no
mise sql regeneration; mise.lock verified unchanged.

Tests: controller 76/76, metadata medium 6/6, tsc clean.

* refactor(web): move the guided face-cleanup dashboard to /admin/face-cleanup/scan

Frees /admin/face-cleanup for the mode chooser. Pure relocation of the
ten-file dashboard cluster via git mv - no behaviour change - plus a
temporary 307 redirect at the old path so this lands without a dead
entry point. The chooser replaces that redirect next.

The navbar is deliberately NOT repointed: NavbarItem highlights via
pathname.startsWith(href), so pointing at /admin/face-cleanup keeps it
active across /scan, /people and the chooser. Repointing it would be
the regression, not the fix.

Four e2e assertions broke on the move. Three are repoints; Playwright
globs match the whole URL, so '**/admin/face-cleanup' does not match
/scan. The fourth needed a real fix rather than a repoint: the
post-apply drain check asserts a person's name is ABSENT, which passes
vacuously on any page that never lists people - or on one that simply
has not finished loading. It now waits for the Eligible faces stat,
which renders unconditionally once a scan is completed and is therefore
independent of the drained-vs-not condition under test.

Tests: 117/117 across the nine guided specs from their new locations,
tsc clean.

* feat(web): add the face-cleanup mode chooser

Replaces slice 4's temporary redirect. Two EQUAL-WEIGHT cards with no
recommended badge on either: we do not know which mode a given admin
lives in, and privileging guided would penalise anyone who spends their
time in manual review.

Two distinct presentations rather than one degraded into the other.
First visit reframes the difference as readiness, not importance -
guided says it needs a scan first, manual advertises that it needs no
scan and can start right away. That line matters: manual review is fully
functional on a brand-new instance, and the returning status-board
layout would otherwise hide that behind an empty guided card, leaving a
first-time admin thinking the feature does nothing until a scan
finishes.

The manual card is disabled while a scan runs. This is the UI half of
resolveFaces' 409 guard: without it an admin can stage dozens of
decisions across a server-paged cluster and lose them all on Apply.
Disabled means genuinely unactivatable - no href and a real
<button disabled>, so keyboard cannot reach it either - not just
opacity. Only pending/running block it; failed and zero-flagged leave
manual reachable.

Visual vocabulary is reused verbatim from the dashboard - card shell,
dot+label+tabular-nums stat rhythm, semantic colours - so this reads as
the same product rather than a bolted-on page.

Tests: 13 new chooser cases, 130/130 across the face-cleanup suite,
tsc clean.

* feat(web): add the manual face-cleanup people browser

Builds the destination the chooser's manual card links to. Zero new
server endpoints: the owner list reuses searchUsersAdmin (the same call
the guided dashboard already makes) and the people grid reuses
getFaceRepairOwnerPeople, whose query parameter is optional - so
omitting it lists all of an owner's people, paginated.

Face crops go through the admin-gated, join-free face-thumbnail route,
NOT the user-scoped /people/:id/thumbnail, which 404s for people the
admin does not own. A null thumbnailFaceId renders a real placeholder
rather than a broken image.

Switching owner resets the page and clears the list, with a request
token guarding against a stale response clobbering a newer selection -
otherwise rows from two owners interleave. Pagination appends rather
than replaces.

Four states are kept distinct: this owner has no people, no results for
this query, load failed, and no users at all. Conflating empty with
error was defect D17 on the guided page.

No client-side filtering is applied to what the endpoint returns; that
passthrough is pinned by test so a later server-side change surfaces
here rather than being silently masked.

Tests: 14 new cases, 144/144 across the face-cleanup suite, tsc clean.

* feat(web): add the manual face-review view-model

A separate pure model rather than a reuse of the guided one, because
the guided model cannot express manual review's semantics and two of
its properties are actively dangerous here.

No neutral state: guided's FaceState is six TERMINAL states and every
face initialises to 'owner', with buildResolveRequest dereferencing
suspectedOwnerId for it. Pointed at a scan-free cluster an untouched
review would POST a move for every face to an undefined destination.
Manual defaults to keep, which produces no bucket entry at all, and an
all-keep review builds to null rather than an empty request - so a
disabled Apply cannot be bypassed and the server never sees the empty
resolve it would 400.

State survives paging: guided rebuilds its model from a $derived over
the face array, so appending a page discards staged decisions. This
model owns its list and exposes appendFaces, which preserves both
states and selection and is idempotent on assetFaceId. Manual is
server-paged, so that defect would have been hit on the first Load
more.

stay is hardcoded [] on every request - manual can never emit it, since
it needs a suspected owner. Two @ts-expect-error guards make owner/stay
a compile error rather than a convention; they are load-bearing, since
tsc reports an unused directive if the type ever stops rejecting them.

Colour/icon tokens are imported from the guided model so one glyph means
one thing across both pages; keep deliberately has no token - it is
signalled by absence.

Tests: 26 cases, 170/170 across the face-cleanup suite, tsc clean.

* feat(web): add the manual face-review page with server-paged cluster faces

Opens any person with no scan in existence and lists ALL of their
faces, via the scan-free cluster endpoint with an empty excludeFaceIds.

Implements the visual inversion this page is built around: guided
stamps every tile because every face holds one of six terminal states,
so its grid is a wall of colour to audit. Manual defaults to keep, so a
tile renders a clean crop with no badge and no ribbon, and colour
appears only where the admin acted. keep needs no colour token - it is
signalled by absence, which keeps the existing rule that state is never
encoded in colour alone without inventing a seventh swatch.

Selection is honest about server paging: it always means LOADED faces,
labelled Select all loaded (N) with showing N of M in the header. An
unqualified select-all would either lie about a cluster of thousands or
force loading all of it; whole-cluster work goes through the
server-enumerated entireCluster instead (slice 10).

Load more appends through the model rather than re-creating it, so
staged marks and selection survive - the defect that made reusing the
guided model impossible, and the first thing a paged cluster would have
hit.

Person name and ownerId resolve from the URL param through the metadata
endpoint rather than navigation state, so refresh and deep links work.
Empty and load-error are kept distinct (conflating them was D17 on the
guided page), and a person the scan flagged shows no flagged badging -
manual ignores scan state entirely.

Tests: 15 new cases, 185/185 across the face-cleanup suite, tsc clean.

* feat(web): add manual review bulk actions and apply

The footer dock: Move to person (via the reused PersonPicker, scoped by
the ownerId the metadata endpoint supplies), Lock, Unknown, Not a face,
and Unmark - plus the staged-work tally and Apply.

Unmark exists because manual defaults to keep. Guided never needed one:
every face there is already stamped, so the admin only ever swaps one
stamp for another. Here marking is a deliberate act, so mis-marking
needs reversing without discarding every other staged decision via
Reset.

stay and owner are not offered and can never be emitted - both need a
suspected owner. The payload assertion pins stay: [] explicitly across
mixed buckets.

Apply is disabled while everything is keep, because buildResolveRequest
returns null and an all-keep POST would be the empty resolve the server
400s. Not a face keeps guided's destructive confirm, and declining it
posts nothing.

A 409 - a scan starting mid-review - surfaces without discarding staged
work. That conflict is what the chooser's disabled-manual card exists to
prevent; if it happens anyway the page must not compound it by throwing
away decisions staged across a paged cluster.

Tests drive the real UI rather than the model. 16 new cases, 201/201
across the face-cleanup suite, guided's 73 specs untouched, tsc clean.

* feat(web): add entire-cluster move and the manual actions help modal

Move entire cluster uses the server-enumerated entireCluster rather than
selection, which is the whole reason it belongs here: on a server-paged
page selection can only ever cover LOADED faces, so whole-cluster work
must not route through it. Unlike guided - where entireCluster rides the
scan's suspected owner - manual has none, so it requires an explicit
destination via PersonPicker, and it posts with no per-face buckets
since the server rejects combining them. It carries its own confirm
because it moves faces the admin has never seen, which is both the point
and the risk.

Manual gets its own help modal rather than extending guided's, whose
names all six actions test is load-bearing and stays untouched. The
modal leads with the thing most likely to confuse someone arriving from
guided: keep writes nothing. It also states that Not a face is the
irreversible one and sits beside Unknown, which means the opposite - bin
this crop versus this is a real person I cannot name.

Swatches are asserted against the tile tokens so an explanation ties
back to the button and the tile it describes, and keep renders no
swatch at all - absence is how the default is signalled.

Tests: 21 new cases, 222/222 across the face-cleanup suite, guided's
specs untouched, tsc clean.

* test(e2e): cover the manual face-review flow and its cross-engine invariant

Two tests. The first drives the whole manual path against a person with
NO scan in existence: chooser to people browser to review page, listing
all faces rather than a flagged subset, then move, lock and not-a-face
through the real UI, asserting durable DB rows rather than UI text.

The second is the point of the feature: a later scan must honour a
manual decision exactly as it honours a guided one - the locked face is
not re-flagged, the detached face stays gone, the moved face is not
re-proposed.

Two seeding traps handled. utils.createFace links faces with
source='manual', which the verdict layer excludes from flagging, so
every face here is downgraded to 'ml' or it would not behave like a
machine-clustered one; and face_search rows are required because both
the cluster listing and lock eligibility inner-join it.

The cross-engine test seeds ONE multi-person scan rather than two
sequential ones: two scans stamped the same now() make getLatestScan
nondeterministic, which would let the invariant assertions pass
vacuously instead of for the right reason.

* docs: document guided and manual face cleanup modes

Adds a Two ways to clean up comparison and a Manual review section to
the admin face cleanup page.

The two points worth documenting explicitly are the ones an admin
cannot infer: that faces you leave alone are deliberately NOT recorded
(marking everything you glanced at as human-verified would stop future
scans flagging it and hide real mistakes, so Lock is the opt-in), and
that Select all covers the faces currently loaded rather than the whole
cluster - with Move entire cluster as the server-resolved alternative.

Also notes that manual review is unavailable during a scan, so the
behaviour reads as deliberate rather than broken.

* fix(web): keep translation-key literal types in the manual review tally

A Record<..., string> annotation widened each value to string, and $t
only accepts known translation keys, so the tally lookup stopped
type-checking. satisfies keeps the exhaustiveness check on the keys
while preserving the literal value types.

Caught only by CI's check-svelte: that task reports 0 FILES locally in
this worktree - even when invoked through mise exactly as CI does - so
neither tsc nor the local svelte-check surfaced it.

* fix(web): build the manual tally translation key with a template literal

satisfies was not enough: indexing an object of whole translation keys
still yields string at the call site, and $t accepts a template-literal
type but not string. Matches the guided dock's idiom exactly -
$t(`admin.face_cleanup_review_tally_${...}`) - by mapping only the key
SUFFIX, with move reusing guided's existing 'other' key rather than
adding a near-duplicate.

Only CI's check-svelte catches this; that task scans 0 files locally
even when invoked through mise as CI does.

* style(web): redesign the face-cleanup mode chooser

Rework the two-door chooser at /admin/face-cleanup into a considered design: a 48px icon tile anchors each mode, guided keeps the primary accent while manual gets a distinct teal identity (neither marked recommended), and each card carries a tinted status well that recolours across all five scan states with the live count as a hero number. Actions become full-width and foot-aligned, with a soft corner glow, inset ring, and hover lift for depth.

Logic, copy, i18n keys, data-testids and the genuinely-disabled manual guard (§7) are unchanged; all 13 chooser page-spec tests still pass, eslint/prettier/svelte-check clean.

* feat(web): infinite-scroll the manual face-review lists

Replace the "Load more" buttons on the manual people browser and the
selected-person review grid with scroll-driven pagination, so both lists
grow as the admin scrolls instead of dead-ending on a button.

Adds a reusable InfiniteScrollSentinel primitive (a sentinel-only port of
people-grid's IntersectionObserver + visibility-fallback logic; people-grid
itself isn't a drop-in where items aren't keyed by `id`). The review grid
keeps appending through vm.appendFaces, so staged marks and selection still
survive each page load.

* docs: spec the face-cleanup scan two-lane triage redesign

* docs: sliced impl-loop plan for the scan two-lane redesign

* feat(web): add exclusion-set selection model for scan triage

* feat(web): add ConfidentLane with approve-all + spot-check exclude

* feat(web): add ReviewFirstLane clickable review list

* feat(web): two-lane triage scan page; drop checklist/table/filter/selection UI

Compose ConfidentLane (approve-all + spot-check exclude) and ReviewFirstLane
(clickable rows) in the scan page, driven by the exclusion-set triage model.
Removes the 3-step checklist, 5 stat cards, filter tabs, and the selection bar;
the two non-actionable totals become quiet footnotes. Deletes ScanChecklist,
FaceCleanupTable, and the old face-cleanup selection model, and prunes their
now-dead i18n keys.

* docs: note the scan-redesign plan corrections applied during impl-loop

* test(e2e): key the scan-drain wait off the header summary, not the removed stat card

The two-lane scan redesign removed the "Eligible faces" stat card the X1 test
used as its "completed snapshot loaded" signal; use the header summary line
("… flagged faces across … people"), which renders on any completed scan.

* fix(web): tidy the review-lane rows — one primary reason pill, no overflow

Collapse the wrapping multi-pill reason column to a single primary reason
(bad-target wins, else the first) plus a "+N", narrow the flagged column, and
share one absolute right-slot between the chevron (default) and the hover
dismiss so the row can no longer widen past the container.

* feat(web): enlarge the guided-review bulk action bar

The routing buttons were text-xs with 13px icons on the dark dock and read as
small/hidden. Enlarge them (text-sm, 16px icons, roomier padding, a defining
inset ring, rounded-lg), tint the destructive "not a face" red to set it apart
from the routine routes, and bump the selected-count and Clear.

* docs: spec for face-cleanup console UX fixes (clickable auto-fix chips, review-lane alignment)

* docs: implementation plan for face-cleanup console UX fixes

* docs: add column-content regression guard to the face-cleanup UX plan

* fix(web): align the review-lane columns and give them headings

* style(web): prettier fallout on the review-lane changes

* feat(web): make auto-fix spot-check chips open the per-cluster review page

* fix(web): persist confident-lane spot-check exclusions across chip click-through
Deeds67 added a commit that referenced this pull request Aug 10, 2026
…an (#838)

* docs: design spec for manual face review mode

Adds a second entry path to the admin face cleanup console: pick any
person and audit all of their faces, with no scan required. Reuses the
existing tile grid, bulk-select-then-apply interaction, and verdict
writes, so a manual decision is indistinguishable from a guided one.

Server work is narrow: relax guard E15 for lock/unknown/detach (keeping
stay scan-only, since it needs a suspected owner), add the eligibility
check that snapshot membership was implicitly providing, and add an
admin person-metadata endpoint for the name/ownerId the review page
currently reads off the scan.

* docs: revise manual face review spec after verification pass

Three independent verification passes against the code disproved the
first draft's central premise and five other claims.

The big one: manual mode cannot reuse the guided review page behind a
mode flag. FlaggedFace.suspectedOwnerId is required, every face defaults
to the 'owner' state, and buildResolveRequest dereferences it - so an
untouched manual review would POST moveToPerson with an undefined
destination for every face. There is also no neutral state (six terminal
states with a tested tally invariant), and the model is $derived over
the face array, so paging would wipe staged decisions.

The design now forks the review UI: a separate manual page under
/people/[personId] with its own view-model (keep default, stable across
appends, never emits stay), leaving all nine guided web specs untouched.

Also corrected: lock is a PRIMARY KEY upsert with DO UPDATE that can
steal a face from another identity (not an idempotent no-op via a plain
unique index - that claim came from a stale comment referencing the
long-gone insertLocks); FaceRepairRepository has no @GenerateSql so no
mise sql regen; the navbar needs no repointing; resolveFaces has no unit
coverage, only medium; and relaxing unknown converts one 400 into a
success with unknown:0.

* docs: chooser page design — equal-weight cards, first-visit and returning states

Pierre's review of the chooser design: a first-time admin has never
scanned, so the page needs a distinct first-visit presentation, and the
two modes should carry equal weight since some admins will live in
manual review rather than triaging scans.

Equal-weight cards with no recommended badge. First visit gets an
explanatory header and advertises that manual review needs no scan —
it is fully usable on a brand-new instance, which the returning
status-board layout would otherwise obscure. Adds the full card state
matrix (never-scanned / running / flagged / zero-flagged / failed),
including the manual card being disabled during a scan, which is the UI
half of the resolveFaces 409 guard and prevents staging dozens of
decisions only to lose them to a conflict.

Slices renumbered 1-26 to cover the two chooser presentations and the
remaining card states separately.

* docs: manual review page design; consolidate 26 slices into 12

Folds the manual review page design into 6.4. The key point is a visual
inversion that falls out of the data model: guided stamps every tile
because every face holds one of six terminal states, while manual
defaults to keep, so tiles start clean and colour appears only where the
admin acted. keep needs no colour token - absence signals it, which
satisfies the existing rule that state is never encoded in colour alone.

Two interaction problems get resolved. Selection cannot claim the whole
cluster over server paging, so it always means loaded faces (Select all
loaded (N), showing N of M) and whole-cluster work goes through the
server-enumerated entireCluster with its own destination picker. And a
keep default needs an undo, so Unmark returns a selection to keep -
guided never needed one because every face is already stamped.

Slices consolidated 26 -> 12 on one test: can it ship alone without
leaving the tree broken or dead? The route move now carries its own e2e
repair (the move alone leaves CI red), the endpoint carries its SDK
regen (no caller otherwise), the eligibility read joins the lock slice
it exists for, and the chooser's three slices collapse into one
component with one state machine. The pure view-model stays separate
despite being small - highest logic risk on the branch.

* feat(server): allow locking any eligible face on a person, not only flagged ones

Manual review needs to lock a face that no scan flagged, so lock is
dropped from the E15 flagged-snapshot gate. stay keeps that gate: it
writes a negative verdict against the face's SUSPECTED owner, read from
the snapshot via a non-null assertion, so without a snapshot row there
is no owner to record against.

Lifting the gate for lock is not merely permissive - it removes a live
guard. getScanFlaggedFacesForPersons INNER JOINs asset_face and
re-validates personId/deletedAt/isVisible at snapshot-read time, so
today a foreign or deleted id can never reach the write path. Dropping
the gate without a replacement was verified to be exploitable: with lock
removed and no eligibility check, resolveFaces returned locked:1 for a
face on another person, for another USER's person, and for a
soft-deleted face - silently re-pointing them onto this person's
identity through replaceFaceIdentities' unscoped ON CONFLICT DO UPDATE.
A nonexistent id crashed with an unhandled FK violation rather than a
400.

getEligibleFaceIdsForPerson replaces that guard explicitly, mirroring
getClusterFacePage's predicate so lockable == listed on the manual
review page. It is advisory - the write-time guards remain
authoritative - and exists so an inapplicable lock is a 400 rather than
a silent no-op or a crash.

Tests: 66/66 in the resolve medium spec, incl. the cross-tenant arm.

* feat(server): allow detach and unknown on any face of a person, not only flagged ones

Completes the E15 relaxation: stay is now the ONLY snapshot-gated
bucket. It keeps that gate because it writes a negative verdict against
the face's SUSPECTED owner, read from the snapshot via a non-null
assertion - with no snapshot row that yields undefined and a 500/FK
violation.

Unlike lock, detach and unknown need no eligibility read: both are
already person-scoped at the write layer (detachFaces filters personId
and keys its identity-strip on the RETURNING output; reattributeFaces
filters still-on-source). That claim is now pinned by test rather than
trusted - a foreign face passed to detach is an inert no-op, left
completely untouched.

Behaviour change (spec 5.4): a stale unknown id - a face that left this
person since the scan - previously 400'd via the gate. It now returns
success with unknown: 0, truthfully reporting zero parks, and the
freshly created cluster is cleaned up rather than orphaned.

Tests: 72/72 in the resolve medium spec, 6/6 cross-flow.

* feat(server): add admin person metadata endpoint for manual face review

GET admin/face-repair/person/:personId returns
{id,name,ownerId,faceCount,thumbnailFaceId}, admin-gated, 404 on
unknown.

The manual review page cannot get these from anywhere else. The guided
page reads personName and ownerId off getLatestScan(), and ownerId is
what scopes the move-picker - so with no scan the picker cannot open at
all. The user-scoped GET /people/:id does not admin-bypass for a person
the admin does not own, the same gap already fixed once for face
thumbnails.

faceCount reuses searchOwnerPeople's exact join conditions so the count
agrees between the browser grid and the review page header; a mismatch
there would read as a bug. The name is returned raw - an unnamed person
yields an empty name and the client applies its own fallback rather than
the server inventing a display string.

Ships with the client regen because the endpoint has no caller until
then. No @GenerateSql was added (this repository carries none) so no
mise sql regeneration; mise.lock verified unchanged.

Tests: controller 76/76, metadata medium 6/6, tsc clean.

* refactor(web): move the guided face-cleanup dashboard to /admin/face-cleanup/scan

Frees /admin/face-cleanup for the mode chooser. Pure relocation of the
ten-file dashboard cluster via git mv - no behaviour change - plus a
temporary 307 redirect at the old path so this lands without a dead
entry point. The chooser replaces that redirect next.

The navbar is deliberately NOT repointed: NavbarItem highlights via
pathname.startsWith(href), so pointing at /admin/face-cleanup keeps it
active across /scan, /people and the chooser. Repointing it would be
the regression, not the fix.

Four e2e assertions broke on the move. Three are repoints; Playwright
globs match the whole URL, so '**/admin/face-cleanup' does not match
/scan. The fourth needed a real fix rather than a repoint: the
post-apply drain check asserts a person's name is ABSENT, which passes
vacuously on any page that never lists people - or on one that simply
has not finished loading. It now waits for the Eligible faces stat,
which renders unconditionally once a scan is completed and is therefore
independent of the drained-vs-not condition under test.

Tests: 117/117 across the nine guided specs from their new locations,
tsc clean.

* feat(web): add the face-cleanup mode chooser

Replaces slice 4's temporary redirect. Two EQUAL-WEIGHT cards with no
recommended badge on either: we do not know which mode a given admin
lives in, and privileging guided would penalise anyone who spends their
time in manual review.

Two distinct presentations rather than one degraded into the other.
First visit reframes the difference as readiness, not importance -
guided says it needs a scan first, manual advertises that it needs no
scan and can start right away. That line matters: manual review is fully
functional on a brand-new instance, and the returning status-board
layout would otherwise hide that behind an empty guided card, leaving a
first-time admin thinking the feature does nothing until a scan
finishes.

The manual card is disabled while a scan runs. This is the UI half of
resolveFaces' 409 guard: without it an admin can stage dozens of
decisions across a server-paged cluster and lose them all on Apply.
Disabled means genuinely unactivatable - no href and a real
<button disabled>, so keyboard cannot reach it either - not just
opacity. Only pending/running block it; failed and zero-flagged leave
manual reachable.

Visual vocabulary is reused verbatim from the dashboard - card shell,
dot+label+tabular-nums stat rhythm, semantic colours - so this reads as
the same product rather than a bolted-on page.

Tests: 13 new chooser cases, 130/130 across the face-cleanup suite,
tsc clean.

* feat(web): add the manual face-cleanup people browser

Builds the destination the chooser's manual card links to. Zero new
server endpoints: the owner list reuses searchUsersAdmin (the same call
the guided dashboard already makes) and the people grid reuses
getFaceRepairOwnerPeople, whose query parameter is optional - so
omitting it lists all of an owner's people, paginated.

Face crops go through the admin-gated, join-free face-thumbnail route,
NOT the user-scoped /people/:id/thumbnail, which 404s for people the
admin does not own. A null thumbnailFaceId renders a real placeholder
rather than a broken image.

Switching owner resets the page and clears the list, with a request
token guarding against a stale response clobbering a newer selection -
otherwise rows from two owners interleave. Pagination appends rather
than replaces.

Four states are kept distinct: this owner has no people, no results for
this query, load failed, and no users at all. Conflating empty with
error was defect D17 on the guided page.

No client-side filtering is applied to what the endpoint returns; that
passthrough is pinned by test so a later server-side change surfaces
here rather than being silently masked.

Tests: 14 new cases, 144/144 across the face-cleanup suite, tsc clean.

* feat(web): add the manual face-review view-model

A separate pure model rather than a reuse of the guided one, because
the guided model cannot express manual review's semantics and two of
its properties are actively dangerous here.

No neutral state: guided's FaceState is six TERMINAL states and every
face initialises to 'owner', with buildResolveRequest dereferencing
suspectedOwnerId for it. Pointed at a scan-free cluster an untouched
review would POST a move for every face to an undefined destination.
Manual defaults to keep, which produces no bucket entry at all, and an
all-keep review builds to null rather than an empty request - so a
disabled Apply cannot be bypassed and the server never sees the empty
resolve it would 400.

State survives paging: guided rebuilds its model from a $derived over
the face array, so appending a page discards staged decisions. This
model owns its list and exposes appendFaces, which preserves both
states and selection and is idempotent on assetFaceId. Manual is
server-paged, so that defect would have been hit on the first Load
more.

stay is hardcoded [] on every request - manual can never emit it, since
it needs a suspected owner. Two @ts-expect-error guards make owner/stay
a compile error rather than a convention; they are load-bearing, since
tsc reports an unused directive if the type ever stops rejecting them.

Colour/icon tokens are imported from the guided model so one glyph means
one thing across both pages; keep deliberately has no token - it is
signalled by absence.

Tests: 26 cases, 170/170 across the face-cleanup suite, tsc clean.

* feat(web): add the manual face-review page with server-paged cluster faces

Opens any person with no scan in existence and lists ALL of their
faces, via the scan-free cluster endpoint with an empty excludeFaceIds.

Implements the visual inversion this page is built around: guided
stamps every tile because every face holds one of six terminal states,
so its grid is a wall of colour to audit. Manual defaults to keep, so a
tile renders a clean crop with no badge and no ribbon, and colour
appears only where the admin acted. keep needs no colour token - it is
signalled by absence, which keeps the existing rule that state is never
encoded in colour alone without inventing a seventh swatch.

Selection is honest about server paging: it always means LOADED faces,
labelled Select all loaded (N) with showing N of M in the header. An
unqualified select-all would either lie about a cluster of thousands or
force loading all of it; whole-cluster work goes through the
server-enumerated entireCluster instead (slice 10).

Load more appends through the model rather than re-creating it, so
staged marks and selection survive - the defect that made reusing the
guided model impossible, and the first thing a paged cluster would have
hit.

Person name and ownerId resolve from the URL param through the metadata
endpoint rather than navigation state, so refresh and deep links work.
Empty and load-error are kept distinct (conflating them was D17 on the
guided page), and a person the scan flagged shows no flagged badging -
manual ignores scan state entirely.

Tests: 15 new cases, 185/185 across the face-cleanup suite, tsc clean.

* feat(web): add manual review bulk actions and apply

The footer dock: Move to person (via the reused PersonPicker, scoped by
the ownerId the metadata endpoint supplies), Lock, Unknown, Not a face,
and Unmark - plus the staged-work tally and Apply.

Unmark exists because manual defaults to keep. Guided never needed one:
every face there is already stamped, so the admin only ever swaps one
stamp for another. Here marking is a deliberate act, so mis-marking
needs reversing without discarding every other staged decision via
Reset.

stay and owner are not offered and can never be emitted - both need a
suspected owner. The payload assertion pins stay: [] explicitly across
mixed buckets.

Apply is disabled while everything is keep, because buildResolveRequest
returns null and an all-keep POST would be the empty resolve the server
400s. Not a face keeps guided's destructive confirm, and declining it
posts nothing.

A 409 - a scan starting mid-review - surfaces without discarding staged
work. That conflict is what the chooser's disabled-manual card exists to
prevent; if it happens anyway the page must not compound it by throwing
away decisions staged across a paged cluster.

Tests drive the real UI rather than the model. 16 new cases, 201/201
across the face-cleanup suite, guided's 73 specs untouched, tsc clean.

* feat(web): add entire-cluster move and the manual actions help modal

Move entire cluster uses the server-enumerated entireCluster rather than
selection, which is the whole reason it belongs here: on a server-paged
page selection can only ever cover LOADED faces, so whole-cluster work
must not route through it. Unlike guided - where entireCluster rides the
scan's suspected owner - manual has none, so it requires an explicit
destination via PersonPicker, and it posts with no per-face buckets
since the server rejects combining them. It carries its own confirm
because it moves faces the admin has never seen, which is both the point
and the risk.

Manual gets its own help modal rather than extending guided's, whose
names all six actions test is load-bearing and stays untouched. The
modal leads with the thing most likely to confuse someone arriving from
guided: keep writes nothing. It also states that Not a face is the
irreversible one and sits beside Unknown, which means the opposite - bin
this crop versus this is a real person I cannot name.

Swatches are asserted against the tile tokens so an explanation ties
back to the button and the tile it describes, and keep renders no
swatch at all - absence is how the default is signalled.

Tests: 21 new cases, 222/222 across the face-cleanup suite, guided's
specs untouched, tsc clean.

* test(e2e): cover the manual face-review flow and its cross-engine invariant

Two tests. The first drives the whole manual path against a person with
NO scan in existence: chooser to people browser to review page, listing
all faces rather than a flagged subset, then move, lock and not-a-face
through the real UI, asserting durable DB rows rather than UI text.

The second is the point of the feature: a later scan must honour a
manual decision exactly as it honours a guided one - the locked face is
not re-flagged, the detached face stays gone, the moved face is not
re-proposed.

Two seeding traps handled. utils.createFace links faces with
source='manual', which the verdict layer excludes from flagging, so
every face here is downgraded to 'ml' or it would not behave like a
machine-clustered one; and face_search rows are required because both
the cluster listing and lock eligibility inner-join it.

The cross-engine test seeds ONE multi-person scan rather than two
sequential ones: two scans stamped the same now() make getLatestScan
nondeterministic, which would let the invariant assertions pass
vacuously instead of for the right reason.

* docs: document guided and manual face cleanup modes

Adds a Two ways to clean up comparison and a Manual review section to
the admin face cleanup page.

The two points worth documenting explicitly are the ones an admin
cannot infer: that faces you leave alone are deliberately NOT recorded
(marking everything you glanced at as human-verified would stop future
scans flagging it and hide real mistakes, so Lock is the opt-in), and
that Select all covers the faces currently loaded rather than the whole
cluster - with Move entire cluster as the server-resolved alternative.

Also notes that manual review is unavailable during a scan, so the
behaviour reads as deliberate rather than broken.

* fix(web): keep translation-key literal types in the manual review tally

A Record<..., string> annotation widened each value to string, and $t
only accepts known translation keys, so the tally lookup stopped
type-checking. satisfies keeps the exhaustiveness check on the keys
while preserving the literal value types.

Caught only by CI's check-svelte: that task reports 0 FILES locally in
this worktree - even when invoked through mise exactly as CI does - so
neither tsc nor the local svelte-check surfaced it.

* fix(web): build the manual tally translation key with a template literal

satisfies was not enough: indexing an object of whole translation keys
still yields string at the call site, and $t accepts a template-literal
type but not string. Matches the guided dock's idiom exactly -
$t(`admin.face_cleanup_review_tally_${...}`) - by mapping only the key
SUFFIX, with move reusing guided's existing 'other' key rather than
adding a near-duplicate.

Only CI's check-svelte catches this; that task scans 0 files locally
even when invoked through mise as CI does.

* style(web): redesign the face-cleanup mode chooser

Rework the two-door chooser at /admin/face-cleanup into a considered design: a 48px icon tile anchors each mode, guided keeps the primary accent while manual gets a distinct teal identity (neither marked recommended), and each card carries a tinted status well that recolours across all five scan states with the live count as a hero number. Actions become full-width and foot-aligned, with a soft corner glow, inset ring, and hover lift for depth.

Logic, copy, i18n keys, data-testids and the genuinely-disabled manual guard (§7) are unchanged; all 13 chooser page-spec tests still pass, eslint/prettier/svelte-check clean.

* feat(web): infinite-scroll the manual face-review lists

Replace the "Load more" buttons on the manual people browser and the
selected-person review grid with scroll-driven pagination, so both lists
grow as the admin scrolls instead of dead-ending on a button.

Adds a reusable InfiniteScrollSentinel primitive (a sentinel-only port of
people-grid's IntersectionObserver + visibility-fallback logic; people-grid
itself isn't a drop-in where items aren't keyed by `id`). The review grid
keeps appending through vm.appendFaces, so staged marks and selection still
survive each page load.

* docs: spec the face-cleanup scan two-lane triage redesign

* docs: sliced impl-loop plan for the scan two-lane redesign

* feat(web): add exclusion-set selection model for scan triage

* feat(web): add ConfidentLane with approve-all + spot-check exclude

* feat(web): add ReviewFirstLane clickable review list

* feat(web): two-lane triage scan page; drop checklist/table/filter/selection UI

Compose ConfidentLane (approve-all + spot-check exclude) and ReviewFirstLane
(clickable rows) in the scan page, driven by the exclusion-set triage model.
Removes the 3-step checklist, 5 stat cards, filter tabs, and the selection bar;
the two non-actionable totals become quiet footnotes. Deletes ScanChecklist,
FaceCleanupTable, and the old face-cleanup selection model, and prunes their
now-dead i18n keys.

* docs: note the scan-redesign plan corrections applied during impl-loop

* test(e2e): key the scan-drain wait off the header summary, not the removed stat card

The two-lane scan redesign removed the "Eligible faces" stat card the X1 test
used as its "completed snapshot loaded" signal; use the header summary line
("… flagged faces across … people"), which renders on any completed scan.

* fix(web): tidy the review-lane rows — one primary reason pill, no overflow

Collapse the wrapping multi-pill reason column to a single primary reason
(bad-target wins, else the first) plus a "+N", narrow the flagged column, and
share one absolute right-slot between the chevron (default) and the hover
dismiss so the row can no longer widen past the container.

* feat(web): enlarge the guided-review bulk action bar

The routing buttons were text-xs with 13px icons on the dark dock and read as
small/hidden. Enlarge them (text-sm, 16px icons, roomier padding, a defining
inset ring, rounded-lg), tint the destructive "not a face" red to set it apart
from the routine routes, and bump the selected-count and Clear.

* docs: spec for face-cleanup console UX fixes (clickable auto-fix chips, review-lane alignment)

* docs: implementation plan for face-cleanup console UX fixes

* docs: add column-content regression guard to the face-cleanup UX plan

* fix(web): align the review-lane columns and give them headings

* style(web): prettier fallout on the review-lane changes

* feat(web): make auto-fix spot-check chips open the per-cluster review page

* fix(web): persist confident-lane spot-check exclusions across chip click-through
Deeds67 added a commit that referenced this pull request Aug 12, 2026
…ict layer (#834)

* feat(server): add person face-suggestion endpoints

* feat: regenerate OpenAPI + SQL and add face-suggestion API e2e

Regenerated TypeScript SDK and Dart client from the three new
face-suggestion endpoints (GET /face-suggestions, POST /confirm,
POST /dismiss). Added SQL query docs for markConfirmed and
markDismissed. Added API e2e spec with 401/400/200/idempotency
coverage for all three endpoints plus the read-gate (hidden person).

* docs: add Phase 4 implementation plan for face-suggestion web UI

* feat(web): add i18n strings for face-suggestion banner and review modal

* phase 3 docs

* feat(web): add getFaceCropTransform crop-math helper

* feat(web): add localStorage snooze for face suggestions

* feat(web): add client-side FaceCrop component for unassigned candidate faces

* feat(web): add PersonSuggestionBanner with snooze

* feat(web): add PersonSuggestionReviewModal guided review queue

* feat(web): mount face-suggestion banner + review modal on person page

* test(e2e): web E2E for face-suggestion banner, review confirm and snooze

- Add PLAYWRIGHT_DB_PORT env var to playwright.config.ts and utils.ts so
  the e2e-web-dev Makefile targets can connect to the dev stack database
  on port 5432 instead of the e2e stack's 5435
- Seed person_face_suggestion rows via raw SQL (no ML required)
- Verify banner renders count, review modal opens and confirms a face,
  and snooze persists the hidden state across reloads

* docs: document face-suggestion review and the dismiss/auto-assign behavior

* fix(build): enable deleteOutDir to prevent stale dist migration files

* revert: restore deleteOutDir false

* feat(web): add suggestionMaxDistance to facial recognition admin settings

* docs: add Phase 5 (shared spaces) face-suggestion design

* feat(server): support space-person face suggestion rows

* feat(server): add space-scoped face embedding search

* feat(server): add space-person suggestion scan queries

* feat(server): add space-person suggestion repository methods

* feat(server): add space-person suggestion scan jobs

* feat(server): chain space face-suggestion scans after identity maintenance

* feat(server): resolve space suggestions when space people merge

* chore: added plan

* docs: add phase 5b face suggestion plan

* feat(server): add shared-space face suggestion DTOs

* feat(server): guard space face suggestion actions

* test(server): cover unshared space suggestion guard

* fix(server): refine space face suggestion DTOs

* test(server): add space face suggestion review edges

* feat(server): read space face suggestions

* fix(server): validate space suggestion person route

* feat(server): confirm space face suggestions

* test(server): fix space suggestion medium fixture

* feat(server): add space face suggestion routes

* test(server): cover space suggestion person param validation

* chore(api): regenerate space face suggestion clients

* fix(ci): cover space suggestion revert cleanup

* docs: format face suggestion phase plans

* fix(ci): align space suggestion schema artifacts

* fix(ci): clean server formatting and schema override

* fix(ci): clean web face suggestion checks

* fix(ci): avoid unused banner dependency expression

* feat(web): add shared-space face suggestion review

* docs: format face suggestion phase 5c plan

* fix(server): queue shared-space suggestion scans on naming

* feat(server): add face suggestion maintenance job

* fix(openapi): regenerate face suggestion job enums

* fix(server): stabilize face suggestion embedding samples

* fix(server): make face suggestion migration overrides idempotent

* fix(server): update generated face embedding SQL

* fix(server): clean dangling active queue entries

* ci: allow test pre-job to read PR files

* ci: allow docker metadata to read PRs

* test: ignore paused jobs when waiting for queues

* test(web): scope tag-disabled fetch assertion

* test(e2e): use real video fixture for live photos

* chore(server): log face identity backfill progress

* fix(server): converge personal identity backfill

* chore(server): demote face identity backfill progress logs

* chore(server): satisfy face identity lint

* docs: design face suggestion ignore action

* docs: harden face suggestion ignore spec

* docs: plan face suggestion ignore action

* docs: tighten face suggestion ignore implementation plan

* feat(server): add face suggestion intent statuses

* feat(server): persist face suggestion rejection and ignore intents

* chore(server): leave face suggestion SQL regen to CI

* test(server): keep face suggestion migration legacy status literal typed

* feat(server): add personal face suggestion reject and ignore APIs

* feat(server): add shared-space face suggestion reject and ignore APIs

* test(server): expect shared-space dismiss to reject suggestions

* chore: regenerate face suggestion API clients

* feat(web): add ignore action to face suggestion review

* chore: cover face suggestion intent migration in revert script

* chore(docs): format face suggestion planning docs

* chore: refresh generated face suggestion artifacts

* feat(i18n): add German and French face-suggestion translations

Adds de/fr translations for the 19 face-suggestion strings the feature
introduced in en.json (review modal, banner, admin job + ML setting,
error toast), keeping en/de/fr at parity.

# Conflicts:
#	i18n/de.json
#	i18n/fr.json

* test(server): wire DatabaseRepository for space merge suggestion test

mergeSpacePeople now delegates to IdentityMergePropagationService (rebased
onto main), which needs databaseRepository.transaction; add DatabaseRepository
to the medium test's real services so the merge-resolves-suggestions case runs.

* fix(server): re-timestamp face-suggestion migrations after rebase

1779000000000 and 1779100000000 now collide with main's
AddSharedSpaceAlbumUserTables / AddSharedSpaceAlbumCreateSideTriggers.
Move the two space/intent face-suggestion migrations to 1784000000000
and 1784100000000, which are free above main's highest fork migration
(1783700000000). Ordering relative to 1778900000000-AddPersonFaceSuggestion
(which creates the table) is preserved.

* fix(server): repair face-suggestion wiring after rebase + regenerate Dart client

- base.service.ts: add personFaceSuggestionRepository to the static create()
  positional list (main introduced create() after this branch forked; the
  import and constructor sites were already present, the third was missing
  and would have shifted every later repository).
- person.service.ts: main split asDateString into asDateTimeString (ISO
  datetime) and asDateString (yyyy-mm-dd). The suggestion fileCreatedAt is a
  date-time, so it now uses asDateTimeString.
- mobile/openapi: regenerate the Dart client (the OpenAPI spec and the
  TypeScript SDK regenerate to a zero diff).

* style(web): re-run prettier on face-suggestion specs after rebase

* test(server): match main's validation error shape in face-suggestion controller specs

Main replaced the flat `badRequest(['[field] message'])` body with
`{ message: 'Validation failed', errors: [...] }` and the
`errorDto.validationError([{ path, message }])` helper. Update the eight
face-suggestion route assertions to the new shape.

* fix(server): route space face-suggestion reads through the 3-path scope helper

main's shared-space-album-scope guard (a static CI test) requires that every
shared_space_library scoping arm carries the linked-album arm, and that every
space asset read carries the visibility gate. Both landed after this branch
forked, so the branch's space-scoped face reads were 2-path and ungated:

- search.repository.ts searchFaces (space): now unions all three paths via
  spaceAssetPathBranches and applies spaceVisibilityGate, so album-linked and
  cross-owner contributed assets are candidates and other members' Hidden/Locked
  assets are not.
- person-face-suggestion.repository.ts getPendingForSpacePerson /
  hasPendingForSpacePerson: same 3-path helper (these already gated visibility).
- shared-space.repository.ts getScannableSpacePeopleWithUnassignedFaces: 3-path
  helper plus the visibility gate.

Generated SQL regenerated (only these two query files change).

Also:
- web PersonSuggestionReviewModal: Faces moved to managers/asset-viewer-manager.
- e2e face-suggestion spec: main's 400 body has no 'error' field; use errorDto.
- medium person.service spec: SystemMetadataRepository was registered in BOTH
  real and mock after the merge, so getMock returned the real repository.

* fix(migrations): renumber AddSpacePersonFaceSuggestion off the #770 collision at 1784000000000

* chore: regenerate OpenAPI clients for the unified face-review branch

* fix(server): preserve manual face-identity links across backfill realign

Slice 1 of the face-review unification proves the assumption the unified verdict
layer rests on: face_identity_face.source='manual' is the durable record that a
human placed a face on a person.

It was not durable. realignFacesToPersonIdentity set source='backfill'
unconditionally, so any face whose link identity had drifted from its person's
identity lost the record - and drift is the normal state right after a people
merge. Every human-confirmed face was one backfill pass away from silently
becoming eligible for re-flagging again.

Realigning which human a face links to is this method's job; erasing that a
human placed it is not. Preserve 'manual', realign everything else as before.

* feat(server): face_person_verdict — the shared face-review verdict layer

Replaces person_face_suggestion with a table that holds both meanings the two
face features need, under one uniqueness constraint per (target, face):

  status='pending'                  the QUEUE   - a suggestion awaiting review
  status IN ('rejected','ignored')  the VERDICT - a durable 'not that person'

Keyed identity-first with the person/space-person target as a fallback, so one
rejection answers the question in personal scope AND in every space, and
survives a people merge without bespoke re-pointing machinery.

There is no 'confirmed' status. The positive verdict lives in
face_identity_face.source='manual', which every human reassignment already
writes; storing it twice is the duplicate-fact problem this table removes.
Confirm now just claims the queue row.

Targets are ON DELETE SET NULL (not CASCADE) so an identity-keyed verdict
outlives the person it was written against; the single-target check is
'<= 1' rather than '= 1' precisely so that SET NULL cannot make a person
DELETE fail.

Three never-deployed fork migrations collapse into one final-form migration.

* feat(server): unify both face engines on the shared verdict layer

Slice 3 (shared exclusion predicates) + Slice 5 (cleanup rewiring, lock
retired), landed together because they are one change to one seam.

- The Face Cleanup in-memory filter (applyDeclineFilters -> applyVerdictFilters)
  now consults the same three facts both engines share: a human placement
  (face_identity_face.source='manual'), a negative verdict (face_person_verdict),
  and a not-a-face tombstone. Verdicts match identity-first with a person/space
  fallback, via opaque target tokens so an identity-keyed 'not Anna' and a
  person-keyed suspicion of Anna resolve to each other.

- face_repair_lock is deleted. A human placement was already recorded by every
  reassignment as the manual identity link; the lock table was a second, weaker,
  non-merge-safe copy of that fact. Confirm/lock now re-affirms the link; an
  admin move (lock flag or not) settles the face the same way a user's confirmed
  suggestion does — which is what stops the two engines fighting over a face.

- face_repair_decline is narrowed to console-local cluster mutes only; its
  face-level 'keep here' rows are now shared verdicts the suggestion engine can
  see. The merge re-pointing machinery both facts needed is gone: identity keys
  and ON DELETE SET NULL make them merge-safe by construction.

- The resolutions page lists negative verdicts from BOTH engines (source +
  actor); un-confirming a placement is a per-person action that demotes the
  manual link back to 'ml'.

Closes leaks 1, 3, 4, 5, 6 from the design; leak 2 and hazard 8 are Slice 6.

* fix(server): exclude soft-deleted faces from search; clear manual links on bulk unassign

Slice 6 — the two source-of-truth safety fixes.

- searchFaces now filters asset_face.deletedAt (it previously filtered only
  asset.deletedAt). A 'not a face' tombstone must be honoured by every
  recognition/suggestion candidate, or the suggestion scan keeps writing pending
  rows for a crop an admin already declared not-a-face.

- unassignFaces ('reset all people') now clears face_identity_face manual links
  in the same operation. Otherwise a library-wide reset leaves every
  previously-confirmed face carrying a stale human-placement record with no
  person behind it, permanently excluding it from both engines.

* test(server): cross-flow integration + drain stale suggestions on cleanup move (leak 3)

Slice 7 — drives BOTH face engines against one database and proves each of the
original leaks is closed end-to-end, using a real embedding KNN cleanup scan so
'not flagged' means the shared verdict layer actually excluded the face:

  leak 1  confirm a suggestion -> cleanup scan does not re-flag it
  leak 2  detach 'not a face'  -> suggestion candidate search never returns it
  leak 3  cleanup move         -> no stale pending suggestion survives
  leak 4/5 user rejects F=Bob  -> cleanup no longer flags F toward Bob

Closing leak 3 required a write-path drain: executeRepair (batch) and
resolveFaces (interactive console) now both call
facePersonVerdictRepository.drainPendingForFaces for every reassigned/detached
face, so the never-reappear guarantee no longer rests on the suggestion read's
personId IS NULL filter — which would let a stale row resurface if the face were
later unassigned.

* feat(web): resolutions page lists negative verdicts from both engines; add un-confirm API

Slice 8 (partial) — the admin manage surface for the unified verdict layer.

- /admin/face-cleanup/resolutions is now a single list of NEGATIVE verdicts
  ('this face is not that person') from BOTH engines, with a source filter
  (admin cleanup vs user review), an actor column, and the space name for
  space-person verdicts. The locks section is gone: human placements are no
  longer rows, so listing them globally would be unbounded and meaningless.

- New POST /admin/face-repair/unconfirm downgrades a human placement's identity
  link from 'manual' back to 'ml' so a re-scan may flag the face again. This is
  the admin's answer to 'why did this face vanish from my queue' and will back
  the per-person 'skipped: human-confirmed' review-page section (deferred
  follow-up).

Regenerated the TS SDK and Dart client for the new resolution-item shape, the
verdictIds/clusterMuteIds remove request, and the un-confirm endpoint.

* test(e2e): update face-cleanup specs for the unified verdict model

- Resolutions page empty-state text and negative-verdict rows (source filter)
  replace the declines/locks sections.
- Lock-undo test becomes an un-confirm test through POST /admin/face-repair/unconfirm.
- Consistency specs assert the human placement as face_identity_face.source='manual'
  rather than a face_repair_lock row, and its merge-survival via the identity key.
- Suggestion e2e SQL seeds target the renamed face_person_verdict table.

* style: prettier on the hand-edited controller and e2e seed rename

* test(server): drop unused repo locals flagged by --max-warnings 0

* test(e2e): assert verdict/placement recovery via durable DB state, not a re-seeded scan

The keep-here and un-confirm e2e tests asserted re-flagging by seeding a SECOND
completed scan and reading getPersonFlaggedFaces — but two scans stamped now()
make getLatestScan's ordering nondeterministic, so the drained first scan could
win and the face would read as still-not-flagged. The re-scan-re-flags semantics
are covered deterministically by the medium tests (face-repair.resolutions +
face-review-cross-flow); the e2e now asserts the durable state the page's Undo /
the un-confirm endpoint actually produced (verdict row deleted; manual link
downgraded to 'ml').

* test(e2e): run the new keep-here scan-seeding test last in the serial suite

X1 (unmodified from #770) was silently SKIPPED in earlier runs because Playwright
serial-skips tests after a failure, so it never actually validated. Once keep-here
passed, X1 ran and failed with 0 flagged tiles: my keep-here test seeded a completed
scan BEFORE X1, which #770 never did (its pre-X1 test seeded a decline, not a scan).
The server handles this correctly (proven by a medium repro), but restoring #770's
order — X1 as the first scan-seeder, my new test last — removes the cross-test
scan-state coupling in the e2e stack.

* test(e2e): seed face-cleanup faces as ML-sourced, not manual

ROOT CAUSE of the X1 (and, via serial-skip, every other) face-cleanup e2e
failure: utils.createFace links each face with face_identity_face.source='manual'
(its shortcut for a full face->identity link). The unified verdict layer
correctly treats source='manual' as a HUMAN placement and excludes such faces
from cleanup flagging — so getPersonFlaggedFaces returned 0 and the review page
showed 'No flagged faces' (confirmed from the Playwright DOM snapshot).

A face a scan flags is by definition an ML attribution, never a human placement
(production only writes 'manual' on explicit human actions — face-editor
reassign, cleanup move/lock, confirm suggestion, merge). seedFlaggedScan now
downgrades its faces to source='ml' so they represent what a real scan flags.
Post-action assertions ('manual' after lock/move) are unaffected.

Verified with a medium repro: manual-linked flagged face -> 0 returned;
ml-linked -> returned.

* test(e2e): preserve manual source on durability re-seeds

The source='ml' downgrade must NOT apply to the Consistency X1/X2 durability
re-seeds: those simulate a later scan re-proposing a face that a prior
move-and-lock (X1) or lock+merge (X2) legitimately set to source='manual'. The
test asserts that placement keeps the face out of the review — downgrading it to
'ml' re-flagged it and broke the assertion. Add a preserveSource opt-out and set
it on the two re-seeds (personId=other / mergeTarget, after the human action).

* test(e2e): run keep-here test before X1 so it is the only resolution row

With source='ml' fixing the real regression, the earlier reorder (keep-here last)
is not only unnecessary but harmful: X1's 'stay' action leaves a persistent
cleanup verdict, so keep-here — running last — saw 2 rows on the global
resolutions page and its 'toHaveCount(0) after undo' found 1 left. Restore
keep-here to before X1, where no prior test has created a verdict, so it is the
sole resolution row and the undo drains the list to empty.

* chore(docs): spec the face verdict layer remediation

* fix(server): re-key face verdicts through merges instead of cascading them away

* fix(server): user face verdicts carry identity and actor; space rejects never no-op

* feat(server): suggestion engine consults the shared verdict layer

* fix(server): manual face placements survive merges, races, and backfill sweeps

* test(server): stub addPersonFaces in space-confirm unit spec (slice 3 follow-up)

* fix(server): byte-match face_person_verdict index overrides; drift gate covers everything

* fix(server): owner-only personal suggestion reads; pending queue honours asset state

* feat(server+web): admin face thumbnails; cleanup surfaces render non-owned clusters

* fix(web): suggestion modal paging/error truthfulness; admin pages fail loudly

Closes D8 (the suggestion modal walked fixed page offsets over a server list
its own actions drain, closing "complete" with roughly half the queue unseen;
act()'s bare catch made 500s indistinguishable from success) and D17 (snooze
baseline never rebased and was keyed per-person instead of per-user; modal
back-nav re-enabled already-acted rows; a failed initial load on the three
admin face-cleanup pages rendered as a reassuring empty state; the console's
bulk-apply skipped its refetch on partial failure).

The modal now always refetches page 1 (acted rows drain server-side, so the
head of the list is the only stable cursor), keeps a client actedFaceIds set
to skip just-acted rows the server hasn't settled yet and to render them
read-only on back-nav, and closes only once a fresh fetch confirms nothing is
left. act() surfaces every error via handleError except the server's one
documented already-resolved outcome (HTTP 400), which advances silently
without inflating the confirmed count. Snooze entries are now keyed by
userId:personId and rebase their baseline down on every read, so a genuinely
new suggestion resurfaces even below the original snoozed count. The three
admin pages gained a distinct loadError state (existing red-banner idiom,
with Retry) ahead of their empty-state branches, and the console's
fetchLatestScan() moved into handleApply's finally so the table always
reflects what the server actually did.

* fix(server): transactional confirm and drains; live dashboard counts

* test: close the verdict-layer coverage gaps found in review

Slice 10 Tasks 1-3: suite hardening (D15), hygiene (D16), and parent-doc
corrections for the face-review-unification remediation.

Suite hardening:
- Remove the `.catch(() => {})` swallow around confirmFaceSuggestion in the
  cross-flow spec; it passes green against the real DB unaided.
- Pin the getPendingForPerson band predicate at its exact boundaries:
  distance == maxDistance (excluded) and distance == suggestionMaxDistance
  (included).
- Assert a cleanup keep-here verdict stores both identityId and actorId when
  the suspected owner already has an established identity.
- Add space-person and fully-orphaned (SET-NULL-target) fixtures to the
  resolutions listing at both the medium-service and web-page layers.
- Fix the FK test that claimed CASCADE but asserted nothing distinguishing
  it from SET NULL; it now deletes the identity and asserts the verdict row
  survives, queried by its own id, with personId nulled.
- Scope the face-cleanup e2e keep-here assertion to the row the test itself
  created so `.serial` order no longer matters.

Hygiene:
- Retitle two stale test names that referenced the retired
  face_repair_lock/face_repair_decline tables.
- Fix the admin resolutions endpoint summary to describe what it actually
  lists.
- Fix the four mark*/mark*ForSpacePerson @GenerateSql param objects, which
  were single malformed object args instead of positional (id, id, opts)
  triples — the malformed shape silently dropped assetFaceId from the
  generated SQL docs.
- Delete 19 orphaned admin.face_cleanup_* keys from de.json/fr.json, absent
  from en.json and unreferenced in web/src, mobile/lib, e2e/src, server/src.
- Delete the dead private findOrCreateSpacePersonForFace in
  shared-space.service.ts (zero callers; only forwarded to
  findOrCreateCompatibleSpacePersonForIdentity).

Parent-doc corrections: append a "2026-07-23 corrections" note to the
design doc covering the unshipped face_repair_cluster_mute rename, the
omitted sixth fork migration, the two never-written coverage-matrix rows,
and the false merge-safety claim fixed by this remediation's own Slice 1.

* chore: regenerate openapi clients + sql docs for the verdict layer; prettier slice plans

* style(server): prettier the slice-3 follow-up + slice-10 hardening specs

* test(e2e): poll job queues with the admin token in the second-user face-cleanup test

* feat: manual face review mode — audit any person's faces without a scan (#838)

* docs: design spec for manual face review mode

Adds a second entry path to the admin face cleanup console: pick any
person and audit all of their faces, with no scan required. Reuses the
existing tile grid, bulk-select-then-apply interaction, and verdict
writes, so a manual decision is indistinguishable from a guided one.

Server work is narrow: relax guard E15 for lock/unknown/detach (keeping
stay scan-only, since it needs a suspected owner), add the eligibility
check that snapshot membership was implicitly providing, and add an
admin person-metadata endpoint for the name/ownerId the review page
currently reads off the scan.

* docs: revise manual face review spec after verification pass

Three independent verification passes against the code disproved the
first draft's central premise and five other claims.

The big one: manual mode cannot reuse the guided review page behind a
mode flag. FlaggedFace.suspectedOwnerId is required, every face defaults
to the 'owner' state, and buildResolveRequest dereferences it - so an
untouched manual review would POST moveToPerson with an undefined
destination for every face. There is also no neutral state (six terminal
states with a tested tally invariant), and the model is $derived over
the face array, so paging would wipe staged decisions.

The design now forks the review UI: a separate manual page under
/people/[personId] with its own view-model (keep default, stable across
appends, never emits stay), leaving all nine guided web specs untouched.

Also corrected: lock is a PRIMARY KEY upsert with DO UPDATE that can
steal a face from another identity (not an idempotent no-op via a plain
unique index - that claim came from a stale comment referencing the
long-gone insertLocks); FaceRepairRepository has no @GenerateSql so no
mise sql regen; the navbar needs no repointing; resolveFaces has no unit
coverage, only medium; and relaxing unknown converts one 400 into a
success with unknown:0.

* docs: chooser page design — equal-weight cards, first-visit and returning states

Pierre's review of the chooser design: a first-time admin has never
scanned, so the page needs a distinct first-visit presentation, and the
two modes should carry equal weight since some admins will live in
manual review rather than triaging scans.

Equal-weight cards with no recommended badge. First visit gets an
explanatory header and advertises that manual review needs no scan —
it is fully usable on a brand-new instance, which the returning
status-board layout would otherwise obscure. Adds the full card state
matrix (never-scanned / running / flagged / zero-flagged / failed),
including the manual card being disabled during a scan, which is the UI
half of the resolveFaces 409 guard and prevents staging dozens of
decisions only to lose them to a conflict.

Slices renumbered 1-26 to cover the two chooser presentations and the
remaining card states separately.

* docs: manual review page design; consolidate 26 slices into 12

Folds the manual review page design into 6.4. The key point is a visual
inversion that falls out of the data model: guided stamps every tile
because every face holds one of six terminal states, while manual
defaults to keep, so tiles start clean and colour appears only where the
admin acted. keep needs no colour token - absence signals it, which
satisfies the existing rule that state is never encoded in colour alone.

Two interaction problems get resolved. Selection cannot claim the whole
cluster over server paging, so it always means loaded faces (Select all
loaded (N), showing N of M) and whole-cluster work goes through the
server-enumerated entireCluster with its own destination picker. And a
keep default needs an undo, so Unmark returns a selection to keep -
guided never needed one because every face is already stamped.

Slices consolidated 26 -> 12 on one test: can it ship alone without
leaving the tree broken or dead? The route move now carries its own e2e
repair (the move alone leaves CI red), the endpoint carries its SDK
regen (no caller otherwise), the eligibility read joins the lock slice
it exists for, and the chooser's three slices collapse into one
component with one state machine. The pure view-model stays separate
despite being small - highest logic risk on the branch.

* feat(server): allow locking any eligible face on a person, not only flagged ones

Manual review needs to lock a face that no scan flagged, so lock is
dropped from the E15 flagged-snapshot gate. stay keeps that gate: it
writes a negative verdict against the face's SUSPECTED owner, read from
the snapshot via a non-null assertion, so without a snapshot row there
is no owner to record against.

Lifting the gate for lock is not merely permissive - it removes a live
guard. getScanFlaggedFacesForPersons INNER JOINs asset_face and
re-validates personId/deletedAt/isVisible at snapshot-read time, so
today a foreign or deleted id can never reach the write path. Dropping
the gate without a replacement was verified to be exploitable: with lock
removed and no eligibility check, resolveFaces returned locked:1 for a
face on another person, for another USER's person, and for a
soft-deleted face - silently re-pointing them onto this person's
identity through replaceFaceIdentities' unscoped ON CONFLICT DO UPDATE.
A nonexistent id crashed with an unhandled FK violation rather than a
400.

getEligibleFaceIdsForPerson replaces that guard explicitly, mirroring
getClusterFacePage's predicate so lockable == listed on the manual
review page. It is advisory - the write-time guards remain
authoritative - and exists so an inapplicable lock is a 400 rather than
a silent no-op or a crash.

Tests: 66/66 in the resolve medium spec, incl. the cross-tenant arm.

* feat(server): allow detach and unknown on any face of a person, not only flagged ones

Completes the E15 relaxation: stay is now the ONLY snapshot-gated
bucket. It keeps that gate because it writes a negative verdict against
the face's SUSPECTED owner, read from the snapshot via a non-null
assertion - with no snapshot row that yields undefined and a 500/FK
violation.

Unlike lock, detach and unknown need no eligibility read: both are
already person-scoped at the write layer (detachFaces filters personId
and keys its identity-strip on the RETURNING output; reattributeFaces
filters still-on-source). That claim is now pinned by test rather than
trusted - a foreign face passed to detach is an inert no-op, left
completely untouched.

Behaviour change (spec 5.4): a stale unknown id - a face that left this
person since the scan - previously 400'd via the gate. It now returns
success with unknown: 0, truthfully reporting zero parks, and the
freshly created cluster is cleaned up rather than orphaned.

Tests: 72/72 in the resolve medium spec, 6/6 cross-flow.

* feat(server): add admin person metadata endpoint for manual face review

GET admin/face-repair/person/:personId returns
{id,name,ownerId,faceCount,thumbnailFaceId}, admin-gated, 404 on
unknown.

The manual review page cannot get these from anywhere else. The guided
page reads personName and ownerId off getLatestScan(), and ownerId is
what scopes the move-picker - so with no scan the picker cannot open at
all. The user-scoped GET /people/:id does not admin-bypass for a person
the admin does not own, the same gap already fixed once for face
thumbnails.

faceCount reuses searchOwnerPeople's exact join conditions so the count
agrees between the browser grid and the review page header; a mismatch
there would read as a bug. The name is returned raw - an unnamed person
yields an empty name and the client applies its own fallback rather than
the server inventing a display string.

Ships with the client regen because the endpoint has no caller until
then. No @GenerateSql was added (this repository carries none) so no
mise sql regeneration; mise.lock verified unchanged.

Tests: controller 76/76, metadata medium 6/6, tsc clean.

* refactor(web): move the guided face-cleanup dashboard to /admin/face-cleanup/scan

Frees /admin/face-cleanup for the mode chooser. Pure relocation of the
ten-file dashboard cluster via git mv - no behaviour change - plus a
temporary 307 redirect at the old path so this lands without a dead
entry point. The chooser replaces that redirect next.

The navbar is deliberately NOT repointed: NavbarItem highlights via
pathname.startsWith(href), so pointing at /admin/face-cleanup keeps it
active across /scan, /people and the chooser. Repointing it would be
the regression, not the fix.

Four e2e assertions broke on the move. Three are repoints; Playwright
globs match the whole URL, so '**/admin/face-cleanup' does not match
/scan. The fourth needed a real fix rather than a repoint: the
post-apply drain check asserts a person's name is ABSENT, which passes
vacuously on any page that never lists people - or on one that simply
has not finished loading. It now waits for the Eligible faces stat,
which renders unconditionally once a scan is completed and is therefore
independent of the drained-vs-not condition under test.

Tests: 117/117 across the nine guided specs from their new locations,
tsc clean.

* feat(web): add the face-cleanup mode chooser

Replaces slice 4's temporary redirect. Two EQUAL-WEIGHT cards with no
recommended badge on either: we do not know which mode a given admin
lives in, and privileging guided would penalise anyone who spends their
time in manual review.

Two distinct presentations rather than one degraded into the other.
First visit reframes the difference as readiness, not importance -
guided says it needs a scan first, manual advertises that it needs no
scan and can start right away. That line matters: manual review is fully
functional on a brand-new instance, and the returning status-board
layout would otherwise hide that behind an empty guided card, leaving a
first-time admin thinking the feature does nothing until a scan
finishes.

The manual card is disabled while a scan runs. This is the UI half of
resolveFaces' 409 guard: without it an admin can stage dozens of
decisions across a server-paged cluster and lose them all on Apply.
Disabled means genuinely unactivatable - no href and a real
<button disabled>, so keyboard cannot reach it either - not just
opacity. Only pending/running block it; failed and zero-flagged leave
manual reachable.

Visual vocabulary is reused verbatim from the dashboard - card shell,
dot+label+tabular-nums stat rhythm, semantic colours - so this reads as
the same product rather than a bolted-on page.

Tests: 13 new chooser cases, 130/130 across the face-cleanup suite,
tsc clean.

* feat(web): add the manual face-cleanup people browser

Builds the destination the chooser's manual card links to. Zero new
server endpoints: the owner list reuses searchUsersAdmin (the same call
the guided dashboard already makes) and the people grid reuses
getFaceRepairOwnerPeople, whose query parameter is optional - so
omitting it lists all of an owner's people, paginated.

Face crops go through the admin-gated, join-free face-thumbnail route,
NOT the user-scoped /people/:id/thumbnail, which 404s for people the
admin does not own. A null thumbnailFaceId renders a real placeholder
rather than a broken image.

Switching owner resets the page and clears the list, with a request
token guarding against a stale response clobbering a newer selection -
otherwise rows from two owners interleave. Pagination appends rather
than replaces.

Four states are kept distinct: this owner has no people, no results for
this query, load failed, and no users at all. Conflating empty with
error was defect D17 on the guided page.

No client-side filtering is applied to what the endpoint returns; that
passthrough is pinned by test so a later server-side change surfaces
here rather than being silently masked.

Tests: 14 new cases, 144/144 across the face-cleanup suite, tsc clean.

* feat(web): add the manual face-review view-model

A separate pure model rather than a reuse of the guided one, because
the guided model cannot express manual review's semantics and two of
its properties are actively dangerous here.

No neutral state: guided's FaceState is six TERMINAL states and every
face initialises to 'owner', with buildResolveRequest dereferencing
suspectedOwnerId for it. Pointed at a scan-free cluster an untouched
review would POST a move for every face to an undefined destination.
Manual defaults to keep, which produces no bucket entry at all, and an
all-keep review builds to null rather than an empty request - so a
disabled Apply cannot be bypassed and the server never sees the empty
resolve it would 400.

State survives paging: guided rebuilds its model from a $derived over
the face array, so appending a page discards staged decisions. This
model owns its list and exposes appendFaces, which preserves both
states and selection and is idempotent on assetFaceId. Manual is
server-paged, so that defect would have been hit on the first Load
more.

stay is hardcoded [] on every request - manual can never emit it, since
it needs a suspected owner. Two @ts-expect-error guards make owner/stay
a compile error rather than a convention; they are load-bearing, since
tsc reports an unused directive if the type ever stops rejecting them.

Colour/icon tokens are imported from the guided model so one glyph means
one thing across both pages; keep deliberately has no token - it is
signalled by absence.

Tests: 26 cases, 170/170 across the face-cleanup suite, tsc clean.

* feat(web): add the manual face-review page with server-paged cluster faces

Opens any person with no scan in existence and lists ALL of their
faces, via the scan-free cluster endpoint with an empty excludeFaceIds.

Implements the visual inversion this page is built around: guided
stamps every tile because every face holds one of six terminal states,
so its grid is a wall of colour to audit. Manual defaults to keep, so a
tile renders a clean crop with no badge and no ribbon, and colour
appears only where the admin acted. keep needs no colour token - it is
signalled by absence, which keeps the existing rule that state is never
encoded in colour alone without inventing a seventh swatch.

Selection is honest about server paging: it always means LOADED faces,
labelled Select all loaded (N) with showing N of M in the header. An
unqualified select-all would either lie about a cluster of thousands or
force loading all of it; whole-cluster work goes through the
server-enumerated entireCluster instead (slice 10).

Load more appends through the model rather than re-creating it, so
staged marks and selection survive - the defect that made reusing the
guided model impossible, and the first thing a paged cluster would have
hit.

Person name and ownerId resolve from the URL param through the metadata
endpoint rather than navigation state, so refresh and deep links work.
Empty and load-error are kept distinct (conflating them was D17 on the
guided page), and a person the scan flagged shows no flagged badging -
manual ignores scan state entirely.

Tests: 15 new cases, 185/185 across the face-cleanup suite, tsc clean.

* feat(web): add manual review bulk actions and apply

The footer dock: Move to person (via the reused PersonPicker, scoped by
the ownerId the metadata endpoint supplies), Lock, Unknown, Not a face,
and Unmark - plus the staged-work tally and Apply.

Unmark exists because manual defaults to keep. Guided never needed one:
every face there is already stamped, so the admin only ever swaps one
stamp for another. Here marking is a deliberate act, so mis-marking
needs reversing without discarding every other staged decision via
Reset.

stay and owner are not offered and can never be emitted - both need a
suspected owner. The payload assertion pins stay: [] explicitly across
mixed buckets.

Apply is disabled while everything is keep, because buildResolveRequest
returns null and an all-keep POST would be the empty resolve the server
400s. Not a face keeps guided's destructive confirm, and declining it
posts nothing.

A 409 - a scan starting mid-review - surfaces without discarding staged
work. That conflict is what the chooser's disabled-manual card exists to
prevent; if it happens anyway the page must not compound it by throwing
away decisions staged across a paged cluster.

Tests drive the real UI rather than the model. 16 new cases, 201/201
across the face-cleanup suite, guided's 73 specs untouched, tsc clean.

* feat(web): add entire-cluster move and the manual actions help modal

Move entire cluster uses the server-enumerated entireCluster rather than
selection, which is the whole reason it belongs here: on a server-paged
page selection can only ever cover LOADED faces, so whole-cluster work
must not route through it. Unlike guided - where entireCluster rides the
scan's suspected owner - manual has none, so it requires an explicit
destination via PersonPicker, and it posts with no per-face buckets
since the server rejects combining them. It carries its own confirm
because it moves faces the admin has never seen, which is both the point
and the risk.

Manual gets its own help modal rather than extending guided's, whose
names all six actions test is load-bearing and stays untouched. The
modal leads with the thing most likely to confuse someone arriving from
guided: keep writes nothing. It also states that Not a face is the
irreversible one and sits beside Unknown, which means the opposite - bin
this crop versus this is a real person I cannot name.

Swatches are asserted against the tile tokens so an explanation ties
back to the button and the tile it describes, and keep renders no
swatch at all - absence is how the default is signalled.

Tests: 21 new cases, 222/222 across the face-cleanup suite, guided's
specs untouched, tsc clean.

* test(e2e): cover the manual face-review flow and its cross-engine invariant

Two tests. The first drives the whole manual path against a person with
NO scan in existence: chooser to people browser to review page, listing
all faces rather than a flagged subset, then move, lock and not-a-face
through the real UI, asserting durable DB rows rather than UI text.

The second is the point of the feature: a later scan must honour a
manual decision exactly as it honours a guided one - the locked face is
not re-flagged, the detached face stays gone, the moved face is not
re-proposed.

Two seeding traps handled. utils.createFace links faces with
source='manual', which the verdict layer excludes from flagging, so
every face here is downgraded to 'ml' or it would not behave like a
machine-clustered one; and face_search rows are required because both
the cluster listing and lock eligibility inner-join it.

The cross-engine test seeds ONE multi-person scan rather than two
sequential ones: two scans stamped the same now() make getLatestScan
nondeterministic, which would let the invariant assertions pass
vacuously instead of for the right reason.

* docs: document guided and manual face cleanup modes

Adds a Two ways to clean up comparison and a Manual review section to
the admin face cleanup page.

The two points worth documenting explicitly are the ones an admin
cannot infer: that faces you leave alone are deliberately NOT recorded
(marking everything you glanced at as human-verified would stop future
scans flagging it and hide real mistakes, so Lock is the opt-in), and
that Select all covers the faces currently loaded rather than the whole
cluster - with Move entire cluster as the server-resolved alternative.

Also notes that manual review is unavailable during a scan, so the
behaviour reads as deliberate rather than broken.

* fix(web): keep translation-key literal types in the manual review tally

A Record<..., string> annotation widened each value to string, and $t
only accepts known translation keys, so the tally lookup stopped
type-checking. satisfies keeps the exhaustiveness check on the keys
while preserving the literal value types.

Caught only by CI's check-svelte: that task reports 0 FILES locally in
this worktree - even when invoked through mise exactly as CI does - so
neither tsc nor the local svelte-check surfaced it.

* fix(web): build the manual tally translation key with a template literal

satisfies was not enough: indexing an object of whole translation keys
still yields string at the call site, and $t accepts a template-literal
type but not string. Matches the guided dock's idiom exactly -
$t(`admin.face_cleanup_review_tally_${...}`) - by mapping only the key
SUFFIX, with move reusing guided's existing 'other' key rather than
adding a near-duplicate.

Only CI's check-svelte catches this; that task scans 0 files locally
even when invoked through mise as CI does.

* style(web): redesign the face-cleanup mode chooser

Rework the two-door chooser at /admin/face-cleanup into a considered design: a 48px icon tile anchors each mode, guided keeps the primary accent while manual gets a distinct teal identity (neither marked recommended), and each card carries a tinted status well that recolours across all five scan states with the live count as a hero number. Actions become full-width and foot-aligned, with a soft corner glow, inset ring, and hover lift for depth.

Logic, copy, i18n keys, data-testids and the genuinely-disabled manual guard (§7) are unchanged; all 13 chooser page-spec tests still pass, eslint/prettier/svelte-check clean.

* feat(web): infinite-scroll the manual face-review lists

Replace the "Load more" buttons on the manual people browser and the
selected-person review grid with scroll-driven pagination, so both lists
grow as the admin scrolls instead of dead-ending on a button.

Adds a reusable InfiniteScrollSentinel primitive (a sentinel-only port of
people-grid's IntersectionObserver + visibility-fallback logic; people-grid
itself isn't a drop-in where items aren't keyed by `id`). The review grid
keeps appending through vm.appendFaces, so staged marks and selection still
survive each page load.

* docs: spec the face-cleanup scan two-lane triage redesign

* docs: sliced impl-loop plan for the scan two-lane redesign

* feat(web): add exclusion-set selection model for scan triage

* feat(web): add ConfidentLane with approve-all + spot-check exclude

* feat(web): add ReviewFirstLane clickable review list

* feat(web): two-lane triage scan page; drop checklist/table/filter/selection UI

Compose ConfidentLane (approve-all + spot-check exclude) and ReviewFirstLane
(clickable rows) in the scan page, driven by the exclusion-set triage model.
Removes the 3-step checklist, 5 stat cards, filter tabs, and the selection bar;
the two non-actionable totals become quiet footnotes. Deletes ScanChecklist,
FaceCleanupTable, and the old face-cleanup selection model, and prunes their
now-dead i18n keys.

* docs: note the scan-redesign plan corrections applied during impl-loop

* test(e2e): key the scan-drain wait off the header summary, not the removed stat card

The two-lane scan redesign removed the "Eligible faces" stat card the X1 test
used as its "completed snapshot loaded" signal; use the header summary line
("… flagged faces across … people"), which renders on any completed scan.

* fix(web): tidy the review-lane rows — one primary reason pill, no overflow

Collapse the wrapping multi-pill reason column to a single primary reason
(bad-target wins, else the first) plus a "+N", narrow the flagged column, and
share one absolute right-slot between the chevron (default) and the hover
dismiss so the row can no longer widen past the container.

* feat(web): enlarge the guided-review bulk action bar

The routing buttons were text-xs with 13px icons on the dark dock and read as
small/hidden. Enlarge them (text-sm, 16px icons, roomier padding, a defining
inset ring, rounded-lg), tint the destructive "not a face" red to set it apart
from the routine routes, and bump the selected-count and Clear.

* docs: spec for face-cleanup console UX fixes (clickable auto-fix chips, review-lane alignment)

* docs: implementation plan for face-cleanup console UX fixes

* docs: add column-content regression guard to the face-cleanup UX plan

* fix(web): align the review-lane columns and give them headings

* style(web): prettier fallout on the review-lane changes

* feat(web): make auto-fix spot-check chips open the per-cluster review page

* fix(web): persist confident-lane spot-check exclusions across chip click-through

* fix(web): default manual-review owner picker to the admin's own account and persist the selection

Browse People defaulted to whichever owner sorted first alphabetically and reset
to that default every time the page remounted (e.g. after reviewing a person and
navigating back), instead of the admin's own account and the last owner picked.

* fix(web): leave the manual review page once its cluster is empty

Moving/parking/detaching the last faces out of a cluster left the admin on a
dead page, and for an unnamed cluster it stacked a 404 error toast directly on
top of the success toast: resolveFaces DELETES an emptied, never-named person
("Empty-unnamed cleanup" in face-repair.service.ts), so the unconditional
post-apply refresh chased a person that no longer existed.

commitResolve now returns to the manual review list whenever the refresh comes
back missing (deleted) or with zero faces (a named cluster survives the same
emptying) — success toast intact. loadPersonData grew an `allowMissing` flag so
only that caller treats a 404 as "the cluster is gone"; mount and Retry still
surface it as a load failure.

Also fixes the refresh itself: it went through appendFaces, which is idempotent
by assetFaceId, so after a PARTIAL apply the grid kept rendering the faces the
resolve had just moved away. The model grew clear(), and a refresh now replaces
what the page holds instead of merging into it — only the scroll sentinel
appends.

* feat(i18n): translate the face review UI into all nine supported locales

The unified face review branch added 241 keys to en.json, but only German
(168) and French (101) had any coverage — every other supported locale had
just the 12 space-album keys. That is why the admin console rendered half in
German and half in English.

Fills the gap for all nine locales the fork translates (de, es, fr, it, nl,
pl, ru, zh_Hans, zh_Hant), so each is now at 241/241:

- de +73, fr +140, and +229 each for es/it/nl/pl/ru/zh_Hans/zh_Hant
- reuses the terminology already established per locale (Gesichtsbereinigung /
  Nettoyage des visages / cluster→Cluster, groupe, grupo, gruppo, 聚类, 叢集)
- Polish and Russian use one/few/many/other plural forms; Chinese mirrors the
  existing one/other convention
- corrects one German string that drifted from its English source
  (face_cleanup_resolutions_empty)

ICU placeholder parity with en.json verified for every key in every locale.

* docs(spec): design the face-suggestion opt-in toggle

Face suggestions are currently enabled by a sentinel (suggestionMaxDistance
above maxDistance) and still need a manual job run, so every step of turning
them on fails silently. Specs an explicit nested suggestions.{enabled,maxDistance}
config, one isFaceSuggestionEnabled helper in place of eight scattered guards,
save-time validation of the band, an auto-queued scan on the enable transition,
and a load-time fold so instances already running the feature survive the key
rename whether configured via the database or a config file.

* docs(plan): implementation plan for the face-suggestion opt-in

Nine tasks off the approved spec. Also amends the spec with a defect found
while planning: because disabling now retains a valid distance band, the
repository band short-circuits no longer imply the feature is off, so all
three suggestion read paths need their own enablement guard.

* feat(server): explicit suggestions.enabled config with one enablement helper

Replaces the suggestionMaxDistance sentinel with a nested
suggestions.{enabled,maxDistance} object and routes all eight guards plus the
three read paths through isFaceSuggestionEnabled. The read-path guards are
load-bearing: disabling now retains a valid band, so the repository
short-circuits no longer imply the feature is off.

* test(server): pin the reachable suggestions.enabled=false toggle, not just an invalid band

Five "feature disabled" fixtures used suggestions.maxDistance: 0, a config
state the DTO's min(0.1) bound makes unreachable in production. Flips them to
suggestions.enabled: false with a valid band so the tests exercise the actual
toggle four guard sites are meant to honour, and adds an enabled:false row to
the two updateSpacePerson/backfillSpacePersonMetadata table-driven tests.
Retitles a test whose name still referenced the removed suggestionMaxDistance
field.

* fix(server): fold the legacy suggestionMaxDistance key into suggestions

Unknown config keys only warn and are then dropped, so the rename would have
silently disabled face suggestions on every already-configured instance. The
fold runs on the partial before it merges over defaults, covering both database
and IMMICH_CONFIG_FILE sources.

* test(server): cover the fold's 0.1-minimum and equality boundaries

Review flagged missing coverage for the two load-bearing boundaries in
foldLegacyFaceSuggestionConfig: the >= 0.1 schema-minimum threshold, and the >
comparison's equality point (both against the default and an overridden
maxDistance). No production code changes; the implementation was already
correct at both boundaries.

* feat(server): reject a face-suggestion band that can never match

An enabled band at or below the recognition distance selects nothing. Refusing
the save replaces the old failure mode where the setting saved cleanly and did
nothing.

* feat(server): queue the face-suggestion scan when the feature is enabled

Enabling the toggle now starts the first scan itself instead of silently
requiring a second trip to the Jobs page.

* test(server): expand onConfigUpdate coverage to exercise all transition paths

Added 4 new test cases and generalized onConfigUpdateTestConfig to vary all
relevant config parameters (recognition distance, suggestions distance, flags).
Tests now verify:
- Band widening while enabled does not re-queue (transition gate works).
- Enabling suggestions with ML or FR disabled does not queue (multi-flag logic).
- Invalid band becoming valid does trigger a queue (full helper logic tested).

* chore(open-api): regenerate clients for the nested suggestions config

* feat(web): explicit face-suggestions toggle in admin settings

Enabling auto-fills the distance from the recognition distance so the number
field stops being the hidden on/off switch.

* fix(web): gate face-suggestions auto-fill effect on config-file mode

The auto-fill effect had no `disabled` guard, so opening the settings page in
read-only config-file mode could silently rewrite the displayed suggestion
distance if a config-file-sourced config already violated the invariant
(config-file configs are only schema-validated on boot, not cross-field
validated). Also tones down "one-tap review" to "quick review" for a desktop
admin page, and adds a unit test for the auto-fill effect since check:svelte
has no signal on this machine.

* feat(i18n): translate the face-suggestions toggle into all nine locales

* test(e2e): drive face suggestions through the explicit toggle

Adds the enabled-false-with-valid-band case, which the repository band
short-circuits cannot catch.

* docs(facial-recognition): document the face-suggestions toggle

* fix(i18n): align face-suggestion terminology with each locale's established term

* test(server): split the table-driven suggestion-scan specs into it.each

should not queue suggestion scans for non-name edits, ... looped 8 cases
inside one shared 5s test budget; under full-suite parallelism this
intermittently exceeded vitest's default testTimeout, and a failure couldn't
say which case broke. Splits it (and its backfillSpacePersonMetadata sibling)
into one independent it.each test per case, each with its own name and its
own timeout. Same cases, same assertions, no behavior change.

* fix(server): don't let the ML master switch disable face suggestions

Face suggestions are a pure vector query over embeddings that already exist and
never call the machine learning service, so gating them on isFacialRecognitionEnabled
(ML master switch AND facial recognition) disabled a feature that still works fine
with the ML container off. Turning it off to reclaim resources after a library is
scanned is a supported configuration — the e2e stack itself runs that way, which is
how this surfaced. Gate on facialRecognition.enabled alone.

* fix(face-cleanup): apply a resolve on a cluster with more than 1000 flagged faces

The resolve DTO capped every face array at 1000 on the assumption that a
selection that large always goes through `entireCluster`. A real 2952-face
cluster flagged 2382 faces toward one owner — a SUBSET, which `entireCluster`
cannot express — and the client emits one move group per (destination, lock),
so Apply 400'd with no payload the admin could ever make valid.

Raise the face bound to 25 000 (~1 MB against a 10 MB body limit) and give
group/person/owner arrays their own 1000 bound, so a higher face ceiling does
not also permit 25 000 destinations. Two paths would have turned that 400 into
a timeout instead of a fix: `drainPendingForFaces` built an unchunked IN-list,
and the "keep here" bucket wrote one round-trip per face — both are now chunked
set-at-a-time writes.

The banner also said only "could not be applied", so a permanent failure read
exactly like a transient one and sent admins into retry loops. Resolve failures
an admin can actually hit now carry a stable reason code, and the page renders a
translated explanation for each (all nine locales) — falling back to the server's
own text rather than to silence.

* fix(web): type the resolve reason-code map as Translations

`$t` is narrowed to the key union derived from en.json, so the map's
`Record<string, string>` widened its values out of that union and svelte-check
rejected `$t(reasonKey)`. Typing the values `Translations` also turns a typo in
one of these keys into a build error instead of a raw key rendered at an admin.

Caught only in CI: `check:svelte` scans 0 files locally in this worktree, so it
is effectively a push-only gate. Verified instead by asserting the four keys
against the union directly, with a deliberately wrong key as the control.

* docs(face-cleanup): design for giving the review destination an identity

The review page receives thumbnailFaceId per suspected owner and renders a
bare name, so a suggestion pointing at "Unbenannter Cluster" is unactionable:
no thumbnail, no size, no way to go and look at it. Three defects share the
root cause — the page reads one field off suspectedOwners[0].

Design: two overlay-only fields (ownerFaceCount, ownerMissing) filled by the
read-time overlay that already refreshes names/thumbnails; a destination card
per suspected owner with a new-tab link to the manual review page; a real
destination chooser for the two bulk actions that hardcode [0] today and
silently mis-route faces attributed to a secondary owner.

* docs(face-cleanup): settle C2-C5 and restructure the spec around TDD slices

Review of the first draft found a runtime blocker and four undecided
behaviors, all now resolved:

- the overlay sketch dropped the bigint->number conversion count() needs
  (getPersonMetadata already does it); ownerFaceCount would have failed
  z.number() at runtime
- C2: the reviewed cluster's faceCount goes live too, from the same
  aggregate. flaggedFraction is denominated on frozen `eligible`, so
  nothing desyncs
- C3: picking the reviewed cluster as its own destination disables both
  bulk actions with a reason, rather than filtering the paginated picker
  or failing server-side after Apply
- C4: destinations whose person row is gone are omitted from the chooser
  and skipped when defaulting; the card still explains why
- C5: staged rest faces follow a destination change, and the dock chip
  now names where they are going

Also records the load-bearing ordering between withCurrentNames and
withLiveFlaggedCounts (the latter's spread is what carries the new fields
through), and replaces the flat test list with five red-first slices in
the behavior register page.spec.ts already uses.

* docs(face-cleanup): implementation plan for the review destination identity

* feat(face-cleanup): report a suspected owner's own face count and whether it still exists

* test(face-cleanup): pin the overlay ordering the destination fields depend on

withLiveFlaggedCounts rebuilds every suspectedOwner from withCurrentNames'
output; its `{ ...owner }` spread is the only reason ownerFaceCount/
ownerMissing survive to the client. Add a regression test plus comments
at both call sites so a future refactor can't drop this silently.

* fix(face-cleanup): correct two stale comments from the review pin

The test's inline comment still claimed reordering the two overlay
passes drops the fields — disproven by hand in task-2 review. Also fix
a repository comment referencing getLatestScan (repository method)
where the chaining actually happens in the service's
getLatestScanStatus.

* feat(face-cleanup): render each suggested destination as an identifiable cluster

* fix(face-cleanup): give the no-thumbnail destination test a real assertion

* feat(face-cleanup): let the admin choose where whole-cluster moves send faces

* fix(face-cleanup): stop the destination chooser from misrepresenting state

- Move "Choose someone else…" out of the <select> into a sibling button: an
  <option>'s value commits to the select natively before any handler runs,
  so a dismissed picker left the control reading the placeholder forever
  and re-selecting the same option fired no further change event in a real
  browser. A button never touches the select's value, removing the bug and
  the double-open risk by construction.
- Render an extra <option> for a destination chosen outside the scan's own
  suggestions, so picking an unlisted person no longer renders the select
  blank.
- Gate rest-tile staging on canBulkMove, and clear any already-staged rest
  faces the instant the destination becomes unusable, so the ribbon/dock
  chip can never name a destination Apply will not honour.
- PersonPicker: report lock:false when the re-flag toggle is hidden, rather
  than echoing its unreachable default.
- Rename a handler parameter that shadowed the module-level ownerPersonId.

* fix(face-cleanup): keep staged rest faces on an unusable destination, block Apply instead

The project decided earlier that changing destination must never discard
staged rest faces (a mis-click should not destroy real work). The prior
auto-clear effect only fired on a valid->invalid switch, but the realistic
trigger for that is exactly the mis-click case the decision covers, and the
discard was silent.

- Remove the clearing effect; staged rest faces survive an unusable
  destination unconditionally.
- Rest-tile staging is now add-only gated: deselecting a face always works,
  only newly staging one is blocked while canBulkMove is false.
- Block Apply (disabled + inline reason) while faces are staged for a
  destination that can't be used, rather than quietly dropping them.
- Stop naming a destination that won't be used: the dock chip, the selected
  rest-tile ribbon, and the rest-section hint all fall back to an unnamed
  variant while canBulkMove is false.
- Disable + dim rest tiles that can't be newly staged, and explain why in
  the same slot as the self-move warning (a distinct "pick a destination
  first" message for the no-destination case).

* fix(face-cleanup): show the destination's own size in the dashboard, not its routing share

The dashboard's review-first lane printed dest.count -- the flagged faces
routing to a destination -- under its name, so a destination holding
thousands of faces displayed as "1 faces". Swap in ownerFaceCount for the
size, move the routing share into the row's tooltip, and mirror the new
suspected-owner fields (ownerFaceCount, ownerMissing) into every hand-written
fixture/type that shadows the server's FaceRepairScan suspected-owner shape.

* test(face-cleanup): tighten the destination tooltip assertion so a size/share swap turns it red

The tooltip test used expect.stringContaining('20'), and (1204).toLocaleString() is
"1,204" -- which contains "20" as a substring. So the exact regression this task
exists to prevent (rendering ownerFaceCount where count belongs), relocated to the
tooltip, still passed. Give count and ownerFaceCount non-overlapping digits (7 vs
1204) and assert both the literal routing-share substring and the absence of the
destination-size number.

* feat(i18n): translate the face-cleanup destination card into all nine supported locales

Tasks 1-5 rebuilt the destination card in English only. The admin who reported
the original problem reads this console in German, so shipping the English
strings alone would leave the reported bug half-fixed for the reporter.

Adds the 16 new admin.face_cleanup_review_* keys to de, fr, es, it, nl, pl, ru,
zh_Hans and zh_Hant -- the nine locales that already carry this console -- and
updates the two edited ones:

- banner_body drops its leading "Default is -> {ownerName}." sentence, which no
  longer has a placeholder to substitute. Leaving it made placeholders.spec.ts
  red for all nine locales; that was the red phase for this change.
- tally_ad…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

changelog:feat Feature change for changelog documentation Improvements or additions to documentation rc Auto-build a release-candidate server image and post it on the PR 🗄️server 🖥️web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant