Conversation
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
force-pushed
the
feat/space-editor-asset-permissions
branch
from
August 23, 2026 11:08
aaab4de to
65a24a6
Compare
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
force-pushed
the
feat/space-editor-asset-permissions
branch
from
August 24, 2026 20:08
c8f9998 to
bb08f0d
Compare
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.
🧪 Release candidate buildLatest RC Images published
How to run this RCIn the directory containing your services:
immich-server:
image: ghcr.io/open-noodle/gallery-server:pr-992-rc.15Then pull and restart: docker compose pull immich-server
docker compose up -dEach 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 Previous builds (15)
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 |
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
force-pushed
the
feat/space-editor-asset-permissions
branch
from
September 19, 2026 22:38
f5b48f4 to
ea45fa8
Compare
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
force-pushed
the
feat/space-editor-asset-permissions
branch
from
September 19, 2026 22:51
ea45fa8 to
6cc464a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
isOwnergates inAssetViewerNavBar.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
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.
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 inaccess-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.creategated onAssetUpdate, which this change widens. SinceStackUpdate/StackDeleteare 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 /facesreachesAssetUpdate— accepted, not fixed. The design originally claimed face writes don't go throughAssetUpdate; that was wrong.PersonService.createFacegates 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 —TagPeopleand 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/:idresolves 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 (
canEditon the single-asset response) and per selection (POST /assets/editable). The batch endpoint's body is a barecheckAccesscall — deliberately the same call the write makes, so the answer cannot drift from enforcement.canEditis optional and never set bymapAsset: 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 wrongfalsefor 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 wiringTagServiceneeded either aprotectedmethod on the upstream-hotBaseServiceor 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-armUNIONwith correlatedEXISTS. Every deny case was mutation-proved non-vacuous: flip the single property under test and it turns to grant.uigreen with 0 hangs.Server medium needs
--maxWorkers=4locally; 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/editablehas 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@ChunkedSeton the DB side.AssetViewer.onAssetUpdatereplaces the viewer's asset wholesale, andcanEditis set only inAssetService.get. Every emitter today re-fetches or is owner-gated, but a future one passing a rawupdateAssetresponse would silently stripcanEditand the widened actions would vanish mid-session.Design and plan are committed under
docs/superpowers/specs/anddocs/superpowers/plans/.