Skip to content

Split WhereSession into a coordinator + scope-tiered child models#53

Merged
kyleve merged 17 commits into
mainfrom
refactor/wheresession-scope-split
Jul 6, 2026
Merged

Split WhereSession into a coordinator + scope-tiered child models#53
kyleve merged 17 commits into
mainfrom
refactor/wheresession-scope-split

Conversation

@kyleve

@kyleve kyleve commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Summary

WhereSession had grown into an ~850-line observable that mixed always-on concerns with UI mirrors and lived for the whole logged-in lifetime — so its store-change subscription drove report/data-issue refreshes even on a headless background relaunch that renders nothing. This splits it by lifetime scope and fixes the leak.

  • WhereSession → slim always-on coordinator. Tracking/authorization, launch-time reminder/summary application (reading WherePreferences), widget republish, and erase. No presentation state, no store-change subscription.
  • YearReportModel (scene-scoped, @State in a new MainTabs). Owns the report / selected year / ranking / missing days, the day-write intents, the drift threshold, and the Resolve badge count. It owns the store-change subscription, started on scene .active and cancelled on .backgroundclosing the leak.
  • View-scoped models. ResolveModel (the data-issue list + dismiss, in ResolutionView); BackupModel and RemindersSettingsModel (backup progress and the reminder/summary editing surface, in SettingsView).
  • Hybrid injection. Ambient app state (WhereModel, the WhereSession coordinator) flows through the environment and is read non-optional so broken wiring is a loud crash; scoped models are passed via initializers so their wiring is compile-checked.
  • Launch. Drops the loadYear step and no longer wires the subscription in syncAuth — the report loads with the scene.
  • Tests move 1:1 with their concern (Backup/Reminders/Resolve/YearReport + a coordinator WhereSessionTests); Where/AGENTS.md + root AGENTS.md describe the new tiers and injection rule.

Code-review follow-ups (round 1, separate commits)

  • Rescan the Resolve list on a drift-threshold change. A threshold edit recomputed the badge but not the list (they could visibly disagree). Mirror the threshold in an observable stored property on YearReportModel and key the Resolve scan .task on a single DataIssueScanInputs (year + report + threshold), so list and badge recompute on the same triggers.
  • Guard the background-rescan pause/resume contract. New tests assert deactivate() stops refreshing on a committed write (proven against a live probe on the same store) and a later activate() re-subscribes and pulls the gap — so the leak can't silently return.
  • Key the scene on a monotonic session id, not its address. RootView keyed MainTabs on ObjectIdentifier(session) (unique only among live objects). WhereSession now mints a process-unique SessionID; a rebuilt session can't collide with a freed one at the same address.
  • Test reminder/summary setter reconciliation. Spy schedulers confirm each RemindersSettingsModel setter drives configure(...) down to the scheduler (enable prompts + reconciles on, disable reconciles off without a prompt, time edit re-reconciles).

Code-review follow-ups (round 2, separate commits)

  • Rename ReportModelYearReportModel. The old name didn't convey that it's scoped to the selected year; renamed across sources, tests, previews/fixtures, and AGENTS.md.
  • Push year date math into WhereCore. Missing-day derivation moved onto YearReport (missingDayRanges/Keys/Count(asOf:calendar:)) and a new Calendar.dayCount(ofYear:) (derives 365/366 from the calendar, no hardcoded literal). The view-model properties are now one-liners.
  • Own the backup export-file lifecycle in WhereCore. BackupCoordinator now tracks and purges the previous temp export directory (survives the Settings screen being torn down); BackupModel no longer holds that state or percent math.
  • Typed LoadState.failed. failed(String)failed(LoadError) (reportUnavailable / clearFailed), so callers can branch on which operation failed while LoadState stays Equatable.
  • refreshAll(forceDataIssueCount:) helper names the paired report + badge refresh the shared sites use; refresh() and refreshDataIssueCount(force:) stay separately callable (the drift-threshold setter recounts only the badge).
  • Drive the ResolveModel dismiss test through a real scan. It now seeds two calendar-adjacent disjoint manual days so the scanner returns a genuine dismissible issue, exercising the production load(...) path instead of the isSeeded fixture short-circuit (which keeps its own narrow test and stays preview-only).
  • File deferred follow-ups in Where/TODOs.md: compiler-checked coordinator wiring (vs the @Environment lookup) and a further split of YearReportModel.

