Skip to content

feat(spaces): let space editors edit space members' assets (#734) - #992

Open
Deeds67 wants to merge 100 commits into
mainfrom
feat/space-editor-asset-permissions
Open

Deeds67 wants to merge 100 commits into
mainfrom
feat/space-editor-asset-permissions

Conversation

@Deeds67

@Deeds67 Deeds67 commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Closes the request in discussion #734.

What this does

A Shared Space Owner/Editor can now edit assets owned by other members of that space — rotate / crop / trim, the image editor, description, date, location, rating, tags, and the re-processing jobs (refresh faces / metadata / thumbnails, transcode). Delete, archive / visibility, and stacking stay owner-only.

The report's framing was off in a way that changed the design. #734 describes it as editor-vs-admin, and lists 8 actions an "editor" is missing. Those 8 are exactly the isOwner gates in AssetViewerNavBar.svelte — instance-admin status is never consulted there. The reporter's "admin" screenshot is simply the owner of that photo. So the real subject is a space editor acting on another member's asset, and everything below follows from that.

Of the 8 actions listed, 4 are granted (rotate left, rotate 180°, refresh faces, refresh metadata) and 4 are deliberately withheld (add to stack, archive, move to locked folder, view in timeline). The grant reaches well beyond that list, though — the whole detail panel, the image editor, video trim, thumbnail regeneration and transcode.

The rule

You may edit an asset if you own it, or if you are Owner/Editor of a space that shows it and its owner is a member of that space.

One sentence, total over all three ways an asset reaches a space (directly added, linked library, linked album). The client never learns which path an asset took — it learns only the answer, from the server.

Anchoring on the owner's membership is what makes this safe: authority comes from the owner having opted into the space, not from a role someone else handed out. A photo belonging to an album member who never joined the space stays untouchable.

⚠️ Three behaviour changes nobody asked for

1. A space editor can no longer edit an asset owned by a non-member. Direct-add accepts Permission.AssetShare = owner ∪ partner, so a member could add their partner's photo to a space — after which every space Owner/Editor gained write access over it, although the partner never joined and partner-sharing conveys read access only. Requiring owner-is-member closes that. Nothing legitimate is lost: the adder cannot edit their partner's photos anywhere else in the product either.

This surfaced as a pre-existing medium test going red. Its fixture never added the asset owner to the space, so it was asserting the old behaviour. The fixture was corrected (767e821f965) — no assertion was weakened, and the two-directional pins live in access-space-edit.repository.spec.ts (S-1/S-2/S-3 grant on all three paths; S-5 denies the non-member case).

2. Stack creation is now owner-only. StackService.create gated on AssetUpdate, which this change widens. Since StackUpdate/StackDelete are stack-owner-only, an editor could otherwise build a stack over your photos that you could never take apart. This closes a hole that was already live for direct-pool assets.

3. POST /faces reaches AssetUpdate — accepted, not fixed. The design originally claimed face writes don't go through AssetUpdate; that was wrong. PersonService.createFace gates the asset side on it, so a space editor can create a face box on a member's asset via the API (the web UI never offers it — TagPeople and the people row stay owner-gated). This was already true for direct- and library-path assets; this PR extends it to the album path like everything else. Note the asymmetry: DELETE /faces/:id resolves to the asset owner, so an editor cannot remove a box it created. Recorded in the spec as accepted-and-known. Say the word and I'll add an owner guard instead — I left it alone because guarding would narrow a pre-existing capability nobody asked to change.

How the client knows

The server answers per asset (canEdit on the single-asset response) and per selection (POST /assets/editable). The batch endpoint's body is a bare checkAccess call — deliberately the same call the write makes, so the answer cannot drift from enforcement.

canEdit is optional and never set by mapAsset: that helper has no auth context and feeds list endpoints, where resolving it would be an N+1 access check per asset and a required field would emit a wrong false for owners. Absent means "not resolved", and the client falls back to a space-membership derivation.

Attribution

An editor changing your photo now writes a row to the space's activity feed, so you can see what changed and who changed it. Owner self-edits log nothing, which keeps it low-volume. Bulk edits group by space — one row per space, since a selection can span several.

Known gap: tag add/remove are not attributed. The helper is private to AssetService, and wiring TagService needed either a protected method on the upstream-hot BaseService or a duplicate. Non-security; worth a follow-up.

Testing

  • access-space-edit.repository.spec.ts — 18 medium tests against real Postgres. The rule is SQL, so unit mocks prove nothing about a three-arm UNION with correlated EXISTS. Every deny case was mutation-proved non-vacuous: flip the single property under test and it turns to grant.
  • End-to-end journey — an editor opens a member's photo, rotates it, corrects the date, sees the activity row, and is refused on archive and delete. The negative half is what proves the line held.
  • Server unit 5785 · server medium 2552/2552 · web 5726 · Playwright ui green with 0 hangs.

Server medium needs --maxWorkers=4 locally; default parallelism exhausts Postgres connections and produces different failure sets per run. Same root cause as some intermittent controller-spec failures — resource contention, not code.

Other things worth knowing

  • POST /assets/editable has no payload cap. Deliberate: it receives a user's multi-selection, so a cap would turn a large select-all into a 400 instead of an answer. Bounded in practice by the 10MB JSON limit (~250k ids) and @ChunkedSet on the DB side.
  • A latent trap for future work: AssetViewer.onAssetUpdate replaces the viewer's asset wholesale, and canEdit is set only in AssetService.get. Every emitter today re-fetches or is owner-gated, but a future one passing a raw updateAsset response would silently strip canEdit and the widened actions would vanish mid-session.
  • Mobile is out of scope — a follow-up spec. Its generated OpenAPI client changed only as a side effect of regeneration. Editors will see actions on web that their phone doesn't offer until that lands.

Design and plan are committed under docs/superpowers/specs/ and docs/superpowers/plans/.

@Deeds67 Deeds67 added the changelog:feat Feature change for changelog label Aug 15, 2026
Comment thread server/src/services/asset.service.ts Fixed
Comment thread server/src/services/asset.service.ts Fixed
Deeds67 added a commit that referenced this pull request Aug 15, 2026
Table-driven coverage for PUT /assets/:id/edits, POST /assets/jobs
(refresh-metadata), PUT /assets/:id/metadata, and PUT /tags/assets against a
space member's asset, across owner/editor/viewer/non-member. Tags asserts the
response body (count) rather than status, since bulkTagAssets uses a
non-throwing checkAccess and always returns 200. Also pins the tightened
partner-share regression (#992 audit item 4): a space editor can no longer
edit an asset a fellow member direct-added on the strength of AssetShare's
partner arm when the asset owner isn't themselves a space member.
Deeds67 added a commit that referenced this pull request Aug 15, 2026
…eal browser

Every existing layer for #734/#992 (medium tests for the SQL rule, unit tests
per server call site, web component tests with a canEdit prop, API-level e2e
for permit/deny) stops short of proving a real user, in a real browser,
against a real server, actually sees the widened buttons. Each of those
layers would still pass even if the server stopped returning canEdit, the
client stopped threading it, or a gate read the wrong flag.

Adds two Playwright web-project specs against the real stack:

- spaces-editor-asset-viewer-affordances: opens a space member's (Bob's)
  photo from inside the space as the space Owner, Editor, Viewer, and Bob
  himself. Confirms Rotate/Refresh-faces/Refresh-metadata are visible for
  Owner/Editor and absent for Viewer, while Delete/Archive/Add-to-stack/
  View-in-timeline stay absent for everyone but Bob (the real owner).

- spaces-editor-selection-toolbar-affordances: selects Bob's asset in the
  space timeline's multi-select toolbar and waits on the real, debounced
  POST /assets/editable round trip before asserting — the async resolution
  path is the least covered by anything else. Confirms the metadata-edit
  actions (rotate/change date/description/location) follow the resolved
  editable set while Archive/Set-visibility stay owner-only, including a
  mixed-selection case.

Both suites anchor on a control that should be visible before asserting any
control is absent, so a still-loading page can't pass an absence check for
the wrong reason. Menu item text is matched against the real i18n keys.

Verified each suite can fail: flipped AssetViewerNavBar's Refresh-jobs gate
from isEditable back to isOwner (Owner/Editor tests went red on the missing
Refresh actions) and flipped DeleteAction's gate from isOwner to isEditable
(Owner/Editor tests went red on Delete now being present), then reverted
both.
Deeds67 added a commit that referenced this pull request Aug 23, 2026
Table-driven coverage for PUT /assets/:id/edits, POST /assets/jobs
(refresh-metadata), PUT /assets/:id/metadata, and PUT /tags/assets against a
space member's asset, across owner/editor/viewer/non-member. Tags asserts the
response body (count) rather than status, since bulkTagAssets uses a
non-throwing checkAccess and always returns 200. Also pins the tightened
partner-share regression (#992 audit item 4): a space editor can no longer
edit an asset a fellow member direct-added on the strength of AssetShare's
partner arm when the asset owner isn't themselves a space member.
@Deeds67
Deeds67 force-pushed the feat/space-editor-asset-permissions branch from aaab4de to 65a24a6 Compare August 23, 2026 11:08
Deeds67 added a commit that referenced this pull request Aug 23, 2026
…eal browser

Every existing layer for #734/#992 (medium tests for the SQL rule, unit tests
per server call site, web component tests with a canEdit prop, API-level e2e
for permit/deny) stops short of proving a real user, in a real browser,
against a real server, actually sees the widened buttons. Each of those
layers would still pass even if the server stopped returning canEdit, the
client stopped threading it, or a gate read the wrong flag.

Adds two Playwright web-project specs against the real stack:

- spaces-editor-asset-viewer-affordances: opens a space member's (Bob's)
  photo from inside the space as the space Owner, Editor, Viewer, and Bob
  himself. Confirms Rotate/Refresh-faces/Refresh-metadata are visible for
  Owner/Editor and absent for Viewer, while Delete/Archive/Add-to-stack/
  View-in-timeline stay absent for everyone but Bob (the real owner).

- spaces-editor-selection-toolbar-affordances: selects Bob's asset in the
  space timeline's multi-select toolbar and waits on the real, debounced
  POST /assets/editable round trip before asserting — the async resolution
  path is the least covered by anything else. Confirms the metadata-edit
  actions (rotate/change date/description/location) follow the resolved
  editable set while Archive/Set-visibility stay owner-only, including a
  mixed-selection case.

Both suites anchor on a control that should be visible before asserting any
control is absent, so a still-loading page can't pass an absence check for
the wrong reason. Menu item text is matched against the real i18n keys.

Verified each suite can fail: flipped AssetViewerNavBar's Refresh-jobs gate
from isEditable back to isOwner (Owner/Editor tests went red on the missing
Refresh actions) and flipped DeleteAction's gate from isOwner to isEditable
(Owner/Editor tests went red on Delete now being present), then reverted
both.
Deeds67 added a commit that referenced this pull request Aug 23, 2026
Follow-up to #992, which widened AssetUpdate but deliberately left the
People row owner-gated because every person/face permission resolves to
pure ownership.

Approach A: new editor-gated endpoints on the fork-only shared-spaces
controller, writing only the space's own taxonomy via the transaction
confirmSpacePersonFaceSuggestion already runs. access.ts and the upstream
person/face controllers are untouched.

Records the constraint that shapes the design: face_identity_face is keyed
on assetFaceId alone, so an attach rewrites a face's identity globally and
propagates to other spaces sharing it. Accepted and pinned rather than
partitioned.

31 BDD scenarios and an 8-slice TDD plan.
Deeds67 added a commit that referenced this pull request Aug 23, 2026
…icate

Reachability plus the owner-hidden-person exclusion, in one predicate so the
read (spec 6.1) and the write (6.3) cannot disagree. No owner-is-member clause:
unlike #992's checkSpaceEditAccess this writes nothing of the owner's, so an
editor may name a non-member's face (F-6).

Covers F-1, F-2, F-3, F-6, F-7, F-9, F-10, F-11.
Deeds67 added a commit that referenced this pull request Aug 23, 2026
POST /shared-spaces/:id/assets/:assetId/faces (spec §6.5). Gated on
requireRole(Editor) -> isAssetInSpace -> requireSpacePersonInSpace. The created
asset_face gets personId = NULL, sourceType = Manual, createdBy = auth.user.id,
then is attached to the space person via the existing linkFaceToSpacePerson
transaction helper.

