transit branch T-2232/bugfix-sync-heartbeat-saves-unrelated-pending-model-changes commits 2 files 6 touched lines +229 / -33

Pre-push review: T-2232 heartbeat context isolation

Review of PR #243's exact origin/main...HEAD changes at 452a20b, after refreshing origin/main to d0512b2. The heartbeat now persists in an independent SwiftData unit of work instead of saving unrelated pending UI edits.

At a glance

  • Persistence isolation: every heartbeat creates a fresh ModelContext over the live ModelContainer, so saving the heartbeat cannot flush unrelated pending changes in a caller or view context.
  • Failure behavior: fetch failures return before mutation; save failures roll back and discard only the transient context; the timer remains scheduled so later beats can recover.
  • Explicit ownership: startHeartbeat and beat now accept the container, and both production call sites pass their live container.
  • Regression coverage: multi-context tests cover insert/update success, unrelated pending changes, fetch and save failure recovery, singleton behavior, and timer continuity.
  • Validation: macOS and iOS unit suites, the iOS UI suite, SwiftLint strict mode, and the SwiftData ownership guard all passed.

Verdict

Ready to push

No correctness, reuse, code-quality, efficiency, specification, documentation, or testing blockers were found. The context boundary directly fixes the over-broad save while preserving the live container and failure semantics. make test-quick, make lint, make test, and make test-ui all completed successfully.

Commits

Three-level explanation

What changed

Think of a SwiftData context as a draft workspace. Previously, the background heartbeat worked in the same workspace as the UI. Saving the heartbeat could therefore save every draft in that workspace, including edits the user had not chosen to save.

Why it matters

The heartbeat now opens its own short-lived workspace, updates only its timestamp, saves that workspace, and throws it away. User drafts remain untouched.

Completeness assessment

The bug fix, both application call sites, failure handling, regression tests, changelog, and implementation report are complete. No missing ticket-scoped behavior was identified.

Architecture

ModelContext is the SwiftData unit of work; save() is context-scoped rather than model-scoped. The manager now receives a ModelContainer and creates a peer context per beat. That peer shares the same persistent store and CloudKit configuration while maintaining independent pending-change tracking.

Patterns and trade-offs

The fresh-context-per-operation pattern makes transaction ownership explicit and avoids retaining stale model graphs. A small injected HeartbeatSaver seam makes save failure deterministic in tests without changing the production default. The extra context creation and one-record fetch happen only once per heartbeat interval and are proportionate to the isolation guarantee.

Completeness assessment

Success, fetch failure, save failure, retry, timer continuity, singleton update, and caller-context isolation are covered. The implementation follows the repository's documented rule that independent contexts are appropriate for background maintenance that owns no view-provided model.

Deep dive

Each main-actor beat constructs ModelContext(container), fetches the fixed-ID SyncHeartbeat, mutates or inserts within that unit, and invokes the injected saver. A thrown fetch exits before graph mutation. A thrown save invokes raw rollback() only on the transient context; because neither the fetched model nor the context escapes, the repository's re-fault caveat has no caller-observable effect.

Architecture impact and edge cases

The container-based API keeps the active CloudKit-backed store while severing change tracking from app/view contexts. Timer ownership remains unchanged, so transient failures do not disable future beats. Existing duplicate-singleton behavior remains first-match, deliberately outside this ticket. Physical-device CloudKit transport is not reproduced by in-memory tests, but the persistence boundary at issue is exercised across independent contexts on both macOS and iOS.

Completeness assessment

All ticket-scoped mechanics and recovery paths are implemented and validated. No architectural divergence from the bugfix report or repository constraints was found.

Important changes — detailed

SyncManager: isolate each heartbeat in a transient context

Transit/Transit/Services/SyncManager.swift

Why it matters. This is the correctness boundary that prevents a maintenance save from committing unrelated pending UI or service changes.

What to look at. SyncManager.beat(container:)

Takeaway. When a SwiftData operation must persist independently, pass the shared container and create a peer context rather than borrowing a caller's unit of work.
Rationale. The bugfix report and inline comments state that ModelContext.save() persists all pending changes in that context, so the heartbeat needs independent change tracking while retaining the live persistent store.

SyncManager: contain save failure inside the isolated unit

Transit/Transit/Services/SyncManager.swift

