Peregrine · Implementation Plan

Local-Only User Profiles

Multiple profiles on one device — so testers play their own game, and so seeded test users let you jump straight to late-stage UI.

2026-07-26 · source: .claude/plans/local-user-profiles.md · status: built & verified
Built — 21/21 UI tests, 159/159 Core tests

The plan below is preserved as written. Jump to “As built” for the four things it got wrong or missed — two of them would have silently destroyed your existing data. Both were caught by running it on a simulator, not by reading the code.

Decisions taken

QuestionAnswer
Switch UXProfile picker on launch
Isolation depthEverything user-scoped — progress, settings, familiarity, narrative
Test usersBlank-slate + DEBUG seeded presets + reset/delete + export

1. The core choice: store-per-profile

Two ways to scope SwiftData per user:

Choosing (B)

Three reasons, in order of weight:

  1. A relationship forbids (A)'s cleaner cousin. Question.drillsDrillResult.question is a cascade relationship. SwiftData does not permit relationships crossing ModelConfiguration boundaries — so I can't keep a shared question library in one configuration and per-profile progress in another. It's all one store, or all separate stores.
  2. (A) leaks by omission. ~40 @Query/FetchDescriptor sites across the game views. Every one needs a profile predicate, and one miss silently shows a tester another tester's data — the exact failure this feature exists to prevent. (B) makes cross-profile leakage structurally impossible.
  3. (B) touches zero gameplay code. None of the 15 game views change. Isolation happens entirely at container-construction time.
What (B) costs — stated plainly

Each profile gets its own copy of the question corpus. StarterLibrary.json is 715 KB and re-imports from the bundle per profile, so a profile costs low single-digit MB — irrelevant on-device.

The real cost: questions you capture on your profile do not appear on a tester's profile. Handled by a "copy library from <profile>" option at creation time, reusing the existing SetExport/SetImporter path. But that's a point-in-time snapshot, not a live share.

Application Support/
  profiles.json                    ← registry (outside every profile)
  Profiles/
    <uuid>/Peregrine.store         ← SwiftData, all 8 @Model types
    <uuid>/Peregrine.store-wal/-shm

+ per profile: UserDefaults(suiteName: "profile.<uuid>")

2. Three stores, three answers

StoreScoping mechanism
SwiftData — 8 @Model typesSeparate store URL per profile
UserDefaults — 7 keys + familiarity.*UserDefaults(suiteName:) per profile
Keychain — Anthropic API keyStays global. Deliberate — see below

2.1 UserDefaults redirection

Two kinds of reader, two mechanisms:

Rather than thread a defaults: argument through 30+ call sites, one injection point in Core:

public enum ProfileScope {
    /// The active profile's defaults. Set once at activation;
    /// `.standard` only before a profile is chosen (and in unit tests).
    public static var defaults: UserDefaults = .standard
}

GameFamiliarity's existing parameter defaults change from = .standard to = ProfileScope.defaults — its explicit-injection test seam is preserved and all 30 call sites keep working unchanged.

Flagging this, not hiding it

That's mutable global state, which I'd normally push back on. It's justified here because it's written exactly once per activation, before any view that reads it is constructed — and the alternative is a 30-site refactor of gameplay code for a testing feature.

2.2 What deliberately stays global

3. Files

New — PeregrineCore/…/Profiles/

FileContents
PlayerProfile.swiftCodable, Identifiable, Hashableid, name, avatar (emoji), kind, createdAt, lastPlayedAt, note
ProfileRegistry.swiftFoundation-only load/save of profiles.json. Atomic write, injectable root URL. Unit-testable with no SwiftData/SwiftUI.
ProfileScope.swiftThe defaults injection point (§2.1)
ProfileArchive.swiftCodable snapshot of all 8 model types + the defaults dictionary → Data. Export only; no import path in v1.

New — Peregrine/Profiles/ (app target)

