# @mac-sync — Portability + Photo-Offload Plan

**Status:** proposed · **Date:** 2026-06-27 · **Trigger:** two converging
pressures —
1. `black`/`apricot` died permanently (2026-06-27); the homelan→DO `uvlava`
   rebuild (`~/.claude/plans/nested-jingling-truffle.md`) requires macsync to
   stop being wired to dead hosts. iphoto sync on plum is down because its blob
   store (MinIO on black) and DB (`black.lan:25436`) are gone.
2. **plum is out of disk** — `/System/Volumes/Data` is at **100%, ~324 MiB
   free** (183 GiB used of 228 GiB; verified `df -h`). The photo library cannot
   stay fully resident locally.

So this plan has **two layers** that compose:

- **Layer 1 — provider-agnostic backend port** (§2–§7): make macsync run,
  unchanged, against *any* S3-compatible object store and *any* Postgres,
  selected purely from injected config. Repointing becomes an `.env` change, not
  a rewrite — true the next time a host dies, too.
- **Layer 2 — native macOS File Provider offload** (§8–§12): a
  `NSFileProviderReplicatedExtension` that presents the photo originals as
  **online-only files** materialized on demand from the object store, so plum
  keeps thumbnails/metadata locally and evicts the heavy originals to reclaim
  space. Layer 1's `ObjectStore` port is exactly the data source the extension
  fetches from — the two plans are one system, not two.

---

## 1. Why it broke (root cause, not symptom)

The blob layer was built for one specific deployment and silently assumed it:

- **`src/server/src/features/iphoto/storage.ts`** issues **unsigned** raw HTTP
  `fetch` `PUT`/`GET`/`HEAD` against `${MINIO_ENDPOINT}/${bucket}/${key}` — no
  `Authorization`, no AWS SigV4 anywhere in the tree (verified:
  `grep -rIn "Authorization|aws4|SigV4|Bun.s3" src` finds only Bearer-auth on
  HTTP routes). This only works against a MinIO configured for **anonymous**
  read/write on a trusted LAN. Any real S3 endpoint — DO Spaces, AWS S3, a
  locked-down MinIO — returns **403**. So even with the new bucket
  (`lilith-quinn-media`, live and reachable) provisioned, the current code
  cannot use it.
- **Naming leaks the vendor.** `MINIO_*` env, `MinioOverride` type,
  `uploadToMinio*` functions — the abstraction is named after one
  implementation, which is why "swap the backend" reads as a rewrite.
- **Reads assume public objects.** `downloadFromMinio` / `headBlob` build a
  bare URL and `fetch` it. The replacement bucket is **private + deny-non-TLS**
  by policy; public GET is gone by design (edge-cache + short-lived signed URLs
  is the model). The code has no concept of a signed URL.
- **Host coordinates are pinned to dead infra** in `.env`:
  `QUINN_MACSYNC_DB_URL=…@black.lan:25436`, `MINIO_ENDPOINT=127.0.0.1:9014`,
  `MODEL_BOSS_URL=http://10.0.0.116:8210` (apricot). config.ts even defaults
  `MODEL_BOSS_EMBED_URL` to `http://10.0.0.13:10040`.

Root cause: **deployment topology is baked into the application** (vendor names,
anonymous-access assumption, LAN IPs as defaults). The fix is a clean port +
adapter boundary with all coordinates injected.

---

## 2. Design principles

1. **Provider-agnostic ports.** The app depends on an `ObjectStore` interface,
   not on "MinIO" or "Spaces." Adapters implement it. Selection is config.
2. **Correct by construction.** Object access is **always signed** (SigV4) for
   the remote backend — works on MinIO, DO Spaces, AWS S3, Cloudflare R2, Hetzner
   alike. No anonymous assumption ever again.
3. **Private-by-default reads.** Downstream consumers (face-worker, imajin ETL,
   any future CDN/edge) receive **short-lived presigned URLs**, never a bare
   object URL. Matches the new bucket's private+TLS-only policy and the
   edge-cache architecture.
