Kanora started with the normal Apple-app answer for a local music library: Core Data in Application Support, with the actual audio files somewhere on disk. That was a reasonable place to begin. Core Data gives the app relationships, migrations, background contexts, batch operations, and enough structure to keep a large library fast without inventing a database layer for sport, but it was also the wrong place for the library's long-term memory to live.
The problem showed up once I stopped thinking of the library as "the database on this Mac" and started treating it as "the user's music collection". Those are not the same thing. A music collection can sit on an external drive, move between machines, survive an app reinstall, spend time on a NAS, or end up plugged into an iPad in the middle of nowhere. A SQLite store inside one app container is good at being local state. It is bad at being the thing a user owns.
The original snapshot/export path proved that the idea was possible, but it was not enough. Snapshots could preserve a lot of useful facts: track IDs, album IDs, playlists, collections, play counts, ratings, play history, classifications, and artwork references. They were manual, though, and they did not preserve watched-root topology as a durable part of the live library. If the Core Data store disappeared and the user had not made a recent snapshot, a rescan could recover audio files, but not the parts of the library that make it theirs.
That distinction matters. A rescan can find a FLAC file, but it cannot know that the track belonged to a particular playlist, had a four-star rating, was last played in the van on a wet Thursday, or came from a watched root that used to live on an external volume. Those facts are not decoration. They are the library.
What changed
The new architecture moves the source of truth into a portable .kanora/ directory that sits with the library, while Core Data becomes a rebuildable local cache and platform index. Core Data has not gone away, and I do not want it to. The app still needs a fast graph for browsing, search, predicates, relationships, sorting, and UI work. The difference is authority. The user's non-regenerable metadata now has a home outside the machine-local database.
The .kanora/ directory stores JSON catalog data for the library graph: libraries, roots, artists, albums, tracks, track files, playlists, collections, play events, artwork assets, migrations, and scan records. Track files carry portable identity through a root ID plus a relative path, rather than relying on an absolute path that only makes sense on one machine. Security-scoped bookmarks stay local; they are access hints for Apple platforms, not portable identity, and putting them into the catalog would have tied the format back to the machine it was supposed to escape.
Why not just put everything in the audio files?
Embedding metadata in audio files is useful, but it is not the right authority for Kanora. Tags can carry common music metadata, not Kanora's full model: playlists, play events, root topology, companion file relationships, local classifications, artwork decisions, and future migration history. They also require writing into files the user may have spent years preserving, so the better split is boring but honest: tags are an input and maybe, later, an optional mirror for selected fields. The .kanora/ catalog is where Kanora-specific state lives. Core Data is the local working copy.
The rebuild test
The important test was not "can we export JSON?" It was whether Kanora could lose its local Core Data store and rebuild the library without losing the facts a rescan cannot infer.
That is now covered by LocalCacheRebuildService and the authority tests. The fixture creates a full graph, exports it to .kanora, discards the source context, rebuilds Core Data from the catalog, and checks that IDs, relationships, ratings, play counts, classifications, playlists, play events, artwork references, and portable file locations survived.
The recovery gate covered the same idea in three parts: rebuild tests for the main metadata, reachability tests for roots and files, and backup tests for retention, corrupt-catalog detection, and labeled backups that are never pruned automatically. That last piece is not glamorous, but it is part of making the system trustworthy. The catalog is canonical, so writing it carelessly would be worse than keeping the old design.
Keeping the catalog current
The first implementation also found a very ordinary bug with very real consequences: imports save through background Core Data contexts. The original catalog-sync observer watched only the view context, so imports and scans could commit successfully while the .kanora catalog stayed empty.
The fix was to treat catalog sync as a two-layer path. AppEnvironment now observes saves from all contexts and schedules a debounced catalog write, which catches scans, imports, metadata edits, and other background writers. FileImportService also triggers an immediate catalog export after an import batch commits with at least one new track, because import is where a user most expects the library file on disk to be current as soon as the work completes.
The failure mode is intentionally contained. A catalog write failure is logged, not thrown as an import error. Importing a track and failing to write the sidecar catalog are related, but they are not the same operation from the user's point of view, and pretending they are would make recovery behavior harder to reason about.
The trade-off
This architecture adds machinery. There are more services, more tests, more migration rules, and more places where the code has to distinguish between portable identity and local access. That is the cost of making the library something that can move.
The benefit is that Kanora no longer has to treat its local database as sacred. If Core Data is missing, empty, corrupt, or belongs to another machine, the app has a path back from the .kanora/ catalog. If a library sits on a drive, the drive can carry the part that matters. The cache can be rebuilt. The user's music and the user's metadata are no longer trapped inside one app container.
That is the foundation the rest of the portable work needed. Once the library has its own memory beside the files, an iPad can read a catalog from an external drive, build a temporary local view of it, and browse it like a normal library without importing the music first. The architectural work was not done because JSON is nicer than SQLite. It was done because the library needed to belong to the user before it could properly travel with them.