Why it matters. A failed heartbeat must neither dirty caller state nor poison the next timer attempt.

What to look at. HeartbeatSaver and save catch path

Takeaway. A narrow injected persistence closure can test failure semantics without replacing the real production context or store.
Rationale. Rollback is intentionally limited to a context and model graph that never escape and are discarded after the beat, avoiding the shared-context re-fault hazard documented elsewhere in the repository.

Heartbeat API: make persistence ownership explicit

Transit/Transit/Services/SyncManager.swift

Why it matters. Accepting a ModelContainer prevents callers from accidentally lending a context whose unrelated changes could be saved.

What to look at. startHeartbeat(container:) and beat(container:)

Takeaway. API shape can enforce transaction boundaries: request the persistence root when a service should own its own unit of work.
Rationale. The implementation needs the app's configured store but must not share the app context's pending-change set.

App call sites: pass the active live container

Transit/Transit/TransitApp.swift

Why it matters. Both launch and Settings restart paths must use the same configured store for heartbeat persistence.

What to look at. TransitApp startup and SettingsView task modifier

Takeaway. Deriving the container from the available context at the boundary preserves store identity without passing that context into background persistence.
Rationale. The changed call sites pass either the app container directly or modelContext.container, preserving existing lifecycle behavior.

SyncManagerTests: exercise independent-context recovery boundaries

Transit/TransitTests/SyncManagerTests.swift

Why it matters. The regression is specifically about what does and does not persist across contexts, including behavior after failed fetches and saves.

What to look at. successfulHeartbeatDoesNotSaveUnrelatedPendingChanges and related heartbeat tests

Takeaway. Persistence isolation tests should observe the store from peer contexts and verify both the intended write and the absence of unintended writes.
Rationale. The added tests cover success, existing singleton update, fetch retry, save retry, caller-context cleanliness, and timer continuity.

Key decisions

Use a fresh ModelContext for every heartbeat.

This preserves access to the live container's persistent stores and CloudKit configuration while isolating pending-change tracking. A short-lived context also prevents failed or stale heartbeat state from leaking into later intervals.

Pass ModelContainer instead of ModelContext through the heartbeat API.

The type boundary communicates that SyncManager owns the heartbeat transaction. It removes the opportunity for this background maintenance path to save a caller-owned context.

Rollback the transient context after an injected save failure.

Raw rollback is acceptable in this narrowly scoped path because the context and its fetched model never escape. The entire graph is discarded immediately, so no view can observe re-faulted models.

Keep fetch failure distinct from an absent singleton.

A thrown fetch logs and returns before mutation; a successful empty fetch inserts the fixed-ID singleton. This preserves fail-closed behavior and allows the next scheduled beat to retry cleanly.

Inject only the save operation for deterministic failure tests.

The production default remains context.save(), while tests can force a throw at the exact persistence boundary and verify cleanup and recovery without introducing a broader storage abstraction.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +2 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex d74cfde..a176dd8 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ## [Unreleased] +- T-2232: Sync heartbeats now fetch, mutate, and save their singleton through a fresh `ModelContext` over the live container, so background sync writes cannot persist unrelated changes pending in the shared app context. Failed heartbeat saves are rolled back and logged before the isolated context is discarded; deterministic regressions cover successful writes, fetch failures, save failures, and retry recovery.+ - Restored the original width-adaptive dashboard layout: iPhone portrait keeps the multi-column Kanban board whenever at least two 200-point columns fit, and uses segmented single-column navigation only below that boundary. This supersedes T-1802 / PR #205 and corrects requirement 13.1. - Dashboard UI tests now dismiss the iOS search presentation, navigate toolbar overflow actions, match combined detail-row accessibility labels, and disambiguate nested alert buttons so the full iPhone 17 suite covers the restored multi-column portrait layout reliably. 
Transit/Transit/Services/SyncManager.swift Modified +25 / -4
diff --git a/Transit/Transit/Services/SyncManager.swift b/Transit/Transit/Services/SyncManager.swiftindex 26a4edd..b97324b 100644--- a/Transit/Transit/Services/SyncManager.swift+++ b/Transit/Transit/Services/SyncManager.swift@@ -40,7 +40,11 @@ final class SyncManager {     typealias HeartbeatFetcher =         (ModelContext, FetchDescriptor<SyncHeartbeat>) throws -> [SyncHeartbeat] +    /// Saves a heartbeat context. Injectable so tests can exercise write failures.+    typealias HeartbeatSaver = (ModelContext) throws -> Void+     private let heartbeatFetcher: HeartbeatFetcher+    private let heartbeatSaver: HeartbeatSaver      /// True when the preference no longer matches the mode the live container runs in,     /// i.e. the user changed the toggle and has not relaunched yet.@@ -51,6 +55,9 @@ final class SyncManager {     init(         heartbeatFetcher: @escaping HeartbeatFetcher = { context, descriptor in             try context.fetch(descriptor)+        },+        heartbeatSaver: @escaping HeartbeatSaver = { context in+            try context.save()         }     ) {         // Default to enabled if never set@@ -62,6 +69,7 @@ final class SyncManager {         self.isSyncEnabled = enabled         self.isCloudSyncActive = enabled         self.heartbeatFetcher = heartbeatFetcher+        self.heartbeatSaver = heartbeatSaver     }      // MARK: - Public API@@ -113,7 +121,7 @@ final class SyncManager {     /// Starts a 60-second repeating heartbeat that writes to SwiftData,     /// triggering CloudKit to pull pending remote changes. Scheduling is gated on the     /// launch-fixed `isCloudSyncActive` mode, not the mutable sync preference.-    func startHeartbeat(context: ModelContext) {+    func startHeartbeat(container: ModelContainer) {         stopHeartbeat()         guard isCloudSyncActive else { return } @@ -121,7 +129,7 @@ final class SyncManager {             while !Task.isCancelled {                 try? await Task.sleep(for: .seconds(60))                 guard !Task.isCancelled else { break }-                beat(context: context)+                beat(container: container)             }         }     }@@ -134,7 +142,12 @@ final class SyncManager {      /// Writes a timestamp to the `SyncHeartbeat` singleton, triggering a     /// CloudKit sync cycle that pulls pending remote changes.-    func beat(context: ModelContext) {+    ///+    /// Each beat owns a fresh context so its save cannot commit changes pending in+    /// the app's shared context. The context never escapes this method, so rolling it+    /// back and discarding it is sufficient cleanup after a failed save.+    func beat(container: ModelContainer) {+        let context = ModelContext(container)         let singletonID = SyncHeartbeat.singletonID         let descriptor = FetchDescriptor<SyncHeartbeat>(             predicate: #Predicate { $0.id == singletonID }@@ -155,7 +168,15 @@ final class SyncManager {         if heartbeat.modelContext == nil {             context.insert(heartbeat)         }-        try? context.save()++        do {+            try heartbeatSaver(context)+        } catch {+            context.rollback()+            Self.logger.error(+                "Skipping sync heartbeat because the save failed: \(error.localizedDescription)"+            )+        }     }      // MARK: - CloudKit Schema
Transit/Transit/TransitApp.swift Modified +1 / -1
diff --git a/Transit/Transit/TransitApp.swift b/Transit/Transit/TransitApp.swiftindex 6176c98..5440def 100644--- a/Transit/Transit/TransitApp.swift+++ b/Transit/Transit/TransitApp.swift@@ -288,7 +288,7 @@ struct TransitApp: App {         // Skip MCP server in unit test host to avoid port conflicts across test runs         guard mcpSettings.isEnabled, !Self.isUnitTestHost else { return }         await mcpServer.start(port: mcpSettings.port)-        syncManager.startHeartbeat(context: container.mainContext)+        syncManager.startHeartbeat(container: container)     }     #endif 
Transit/Transit/Views/Settings/SettingsView.swift Modified +1 / -1
diff --git a/Transit/Transit/Views/Settings/SettingsView.swift b/Transit/Transit/Views/Settings/SettingsView.swiftindex c9a3959..0f8dca6 100644--- a/Transit/Transit/Views/Settings/SettingsView.swift+++ b/Transit/Transit/Views/Settings/SettingsView.swift@@ -350,7 +350,7 @@ extension SettingsView {                         .onChange(of: mcpSettings.isEnabled) { _, enabled in                             if enabled {                                 scheduleMCP(.start)-                                syncManager.startHeartbeat(context: modelContext)+                                syncManager.startHeartbeat(container: modelContext.container)                             } else {                                 mcpPortChangeCoordinator.cancelPendingPortChange()                                 scheduleMCP(.stop)
Transit/TransitTests/SyncManagerTests.swift Modified +96 / -27
diff --git a/Transit/TransitTests/SyncManagerTests.swift b/Transit/TransitTests/SyncManagerTests.swiftindex 1c3b3cb..8acb0ec 100644--- a/Transit/TransitTests/SyncManagerTests.swift+++ b/Transit/TransitTests/SyncManagerTests.swift@@ -72,7 +72,7 @@ struct SyncManagerTests {             let fixture = try TestModelContainer()             let manager = SyncManager()             manager.recordActiveCloudSync(true)-            manager.startHeartbeat(context: fixture.context)+            manager.startHeartbeat(container: fixture.container)             defer { manager.stopHeartbeat() }              #expect(manager.isHeartbeatRunning,@@ -87,7 +87,7 @@ struct SyncManagerTests {             let fixture = try TestModelContainer()             let manager = SyncManager()             manager.recordActiveCloudSync(true)-            manager.startHeartbeat(context: fixture.context)+            manager.startHeartbeat(container: fixture.container)             defer { manager.stopHeartbeat() }              manager.setSyncEnabled(false)@@ -107,7 +107,7 @@ struct SyncManagerTests {             let manager = SyncManager()             manager.recordActiveCloudSync(false)             manager.setSyncEnabled(true)-            manager.startHeartbeat(context: fixture.context)+            manager.startHeartbeat(container: fixture.container)              #expect(manager.isSyncEnabled)             #expect(manager.syncChangeRequiresRestart)@@ -123,7 +123,7 @@ struct SyncManagerTests {             let fixture = try TestModelContainer()             let manager = SyncManager()             manager.recordActiveCloudSync(true)-            manager.startHeartbeat(context: fixture.context)+            manager.startHeartbeat(container: fixture.container)             defer { manager.stopHeartbeat() }              manager.setSyncEnabled(false)@@ -142,7 +142,7 @@ struct SyncManagerTests {             let fixture = try TestModelContainer()             let manager = SyncManager()             manager.recordActiveCloudSync(true)-            manager.startHeartbeat(context: fixture.context)+            manager.startHeartbeat(container: fixture.container)             manager.setSyncEnabled(false)              manager.stopHeartbeat()@@ -155,6 +155,7 @@ struct SyncManagerTests {     // MARK: - T-1699 Regression: heartbeat singleton fetch failures      private struct FetchFailure: Swift.Error {}+    private struct SaveFailure: Swift.Error {}      private final class HeartbeatFetchController {         var shouldFail = false@@ -170,21 +171,44 @@ struct SyncManagerTests {         }     } +    private final class HeartbeatSaveController {+        var shouldFail = false++        func save(context: ModelContext) throws {+            if shouldFail {+                throw SaveFailure()+            }+            try context.save()+        }+    }+     private func heartbeatCount(in context: ModelContext) throws -> Int {         try context.fetch(FetchDescriptor<SyncHeartbeat>()).count     } +    private func projectCount(in context: ModelContext) throws -> Int {+        try context.fetch(FetchDescriptor<Project>()).count+    }++    // MARK: - T-2232 Regression: heartbeat context isolation+     @Test-    func heartbeatWithMissingSingletonInsertsAndSavesOneRecord() throws {+    func successfulHeartbeatDoesNotSaveUnrelatedPendingChanges() throws {         let fixture = try TestModelContainer()         let context = fixture.context+        context.insert(Project(name: "Pending", description: "", gitRepo: nil, colorHex: ""))         let manager = SyncManager() -        manager.beat(context: context)+        manager.beat(container: fixture.container) -        #expect(try heartbeatCount(in: context) == 1)-        #expect(!context.hasChanges,-                "A successful missing-singleton heartbeat must save its newly inserted record")+        // Expected: the heartbeat commits independently. Actual before T-2232: saving+        // the caller's context also committed this pending project.+        #expect(context.hasChanges,+                "A successful heartbeat must leave the caller's pending changes unsaved")+        let verificationContext = ModelContext(fixture.container)+        #expect(try heartbeatCount(in: verificationContext) == 1)+        #expect(try projectCount(in: verificationContext) == 0,+                "A successful heartbeat must not persist unrelated caller-context models")     }      @Test@@ -197,38 +221,83 @@ struct SyncManagerTests {         try context.save()         let manager = SyncManager() -        manager.beat(context: context)+        manager.beat(container: fixture.container) -        #expect(try heartbeatCount(in: context) == 1)-        #expect(existing.lastBeat > .distantPast)-        #expect(!context.hasChanges,-                "A successful existing-singleton heartbeat must save its updated timestamp")+        let verificationContext = ModelContext(fixture.container)+        let storedHeartbeat = try #require(+            verificationContext.fetch(FetchDescriptor<SyncHeartbeat>()).first+        )+        #expect(try heartbeatCount(in: verificationContext) == 1)+        #expect(storedHeartbeat.lastBeat > .distantPast)     }      @Test-    func heartbeatFetchFailureDoesNotInsertOrSaveAndNextBeatRecovers() throws {+    func heartbeatFetchFailureDoesNotInsertOrSaveAndNextBeatRecoversInIsolation() throws {         let fixture = try TestModelContainer()         let context = fixture.context-        let pendingProject = Project(name: "Pending", description: "", gitRepo: nil, colorHex: "")-        context.insert(pendingProject)+        context.insert(Project(name: "Pending", description: "", gitRepo: nil, colorHex: ""))         let fetcher = HeartbeatFetchController()         let manager = SyncManager(heartbeatFetcher: fetcher.fetch)         fetcher.shouldFail = true -        manager.beat(context: context)+        manager.beat(container: fixture.container) -        #expect(try heartbeatCount(in: context) == 0,+        let failedBeatVerification = ModelContext(fixture.container)+        #expect(try heartbeatCount(in: failedBeatVerification) == 0,                 "A failed singleton fetch must not be treated as a missing record")         #expect(context.hasChanges,-                "A failed singleton fetch must return before it can save unrelated pending changes")+                "A failed singleton fetch must leave unrelated caller changes pending")          fetcher.shouldFail = false-        manager.beat(context: context)+        manager.beat(container: fixture.container) -        #expect(try heartbeatCount(in: context) == 1,+        let recoveredBeatVerification = ModelContext(fixture.container)+        #expect(try heartbeatCount(in: recoveredBeatVerification) == 1,                 "The next heartbeat must retry normally after a transient fetch failure")-        #expect(!context.hasChanges,-                "The recovered heartbeat should retain the normal best-effort save behavior")+        #expect(context.hasChanges,+                "A recovered heartbeat must still leave unrelated caller changes pending")+        #expect(try projectCount(in: recoveredBeatVerification) == 0,+                "Recovery must not persist unrelated caller-context models")+    }++    @Test+    func heartbeatSaveFailureDoesNotDirtyCallerContextAndNextBeatRecovers() throws {+        let fixture = try TestModelContainer()+        let context = fixture.context+        let existing = SyncHeartbeat()+        existing.lastBeat = .distantPast+        context.insert(existing)+        try context.save()+        context.insert(Project(name: "Pending", description: "", gitRepo: nil, colorHex: ""))+        let saver = HeartbeatSaveController()+        let manager = SyncManager(heartbeatSaver: saver.save)+        saver.shouldFail = true++        manager.beat(container: fixture.container)++        // Expected: only an isolated heartbeat context becomes dirty and is discarded.+        // Actual before T-2232: the shared heartbeat model remains mutated in the caller.+        #expect(existing.lastBeat == .distantPast,+                "A failed heartbeat save must not leave heartbeat state dirty in the caller context")+        #expect(context.hasChanges,+                "The caller's unrelated pending project must remain pending")+        let failedBeatVerification = ModelContext(fixture.container)+        let storedAfterFailure = try #require(+            failedBeatVerification.fetch(FetchDescriptor<SyncHeartbeat>()).first+        )+        #expect(storedAfterFailure.lastBeat == .distantPast)+        #expect(try projectCount(in: failedBeatVerification) == 0)++        saver.shouldFail = false+        manager.beat(container: fixture.container)++        let recoveredBeatVerification = ModelContext(fixture.container)+        let storedAfterRecovery = try #require(+            recoveredBeatVerification.fetch(FetchDescriptor<SyncHeartbeat>()).first+        )+        #expect(storedAfterRecovery.lastBeat > .distantPast)+        #expect(context.hasChanges)+        #expect(try projectCount(in: recoveredBeatVerification) == 0)     }      @Test@@ -240,9 +309,9 @@ struct SyncManagerTests {             fetcher.shouldFail = true             let manager = SyncManager(heartbeatFetcher: fetcher.fetch) -            manager.startHeartbeat(context: fixture.context)+            manager.startHeartbeat(container: fixture.container)             defer { manager.stopHeartbeat() }-            manager.beat(context: fixture.context)+            manager.beat(container: fixture.container)              #expect(manager.isHeartbeatRunning,                     "A failed beat must leave the existing timer scheduled to retry at its next interval")
specs/bugfixes/sync-heartbeat-context-isolation/report.md Added +104 / -0
diff --git a/specs/bugfixes/sync-heartbeat-context-isolation/report.md b/specs/bugfixes/sync-heartbeat-context-isolation/report.mdnew file mode 100644index 0000000..0900100--- /dev/null+++ b/specs/bugfixes/sync-heartbeat-context-isolation/report.md@@ -0,0 +1,104 @@+# Bugfix Report: Sync Heartbeat Context Isolation++**Date:** 2026-08-30+**Status:** Fixed++## Description of the Issue++The periodic sync heartbeat writes through the caller-provided `ModelContext`, which is the app's shared main context in production. `ModelContext.save()` persists every pending change in that context, so a background heartbeat can commit unrelated work before its owning UI or automation workflow chooses to save it. If the heartbeat save fails, the swallowed error can also leave the heartbeat mutation dirty in the shared context.++**Reproduction steps:**+1. Insert or modify an unrelated model in the context passed to `SyncManager.beat(context:)` without saving it.+2. Run a successful heartbeat, or force a heartbeat save failure and then retry.+3. Observe that a successful beat persists the unrelated model, while a failed save leaves heartbeat state mixed into the caller's pending changes.++**Impact:** Background maintenance can violate transaction ownership for any pending insert, update, or delete on the shared app context. The 60-second timer makes this timing-dependent and can affect UI and automation workflows.++## Investigation Summary++A structured inspection traced the heartbeat from app and Settings call sites through its fetch, mutation, and save behavior.++- **Symptoms examined:** Successful heartbeat saves clear unrelated caller changes; failed heartbeat saves are swallowed after mutating a caller-owned model.+- **Code inspected:** `TransitApp.swift`, `SettingsView.swift`, `Services/SyncManager.swift`, `Models/SyncHeartbeat.swift`, and `TransitTests/SyncManagerTests.swift`.+- **Hypotheses tested:** The singleton fetch guard prevents duplicate insertion and fetch failures, but does not isolate writes. Timer lifecycle and CloudKit launch-mode gating are unrelated to the transaction leak.++### Systematic inspection defects++1. **Data flow — `SyncManager.swift` heartbeat API:** `startHeartbeat(context:)` retains a caller-owned context and supplies it to each maintenance beat.+2. **Database interaction — `SyncManager.swift` beat implementation:** The heartbeat singleton is fetched and mutated in that same caller-owned context.+3. **Transaction handling — `SyncManager.swift` save:** Saving the context commits all registered pending changes, not only `SyncHeartbeat`.+4. **Error handling — `SyncManager.swift` save:** `try?` suppresses write failures without cleaning up the heartbeat mutation from the shared context.+5. **Test expectation — `SyncManagerTests.swift`:** The prior recovery test treated clearing all shared-context changes as normal behavior, encoding the defect rather than transaction isolation.++## Discovered Root Cause++The heartbeat assumes a SwiftData context save is scoped to the heartbeat model. A `ModelContext` is actually a unit of work: its save persists every pending insert, update, and delete registered in that context.++**Defect type:** Data-flow and transaction-isolation error.++**Five Whys:**+1. Why are unrelated changes persisted? Because the heartbeat calls `save()` on the context containing them.+2. Why does the heartbeat receive that context? Because app and Settings integrations pass their shared environment/main context into `startHeartbeat`.+3. Why is that unsafe? Because the heartbeat is background maintenance with independent transaction ownership, while the shared context belongs to interactive and automation workflows.+4. Why can failure contaminate later saves? Because the heartbeat mutates a model before a fallible save and suppresses the error without rollback or disposal.+5. Why did tests allow it? Because the existing recovery assertion described a context-wide save as expected best-effort behavior rather than asserting isolation.++**Contributing factors:** The heartbeat must touch the live container to trigger CloudKit, but that requirement was conflated with using the live container's shared context. SwiftData permits an independent `ModelContext` over the same `ModelContainer`, which separates change tracking while retaining the same store and CloudKit configuration.++## Resolution for the Issue++`SyncManager.startHeartbeat` and `beat` now accept the live `ModelContainer` rather than a caller-owned `ModelContext`. Every beat creates a fresh `ModelContext(container)` and performs the singleton fetch, timestamp mutation, insertion when needed, and save within that isolated unit of work. A successful heartbeat therefore persists only heartbeat-context changes and cannot clear or commit pending work in the app's shared context.++Fetch failures are logged and return before mutation. Save failures call `context.rollback()`, log the error, and then discard the isolated context. Ordinary rollback is safe here because no fetched heartbeat model or context escapes `beat`; the context is immediately abandoned, so the SwiftData re-fault caveat for caller-observed models does not apply.++The app launch and Settings call sites now pass their live container. A deterministic save seam remains for failure-path regression coverage.++**Alternative considered:** Continue accepting a caller context and derive `context.container` inside the heartbeat. This would isolate persistence, but was rejected because a container-based API makes transaction ownership explicit and prevents future callers from assuming the supplied context participates in the beat.++## Regression Test++**Test file:** `Transit/TransitTests/SyncManagerTests.swift`++**Tests:**+- `successfulHeartbeatDoesNotSaveUnrelatedPendingChanges`+- `heartbeatFetchFailureDoesNotInsertOrSaveAndNextBeatRecoversInIsolation`+- `heartbeatSaveFailureDoesNotDirtyCallerContextAndNextBeatRecovers`++**What they verify:** Successful, fetch-failed, and save-failed heartbeat paths leave unrelated caller-context changes pending and unpersisted; heartbeat retries still commit their own isolated record.++**Run command:** `make test-quick`++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/Services/SyncManager.swift` | Creates a fresh heartbeat context over the supplied container; rolls back and logs failed saves |+| `Transit/Transit/TransitApp.swift` | Passes the live model container when starting the app heartbeat |+| `Transit/Transit/Views/Settings/SettingsView.swift` | Passes the environment context's container when starting from Settings |+| `Transit/TransitTests/SyncManagerTests.swift` | Verifies isolation for success, fetch failure, save failure, and retry paths |+| `CHANGELOG.md` | Records the completed T-2232 fix |+| `specs/bugfixes/sync-heartbeat-context-isolation/report.md` | Root-cause, resolution, and verification record |++## Verification++**Automated:**+- [x] Regression tests pass (`make test-quick`)+- [x] Full iOS test suite passes (`make test`)+- [x] UI test suite passes (`make test-ui`)+- [x] Linters/validators pass (`make lint`)++**Manual verification:**+- The red phase failed only the three new T-2232 isolation regressions against the shared-context implementation.+- The same regressions passed after moving heartbeat persistence into a fresh context.++## Prevention++**Recommendations to avoid similar bugs:**+- Give background maintenance operations their own `ModelContext` when they do not own pending UI/service changes.+- Do not assume `ModelContext.save()` is model-scoped.+- Exercise both save success and save failure when testing background persistence paths.++## Related++- Transit T-2232+- Prior context-isolation constraint: T-173 applies to services mutating view-provided models; this heartbeat is the opposite case because it owns no view model and requires an isolated maintenance transaction.

Things to double-check

Physical-device CloudKit propagation

The in-memory tests establish SwiftData context isolation and persistence behavior but cannot directly demonstrate CloudKit transport on a physical device. The implementation retains the live container, and both full platform test suites pass; a device smoke test remains optional release-level confidence rather than a blocker for this scoped fix.

Pre-existing duplicate heartbeat records

The branch preserves the existing first-match singleton behavior. Repairing historical duplicate records is outside T-2232 and is not introduced or worsened by this change.