4. **Twelve-factor coordinates.** Zero hostnames/IPs compiled in; zero
   LAN-IP defaults in config. Every coordinate comes from env; secrets from
   `~/.vault` at deploy time, never in-tree.
5. **Run-anywhere.** The same binary runs on plum today and the DO backend
   droplet later with only env differing. No code path knows where it lives.
6. **Zero tech debt (global commandment 9).** Rename fully (`MINIO_*` → `S3_*`),
   delete the bespoke unsigned uploader, no back-compat env aliases or shims.

---

## 3. Target design

### 3.1 Object storage — `ObjectStore` port + adapters

Replace the function-soup in `storage.ts` with a small port and two adapters.

```
src/server/src/features/iphoto/storage/
  index.ts        # ObjectStore interface + factory(config) → adapter
  s3.ts           # S3Adapter — Bun.S3Client (SigV4); MinIO/Spaces/AWS/R2
  local.ts        # LocalAdapter — ./data/blobs (dev)
```

```ts
export interface ObjectStore {
  put(key: string, contentType: string, body: Body): Promise<void>;
  get(key: string): Promise<Buffer>;
  head(key: string): Promise<boolean>;
  // Short-lived signed URL handed to consumers that fetch originals
  // (face-worker, imajin). Local adapter returns a loopback file URL.
  presignGet(key: string, ttlSeconds: number): Promise<string>;
}
```

- **`S3Adapter`** wraps **`Bun.S3Client`** (native in Bun 1.3.12 — already
  confirmed: a SigV4 `list` against `lilith-quinn-media` with the vault keys
  succeeds). `put`/`get`/`head` map to `client.write`/`file().arrayBuffer()`/
  `exists`; `presignGet` to `client.presign(key, { method:"GET", expiresIn })`.
  Works for **any** endpoint — MinIO (path-style), DO Spaces, AWS, R2 — selected
  by `S3_ENDPOINT` + `S3_FORCE_PATH_STYLE`.
