Skip to content

Add demo mode, and open nothing until the user picks a world - #150

Merged
kyleve merged 27 commits into
mainfrom
demo-mode
Jul 28, 2026
Merged

Add demo mode, and open nothing until the user picks a world#150
kyleve merged 27 commits into
mainfrom
demo-mode

Conversation

@kyleve

@kyleve kyleve commented Jul 28, 2026

Copy link
Copy Markdown
Owner

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:

onboarding (gate) → resolve-scope → start-session → sync-auth → …
  • WhereScope (new, WhereUI) is what the app is logged in to: one open store's WhereServices, the WherePreferences driving it, and the durable log store they record into. Created whole, never reconfigured; WhereSession is built from one, so a logged-in surface can't read one world's store against another's preferences. WhereModel outlives any scope and owns which is active.
  • The store opens on demand, and at most one scope is live at a time — opened by onboarding (finishing, or restoring a backup) or by resolve-scope for 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.
  • The gate declares modes: .all, not the .foreground default: 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.
  • The logging system is injected from the app root down through WhereModel to each scope, and WhereModel.logSystem has no default. That makes "which world's records went where" testable on a private Periscope, 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.

  • The data is a plausible current year — New York as home base with two California trips (three once there's enough year to spread them over), bracketed by dual-region travel days, a phone-off stretch reconstructed by hand, and two corrected attributions. Everything is sized against the elapsed year rather than the calendar, so a demo entered in February has the same shape as one entered in December: New York holds 67–82% of days at every point, and California always appears.
  • Outstanding issues are 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 appear as backfills, which show the manual-entry audit trail without implying a backlog.
  • Entering builds the scope behind the launch splash (captioned for the work) and resolves the gate. Leaving is a section at the top of Settings running WhereLaunch.exitDemoPlan. Quitting mid-demo needs no code — nothing was persisted.
  • It leaves no mark. No permission is ever requested, Spotlight indexing is skipped, and Settings hides Backup, Data, and Appearance for the duration — each would reach past the demo onto the device. WhereModel owns 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.
  • It presents a fully-granted user — the scripted location source reports .always and the noop schedulers are built authorized: 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:

Commit Why
Let a Periscope sink be detached again Sinks could only ever be added, so demo logs would persist through the real sink. add(sink:) now returns a SinkToken; remove(_:) settles the sink on the way out.
Ship the in-memory key-value store and noop issue-alert scheduler Both were #if DEBUG + @_spi(Testing), while their siblings already shipped. Demo mode is a production consumer.
Give the logged-in world a type: WhereScope Pure ownership move, no behavior change.
Let a LaunchPlan be rooted at a gate A plan could only start at a value-producing step, so the earliest a gate could park was after something was already built.

Behavior changes worth reviewing

  • Fresh install: no store file, no CloudKit contact, no durable log store until Get Started, Restore, or an onboarded launch. Store creation now happens on the finish tap rather than hidden behind the splash.
  • Reset: the relaunch parks at onboarding with no session at all, rather than rebuilding one behind the gate. The scope is released rather than kept dormant, so logging back in builds a new one. Tests assert the new shape.
  • Pre-scope logs are OSLog-only, including the ambient sources' opening snapshots, since the durable sink belongs to a scope.
  • App Intents answer from the demo store while a demo is active (onServicesReady fires 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 WhereUITests for twenty minutes rather than failing it, so the branch audits the family:

  • WhereBootstrap.makeLogStore() hardcoded .onDisk. SwiftDataStore.Storage.default has 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's Periscope.store from the test host's sandbox — where it neither succeeds nor fails promptly. WhereBootstrap.logStorage now mirrors the guard, with a matching regression test. The environment check sits in the composition root, not in PeriscopeCore.
  • WhereModel's makeBootstrap defaulted to a real WhereBootstrap, which is what hung the bundle. The default is gone (as logSystem's already was), so omitting it is a compile error; the preview/test init rebuilds over the services it was handed.
  • WherePreferences(store:) defaulted to UserDefaults.standard, and WhereModel, WhereSession, and YearReportModel each defaulted preferences: to a fresh one — so the host's process-wide defaults were what you got by saying nothing. All four are gone; production names UserDefaults.standard at the composition root and every fixture names InMemoryKeyValueStore(). 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 public make(...) requires them, so the real world is named once, in WhereBootstrap.
  • 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.

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.

Also in this branch

Two dev-tooling fixes found while running the feature on device, unrelated to demo mode but in the diff:

  • ./Where/install and ./flaky keep their DerivedData under ~/Library/Developer/Xcode/DerivedData instead of $TMPDIR. macOS sweeps files there after ~3 days and deletes files while leaving directories, which stranded a package checkout with no Package.swift and failed the build with "the package manifest … cannot be accessed". ./profile keeps its temp location deliberately — it runs xcodebuild clean every time.
  • .swiftformat now excludes DerivedData as well as Derived. SwiftFormat doesn't read .gitignore, so a DerivedData in 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 \.isInDemoMode seam 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.

kyleve and others added 10 commits July 27, 2026 18:04
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>
kyleve added a commit that referenced this pull request Jul 28, 2026
- 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.
kyleve added 9 commits July 28, 2026 07:16
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.
Comment thread Where/WhereUI/Sources/Model/WhereModel.swift Outdated
Comment thread Where/WhereUI/Sources/Model/WhereModel.swift
Comment thread Where/WhereUI/Sources/Model/WhereScope.swift Outdated
Comment thread Where/WhereUI/Sources/Onboarding/OnboardingView.swift Outdated
Comment thread Where/WhereUI/Sources/Onboarding/OnboardingView.swift Outdated
Comment thread Where/AGENTS.md Outdated
kyleve added 8 commits July 28, 2026 12:32
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.
@kyleve

kyleve commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Posted by an AI agent on kve's behalf.

Follow-up idea: launching a demo from inside the app

Not 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 demo world is a whole second scope, not a flag, and creation is already separate from activation: WhereModel.makeDemoScope() builds the store, preferences, log store, and no-op seams without touching the active scope. Only activateDemo(_:) is destructive, because it calls logOut() first.
  • SwiftUI's environment is per-subtree, so a sheet is the natural presentation. Inject the demo's WhereSession and \.isInDemoMode = true on the modal's subtree and every existing view resolves the demo inside the sheet and the real world behind it — nav pushes inherit it correctly, and no view needs changing.
  • Two ModelContainers are fine here. The race this codebase remembers was two containers over the same file; this is one on-disk plus one in-memory, so SwiftData isn't a blocker.
  • The device-facing seams are already inert in a demo — no-op schedulers, outbox, and widget refresher — and forIntents(sharingStoreOf:) now inherits them, so even a derived stack stays inert.

The gating blocker: log routing

Periscope.drain() fans every record out to every registered sink; there is no per-record routing. That is precisely why only one scope routes at a time, and it's what keeps demo logs off disk. Two worlds routing concurrently would cross-file each other — demo records into the user's Periscope.store, real records into the demo's in-memory one.

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: WhereModel.scopeState

Single-valued by design, and this PR enshrines "at most one scope is live at a time" in both the code and Where/AGENTS.md. A layered demo needs a second live scope with the real one still active — so the state goes from "which world" to "which world is in front, over what base", while keeping today's case spellable, since a demo entered from onboarding has no real world behind it. Those two situations differ in nearly every downstream decision, so this type wants settling before anything else gets built.

The sharp foot-gun

Settings' exit-demo section calls deactivateDemo()logOut(), which would log the user out of their real world. An existing button whose meaning silently inverts is worse than a missing one. In a layered demo the exit is "dismiss and release the scope", not a lifecycle teardown.

Three decisions, not obstacles

  • IntentServices holds one installed stack, replaced by onServicesReady on every session start — a layered demo session would repoint Siri and Shortcuts at the demo store unless it deliberately doesn't install. Probably it shouldn't: intents should keep answering from real data while someone browses a demo.
  • The launch machinery assumes one session (model.session is a single optional; startSession returns early when set). A layered demo shouldn't run the launcher at all — no gate, no auth sync, no capture-today — so it needs a small prepare path of its own.
  • AppDelegate's Spotlight guard reads model.isInDemoMode, which becomes ambiguous when the app is both showing a demo and has a real world it should still index.

Suggested sequence

  1. Per-world log routing in PeriscopeCore (lands alone, no product change).
  2. Reshape the scope state from "one of" to "front over base".
  3. The presentation and the "What's New" entry point.

@kyleve
kyleve merged commit 6a68bcd into main Jul 28, 2026
4 checks passed
kyleve added a commit that referenced this pull request Jul 28, 2026
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.
kyleve added a commit that referenced this pull request Jul 29, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant