docs(immich): build spec for edit sync (#224) - #650
Conversation
Captures the design conversation that produced the v0.4.0 plan for
syncing non-destructive edits with Immich. Replaces design-photo-
editing.md section 5 (which targeted PUT /assets/{id}/original — an
endpoint that has been removed in current Immich main).
Key decisions documented:
- Two mechanisms by edit family: native /assets/{id}/edits API for
crop/rotate/mirror, render+upload+stack+tag+XMP for everything
else.
- Original assets are never modified.
- Local edits table remains source of truth; server-side artefacts
are projections.
- Recovery via custom XMP block embedded in the rendered JPEG —
schema namespace urn:moments:edits:1.0, fields originalAssetId /
originalContentHash / editVersion / renderedAt / editJson.
- Server-side discovery via a moments-edit tag on rendered assets.
Verified empirically against v2.7.5: tag is invisible in the
photo info panel, surfaces only via sidebar/search.
- Stacks join the issue #628 heartbeat reconciliation as the fifth
reconciled table.
The spec is split into four phases (A: schema + sync wiring, B:
geometric edits, C: pixel render+stack, D: recovery). Phases A and B
are independent and can land in parallel; C depends on A; D depends
on C.
Open questions are explicit in section 11 — most importantly,
empirically verifying the corrected nested-parameters payload for
PUT /assets/{id}/edits before Phase B implementation begins (the
first probe used a flat shape and saw 500s; the OpenAPI spec on main
confirms the nested form is correct).
Issue #641 (server_updated_at / server_edit_hash on edits table) is
superseded — its premise was "server has bytes that diverged from
local" which doesn't apply in either of the two new mechanisms. Only
the column for "which Immich asset holds our render" survives, and
that's covered in the new spec's §3.2 migration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Claude finished @justinf555's task in 2m 42s —— View job PR Review: docs(immich): build spec for edit sync (#224)
|
| Mutation variant | entity_type | action | payload |
|---|---|---|---|
AssetEditsApplied { id, actions } |
"asset" |
"apply_edits" |
{"actions": [...]} |
AssetEditsCleared { id } |
"asset" |
"clear_edits" |
— |
StackCreated { primary_asset_id, original_asset_id } |
"stack" |
"create" |
{"primary_id": "...", "original_id": "..."} |
| ... |
Design gap — §7.2 step 8 ordering dependency unspecified
- After push completes and external_id is stamped on the new media row: record
StackCreatedandAssetTaggedMomentsEditoutbox entries.
The outbox drains via PushManager::push_one sequentially, but the new media row for the rendered asset doesn't have its external_id set until the upload succeeds. The spec doesn't say how StackCreated finds the rendered asset's external_id — does AssetImported push return the new id synchronously? Does push_one stamp it before returning? Or does server_rendered_asset_id need to be set via a callback?
This is subtle but important: if StackCreated is queued before the rendered asset's external_id is available, lookup_media_external_id in the push handler will fail. The spec should say explicitly whether StackCreated is queued after AssetImported completes in-process, or whether the push handler for AssetImported is responsible for enqueueing it.
Minor — straighten_degrees not classified
EditState.TransformState in design-photo-editing.md includes straighten_degrees: f64 (freeform ±45°). §5.3's action mapping only covers crop/rotate (multiples of 90°)/mirror and is silent on straighten. The spec's EditState::is_geometric_only() predicate needs to explicitly classify straighten — it's almost certainly a pixel adjustment (requires interpolation), not a geometric one, but the spec doesn't say so. If it's misclassified as geometric, Phase B would silently try to send a float angle to Immich's rotate action, which only accepts 90/180/270.
Minor — §11.6 open question already resolved by this PR
§11.6 asks "What about section 5 of design-photo-editing.md?" — this PR already answers it (shrinks to a 7-line pointer). The question can be crossed off or removed from the open-questions list. Fix this →
Minor — persist_stack_locally helper undefined
§4.4 StackCreated push handler calls self.persist_stack_locally(...) but this helper is not specified anywhere. At minimum the spec should note it as a new method on PushManager and describe what it does (insert into stacks, update media.stack_id for both members).
Minor — hardcoded local path in §10.2
API key at `/home/justin/.config/immich-test/api_key`
The make test-immich target should read this from an env var (IMMICH_TEST_API_KEY_FILE or similar) so CI can supply it without the path being tied to a developer's home directory.
Nits
- §4.2, step 3:
editing().upsert_edits()— verify this method exists or is planned; the currentLibraryEditingtrait indesign-photo-editing.mdexposessave_edit_state, notupsert_edits. Alignment between the trait name and the handler call would reduce friction at implementation time. quick-xmldependency will need to be added toCargo.tomlforsrc/renderer/xmp.rs. Worth noting in the Phase C checklist.- §5.2
originalContentHashis documented as "SHA-1, base64" — confirm this matches Moments' existingmedia.content_hashformat (the CLAUDE.md doesn't mention the hash algorithm). If they differ, the fallback lookup in §7.4 step 4 will silently fail to match.
Overall: Approve the direction and the phased plan. The two critical items (copy-paste face.asset_id and the contradicted recovery SQL) should be fixed before the spec is used to drive Phase B/C implementation — the rest are gaps that can be clarified at implementation time but would be cleaner to resolve now.
📊 Code Complexity ReportTop 20 functions by cognitive complexity · job log |
🔒 Security Report
|
| In Immich v2.7.5 web UI: | ||
|
|
||
| - Photo info panel: **does not display tags** (verified empirically). | ||
| - Sidebar Explore → Tags page: lists `moments-edit` as a category; clicking shows tagged assets. |
There was a problem hiding this comment.
Sidebar doesn't have Tags as a subsection anymore
CI Summary
|

Summary
Captures the design conversation that produced the v0.4.0 plan for syncing non-destructive edits with Immich. New file
docs/design-immich-edit-sync.md; section 5 ofdesign-photo-editing.mdshrinks to a 7-line pointer at the new doc.The original design proposed
PUT /assets/{id}/originalto overwrite the user's original on the server. That endpoint was removed in current Immichmain. Empirical probing of v2.7.5 (running locally) confirmed the only viable mutation surfaces are/assets/{id}/edits(crop/rotate/mirror only) andPOST /assets+POST /stacksfor everything else.What's in the spec
/assets/{id}/editsAPI for crop/rotate/mirror, render+upload+stack+tag+XMP for exposure/colour/vignette/etc.editstable stays the source of truth; server-side artefacts are projections.urn:moments:edits:1.0) embedded in the rendered JPEG. CarriesoriginalAssetId,originalContentHash,editVersion,renderedAt,editJson. Lazy parse on edit-open if local row is missing — supports new-install / second-machine scenarios.moments-edittag on rendered assets. Verified on v2.7.5: invisible in the photo info panel, surfaces only via sidebar / search.Phased plan
Four phases, sequenced for incremental review:
/assets/{id}/editsA and B are independent and can land in parallel. Each phase has explicit acceptance criteria in §9 of the spec.
Open questions
Six listed in §11 of the spec. The blocker before Phase B implementation:
PUT /assets/{id}/editswith the corrected nested-parameterspayload. The first probe used a flat shape and saw 500s; the OpenAPI spec on main confirms{action, parameters: {…}}is the correct form.Knock-on effects
size/L— the spec splits it into four phases.Test plan
make lint(no code changes; doc-only)PUT /assets/{id}/editswith the corrected payload (§11.1) before Phase B startsPOST /assetsround-trip (§11.2) before Phase C starts🤖 Generated with Claude Code