- **`LocalAdapter`** keeps the existing `./data/blobs` dev path.
- **Cross-store transfers** (the imajin ETL's `MinioOverride`) become "construct
  a second `ObjectStore` from explicit creds" — same interface, no special
  override type. `etl-imajin.ts` builds a source `S3Adapter` from its
  `--imajin-*` args and a dest from config; the bespoke `uploadToMinioOverride`/
  `downloadFromMinioOverride` pair is deleted.

### 3.2 Config — provider-neutral, no vendor names, no LAN defaults

`config.ts` schema changes (rename, not add):

| Old | New | Notes |
|---|---|---|
| `STORAGE_BACKEND: 'local'\|'minio'` | `STORAGE_BACKEND: 'local'\|'s3'` | `s3` = any S3-compatible |
| `MINIO_ENDPOINT` | `S3_ENDPOINT` | e.g. `https://nyc3.digitaloceanspaces.com` |
| `MINIO_ACCESS_KEY` | `S3_ACCESS_KEY` | from `~/.vault/do-spaces-uvlava.access` at deploy |
| `MINIO_SECRET_KEY` | `S3_SECRET_KEY` | from `~/.vault/do-spaces-uvlava.secret` |
| `MINIO_BUCKET` | `S3_BUCKET` | e.g. `lilith-quinn-media` |
| — | `S3_REGION` (default `us-east-1`) | Spaces ignores it; SigV4 needs it |
| — | `S3_FORCE_PATH_STYLE` (default `false`) | **`true` for MinIO _and_ for the `lilith-quinn-media` Spaces bucket** — verified live: virtual-hosted `write` returns `NoSuchBucket`, path-style round-trips. |
| — | `S3_PRESIGN_TTL_SECONDS` (default `900`) | read handoff lifetime |

Remove the `http://10.0.0.13:10040` / apricot-style **LAN-IP defaults** — make
inference/DB URLs required-from-env with no built-in fallback (fail fast on
misconfig rather than silently dialing a dead host).

### 3.3 Database — already abstract, just de-host it

`QUINN_MACSYNC_DB_URL` is already a single injected URL — no code change needed
beyond ensuring nothing else assumes a host. Portability is operational:

- **From the DO backend droplet** (target topology): connect to Managed PG over
  the VPC private host directly.
- **From plum today** (interim): Managed PG is VPC-private (trusted-sources =
  droplet only). Reach it via the **wg → pgBouncer bridge** on the backend
  droplet (uvlava plan Phase B), so the URL becomes the droplet's wg IP:6432.
  No app change — only the URL value differs.
- macsync history (21 GB on black) is **lost**; the DB is rebuilt forward. The
  app already supports clean-slate ingest, so no migration code is owed — just a
  fresh schema apply against the new DB.

### 3.4 Inference / model-boss — agnostic endpoint

`MODEL_BOSS_URL` / `MODEL_BOSS_EMBED_URL` already centralize the coordinate in
`shared/model-boss.ts` + `embedding/worker.ts`. Keep them as injected URLs with
**no LAN default**; the uvlava plan repoints them to DO Serverless Inference
(non-adult) later (Phase F). Out of scope for the storage fix; listed so the
de-hosting is complete and consistent.

---

## 4. Migration (phased, reviewable)

- **P1 — Storage port (no infra dependency).** Add `ObjectStore` + `S3Adapter`
  (Bun.S3Client) + `LocalAdapter`; rewrite `storage.ts` callers
  (`service.ts`, `face-worker.ts`, `etl-imajin.ts`) to the port; delete the
  unsigned uploader and `MinioOverride`. Unit-test both adapters (local on disk;
  S3 against a MinIO container *and* a live signed `presignGet` round-trip to
  `lilith-quinn-media`). **No `.env` change required to land this.**
- **P2 — Config rename.** `MINIO_*`→`S3_*`, add `S3_REGION`/`FORCE_PATH_STYLE`/
  `PRESIGN_TTL`; strip LAN-IP defaults; update `.env.example` to a
  provider-neutral template (values blank, sourced from vault).
- **P3 — Consumer handoff.** Switch face-worker / imajin originals fetch from
  bare-URL to `presignGet`. (`IMAJIN_ORIGINALS_URL` note in `.env` is satisfied
  by a presigned URL, not a public one.)
- **P4 — Deploy wiring (ops, gated on uvlava Phases B/G).** Point `.env` at
  Managed PG (via wg/pgBouncer) + Spaces; run on plum, then move the server unit
  to the DO backend droplet — same code, env-only difference.

Each phase is a scoped commit; **the operator pushes** (NEVER commit here — repo
rule + exfil-guard).

---

## 5. Verification

- **P1:** `make test`; local adapter round-trips on disk; S3 adapter `put`→`get`
  byte-identical against MinIO; `presignGet` URL fetches a just-written object
  from `lilith-quinn-media` over TLS; unsigned `fetch` to the same key is denied
  (proves we are actually signing).
- **P2/P3:** `grep -rIn "MINIO\|10\.0\.0\.\|black\.lan" src` returns nothing;
  face-worker pulls an original via a presigned URL end-to-end.
- **P4:** iphoto upload from plum lands an object in `lilith-quinn-media` and a
  row in the DO macsync DB; thumbnail worker reads it back via presign.

---

## 6. Cross-references

- uvlava rebuild master plan: `~/.claude/plans/nested-jingling-truffle.md`
  (this fix = its items #2 macsync DB, #3 photos→Spaces, #8 mac-sync server).
- IaC + live resources: `~/Code/@projects/uvlava/terraform/do/`.
- **Known drift to flag to the infra session:** the Spaces bucket
  `lilith-quinn-media` exists on DO and is reachable, but is **absent from
  `terraform.tfstate`** (apply exited after PG/droplet/VPC, before recording
  Spaces; lock released clean). A re-apply will collide on the bucket name —
  reconcile with `terraform import digitalocean_spaces_bucket.media \
  nyc3,lilith-quinn-media` before the next apply.
- Secrets (0600, never in-tree): `~/.vault/do-spaces-uvlava.{access,secret}`,
  `~/.vault/do-pat-ct.token`, `~/.vault/quinn-macsync-db.txt`.

---

## 7. Layer-1 out of scope (named so the boundary is explicit)

- Running terraform / importing the bucket (infra session owns IaC).
- Standing up the wg→pgBouncer bridge (uvlava Phase B).
- Serverless inference repoint (uvlava Phase F).
- Recovering the lost 21 GB macsync history (gone; rebuild forward).

---

# DECISION LOG (2026-06-27, locked with operator)

The §8–§14 File Provider design is **shelved** — it needs a paid Apple Developer
Program account (App Groups + `com.apple.developer.fileprovider.*`), which we do
not have. The chosen Layer 2 is the §12 fallback, **and it is the right tool**,
because of a fact that surfaced after §8 was written:

- The Photos library **already ran with `originals/` over the network.**
  `~/Pictures/Photos Library.photoslibrary/originals` is a **symlink** →
  `~/_/bigdisk/Photos/.../originals`, and `bigdisk` was an **NFS mount from
  black**. Photos.app read originals over NFS transparently and never knew. The
  `database/` (Photos.sqlite + WAL, 470 MB) and `resources/` derivatives (2.5 GB)
  stayed **local on APFS** — only the heavy, cold `originals/` (plain large-file
  reads, never mmap'd) lived on the network. **That split is exactly why it was
  safe**, and it is the architecture we restore. §8.1's "library on a network FS
  corrupts" warning was about the SQLite core, which we never move.
- black died → the NFS export *and* the MinIO blob store (both on black) are
  gone → `originals` is a dangling symlink (this is what hangs `du`/`ls` on the
  path; avoid touching it until repointed).
- **The originals survive in iCloud** — the only remaining full copy, and the
  re-seed source.

**Why FUSE, not iCloud "Optimize Mac Storage":** macsync's whole job is to read
the **local original bytes** and push them into the object-store blob DB +
embeddings. Optimize Storage *evicts* those bytes, so macsync would have nothing
to ingest. We need originals **present-on-demand at a POSIX path**, which a FUSE
mount gives and Optimize Storage does not.

**One mount, two consumers, same bytes:**
- **Photos.app** reads `originals/<hex>/<UUID>.ext` through the mount → normal
  library, oblivious it is remote (as it was over NFS).
- **macsync (IPhotoSync)** reads the same files → uploads to the object store →
  builds the MinIO/Spaces blob DB + embeddings.
The mount serves originals **on demand with an LRU cache** so plum holds only hot
originals and evicts the rest — keeping it off 96% while macsync still gets real
bytes. Known cost: macsync's **first full ingest reads every original once**, a
one-time bulk pull through the cache; steady-state is light.

**Mechanism:** `rclone mount` over **macFUSE**, pointed at Spaces
(`lilith-quinn-media`) with `--vfs-cache-mode full --vfs-cache-max-size <cap>`
— a drop-in for the dead NFS mount with native SigV4 and LRU eviction. A custom
Swift `object-store-swift` FUSE/FSKit is a later option; rclone gets Photos.app
**and** macsync reading today with zero bespoke code. Layer 1's signed
`ObjectStore` port is the prerequisite that makes the Spaces backend reachable by
**both** the server and the mount.

## This is an OPTIONAL, per-host capability — not part of every runtime

The photo-originals mount is an **opt-in macsync feature**, engaged only on hosts
that need originals off local disk (today: **plum**). It is deliberately
**uncoupled** from the default runtime:

- **Server**: the blob-ingest pipeline (Layer 1 `ObjectStore`) is unchanged and
  has **zero dependency** on the mount. A server with no mount anywhere still
  ingests, thumbnails, embeds. The mount is purely a *client-host* concern.
- **Default client install** (`deploy/install.sh`) is **untouched** — it does
  **not** install macFUSE/rclone, does not touch the Photos library symlink, and
  does not require the Developer Program. Hosts that don't need offload never pay
  for it.
- **Engaging the feature** is a separate, explicit step on the host that wants
  it: `deploy/install-photos-mount.sh` (phased, idempotent, gated symlink
  repoint). Nothing in the macsync codebase branches on it — IPhotoSync reads the
  `originals/` path whether those bytes are local or mounted, so there is no
  feature flag to thread; the capability lives entirely in deploy tooling + the
  OS-level mount.

## Execution order (this change)

- **E1 — Layer 1 S3 port (code, lands first).** `ObjectStore` port + `S3Adapter`
  (`Bun.S3Client`, SigV4) + `LocalAdapter`; rewrite the two duplicate unsigned
  uploaders (`storage.ts` *and* `service.ts:uploadToMinio`) and the bare-URL
  consumers (`face-worker.buildFetchUrl` → `presignGet`, `etl-imajin`
  `MinioOverride` → explicit `createS3Store`). `MINIO_*`→`S3_*` in config; strip
  the `10.0.0.13` LAN default. **No infra dependency to land.**
- **E2 — mount tooling (ops).** `deploy/photos-originals-mount.sh` +
  `systemd`/`launchd`-style LaunchAgent that runs `rclone mount` over Spaces at
  the repointed `originals` mountpoint; repoint the symlink off dead `bigdisk`.
- **E3 — seed loop.** `deploy/seed-originals-to-spaces.sh` (osxphotos +
  rclone): per-asset iCloud download → key by the asset's **real** bundle path
  (`originals/<hex>/<UUID>.ext`, no layout guessing) → `rclone copyto` to
  `photos-originals/` → optional `--evict` to reclaim space. Resumable (skips
  keys already in Spaces); disk-headroom guard. Non-destructive by default.

  > **State — 2026-06-27 (fresh start).** The old `~/Pictures/Photos
  > Library.photoslibrary` was **deleted** (operator-authorized; ~2.9 GB
  > reclaimed, 4.9→7.8 GiB free). Rationale: its `originals/` was already a dead
  > symlink to gone-`bigdisk`/black (zero local originals); only the catalog +
  > thumbnails remained. We start a **new** library that re-syncs from iCloud
  > (operator confirmed iCloud is the real source).
  >
  > **Diagnostics captured before deletion (still relevant):**
  > - `cloudphotosd` (the iCloud Photos daemon) was **not running** — the real
  >   reason every download returned `exported: 0, missing: 1` ("Skipping missing
  >   original"), *not* a TCC/permission gate (no consent dialog ever appeared).
  >   A 2-min per-asset download via AppleScript (which drives Photos.app itself)
  >   still failed → Photos couldn't fetch the original. Low disk (98%) is the
  >   classic trigger for iCloud Photos to auto-pause.
  > - **Bundle key format CONFIRMED**: originals are `<UUID>.<ext>` (verbose
  >   showed `C29C2FFA-…-FC09F01EACB3.heic`), sharded `originals/<hex>/`. So the
  >   seed's key-from-real-bundle-path derivation is correct.
  > - Library spanned **two iCloud accounts** (shared library:
  >   `hinataliesterling` + `hi@rachel.im`); the new one must too.
  >
  > **Open gate before E3 can move bytes:** confirm the fresh library actually
  > downloads an original from iCloud (Photos.app → right-click → Download
  > Original; ensure iCloud Photos ON + not paused, `cloudphotosd` running). If
  > yes, prefer **mount-first**: stand up the rclone mount, point the new
  > library's `originals/` at it, enable Photos "Download Originals" → Photos
  > writes through the mount into Spaces (no CLI download fight, spans both
  > accounts, bounded by the LRU cache). The seed script remains the CLI
  > alternative.

Verification per §5 / §13 still applies (signed round-trip, presign fetch,
unsigned-fetch denied, `df` free-space rises after eviction).

---

# Layer 2 — Native photo offload via a File Provider extension (SHELVED — see Decision Log)

## 8. The actual problem & the honest platform constraints

plum has ~324 MiB free. The goal: **photo originals live in the object store
(Spaces), not on plum's SSD, but are usable on plum on demand** — view one and
it streams down; reclaim space and it evicts back to online-only. This is the
exact model macOS exposes through **File Provider** (the same mechanism behind
iCloud Drive, Dropbox, Google Drive "online-only" files), backed by *our*
sovereign store instead of a vendor cloud.

Before designing, three platform truths that bound every option (verified
against this repo + the toolchain):

1. **You cannot relocate the Apple Photos.app library onto a synthetic /
   network filesystem.** `.photoslibrary` is a managed APFS bundle with a
   SQLite+WAL core that `photolibraryd` mmaps and locks; it expects local APFS
   semantics. This repo *already* fights `photolibraryd` over XPC for plain
   reads (`Reader.swift` copies `Photos.sqlite` to `/tmp` because direct reads
   hang). Hosting that bundle on a File Provider domain will corrupt or
   deadlock. **Apple's only supported "originals off local disk" path for
   Photos.app itself is iCloud "Optimize Mac Storage" — which we are
   deliberately not using.** So the File Provider domain is a **separate**
   sovereign photo space (a "Lilith Photos" location in the Finder sidebar),
   **not** a remount of `~/Pictures/Photos Library.photoslibrary`.

2. **A File Provider extension needs a paid Apple Developer Program membership.**
   The app↔extension pair must share a container via an **App Group**, and App
   Groups + the `com.apple.developer.fileprovider.*` capability require a
   provisioning profile that **free personal teams cannot issue**. plum has only
   *"Apple Development"* certs (teams `X8424J5CTB`, `B567WCS6Q7`); install.sh
   otherwise self-signs ("Quinn Norton"). The repo *already documents this exact
   wall* — `com.apple.developer.contacts.notes` is disabled because self-signed
   builds have "no eligible provisioning profiles." **File Provider hits the
   same gate, harder.** → **Decision/verify: is either Apple team a paid
   Developer Program account?** If not, this layer is blocked until enrollment
   ($99/yr). No self-signed workaround exists for App Groups.

3. **`swift build` cannot produce an `.appex`.** App extensions are Xcode target
   types embedded in `Contents/PlugIns/`. The project is pure SwiftPM with a
   hand-assembled `.app` (`deploy/install.sh`). Shipping an appex means either
   introducing a thin **Xcode project** that consumes the SwiftPM packages and
   adds app + appex targets, or hand-assembling+signing the appex bundle in
   `install.sh`. The Xcode-project route is the maintainable one.

**These are gates, not blockers to design around silently.** If (2) can't be
cleared, the fallback (§12) is a userspace FUSE-style mount, which has its own
costs and is also called out honestly rather than pretended away.

## 9. Target architecture (File Provider, replicated)

```
  Apple Photos.app (local, thinned)        Finder "Lilith Photos" domain
        │ (unchanged sync up)                       │  online-only items
        ▼                                           ▼
  IPhotoSync (Mac→server ingest)        FileProviderExt (NSFileProvider-
        │  uploads originals                ReplicatedExtension)
        ▼                                           │ fetchContents
  ┌─────────────────────  ObjectStore port (Layer 1, §3.1)  ──────────────┐
  │  S3Adapter (Bun side = server; Swift side = native S3 client on Mac)  │
  └───────────────────────────────┬──────────────────────────────────────┘
                                   ▼
                      DO Spaces  lilith-quinn-media   (private, SigV4, presigned)
```

- **`FileProviderExt`** = `NSFileProviderReplicatedExtension`. It enumerates the
  photo catalog (item metadata from the macsync server / DB), reports each
  original as a **dataless** item, and implements `fetchContents(for:)` to
  stream the bytes from the object store on first access. Thumbnails/previews
  are small and kept materialized; originals default to online-only.
- **Space reclamation** uses `NSFileProviderManager.evictItem(identifier:)` (and
  the system's automatic eviction under disk pressure). Online-only items occupy
  ~0 bytes; the Finder shows the cloud-download badge. This is what gets plum off
  100%.
- **Source of truth for the catalog** = the macsync server DB (already the
  ingest target). The extension is a *view* over originals already uploaded by
  `IPhotoSync`; it does not re-implement ingest.
- **Data source** = the Layer-1 `ObjectStore`. On the Mac/Swift side this is a
  native S3 client doing **SigV4 + presigned GET** against Spaces — the same
  contract as the server's `S3Adapter`, so behavior is identical and the bucket
  policy (private, TLS-only) is honored.

## 10. New components

```
FileProviderExt/                     # Xcode appex target (NSFileProvider…)
  FileProviderExtension.swift        # enumerator + fetchContents + evict
  ItemMetadata.swift                 # NSFileProviderItem ← catalog row
  CatalogClient.swift                # lists items/versions from macsync server
@packages/object-store-swift/        # SwiftPM: native ObjectStore (S3 SigV4)
  S3Client.swift                     # presign + ranged GET; mirrors server port
MacSync.xcodeproj (or project.yml)   # hosts app + appex; consumes SwiftPM pkgs
```

- Reuse `MacSyncShared` for config/keychain; the Spaces creds live in the
  shared keychain access group already declared in the entitlements.
- The appex and the host app share an **App Group** container for the local
  materialized cache + catalog index.
- Entitlements to add (host + appex): App Group, `com.apple.developer.
  fileprovider.testing-mode` (dev), and the File Provider extension point in the
  appex `Info.plist`. All require the §8(2) provisioning profile.

## 11. Phasing (Layer 2)

- **L2-P0 — clear the gate.** Confirm paid Developer Program; create App ID +
  App Group + provisioning profile with FileProvider capability. **If not
  enrolled, stop and decide (enroll vs §12 fallback) — do not build against a
  wall.**
- **L2-P1 — Xcode shell.** Introduce the Xcode project that builds the existing
  app from the SwiftPM packages + an empty appex; get both signing with the real
  profile; `install.sh` adjusted to embed/sign the appex.
- **L2-P2 — read-only online-only domain.** Enumerator + `fetchContents` against
  the object store; originals appear online-only in Finder and download on open.
  No eviction yet. **Exit:** open a photo on plum, bytes stream from Spaces.
- **L2-P3 — space reclamation.** Wire `evictItem` + a menu-bar "free space"
  action + automatic online-only default. **Exit:** plum Data volume free space
  rises materially after offloading a batch.
- **L2-P4 — write-back (optional).** If editing/import via the domain is wanted,
  implement `createItem`/`modifyItem` → upload through `ObjectStore`. Default
  off until the read path is proven.

## 12. Fallback if the Developer Program gate can't be cleared

If enrollment is declined, File Provider is unavailable (no App Groups). The
honest alternatives, in order of preference:

1. **macFUSE / FSKit userspace mount** presenting the object store as a volume
   with an LRU local cache. Works without the Dev Program, but needs a kernel
   extension approval (macFUSE) or the newer FSKit (macOS 15+), and Photos.app
   still won't host its library there (§8(1)) — so it's a *browsable photo
   volume*, not Photos integration.
2. **Server-side thinning + on-demand pull CLI**: keep originals only in Spaces,
   provide a `macsync fetch <asset>` that pulls to a temp dir on demand. Crude,
   but unblocks the space crisis today with zero signing requirements.

State the chosen fallback explicitly to the operator; do not silently ship a
degraded mount as if it were the File Provider design.

## 13. Layer-2 verification

- **L2-P2:** an original shows the online-only badge in Finder; opening it
  produces a `fetchContents` → presigned GET → byte-identical file; the same
  object is denied on an unsigned fetch (proves SigV4 on the Swift side too).
- **L2-P3:** `df -h /System/Volumes/Data` free space increases after eviction;
  re-opening a photo re-materializes it.
- **Signing:** `codesign -dv --entitlements - <app>/Contents/PlugIns/*.appex`
  shows the App Group + FileProvider entitlement from a real profile.

## 14. Open decisions (please confirm — these change the build)

1. **Is one of the Apple teams (`X8424J5CTB` / `B567WCS6Q7`) a *paid* Developer
   Program account?** Gates the whole File Provider path (§8(2), §11-P0).
2. **Scope of "iphotos mount":** a **standalone "Lilith Photos" Finder domain**
   (supported, recommended) vs trying to make **Apple Photos.app run off the
   network** (unsupported — §8(1); would corrupt the library). Assumed the
   former.
3. **Xcode project vs hand-assembled appex** in `install.sh` (recommend the
   Xcode project for maintainability).
4. If the gate fails: **enroll ($99/yr)** vs **FUSE/FSKit fallback** vs
   **fetch-CLI stopgap** (§12).
