···6677```bash
88swift build -c release | xcsift # Build release binary
99-swift test | xcsift # Run tests (44 tests, 8 suites)
99+swift test | xcsift # Run tests (63 tests)
1010swiftlint --fix # Auto-fix lint issues
1111```
12121313## Architecture
14141515- **LadderKit** (library product): consumed by other Swift packages via SPM
1616- - `PhotoLibrary` protocol + `PhotoKitLibrary` — asset discovery and export via PhotoKit
1717- - `PhotosDatabase` — reads Photos.sqlite for metadata enrichment (keywords, people, descriptions, albums, filenames, edits)
1616+ - `PhotoLibrary` protocol + `PhotoKitLibrary` — asset discovery and export via PhotoKit. `fetchAssets` preserves caller-provided identifier keys (bare UUID or full `"UUID/L0/001"`). `loadEnrichedAssets(libraryURL:)` is a one-call convenience that enumerates + enriches.
1717+ - `PhotosDatabase` — reads Photos.sqlite for metadata enrichment (keywords, people, descriptions, albums, filenames, edits) and local-availability flags.
1818 - `PhotosLibraryPath` — validates `.photoslibrary` bundles, derives database paths
1919- - `PhotoExporter` — concurrent file export with inline SHA-256 hashing
1919+ - `PhotoExporter` — concurrent file export with inline SHA-256 hashing. Partitions a batch into local vs. iCloud lanes; the iCloud lane can be throttled by an `AdaptiveConcurrencyControlling` controller.
2020+ - `LocalAvailabilityProviding` + `PhotosDatabaseLocalAvailability` — tells the exporter which assets are cached locally (`ZLOCALAVAILABILITY = 1`) so iCloud-only work can be isolated. Use `.fromLibrary(at:)` for one-call setup.
2121+ - `AdaptiveConcurrencyControlling` + `ExportOutcome` — observation-only protocol for tuning the iCloud lane. LadderKit ships no concrete controller; callers own the policy. Outcomes are `.success`, `.transientFailure`, `.permanentFailure`.
2222+ - `AppleScriptRunner` / `ScriptExporter` — iCloud-only fallback via Photos.app. Detects `-1728 "Can't get media item"` and raises `AppleScriptError.assetUnavailable`, which the exporter maps to `ExportClassification.permanentlyUnavailable`.
2323+ - `ExportError.classification` — `.other`, `.transientCloud`, or `.permanentlyUnavailable`. Flows end-to-end so callers can route retry/skip decisions without string-matching.
2024 - `StreamingHasher` / `FileHasher` — incremental and one-shot SHA-256
2125 - `PathSafety` — filename sanitization and path traversal prevention
2226 - `AssetInfo`, `AlbumInfo`, `PersonInfo`, `AssetKind` — data models (all `Codable` + `Sendable`)
2323-- **CLI** (executable): thin JSON-in/JSON-out wrapper around `PhotoExporter` for subprocess use by the attic Deno CLI
2727+- **CLI** (executable): thin JSON-in/JSON-out wrapper around `PhotoExporter`. Kept for ad-hoc use; the Swift `attic` CLI consumes LadderKit directly as a library.
24282529## Key Design Decisions
2630
+74-8
README.md
···12121313```swift
1414dependencies: [
1515- .package(url: "https://github.com/tijs/ladder", from: "0.2.0"),
1515+ .package(url: "https://github.com/tijs/ladder", from: "0.5.1"),
1616],
1717targets: [
1818 .target(
···94949595The fallback runs sequentially (one asset at a time) after all PhotoKit exports complete. SHA-256 is computed after export using `FileHasher`. Pass `scriptExporter: nil` to disable the fallback.
96969797+When Photos.app reports `-1728 "Can't get media item"` (typical for shared-album assets whose derivative has gone missing server-side), the runner raises `AppleScriptError.assetUnavailable` and the exporter classifies the error as `.permanentlyUnavailable` so callers can skip-forever instead of retrying.
9898+9799This approach is inspired by [osxphotos](https://github.com/RhetTbull/osxphotos) (MIT license) by Rhet Turnbull.
9810099101**Additional permission required:** The AppleScript fallback needs Automation permission (System Settings > Privacy & Security > Automation > ladder > Photos).
100102103103+### Adaptive iCloud-lane throttling
104104+105105+When "Optimize Mac Storage" is enabled, a batch typically mixes locally-cached assets (fast, parallel-safe) with iCloud-only assets (slow, easily throttled by iCloud). `PhotoExporter` can partition the batch and apply separate concurrency limits per lane.
106106+107107+```swift
108108+let availability = PhotosDatabaseLocalAvailability.fromLibrary(at: libraryURL)
109109+let exporter = PhotoExporter(
110110+ stagingDir: stagingDir,
111111+ library: library,
112112+ scriptExporter: AppleScriptRunner(),
113113+ localAvailability: availability,
114114+ adaptiveController: MyAIMDController() // your policy
115115+)
116116+```
117117+118118+`AdaptiveConcurrencyControlling` is observation-only:
119119+120120+```swift
121121+public protocol AdaptiveConcurrencyControlling: Sendable {
122122+ func currentLimit() async -> Int
123123+ func record(_ outcome: ExportOutcome) async
124124+}
125125+126126+public enum ExportOutcome: Sendable {
127127+ case success
128128+ case transientFailure // iCloud throttling / network blip — tune down
129129+ case permanentFailure // asset gone — ignore for tuning
130130+}
131131+```
132132+133133+LadderKit ships no concrete controller. Implement your own (AIMD, EWMA, whatever fits) or pass `nil` to run the iCloud lane at the exporter's static `maxConcurrency`.
134134+135135+### Local-availability lookup
136136+137137+`PhotosDatabaseLocalAvailability` reads `ZINTERNALRESOURCE.ZLOCALAVAILABILITY` to determine which asset originals are cached locally:
138138+139139+```swift
140140+guard let availability = PhotosDatabaseLocalAvailability.fromLibrary(at: libraryURL)
141141+else { return }
142142+143143+if availability.isLocallyAvailable(uuid: "B84E8479-475C-4727-A7F4-B3D5E5D71923") {
144144+ // export will be fast, no iCloud round-trip
145145+}
146146+```
147147+148148+### Error classification
149149+150150+`ExportError.classification` distinguishes genuinely-dead assets from transient failures, so callers can route retry/skip decisions without parsing messages:
151151+152152+```swift
153153+public enum ExportClassification: Sendable {
154154+ case other // unclassified
155155+ case transientCloud // retry later
156156+ case permanentlyUnavailable // skip forever (e.g., -1728)
157157+}
158158+```
159159+101160### Standalone Hashing
102161103162```swift
···117176118177| Protocol / Type | Purpose |
119178|---|---|
120120-| `PhotoLibrary` | Asset discovery and fetch by identifier |
121121-| `AssetHandle` | Single asset's exportable resource |
122122-| `PhotoExporter` | Concurrent export with inline hashing |
123123-| `ScriptExporter` | AppleScript fallback for iCloud-only assets |
124124-| `PhotosDatabase` | Photos.sqlite enrichment reader |
179179+| `PhotoLibrary` | Asset discovery and fetch by identifier (keys preserved) |
180180+| `PhotoKitLibrary.loadEnrichedAssets(libraryURL:)` | Enumerate + enrich in one call |
181181+| `AssetHandle` | Single asset's exportable resource (`isShared` flag for shared-album detection) |
182182+| `PhotoExporter` | Concurrent export with inline hashing, local/iCloud lane partition |
183183+| `AdaptiveConcurrencyControlling` / `ExportOutcome` | Throttle the iCloud lane with your own policy |
184184+| `LocalAvailabilityProviding` / `PhotosDatabaseLocalAvailability` | Which assets are cached locally |
185185+| `ScriptExporter` / `AppleScriptRunner` | AppleScript fallback for iCloud-only assets |
186186+| `AppleScriptError` | Includes `.assetUnavailable` for permanent `-1728` failures |
187187+| `ExportError.classification` | `.other` / `.transientCloud` / `.permanentlyUnavailable` |
188188+| `PhotosDatabase` | Photos.sqlite enrichment reader (+ `localAvailableUUIDs`) |
125189| `PhotosLibraryPath` | Library bundle validation and path derivation |
126190| `StreamingHasher` | Incremental SHA-256 |
127191| `FileHasher` | One-shot file SHA-256 |
···238302 LadderKit/
239303 AssetInfo.swift AssetInfo, AssetKind, AlbumInfo, PersonInfo
240304 PhotoLibrary.swift PhotoLibrary protocol + PhotoKit implementation
241241- PhotoExporter.swift concurrent export with inline hashing
305305+ PhotoExporter.swift concurrent export, local/iCloud lane partition, inline hashing
306306+ AdaptiveConcurrency.swift AdaptiveConcurrencyControlling + ExportOutcome
307307+ LocalAvailability.swift LocalAvailabilityProviding + PhotosDatabaseLocalAvailability
242308 AppleScriptExporter.swift iCloud-only fallback via Photos.app
243309 PhotosDatabase.swift Photos.sqlite enrichment reader
244310 PhotosLibraryPath.swift library bundle validation
···262328swift test
263329```
264330265265-44 tests across 8 suites. All tests use mock implementations — no Photos library, credentials, or network required.
331331+63 tests. All tests use mock implementations — no Photos library, credentials, or network required.