Architecture
Architecture
The stable architectural reference for cairn. Read this before making
non-trivial changes to the codebase. Working-session context (current
status, open TODOs) lives in CLAUDE.md; historical
design context lives in docs/history/.
Project name
The product is cairn — rendered all-lowercase in user-facing prose
(README, App Store, marketing). Swift identifiers stay conventional
PascalCase: package Cairn, modules CairnCore / CairnIOSCore /
CairnCLI, type struct Cairn. Binary (cairn), tag prefix
(cairn/v1/run/<id>), env vars, and file paths are lowercase.
The name does not contain “Immich” by design (App Store trademark risk, brand-confusion liability, stewardship). cairn is not affiliated with Immich; compatibility is described in user-facing prose, not in identifiers.
Repo layout
Sources/
CairnCore/ # Multi-platform pure-logic library. No Apple-only APIs.
CairnIOSCore/ # iOS-side: SwiftData/Keychain/PhotoKit/UserDefaults impls + SwiftUI screens.
UI/ # All SwiftUI views (palette, tokens, primitives, screens, root).
CairnCLI/ # `cairn` command-line tool (verify / dry-run / trash / restore / journal / history / diagnose).
Tests/
CairnCoreTests/
CairnIOSCoreTests/
iOS/ # iOS app target (XcodeGen + Fastlane). See iOS/README.md.
App/
CairnApp.swift # @main App, BGTaskScheduler.register
AppDependencies.swift # Concrete wiring
fastlane/ # Adapted from ReferenceFrame's setup
project.yml # XcodeGen config — single source of truth
Makefile # `make help` lists everything
cairn/ # HTML/JSX prototype + HANDOFF.md. Reference, not shipped.
docs/ # App Store metadata, launch copy, history.
history/ # Archived design documents (predecessor to this file).
notes/ # Audits, scratch design plans.
ARCHITECTURE.md # This file.
CLAUDE.md # Working-session crib for AI coding tools.
CONTRIBUTING.md # How to contribute.
README.md
LICENSE # MIT
Reference: Immich source
A shallow clone of the Immich repo lives at /Users/graham/code/immich
on the maintainer’s machine for reading the mobile app
(mobile/, Dart/Flutter) and server API (server/src/, NestJS +
Kysely). When in doubt about Immich’s current behavior, verify against
the actual checkout rather than against training-data knowledge — the
project moves fast.
Build / test / run
Swift package (CairnCore + CairnIOSCore + CairnCLI)
swift build # compile all targets
swift test # full test suite (~650 tests)
swift run cairn --help # CLI
CLI reads .env from CWD. Required vars: IMMICH_URL, IMMICH_API_KEY.
Never echo .env contents to tool output. Common subcommands:
cairn verify— connectivity + auth check.cairn dump-server-checksums --output FILE— write base64 SHA1s for all server assets, one per line.cairn dry-run --local-checksums-file FILE— full reconciliation, no mutation.cairn trash --local-checksums-file FILE [--yes]— destructive. Refuses on first run; forces dry-run first.cairn restore --run-id X [--asset-id ...] [--file-name-matches REGEX]— undo a run, optionally per-asset or by filename pattern.cairn journal show [--run-id X | --last]/cairn journal list— local audit log.cairn history list [--detailed]/cairn history show --run-id X— server-side reconstruction (requirestag.readon API key).cairn diagnose— visibility-class breakdown, Live Photo integrity check.
Persistent state (gitignored): ever-seen.json, deletion-journal.jsonl, exclusions.json, confirmed-deleted.json, cairn-settings.json.
iOS app
See iOS/README.md for the full setup walkthrough.
Short version:
cd iOS
make install # brew install xcodegen + bundle install
make generate # produces Cairn.xcodeproj from project.yml
open Cairn.xcodeproj
# In Xcode: select Cairn target → Signing & Capabilities → set Team
# Then: Cmd-R to build to simulator
After that:
make test # swift test
make beta # bump build, build IPA, upload to TestFlight
make release # bump build, build IPA, upload to App Store
Identity model
Asset identity on the Immich server is SHA1 of file content,
base64-encoded, exposed as AssetResponseDto.checksum
(server/src/dtos/asset-response.dto.ts:112). The server enforces
sha1 as the only supported algorithm (server/src/enum.ts:44).
The older deviceAssetId / deviceId scheme used by the Immich
mobile app was removed from the server schema in Apr 2026 (migration
1776263790468-DropDeviceIdAndDeviceAssetId.ts). Mobile still sends
those fields but they aren’t persisted. Do not build reconciliation
around them.
Cairn’s reconciliation uses a client-side observed SHA1 set to
express “delete on iPhone propagates to Immich” without touching
photos that were never on the iPhone. The store is ObservedStore
in CairnCore.
Per-(URL, userId) partitioning
ServerPartitionKey is (URL, userId) — the Immich user UUID
partitions per-server state alongside the URL. Same-URL different-user
accounts (a real testing scenario, e.g., admin + demo accounts on a
self-hosted Immich) get isolated journal / runs / observed /
quarantine / exclusions state. Without this, switching to a second
account on the same URL leaked the first account’s state into the
second.
- Userid fetch:
ImmichClient.usersMe()(GET /api/users/me) returns the active user’s UUID + email + name. Cached in Keychain alongside URL + API key. One fetch at setup; thereafter the cache is authoritative — the app boots without Immich connectivity required to pick the right partition. - Email is a label, userId is the discriminator:
userIdis the stable Immich primitive (survives email/username changes; new Immich users with the same email get a new UUID, correctly treated as different accounts). Email persists for human-readable filesystem layout and future multi-account UI. - Migration: legacy installs at the URL-only directory
(
<sanitizedURL>) are renamed in place to<sanitizedURL>__<userId>on the first activate-server call after the userId is cached. Best-effort — rename failure logs and continues with a fresh empty partition (state loss in that pathological case but no hard failure). - Opportunistic identity fetch: bootstrap activates with
userId: nilif the cache is empty (legacy install). On the first successfulclient.usersMe()after bootstrap, the identity is persisted; the next launch picks it up. Cairn doesn’t swap containers underneath an active sync. - “Indexed” stat:
model.library.indexedisLocalHashStore.allChecksums() ∩ ObservedStore.snapshot()for the active partition — “photos cairn knows about for this account.” Falls back to plainLocalHashStore.indexedCount()when ObservedStore is unavailable (transient SwiftData hiccup). - What stays global:
LocalHashStore,DeferredHashStore,LocalAssetMetadataStore. The hash cache is content-addressed (SHA1 of bytes); sharing across accounts on the same device is a real efficiency win — no rehashing 6,500 photos when switching accounts. Asymmetric design that’s worth remembering: per-(URL, user) for state derived from cairn’s actions; global for content-derived caches.
Scope-aware indexing
CairnSettings.indexingScope lets the user restrict cairn’s purview
to a hand-picked set of Photos albums
(.selectedAlbums(Set<PHAssetCollection.localIdentifier>)) rather
than the full library. Each ObservedStore entry carries a
Set<String> of album localIdentifiers — the selected-scope albums
in which cairn most recently observed the asset. The reconciler
filters observed checksums to entries where tags ∩ scope ≠ ∅ before
computing candidates: out-of-scope photos quietly exclude themselves;
legacy entries (untagged, written before scope-aware indexing) are
also out of scope under restricted scope until re-observed.
Implementation details:
StoredObservedChecksum.albumIdsCSV(SwiftData) and[String: [String]]JSON (JSONFileObservedStore) carry tags. The SwiftData field has a default"", making the field addition migration-free for SwiftData’s lightweight migration path. The JSON store transparently migrates the legacy v1[String]format on first read.recordObserved(_:)onObservedStoreupserts entries with replaced (not merged) tags — moving an asset between albums shows up correctly.setTags(for:tags:)bulk-updates tags on existing entries; used during scope-change rebuilds.recomputeScopeTagsaction (wired inCairnAppRootvia.onChange(of: model.settings.indexingScope)) walks the user’s selected albums and tags observed entries that match. Without this, toggling to a restricted scope would leave every entry untagged → out of scope → “0 candidates forever.”- The reconciler’s PhotoKit enumeration sites still walk the full
library at v1; the engine-level filter is the safety boundary. A
future iteration could push album filtering into the enumerator
(e.g.,
PHAsset.fetchAssets(in: collection)per selected album) for performance and to skip hashing out-of-scope photos. Not required for correctness. - Scope change semantics:
.fullLibrarybypasses the filter entirely (legacy behavior preserved)..selectedAlbums([])is a valid degraded state (engine emits zero candidates — safe, since “no albums picked yet” is a real intermediate state during the picking flow). Empty tags ≠ wildcard match: untagged entries are out-of-scope under restricted scope by design.
API endpoints that matter
All auth via x-api-key header.
DELETE /api/assetswith{ ids:[...], force: false }→ trash (not hard-delete). Plural path.POST /api/search/metadata(paginated) → lists assets includingchecksum. Default visibility excludeshidden; passvisibilityto filter explicitly. To get all visibilities, iterate.POST /api/trash/restore/assetswith{ ids:[...] }→ restore.POST /api/tags→ upsert tag by name.PUT /api/tags/assets→ bulk-tag assets.GET /api/tags→ list tags (needstag.read).POST /api/sync/stream→ JSONL change-event stream. Unreachable via API key auth — Immich rejects session-less requests at the service layer (sync.service.ts:78). The feature flaguseIncrementalServerSyncis wired but the path requires session auth was the missing piece and is now wired (see Settings → Advanced → Sign in to Immich). The original design doc lives atdocs/active-design/sync-stream-incremental-server-sync-plan.md.
Portability contract (Option C — Swift now, Kotlin if/when)
Decision: stay single-platform Swift for Phase 1 and Phase 2. Port
CairnCore to Kotlin directly if Android demand materializes. Not
KMP, not a Rust core.
Rules to keep that future port viable:
CairnCorestays pure Foundation + CryptoKit. No PhotoKit, SwiftData, Keychain, UIKit, SwiftUI, BackgroundTasks.- Apple-only APIs live behind protocols defined in Core. iOS provides
concrete impls in
CairnIOSCore. - The test suite is the conformance spec.
- Port order: Types → SafetyRails + ReconciliationEngine → Hashing → DeletionJournal + JournalReader → TagSchema → ImmichClient → TrashOrchestrator + RestoreOrchestrator.
LoC reality check: cairn is at ~31k LoC total, of which ~7,000 LoC are iOS-bound (impls + UI). Well above the original “1,000–2,000” plan estimate — half is feature additions beyond the original plan; half is design polish driven by the prototype. Worth knowing if Android ever becomes serious.
Tag schema (v1)
Every trash run writes cairn/v1/run/<run_id> as a tag on Immich.
One tag per run; every trashed asset (including Live Photo motion
videos) attached. <run_id> = ISO-8601 timestamp + short device id.
Schema-versioned at the path — bump to v2 for breaking changes;
old tools keep reading old tags.
API key scopes:
- Required for normal operation:
asset.read,asset.view,asset.download,asset.delete,tag.create,tag.asset. - Required additionally for
cairn historyandrestore --file-name-matches:tag.read. asset.view+asset.downloadare required for thumbnail fetching in the iOS app.
The sync.* permission family exists on the Immich server but is unreachable via API-key auth (sync.service.ts requires auth.session?.id at the service-layer guard, before any permission check). Incremental sync therefore uses a separate session-auth path — POST /api/auth/login with email + password, session token cached in Keychain, sent as Authorization: Bearer on /sync/* calls only. Wired through Settings → Advanced → Sign in to Immich.
Confirmed-deletion signal
The default reconciliation strategy is a negative signal — “checksum is in observed but no longer in current-local → delete candidate.” Vulnerable to gradual library loss (iCloud sync degradation, partial restores, “Remove from this iPhone”).
Cairn adds a positive signal that proves a checksum’s absence is a real user-initiated deletion.
How the positive signal is derived
The original design assumed cairn could enumerate
PHAssetCollectionSubtype.smartAlbumRecentlyDeleted on a schedule.
That was wrong — Apple never exposed Recently Deleted as a public
enumerable subtype. A live probe verified that
PHPhotoLibrary.fetchPersistentChanges(since:) fires a
deletedLocalIdentifiers event immediately at soft-delete time (not
deferred to the 30-day purge), so the architecture pivoted to that
API.
Current pipeline (Sources/CairnIOSCore/PhotoKitPersistentChangeReconciler.swift):
- First run (or after
PHPhotosError.persistentChangeTokenExpired): enumerate the full library, hash every asset, rebuild a[localIdentifier: Set<Checksum>]cache inLocalHashStore, snapshot the currentPHPersistentChangeTokeninPersistentChangeTokenStore. - Subsequent wakes (foreground
requestSync/BGAppRefreshTask): callfetchPersistentChanges(since: savedToken), iterate eachPHPersistentChange, collect inserted/updated/deletedlocalIdentifiers (viachangeDetails(for: PHObjectType.asset)). For each deleted id, look up its cached checksums andConfirmedDeletedStore.union(_:at: now)— this is the positive signal and it stamps the quarantine clock. For each inserted/updated id, re-hash and refresh the cache +ObservedStore; alsoConfirmedDeletedStore.remove(_:)so re-appeared assets stop being flagged. Save the new token. - Token expired → fall back to full enumeration (step 1). Token retention is system-controlled; Apple doesn’t document a retention window, so always handle this error gracefully.
The LocalHashStore is iOS-specific (PhotoKit’s localIdentifier is
Apple-only). The protocol lives in CairnCore/LocalHashStore.swift
so a Kotlin port swaps in MediaStore URIs without changing the
ReconciliationEngine.
Quarantine window
ConfirmedDeletedStore.snapshot() returns [Checksum: Date] — the
confirmation timestamp starts a per-item quarantine clock.
ReconciliationEngine partitions:
- in-quarantine:
confirmedAt + quarantineDays > now→ held for user review - past-quarantine: eligible to trash (subject to strictness)
CairnSettings.quarantineDays (range 0...90, default 14) controls
the window. Settings screen surfaces a slider below the strictness
picker.
Strictness modes
CairnSettings.deletionStrictness (default .trusting — the flip
from .strict was intentional now that quarantine provides the
primary safety):
.trusting— past-quarantine confirmed items trash; in-quarantine items wait; unconfirmed (diff-only, no positive signal) items flow through to trash. Quarantine alone is the safety window..strict— past-quarantine confirmed items trash; in-quarantine items wait; unconfirmed items also go to pending review. Paranoid mode; requires both signals.
ReconciliationOutput carries three buckets: deleteCandidates,
pendingReviewCandidates, and heldByQuarantineCandidates (a subset
of pending, distinguished so the UI can render an “eligible in N
days” countdown rather than a generic “pending” label).
Mass-offload banner
If a single requestSync confirms ≥ CairnAppModel.massOffloadThreshold
(50) deletions in one burst, the Pending Review screen surfaces a
warn-tone Callout offering a single “Bulk exclude N” action — so a
user who just offloaded hundreds of photos to free storage can
protect them in one tap rather than reviewing each.
iCloud-Optimized + Live Photos
iCloud-Optimized assets still appear in PHAsset.fetchAssets();
cairn’s enumerator downloads on demand via PHAssetResourceManager
(isNetworkAccessAllowed = true). First-sync hashing pays a network
cost but no correctness issue. Live Photos produce two checksums per
PHAsset (still + paired video) which both land in
LocalHashStore[localIdentifier]; when the id is deleted, both
checksums propagate to ConfirmedDeletedStore.
Live Photos and hidden assets
A Live Photo is one PHAsset on iOS but two Immich assets: still
(visibility: timeline) + motion video (visibility: hidden),
linked by the still’s livePhotoVideoId field. search/metadata
excludes hidden by default.
The server does NOT cascade trash through livePhotoVideoId
(verified empirically). TrashOrchestrator.run explicitly includes
linked video UUIDs in every delete batch —
TrashOrchestratorTests.livePhotoVideoIncluded pins this. locked
visibility needs an elevated-permissions flow cairn’s API key
doesn’t have; tooling skips it.
Edit semantics (PhotoKit ↔ Immich asymmetry)
This is the trickiest reasoning in the codebase and the most
surprising for users. Read this whole section before touching
EditRetirementStore or the reconciler’s edit-handling paths.
The asymmetry
When a user edits a photo in Photos.app, PhotoKit advances the
asset’s modificationDate and the rendered bytes change (so does
the SHA1). Apple’s edit model preserves the original locally —
PHAssetResource enumeration shows both .photo (original bytes)
and .fullSizePhoto (edited bytes), plus a private .adjustmentData
blob — but adjustment data never leaves Photos.app. Export,
AirDrop, or upload-to-Immich all give you flat rendered bytes; the
edit history is unrecoverable outside the device’s PHPhotoLibrary.
The Immich mobile app picks the resource tagged isCurrent (the
edited bytes — see mobile/ios/Runner/Sync/PHAssetExtensions.swift),
uploads as a new asset, server gets a separate row keyed by the new
SHA1. Net: server now has two assets per edited photo (original +
edited). Immich never deletes the original on edit; it just
accumulates versions.
What cairn cannot do
- Apply Apple’s adjustment data outside Photos.app. The
.adjustmentDatablob format is private and Apple has changed it across iOS versions (notably around iOS 13 ML edits and the iOS 16 Adjustment 2.0 work). Even if cairn captured it as a sidecar to Immich, no non-Apple tool could render the edit. The only first-class portable Apple-edits path is iCloud Photos (which syncs adjustment data across iCloud-connected devices) — Immich is not iCloud. - Backup edits losslessly. Practically, an Immich-backed photo that’s been edited has both the original and the rendered edited result on the server. Re-importing the original to Photos.app gets you back the original-content image but no editing history.
What cairn does
EditRetirementStore[id_X] records the first SHA1 set cairn ever
observed for each localIdentifier. The set is first-write-wins
(re-observation through full-enum, orphan-sweep, etc. is a no-op).
For Live Photos this is naturally a 2-element set (still + paired
motion).
While id_X is alive in PhotoKit:
- Its current bytes live in
LocalHashStore[id_X]. - Its
firstObservedset is union’d intocurrentLocalChecksumsat reconciliation time (seeAppDependencies.performLiveReconciliation’sextendedLocal). The protected SHA1s are exempt from candidate evaluation — they stay safe on Immich. - Any intermediate SHA1 (cache held it transiently between edits,
isn’t the first observed) goes through
ConfirmedDeletedStorequarantine on retirement → trashes after 14 days.
When id_X is deleted (PhotoKit deletedLocalIdentifier or the
orphan sweep catches a back-channel deletion), the firstObserved
set is union’d into removedChecksums alongside the cache’s current
bytes. Both flow through trulyAbsent filter →
ConfirmedDeletedStore.union → quarantine clock starts → trashes
after 14 days. Then editRetirement.remove(for: [id_X]) cleans up.
Worked examples
Edit → revert → edit again (SHA1_O original,
SHA1_E1/SHA1_E2 edits):
- Initial: cache
{SHA1_O}, firstObserved{SHA1_O}. Server{SHA1_O}. - Edit 1: cache
{SHA1_E1}. retired ={SHA1_O}∈ firstObserved → protect. Server gainsSHA1_E1. cairn flags nothing. - Revert: cache
{SHA1_O}. retired ={SHA1_E1}∉ firstObserved → quarantine. After 14 days,SHA1_E1trashes. - Edit 2: cache
{SHA1_E2}. retired ={SHA1_O}∈ firstObserved → protect. Server gainsSHA1_E2. - Steady state: server
{SHA1_O, SHA1_E2}. Always exactly one original + one current.
Edit → edit (no revert): same outcome. The intermediate SHA1_E1
quarantines and trashes; the original stays anchored.
Delete after multiple edits: removedChecksums includes both
current bytes and firstObserved set. Both quarantine. After 14
days, both trash on Immich. User intent — “delete on iPhone
propagates to Immich” — preserved across the whole edit history.
Caveats
- Cairn-installed-after-edit:
firstObservedends up being whatever bytes existed at first observation, not the true pre-cairn original. The pre-cairn original (uploaded by Immich earlier) is invisible to cairn (its SHA1 isn’t inObservedStoresince cairn never hashed it), so reconciliation never proposes trashing it — it stays safe on Immich by virtue of never being a candidate. Net behavior is fine: any historical Immich asset cairn never observed is permanently safe; cairn’sfirstObservedanchor for what it DOES know about adds an additional protected version. ConfirmedDeletedStore.unionis first-write-wins on the timestamp. If a SHA1 is somehow already confirmed (legacy.distantPastmigration, prior session), a freshnowstamp doesn’t replace it. For edits this is rarely an issue because intermediate edit SHA1s are typically novel content, but it’s the same pattern that bit us during the orphan-sweep regression — when in doubt, Reset Index gives a clean slate.- Per-id, not per-photo.
EditRetirementStorekeys onlocalIdentifier. If two PHAssets share a SHA1 (duplicate import via AirDrop-to-self), they get independentfirstObservedentries. Deletion of one doesn’t propagate the other’s protection.
Hashing
Use CryptoKit.Insecure.SHA1 on iOS — hardware-accelerated on all
modern Apple silicon. “Insecure” refers to cryptographic suitability;
for content-addressing it’s fine and cairn has no choice (server only
accepts SHA1). PhotoKit file I/O dominates, not the hash. Empirical
baseline: ~2.3 GB/s sustained on macOS NVMe (Hashing module).
Expect ~200–500 MB/s on iPhone NAND.
SwiftUI/UI conventions in CairnIOSCore
- All colors via
@Environment(\.cairnTokens). Components NEVER reach for rawColor.redetc. Tokens are derived fromCairnPaletteperColorScheme. - Apply theme at root:
.cairnTheme(palette). - Microcopy from the prototype is verbatim — see
cairn/HANDOFF.md“Keep these copies verbatim”. Cite source in code comments when copy is load-bearing. - Each screen has multiple
#Previewblocks (light, dark, key states) and usesCairnFixturesfor preview data. - Use existing primitives (
AppHeader,KeylineSection,CairnCard,KeyValRow,ToggleRow,Stat,Callout,CairnTabBar,ApiKeyInput,ImmichAssetThumb) — don’t re-roll.MockAssetThumbstill exists as an internal gradient fallback but call sites should useImmichAssetThumb(takesassetId: String?; falls back to the same gradient when the id is nil or the environment loader is absent, so previews keep working). - Inline “cairn” in prose uses
Text.cairnWord(monospace). Defined once inCairnPrimitives.swift; screens concat viaText("... ") + .cairnWord + Text(" ..."). The standalone hero wordmark (display-size title at onboarding / status header) is styled separately and deliberately doesn’t use this helper — that’s a logo element, not inline prose. CairnMarkrenders the SVG atSources/CairnIOSCore/Resources/Media.xcassets/CairnMark.imagesetas a vector asset (Xcode converts SVG → vector PDF at build time). Multi-color, so fixed across themes. A monochromatic variant would need.templaterendering +.foregroundStyle(t.primary)to regain theme responsiveness.- Preview-vs-runtime fixture split:
CairnAppRootfalls back toCairnFixtures.candidatesinside the.dryRunsheet only whenProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1". At runtime, a nilmodel.reconciliationrenders an empty list rather than fake candidates. Don’t reintroduce the runtime fallback — it caused the “15 fake assets on first sync” bug. - Thumbnail loader environment:
ImmichAssetThumbreads@Environment(\.immichThumbnailLoader).AppDependenciesbuilds the loader when credentials are available andCairnApp.swiftthreads it via.environment(\.immichThumbnailLoader, dependencies.thumbnailLoader). Nil loader → gradient placeholder; real loader → auth’d fetch + cache.
iOS app shell (Xcode project)
Lives in iOS/. See iOS/README.md. Key points:
- The
.xcodeprojis gitignored;iOS/project.ymlis the source of truth. Runmake generateafter editingproject.yml. iOS/App/CairnApp.swiftis the@mainApp. OwnsBGTaskScheduler.registerfor the scheduled background scan; injectsImmichThumbnailLoadervia environment.iOS/App/AppDependencies.swiftwires concrete iOS-side impls (Keychain, SwiftData, PhotoKit, ImmichClient,PhotoKitPersistentChangeReconciler,ImmichThumbnailLoader) into aCairnAppActionsbundle the UI consumes.requestSyncruns the reconciler then reconciliation, populatesmodel.reconciliation,model.library,model.lastScanBurstCounton MainActor.confirmTrash/approvePending/excludePending/bulkExcludeRecentOffloadall read from that cached reconciliation.- Onboarding’s
verifyServerpersists credentials to Keychain and rebuildsimmichClient+thumbnailLoaderon successful verify — a mid-onboarding crash no longer forces a retype, and the “first dry-run” step inherits a working client. - Fastlane lanes mirror
ReferenceFrame’s pattern — App Store Connect API key auth via env vars.
Memory of historical bugs / lessons
Each of these is something we got wrong before. Worth reading before making changes in the relevant area.
- The Immich
/server/pingendpoint returnstext/htmland 406s if you sendAccept: application/json. Don’t add an Accept header inImmichClient. IMMICH_URLmay or may not include/api.ImmichClient.normalizehandles both.search/metadataexcludeshiddenvisibility by default, which hides Live Photo motion videos.assetsForTagiterates all non-locked visibilities to surface them.PHAssetCollectionSubtype.smartAlbumRecentlyDeleteddoes NOT exist as a public case. Apple shipssmartAlbumRecentlyAdded(206) but no correspondingRecentlyDeleted. Earlier code that referenced it never compiled on iOS; the pipeline now usesPHPhotoLibrary.fetchPersistentChanges(since:). If you find code or plans referring to “enumerate Recently Deleted,” that’s the legacy path — don’t resurrect it.PHPhotoLibrary.fetchPersistentChanges(since:)firesdeletedLocalIdentifiersat soft-delete time on iOS 26.4, not deferred to the 30-day purge. Verified empirically with a probe. Retention window for the change log is not documented by Apple — always handlePHPhotosErrorPersistentChangeTokenExpiredwith a full re-enumeration fallback.CairnAppRoot’s fixture fallback is preview-only. Guarded byProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"so a nilmodel.reconciliationrenders empty at runtime, fixtures in #Previews. Caused the “15 fake assets on first sync” bug before the guard — don’t regress.CairnSettingsCodable has a custominit(from:)that decodes missing keys as defaults. Required because new fields keep being added post-release and legacy payloads would otherwise fail to decode.ConfirmedDeletedStore.union(_:at:)is first-write-wins on the timestamp — re-confirming a checksum does NOT reset its quarantine clock. Flapping assets (offload/restore/offload) still age out predictably.- Edit-bypasses-quarantine was a real regression. Earlier code
stamped every retired SHA1 from
LocalHashStore.set(_:for:)intoConfirmedDeletedStoreon retire — meaning editing a kept photo silently scheduled the original for trash on Immich. Replaced with theEditRetirementStorefirst-observed-anchor model (see “Edit semantics” above). If you see commits or branches referring to “edit-retire-to-quarantine,” that’s the wrong-semantics path — don’t resurrect it. - Persistent-change log can return 0 events even when there’s
drift.
fetchPersistentChanges(since: token)is event-relative-to-token, not authoritative for current library state. A deletion that happened before the saved token (rebuild push, prior-sync token-save, etc.) is invisible to the next fetch. The orphan sweep (reconcileCacheAgainstLibrary) is the safety net — it must run unconditionally every incremental scan, not gated onhasChanges. An earlier optimization gated it for relaunch perf; that broke the safety contract. - The Immich mobile app uploads the edited bytes, not the
original, when an asset has been edited (it picks the
isCurrentPHAssetResourcepermobile/ios/Runner/Sync/PHAssetExtensions.swift). Confirmed empirically. Affects how cairn reasons about server-side asset cohorts after edits. - The Immich
/sync/*endpoints reject API-key auth (the service-layer guard atsync.service.ts:78requiresauth.session?.id, which API keys never carry). Permissionssync.streamandsync.checkpoint.*show up in the admin UI as togglable but are functionally unreachable for API-key clients. Resolution: cairn’s iOS app exposes a separate session-auth flow (Settings → Advanced → Sign in to Immich) that hits/api/auth/login, caches the access token in Keychain, and sends it asAuthorization: Beareron/sync/*calls only. Every other endpoint still uses the API key. - The harness’s “is git repository” check is cached at session start;
running
git initmid-session leaves agent worktree isolation permanently disabled until the next session start.