Extracts the edit-aware coordinate transform out of PersonService.createFace
into a shared convertFaceBoxToOriginalImageSpace helper (src/utils/asset.util.ts)
so the owner path and the new space path can never drift on the geometry that
converts a box drawn on an edited preview into original-image coordinates
(#992 makes a rotated preview a likely path, not an exotic one -- F-16). The
owner path is refactored to call the same helper; its existing unit tests pass
unchanged, which is the refactor's own proof.

PersonRepository.createAssetFace now takes a trailing db/trx param (defaulting
to this.db) so it can be called from inside the space service's transaction,
per the Slice 2 deadlock lesson: any repository method invoked inside a
databaseRepository.transaction must take and use the trx, never this.db.

Covers F-16 (rotated-preview coordinates, verified against independently
computed expected values) and F-17 (400 when edits exist but exif dimensions
are unavailable, matching the owner path's message).
Deeds67 added a commit that referenced this pull request Aug 23, 2026
Two new SharedSpaceActivityType members, PersonFaceAssign and PersonFaceDetach,
written from attachFaceToSpacePerson/detachFaceFromSpacePerson inside the same
transaction as the change (logActivity's trailing db param), so a row can never
describe a link that then rolled back.

Owner-self rule (F-26): nothing is logged when the acting user owns the asset,
matching #992's asset_edit rule. Ownership comes from the asset, not from space
role -- getFaceOwnerLink now also carries the asset's ownerId so both methods
can check it without a second round-trip.

Covers F-24, F-25, F-26.
Deeds67 added a commit that referenced this pull request Aug 23, 2026
Space-flavoured siblings of PersonSidePanel and FaceEditor, wired to the
shared-space face endpoints (spec section 6.1-6.5) instead of the owner-only
person/face endpoints:

- SpacePersonSidePanel.svelte -- lists an asset's faces via
  getSpaceAssetFaces, and attaches/detaches/creates via
  attachSpacePersonFace / detachSpacePersonFace / createSpacePerson. Every
  action is immediate (PUT/DELETE), unlike the owner panel's batched
  "Done" -- the space endpoints are already transactional per action.
  Shows an error state with no affordances when the faces request fails
  (F-31), never guessing one.
- SpaceFaceEditor.svelte -- draws a box via createSpaceAssetFace.
  SpaceAssetFaceCreateDto.spacePersonId is required (unlike the owner
  path, a box cannot be drawn unassigned), so "create a new person" opens
  CreateSpaceFaceModal (create person, then draw attached) rather than a
  bare create call.

Extracted rather than copied, per the plan:
- lib/utils/face-box-drag.ts -- the drag/canvas coordinate transform
  (content <-> natural image space) and selector-placement math, pulled
  out of FaceEditor.svelte and shared by SpaceFaceEditor.svelte. Two
  copies of this transform would drift and silently misplace boxes,
  especially with #992 rotated/edited assets in the mix.
- PersonPickerGrid.svelte -- the people-search result grid, pulled out of
  AssignFaceSidePanel.svelte (owner) and reused by SpacePersonSidePanel.

DetailPanel.svelte fix found while gating this task: canEditSpacePeople
was missing the effectiveSpaceId check, so a non-owner with a
server-authoritative canEdit=true but NO space context (e.g. a
partner/album share) would incorrectly get space-editor affordances.
Caught by the existing W-18 regression test in detail-panel.spec.ts.
Fixed to `!!effectiveSpaceId && canEdit && !isOwner`.

i18n: adds unassign_face (the detach icon's aria-label) in all nine
maintained locales plus en, matching each file's existing tag/delete-face
register.

Covers F-27, F-28, F-29, F-30, F-31.
Deeds67 added a commit that referenced this pull request Aug 23, 2026
The first draft said the affordances render only under /spaces/:id/...
That is not what ships: AssetService.get auto-resolves a space for any
single-asset read without an explicit spaceId, so the affordance follows the
ASSET -- it appears wherever a photo is reachable through a space the viewer
edits, the main timeline included.

Accepted rather than route-scoped, so the People row agrees with the rest of
the detail panel, which already behaves this way for #992's canEdit. Every
server gate is unchanged; spaceId is still an explicit path parameter on every
endpoint, so no write can land in a space the caller did not name.

F-28's negative case becomes 'in no space she edits' rather than 'not on a
space URL' -- the real boundary. Recorded in 11 with the note that this was
found by a Playwright failure, not by reading code: the component tests set
the props directly and never exercised how they are derived.
@Deeds67
Deeds67 force-pushed the feat/space-editor-asset-permissions branch from c8f9998 to bb08f0d Compare August 24, 2026 20:08
Deeds67 added a commit that referenced this pull request Aug 24, 2026
Table-driven coverage for PUT /assets/:id/edits, POST /assets/jobs
(refresh-metadata), PUT /assets/:id/metadata, and PUT /tags/assets against a
space member's asset, across owner/editor/viewer/non-member. Tags asserts the
response body (count) rather than status, since bulkTagAssets uses a
non-throwing checkAccess and always returns 200. Also pins the tightened
partner-share regression (#992 audit item 4): a space editor can no longer
edit an asset a fellow member direct-added on the strength of AssetShare's
partner arm when the asset owner isn't themselves a space member.
Deeds67 added a commit that referenced this pull request Aug 24, 2026
…eal browser

Every existing layer for #734/#992 (medium tests for the SQL rule, unit tests
per server call site, web component tests with a canEdit prop, API-level e2e
for permit/deny) stops short of proving a real user, in a real browser,
against a real server, actually sees the widened buttons. Each of those
layers would still pass even if the server stopped returning canEdit, the
client stopped threading it, or a gate read the wrong flag.

Adds two Playwright web-project specs against the real stack:

- spaces-editor-asset-viewer-affordances: opens a space member's (Bob's)
  photo from inside the space as the space Owner, Editor, Viewer, and Bob
  himself. Confirms Rotate/Refresh-faces/Refresh-metadata are visible for
  Owner/Editor and absent for Viewer, while Delete/Archive/Add-to-stack/
  View-in-timeline stay absent for everyone but Bob (the real owner).

- spaces-editor-selection-toolbar-affordances: selects Bob's asset in the
  space timeline's multi-select toolbar and waits on the real, debounced
  POST /assets/editable round trip before asserting — the async resolution
  path is the least covered by anything else. Confirms the metadata-edit
  actions (rotate/change date/description/location) follow the resolved
  editable set while Archive/Set-visibility stay owner-only, including a
  mixed-selection case.

Both suites anchor on a control that should be visible before asserting any
control is absent, so a still-loading page can't pass an absence check for
the wrong reason. Menu item text is matched against the real i18n keys.

Verified each suite can fail: flipped AssetViewerNavBar's Refresh-jobs gate
from isEditable back to isOwner (Owner/Editor tests went red on the missing
Refresh actions) and flipped DeleteAction's gate from isOwner to isEditable
(Owner/Editor tests went red on Delete now being present), then reverted
both.
Deeds67 added a commit that referenced this pull request Aug 24, 2026
Follow-up to #992, which widened AssetUpdate but deliberately left the
People row owner-gated because every person/face permission resolves to
pure ownership.

Approach A: new editor-gated endpoints on the fork-only shared-spaces
controller, writing only the space's own taxonomy via the transaction
confirmSpacePersonFaceSuggestion already runs. access.ts and the upstream
person/face controllers are untouched.

Records the constraint that shapes the design: face_identity_face is keyed
on assetFaceId alone, so an attach rewrites a face's identity globally and
propagates to other spaces sharing it. Accepted and pinned rather than
partitioned.

31 BDD scenarios and an 8-slice TDD plan.
Deeds67 added a commit that referenced this pull request Aug 24, 2026
…icate

Reachability plus the owner-hidden-person exclusion, in one predicate so the
read (spec 6.1) and the write (6.3) cannot disagree. No owner-is-member clause:
unlike #992's checkSpaceEditAccess this writes nothing of the owner's, so an
editor may name a non-member's face (F-6).

Covers F-1, F-2, F-3, F-6, F-7, F-9, F-10, F-11.
Deeds67 added a commit that referenced this pull request Aug 24, 2026
POST /shared-spaces/:id/assets/:assetId/faces (spec §6.5). Gated on
requireRole(Editor) -> isAssetInSpace -> requireSpacePersonInSpace. The created
asset_face gets personId = NULL, sourceType = Manual, createdBy = auth.user.id,
then is attached to the space person via the existing linkFaceToSpacePerson
transaction helper.

Extracts the edit-aware coordinate transform out of PersonService.createFace
into a shared convertFaceBoxToOriginalImageSpace helper (src/utils/asset.util.ts)
so the owner path and the new space path can never drift on the geometry that
converts a box drawn on an edited preview into original-image coordinates
(#992 makes a rotated preview a likely path, not an exotic one -- F-16). The
owner path is refactored to call the same helper; its existing unit tests pass
unchanged, which is the refactor's own proof.

PersonRepository.createAssetFace now takes a trailing db/trx param (defaulting
to this.db) so it can be called from inside the space service's transaction,
per the Slice 2 deadlock lesson: any repository method invoked inside a
databaseRepository.transaction must take and use the trx, never this.db.

Covers F-16 (rotated-preview coordinates, verified against independently
computed expected values) and F-17 (400 when edits exist but exif dimensions
are unavailable, matching the owner path's message).
Deeds67 added a commit that referenced this pull request Aug 24, 2026
Two new SharedSpaceActivityType members, PersonFaceAssign and PersonFaceDetach,
written from attachFaceToSpacePerson/detachFaceFromSpacePerson inside the same
transaction as the change (logActivity's trailing db param), so a row can never
describe a link that then rolled back.

Owner-self rule (F-26): nothing is logged when the acting user owns the asset,
matching #992's asset_edit rule. Ownership comes from the asset, not from space
role -- getFaceOwnerLink now also carries the asset's ownerId so both methods
can check it without a second round-trip.

Covers F-24, F-25, F-26.
Deeds67 added a commit that referenced this pull request Aug 24, 2026
Space-flavoured siblings of PersonSidePanel and FaceEditor, wired to the
shared-space face endpoints (spec section 6.1-6.5) instead of the owner-only
person/face endpoints:

- SpacePersonSidePanel.svelte -- lists an asset's faces via
  getSpaceAssetFaces, and attaches/detaches/creates via
  attachSpacePersonFace / detachSpacePersonFace / createSpacePerson. Every
  action is immediate (PUT/DELETE), unlike the owner panel's batched
  "Done" -- the space endpoints are already transactional per action.
  Shows an error state with no affordances when the faces request fails
  (F-31), never guessing one.
- SpaceFaceEditor.svelte -- draws a box via createSpaceAssetFace.
  SpaceAssetFaceCreateDto.spacePersonId is required (unlike the owner
  path, a box cannot be drawn unassigned), so "create a new person" opens
  CreateSpaceFaceModal (create person, then draw attached) rather than a
  bare create call.

Extracted rather than copied, per the plan:
- lib/utils/face-box-drag.ts -- the drag/canvas coordinate transform
  (content <-> natural image space) and selector-placement math, pulled
  out of FaceEditor.svelte and shared by SpaceFaceEditor.svelte. Two
  copies of this transform would drift and silently misplace boxes,
  especially with #992 rotated/edited assets in the mix.
- PersonPickerGrid.svelte -- the people-search result grid, pulled out of
  AssignFaceSidePanel.svelte (owner) and reused by SpacePersonSidePanel.

DetailPanel.svelte fix found while gating this task: canEditSpacePeople
was missing the effectiveSpaceId check, so a non-owner with a
server-authoritative canEdit=true but NO space context (e.g. a
partner/album share) would incorrectly get space-editor affordances.
Caught by the existing W-18 regression test in detail-panel.spec.ts.
Fixed to `!!effectiveSpaceId && canEdit && !isOwner`.

i18n: adds unassign_face (the detach icon's aria-label) in all nine
maintained locales plus en, matching each file's existing tag/delete-face
register.

Covers F-27, F-28, F-29, F-30, F-31.
Deeds67 added a commit that referenced this pull request Aug 24, 2026
The first draft said the affordances render only under /spaces/:id/...
That is not what ships: AssetService.get auto-resolves a space for any
single-asset read without an explicit spaceId, so the affordance follows the
ASSET -- it appears wherever a photo is reachable through a space the viewer
edits, the main timeline included.

Accepted rather than route-scoped, so the People row agrees with the rest of
the detail panel, which already behaves this way for #992's canEdit. Every
server gate is unchanged; spaceId is still an explicit path parameter on every
endpoint, so no write can land in a space the caller did not name.

F-28's negative case becomes 'in no space she edits' rather than 'not on a
space URL' -- the real boundary. Recorded in 11 with the note that this was
found by a Playwright failure, not by reading code: the component tests set
the props directly and never exercised how they are derived.
@Deeds67 Deeds67 added the rc Auto-build a release-candidate server image and post it on the PR label Aug 26, 2026
@github-actions

github-actions Bot commented Aug 26, 2026

Copy link
Copy Markdown

🧪 Release candidate build

Latest RC pr-992-rc.15 — built from 6cc464a · build run

Images published

  • ghcr.io/open-noodle/gallery-server:pr-992-rc.15 (linux/amd64 + linux/arm64)
How to run this RC

In the directory containing your docker-compose.yml, create (or append to) a docker-compose.override.yml:

services:
  immich-server:
    image: ghcr.io/open-noodle/gallery-server:pr-992-rc.15

Then pull and restart:

docker compose pull immich-server
docker compose up -d

Each push publishes a new numbered tag, so update the image line to move to a newer RC. To roll back, point it at an earlier rc.<n> or delete the override and run docker compose up -d again.

Previous builds (15)
Tag Commit Built
pr-992-rc.14 ea45fa8 2026-09-19 22:49 UTC
pr-992-rc.13 f5b48f4 2026-09-13 21:08 UTC
pr-992-rc.12 3eb1160 2026-09-11 21:20 UTC
pr-992-rc.11 acd2850 2026-09-11 19:59 UTC
pr-992-rc.10 70c8fc1 2026-09-02 21:49 UTC
pr-992-rc.9 4c91f01 2026-09-02 16:25 UTC
pr-992-rc.8 88de288 2026-09-02 10:28 UTC
pr-992-rc.7 3854b6e 2026-09-01 11:23 UTC
pr-992-rc.6 521436a 2026-09-01 11:12 UTC
pr-992-rc.5 521cbb0 2026-08-31 22:12 UTC

5 older build(s) omitted. Their tags are still in GHCR until this PR closes.

Last updated Sat, 19 Sep 2026 23:02:27 GMT — every push while the rc/rc-ml label is set publishes a new numbered RC. All pr-992 RC images are deleted when this PR closes.

Deeds67 added a commit that referenced this pull request Aug 26, 2026
…cover the detach guard

Three defects a review of #992 turned up, each verified against the code before
being believed.

1. The space face read handed back RAW stored coordinates. Boxes are stored
   against the original bytes while the client renders the edited preview and
   crops each face out of it, so the owner's twin read projects them
   (`PersonService.getFacesById` -> `mapFaces` -> `transformFaceBoundingBox`) and
   `createSpaceAssetFace` inverts the same transform on the way in. This endpoint
   was on the opposite convention from its own write: every crop in the editor's
   panel was cut from the wrong region of an edited asset -- and #992 is what
   makes editing a MEMBER's asset possible, so that is an ordinary case here.
   Skipped when the asset has no dimensions to project against, where the
   transform would scale every box to a point; the write twin refuses outright
   instead (F-17), but a read still has to render.

2. `findSpaceForAssetAndUser` had no linked-album arm, though
   `checkSpaceEditAccess` grew one in this branch. An album-reached member asset
   was therefore editable while resolving to no space at all: the metadata
   affordances appeared, the face affordances silently did not (they fall back to
   `resolvedSpaceId`), and `logCrossOwnerEdit` wrote no attribution row for the
   edit that had just happened. Routed through `spaceAlbumAssetExists` so the arm
   cannot drift from the album scoping every other surface uses; that helper
   carries cross-owner contributions (#764) in the same predicate.

3. `setFaceOwnerPerson`'s `expectedPersonId` compare-and-set had no test at any
   layer -- `grep expectedPersonId server/test e2e/src` returned nothing, and the
   e2e named for the guard detaches one face while asserting on another, so
   deleting the guard left the suite green. Both halves are now covered where
   they can actually be built: the same-human condition in the medium face-assign
   spec, the compare-and-set in the person repository spec. Both were
   mutation-proved -- dropping the guard turns each red -- and the e2e's comment
   now says what it does and does not prove.
Deeds67 added a commit that referenced this pull request Sep 1, 2026
Neither side-panel picker had any e2e. The two existing face specs
(spaces-editor-face-affordances, spaces-editor-face-assign-completion) both
drive the on-photo overlay `#space-face-editor` reached through "Tag People";
nothing ever opened the picker behind "Edit people" -- which is the surface
#992's field testing kept landing on, and the one just folded onto a shared
panel.

owner-face-picker covers `AssignFaceSidePanel`. Its ordering test is built to be
falsifiable, which took some doing: `getAllPeople` sorts named people first ONLY
on its no-`closestFaceAssetId` branch, and the picker passes `closestAssetId`,
which swaps that block for an embedding distance ordering. These fixtures have no
`face_search` rows, so every distance is NULL and `isFavorite DESC` is what
actually decides -- favouriting the UNNAMED cluster is therefore the one lever
that makes the server hand the picker its candidates unnamed-first. The test then
asserts BOTH halves: that the response really did arrive as ['', 'Zelda
Namewell'], and that the panel renders it the other way round. Without the first
assertion the second would prove nothing the day the server ordering changes.

space-face-picker covers `SpacePersonSidePanel` -- the shared panel's other
caller, a different read (`GET /shared-spaces/:id/assets/:assetId/faces`) and a
different write. It filters client-side, attaches through the space endpoint, and
the face row takes the name.

Three locator traps worth naming, each of which produced a green-looking failure
first:

- Each face card is ITSELF a button wrapping the pencil, so its accessible name
  contains "Select new face" and a non-exact match resolves the card -- clicking
  which does nothing and leaves the picker shut.
- A global people-search input elsewhere on the page has placeholder "Search
  people...", so the picker's field needs scoping rather than a bare
  `getByPlaceholder`. `PersonPickerPanel` gains a testid for that.
- Assertions go on the candidate CAPTIONS, not the buttons: these fixtures' person
  thumbnails 404, so every button reads "Error loading image <name>", and the
  caption is what a human reads off the card anyway.

Verified against a locally rebuilt e2e stack: 4 passed, and 18 passed over
--repeat-each=2 alongside both existing face specs.
…layer

Revises spec 6.3.1. A space editor's face assignment used to live only in
`shared_space_person_face`, deliberately never touching `asset_face.personId`, so
that an editor could not relabel the owner's private library.

That produced a surface users read as broken. The asset-detail People row is
seeded from `asset_face.personId`, so an editor's assignment never appeared
there -- and a detach never removed anyone, because
`findSpacePersonsByLinkedPersonIds` is not scoped to the asset and keeps
resolving a person who is attached anywhere else in the space. Reloading could
not converge.

Attach now writes the face identity and `asset_face.personId`, creating a
`person` for the asset owner when they have never named that human. Detach
clears `asset_face.personId`, but only when the owner's person carries the same
identity as the space person being detached -- an editor removing "Uncle Tom"
must never null out the owner's unrelated "Dad" tag on the same face.

Both owner-side layers move together on purpose: propagating `personId` while
pinning the identity would leave the owner's person row and `face_identity_face`
disagreeing about one face, which is the split 6.3.1 was written to avoid.

An editor can now alter face tags on the owner's own copy of a photo shared into
the space. That is intended, and bounded to assets shared into the space.

F-36 inverts: it asserted the insulation, and now asserts the propagation. Two
new detach tests cover the clear and the identity guard; both were verified to
fail without the change.
Adds an API e2e spec asserting what the ASSET OWNER sees, which is the read path
the propagation exists to fix and the one every existing space-face assertion
misses -- they all read space surfaces as the editor, which is why the bug
survived: the editor's own face editor showed "Unassigned" correctly the whole
time while the owner's People row did not move.

Four cases: attach writes the owner's personId and the owner's own
`GET /assets/:id` lists the new person; detach clears it and the owner stops
seeing them; detaching an unrelated space person leaves the owner's own tag
intact; and an attach/detach/re-attach round trip lands back on the SAME owner
person rather than creating a duplicate.

Each case asserts both the column via SQL and the owner-visible API response.
The SQL half alone would pass even if the owner's view never changed, which is
exactly the failure being pinned.

Verified red against a build with the service change reverted: three of the four
fail with "expected null not to be null". The fourth (the guard) passes either
way by construction -- it asserts a tag is preserved, and nothing touched it
before -- so the guard logic is pinned by the unit test instead, which was
verified red by dropping the identity comparison.

Space people are created WITH their seed face throughout. An earlier draft
created the person first and attached second, and failed about one run in three
with "Person not found" as the background identity/metadata backfills raced the
window where the person had no faces.

Also inverts the journey spec's F-36, which asserted the insulated model in
detail, and adds the F-34 propagation assertions to the unit suite.
…er a face edit

Two defects on the asset-detail People row, both visible right after a space
editor changes a photo's people.

1. The edit controls disappeared until the info panel was closed and reopened.
   `AssetService.get` set `resolvedSpaceId` only on the branch that INFERS the
   space, never on the branch where the caller names one. DetailPanel's
   post-edit refetch passes `spaceId` explicitly, so the response came back with
   no resolved space; on /photos/:id the route carries no space either, so
   `canEditSpacePeople` -- which falls back to `resolvedSpaceId` -- resolved
   false and switched its own affordances off. Both branches now report it.

2. Reopening the panel showed the pre-edit people until a full page reload.
   DetailPanel assigned `asset = await getAssetInfo(...)`, but `asset` is a plain
   prop and AssetViewer derives it from `cursor.current`, so the assignment only
   updated DetailPanel's own copy. It now emits `AssetUpdate`, the existing route
   back to the parent: AssetViewer's `onAssetUpdate` listener replaces
   `cursor.current`, which flows back down through the derived prop.

Both tests were verified red against the previous behaviour. The web test
asserts the emit rather than rendered output on purpose -- a local-only
assignment renders identically in isolation and would pass a DOM assertion while
leaving the parent stale, which is exactly how this shipped.

Adds a refresh-capable side-panel stub; the existing one only echoes props and
cannot drive the refresh path.
…rson

Every existing case in this spec reads as the OWNER, with the space inferred.
DetailPanel re-reads with `spaceId` passed explicitly after a face edit, which is
a different branch of `AssetService.get` -- and it is the branch behind "I tag a
new person and it doesn't show up until I refresh".

Asserts the new person survives that branch's `spacePersonId` filter, which it
can only do because the attach now writes the owner-side `asset_face.personId`
the filter's lookup keys off, and that the branch reports `resolvedSpaceId` so
the panel's edit affordances do not disable themselves.
The earlier fix only covered the side panel's edit-faces flow. Tagging from the
ON-PHOTO editor -- the "+" button, which opens face-edit mode over the image --
goes through SpaceFaceEditor, which took no refresh callback at all: it awaited
createSpaceAssetFace (or the create-person modal) and then just called onClose().

So both surfaces stayed stale until a full page reload: the People row, which
reads the asset's `people`, and the face boxes over the photo, which read
faceManager. The owner's FaceEditor at least cleared faceManager here; the
space-flavoured one did neither.

Extracts `refreshAssetPeople(assetId, spaceId)` so the two entry points cannot
drift on what "people changed" means -- it re-reads the asset in its space,
emits AssetUpdate so AssetViewer's cursor picks it up, and reloads the face
boxes. DetailPanel now delegates to it too.

The refresh runs BEFORE onClose: closing unmounts the editor, so firing it after
would race the teardown. Both new editor tests were verified red by commenting
the call out.
`unicorn/no-await-expression-member` rejects `(await getFaceOwnerPerson(id)).name`,
which the propagation spec used at eight assertion sites. Two destructuring helpers
at module scope replace them -- module scope because they close over nothing, which
`unicorn/consistent-function-scoping` requires.
Two separate breakages, both from reversing the owner-insulation rule.

Wiring: the SharedSpaceService setups injected no PersonRepository, so
this.personRepository was undefined the moment attach/detach started
propagating -- every attach threw before reaching an assertion.

Contract: four cases still pinned the old insulation, asserting the owner's
layer was untouched. They now pin the propagation instead -- F-23 (the attach
writes asset_face.personId and creates the owner's person), F-36/F-40 (an
owner-named face is re-pointed for everyone, while the owner's person row and
his other faces survive), F-16 (a drawn-and-named box propagates), and the
suggestion confirm. F-39 is deliberately NOT reversed: the propagation is
identity-scoped, so an unrelated attach must still leave the owner alone.

F-36's fixture gains a second face under 'Dad'. With only one, the surviving
"his other faces are intact" claim was vacuous and the identity resolved to
nothing once that single face moved.

The cross-flow S5.4/S5.5 case used personId === null as its proxy for "the
recognition pass never touched this face". That proxy is gone, so it now
captures the value the confirm propagated and asserts the pass leaves it
unchanged, which is what it meant to say.
… lists

An editor opening "Edit faces" on a photo got a spinner that never resolved.
`getAssetFacesForSpace` joined `shared_space_person_face` unscoped and then
constrained `shared_space_person` to the space. The projection table is
per-space, so a face named in a SECOND space produced a second row for the same
`asset_face`, with the other space's columns merely NULLed rather than dropped.
The panel keys its `{#each}` on the face id, and Svelte throws
`each_key_duplicate` on a repeat -- it then abandons the branch swap, leaving the
loading spinner mounted with no error and no way back. The scoping moves inside
the join, into a derived table, so the read is one row per face.
`reviewableAssetVisibility` moves above that join: it takes an ExpressionBuilder
over `DB` itself, which the alias widens (where a predicate sits in the chain
does not change the SQL).

Sweeping for the same shape found three more keyed lists that can be handed a
repeated id, all fork surfaces, none defended the way the global people page
already is:

- The space People grid and its visibility manager concatenate OFFSET pages of a
  read ordered by hidden state, name and asset count -- and `recountPersons` runs
  on every face attach/detach, so an editor naming a face in that same space
  between two page requests shifts the window and re-emits a row.
- The space activity feed pages by OFFSET over a feed that grows at the head, so
  anything written while it is open pushes the boundary down. Its ORDER BY was
  also not a total order: rows written in one transaction all take that
  transaction's `now()`, and an ambiguous tie under OFFSET can repeat a row or
  skip one. Both halves are fixed -- `appendUniqueById` on the client, an `id`
  tiebreaker in SQL.

Every test was verified red first, each against its own defect: two rows for one
face out of the repository, an activity order that was not total, and
`each_key_duplicate` thrown out of all three web surfaces.
Follow-up to a2775bf, which introduced the defect it fixes. Merging the
pages by id means the kept-row count is no longer the number of rows the server
has served -- and OFFSET counts what the server served. Paging on `people.length`
therefore re-requested every row the merge had dropped, and a page that was
entirely duplicates did not advance the count at all: `hasMore` stayed true, the
grid stayed the same size, and the infinite-scroll sentinel re-fired the
identical request indefinitely.

Both lists on the page now carry their own served-row counter, which is the
reasoning the activity feed in that same commit already had written down.

Verified red first: the second page requested offset 199 where the server had
already served 200 rows.
…cover the detach guard

Three defects a review of #992 turned up, each verified against the code before
being believed.

1. The space face read handed back RAW stored coordinates. Boxes are stored
   against the original bytes while the client renders the edited preview and
   crops each face out of it, so the owner's twin read projects them
   (`PersonService.getFacesById` -> `mapFaces` -> `transformFaceBoundingBox`) and
   `createSpaceAssetFace` inverts the same transform on the way in. This endpoint
   was on the opposite convention from its own write: every crop in the editor's
   panel was cut from the wrong region of an edited asset -- and #992 is what
   makes editing a MEMBER's asset possible, so that is an ordinary case here.
   Skipped when the asset has no dimensions to project against, where the
   transform would scale every box to a point; the write twin refuses outright
   instead (F-17), but a read still has to render.

2. `findSpaceForAssetAndUser` had no linked-album arm, though
   `checkSpaceEditAccess` grew one in this branch. An album-reached member asset
   was therefore editable while resolving to no space at all: the metadata
   affordances appeared, the face affordances silently did not (they fall back to
   `resolvedSpaceId`), and `logCrossOwnerEdit` wrote no attribution row for the
   edit that had just happened. Routed through `spaceAlbumAssetExists` so the arm
   cannot drift from the album scoping every other surface uses; that helper
   carries cross-owner contributions (#764) in the same predicate.

3. `setFaceOwnerPerson`'s `expectedPersonId` compare-and-set had no test at any
   layer -- `grep expectedPersonId server/test e2e/src` returned nothing, and the
   e2e named for the guard detaches one face while asserting on another, so
   deleting the guard left the suite green. Both halves are now covered where
   they can actually be built: the same-human condition in the medium face-assign
   spec, the compare-and-set in the person repository spec. Both were
   mutation-proved -- dropping the guard turns each red -- and the e2e's comment
   now says what it does and does not prove.
…he attach working

Field report against pr-992-rc.3: in the space-flavoured "Select face" picker,
typing a name produced no suggestions and tapping a person's card appeared to do
nothing, so there was no way to name a face after an existing space person.
"Create person" still worked, which is how the tester got past it -- at the cost
of a duplicate person.

The picker's only text field was the CREATE-person name box. It filtered nothing,
so on a space with hundreds of people scrolling was the only way to reach anyone.
Both siblings of this picker already search -- the owner's AssignFaceSidePanel via
PeopleSearch, and SpaceFaceEditor over its own loaded candidates -- and the panel
is too narrow for two text inputs, so the one field now does both jobs: the name
you type either finds the person or creates them. Same matcher as SpaceFaceEditor,
so "angela" finds "Ángela". Filtered client-side because the candidates are all
loaded already; the endpoint's own `name` parameter would cost a request per
keystroke and return the same rows.

Two things that make a tap read as dead on a phone go with it, neither proven to
be what the tester hit but both true regardless:

- The shared PersonPickerGrid loaded every thumbnail eagerly. Opening the picker
  on a real library fires that many requests at once, and over HTTP/1.1 -- a
  plain-HTTP instance on a tailnet, say -- they fill the six-connection pool, so
  the PUT the next tap issues queues behind them with nothing on screen to say so.
  They load lazily now, which also helps the owner's picker, where `GET /people`
  serves up to 500 in one page.

- The attach had no in-flight state at all, unlike its create-person twin. A slow
  write left the card sitting there unchanged, which is indistinguishable from a
  tap that never registered, and nothing stopped a second impatient tap firing a
  second attach. The grid now shows its spinner until the PUT settles.

The other half of the report -- that the picker looks completely different as
Administrator -- is not a defect and not role-dependent: the two screens are
different components chosen by CONTEXT. Viewing a space member's asset gives the
space picker (space people, all named, alphabetical); viewing your OWN asset gives
upstream's AssignFaceSidePanel, whose `getAllPeople({ withHidden: true,
closestAssetId })` returns every person including the unnamed ones, ordered by
similarity to the face rather than by name. That is upstream behaviour, unchanged
by this branch.

PersonPickerGrid gains a spec of its own: it is new here and shared by both
pickers, and the click path through it had no direct coverage.
eslint's unicorn/no-await-expression-member rejects reading a member straight off
an await expression, which is what the new PersonPickerGrid spec did to pick the
<img> out of the elements carrying a candidate's name.
Field report against pr-992-rc.5: as the admin, naming a face still produced
"an unstructured list of unlabeled faces instead of the named people list",
where the same photo in the same space gave a regular user a tidy one.

Not role-dependent in name, but it is in effect. `canEditSpacePeople` is
narrowed to `!isOwner`, so which picker you get turns on whether you OWN the
photo -- and for a given photo that is simply who is looking. The editor gets
SpacePersonSidePanel: space people, named, alphabetical, search field in plain
sight. The owner gets upstream's AssignFaceSidePanel: `getAllPeople({withHidden:
true, closestAssetId})`, so the entire library including every unnamed cluster,
ordered by resemblance rather than by name, with the search hidden behind a
magnifier icon. On a library of any size that is a wall of anonymous crops. The
earlier reply called this expected because it is upstream's screen unchanged --
true, and beside the point.

PersonPickerPanel now owns what the two can agree on: the panel chrome, the
search field, the ordering and the grid. Each caller supplies only where
candidates come from and what a click writes, because those genuinely differ --
`person` via `reassignFacesById` against `shared_space_person` via §6.3 -- and
the owner must keep the whole library on their own photo.

What actually answers the report is `orderPickerCandidates`: a STABLE partition
putting named people ahead of unnamed clusters. Stable matters. The owner's list
arrives in `closestAssetId` order, which is the best signal it has, so the
likeliest named person stays first and the clusters move below rather than being
re-sorted away. It is a no-op on the space list, which getPersonsBySpaceId
already serves named-first -- both now read off one helper instead of off that
ordering continuing to hold. Unnamed clusters stay reachable: attaching a face to
one is a real move, it merges two sightings of the same stranger.

Dropping PeopleSearch from the owner panel means reissuing its request here, so
its behaviour is carried over rather than quietly lost: the query still goes to
the SERVER (getAllPeople serves one page, and narrowing the loaded list would
stop finding anyone past it), searchNameLocal still applies the same
prefix-and-slice narrowing, the abort still stops a slow keystroke landing on a
later one, and an unsaturated result set is still narrowed in place when the
query only grows -- one request per name, not one per letter.

Left alone deliberately: merging the two candidate lists. A space person named
only on another member's assets has no owner-layer person, so the owner's picker
cannot reach them and "create person" makes a second identity. Closing that means
one grid holding two id namespaces landing on the §5.1/§6.3.1 propagation rules
-- a change to the model, not to the presentation. Recorded in §7.2.1 with the
rest of this reasoning.

SpaceFaceEditor keeps its own list: it is a positioned overlay measured against
the drawn box, not this panel, and it already searches and already orders
named-first.

AssignFaceSidePanel gains a spec of its own -- it had none, and it is now the
half of this pair that changed.
Neither side-panel picker had any e2e. The two existing face specs
(spaces-editor-face-affordances, spaces-editor-face-assign-completion) both
drive the on-photo overlay `#space-face-editor` reached through "Tag People";
nothing ever opened the picker behind "Edit people" -- which is the surface
#992's field testing kept landing on, and the one just folded onto a shared
panel.

owner-face-picker covers `AssignFaceSidePanel`. Its ordering test is built to be
falsifiable, which took some doing: `getAllPeople` sorts named people first ONLY
on its no-`closestFaceAssetId` branch, and the picker passes `closestAssetId`,
which swaps that block for an embedding distance ordering. These fixtures have no
`face_search` rows, so every distance is NULL and `isFavorite DESC` is what
actually decides -- favouriting the UNNAMED cluster is therefore the one lever
that makes the server hand the picker its candidates unnamed-first. The test then
asserts BOTH halves: that the response really did arrive as ['', 'Zelda
Namewell'], and that the panel renders it the other way round. Without the first
assertion the second would prove nothing the day the server ordering changes.

space-face-picker covers `SpacePersonSidePanel` -- the shared panel's other
caller, a different read (`GET /shared-spaces/:id/assets/:assetId/faces`) and a
different write. It filters client-side, attaches through the space endpoint, and
the face row takes the name.

Three locator traps worth naming, each of which produced a green-looking failure
first:

- Each face card is ITSELF a button wrapping the pencil, so its accessible name
  contains "Select new face" and a non-exact match resolves the card -- clicking
  which does nothing and leaves the picker shut.
- A global people-search input elsewhere on the page has placeholder "Search
  people...", so the picker's field needs scoping rather than a bare
  `getByPlaceholder`. `PersonPickerPanel` gains a testid for that.
- Assertions go on the candidate CAPTIONS, not the buttons: these fixtures' person
  thumbnails 404, so every button reads "Error loading image <name>", and the
  caption is what a human reads off the card anyway.

Verified against a locally rebuilt e2e stack: 4 passed, and 18 passed over
--repeat-each=2 alongside both existing face specs.
Field report against pr-992-rc.7 narrowed the remaining complaint to something
much sharper than "the admin's picker looks wrong": on ONE photo, opening the
picker on an already-named face gave a well-structured list of names, and opening
it on an unassigned face gave a wall of unlabelled thumbnails. Same user, same
library, same screen.

That rules out everything role- or space-shaped. The only input that differs
between those two opens is `closestAssetId`, which feeds the resemblance
ordering -- and ordering plus LIMIT is membership. `GET /people` serves one
500-row page, and `getAllForUser` sorted that page by resemblance ALONE on its
`closestFaceAssetId` branch. On a library past 500 people -- unnamed clusters
dominate that count -- the named people were cut from the page by resemblance,
and a different set of them survived for each face. The screenshots show it
plainly: ten names for one face, two for the next.

So the previous commit's client-side partition was treating a symptom. It made
the page it received read correctly, which is real -- both screenshots do show
names first -- but no re-ordering can recover a row the response never contained.

The branch now applies the same named-first key its no-`closestFaceAssetId`
sibling has always had, ahead of the limit, with resemblance ordering preserved
WITHIN each group so the likeliest name is still first, and `person.id` as a
final tiebreak. Without it the order among equal-distance rows is unspecified,
which under OFFSET paging hands the client a row it already has or skips one --
and every distance is NULL when the edited face has no embedding, which is
exactly the all-equal case.

Note `isFavorite DESC` still outranks the name, upstream's ordering untouched, so
a favourited cluster can still lead the page. That is a deliberate leftover: it is
one row, not a truncation, and it is what the web e2e uses as its lever.

The medium test gives the cluster the PERFECT embedding match and the named
person the worst, then asks for `take: 1` -- so the named person can only come
back if the name outranks resemblance. Confirmed to fail without the fix
(`expected [ '' ] to deeply equal [ 'Zelda' ]`), which is the reported bug in
miniature.

No query-doc regeneration: `@GenerateSql` supplies no `closestFaceAssetId`, so the
documented query takes the other branch and is unchanged.
Field report against pr-992-rc.8: with the names no longer truncated away, what
was left was an order that "doesn't seem to follow any clear sort order, neither
alphabetical nor by frequency" -- unscannable on a library with several hundred
named people.

It was sorted by resemblance to the face you tapped: upstream's `closestAssetId`,
which orders every person by the cosine distance between their representative
face embedding and that one. As a name suggestion it earns its place for the
first handful of rows; after those it is indistinguishable from random, and there
were several hundred of those rows.

The picker simply stops asking for it. Without `closestAssetId`, `getAllForUser`
serves its other branch -- named people alphabetical, unnamed clusters after them
by face count -- which is the order `getPersonsBySpaceId` already gives the space
picker and that every other people list in the fork already uses. This picker was
the sole exception, so this is one query parameter removed rather than a new
ordering invented.

Little is lost with it. The suggestion was only ever trustworthy at the top of
the list, this fork already has a dedicated surface for suggesting names against
a known person, and a search field now sits in plain sight above the grid.

The resemblance ordering stays on the server, still named-first ahead of the
LIMIT, for any API consumer that asks for it.

The e2e fixture creates its two named people in REVERSE alphabetical order, so
creation order cannot masquerade as sorted, and still favourites the unnamed
cluster -- `isFavorite DESC` outranks the name key, so the response still leads
with the cluster and the render assertion still proves the client re-partitions
rather than passing vacuously.

Found while confirming the above, NOT fixed here: the person page's merge picker
is the only other caller of the closest ordering (`closestPersonId`, behind its
"sort faces" toggle) and it also sends `withSharedSpaces: true`, which
`PersonService.getAll` short-circuits on BEFORE it reads `closestPersonId`. That
toggle has never reached the ordering it asks for. Recorded in §7.2.1.
CI caught four errors local eslint could not: this worktree's eslint crashes
inside the tscompat plugin on the installed TypeScript, so `pnpm lint` never
reached the rules. Verified this time by re-running the same config with just
that rule switched off.

Three `unicorn/no-await-expression-member` (a member read straight off an await,
hoisted into a variable) and one `unicorn/prefer-string-repeat` (the
whitespace-only name in the ordering test, now `' '.repeat(3)`).

No behaviour change: 48 web unit tests and all four picker e2e tests still pass.
The two `-design.md` docs explain why the code is shaped the way it is and
are cited from `asset-face.table.ts`, the migration and three e2e specs, so
they stay. The asset-permissions implementation plan and the nine
face-assignment slice files were execution checklists: they expired when the
work landed, and `specs/` is explicitly not the place for them.

Also repoints one e2e comment at the design doc's real path -- it still cited
the pre-flattening `docs/superpowers/specs/` location.

Claude-Session: https://claude.ai/code/session_01NXWwJL5cQK1ci6EucgfSu8
…te union

The v3.2.0 cutover re-keyed the person model with no conflict anywhere on this
branch: `asset_face.personId` became `asset_face.personGroupId`, `person` lost its
standalone `id` for a composite `(ownerId, personGroupId)` key, and
`spaceAlbumAssetExists`/`spaceAssetPathBranches` turned the old implicit
`requireShowInTimeline` into a REQUIRED `albumTimelineGate` union.

Repository/DB layer now speaks `personGroupId`; the API surface (routes, DTOs,
web client) keeps `personId` untouched, matching `face-repair.repository.ts` vs
`face-repair.dto.ts`.

- `getOrCreateOwnerPersonForIdentity` mints the owner's `person_group` first, as
  `createWithGroup` does, and returns `{ personGroupId }`. `setFaceOwnerPerson`
  takes `personGroupId` / `expectedPersonGroupId`.
- `getFaceOwnerLink`, `isFaceAssignableInSpace` and `getAssetFacesForSpace` join
  `person.personGroupId` to `asset_face.personGroupId`; every predicate stays in
  the clause it was in, so the LEFT JOINs stay LEFT JOINs.
- `albumTimelineGate: 'none'` at all three new call sites — `checkSpaceEditAccess`
  (editability must not follow a timeline toggle, as its comment already said),
  `isFaceAssignableInSpace` (matching its sibling `isFaceReachableInSpace`) and
  `findSpaceForAssetAndUser` (which must agree with `checkSpaceEditAccess` or an
  edit resolves to a space the response never names).

Claude-Session: https://claude.ai/code/session_01MyyCWxY1QMcUrym8C7nwwi
…ync ORDER

The v3.2.0 base took `1791000000000` for `RepointFaceReviewToPersonGroup`, so this
branch's `AddAssetFaceCreatedBy` moves to `1796000000000`. It has never run on a
database that main knows about, so nothing needs a compatibility alias; the
revert script's DELETE entry moves with it.

The base also added `migrations-gallery/ORDER` and the
`migrations:verify-order:gallery` gate, which this branch predates. Regenerated
with `sql-tools --source-folder src/schema/migrations-gallery migrations
sync-order`; the manifest is consistency-only, as the fork runs
`allowUnorderedMigrations: true`.

The two unit fixtures move for the same v3.2.0 reason as the code: `update()` now
answers with `this.get(auth, id)`, which re-checks AssetRead after the write, so
the cross-owner-attribution cases have to grant the space read the acting editor
genuinely holds. F-34's assertion follows `setFaceOwnerPerson` onto `personGroupId`.

Claude-Session: https://claude.ai/code/session_01MyyCWxY1QMcUrym8C7nwwi
…ixture on v3.2.0

Three static gates the base grew while this branch was out, each of which this
branch trips without touching a line of the gate's subject:

- `docs/fork/ownership.yml` enumerates every gallery migration, so the renumbered
  `1796000000000-AddAssetFaceCreatedBy` has to be listed or the Gallery Migration
  Count audit reports a file the manifest does not know about.
- `tools/upstream-preflight`'s branded-spinner guard derives its swapped set from
  the tree and fails on anything unlisted, so the three components this branch
  adds that import the fork-local spinner — `SpaceFaceEditor`, `PersonPickerGrid`
  and `SpacePersonSidePanel` — join the list.
- The space People page loader now returns `hasSpacePeople`, and
  `canManageVisibility` gates the show-and-hide affordance on it. The paging test
  added here built its `data` prop without that field, so the button it clicks was
  never rendered; the fixture now computes it the way `+page.ts` does.

Claude-Session: https://claude.ai/code/session_01MyyCWxY1QMcUrym8C7nwwi
The same v3.2.0 re-key as the server side, reaching the e2e harness. The raw SQL
in `utils.createUnassignedFace` / `getFaceOwnerPerson` was the dangerous half:
`asset_face."personId"` and `person."id"` are strings, so `tsc` had nothing to say
about either, and both would only have failed at run time against the real schema.

`getFaceOwnerPerson` still RETURNS `personId`, deliberately — under the re-key
`personGroupId` is the id the HTTP API hands back as `PersonResponseDto.id`, which
is what its callers compare against. Same reason the base's own specs read
`"personGroupId" AS "personId"`.

The `utils.createFace({ personId })` call sites move to `personGroupId`, matching
every other spec in the suite; the route params and path templates that spell
`personId` are the public API surface and stay.

Claude-Session: https://claude.ai/code/session_01MyyCWxY1QMcUrym8C7nwwi
`SpaceAssetFaceResponseDto`'s six numeric fields move from `z.number()` to
`z.int()`, which is what unblocked the regen: v3.2.0's `patchOpenAPI` now refuses
a bare `number` with no declared format, and `sync-open-api` exited 1 naming all
six. They are `integer` columns on `asset_face`, and every sibling face DTO
(`person.dto.ts`, `sync.dto.ts`) already spells them `z.int()` — so this is the
accurate type, not a placation. The spec delta is exactly those six properties.

The SQL docs are the half that was genuinely stale: `getAssetFacesForSpace`,
`isFaceAssignableInSpace` and `getFaceOwnerLink` still emitted
`"person"."id" = "asset_face"."personId"`. They now emit `personGroupId`, and
they are still LEFT JOINs — the re-key did not quietly promote one to an inner
join. `access.repository.sql` and `face.identity.repository.sql` regenerate
byte-identical, which is the useful confirmation that `albumTimelineGate: 'none'`
reproduces the pre-cutover SQL for `checkSpaceEditAccess` exactly.

Regenerated against a scratch database built by `migrations:run` from empty, so
`1796000000000-AddAssetFaceCreatedBy` applied against live Postgres for the first
time under its new name. `migrations:generate` reports no changes, so the
declarative `asset-face.table.ts` and the migration agree.

`packages/sdk/src/fetch-client.ts` regenerates byte-identical and is therefore
absent from this commit.

Claude-Session: https://claude.ai/code/session_01MyyCWxY1QMcUrym8C7nwwi
getAssetInfo and faceManager moved into refresh-asset-people.ts when the
shared helper was extracted; DetailPanel kept importing both without using
them. Also narrow SpaceFaceEditor's unused constructor arg to `_`, the only
spelling the no-unused-vars config allows.

Claude-Session: https://claude.ai/code/session_01MyyCWxY1QMcUrym8C7nwwi
Field report (email, 2026-09-12, pr-992-rc.12): a space Editor types a
brand-new name on the face picker and taps "Create person" once, and
the face intermittently stays unassigned with no error shown.

createSpacePerson's transaction writes shared_space_person -- the same
table handleSharedSpacePersonDedup (queued on every real asset-add)
touches concurrently, in a real BullMQ worker the deployed stack
actually runs (ML being disabled does not disable this job, it needs
no ML call). This is the #864 shape already documented on
retryOnDeadlock: under concurrent load Postgres occasionally picks the
interactive create as the deadlock victim.

Confirmed directly with a 400-iteration HTTP-level stress test against
a running server: PostgresError: deadlock detected on the
shared_space_person insert, at roughly the same ~1-in-15-25 rate as
the reported flake. Wrapping the transaction in retryOnDeadlock cut
that to ~1 in 400 in the same test.

Adds the two missing regression tests that pinned this: an e2e web
test driving the exact "type a new name, tap Create person once" path
through the browser, and an API-level check that the People row
(GET /assets/:id?spaceId=...) reflects a space person created from a
brand-new face, not just an attach to an existing one.

A rarer, structurally different race remains (the create resolving to
an identity a concurrent dedup/merge pass just touched, inheriting its
blank name instead of the typed one) -- worth its own follow-up, not a
reason to hold this fix.

Claude-Session: https://claude.ai/code/session_01Fbz5xaHvK5zVbhicg1pQAx
@Deeds67
Deeds67 force-pushed the feat/space-editor-asset-permissions branch from f5b48f4 to ea45fa8 Compare September 19, 2026 22:38
main replaced the namespaced lodash import with named ones, so the remaining
`_.chunk` in the cross-owner attribution loop is now `chunk` (and its loop
variable renamed off the shadowing name). `mapAsset` is used as a value in the
asset service spec, so it cannot be a type-only import.
@Deeds67
Deeds67 force-pushed the feat/space-editor-asset-permissions branch from ea45fa8 to 6cc464a Compare September 19, 2026 22:51
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 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.

2 participants