Conversation
The pipeline could only ever gain sinks, which makes an in-memory logging mode impossible: the app's durable on-disk store stays attached for the process, so anything logged while it is attached persists. `add(sink:)` now mints a `SinkToken` and `remove(_:)` detaches it. Removal brackets the deregistration with the drain wait so the sink is settled on the way out — it receives everything emitted before the call, is flushed, and hears nothing after. Removing a store also uninstalls that store's crash journal, which is an emit-side tap on the store rather than a pipeline-wide feature. Groundwork for the Where app's demo mode, which swaps the durable sink for an in-memory one while demoing. Co-authored-by: Cursor <cursoragent@cursor.com>
Demo mode runs a whole session on in-memory preferences and schedulers that never ask for a system permission, so these two stop being test scaffolding and become production wiring. Their siblings (the reminder and summary noop schedulers, the noop widget refresher, the no-op outbox) already shipped, so this also settles an inconsistency. Existing test call sites are unaffected: a plain public symbol still resolves through an @_spi(Testing) import. Co-authored-by: Cursor <cursoragent@cursor.com>
The store's services and the preferences driving them sat flat on the app-scoped WhereModel as attach-once properties, so "what the app is logged in to" was a set of fields rather than a thing. That shape is what makes a second world (an in-memory demo) awkward: switching would mean reassigning fields one at a time, with a half-swapped model spellable in between. WhereScope now bundles the services and preferences of one logged-in world, created whole and never reconfigured, and WhereModel tracks which scope is active. WhereSession is built from a scope, so a logged-in surface can't read one world's store against another's preferences. Groundwork only: the trunk order, the launch's single store open, and every behavior are unchanged. The durable log store stays on the model for now — it attaches asynchronously off the launch critical path, and moves into the scope when scope creation owns opening it. Co-authored-by: Cursor <cursoragent@cursor.com>
A plan could only start at a value-producing step, so the earliest a gate could park was after something had already been built. An app that must build nothing until the user chooses — Where's demo mode, where picking demo decides which store is opened at all — had no way to express that. Rooting at a gate is safe for the same reason `.gate` is: a gate transforms nothing, so the plan's Input and Output are its Value and no data-flow hole is possible. Such a gate declares `modes: .all`, since parking a headless launch is the point rather than the deadlock the `.foreground` default avoids; the parked drive is superseded normally on promotion. Co-authored-by: Cursor <cursoragent@cursor.com>
The launch opened the store before asking the user anything: a fresh install that never finished onboarding still created a SwiftData file, contacted CloudKit, and opened a durable log store — and a headless launch did the same, unseen. The trunk now starts at the onboarding gate. Nothing behind it runs until the gate resolves, so the store is opened by whoever commits to using the app for real: onboarding (finishing the flow, or restoring a backup) or the resolve-scope step for someone who onboarded on an earlier launch. The gate applies to every launch reason rather than the foreground-only default, because parking a headless launch is the point here; a genuine background wake can only happen once location monitoring is running, by which point the gate isn't needed. Consequences worth knowing: - Onboarding no longer receives a session, because none exists yet. It drives the scope it creates instead, and a store that won't open fails the gate — the same failure surface an unopenable store always had. - The durable log store belongs to a scope rather than the process, since what gets persisted depends on which world is active. Ambient sources still start at process launch, so records before the first scope reach OSLog only. - Logging out (the reset teardown) keeps the scope dormant, so logging back in reuses its open container instead of racing a second one over the file. - WhereBootstrap is behind a protocol, so a test can drive the logged-out -> logged-in path without touching disk or CoreLocation. Co-authored-by: Cursor <cursoragent@cursor.com>
Demo mode needs something to demonstrate, and an empty app demonstrates nothing. DemoDataBuilder writes a plausible current-year dataset into whatever services it's handed: passive GPS around New York, three trips to California bracketed by dual-region travel days, a week the phone was off (half reconstructed by hand, half never filled in), a few recent missing days, and two attributions the user corrected after the fact. The messy parts are the point — the calendar, the year report, and the Resolve tab each need something true to show. Placement is proportional to the elapsed year rather than fixed to calendar dates, so entering demo mode in February looks as coherent as in December, and the script is derived from the year, so it is the same every time it is built. Co-authored-by: Cursor <cursoragent@cursor.com>
A demo scope is the mirror of the real one: an in-memory store seeded with the demo year, in-memory preferences that say onboarded and tracking, an in-memory log store, and no-op schedulers and widget refresher throughout — so demo mode asks for no permission and leaves nothing on the device. WhereModel gains the demo half of its scope state, carrying any real scope along dormant so it can't be lost while a demo runs. Entering detaches the real scope's durable log sink (a demo entered after a reset would otherwise keep journaling to the user's on-disk history) and exiting gives it back. The exit teardown has no erase step: releasing an in-memory world is the whole cleanup, which is also why quitting mid-demo needs no code at all. Two surfaces learn about the mode: \.isInDemoMode in the environment for the views, and AppDelegate, which skips Spotlight indexing so demo regions never reach an index that outlives the process. Co-authored-by: Cursor <cursoragent@cursor.com>
A third button under "Restore from a backup": tapping it builds the demo world behind an interstitial and resolves the onboarding gate, so the launch continues into the logged-in app on demo data. Nothing durable happens along the way, which is what lets the intro simply come back if it fails. The interstitial is the launch splash with a caption naming the work, rather than a screen of its own — entering demo mode ends in that same splash, so sharing it makes the entry read as one wait instead of two. The caption is shown from the first frame, since a deliberate wait shouldn't reassure the user late the way a slow launch does. Co-authored-by: Cursor <cursoragent@cursor.com>
Settings gains an exit section at the very top while a demo is running — above the real settings, where a temporary state belongs — which runs the exit teardown through the same lifecycle proxy the reset flow uses. No confirmation: demo data was never saved, so leaving costs nothing that quitting the app wouldn't. Three groups step aside for the duration, filtered in one place so the list and the search index can't disagree: backup would write or restore a real archive, data would erase and reset (blanking only the demo, while reading as though it erased something real), and appearance exists solely to set an alternate app icon, which outlives the process. Co-authored-by: Cursor <cursoragent@cursor.com>
The composition story changed shape, so the docs that described it were wrong in the same way in several places: the store no longer opens at launch, "open-store" is now "resolve-scope", and a session comes after the onboarding gate rather than before it. Adds a "Scopes and the launch" section to the feature doc covering what an agent can't re-derive — why the gate roots the trunk, why it declares `modes: .all`, why logging out keeps a scope dormant, and everything demo mode must not touch — and points the root composition rule at it. Co-authored-by: Cursor <cursoragent@cursor.com>
- Identifiers/keys bullet: a dedicated struct is a first-class option beside a typed enum when the identity has structure — Where's StoreURL composite keys are the example (review comment on AGENTS.md:317). - Modeling state: expanded with two worked examples as requested — LifecycleKit's typed LaunchPlan (invalid wiring unrepresentable, PR #116) and WhereScope (invalid ownership unrepresentable, PR #150). - BroadwayCatalogTests hosting: the app-hosted arrangement is a deviation, not the intent — filed a P1 in Shared/Broadway/TODOs.md to rewire it through the unitTests helper into StuffTestHost, and both Broadway AGENTS.md files now flag the deviation and point at the item.
A scope opened its durable log store asynchronously, and detaching was guarded on a token that only exists once that open lands — so a store finishing after a demo had shadowed its scope attached anyway, routing the demo's records to the user's on-disk history. Reachable in the app: a failed backup restore opens the store, and the user then taps into the demo. The store, the registration, and "should this scope be receiving records" were three facts in two optionals. They're now one state — pending / routing / idle — where a store arriving while idle is remembered rather than attached, so the leak isn't spellable. The logging system is injected too, from the app root down through WhereModel to each scope. That's what makes the invariant testable: a test emits into a private system and reads the stores back to see which world's records went where (aLogStoreOpeningLateNeverAttachesToAShadowedScope fails against the old behavior). It also stops the demo tests registering sinks on Periscope.shared, which PeriscopeCore's own docs tell tests not to touch.
Trip starts and gap positions were proportional to the elapsed year but their *sizes* were fixed, so the demo's shape drifted with the calendar. A demo entered on January 9th was 5 of 9 days unlogged; mid-February counted more California days than New York ones, inverting the home-base story; and at a full year the trips barely registered at 8% of days. Everything is now sized from the elapsed span: an away budget split across two trips (three once there's a year to spread them over), and a phone-off stretch scaled to the span. New York holds 67-82% of days at every span and California always appears. Outstanding issues are now rare and recent — at most three, all within the last fortnight — because someone using the app deals with problems as they come up. Older lapses are fully backfilled instead, which still shows the manual-entry trail without implying a backlog the app let pile up. Both properties are asserted across six entry points in the year, since the old test only checked "non-empty and in the right year", which is how a half-unlogged January got through.
Entering demo mode built its SwiftData container synchronously on the main actor, while the entry interstitial's radar and pulsing icon were animating — a few dropped frames right where the screen is meant to look busy working. The real scope has always avoided this: WhereBootstrap.makeServices wraps its open in a detached task with a comment saying why. The demo path now does the same. Everything else in the factory already ran off the main actor (seeding and the log store are nonisolated async), so this was the last stall on the way in.
Three review follow-ups. A scope used to register its log sink from inside the factory that built it, while the model owned unregistering — so a demo world built and then abandoned (a failed build, an entry the user backed out of) kept receiving records for the rest of the process. A scope now *holds* its store from birth and only routes once the model activates it, which also reads as the rule it always should have been: the active scope is the one that logs. The mapping from scope state to `\.isInDemoMode` is now a named modifier with a test on it, rather than an inline expression at the app root that could be deleted without anything failing — taking the only way out of demo mode with it. And two small honesty fixes: attaching a log store with no scope to attach it to trips in debug instead of silently dropping it, and a cancelled demo build no longer surfaces a CancellationError to the user as "couldn't build the demo".
Brings in #149's doc re-validation, which adopted a "rules state what, not why" standard and roughly halved the AGENTS.md tree — including every file this branch had added narrative sections to. Resolved by taking main's rewrite wholesale and re-applying this branch's rules in the new form: an imperative sentence, at most one clause of consequence, and a pointer to the guard test. Where/AGENTS.md's scope and demo-mode sections went from ~60 lines to ~40 that way, and the two verbose bullets that auto-merged into LifecycleKit and the app target were compressed to match their now-terse neighbours. Also took main's verified deletions rather than this branch's copies: the P2 claiming reconcileTracking() requests authorization was disproven upstream and moved to Completed, so it stays gone here.
macOS sweeps files under /var/folders/…/T that haven't been read in about three days, and it deletes files while leaving directories — so a package checkout came back with its Sources and .git intact but no Package.swift, and ./Where/install failed with "the package manifest … cannot be accessed". Both scripts now keep their DerivedData in the checkout, which .gitignore already covers via DerivedData/. This matters most for ./flaky, whose warm rebuild is the whole point of keeping the cache and was silently going cold on the same timer. ./profile keeps its temp location deliberately: it runs `xcodebuild clean` every time, so nothing carries between runs and the sweep is free cleanup. Nothing prunes the new directories, so delete them by hand to reclaim space.
Putting them in the repo traded one problem for a worse one: ./swiftformat walks the tree and only excludes `Derived`, so it reformatted the vendored sources of swift-syntax, swift-custom-dump and AccessibilitySnapshot inside the package checkouts. ./xcstrings and ./sync-agents walk the tree too. Both scripts now use ~/Library/Developer/Xcode/DerivedData, suffixed per checkout so clones don't share. Nothing sweeps it, no tree-walking script can descend into it, and it is where a build cache belongs.
Demo mode logged three warnings on every launch — reminders, daily summary, and issue alerts each "enabled but notifications not authorized". The schedulers were correctly stubbed (nothing ever reached UNUserNotificationCenter), but the noops report *unauthorized* on purpose, so previews and tests can exercise the denied affordances. Paired with the notification preferences defaulting to on, that told the session it was in a state the user could do nothing about. The noops now take `authorized:`, defaulting to false so previews and tests keep the denied path, and demo mode passes true. That also matches what the demo already claims elsewhere — its location source reports `.always` — and it takes away a dead end: the alerts screen was offering a trip to Settings for a permission the demo can neither hold nor need. Nothing is scheduled either way.
SwiftFormat doesn't read .gitignore, and the exclude list only named Tuist's `Derived`. A `DerivedData` in the tree — Xcode's default with "Relative to workspace", and where ./Where/install briefly kept its cache — holds vendored package sources, so a plain ./swiftformat run reformatted swift-syntax, swift-custom-dump and AccessibilitySnapshot in place.
Three of the review comments on #150. `WhereScope`'s public initializer becomes `fake(services:preferences: logSystem:)`, so every call site names which of the three kinds of world it is asking for — real, demo, or a stand-in. The onboarding intro's two "is running" flags and two loose error strings become one `OnboardingIntroState`. Restoring a backup and building a demo each take over the whole screen, so only one can be underway and a failure always belongs to whichever produced it — as separate properties, "restoring and building at once" and "failed with no error" were both spellable. It is `@Observable` for the same reason `SaveErrorAlertState` is: the activity stays the source of truth while `isShowingFailure` gives `.alert(isPresented:)` its binding without a closure-built `Binding` in the view. The failure carries the `Error` rather than a pre-formatted string, so the view formats it where it presents it and anything else can still inspect it.
Logging out now releases the scope instead of keeping a dormant one, so the next login *builds* one from the model's assembler. Two seams still assumed the old reuse and quietly reached for the real world: - The public init defaulted `makeBootstrap` to a real `WhereBootstrap`, so a test that logged out and relaunched opened the app's on-disk Periscope store from inside the test host — which neither succeeds nor fails quickly. `resetRelaunchHandsTheFreshSessionsServicesToTheHookAgain` hung the bundle for 20 minutes on it. The default is gone, for the same reason `logSystem` has none: a login is too consequential to opt into silently. - The preview/test init now rebuilds over the services it was handed, via `InjectedServicesAssembler`, so a reset or a demo exit can cycle without touching disk. The two log-routing tests asserted the dormant contract (leaving a demo resumed routing into the shadowed scope's store). They now pin the real one: leaving routes nowhere, and the next login gets the durable store back. The invariant they exist for — a store that opens late never attaches to a shadowed scope — is unchanged. Closes review finding #5 (bootstrap ownership).
`WhereBootstrap.makeLogStore()` hardcoded `.onDisk`, so any suite that logged in opened the user's real `Periscope.store` — and from a test host's sandbox that neither succeeds nor fails promptly, it stalls. That is what hung WhereUITests for twenty minutes. `SwiftDataStore.Storage.default` has guarded against exactly this for the persistence store all along; the log store simply never got the same treatment, which is why one half of a scope quietly did the right thing and the other didn't. `WhereBootstrap.logStorage` now mirrors it, with a regression test matching `storageDefault_isInMemoryUnderTestRunner`. The environment check sits in the composition root rather than in PeriscopeCore: a general-purpose logging framework shouldn't know what a test host is, and this is the only place that opens a durable store.
`WherePreferences(store:)` defaulted to `UserDefaults.standard`, and `WhereModel` (both inits), `WhereSession`, and `YearReportModel` each defaulted `preferences:` to a fresh one — so the host's real, process-wide defaults were what you got by saying nothing. A test or preview that omitted the argument read and wrote the simulator's own settings, leaking state between tests and across runs. Nothing was actually doing that in the test suites, which passed an in-memory store by convention, but two PreviewSupport fixtures were, in direct violation of that file's own no-disk contract. All four defaults are gone; production names `UserDefaults.standard` at the composition root and every fixture names `InMemoryKeyValueStore()`. Previews route through one `PreviewSupport.previewPreferences()`. Seven suites had each hand-rolled the same `makePreferences()`, so that moves to `Tests/Support/TestPreferences.swift` and the copies are gone — call sites are unchanged. Closes the second of three audit findings on defaults that reach the real world from a test host.
`WhereServices`'s `@_spi(Testing)` init — documented as the seam for tests and previews — defaulted its three notification schedulers and its widget refresher to the real `UserNotification*` and `WidgetCenter` ones. The reconcilers behind them fire on ordinary writes, so ten WhereCore suites were scheduling real notifications and reloading the user's widget timelines as a side effect of saving a day. Those defaults are now the no-ops, which is what a seam for tests should do when told nothing. The public `make(...)` loses the same four defaults entirely, so the real world is named once, in `WhereBootstrap` — the demo scope already named its no-ops there. That left `forIntents(sharingStoreOf:)`, which built fresh schedulers rather than deriving them: a stack derived from the demo world would have posted real notifications and reloaded real widgets from a world that is otherwise entirely in memory. It now inherits the four seams from its base, for the same reason it already inherited the attributor. `activitySummaryGenerator` keeps its real default: it runs only on explicit user action and there is no production no-op to point at. Noted rather than changed. Closes the last of three audit findings on defaults that reach the real world from a test host.
The doc still described the dormant scope that logging out used to keep, and named a guard test that no longer exists. Logging out releases and tears the scope down; the next login builds a fresh one. Flagged by review on the AGENTS.md line itself.
Three passages still described the dormant scope logging out used to keep — "reuses that container", "keeps the real scope dormant" — which the code stopped doing when the state collapsed to loggedOut/real/demo. Same staleness as the AGENTS.md line, in the type that owns the behavior. Also records why one scope routes logs at a time, since it isn't visible from here: Periscope fans every record out to every registered sink, so two worlds routing at once would cross-file each other's records.
Follow-up idea: launching a demo from inside the appNot in this PR — parking it here so it isn't lost. The question: could demo mode be entered from the running app (a "What's New" screen, say) as a modal or pushed presentation, for "explore the app and new features", without the log-out-and-reset path this PR ships? Feasible, and the view layer is the easy half. The two hard parts are elsewhere. What this PR already sets up for it
The gating blocker: log routing
The fix is to give a record a world identity and a sink registration a predicate to filter on. PeriscopeCore surgery, but self-contained, independently useful, and testable on an isolated system. It can land on its own with no product change. The modeling question:
|
The only conflict is `PreviewSupport.resolveModel`, where both sides edited adjacent lines for unrelated reasons. Kept both: main's `previewPreferences()` (its sweep of fixtures that reached the host's real `UserDefaults`) and this branch's `setDataIssues(seededWithIssues ? … : [])` (seeding both modes, so the empty case stops racing a live store scan). Taking either side alone would have regressed the other — main still carried the race, and this branch had reverted its own narrower version of the preferences fix in favor of main's. `loadLeavesASeededFixtureAlone` now uses the `makePreferences()` helper main added to the file.
…s into the reports (#153) Fixes the red `main` (run [30402846712](https://github.com/kyleve/Stuff/actions/runs/30402846712)), plus a reporting bug found while diagnosing it. The snapshot job's only real failure was `ResolutionViewSnapshotTests/resolution()`, on `Empty_iPhone`, at max channel delta 255 over 91.7% of the image. Everything else in that run was noise the new tolerance absorbs or the already-known `swiftDataInspector` issue. Merged with #150 (demo mode) — see [Merging alongside #150](#merging-alongside-150) for the one conflict and why both sides had to survive it. ## 1. `resolution.Empty` never rendered the empty state `PreviewSupport.resolveModel(seededWithIssues: false)` skipped `setDataIssues` entirely, so the fixture came back with `hasLoaded == false` — which `ResolutionView` deliberately cannot distinguish from "the first scan hasn't landed yet". The view therefore held `AppIconLoadingView` until its `.task(id:)` scan of the empty in-memory store returned, which found the whole year missing. So the committed *reference* was that scan's output — a populated list titled "Missing days / Jan 1 – Jul 14 / 195 days" — not the all-clear state the case is named for, and every capture was a race against an async store read. CI lost that race and baked the placeholder. The settle loop can't catch this, which is why the suite had been reporting green: a placeholder is perfectly pixel-stable, so the loop proves stability and the capture proceeds. Same failure class as `root.LoggedIn` baking the launch splash, already ledgered in `Where/TODOs.md`. It stayed hidden until now because `drainInFlightAnimations` used to waste ~1s per capture; removing that waste in #151 took away the accidental slack. The prior `main` run confirms the shape — `resolution()` passed there in 23.0s, and failed here in 12.1s. **Fix.** Both fixture modes now seed. `setDataIssues([])` is what marks the model loaded *and* `isSeeded`, so the view's `load(...)` is a no-op and the first rendered frame is already final. The case no longer depends on the store, on `now`, or on how fast the machine is — there is no phase transition left to race. The two references are re-recorded to the "All clear" state, coverage the suite never had (`WithIssues` already pins the populated list), and consistent with the app's other empty states. `ResolveModelTests` gains two guards, both deterministic and independent of the image suite: the fixture comes back loaded in both modes, and `load(...)` leaves a seeded fixture alone against a store whose scan *does* find issues — the short-circuit the empty case's determinism now rests on. ## 2. The reporting tests were writing rows into the reports Found while explaining a suspicious line in `./test --review`. `./test` recovers `SNAPSHOT_DIFF` and `SNAPSHOT_TIMING` by grepping them out of the run logs, and counts timing lines as captured images for the progress line. Both were encoded *and* printed by a single function, so the tests that pin those wire formats emitted genuine lines: - **`--review` listed a reference that does not exist**, at the *top* of the list, because the fixture's numbers were borrowed from the real `swiftDataInspector` regression (max delta 203, 7430 pixels, 0.235%). The row most worth investigating was the one that wasn't real. - **`--timings` counted five captures that never happened.** `./test --only SnapshotKitTestingTests --timings` reported "5 captures, 0.1s total, 0.024s per image" for a run that captured nothing, naming fixtures (`repeated`, `one-phase`, `mixed`, …) in its slowest-captures list. On `--everything --timings` those blended into the aggregate the module's performance decisions are read off. The `SNAPSHOT_DIFF` env gate never covered this: it's checked by the pipeline before calling `report`, not inside it. **Fix.** Each channel is split so printing belongs to the pipeline alone and the payload is separately askable — `SnapshotDiffReporting.line(describing:)`, `SnapshotCaptureTiming.line()`, and `SnapshotSettleReporting.line(...)` return the same JSON without emitting, and all four test sites call those. The settle channel gets the same treatment for symmetry; nothing aggregates it today, but it's one scraper away from the same bug. The rule is now an invariant in `AGENTS.md`, since it's enforced by convention rather than by the compiler. Verified in both directions: a unit-only run reports no timing lines and no differing captures, while the full suite still prints the complete phase breakdown — now **264** captures rather than 269 — and the diff table. ## Merging alongside #150 #150 edited the adjacent lines of the same function for an unrelated reason, so neither side could be taken wholesale: - **#150** replaced `WherePreferences()` with its new `previewPreferences()` helper, part of its sweep of fixtures that reached the host's real `UserDefaults`. This branch briefly carried a narrower version of that same fix; it is reverted here, because #150's is better — it covers every fixture and removes the parameter defaults so omitting one is a compile error. - **This branch** replaced `if seededWithIssues { … }` with `setDataIssues(seededWithIssues ? sampleDataIssues() : [])`. #150 left that line alone, so `main` carried the race until this merge. The merge keeps both, and `loadLeavesASeededFixtureAlone` uses the `makePreferences()` helper #150 added to the test file. ## Docs `SnapshotKitTesting`'s README claimed `.settled` "waits for pixel-stable renders so `.task`-driven content loads" — the over-promise that let the first bug land. It now says pixel stability gives async content time to load but cannot certify that it did. `AGENTS.md` gains two invariants: a settled capture is not a ready capture (seed the fixture or gate on `onReadyToSnapshot`), and only the pipeline may print a report channel. ## Testing Run locally on iPhone 17 / iOS 27.0, on the merged tree: - `./test --everything --review --timings` — 1365 unit tests and 30 snapshot tests / 264 images, all passing; every remaining diff is max delta 1–2 sub-visible drift and no fabricated rows appear in either report - both reporting fixes verified from both sides: unit-only runs report nothing, real snapshot runs report fully - `swift run bumper config/test/lint .` — no architecture violations - `./swiftformat --lint` and `./xcstrings --lint` clean
Adds a demo mode to the Where app — a third button on the onboarding intro that drops you into the logged-in app on a seeded, in-memory year — and, to make it possible, changes when the app opens the user's store at all.
The structural half
The launch used to open the store, contact CloudKit, and open a durable log store before asking the user anything, including on headless launches. A fresh install that never finished onboarding still left a SwiftData file behind.
The trunk is now rooted at the onboarding gate, so nothing behind it runs until the user chooses a world to work in:
WhereScope(new, WhereUI) is what the app is logged in to: one open store'sWhereServices, theWherePreferencesdriving it, and the durable log store they record into. Created whole, never reconfigured;WhereSessionis built from one, so a logged-in surface can't read one world's store against another's preferences.WhereModeloutlives any scope and owns which is active.resolve-scopefor someone already onboarded. Logging out (a reset, or leaving a demo) releases the scope and tears it down; the next login builds a fresh one. What went wrong historically wasn't opening a second container ever, it was two long-lived ones open at once, which sequenced teardown makes unspellable.modes: .all, not the.foregrounddefault: parking a headless launch is the point, since the alternative is opening the user's store for a launch they can't see. A genuine background wake needs location monitoring, which needs the permission this flow asks for — by which point the gate isn't needed.WhereModelto each scope, andWhereModel.logSystemhas no default. That makes "which world's records went where" testable on a privatePeriscope, and stops tests attaching sinks to the process-wide one.Demo mode
A second scope rather than a flag threaded through the first: in-memory store seeded by the new
DemoDataBuilder, in-memory preferences, in-memory log store, and no-op schedulers, outbox, and widget refresher throughout.WhereLaunch.exitDemoPlan. Quitting mid-demo needs no code — nothing was persisted.WhereModelowns when a scope routes its logs, so entering detaches the real scope's on-disk sink, and a demo world built but never entered registers nothing at all..alwaysand the noop schedulers are builtauthorized: true, so no surface nags about a permission the demo can't obtain.Groundwork commits
Each is its own commit and useful on its own:
add(sink:)now returns aSinkToken;remove(_:)settles the sink on the way out.#if DEBUG+@_spi(Testing), while their siblings already shipped. Demo mode is a production consumer.Behavior changes worth reviewing
onServicesReadyfires per session). Process-scoped and self-correcting on exit; accepted rather than special-cased.Defaults that reached the real world
Releasing the scope on logout means the next login genuinely builds one, which turned several previously-unexercised defaults into live paths to the user's device from inside a test host. One of them stalled
WhereUITestsfor twenty minutes rather than failing it, so the branch audits the family:WhereBootstrap.makeLogStore()hardcoded.onDisk.SwiftDataStore.Storage.defaulthas guarded the persistence store against test runners all along; the log store never got the same treatment, so one half of a scope quietly did the right thing and the other opened the user'sPeriscope.storefrom the test host's sandbox — where it neither succeeds nor fails promptly.WhereBootstrap.logStoragenow mirrors the guard, with a matching regression test. The environment check sits in the composition root, not in PeriscopeCore.WhereModel'smakeBootstrapdefaulted to a realWhereBootstrap, which is what hung the bundle. The default is gone (aslogSystem's already was), so omitting it is a compile error; the preview/test init rebuilds over the services it was handed.WherePreferences(store:)defaulted toUserDefaults.standard, andWhereModel,WhereSession, andYearReportModeleach defaultedpreferences:to a fresh one — so the host's process-wide defaults were what you got by saying nothing. All four are gone; production namesUserDefaults.standardat the composition root and every fixture namesInMemoryKeyValueStore(). Two PreviewSupport fixtures had been violating that file's own no-disk contract.WhereServices's@_spi(Testing)init defaulted its notification schedulers and widget refresher to the real ones. The reconcilers behind them fire on ordinary writes, so ten WhereCore suites were scheduling real notifications and reloading real widget timelines as a side effect of saving a day. Those defaults are now the no-ops — the right answer for a seam documented as being for tests — and the publicmake(...)requires them, so the real world is named once, inWhereBootstrap.forIntents(sharingStoreOf:)built fresh schedulers rather than deriving them, so an intents stack derived from the demo world would have posted real notifications and reloaded real widgets from a world that is otherwise entirely in memory. It now inherits those seams from its base, for the same reason it already inherited the attributor.activitySummaryGeneratorkeeps its real default: it runs only on explicit user action and there is no production no-op to point at. Noted rather than changed.Also in this branch
Two dev-tooling fixes found while running the feature on device, unrelated to demo mode but in the diff:
./Where/installand./flakykeep their DerivedData under~/Library/Developer/Xcode/DerivedDatainstead of$TMPDIR. macOS sweeps files there after ~3 days and deletes files while leaving directories, which stranded a package checkout with noPackage.swiftand failed the build with "the package manifest … cannot be accessed"../profilekeeps its temp location deliberately — it runsxcodebuild cleanevery time..swiftformatnow excludesDerivedDataas well asDerived. SwiftFormat doesn't read.gitignore, so aDerivedDatain the tree (Xcode's default with "Relative to workspace") gets its vendored package sources reformatted in place.Testing
DemoDataBuilderTests(two parameterized suites pinning the year's shape and the issue budget across six entry points, plus determinism and both kinds of manual entry),DemoModeTests(isolation from real preferences, no real store opened when demoing from a fresh install, reset → demo → exit lands on a fresh real scope, log routing across a demo cycle, a late-opening store never attaching to a shadowed scope),DemoModeEnvironmentTests(the\.isInDemoModeseam in both directions), plus new launch coverage for parking before anything is opened, headless parking, and an unopenable store still failing the launch. Snapshots re-recorded for the onboarding intro, and added for the demo Settings screen and the splash's work caption.Green locally on the merged tree:
Stuff-iOS-Tests,StuffSnapshotTests,bumper lint/test/config,./swiftformat --lint,./xcstrings --lint,./attribution --check.