Merged main in (picks up the new Foreman targets + the slimmed AGENTS.md files); resolved the Where/AGENTS.md conflict by keeping the slim structure and correcting only the sentences the split made stale.

Test plan

  • ./swiftformat --lint clean
  • tuist test WhereUITests green locally (incl. YearReportModelTests pause/resume + RemindersSettingsModelTests reconcile + the reworked ResolveModelTests dismiss test)
  • tuist test WhereCoreTests green locally (incl. new Calendar+DayCountTests, YearReport+MissingDaysTests, and the BackupCoordinator purge test)
  • Both schemes re-verified green on the post-merge tree
  • Full CI (macos-26): tuist test --no-selective-testing across all schemes
  • Manual smoke: change the drift threshold in Settings and confirm the Resolve badge and list update together; background/foreground the app and confirm no refresh churn while backgrounded; run an erase/reset and confirm the scene rebuilds

Made with Cursor

kyleve and others added 5 commits June 30, 2026 20:38
WhereSession had grown into an ~850-line observable that mixed always-on
concerns with UI mirrors and lived for the whole logged-in lifetime, so its
store-change subscription drove report/data-issue refreshes even on a headless
background relaunch nothing renders.

Decompose it by lifetime scope:

- WhereSession stays as a slim always-on coordinator: tracking/authorization,
  launch-time reminder/summary application (reading WherePreferences), widget
  republish, and erase. No presentation state, no store-change subscription.
- ReportModel (scene-scoped, @State in a new MainTabs container) owns the
  report/selectedYear/ranking/missing-days, the day-write intents, the drift
  threshold, and the Resolve badge count. It owns the store-change subscription,
  started on scene .active and cancelled on background — closing the leak.
- ResolveModel (view-scoped in ResolutionView) owns the data-issue list +
  dismiss; BackupModel and RemindersSettingsModel (view-scoped in SettingsView)
  own backup progress and the reminder/summary editing surface.

Injection is hybrid: ambient app state (WhereModel, the WhereSession
coordinator) flows through the environment and is read non-optional so broken
wiring is a loud crash; scoped models are passed explicitly via initializers so
their wiring is compile-checked. Launch drops the loadYear step and no longer
wires the subscription in syncAuth (the report loads with the scene).

Tests move 1:1 with their concern (Backup/Reminders/Resolve/Report + a
coordinator WhereSessionTests), and Where/AGENTS.md + the root AGENTS.md are
updated to describe the new tiers and injection rule.

Co-authored-by: Cursor <cursoragent@cursor.com>
After the WhereSession split, changing the drift threshold in Settings only
recomputed the Resolve badge count (on ReportModel), not the issue list: the
view-scoped ResolveModel re-scanned only via .task(id: report.report), which a
threshold change doesn't mutate. The badge and list could then visibly disagree
until the next committed write or year switch.

WherePreferences isn't observable by design, so reading the threshold through it
can't drive a body re-run. Mirror it in an observable stored property on
ReportModel (as RemindersSettingsModel already does for its preferences), and
key the Resolve list's scan .task on a single DataIssueScanInputs value (year +
report + threshold). The list and the badge now recompute on the same triggers
by construction, and the scanner cache keyed by (year, threshold) makes the two
concurrent scans converge regardless of order.

Co-authored-by: Cursor <cursoragent@cursor.com>
Moving the store-change subscription onto the scene-scoped ReportModel means a
backgrounded scene stops refreshing. ReportModelTests covered
observeDataChanges() refreshing on a write, but nothing asserted the
pause/resume contract, so a future edit could silently reintroduce the leak and
stay green.

Add three tests: activate() subscribes and pulls (and stays live for a later
committed write); deactivate() stops refreshing on a write — proven against a
live probe on the same store, which makes the negative assertion deterministic
(once the probe reacts to the shared ping, the paused model has demonstrably
seen and ignored it); and a subsequent activate() re-subscribes and pulls the
gap accumulated while backgrounded.

Co-authored-by: Cursor <cursoragent@cursor.com>
RootView keyed MainTabs (and thus the scene-scoped ReportModel) on
ObjectIdentifier(session), which is only unique among live objects: a session
freed and a new one allocated at the same address could collide, leaving the
scene un-rebuilt across a reset. Unlikely in practice — the old session is
retained via the environment until the new tree replaces it — but the guarantee
shouldn't rest on allocator behavior.