FileContents
ProfileManager.swift@Observable. Owns registry, profiles, and session: (profile, ModelContainer, UserDefaults)?. Methods: activate, leave, create, reset, delete, exportArchive. Owns the per-profile first-run work moved out of App.init().
ProfilePickerView.swiftThe launch gate. Noir "personnel file" framing — profiles as ID cards, styled with existing PG tokens. Tap to enter; + to add; swipe/context menu → Reset / Export / Delete (destructive actions confirm).
ProfileEditorView.swiftName + emoji + kind. On create, choose starting library: Starter library (bundled JSON) or Copy from <profile>.
ProfileSeeder.swift DEBUGPresets writing synthetic VigilanceResult/NightTake/DrillResult/PersonalHistory + familiarity.*: Rookie (~1 night), Regular (~15 nights, mid mastery, one busted con), Veteran (~60 nights, several retired cons, late-act narrative state). Reuses DemoData patterns.

Modified

FileChange
PeregrineApp.swiftDrop the eager let container. Hold @State ProfileManager. Body branches: no session → picker; else root view with .modelContainer / .defaultAppStorage / .id(profile.id). Move DemoData.seedIfRequested, backfillImageHashes, importStarterLibraryIfPresent out of init() into activate — they need the active container, and per-profile is where they belong.
GameFamiliarity.swiftParameter defaults .standardProfileScope.defaults (9 signatures)
ShakedownGameView.swift:197, :199, :208UserDefaults.standardProfileScope.defaults
DemoData.swift-resetDemoData currently deletes 5 of 8 model types and prefix-wipes familiarity.*. Replace with: delete the UI-test profile's store directory + defaults suite outright, before activation. Strictly more hermetic — and it stops missing GameSession/PersonalHistory/SuspectRecord.
SettingsView.swiftAdd a "Switch profile" row — see §5, this is an addition beyond the chosen option
project.ymlNo target edits needed (new folders fall inside the existing source glob) — but run xcodegen generate

4. Launch behaviour

launch
 ├─ -demoData present?  → activate fixed UI-test profile
 │                        (uuid 00000000-…-0001, created on demand;
 │                         -resetDemoData wipes it first). Picker skipped.
 └─ otherwise           → ProfilePickerView
                            last-played listed first as "Continue as <name>";
                            everything else below.
No UI-test changes required

Existing tests pass ["-demoData","-resetDemoData"] and route via -route*. The picker is bypassed whenever -demoData is present, and the -route* args still reach PeregrineRootView.init unchanged.

5. One addition beyond what you chose

Your call

You chose picker-on-launch only, not "both". I'm including a "Switch profile" row in the You tab anyway — because without it, every switch during your own test loop needs a force-quit, and hopping between seeded test profiles to inspect UI states is the second half of this request. It's ~10 lines (set manager.session = nil).

Say the word and I'll drop it.

6. Build order

  1. Core: PlayerProfile, ProfileRegistry, ProfileScope + registry unit tests (round-trip, corrupt-file recovery, atomic write).
  2. GameFamiliarity / ShakedownGameView defaults redirection. Build green, behaviour unchanged — ProfileScope.defaults is still .standard.
  3. ProfileManager + per-profile container/defaults construction.
  4. PeregrineApp restructure + UI-test bypass. Verify existing UI tests pass here — highest-risk step.
  5. ProfilePickerView + ProfileEditorView (create / blank / copy-library).
  6. Reset + Delete.
  7. ProfileArchive + export share sheet.
  8. ProfileSeeder DEBUG presets.
  9. xcodegen generate, build, run the UI test suite.

Final review

Pros

  • Isolation is structural, not disciplinary. Separate store files mean a forgotten predicate cannot leak between testers. With ~40 query sites, that's the difference between "correct" and "probably correct".
  • Zero gameplay-code churn. Not one of the 15 game views changes. Blast radius is the app entry point plus two UserDefaults seams.
  • Reuses what exists. SetExport/SetImporter for library copying, DemoData patterns for seeding, PG tokens for the picker.
  • Reversible. Delete the Profiles/ folders, restore the eager container.

