Conversation
Deeds67
force-pushed
the
feat/dissolve-person
branch
from
September 11, 2026 19:28
48c4688 to
a004c51
Compare
Deeds67
force-pushed
the
feat/dissolve-person
branch
2 times, most recently
from
September 19, 2026 21:56
ec3186f to
5ed8aa0
Compare
Users report people that accumulate thousands of faces that are not that person, often crops of non-person objects, with no way to repair them. The cause of the dead end is that every existing repair tool is scoped to machine-learning faces that carry an embedding: force recognition only unassigns ML faces, the recognition fan-out filters on sourceType, and every face-repair query inner-joins face_search. Faces imported from file metadata satisfy neither condition, so they are invisible to the scan, manual review and "Reset all people" alike, and can never gain an embedding. Deleting the person makes it worse -- personId is SET NULL, so the rows outlive the person as invisible orphans. The design adds a source-aware dissolve to the admin console along two axes (which faces, what happens to them) behind a mandatory preview, and repairs by clearing asset_job_status.facesRecognizedAt so the ordinary non-forced detection pass re-processes exactly the affected assets. Needs no migration: all six referencing foreign keys already cascade. Records a blast-radius analysis of ten ways the operation could reach data outside the target person, each with a named test. Two are existing unscoped helpers that must not be called from a person-scoped action: deleteAllOrphanedPersons deletes orphaned space persons instance-wide, and PersonCleanup deletes faceless people library-wide. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
Verifying the spec's own claims against the code turned up three defects in it. The "repaired on the next run" claim was over-broad. streamForDetectFacesJob runs through assetsWithPreviews(), which requires a non-hidden, non-trashed asset with a Preview file, and handleDetectFaces gates again on the file count and on visibility. On assets failing those checks the dissolve would delete the junk and recover nothing, so the preview now counts them and warns rather than promising a repair. Soft-deleted faces are not merely stale, they are harmful. The faces subquery feeding handleDetectFaces has no deletedAt filter, so a tombstoned face still matches a fresh detection, absorbs the embedding and stays soft-deleted -- swallowing the real face. Hard-deleting them in delete outcomes is therefore required, not tidiness. Nothing stopped a dissolve from deleting faces underneath a running recognition pass, though the scan path already refuses that. Same guard. Also adds a slice for the preview endpoint, which had test coverage only as a side effect of other slices despite being the entire safety mechanism for an irreversible operation, and records the sql/open-api regeneration gates. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
…predicate The first test only covered person-scoping (DissolveScope.All excludes nothing but pet faces, and no pet face was seeded), so an implementation that dropped the scope predicate clause entirely would have passed identically. Add a case that seeds an exif face and a machine-learning face for the same person and asserts DissolveScope.Exif clears only the exif asset's watermark. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
Adds the shared blast-radius fixture (P1 target, P2 same-user bystander sharing an asset, P3 pet, P4 other user, P5 faceless) and the three isolation tests it backs: no bystander/other-user/pet/faceless-person touched, no space person deleted beyond what the dissolve itself orphaned (L1), and no manual face_identity_face link belonging to another person stripped (L4/L5). All three passed on first run against task 3's write path; each was verified by deliberately breaking its matching scope (dropping the personId predicate, keying the identity delete by identityId, substituting an unscoped orphan sweep) and confirming the expected test failure before restoring. Also adds coverage for DissolveWriteResult.deletedThumbnailPath (L7), which the brief's tests never exercised against a real database: a non-empty thumbnailPath comes back verbatim, and the schema's '' default normalizes to null rather than queuing a delete for it. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
…it off the factory mediumFactory.sharedSpaceInsert deliberately omits id (shared_space generates one), so space.id didn't exist on the returned object and tsc --noEmit failed even though vitest's esbuild transform let the test run and pass. Capture the id from the insert's returningAll() row instead, matching the existing newSharedSpace pattern in test/medium.factory.ts. Re-verified the L1 orphan test's deliberate break still bites after this change. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
…ees actually break-tested Two gaps from review, both in the shared fixture design: - The old "pet person and its faces survive" assertion was dead: P3's face already has personId = p3.id, so it's excluded from the target dissolve by the personId equality alone — dissolveScopePredicate's notPet term is never consulted. Replaced with a real L6 test: a pet-tagged face (carries a pet_search row) owned by the TARGET person itself, which is only excluded by notPet. Verified it bites by temporarily neutering notPet to `sql<SqlBool>\`true\`` in face-dissolve.ts, confirming the new assertion fails, then restoring. - The L2 "faceless person survives" assertion had never been break-tested. Verified by temporarily adding an unscoped "delete every person with zero remaining asset_face rows" sweep (mimicking JobName.PersonCleanup) to the write path, confirming only that assertion fails, then restoring. Also split the five-assertion combined test into one it() per guarantee (L8, P2/bystander, unrelated-pet-person, L6, L2, target-deleted) so a failing assertion can no longer hide whether a later one in the same test would have failed too — the coupling that hid the dead L6 check in the first place. buildFixture stays shared across all of them. Re-confirmed the three previously-verified breaks (drop personId, key face_identity_face by identityId, unscoped orphan sweep) still bite in isolation after the split. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
…test-discrimination gaps sharedAssets counted a co-occurring pet's face as another person's face, inflating the re-detection-risk warning even though handleDetectFaces never removes pet faces. Also strengthens the counts.spec fixture so sharedAssets discriminates asset-dedup from row-counting, notRedetectable's trashed disjunct is exercised, exif is asserted against a mixed-source scope, and assets discriminates COUNT(DISTINCT) from a plain COUNT. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
Adds FaceDissolveService with the pet/unknown-person/active-recognition/ count-drift/redetect guards, outcome-based follow-up job queueing (re-detect, thumbnail delete, thumbnail regenerate), and preview warnings. Never queues the unscoped PersonCleanup/deleteUnreferencedIdentities/ deleteAllOrphanedPersons cleanups. Also creates server/src/dtos/face-dissolve.dto.ts (DissolveRequest/ DissolveResponse/DissolveWarning schemas and types) ahead of schedule: Task 7 was going to add it, but this task's service imports from it, so building it here avoids a compile-order defect between the two tasks. Only the DTO file is taken from Task 7 — no controller routes or SDK regen. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
- "regenerates the thumbnail only when the person survives" only ran the survives case; add a negative case asserting PersonGenerateThumbnail is not queued for delete-faces-and-person, so a regression that queues it unconditionally is now caught. - Assert the three previously-unexercised warning codes: recluster-similar, shared-assets, metadata-import-on (with both a firing and a non-firing case where cheap), matching strands-faces and not-redetectable's existing coverage. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
…n outcome Its title claimed "regardless of outcome" but the body only ran outcome: 'unassign'. A gate accidentally scoped to dto.outcome !== 'delete-faces-and-person' would have passed anyway, since 'unassign' also satisfies that condition. Now asserts the warning fires for both 'unassign' and 'delete-faces-and-person', and added the exif: 0 negative case that was missing entirely. Also added the two minor count-threshold negatives noted in review: recluster-similar absent at mlWithEmbedding: 0, shared-assets absent at sharedAssets: 0. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
Adds FaceDissolveRepository.getPeopleHealth, a per-person aggregate of face counts by source (EXIF/machine-learning/manual) and embedding presence, exposed via GET /admin/face-repair/people and a new "Health" tab on the face-cleanup people page. Without this a person made entirely of EXIF- imported faces is unreachable: the cleanup scan requires an embedding, and the only other people endpoint is a name-search picker. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
…18n terms
- Add a person.id tiebreaker after the sort column so paginating a tied
health listing (the common case: every uncontaminated person ties at 0
under exifFaces/facesWithoutEmbedding) can't duplicate or skip a row.
Same pattern as face-repair.repository.ts's getAllFaces query under the
identical limit(size+1)/offset scheme.
- Extend the sort test to exercise facesWithoutEmbedding (previously only
faceCount/exifFaces were tested, leaving a bad HEALTH_SORT_COLUMN mapping
free to reach sql.ref undetected), and add a dedicated pagination test for
a fully-tied sort key.
- fr.json/ru.json: reuse each file's existing term for "embedding" instead
of the synonym ("empreinte") or undeclined Latin script ("без embedding")
this task's first pass introduced.
Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
The Face health tab links a contaminated person to the manual-review page, which had no way to dissolve one — discovery and action were disconnected. Mount the dialog there too, keeping the guided-page entry. Also: open on the `exif` scope rather than the broadest destructive selection, give the not-re-detectable count copy that says what it means instead of borrowing "Skipped", and split the spec's expectedFaceCount from counts.faces so the round-trip test can no longer pass on a recomputed number. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
Drives the real discovery path (Face health tab -> the contaminated person's row -> manual-review page -> Dissolve) rather than the guided page's own launcher, since that is the seam Task 9's fix (0a78672) made work and the path an admin actually takes to find an EXIF-only person no scan can ever flag. Extends utils.createFace with additive `sourceType: 'exif'` and `withEmbedding` options so a fixture can represent a face imported from file metadata with no embedding - the shape nothing else in the console can see. Defaults are unchanged for the nine existing callers. Also asserts the repair half of the operation, not just the delete: the affected asset's asset_job_status.facesRecognizedAt is cleared, so the ordinary (non-forced) detection pass will re-process it. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
…pe predicate
`PeopleHealthQuerySchema` made `ownerId` optional, so the discovery aggregate could
be asked to GROUP BY person.id over every visible asset_face row on the instance,
ordered by an aggregate alias — LIMIT/OFFSET prune nothing there, and each page
re-runs the whole aggregate. The only client always sends an ownerId (both fetch
entry points guard on `selectedOwnerId`), so requiring it costs no UI change and
removes the unbounded shape from the API surface.
`getCounts` and `dissolve` carried byte-identical `inScope` closures. That is not
just duplication: if the two ever diverge, the mandatory preview describes a
different face set than the apply touches — on an operation with no undo, and no
test would catch a one-sided edit. Both now call one `dissolveFacePredicate`.
The regenerated `face.dissolve.repository.sql` is byte-identical, which is the
proof that the extraction preserved behaviour.
Also:
- `dissolveScopePredicate`'s switch gains `default: return scope satisfies never`.
tsconfig has no `noImplicitReturns`, so a future fifth scope would otherwise
return `undefined` into `eb.and([predicate, undefined])` and silently drop the
scope term from an irreversible delete.
- `scope` gets `.meta({ id: 'DissolveScope' })`, so the SDK stops exporting a bare
`enum Scope` — a generic name (OAuth/permission scope) that a later endpoint will
want, and a published export cannot be renamed without a breaking change.
Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
Two things the preview told an admin were not reliably true, on the one panel standing between them and an irreversible delete. **The missing warning.** The design spec (§2) requires a warning that unassign leaves the person with zero faces and the nightly `PersonCleanup` deletes it regardless — we do not queue that job ourselves (L2), but we must not imply the person survives. It was dropped between the spec and the implementation plan and never built. It matters because "Unassign only" is presented as the *least* destructive outcome: an admin who deliberately declines "delete the person" can still lose it overnight, silently. `getCounts` now also returns `remainingLiveFaces` — the person's faces the dissolve does NOT touch, counted with `getAllWithoutFaces`'s own definition (`deletedAt IS NULL AND isVisible`), because that is the query deciding the person's fate — and the new `person-will-be-cleaned-up` warning fires when an unassign would leave it at zero. **The warning that asserted a setting it never read.** `metadata-import-on` fired on `counts.exif > 0` alone, but its copy is a factual claim about the current setting — en "Import faces from metadata is on.", de "… ist eingeschaltet.", ru "… включено.". EXIF faces only prove the setting was on at *import* time. An admin who had already turned it off was told, in ten languages, that it is on. It now reads `metadata.faces.import` from the live config and stays silent when off; the copy is unchanged because it is now true whenever it appears. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
…what shipped The spec's medium-test matrix names a `rollback` row that was never written. It guards a live regression rather than a hypothetical one: `dissolve()` passes `trx` into `clearFacesRecognizedAt(personId, scope, trx)`, and that third argument defaults to `this.db`. Drop it and the watermark clear runs in its own autocommit outside the transaction — a dissolve that rolls back still leaves its assets re-queued for detection, with no face change to justify it. The new test forces a failure at the last statement of the transaction (a BEFORE DELETE trigger on `person`, created and dropped in the test) so a rollback has to undo both the delete and the watermark. Verified discriminating: dropping the `trx` argument fails this test — `expected null to deeply equal 2026-01-01…` — and no other test in the file. `leaves soft-deleted faces alone on unassign (L12)` asserted `personId` is null, i.e. that the face *was* unassigned — the opposite of its own title. Renamed to `does not hard-delete soft-deleted faces on unassign`, which is what it checks. Spec amendments, all recording reality rather than changing the design: - preview sample crops (§2) and the dashboard banner (§1) were cut during implementation; the spec claimed both. - §5 placed the modal only under `[personId]/` and never connected discovery to it. Both shipped entry points are now recorded, including the manual-review page the Health tab actually links to. - L1 gains its residue: deleting a face that a *surviving* `shared_space_person` pointed at leaves `representativeFaceId` NULL, dropping that space person out of `getSpacePersonsWithEmbeddings` until the space dedup job's `repairOrphanedRepresentativeFaces` runs. - `getCounts` counts soft-deleted faces in `faces` while `getPeopleHealth` excludes them, so the health row and the preview can disagree. Recorded as intended, with the reason for each. - the `expectedFaceCount` guard is a read compared outside the write transaction, so "concurrent dissolve → second gets 409" is best-effort, not guaranteed. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
`outcome` was an anonymous literal union, which the OpenAPI spec renders as an `anyOf` of three single-value enums. The Dart generator handles that form by naming the field's type `OutcomeEnum` and then never generating that class, so `mobile/openapi` stopped compiling and took the whole Flutter suite down with it — 242 failures, all the same unresolved symbol. The literal union was chosen to keep oazapfts from renumbering an anonymous enum on regeneration. Naming the schema solves that problem too, and is what the sibling `scope` field already does. The regenerated SDK diff is purely additive: a new `DissolveOutcome` enum, no other enum renumbered. Web widens the generated enum to its literal union with the same template literal already used for `scope`, so no runtime enum value is imported and specs stubbing `@immich/sdk` keep working. Mobile 3568 passing · server 6405 passing · web check:all clean. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
…scovery with the dissolve Two defects found testing rc2 against real contaminated people. 1. The dialog could display the PREVIOUS scope's counts under the newly selected chip. The component keeps the last successful preview in `preview`, so a preview that never lands leaves those numbers on screen — the panel then confidently describes a different face set than the button would delete, on an operation with no undo. Reported as "without-embedding shows identical numbers to machine-learning", which is exactly what a failed preview looks like after viewing the machine-learning tab. Counts and warnings now render only when they describe the current selection. `previewing` already meant precisely that, and already gated the apply button; it just was not gating what the admin reads. Blank beats wrong here. The scope predicate itself was verified correct against a real database first: without-embedding returns the exif + bare-ML faces and never the embedded ones. 2. The Health tab under-reported against the dialog for the same person, by exactly the number of non-live faces. getPeopleHealth filtered isVisible/deletedAt; the dissolve filters neither, so discovery hid faces the operation would delete. The aggregate now counts what a dissolve removes — predicting that is its whole purpose. This reverses a deliberate earlier decision (a test named the old intent); the alternative, making the dissolve skip those faces, would leave contamination behind. Test gaps that let both through: getCounts was exercised with Exif, All and MachineLearning but never WithoutEmbedding — the one scope whose predicate is an anti-join rather than an equality; and nothing asserted the two surfaces agree. Both are now covered, and the stale-counts test was mutation-checked: reverting the gate fails it. Server 6405 · web 6382 · face-dissolve medium 10/10. Claude-Session: https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP
…ointing
Upstream moved `asset_face` off `person.id` and onto `person_group`: `asset_face.personId`
is now `asset_face.personGroupId`, and `person` has a composite `(ownerId, personGroupId)`
primary key with no `id` column at all. Nothing in the dissolve conflicted during the
replay — every query that named the old columns simply stopped compiling.
The dissolve's DB layer now speaks `personGroupId`:
- `dissolveFacePredicate`, `clearFacesRecognizedAt`, `getCounts` and `dissolve` address the
target by `personGroupId`; the sibling-face and pet-sibling subqueries join
`other.personGroupId` and `person.personGroupId`; `unassign` clears `personGroupId`; the
`delete-faces-and-person` arm reads and deletes the `person` row by `personGroupId`.
- `getPeopleHealth` joins and groups on `person.personGroupId` and selects it `as id`, so
`PersonHealthRow.id` keeps carrying the person's public id — including the stable
tiebreaker, which now orders by `person.personGroupId`.
- `FaceDissolveService` resolves the target through `PersonRepository.getByGroupIdOnly`,
the fork's accessor for call sites that only hold the public id (`getById` is gone), and
queues `PersonGenerateThumbnail` with `IPersonJob`'s `{ ownerId, personGroupId }`.
The API surface is unchanged: the route param, the DTOs and the web client still say
`personId`, matching how face-repair was carried across the same change.
The medium fixtures insert straight into the tables rather than through the ctx helpers, so
they now seed the `cluster_group` a user hangs off and the `person_group` a person hangs
off; `seedPerson` returns the row whose `personGroupId` is the person's public id. Sound
only under Option M's 1:1 person_group-to-person invariant, which
`person_personGroupId_key` enforces in the database.
`server/src/queries/face.dissolve.repository.sql` still documents the pre-repointing SQL
and is left to the central regen pass.
Claude-Session: https://claude.ai/code/session_01MyyCWxY1QMcUrym8C7nwwi
`sync-open-api` refused to run at all: upstream's `patchOpenAPI` now rejects any schema property emitted as `type: number` with no format, on the grounds that an unformatted number is nearly always an integer someone forgot to declare. Every count in the dissolve DTOs is exactly that, so they are `z.number().int()` now — the spec emits `type: integer` and the TypeScript SDK's types are unchanged (`number` either way). That validator throws on the first offending schema rather than collecting across all of them, so it only ever named `PeopleHealthResponseDto.total`; the other eight are the same mistake and are fixed in the same pass. Counts nested inside an unnamed sub-schema (`DissolveResponseDto.counts`, the warnings array, the people rows) are not walked by the check at all, but they are counts too. The regenerated `fetch-client.ts` differs from the hand-merged one only in where the two dissolve enums sit relative to `Recommendation`/`Status` — the generator emits them earlier. Generator output wins; no declaration changed. `face.dissolve.repository.sql` picks up the person_group repointing: `asset_face."personId"` becomes `asset_face."personGroupId"`, the person joins key on `"personGroupId"`, and getPeopleHealth selects `"person"."personGroupId" as "id"`. Generated against a scratch database seeded from the migrations; no other query file moved. Claude-Session: https://claude.ai/code/session_01MyyCWxY1QMcUrym8C7nwwi
The files this PR adds whole never conflicted, so they kept pre-ESM import specifiers; give them the `.js` extensions the server now requires. Dropping the dissolve DTO import that resolving the controller's import block left duplicated.
Deeds67
force-pushed
the
feat/dissolve-person
branch
from
September 27, 2026 20:00
5ed8aa0 to
ddc2a92
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a way to dissolve a person from the admin face cleanup console, for people that have accumulated faces which aren't them.
What it does
Two axes, chosen independently:
all,exif(imported from file metadata),machine-learning, orwithout-embeddingunassign,delete-faces, ordelete-faces-and-personPlus the discovery listing needed to find such a person in the first place: a Health tab on
/admin/face-cleanup/peopleshowing each person's face count broken down by source, sortable by the contamination signals. A cleanup scan can't surface these people, because it only looks at faces that have an embedding.Repair happens on the next detection run. A dissolve clears
asset_job_status.facesRecognizedAtfor exactly the affected assets. SincestreamForDetectFacesJobapplies itsfacesRecognizedAt IS NULLfilter only whenforce === false, the ordinary "Detect faces (missing)" pass re-processes precisely those photos — no library-wide rebuild.The scope axis exists because the two cases need opposite handling: metadata-imported faces have no embedding and can never be re-clustered, so deleting them is the repair; mis-clustered ML faces have embeddings that should survive and be re-clustered.
No migration — all six referencing foreign keys already cascade.
Safety
The operation is irreversible, so the preview does the work: counts split by source and embedding presence, how many photos also contain other people, how many can never be re-detected, six warnings, and a typed-name confirmation.
Three behaviours worth knowing when reviewing:
deleteAllOrphanedPersons()is instance-wide andPersonCleanupdeletes faceless people library-wide; neither is reachable from a dissolve. This holds transitively — theAssetDetectFacesQueueAll { force: false }a dissolve queues skips both theif (force)branch that calls them and theforce === undefinedbranch that queuesPersonCleanup.handleDetectFaceskeeps pet faces out offaceIdsToRemove.Testing
Medium tests against a real database carry the load — the cascades and the re-detect gate are invisible to a mocked repository. The isolation fixture is built so a missing
WHEREclause can't pass it: two people of the same user sharing an asset, a pet person, a second user, a shared space holding an orphan that pre-dates the run, an unrelated faceless person, and aface_identityshared across two people with one side manually placed.Assertions were checked by mutation rather than assumed — each guarantee verified to fail when the thing it protects is broken. That caught a pet-exclusion assertion that couldn't fail under any single-fault break, four preview counts that looked covered and weren't, and a test whose title claimed more than its body checked.
Server unit 6405 passing · face-dissolve medium 27/27 · web 362/362 · one end-to-end run driving discovery → modal → HTTP → database, asserting both the deletion and the cleared watermark.
Known before merge
sync:open-api→ oazapfts → SDK build → dart script), because//-prefixed mise tasks run against the main checkout rather than a worktree. Worth knowing for review: the OpenAPI Clients check verifies the spec and TypeScript SDK regenerate clean but never compiles the Dart client — only Unit Test Mobile does. It caught one real defect here, an anonymous enum on a body property that the Dart generator references asOutcomeEnumand then never defines; both request enums are now named schemas.ORDER BYon an aggregate alias meansLIMIT/OFFSETprune nothing, so each page of the Health tab re-aggregates that owner's faces. Measured below at ~50ms/page; keyset pagination is the follow-up if it ever bites.Measured against a real library
Validated on a throwaway clone of a real 2,344-person / 60,253-face library (~8× the people and 10× the faces of the seeded benchmark), connected as the
galleryrole so the per-rolejit=offactually applied.The discovery aggregate holds up: 55ms for page 1 and 49ms for a deep page at offset 2250 — the
face_searchanti-join plans asloops=1, hashed once rather than re-run per row. A deep page costing the same as the first is what confirms the re-aggregation note above is real but cheap.A dissolve of the largest real person (6,921 faces) was run inside a transaction and rolled back — real data measured without mutating it. Preview 29ms, watermark clear 104ms, face delete 861ms, person row 5ms; ~1.0s total. The isolation property the test matrix exists for held on real data: people 2344→2343 (exactly one), faces 60253→53332 (exactly 6,921, not one row more), 6,865 assets requeued. After rollback all three counts returned to their original values.
Worth noting as a negative control: this library is entirely healthy — zero EXIF faces, zero manual, zero faces without embeddings — so the Health tab correctly reported all-zero contamination columns rather than inventing candidates. No genuinely contaminated person existed here to dissolve for real.
https://claude.ai/code/session_017Sw5EMqwWaHxAfybKgFxmP