Give WhereSession a process-unique, monotonically increasing SessionID minted at
init and key MainTabs on session.id. A monotonic token is never reused within
the process, so a rebuilt session always forces a fresh scene.

Co-authored-by: Cursor <cursoragent@cursor.com>
RemindersSettingsModelTests covered the preferences round-trip but not that a
setter actually drives services.reminders.configure / services.summary.configure
down to the scheduler. The coordinator's launch/foreground apply path is tested
in WhereSessionTests, but the live edit on the Settings surface wasn't — so a
setter could stop reconciling and stay green.

Inject spy schedulers and assert each setter reaches the reconciler: enabling
prompts for authorization and reconciles on, disabling reconciles off without a
prompt, and a time edit re-reconciles with the new time — for both the reminder
and the daily-summary schedule.

Co-authored-by: Cursor <cursoragent@cursor.com>
/// threading the coordinator through.
@MainActor
@Observable
public final class ReportModel {

@kyleve kyleve Jul 1, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this name is very clear given this comment, should this be public final class YearReportModel or similar?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Renamed ReportModelYearReportModel across the module (source, tests, previews/fixtures, and Where/AGENTS.md). Commit 164685f.

kyleve and others added 3 commits June 30, 2026 22:52
The scene-scoped ReportModel scans for the tab badge on activate(), but the
view-scoped ResolveModel only scans from ResolutionView's .task after the first
render — so a populated Resolve tab could flash the "all clear" empty state for a
frame while the badge already showed a non-zero count.

Track hasLoaded on ResolveModel (set once the first scan lands, or when a fixture
is seeded) and hold a spinner in ResolutionView until then, rather than the empty
state. It stays true afterward, so later re-scans update the list in place with
no spinner flicker.

Co-authored-by: Cursor <cursoragent@cursor.com>
refreshDataIssueCount read the drift threshold straight from
preferences.driftThresholdMeters, while the Resolve list scans on
dataIssueScanInputs, which reads the observable driftThresholdStorage mirror.
The two are kept in lockstep by the sole setter today, so this was already
consistent — but reading the mirror in both places makes the badge/list
agreement hold by construction rather than resting on a shared invariant, and it
survives a future second writer of the preference.

Co-authored-by: Cursor <cursoragent@cursor.com>
ReportModel.locations(in:) and representativeCoordinates() (migrated verbatim
from WhereSession) used try? and returned []/[:] on failure, discarding the
error — against the repo's "never silently swallow" rule, where an empty result
reads as "no locations" indistinguishable from success.

Replace with do/catch that logs a warning and returns empty, so the Elsewhere
drill-in still renders nothing on failure but the failure is observable in the
log rather than passing silently.

Co-authored-by: Cursor <cursoragent@cursor.com>
/// `services` / `preferences` / `now` / `calendar` are exposed so the
/// view-scoped models a tab builds (`BackupModel`, `RemindersSettingsModel`,
/// `ResolveModel`) can be constructed from the injected `report` without also
/// threading the coordinator through.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have my eye on you!!! Let's work towards fixing this; we should thread it through so the compiler checks it. Let's add it to the TODOs.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Left the @Environment lookup as-is for this PR and filed the compiler-checked follow-up as a P2 in Where/TODOs.md: thread the coordinator through initializers (like the scoped models already are) or use a non-defaulting typed EnvironmentKey so a broken wiring fails to build. Commit 07a88c8.


/// Start-of-day keys for days that still need logging in the loaded year.
public var missingDayKeys: Set<Date> {
guard let report, isViewingCurrentYear else { return [] }

@kyleve kyleve Jul 1, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hnnm this indicates to me we should still further split this type apart (nil type checks), lets file this away for further dealing.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Filed a P2 in Where/TODOs.md to split YearReportModel further — separating the read-only presentation state from the write-intent (setManualDay(s), overrideDay, clear*) and Elsewhere drill-in (days(in:), locations(in:), representativeCoordinates()) surface. Commit 07a88c8.

The data-change observer re-pulls the report and then the badge count in two
sequential awaits. The test waited only for the report to repopulate before
asserting dataIssueCount > 0, so it could read the stale 0 in the window between
the two awaits — fast enough to pass locally, but it lost the race on the loaded
CI runner.

Wait for the final state (report populated AND count > 0) instead of the
intermediate one, so the assertion can't observe the gap.

Co-authored-by: Cursor <cursoragent@cursor.com>
case idle
case loading
case loaded
case failed(String)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this hold an error instead? Thoughts?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Agreed. LoadState.failed(String) is now failed(LoadError), a typed enum with reportUnavailable(message:) / clearFailed(message:). It keeps the user-presentable text as a String (so LoadState stays Equatable for the refresh guards and tests) while naming which operation failed, so a caller can branch on it. Commit fff422c.

/// Unlogged days this year (Jan 1 through today), collapsed into ranges, for
/// the warning banner and the backfill flow. Empty unless viewing the
/// current year, since past years can't gain "today" coverage.
public var missingDays: [MissingDayRange] {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like the majority of the logic in this method should be moved into the report itself so we're not doing date math in the view model.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Moved it down. The derivation now lives on YearReport in WhereCore (missingDayRanges(asOf:calendar:)), which handles the "current year only" rule and the today-exclusion; the view-model missingDays is now just report?.missingDayRanges(asOf: now(), calendar: calendar) ?? []. Commit c75f5cb.

}

/// Start-of-day keys for days that still need logging in the loaded year.
public var missingDayKeys: Set<Date> {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as above, can we move this into report so this becomes a one liner w/ no logic?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Same treatment — now report?.missingDayKeys(asOf: now(), calendar: calendar) ?? [], with the logic on YearReport in WhereCore. Commit c75f5cb.

day: 15,
)),
let range = calendar.range(of: .day, in: .year, for: midYear)
else { return 365 }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't hardcode, pull from the calendar.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Done. daysInSelectedYear now reads calendar.dayCount(ofYear: selectedYear) — a new Calendar extension in WhereCore that derives 365/366 from the calendar's own range(of: .day, in: .year, for:), so leap years come out right instead of a hardcoded literal. Commit c75f5cb.


/// Number of calendar days in the selected year (365, or 366 in a leap
/// year). Region cards scale their ambient progress bar against this.
public var daysInSelectedYear: Int {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above; can we move most of this down onto report?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Pushed down to the same Calendar.dayCount(ofYear:) extension in WhereCore (with its own tests); the property is now a one-liner. Commit c75f5cb.

public func activate() async {
observeDataChanges()
await refresh()
await refreshDataIssueCount(force: false)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason for refresh and refreshDataIssueCount to be separate? I see we call them next to each other frequently.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Added refreshAll(forceDataIssueCount:) to name the paired call the shared sites (activate(), select(year:), the store-change observer) use. Kept refresh() and refreshDataIssueCount(force:) separately callable on purpose: the drift-threshold setter recomputes only the badge count with no report re-pull, so folding them would force a redundant report fetch there. Commit 6ac4e08.

#if DEBUG
/// Preview/test seam: inject a `ResolveModel` seeded via
/// `@_spi(Testing) setDataIssues` so the list renders without raw samples.
init(report: ReportModel, resolve: ResolveModel) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing @_spi(Testing)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Can't apply @_spi(Testing) here — the compiler only allows it on public/open declarations, and ResolutionView (and this DEBUG init) is internal ("internal initializer cannot be declared '@_spi'"). The seam stays gated behind #if DEBUG and is reached from tests via @testable import WhereUI, which is how the module's other internal test seams work. The one public cross-module seam in this area — ResolveModel.setDataIssues(_:) — is @_spi(Testing).

/// Set by the `@_spi(Testing)` seeder so `load(...)` doesn't clobber
/// preview/test fixtures with an empty scan of a store that has no raw
/// samples. Never compiled into release.
private var isSeeded = false

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing @_spi(Testing)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

The actual cross-module test seam here — setDataIssues(_:) — is already @_spi(Testing) (public, in the DEBUG extension below). This line is the private var isSeeded flag it sets, and @_spi can't go on a private (or any non-public) declaration, so it stays internal to the #if DEBUG seam.

/// tunes the border-drift relabel suggestions.
public func load(year: Int, primaryRegions: [Region]) async {
#if DEBUG
if isSeeded { return }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm seeing this now; I don't love this short circuit since it means our tests aren't exercising this path. Any way to actually trigger this instead of adding the isSeeded override?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Good call. Reworked dismissWritesToStoreAndRemovesRow to seed two calendar-adjacent manual days with disjoint regions (CA then NY), which makes the scanner return a genuine dismissible abrupt-change issue — so dismiss now runs the real load(...) scan instead of a setDataIssues fixture, and the isSeeded short-circuit is no longer on the tested path. The seam keeps its own narrow test (seedingMarksTheModelLoaded) and stays preview-only. Commit 7050bd6.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got to here in my review.

/// outlive the screen that drives it.
@MainActor
@Observable
public final class BackupModel {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still feels like a "fat" view model to me, lots of percent math, etc. Can we push more of this down into WhereCore?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Pushed the export-file lifecycle down into BackupCoordinator (WhereCore): it now tracks the previous temp export directory and purges it on the next export, with a test (exportPurgesThePreviousExportDirectory). BackupModel no longer owns that previousExportDirectory state or the cleanup logic. Commit 2697578.

kyleve and others added 7 commits July 5, 2026 22:11
The scene-scoped model is specifically the selected *year*'s report and
everything derived from it, so `YearReportModel` reads more clearly than
the generic `ReportModel`. Pure rename (type, file, preview factories,
and docs) — no behavior change.

Addresses PR review: "should this be `YearReportModel` or similar?"

Co-authored-by: Cursor <cursoragent@cursor.com>
The missing-day derivation (present-day set + backlog cutoff, current-year
only) now lives on `YearReport.missingDay{Keys,Ranges,Count}(asOf:calendar:)`,
and the year-length math on `Calendar.dayCount(ofYear:)`. `YearReportModel`
just supplies "now" + the calendar, so its `missingDays` / `missingDayKeys` /
`missingDayCount` / `daysInSelectedYear` are one-liners with no date math.

`daysInSelectedYear` also drops the hardcoded `365`: the count is derived from
the calendar's day-of-year range, with the literal now only an asserted release
fallback for a calendar that can't resolve a date.

Addresses PR review: move the date math onto `report`; don't hardcode 365.

Co-authored-by: Cursor <cursoragent@cursor.com>
The export staging-directory bookkeeping — track the last export and delete
it when the next one starts — moves from `BackupModel` into the
`BackupCoordinator` actor, which already owns export. This also fixes a
silently-swallowed `try?` on the cleanup (now logged) and makes the cleanup
survive the Settings view being torn down, since the coordinator lives for the
app. `BackupModel` keeps only the export/import UI state.

Addresses PR review: push more of this "fat" view model down into WhereCore.

Co-authored-by: Cursor <cursoragent@cursor.com>
`LoadState.failed` now carries a typed, `Equatable` `LoadError`
(`reportUnavailable` / `clearFailed`) instead of a bare `String`, so the
failing operation is named in the type and tests can match the case. The error
screens read `error.message`; the value stays a `String` (not `any Error`) so
`LoadState` remains `Equatable` for the refresh guards. Adds a `failSamples()`
hook to `TestStore` and a test that a failed load parks in `.reportUnavailable`.

Addresses PR review: "should this hold an error instead?"

Co-authored-by: Cursor <cursoragent@cursor.com>
`activate()`, `select(year:)`, and the store-change observer all pulled a fresh
report and recounted the Resolve badge back to back; give that pairing a name
(`refreshAll(forceDataIssueCount:)`). `refresh()` and
`refreshDataIssueCount(force:)` stay separately callable — the drift-threshold
setter recomputes only the count, with no report re-pull.

Addresses PR review: "any reason refresh and refreshDataIssueCount are separate?"

Co-authored-by: Cursor <cursoragent@cursor.com>
`dismissWritesToStoreAndRemovesRow` seeded a fixture via `setDataIssues` and
then dismissed it, so the `isSeeded` short-circuit meant the test never ran the
actual `load(...)` scan. Seed the store with two calendar-adjacent, disjoint
manual days instead — the scanner returns a genuine dismissible abrupt-change
issue, and dismiss now exercises the production path. The seam keeps its own
narrow test (`seedingMarksTheModelLoaded`) and stays preview-only.

Addresses PR review: "tests aren't exercising this path — any way to trigger it
instead of the isSeeded override?"

Co-authored-by: Cursor <cursoragent@cursor.com>
Two review threads flagged follow-up work that's out of scope for this PR:
compiler-checked coordinator wiring (vs the silent `@Environment` lookup) and a
further split of `YearReportModel` (presentation state vs write-intent/drill-in
surface). Record both under P2 so they're tracked rather than lost in the PR.

Co-authored-by: Cursor <cursoragent@cursor.com>
…-scope-split

Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	Where/AGENTS.md
@kyleve kyleve merged commit 7f67e68 into main Jul 6, 2026
3 checks passed
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