Cons

  • Mutable global (ProfileScope.defaults). A real smell, accepted over a 30-site refactor. Written once per activation.
  • Corpus duplication. "The library" is no longer one thing. Copy-from-profile is a snapshot — questions captured after a tester's profile exists won't reach them without a re-copy.
  • Runtime container swap is the risky move. .id(profile.id) forces a full rebuild, but a view holding a stale ModelContext across a switch would crash. I believe there are none (the codebase uses @Environment(\.modelContext) throughout, no cached contexts) — but that's what to watch in step 4.

Edge cases this plan handles poorly

Worth considering

If corpus duplication becomes annoying, the alternative is profileID on the seven progress models with Question left global — but that requires severing the Question.drills relationship (store questionID: UUID on DrillResult instead). Cleaner data model, meaningfully more invasive. Not now.

82/100
Confidence
High on the architecture — store-per-profile is clearly right given the relationship constraint and the query-site count, and I'd defend it. The 18 points off: the runtime container swap is the one part I can't fully verify by reading (step 4 is where surprises live), and ProfileScope is a compromise rather than the elegant answer. Everything else is mechanical.

As built — where the plan was wrong

Four things the plan missed. The first two would have silently destroyed existing data; both were caught by running the thing on a simulator rather than by reading the code.

1. The plan had no migration at all — critical

The plan restructured PeregrineApp to open a per-profile store and never said what happens to the store that already exists. Shipping it as written would have orphaned every night, dossier and captured question on your device — the app would have looked like a fresh install.

Fixed: ProfileManager.adoptLegacyStoreIfNeeded(). On the first launch with an empty registry, the pre-profiles store is moved into a new .owner profile named “You”, and the app's own UserDefaults.standard keys are copied into that profile's suite. Runs only when the registry is empty, so it cannot fire twice.

2. The legacy store is not where the plan assumed — critical

Because the app declares the App Group group.com.lochlustra.Peregrine (for CaptureInbox), SwiftData's implicit container writes to <AppGroup>/Library/Application Support/default.storenot the app's private container. FileManager.url(for: .applicationSupportDirectory, …) returns the private path and finds nothing.

Fixed: ProfileRegistry.defaultRoot() prefers the App Group's Application Support, and legacyStoreCandidates checks both locations (a device predating the share extension has it in the private container).

This is the failure mode worth remembering: looking in the wrong container doesn't error — it reads as “fresh install”.

3. External storage has to move with the store

Question.imageData is @Attribute(.externalStorage), so anything past SwiftData's inlining threshold — every real Bluebook screenshot — lives in a sidecar directory named after the store (default.store.default_SUPPORT). Moving the store alone leaves the rows intact and every captured image empty.

Fixed: moveExternalStorage(from:to:) renames it alongside the store. Note this gap is invisible in the simulator — bundled starter and demo data are small enough to be inlined, so _EXTERNAL_DATA is empty there. It would only have shown up on a device with real captures.

4. UI-test bypass needed changes the plan didn't anticipate

One existing test changed: testAPIKeySavesToKeychain now launches with -demoData, because a bare launch legitimately opens the picker now. No other existing test was touched.

Also added beyond the plan

88/100
Confidence, revised
Up from 82. The runtime container swap — the thing flagged as unverifiable by reading — works, and is now covered by tests that switch profiles and assert isolation.
Running it on device — no backup needed

The pre-profiles store held only throwaway testing, and adoption is non-destructive by construction: it moves the store and leaves it untouched if the move fails, so the failure mode is “no profiles appear”, never “data gone”.

To skip adoption entirely, delete the app first. You get an empty picker and can name your own profile deliberately instead of inheriting “You” — and it exercises the same first-run path a tester will hit. One caveat: iOS wipes Keychain items on app deletion, so the Anthropic API key has to be re-entered. The starter library re-imports itself per profile.