Skip to content

feat(faces): dissolve a contaminated person from the face cleanup console - #1080

Open
Deeds67 wants to merge 28 commits into
mainfrom
feat/dissolve-person
Open

Deeds67 wants to merge 28 commits into
mainfrom
feat/dissolve-person

Conversation

@Deeds67

@Deeds67 Deeds67 commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

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:

  • Which faces — all, exif (imported from file metadata), machine-learning, or without-embedding
  • What happens to them — unassign, delete-faces, or delete-faces-and-person

Plus the discovery listing needed to find such a person in the first place: a Health tab on /admin/face-cleanup/people showing 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.facesRecognizedAt for exactly the affected assets. Since streamForDetectFacesJob applies its facesRecognizedAt IS NULL filter only when force === 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:

  • Nothing library-wide is ever triggered. deleteAllOrphanedPersons() is instance-wide and PersonCleanup deletes faceless people library-wide; neither is reachable from a dissolve. This holds transitively — the AssetDetectFacesQueueAll { force: false } a dissolve queues skips both the if (force) branch that calls them and the force === undefined branch that queues PersonCleanup.
  • Re-detecting a shared photo can remove another person's face if the detector no longer finds it. Accepted rather than solved — avoiding it would mean skipping repair on the photos that need it most — and surfaced as a count. Pet co-occupants are excluded from that count, since handleDetectFaces keeps pet faces out of faceIdsToRemove.
  • Hidden, trashed and preview-less assets can never be re-detected, so the dialog counts them separately and doesn't promise repair for them.

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 WHERE clause 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 a face_identity shared 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

  • OpenAPI/SDK output was regenerated by hand (build → 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 as OutcomeEnum and then never defines; both request enums are now named schemas.
  • ORDER BY on an aggregate alias means LIMIT/OFFSET prune 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.
  • The face delete dominates a dissolve at roughly 124µs/face, in one statement holding a write transaction. ~0.9s for 6.9k faces; a 50k-face person would hold it ~6s. Fine for an admin-triggered one-off, worth knowing before someone runs it on a much larger person.

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 gallery role so the per-role jit=off actually applied.

The discovery aggregate holds up: 55ms for page 1 and 49ms for a deep page at offset 2250 — the face_search anti-join plans as loops=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

@Deeds67 Deeds67 added the changelog:feat Feature change for changelog label Sep 6, 2026
@Deeds67 Deeds67 changed the title feat(server,web): dissolve a contaminated person from the face cleanup console feat(faces): dissolve a contaminated person from the face cleanup console Sep 6, 2026
@Deeds67
Deeds67 force-pushed the feat/dissolve-person branch from 48c4688 to a004c51 Compare September 11, 2026 19:28
@Deeds67
Deeds67 force-pushed the feat/dissolve-person branch 2 times, most recently from ec3186f to 5ed8aa0 Compare September 19, 2026 21:56
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
Deeds67 force-pushed the feat/dissolve-person branch from 5ed8aa0 to ddc2a92 Compare September 27, 2026 20:00

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant