scramble commit 4fd2a39 (merged: 804b4bd / PR #16) base 9855eab (parent) files 12 touched lines +234 / -96 prod / test / tooling / docs 1 / 8 / 1 / 2

Review: fix unit-test flakiness (merged as PR #16)

Fixes the flaky unit suite — non-retained ModelContainers in 7 test helpers (SwiftData SIGTRAP), an async-cancellation race in TripSyncEventBus, a deterministic LocalWriteHookPBT setup bug the crash had masked, and a Makefile that reported success even when tests failed. Note: this change has already landed on origin/main as PR #16 — this page reviews the commit content for the record.

At a glance

  • Root cause found via crash reports (.ips): a ModelContext does not retain its ModelContainer; 7 helpers dropped the container and the next model access trapped in SwiftData. ARC timing made a different test crash each run — hence "flaky."
  • The prior testing.md diagnosis ("Xcode 26.5 multi-container toolchain bug") was wrong and is corrected.
  • A second, independent flake — TripSyncEventBus.stopCancelsIteration() — was an async cancel-vs-deliver race; fixed deterministically in production.
  • Fixing the crash unmasked a deterministic LocalWriteHookPBT setup bug (production code was correct) — now fixed.
  • make test was returning 0 even on failure (GNU Make 3.81 ignores .SHELLFLAGS, so | xcbeautify swallowed the exit code) — fixed with an inline set -o pipefail;.
  • Status: merged to origin/main as PR #16 (804b4bd); a later PR #15 (T-1619) also landed on main.

Verdict

Clean — already merged (PR #16)

Three parallel review agents (reuse, quality, efficiency) found no blockers, majors, or correctness bugs. The only production logic change is a 7-line cancellation guard in TripSyncEventBus; everything else is test-only, tooling, or docs. The full unit suite ran green and crash-free across repeated runs (778 pass / 0 fail / 0 crash, single host process) and swiftlint + swift-format were clean.

The commit was pushed and squash-merged to origin/main as PR #16 before this review page was produced, so there is nothing left to push — the page stands as the review of record. One out-of-scope follow-up remains open (a shared in-memory-container test factory to retire ~40-way boilerplate).

Review findings

2 raised · 0 fixed · 2 skipped

Jump to findings →

Commits

Three-level explanation

What changed

The app's automated tests would sometimes pass and sometimes crash for no obvious reason — the definition of "flaky." This change makes them reliable.

Why it was happening

Several tests set up a temporary in-memory database (SwiftData) to work against. They kept a handle to the database's context (the thing you read/write through) but let go of the database container itself. Swift's automatic memory management then sometimes threw the container away mid-test, and the next database access crashed. Because the timing was random, a different test would crash each run — so it looked flaky rather than broken.

The fix

  • Seven test helpers now hold onto the container for as long as the test needs it.
  • A separate timing bug in the event bus (a message could still be delivered right after it was told to stop) was made deterministic.
  • A test that had a genuine setup mistake — hidden until now because the crash aborted the run first — was corrected.
  • The make test command was fixed so it actually reports failures instead of always saying "success."

Architecture

SwiftData's ModelContext holds only a non-owning link to its ModelContainer. Test helpers that did let ctx = try makeContainer().mainContext (or returned a Setup/Seed struct holding only the context) let ARC deallocate the container as soon as the helper returned. Subsequent model mutation trapped inside SwiftData (EXC_BREAKPOINT). The simulator's ARC timing usually masked it; under load a different suite lost the race each run.

Patterns

  • Retain-the-container: struct helpers gain a let container: ModelContainer field (written, never read — its job is to keep the container alive); bare-context helpers now return the container and callers derive container.mainContext. Matches a convention already present in untouched suites (PackingCountsTests, ZoneMigrationCoordinatorTests).
  • Cancellation before side-effect: TripSyncEventBus.start() checks Task.isCancelled at the top of its for await loop, so a buffered event delivered after stop() is dropped instead of dispatched.
  • Stage-then-commit-once: LocalWriteHookPBT now seeds+commits its to-be-deleted records up front and stages all deletions before a single commitDeletion, mirroring the real TripDeletion.delete cascade.

Trade-offs

The retain-field pattern is per-suite (7 near-identical helpers touched). A shared TestSupport factory would remove ~40-way boilerplate but touches ~40 files — deferred as out of scope for a flakiness fix.

Deep dive

Ground truth came from ~/Library/Logs/DiagnosticReports/Scramble-*.ips: faulting thread _assertionFailure → SwiftData → a model setter (e.g. TripPackingItem.note.setter) called directly from the test — a use-after-dealloc, not the previously-assumed toolchain multi-container trap. Distinct .ips files naming different tests across one run confirmed non-determinism.

Architecture impact

TripSyncEventBus is @MainActor with synchronous handlers, so the iteration task can only observe cancellation at the for await suspension point; an already-buffered AsyncStream element may be delivered before cancellation is seen. if Task.isCancelled { break } makes stop()'s documented contract ("subsequent yields don't reach handlers") hold regardless of the deliver-vs-cancel race. break (not continue) avoids draining the buffer.

Edge cases & tooling

  • LocalWriteHookPBT failed only for deletedPerSurviving ≥ 1: the mid-staging hook.commit + notifier.calls.removeAll() flushed and erased the staged vanishing-zone deletions before the final assertion. Fix preserves coverage (Invariant checks use isSubset).
  • macOS GNU Make 3.81 predates .SHELLFLAGS (3.82), so the Makefile's pipefail was dead; xcodebuild | xcbeautify returned xcbeautify's 0. An inline PIPEFAIL := set -o pipefail; prefix on the 5 piped recipes restores fail-fast independent of make version.

Important changes — detailed

TripSyncEventBus: honour cancellation before dispatch

Scramble/Scramble/Sharing/TripSyncEventBus.swift

Why it matters. The only production logic change. Fixes a genuine async race where a stopped bus could still dispatch a buffered event to subscribers (orchestrator + coordinator).

What to look at. TripSyncEventBus.swift:105-117 (the `for await` loop in `start()`)

Takeaway. Tests asserting the ABSENCE of an effect after a fixed `Task.sleep` are inherently racy — you can't poll for "nothing happened." Enforce the invariant in production (check `Task.isCancelled` before the side-effect) instead of tuning sleeps.
Rationale. `stop()` cancels the @MainActor iteration task, but an AsyncStream element buffered before cancellation can still be delivered by `for await`; checking `Task.isCancelled` before dispatch makes the documented `stop()` contract deterministic.

Retain the ModelContainer across 7 test helpers

Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swift

Why it matters. The dominant flakiness root cause. Each helper dropped the container, so ARC could deallocate it mid-test → SwiftData SIGTRAP. Non-deterministic → looked flaky.

What to look at. Setup/MatchedSeed structs gain `container`; bare-context helpers return the container (7 suites)

Takeaway. A `ModelContext` does not keep its `ModelContainer` alive. Any helper that vends a context/@Model must also retain the container.
Rationale. Extends the earlier T-1605 fix (d663be1) which patched only two of the affected suites; aligns the 7 stragglers with a retain convention already used by other suites.

LocalWriteHookPBT: stage-then-commit-once (test-setup fix)

Scramble/ScrambleTests/Sharing/LocalWriteHookPBT.swift

Why it matters. A deterministic pre-existing failure the crash cascade had hidden. Left unfixed, the suite would go red the moment the crash was fixed — looking like a regression.

What to look at. LocalWriteHookPBT.swift:80-114 (seed to-be-deleted items) and 180-195 (delete without intermediate commit)

Takeaway. When a PBT over a chokepoint fails only for a sub-slice of scenarios, suspect the test's staging sequence before the production code.
Rationale. The original mid-staging `hook.commit` + `notifier.calls.removeAll()` flushed the staged vanishing-zone deletions; restructuring to commit seed records up front and stage all deletions before one `commitDeletion` mirrors the real `TripDeletion.delete` path. Production `LocalWriteHook` unchanged.

Makefile: make test/build actually fail on failure

Makefile

Why it matters. `make test` / `make test-quick` returned exit 0 even with failing tests — the pre-push gate was blind, which is largely why these failures stayed invisible.

What to look at. Makefile: `PIPEFAIL := set -o pipefail;` prefix on build/test-quick/test/test-ui/install

Takeaway. On macOS default GNU Make 3.81, `.SHELLFLAGS` is silently ignored (added in 3.82). If you pipe a build/test command to a formatter, set `pipefail` inline per recipe or the pipe hides the real exit code.
Rationale. Inline `set -o pipefail;` works regardless of make version and is harmless when xcbeautify is absent (no pipe). Verified the fixed recipe returns non-zero on a forced failure.

testing.md: correct the misdiagnosis

docs/agent-notes/testing.md

Why it matters. The old note blamed an Xcode 26.5 toolchain bug and told future sessions NOT to fix it — actively harmful guidance now that the real cause is known.

What to look at. docs/agent-notes/testing.md (full rewrite)

Takeaway. A confidently-wrong agent note is worse than none; correct it when the evidence contradicts it.
Rationale. Crash reports proved use-after-dealloc, not a multi-container toolchain trap. The note now documents the real cause, the .ips diagnosis path, the retain rule, the TripSyncEventBus flake, and the make 3.81 caveat.

Key decisions

Fix the LocalWriteHookPBT test, not the production code.

The PBT failed deterministically for every deletedPerSurviving ≥ 1 scenario. Reading LocalWriteHook.commitChanges showed it correctly collects from the context's pending insert/change/delete arrays; the fault was the test's own mid-staging hook.commit flushing the staged deletions. The real TripDeletion.delete path stages then commits once, so the test was wrong, not the code.

Enforce cancellation in production rather than harden the test's sleep.

The alternative — a longer/awaited sleep in the test — cannot make "assert nothing happened" deterministic. Guarding dispatch with Task.isCancelled makes stop()'s contract hold for real callers too, so it's a genuine robustness improvement, not just a test appeasement.

Retain via struct field / return-the-container, not a process-wide cache.

A static container cache would also prevent dealloc but leaks all containers for the process and reintroduces cross-test coupling. Scoping the container's lifetime to the test matches the existing T-1605 convention and keeps each test isolated.

Defer the shared TestSupport container factory.

All three review agents flagged the ~40-way duplication of the in-memory-container boilerplate as the ideal follow-up. It touches ~40 files and is orthogonal to the flakiness fix, so it is intentionally out of scope for this commit.

Review findings

SeverityAreaFindingResolution
minorScrambleTests (~40 suites)The in-memory-container boilerplate (Schema V3 + ModelConfiguration + ModelContainer) is duplicated across ~40 suites. This diff perpetuates it (does not worsen it).Deferred: a shared TestSupport.makeInMemoryContainer() factory would retire the duplication and structurally prevent the non-retain bug from recurring. Out of scope for a flakiness patch (touches ~40 files).
nit7 test helpersThe 'retains the container' WHY comment is near-verbatim across the fixed sites.Acceptable for test helpers; skipped.

Per-file diffs

Click to expand.

CHANGELOG.md Modified +15 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex ad13aed..4a4965b 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -93,6 +93,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Fixed +- Unit-test flakiness — non-retained `ModelContainer` in test helpers (the dominant cause):+  - Seven suites built a SwiftData `ModelContainer` as a local, derived a `ModelContext`/`@Model` from it, and returned only the context (or a struct/context that didn't include the container). A `ModelContext` does not keep its `ModelContainer` alive, so ARC could deallocate it mid-test and the next model access trapped inside SwiftData (`EXC_BREAKPOINT`/`SIGTRAP`). Non-deterministic ARC timing made a different test "crash" each run, with the host restarting and the remaining tests reporting as `0.000s` crashes — i.e. flaky. Confirmed via `~/Library/Logs/DiagnosticReports/Scramble-*.ips` (use-after-dealloc from a model setter called by the test), not the "Xcode 26.5 multi-container toolchain bug" previously assumed.+  - Fixed by retaining the container for the caller's lifetime: `Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swift` (`Setup` gains a `container` field), `RulesEngine/RulesEngineRunnerTests.swift` (`MatchedSeed` gains `container`), `Components/PackingItemRowAccessibilityTests.swift` + `Components/TaskRowAccessibilityTests.swift` (`Setup` gains `container`), and the bare-context helpers `Models/PackingItemContentBridgeTests.swift`, `Models/TripPackingItemBridgeTests.swift`, `Explainability/WhyResolverParticipantHideTests.swift` (helper now returns the `ModelContainer`; callers derive the context). Extends the earlier T-1605 fix (`d663be1`) that patched only two of the affected suites. Same root cause that crashed the on-device test host (see `docs/agent-notes/`); test-only change.++- Unit-test flakiness — async-cancellation race in `TripSyncEventBus`:+  - `Scramble/Scramble/Sharing/TripSyncEventBus.swift` — `start()`'s iteration task now checks `Task.isCancelled` before dispatching each event. `stop()` cancels the task, but an event already buffered in the `AsyncStream` can still be delivered by `for await` on the next resume; whether it reached handlers depended on a cancel-vs-deliver race, so `TripSyncEventBusTests.stopCancelsIteration()` passed some runs and failed others. The guard makes `stop()`'s documented contract ("subsequent yields don't reach handlers") deterministic.++- `LocalWriteHookPBT.mixedZonePartition` test-setup bug (deterministic; was masked by the crash cascade):+  - `Scramble/ScrambleTests/Sharing/LocalWriteHookPBT.swift` — the surviving-zone deletion branch called `hook.commit(context)` + `notifier.calls.removeAll()` mid-staging, which prematurely flushed the already-staged vanishing-zone deletions and erased the notifier record, so the final `commitDeletion` reported no vanishing-zone deletions (Invariant 2 failed for every `deletedPerSurviving >= 1` scenario). Restructured so each surviving zone's to-be-deleted items are created and committed during seeding, then deleted with no intermediate commit — mirroring the real `TripDeletion.delete` reverse-cascade. Production `LocalWriteHook` code was correct and is unchanged.++- `make test` / `make test-quick` reported success even when tests failed:+  - `Makefile` — macOS ships GNU Make 3.81, which predates `.SHELLFLAGS` (added in 3.82), so the file's `pipefail` was silently ignored and `xcodebuild test | xcbeautify` returned xcbeautify's exit code (`0`), masking test/build failures. Added a `PIPEFAIL := set -o pipefail;` prefix to every recipe that pipes to `xcbeautify` (`build`, `test-quick`, `test`, `test-ui`, `install`) so failures propagate regardless of make version (harmless when `xcbeautify` is absent and there is no pipe).++- `docs/agent-notes/testing.md` — rewrote the note: the "Xcode 26.5 — SwiftData multi-container crash" diagnosis was wrong (the crash reports show non-retained-container use-after-dealloc). Now documents the real root cause, how to diagnose it from `.ips` crash reports, the retain-the-container rule, the second `TripSyncEventBus` flake, and the GNU Make 3.81 exit-code-masking caveat.+ - On-device launch failures (SwiftData migration + CloudKit):   - `Scramble/Scramble/Models/Schema.swift`, `Scramble/Scramble/Persistence/ModelStore.swift` — removed the short-lived `SchemaV4` and the V3 → V4 migration stage that crashed on-device with `NSInvalidArgumentException: Duplicate version checksums across stages detected`. `SchemaV4` was byte-identical to `SchemaV3` because its only addition (`Trip.countryCode`) lives on the shared top-level `Trip` class that every schema version references, and SwiftData computes the version checksum from structure, not the version number. `Trip.countryCode` now belongs to `SchemaV3` and is added to existing stores via automatic lightweight inference; `AppMigrationPlan` is back to `[V1, V2, V3]`.   - `Scramble/Scramble/Models/TripPackingItem.swift` (plus `SnapshotMaintenance`, `TripPackingItemRecordTranslator`, `SchemaV3MigrationStage`, `ZoneMigrationCoordinator`, `RecordRepresentable` doc) — converted `TripPackingItem.personSnapshot` from an inverse-less `@Relationship` to a `personSnapshotID: UUID?` value reference (mirroring `TripTask.assigneePersonID`). The `globals` container's SwiftData CloudKit mirror rejected the inverse-less relationship on a real device (`NSCocoaErrorDomain Code=134060 ... requires that all relationships have an inverse: TripPackingItem: personSnapshot`), dropping `globals` to a local-only fallback that silently disabled iCloud sync for `Person` and the master lists. A `personSnapshot` computed bridge keeps every read site unchanged, and the `CKRecord` wire format (`personSnapshotID: String`) is unchanged. Predicates use the stored `personSnapshotID`.
Makefile Modified +13 / -5
diff --git a/Makefile b/Makefileindex 13a58bc..c0e8d74 100644--- a/Makefile+++ b/Makefile@@ -19,6 +19,14 @@ else PIPE_PRETTY = endif +# The .SHELLFLAGS pipefail above is ignored by GNU make 3.81 (the macOS system+# default; .SHELLFLAGS landed in 3.82). Without pipefail, `xcodebuild | xcbeautify`+# returns xcbeautify's exit code (0) and SILENTLY MASKS test/build failures —+# `make test` would report success on a red suite. Prefix every piped recipe+# with this so pipefail is set in the recipe's own shell regardless of make+# version (harmless when xcbeautify is absent and there is no pipe).+PIPEFAIL := set -o pipefail;+ SWIFTLINT := $(shell command -v swiftlint 2>/dev/null) SWIFT_FORMAT := $(shell command -v swift-format 2>/dev/null) @@ -59,7 +67,7 @@ help:  .PHONY: build build:-	xcodebuild build \+	$(PIPEFAIL) xcodebuild build \ 		-project $(PROJECT) \ 		-scheme $(SCHEME) \ 		-destination 'platform=iOS Simulator,name=$(SIMULATOR)' \@@ -79,7 +87,7 @@ clean: # extra XCTest UI runner and is the dominant cost of a full `test` run). .PHONY: test-quick test-quick:-	xcodebuild test \+	$(PIPEFAIL) xcodebuild test \ 		-project $(PROJECT) \ 		-scheme $(SCHEME) \ 		-destination 'platform=iOS Simulator,name=$(SIMULATOR)' \@@ -94,7 +102,7 @@ test-quick: # simulator clones race on launch / status-bar overrides and produce flakes. .PHONY: test test:-	xcodebuild test \+	$(PIPEFAIL) xcodebuild test \ 		-project $(PROJECT) \ 		-scheme $(SCHEME) \ 		-destination 'platform=iOS Simulator,name=$(SIMULATOR)' \@@ -106,7 +114,7 @@ test:  .PHONY: test-ui test-ui:-	xcodebuild test \+	$(PIPEFAIL) xcodebuild test \ 		-project $(PROJECT) \ 		-scheme $(SCHEME) \ 		-destination 'platform=iOS Simulator,name=$(SIMULATOR)' \@@ -134,7 +142,7 @@ install: 		echo "Error: No '$(DEVICE_MODEL)' device found. Connect one or override DEVICE_MODEL=..."; \ 		exit 1; \ 	fi-	xcodebuild build \+	$(PIPEFAIL) xcodebuild build \ 		-project $(PROJECT) -scheme $(SCHEME) \ 		-destination 'id=$(DEVICE_ID)' -configuration $(CONFIG) \ 		-derivedDataPath $(DERIVED_DATA) $(PIPE_PRETTY)
Scramble/Scramble/Sharing/TripSyncEventBus.swift Modified +7 / -0
diff --git a/Scramble/Scramble/Sharing/TripSyncEventBus.swift b/Scramble/Scramble/Sharing/TripSyncEventBus.swiftindex 47e16e3..e56e4ce 100644--- a/Scramble/Scramble/Sharing/TripSyncEventBus.swift+++ b/Scramble/Scramble/Sharing/TripSyncEventBus.swift@@ -104,6 +104,13 @@ final class TripSyncEventBus {     let coordinator = coordinatorHandler     iterationTask = Task { @MainActor [events] in       for await event in events {+        // Honour cancellation before dispatching. `stop()` cancels this+        // task, but an event already buffered in the stream can still be+        // delivered by `for await` on the next resume; without this guard+        // whether that buffered event reaches handlers depends on the+        // cancel-vs-deliver race. Checking here makes `stop()`'s contract+        // ("subsequent yields don't reach handlers") deterministic.+        if Task.isCancelled { break }         Self.dispatch(event, to: orchestrator, label: "orchestrator")         Self.dispatch(event, to: coordinator, label: "coordinator")       }
Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift Modified +6 / -1
diff --git a/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift b/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swiftindex 895b701..505d170 100644--- a/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift+++ b/Scramble/ScrambleTests/Components/PackingItemRowAccessibilityTests.swift@@ -91,6 +91,11 @@ struct PackingItemRowAccessibilityTests {   // MARK: - Helpers    struct Setup {+    /// Retains the container for the setup's lifetime. A `ModelContext` does+    /// not keep its `ModelContainer` alive, so without this the container+    /// deallocates when the helper returns and any later model access traps+    /// inside SwiftData (SIGTRAP).+    let container: ModelContainer     let context: ModelContext   } @@ -100,7 +105,7 @@ struct PackingItemRowAccessibilityTests {       schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none     )     let container = try ModelContainer(for: schema, configurations: [config])-    return Setup(context: container.mainContext)+    return Setup(container: container, context: container.mainContext)   }    static func makeItem(
Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swift Modified +6 / -1
diff --git a/Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swift b/Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swiftindex 4854a54..660d38c 100644--- a/Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swift+++ b/Scramble/ScrambleTests/Components/TaskRowAccessibilityTests.swift@@ -134,6 +134,11 @@ struct TaskRowAccessibilityTests {   // MARK: - Helpers    struct Setup {+    /// Retains the container for the setup's lifetime. A `ModelContext` does+    /// not keep its `ModelContainer` alive, so without this the container+    /// deallocates when the helper returns and any later model access traps+    /// inside SwiftData (SIGTRAP).+    let container: ModelContainer     let context: ModelContext   } @@ -143,6 +148,6 @@ struct TaskRowAccessibilityTests {       schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none     )     let container = try ModelContainer(for: schema, configurations: [config])-    return Setup(context: container.mainContext)+    return Setup(container: container, context: container.mainContext)   } }
Scramble/ScrambleTests/Explainability/WhyResolverParticipantHideTests.swift Modified +16 / -8
diff --git a/Scramble/ScrambleTests/Explainability/WhyResolverParticipantHideTests.swift b/Scramble/ScrambleTests/Explainability/WhyResolverParticipantHideTests.swiftindex a07b56c..0a40eb0 100644--- a/Scramble/ScrambleTests/Explainability/WhyResolverParticipantHideTests.swift+++ b/Scramble/ScrambleTests/Explainability/WhyResolverParticipantHideTests.swift@@ -16,22 +16,26 @@ import Testing @MainActor struct WhyResolverParticipantHideTests { -  private func makeContext() throws -> ModelContext {+  // Returns the container (not the context) so the caller retains it. A+  // `ModelContext` does not keep its `ModelContainer` alive; returning a bare+  // context lets the container deallocate and the next model access traps+  // inside SwiftData (SIGTRAP).+  private func makeContainer() throws -> ModelContainer {     let schema = Schema(versionedSchema: SchemaV3.self)     let config = ModelConfiguration(       schema: schema,       isStoredInMemoryOnly: true,       cloudKitDatabase: .none     )-    let container = try ModelContainer(for: schema, configurations: [config])-    return ModelContext(container)+    return try ModelContainer(for: schema, configurations: [config])   }    // MARK: - TripTask    @Test("Participant: rule-driven task with missing master returns nil")   func participantTaskWithUnresolvedMasterIsHidden() throws {-    let context = try makeContext()+    let container = try makeContainer()+    let context = ModelContext(container)     let trip = Trip(name: "Trip", startDate: .now, endDate: .now)     context.insert(trip)     let task = TripTask(@@ -53,7 +57,8 @@ struct WhyResolverParticipantHideTests {    @Test("Owner: rule-driven task with missing master returns .ruleMasterDeleted")   func ownerTaskWithUnresolvedMasterFallsBack() throws {-    let context = try makeContext()+    let container = try makeContainer()+    let context = ModelContext(container)     let trip = Trip(name: "Trip", startDate: .now, endDate: .now)     context.insert(trip)     let task = TripTask(@@ -71,7 +76,8 @@ struct WhyResolverParticipantHideTests {    @Test("Participant: manual tasks still resolve to .manual (no hide)")   func participantManualTaskStillRenders() throws {-    let context = try makeContext()+    let container = try makeContainer()+    let context = ModelContext(container)     let trip = Trip(name: "Trip", startDate: .now, endDate: .now)     context.insert(trip)     let task = TripTask(@@ -95,7 +101,8 @@ struct WhyResolverParticipantHideTests {    @Test("Participant: rule-driven packing item with missing master returns nil")   func participantPackingItemWithUnresolvedMasterIsHidden() throws {-    let context = try makeContext()+    let container = try makeContainer()+    let context = ModelContext(container)     let trip = Trip(name: "Trip", startDate: .now, endDate: .now)     context.insert(trip)     let item = TripPackingItem(@@ -118,7 +125,8 @@ struct WhyResolverParticipantHideTests {    @Test("Owner: rule-driven packing item with missing master falls back to .ruleMasterDeleted")   func ownerPackingItemWithUnresolvedMasterFallsBack() throws {-    let context = try makeContext()+    let container = try makeContainer()+    let context = ModelContext(container)     let trip = Trip(name: "Trip", startDate: .now, endDate: .now)     context.insert(trip)     let item = TripPackingItem(
Scramble/ScrambleTests/Models/PackingItemContentBridgeTests.swift Modified +20 / -9
diff --git a/Scramble/ScrambleTests/Models/PackingItemContentBridgeTests.swift b/Scramble/ScrambleTests/Models/PackingItemContentBridgeTests.swiftindex b505c2a..60a5ef6 100644--- a/Scramble/ScrambleTests/Models/PackingItemContentBridgeTests.swift+++ b/Scramble/ScrambleTests/Models/PackingItemContentBridgeTests.swift@@ -16,7 +16,8 @@ struct TripPackingItemContentBridgeTests {    @Test("subItems get/set round-trips through subItemsData")   func subItemsRoundTrip() throws {-    let context = try Self.makeContext()+    let container = try Self.makeContainer()+    let context = container.mainContext     let item = TripPackingItem(name: "Toys")     context.insert(item) @@ -29,7 +30,8 @@ struct TripPackingItemContentBridgeTests {    @Test("setting subItems = [] clears subItemsData to nil")   func emptyListClearsData() throws {-    let context = try Self.makeContext()+    let container = try Self.makeContainer()+    let context = container.mainContext     let item = TripPackingItem(name: "Books")     context.insert(item) @@ -43,7 +45,8 @@ struct TripPackingItemContentBridgeTests {    @Test("non-nil empty Data() reads back as []")   func nonNilEmptyDataReadsAsEmpty() throws {-    let context = try Self.makeContext()+    let container = try Self.makeContainer()+    let context = container.mainContext     let item = TripPackingItem(name: "Snacks")     context.insert(item) @@ -55,7 +58,8 @@ struct TripPackingItemContentBridgeTests {    @Test("garbage Data decodes to []")   func garbageDataDecodesToEmpty() throws {-    let context = try Self.makeContext()+    let container = try Self.makeContainer()+    let context = container.mainContext     let item = TripPackingItem(name: "Gear")     context.insert(item) @@ -65,7 +69,8 @@ struct TripPackingItemContentBridgeTests {    @Test("note set then cleared")   func noteSetThenCleared() throws {-    let context = try Self.makeContext()+    let container = try Self.makeContainer()+    let context = container.mainContext     let item = TripPackingItem(name: "Camera")     context.insert(item) @@ -80,7 +85,8 @@ struct TripPackingItemContentBridgeTests {    @Test("note via init")   func noteViaInit() throws {-    let context = try Self.makeContext()+    let container = try Self.makeContainer()+    let context = container.mainContext     let item = TripPackingItem(name: "Charger", note: "USB-C only")     context.insert(item)     try context.save()@@ -89,7 +95,8 @@ struct TripPackingItemContentBridgeTests {    @Test("skip -> restore leaves note and subItems unchanged")   func skipRestorePreservesContent() throws {-    let context = try Self.makeContext()+    let container = try Self.makeContainer()+    let context = container.mainContext     let item = TripPackingItem(name: "Toys", note: "soft ones only")     context.insert(item)     item.subItems = ["bear", "blocks"]@@ -106,11 +113,15 @@ struct TripPackingItemContentBridgeTests {     #expect(item.subItems == ["bear", "blocks"])   } -  private static func makeContext() throws -> ModelContext {+  // Returns the container (not the context) so the caller retains it. A+  // `ModelContext` does not keep its `ModelContainer` alive; returning a bare+  // context lets the container deallocate and the next model access traps+  // inside SwiftData (SIGTRAP).+  private static func makeContainer() throws -> ModelContainer {     let schema = Schema(versionedSchema: SchemaV3.self)     let config = ModelConfiguration(       schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none     )-    return try ModelContainer(for: schema, configurations: [config]).mainContext+    return try ModelContainer(for: schema, configurations: [config])   } }
Scramble/ScrambleTests/Models/TripPackingItemBridgeTests.swift Modified +16 / -7
diff --git a/Scramble/ScrambleTests/Models/TripPackingItemBridgeTests.swift b/Scramble/ScrambleTests/Models/TripPackingItemBridgeTests.swiftindex 8866a0a..cc0ffad 100644--- a/Scramble/ScrambleTests/Models/TripPackingItemBridgeTests.swift+++ b/Scramble/ScrambleTests/Models/TripPackingItemBridgeTests.swift@@ -17,7 +17,8 @@ struct TripPackingItemBridgeTests {    @Test("Fast path: resolves via trip.participantSnapshots")   func resolvesViaParticipantSnapshots() throws {-    let context = try Self.makeContext()+    let container = try Self.makeContainer()+    let context = container.mainContext     let trip = Trip(name: "T", startDate: .now, endDate: .now)     context.insert(trip)     let snapshot = TripPersonSnapshot(personID: UUID(), name: "Alice", trip: trip)@@ -33,7 +34,8 @@ struct TripPackingItemBridgeTests {    @Test("Fallback path: resolves via modelContext fetch when the trip side isn't wired")   func resolvesViaContextFetchWhenTripNil() throws {-    let context = try Self.makeContext()+    let container = try Self.makeContainer()+    let context = container.mainContext     let snapshot = TripPersonSnapshot(personID: UUID(), name: "Bob")     context.insert(snapshot)     // No trip relationship and no participantSnapshots, so the in-memory@@ -49,7 +51,8 @@ struct TripPackingItemBridgeTests {    @Test("Nil personSnapshotID resolves to nil")   func nilIDResolvesNil() throws {-    let context = try Self.makeContext()+    let container = try Self.makeContainer()+    let context = container.mainContext     let item = TripPackingItem(name: "Towel")     context.insert(item)     try context.save()@@ -60,7 +63,8 @@ struct TripPackingItemBridgeTests {    @Test("Dangling personSnapshotID (no matching snapshot) resolves to nil")   func danglingIDResolvesNil() throws {-    let context = try Self.makeContext()+    let container = try Self.makeContainer()+    let context = container.mainContext     let item = TripPackingItem(name: "Boots")     item.personSnapshotID = UUID()  // points at nothing     context.insert(item)@@ -71,7 +75,8 @@ struct TripPackingItemBridgeTests {    @Test("Setter stores the snapshot's id and clears on nil")   func setterStoresID() throws {-    let context = try Self.makeContext()+    let container = try Self.makeContainer()+    let context = container.mainContext     let snapshot = TripPersonSnapshot(personID: UUID(), name: "Cara")     context.insert(snapshot)     let item = TripPackingItem(name: "Map")@@ -83,11 +88,15 @@ struct TripPackingItemBridgeTests {     #expect(item.personSnapshotID == nil)   } -  private static func makeContext() throws -> ModelContext {+  // Returns the container (not the context) so the caller retains it. A+  // `ModelContext` does not keep its `ModelContainer` alive; returning a bare+  // context lets the container deallocate and the next model access traps+  // inside SwiftData (SIGTRAP).+  private static func makeContainer() throws -> ModelContainer {     let schema = Schema(versionedSchema: SchemaV3.self)     let config = ModelConfiguration(       schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none     )-    return try ModelContainer(for: schema, configurations: [config]).mainContext+    return try ModelContainer(for: schema, configurations: [config])   } }
Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swift Modified +6 / -1
diff --git a/Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swift b/Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swiftindex 4c295eb..86b2caf 100644--- a/Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swift+++ b/Scramble/ScrambleTests/Notifications/NotificationsServiceTests.swift@@ -177,6 +177,11 @@ struct NotificationsServiceTests {   // MARK: - Helpers    struct Setup {+    /// Retains the container for the setup's lifetime. A `ModelContext` does+    /// not keep its `ModelContainer` alive, so without this the container+    /// deallocates when the helper returns and any later model access traps+    /// inside SwiftData (SIGTRAP).+    let container: ModelContainer     let stub: StubNotificationCenter     let service: NotificationsService     let context: ModelContext@@ -222,7 +227,7 @@ struct NotificationsServiceTests {       coalesceWindow: .milliseconds(100)     )     return Setup(-      stub: stub, service: service, context: context, calendar: cal+      container: container, stub: stub, service: service, context: context, calendar: cal     )   } }
Scramble/ScrambleTests/RulesEngine/RulesEngineRunnerTests.swift Modified +7 / -1
diff --git a/Scramble/ScrambleTests/RulesEngine/RulesEngineRunnerTests.swift b/Scramble/ScrambleTests/RulesEngine/RulesEngineRunnerTests.swiftindex d8e3a54..a2ff485 100644--- a/Scramble/ScrambleTests/RulesEngine/RulesEngineRunnerTests.swift+++ b/Scramble/ScrambleTests/RulesEngine/RulesEngineRunnerTests.swift@@ -318,6 +318,11 @@ struct RulesEngineRunnerTests {   /// Bundle returned by `seedMatchedPackingItem` for the independence   /// assertions (a struct rather than a 4-tuple per SwiftLint).   private struct MatchedSeed {+    /// Retains the container for the seed's lifetime. A `ModelContext` does+    /// not keep its `ModelContainer` alive, so without this the container+    /// deallocates when the helper returns and any later model access traps+    /// inside SwiftData (SIGTRAP).+    let container: ModelContainer     let context: ModelContext     let trip: Trip     let person: Person@@ -355,7 +360,8 @@ struct RulesEngineRunnerTests {     let runner = RulesEngineRunner(context: context)     _ = try runner.runForTrip(trip)     let item = try #require(try context.fetch(FetchDescriptor<TripPackingItem>()).first)-    return MatchedSeed(context: context, trip: trip, person: person, item: item)+    return MatchedSeed(+      container: container, context: context, trip: trip, person: person, item: item)   }    @Test("Req 7.1/7.2: recompute leaves an existing item's note + subItems unchanged")
Scramble/ScrambleTests/Sharing/LocalWriteHookPBT.swift Modified +22 / -7
diff --git a/Scramble/ScrambleTests/Sharing/LocalWriteHookPBT.swift b/Scramble/ScrambleTests/Sharing/LocalWriteHookPBT.swiftindex 9e280f1..ef64fd7 100644--- a/Scramble/ScrambleTests/Sharing/LocalWriteHookPBT.swift+++ b/Scramble/ScrambleTests/Sharing/LocalWriteHookPBT.swift@@ -77,9 +77,18 @@ struct LocalWriteHookPBT {     let notifier = RecordingNotifier()     let hook = LocalWriteHook(notifier: notifier) -    // Seed surviving zones: trip + TripZoneState per zone.+    // Seed surviving zones: trip + TripZoneState per zone, plus the items+    // each surviving zone will later delete. Those to-be-deleted items are+    // created and committed HERE, during seeding, so they exist as committed+    // records before any deletion is staged. Creating them mid-staging (with+    // the intermediate `hook.commit` they would then require) prematurely+    // flushes the vanishing-zone deletions staged below, so the final+    // `commitDeletion` would no longer see them — which is not how the real+    // `TripDeletion.delete` cascade behaves (it stages everything, then+    // commits once).     var survivingZoneIDs: [CKRecordZone.ID] = []     var survivingTrips: [Trip] = []+    var survivingDeletedItems: [[TripPackingItem]] = []     for index in 0..<scenario.survivingZoneCount {       let trip = Trip(name: "Surviving\(index)", startDate: .now, endDate: .now)       context.insert(trip)@@ -93,8 +102,15 @@ struct LocalWriteHookPBT {         zoneScope: "private"       )       context.insert(state)+      var delItems: [TripPackingItem] = []+      for delIndex in 0..<scenario.deletedPerSurviving {+        let item = TripPackingItem(trip: trip, name: "s-del-\(delIndex)")+        context.insert(item)+        delItems.append(item)+      }       survivingZoneIDs.append(zoneID)       survivingTrips.append(trip)+      survivingDeletedItems.append(delItems)     }     try hook.commit(context)     notifier.calls.removeAll()@@ -161,14 +177,13 @@ struct LocalWriteHookPBT {       context.delete(state)     } -    // Surviving-zone deletions: insert + commit some items then delete them.+    // Surviving-zone deletions: delete the items committed during seeding,+    // and stage fresh dirty items. No intermediate commit here — everything+    // is committed once via `commitDeletion` below, mirroring the real+    // reverse-cascade.     for (index, trip) in survivingTrips.enumerated() {       let zoneID = survivingZoneIDs[index]-      for delIndex in 0..<scenario.deletedPerSurviving {-        let item = TripPackingItem(trip: trip, name: "s-del-\(delIndex)")-        context.insert(item)-        try hook.commit(context)-        notifier.calls.removeAll()+      for item in survivingDeletedItems[index] {         context.delete(item)         expectedSurvivingDeleted[zoneID, default: []].insert(item.id.uuidString)       }
docs/agent-notes/testing.md Modified +100 / -56
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex 5a8f619..9489db4 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -1,66 +1,110 @@ # Testing notes -## Xcode 26.5 — SwiftData multi-container crash in the test suite+## The unit suite's "flaky crash" was non-retained `ModelContainer`s (NOT a toolchain bug) -**Symptom.** Running `make test-quick` (or `make test`, or any `xcodebuild test`-covering more than a handful of SwiftData suites) crashes mid-run with a cascade-of `0.000s` failures attributed to "Test crashed with signal trap" /-`Crash: Scramble at <SomeSwiftDataTest>`. ~80–100 tests pass first, then the-host process dies and every remaining test in that process reports as crashed;-xcodebuild retries on a fresh simulator clone and crashes again. The crash is an-`EXC_BREAKPOINT` (`SIGTRAP`) raised **inside SwiftData**, not an assertion-failure.+**Symptom.** Running `make test-quick` (or `make test`) intermittently crashed+mid-run: ~80–100 tests passed, then the test host died with an+`EXC_BREAKPOINT` (`SIGTRAP`) raised inside SwiftData, every remaining test in+that process reported as a `0.000s` "crash," and xcodebuild relaunched on a+fresh simulator clone and often crashed again. Which test "crashed" differed+run to run, so it read as flaky. -**Root cause.** Every SwiftData test suite builds its own in-memory container-with a freshly-derived schema:+**Root cause (confirmed June 2026 via crash reports).** Several test helpers+built a SwiftData `ModelContainer` as a local, derived a `ModelContext` (and+sometimes `@Model` objects) from it, and returned only the context — not the+container:  ```swift-let schema = Schema(versionedSchema: SchemaV3.self)   // re-derived per container-let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)-return try ModelContainer(for: schema, configurations: [config])+// BUG: container is a temporary; nothing the caller holds retains it.+private static func makeContext() throws -> ModelContext {+  let schema = Schema(versionedSchema: SchemaV3.self)+  let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)+  return try ModelContainer(for: schema, configurations: [config]).mainContext+} ``` -Under the Xcode 26.5 toolchain, creating a **2nd+ `ModelContainer` for the same-`SchemaV3`** within a single test process traps. It is **not** caused by any-feature code — proven by:-- a pre-existing, untouched suite (`TripPackingItemBridgeTests`) crashing 0/N-  when run alone, and-- the same crash reproducing at the pre-feature baseline commit.--It surfaced with the Xcode 26.5 upgrade (June 2026); earlier toolchains-tolerated the repeated-container pattern.--**What still works.**-- `make build` — fine.-- `xcodebuild build-for-testing` — fine (both unit + UI targets compile).-- `make lint` / `make format` — fine.-- A **single** test in its own process (`-only-testing:Suite/func`) sometimes-  runs and reports; often the runner instead reports `0 tests` with scrambled-  output (`"Testing started"` printed after `** TEST SUCCEEDED **`). Either way-  it does not crash — but you cannot rely on it for a clean red/green signal.-- Reusing one shared `Schema` instance across containers avoided the crash in a-  minimal 8-container diagnostic, but **did not** fix a real multi-test suite —-  so schema-sharing is a clue, not the whole fix.--**Recovery that does NOT help:** `simctl shutdown all`, deleting-`~/Library/Developer/XCTestDevices` clones, restarting-`com.apple.CoreSimulator.CoreSimulatorService`, `simctl erase all`, device-reboot. A separate, worse "wedge" (stuck `launchd_sim` surviving `kill -9`) is-cleared by a **machine reboot**, but the reboot does **not** fix the-multi-container `SIGTRAP` above — that is a toolchain regression, not a wedged-service.--**Until it's fixed (proper fix is out of scope of any one feature — it's a-project-wide test-harness change):** verify SwiftData-touching work with-`make build` + `build-for-testing` + `make lint`, and treat a full-suite/whole--suite crash as the toolchain, not a code failure. Do **not** burn time fighting-the simulator. A real fix likely means a shared test-support container/schema-factory used by every suite (and confirming it survives multi-suite runs).--**Operational caution:** do not run two `xcodebuild test` invocations against the-same simulator concurrently (parallel worktrees) — they race and can wedge-CoreSimulator. Also scope any `pkill` to Scramble (`pkill -f "xcodebuild.*Scramble"`),-not a blanket `pkill -f xcodebuild`, which will kill unrelated projects' test runs-on a shared machine.+A `ModelContext` does **not** keep its `ModelContainer` alive. The container+deallocates when the helper returns; the next model access/mutation through the+orphaned context traps inside SwiftData (`_assertionFailure` → `SIGTRAP`). It+manifests as "flaky" because ARC dealloc timing is non-deterministic — under+simulator load it sometimes collects the container mid-test, sometimes not, and+a different orphaned suite loses the race each run.++The earlier theory in this note — "Xcode 26.5 multi-container toolchain bug" —+was **wrong**. The crash reports+(`~/Library/Logs/DiagnosticReports/Scramble-*.ips`) show a use-after-dealloc+from a model setter called directly from the test, e.g.:++```+libswiftCore  _assertionFailure+SwiftData     …+Scramble      TripPackingItem.note.setter        ← mutate through orphaned context+ScrambleTests RulesEngineRunnerTests.deletingItemRemovesNoteAndSubItems()+```++The note's own "evidence" for a toolchain bug (e.g. `TripPackingItemBridgeTests`+crashing 0/N when run alone) was just that suite's own non-retained helper.+Repeated multi-container creation is **not** the problem — creating many+in-memory containers is fine; only dropping one while its context is still in+use is fatal.++**How to diagnose.** Run the suite, then read the newest+`~/Library/Logs/DiagnosticReports/Scramble-*.ips`. Parse the faulting thread:+`SIGTRAP` + `SwiftData` + `_assertionFailure` + a frame in the test that mutates+a model = a non-retained container. Different `.ips` files naming different+tests across one run confirms the non-determinism.++**The fix / the rule.** Every helper that vends a `ModelContext` or `@Model`+must keep the container alive for as long as the caller uses it:++- Helper returns a struct → add a `container: ModelContainer` field (it never+  needs to be read; it exists to retain).+- Helper returns a bare context → return the `ModelContainer` instead and let+  the caller derive `container.mainContext` / `ModelContext(container)`.+- Helper used inside one test body → bind `let container = …; let context =+  container.mainContext` (already-scoped is fine).++This is the same rule as `rules/language-rules/swift.md` → "Retain the+ModelContainer in tests — never use a temporary." Fixed across all seven+affected suites (`NotificationsServiceTests`, `RulesEngineRunnerTests`,+`PackingItemContentBridgeTests`, `TripPackingItemBridgeTests`,+`PackingItemRowAccessibilityTests`, `TaskRowAccessibilityTests`,+`WhyResolverParticipantHideTests`); the earlier T-1605 commit (`d663be1`) had+fixed the same pattern in two category suites but missed the rest. Verified by a+full clean run (single host PID, zero `0.000s` crashes) plus repeated runs of+the SwiftData-heavy suites.++## A second, independent flake: async-cancellation race in `TripSyncEventBus`++Once the crash was fixed and the suite ran to completion,+`TripSyncEventBusTests.stopCancelsIteration()` showed up as genuinely flaky+(passed one run, failed the next). `start()` spawns a `@MainActor` iteration+task; the test calls `start()` then `stop()` (which cancels it) with no+suspension between, so the task only runs when the test next awaits — by which+point an event has already been yielded into the `AsyncStream` buffer. Whether a+cancelled `for await` delivers that buffered element before observing+cancellation is timing-dependent. Fix was in production+(`TripSyncEventBus.start`): check `Task.isCancelled` at the top of the loop+before dispatching, so a stopped bus deterministically never dispatches —+honouring `stop()`'s documented contract.++Lesson: tests that assert the **absence** of an effect after a fixed+`Task.sleep` are inherently racy (you can't poll for "nothing happened"). The+other `TripSyncEventBus` tests use a `waitFor`-style condition poll, which is the+robust pattern for asserting presence.++## Operational cautions (still valid)++- Simulator tests run **serially** (`-parallel-testing-worker-count 1`);+  parallel simulator clones race on launch and produce different flakes. Don't+  change this.+- Do not run two `xcodebuild test` invocations against the same simulator+  concurrently (e.g. parallel worktrees) — they race and can wedge+  CoreSimulator.+- Scope any `pkill` to Scramble (`pkill -f "xcodebuild.*Scramble"`), never a+  blanket `pkill -f xcodebuild`, which kills unrelated projects' runs.+- There are two simulators named "iPhone 17 Pro"; `name=`-based destinations are+  ambiguous but resolve deterministically in practice — prefer a UDID if a run+  ever picks the wrong one.  See `persistence.md` for the SchemaV3 / no-SchemaV4 model rules.

Things to double-check

TripSyncEventBus consumers

Confirm no subscriber depends on receiving an event that arrives immediately after stop(). Production only calls stop() in tests today, so this should be safe.

Makefile now fails on test failure

Intended behaviour change: make test/test-quick/build now return non-zero on failure. Any script/CI step that previously tolerated a red suite because the exit was 0 will now correctly fail.

On-device NotificationsServiceTests

The -skip-testing:NotificationsServiceTests device workaround was very likely the same non-retained-container crash and should now be unnecessary — verify on the next physical-device run before dropping the skip.