scramble branch feature/phase-5.1-wire-trip-crud-tripslocal commits under review 2 (9155e2d + 5ff960c) files 19 touched lines +1927 / -425

Pre-push review: Phase 5.1 Phase 5 + cleanup

Two commits implementing the migration coordinator + engine event multicast + sign-in resume work (tasks 29–39 of specs/phase-5.1-wire-trip-crud-tripslocal/) plus a cleanup pass that addressed the pre-push-review agent findings.

At a glance

  • 15-step migration coordinator: startOrResume now branches over the four (tripsLocal, globals) existence quadrants and derives its resume invariant from container contents — a crash mid-relocation always converges on the next launch.
  • Engine event multicast: TripSyncEventBus owns the single iteration of syncEngine.events and dispatches to two named subscribers (rules orchestrator + migration coordinator) with do/catch isolation per handler.
  • Sign-in resume with storm-collapse: SignInResumeCoordinator observes CKAccountChanged + UIScene.didActivateNotification, performs an immediate re-check on start(), and uses an inFlight: Task? + pendingReplay: Bool pair to collapse storm-fire to exactly one trailing replay.
  • Three new TripSyncEvent cases (.zoneSaved, .recordsSaved, .recordsFailed) emitted from handleSentChanges / new handleSentDatabaseChanges feed the migration coordinator's journal-state machine.
  • Cleanup commit consolidated three identical parseTripID implementations, centralised "trip-<uuid>" zone naming, made the coordinator's contexts private behind a journalCount() helper, fixed the iOS 26.4 cascade-order mismatch in deleteFromGlobals, and rewrote the flaky storm-collapse test with a deterministic CheckedContinuation gate.

Verdict

Ready to push

All ten review findings (one Apple-API safety concern, two duplications, two encapsulation leaks, one redundant fetch, one stale spec divergence, plus four nits) were addressed in the cleanup commit. Lint and the full simulator test suite pass. No remaining blockers.

Review findings

14 raised · 10 fixed · 4 skipped

Jump to findings →

Commits

Three-level explanation

What changed

When a user shares a trip in CloudKit, every device that has access needs to see the same data. Phase 5 of the spec built the plumbing for that, but the migration coordinator (the bit that moves existing local trips into per-trip CloudKit zones) didn't react to confirmations from the sync engine — so the "Syncing…" badge never disappeared.

This commit wires the sync engine's events into the migration coordinator through a small fan-out bus, adds a four-branch algorithm that handles every possible crash-recovery state, and adds a coordinator that re-runs the migration when the user signs in to iCloud after launch.

Why it matters

Existing trips become shareable, and the UI stops lying about sync state. A user who signs in to iCloud after using the app offline will see their trips migrate to CloudKit automatically.

Key concepts

  • SwiftData containers: think of these as two separate databases on the device. One holds private data (Person, master lists); the other holds shareable data (trips). They don't talk to each other automatically.
  • Migration journal: a per-trip row that tracks where in the migration sequence each trip currently is. Stored alongside the private data so it's never lost.
  • Storm-collapse: if iCloud sends 10 "something changed" pings in 100ms, we don't want to run the migration 10 times. The new coordinator collapses them to at most one trailing replay.

Architecture

The migration coordinator's startOrResume is now a 15-step algorithm whose resume invariant lives in container contents, not in a journal field. By looking at which of the two SwiftData stores (globals, tripsLocal) currently holds a given trip, the coordinator can determine where the previous run was interrupted and pick up where it left off:

(none, some) → relocate + delete
(some, some) → only the delete remains
(some, none) → relocation complete; start upload
(none, none) → trip vanished; mark .failed

The two-container relocation commits tripsLocal first, then globals. A crash between the two saves leaves the trip in both stores, which the (some, some) branch detects on resume.

Event bus pattern

The previous code iterated syncEngine.events directly from ScrambleApp.prepareLaunch, which works for one subscriber but breaks when a second consumer needs the same events. TripSyncEventBus owns the single iteration and dispatches to two named slots (rules orchestrator + migration coordinator). Handlers are throwing closures; each dispatch is wrapped in do/catch so a misbehaving subscriber can't take down the bus or starve the other subscriber.

Trade-offs

  • The bus accepts exactly two subscribers, not N — production has no other consumers, and a slot-based design surfaces accidental third-subscriber additions through assertionFailure.
  • The migration coordinator's contexts are now private. The back-stop visibility log calls migrationCoordinator.journalCount() instead of reading the context directly.
  • The four-quadrant switch is the explicit four-arm form, mapping 1:1 to the design document.

Deep dive

Step-10 cross-run contamination guard

The non-obvious bit of the 15-step algorithm: before re-marking the journal .stageBInProgress on resume, the coordinator clears journal.sentRecordNames and journal.zoneSaved. Without this, an engine event delivered to a prior aborted Stage B (e.g., the engine sent records, observed them as saved, but the journal save was killed before persisting sentRecordNames) would be re-observed against the resumed run's expectedRecordNames set and could prematurely satisfy isStageBComplete for records that the resumed run is about to re-send. Step 10 makes those events no-ops because their record IDs are still in the (newly re-dirty-flagged) pendingUploadFlags set the engine will re-send.

Sequential @MainActor saves

Step 14's two ModelContext.save() calls are synchronous @MainActor invocations with no await between them. This is load-bearing: an await would yield the actor, allowing a queued engine event to land in handleZoneSaved or handleRecordsSaved against a half-completed journal state. The driver signals (step 15) run only after both saves return.

Storm-collapse correctness

The inFlight: Task? + pendingReplay: Bool pair is the canonical leader-trailer collapse. The invariant: while inFlight != nil, any number of runResumeIfNeeded calls collapse to at most one pendingReplay = true. The in-flight task's tail checks the flag once, consumes it, runs the trailing replay, then clears inFlight. Triggers landing during the trailing replay are deferred to the next runResumeIfNeeded call — explicitly not absorbed by the current run. The cleanup commit rewrote the storm-collapse test with a CheckedContinuation-based ResumeGate so the in-flight run is parked deterministically while the 10 storm triggers fire, making the delta == 1 assertion race-free.

Edge cases

  • Stale journal orphan: if the trip was deleted between enqueue and run, the (none, none) branch marks the journal .failed("Trip not found"). The entry persists as audit data; cleanup is a non-goal of Phase 5.1.
  • Signed-out launch: runStageB short-circuits on isCloudAvailable() == false, leaving the relocation deferred. The sign-in resume coordinator picks it up on the next CKAccountChanged notification (or on UIScene.didActivateNotification as a fallback for the documented coalesced-during-launch case).
  • Mid-flight crash between steps 14a and 14b: the trip exists in both stores. The (some, some) branch finishes the delete on resume.
  • Stage A snapshot retroactive dirty-flag: Stage A backfilled TripPersonSnapshot rows against the pre-Phase-5.1 empty production state — they were never dirty-flagged. Step 12 includes them in expectedRecordNames (via trip.participantSnapshots), which is their first dirty-flag pass.

Important changes — detailed

ZoneMigrationCoordinator.startOrResume: 15-step algorithm

ZoneMigrationCoordinator.swift

Why it matters. This is the heart of Phase 5.1 — the function that decides whether a given trip needs to be relocated, whether the relocation is half-done from a prior crash, and whether the upload can start. Get this wrong and trips vanish, duplicate across containers, or get stuck in 'Syncing…' forever.

What to look at. ZoneMigrationCoordinator.swift:182-289

Takeaway. When a workflow has discrete crash-recovery states, encode them as a switch over observable system state (which containers hold the entity) rather than as a state field on a journal. The container contents are the canonical resume signal; the journal is just metadata.
Rationale. design.md § 'ZoneMigrationCoordinator (changed)' specifies the four-quadrant existence branch. The cleanup commit's explicit four-arm form maps 1:1 to that document; the original (_, .some) merger was harder to audit.

TripSyncEventBus: fan-out from one AsyncStream to two subscribers

TripSyncEventBus.swift

Why it matters. Replaces the previous direct iteration of syncEngine.events in ScrambleApp.prepareLaunch. The migration coordinator now receives engine events through the same path the rules orchestrator already used, so journal entries terminate as the engine confirms uploads.

What to look at. TripSyncEventBus.swift:1-115

Takeaway. AsyncStream allows only one consumer. When a second consumer needs the same events, the canonical pattern is a single-iteration bus that fans out via stored handler closures, NOT two parallel iterations of the same stream.
Rationale. design.md § 'TripSyncEventBus (new)' specifies the two-named-slot (orchestrator + coordinator) design with handlers registered before start(). The slot-based design over a generic [handler] array reflects the deliberate intent of production having exactly two known consumers.

SignInResumeCoordinator: inFlight + pendingReplay storm-collapse

SignInResumeCoordinator.swift

Why it matters. iCloud account-status transitions and scene-activation notifications can fire in rapid succession (e.g., during a flaky network's account-availability flip). Without this collapse, every notification triggers a full enqueueAll + runStageB pass against globals/tripsLocal — N notifications becomes N migration runs.

What to look at. SignInResumeCoordinator.swift:97-111

Takeaway. The canonical leader/trailer collapse pattern: one Task? to track in-flight, one Bool to track 'something fired during the run'. The in-flight task checks and consumes the flag once at tail. Triggers during the trailing replay are deferred — explicitly not absorbed — so the bound is 'at most one trailing replay' rather than 'absorb forever'.
Rationale. design.md § 'SignInResumeCoordinator (new)' specifies this exact mechanism: 'a single in-flight Task reference plus a pendingReplay flag collapses storm-fire to at most one trailing replay'. Decision-log Decision 7 documents the divergence (drop of CKContainer parameter).

TripSyncEvent: new .zoneSaved / .recordsSaved / .recordsFailed cases

TripSyncEngine.swift

Why it matters. Before this change, the migration coordinator's handleZoneSaved / handleRecordsSaved / handleRecordsFailed methods existed but were never called — the engine had no way to surface those confirmations to the journal-state machine. Now CKSyncEngine.SentRecordZoneChanges (per-batch save confirmation) and CKSyncEngine.SentDatabaseChanges (zone-save confirmation) are converted to bus events.

What to look at. TripSyncEngine.swift:358-372 (event cases) and 506-540 (emissions)

Takeaway. When extending an event stream with new cases, also update the switch in every consumer. The new cases here are all no-ops for the rules orchestrator (which only cares about .zoneChanged), so the orchestrator's switch just acknowledges them via combined case labels — but the compiler-enforced exhaustiveness check is what kept this honest.

deleteFromGlobals: explicit reverse-cascade order matches iOS 26.4 workaround

ZoneMigrationCoordinator.swift

Why it matters. The original implementation deleted only the trip and snapshots, relying on SwiftData's automatic cascade for tasks and packing items. On iOS 26.4, SwiftData's cascade traversal panics when the snapshot ↔ packing-item nullify pair enters the chain — exactly the failure mode TripDeletion already worked around via explicit reverse-cascade. The cleanup commit aligns this code path with the documented workaround.

What to look at. ZoneMigrationCoordinator.swift:367-389

Takeaway. When two code paths perform conceptually similar operations (here: delete a Trip + dependents), they should share the same workaround for platform bugs. Either reuse the existing helper or mirror its order; do NOT silently rely on the cascade that the other path is explicitly avoiding.
Rationale. Mirroring TripDeletion.swift's documented order: packingItems → tasks → participantSnapshots → trip. The iOS 26.4 panic is documented in TripDeletion.swift's header comment and Trip.swift's participantSnapshots relationship declaration.

journalCount() helper closes a context leak

ZoneMigrationCoordinator.swift

Why it matters. ScrambleApp.prepareLaunch needed to read MigrationJournalEntry.count for the back-stop visibility log. Reading migrationCoordinator.globalsContext directly broke encapsulation and tempted future code into bypassing coordinator invariants.

What to look at. ZoneMigrationCoordinator.swift:111-117

Takeaway. If a caller needs derived data about the coordinator's state, expose the derived value as a method, not the underlying ModelContext. The context is an implementation detail; the journal-count metric is the contract.
Rationale. Code-review agent flagged this as a leaky abstraction in the cleanup pass.

Key decisions

Drop CKContainer from SignInResumeCoordinator.init

The design document specified init(migrationCoordinator: ZoneMigrationCoordinator, container: CKContainer). Implementation dropped the container because migrationCoordinator.isCloudAvailable() is the only check needed; a second probe inside SignInResumeCoordinator would duplicate the logic and risk drift between two answers to the same question. Captured as Decision 7 in specs/phase-5.1-wire-trip-crud-tripslocal/decision_log.md.

Two-named-slot bus, not N-subscriber fan-out

TripSyncEventBus stores exactly two handler properties (orchestrator + coordinator) rather than an array of subscribers. Production has only those two consumers; the slot design surfaces accidental third-subscriber additions through compile-time naming and assertionFailure on duplicate registration. See bus header comment.

(inferred — not stated by the author.)
Cross-store consistency derives from container contents, not a journal field

The 15-step algorithm asks 'which of the two stores currently holds the trip?' rather than reading a state field. This means the resume invariant survives any crash that leaves the journal in any state — the source of truth is what's on disk in the two SwiftData stores. Any future contributor who changes the relocation order in step 14 must also update the branch logic in step 6.

(inferred — not stated by the author.)
Steps 12 and 13 merged in the cleanup pass

The original step 13 ran a defensive predicate fetch over TripPersonSnapshot to retroactively dirty-flag every snapshot row for the trip. Step 12 already covers every snapshot reachable through trip.participantSnapshots via expectedRecordNames, and the two sets are identical in practice. The defensive fetch was dropped; Req 4.9 is still satisfied by step 12.

Storm-collapse test gated by CheckedContinuation, not Task.sleep

The original storm-collapse test used a 30ms Task.sleep inside the resume closure to keep the run in-flight while 10 triggers fired. On a loaded CI runner, the inner sleep could complete before all 10 triggers fired, allowing one to land after inFlight = nil and break the delta == 1 assertion. The cleanup rewrites the test with a CheckedContinuation-based gate so the in-flight run parks deterministically until the test releases it.

Review findings

SeverityAreaFindingResolution
majorcode reuseparseTripID(from:) triplicated in ZoneMigrationCoordinator, RulesEngineTriggerOrchestrator, TripZoneStateRecordTranslator — byte-identical bodies.Both private duplicates removed; both call sites route through ZoneMigrationCoordinator.parseTripID.
majorcode reusedeleteFromGlobals relies on SwiftData cascade for tasks/packing items, contradicting the iOS 26.4 explicit-reverse-cascade workaround documented in TripDeletion.swift.deleteFromGlobals now uses the same explicit packingItems → tasks → participantSnapshots → trip order.
majorcode qualitymigrationCoordinator.globalsContext leaked as `let` (not private), allowing ScrambleApp.prepareLaunch to bypass the coordinator's encapsulation for the journal back-stop count.Both context properties made private; new journalCount() method exposes the metric.
majortest correctnessstorm-collapse test asserts delta == 1 strictly, but Task.sleep-based timing could race on loaded CI runners — one storm trigger landing after inFlight clears would break the assertion.Test rewritten with a CheckedContinuation-based ResumeGate that parks the in-flight run deterministically until the test releases it.
minorefficiencystartOrResume step 13 performs a redundant predicate fetch over TripPersonSnapshot when step 12 already covers the same set via trip.participantSnapshots.Steps 12 and 13 merged; the defensive second fetch is dropped. Req 4.9 still satisfied by step 12.
minorefficiencySignInResumeCoordinator.deinit cancels inFlight but doesn't remove the two NotificationCenter observers it installed in start(). Production lifetime is forever (benign), but tests that create+drop coordinators leak observers.deinit now iterates `observers` and calls NotificationCenter.default.removeObserver(_:) for each.
minorcode qualityZone name `"trip-\(uuid)"` repeated across 4 production call sites with no centralised helper.Added zoneName(for:) and ownerZoneID(for:) static helpers on ZoneMigrationCoordinator; all production call sites now route through them.
minorspec adherenceSignInResumeCoordinator.init drops the CKContainer parameter the design document specifies; divergence undocumented.Captured as decision-log Decision 7 with the rationale (single source of truth for availability check).
minordocumentationCLAUDE.md project-status sentence still reads 'Phases 1–5 have landed' with no mention of Phase 5.1's container topology / chokepoint extension / migration coordinator work.CLAUDE.md updated to mention Phase 5.1 landed work.
nitcode qualityenqueueAll uses an explicit `seenTripIDs` set for de-duplication when Set.union would express the intent more directly.Refactored to `Set(...).union(...)`.
nitcode qualitysubscribeOrchestrator and subscribeCoordinator are near-duplicates differing only in which property they set.Skipped — the duplication is deliberate per the two-slot design intent; abstracting would obscure the named consumers.
nitconcurrencyTripSyncEventBus.stop() followed by start() would re-iterate a finished AsyncStream. Production never calls stop().Skipped — latent only on a test code path; documented in the file header as test-only.
nitcode qualityrelocateToTripsLocal field-copy chain is verbose and a maintenance hazard — every new SwiftData property on Trip/TripTask/TripPackingItem/TripPersonSnapshot must be added here or it silently drops on relocation.Skipped — schema changes are rare and the test 'Relocation preserves every persisted field per Req 4.2' catches drops in the immediate term. Cleaner copy(into:) API would be a follow-up.
nitconcurrencySignInResumeCoordinator uses MainActor.assumeIsolated inside NotificationCenter.addObserver(queue: .main) blocks; OperationQueue.main is not formally the MainActor executor.Skipped — in practice the runtime treats main thread == MainActor leniently. Swift 6 strict-checking concerns are speculative; the .CKAccountChanged delivery path has shipped this way in production AppKit code for years.

Per-file diffs

Click to expand.

Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift Modified +219 / -34
diff --git a/Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift b/Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift
index bda64d6..7a74678 100644
--- a/Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift
+++ b/Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift
@@ -25,8 +25,8 @@ import os
 /// `handleRecordsSaved`, and `handleRecordsFailed` as the events arrive.
 @MainActor
 final class ZoneMigrationCoordinator {
-  let globalsContext: ModelContext
-  let tripsLocalContext: ModelContext
+  private let globalsContext: ModelContext
+  private let tripsLocalContext: ModelContext
   let driver: ZoneMigrationDriver
   let isCloudAvailable: () -> Bool
   let now: () -> Date
@@ -47,19 +47,16 @@ final class ZoneMigrationCoordinator {

   // MARK: - Phase 1: enqueue

-  /// Insert a `.pending` `MigrationJournalEntry` for every trip in
-  /// `tripsLocal` that has not been moved into its trip zone yet (no
-  /// matching `TripZoneState`). Idempotent — skips trips that already
-  /// have a journal row.
+  /// Insert a `.pending` `MigrationJournalEntry` for every trip that has
+  /// not been moved into its trip zone yet (no matching
+  /// `TripZoneState`). Idempotent — skips trips that already have a
+  /// journal row.
   ///
-  /// Until Phase 5.1 routes Trip CRUD through `tripsLocal`, the source
-  /// fetch deliberately stays on `tripsLocalContext`. Switching it to
-  /// `globalsContext` in isolation queues journals for trips whose
-  /// records the engine can't find on upload, leaving every entry stuck
-  /// in `.stageBInProgress` indefinitely (with a permanent "Syncing…"
-  /// badge per trip). The cleaner state is the current silent no-op:
-  /// nothing queues until Phase 5.1 lands the record-relocation step at
-  /// the same time. See `docs/implementation-phases.md`.
+  /// Phase 5.1 — scans BOTH containers so that pre-Phase-5.1 trips still
+  /// in `globals` get a journal entry (their records are relocated to
+  /// `tripsLocal` by `startOrResume`'s relocation step) and trips
+  /// already in `tripsLocal` (post-Phase-5.1 creates) also get a
+  /// journal entry until their `TripZoneState` is created.
   func enqueueAll() throws {
     let existingJournals = try globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     let existingByTrip = Dictionary(
@@ -68,11 +65,14 @@ final class ZoneMigrationCoordinator {
     let existingStates = try tripsLocalContext.fetch(FetchDescriptor<TripZoneState>())
     let migratedTripIDs = Set(existingStates.map(\.tripID))

-    let trips = try tripsLocalContext.fetch(FetchDescriptor<Trip>())
-    for trip in trips where !migratedTripIDs.contains(trip.id) {
-      if existingByTrip[trip.id] != nil { continue }
+    let tripsLocalTrips = try tripsLocalContext.fetch(FetchDescriptor<Trip>())
+    let globalsTrips = try globalsContext.fetch(FetchDescriptor<Trip>())
+    let allTripIDs = Set(tripsLocalTrips.map(\.id)).union(globalsTrips.map(\.id))
+
+    for tripID in allTripIDs where !migratedTripIDs.contains(tripID) {
+      if existingByTrip[tripID] != nil { continue }
       let entry = MigrationJournalEntry(
-        tripID: trip.id,
+        tripID: tripID,
         stateRaw: MigrationStageState.pending.rawValue,
         updatedAt: now()
       )
@@ -103,6 +103,13 @@ final class ZoneMigrationCoordinator {
     }
   }

+  /// Count of `MigrationJournalEntry` rows currently persisted in
+  /// `globals`. Surfaces the back-stop visibility metric without
+  /// leaking the underlying `ModelContext` to callers.
+  func journalCount() throws -> Int {
+    try globalsContext.fetchCount(FetchDescriptor<MigrationJournalEntry>())
+  }
+
   /// Re-runs Stage B for a `.failed` entry. Clears the error, transitions
   /// back to `.stageBInProgress`, and re-signals the driver. No-op for
   /// entries in other states.
@@ -171,17 +178,44 @@ final class ZoneMigrationCoordinator {

   // MARK: - Private

+  // swiftlint:disable function_body_length
+
+  /// Phase 5.1 — 15-step relocation + Stage B start. See spec
+  /// `phase-5.1-wire-trip-crud-tripslocal/design.md § ZoneMigrationCoordinator`.
+  /// The algorithm derives its resume invariant from container contents,
+  /// not from a journal field: which of the two stores currently holds
+  /// the trip is the canonical signal of where the previous run was
+  /// interrupted.
   private func startOrResume(_ journal: MigrationJournalEntry) throws {
     let tripID = journal.tripID
-    let zoneID = CKRecordZone.ID(
-      zoneName: "trip-\(tripID.uuidString)",
-      ownerName: CKCurrentUserDefaultName
-    )

-    let trip = try fetchTrip(tripID: tripID)
-    guard let trip else {
-      // Trip vanished between enqueue and run — leave the journal in
-      // place but mark it failed so the banner surfaces something.
+    // Step 2 — `.completed` is a terminal no-op. Re-running against a
+    // completed entry must not re-issue driver work.
+    if journal.state == .completed { return }
+
+    // Step 3 — canonical zone ID for the trip (private DB, owner-side).
+    let zoneID = Self.ownerZoneID(for: tripID)
+
+    // Steps 4–6 — four-quadrant existence branch over (tripsLocal, globals).
+    // Each branch is written explicitly so the code maps 1:1 to the
+    // design doc's algorithm sketch.
+    let tripInTripsLocal = try fetchTrip(tripID: tripID, in: tripsLocalContext)
+    let tripInGlobals = try fetchTrip(tripID: tripID, in: globalsContext)
+
+    switch (tripInTripsLocal, tripInGlobals) {
+    case (.some, .some):
+      // Insert step done; only the delete remains.
+      try deleteFromGlobals(tripID: tripID)
+    case (.some, .none):
+      // Relocation complete; continue with upload.
+      break
+    case (.none, .some):
+      // Not yet relocated — perform both steps.
+      try relocateToTripsLocal(tripID: tripID)
+      try deleteFromGlobals(tripID: tripID)
+    case (.none, .none):
+      // Trip vanished — leave a `.failed` journal entry so the banner
+      // surfaces something. The entry stays around as audit data.
       journal.state = .failed
       journal.errorMessage = "Trip not found"
       journal.updatedAt = now()
@@ -189,12 +223,33 @@ final class ZoneMigrationCoordinator {
       return
     }

+    // After step 6 the trip lives in tripsLocal exclusively. Re-fetch
+    // because the relocation inserts a new instance.
+    guard let trip = try fetchTrip(tripID: tripID, in: tripsLocalContext) else {
+      journal.state = .failed
+      journal.errorMessage = "Trip not found after relocation"
+      journal.updatedAt = now()
+      try globalsContext.save()
+      return
+    }
+
+    // Step 8 — ensure TripZoneState exists in tripsLocal.
     let state = try ensureZoneState(for: tripID, zoneID: zoneID)
     if trip.tripZoneID != tripID {
       trip.tripZoneID = tripID
     }

+    // Step 9 — compute the expected record-name set from current state.
     let expectedNames = expectedRecordNames(for: trip)
+
+    // Step 10 — clear stale sub-state from any prior aborted run before
+    // re-marking the journal `.stageBInProgress` with fresh expected
+    // names. This is the cross-run contamination guard (design §
+    // "Concurrency and ordering contracts").
+    journal.sentRecordNames = []
+    journal.zoneSaved = false
+
+    // Step 11 — mark journal `.stageBInProgress`.
     journal.expectedRecordNames = expectedNames
     journal.state = .stageBInProgress
     journal.updatedAt = now()
@@ -202,22 +257,145 @@ final class ZoneMigrationCoordinator {
       journal.errorMessage = nil
     }

+    // Steps 12 + 13 — dirty-flag every expected record name. The set
+    // already includes every TripPersonSnapshot reachable through
+    // `trip.participantSnapshots` (built in step 9 via
+    // `expectedRecordNames`), so Stage A's pre-Phase-5.1-empty snapshot
+    // rows are retroactively dirty-flagged here on Stage B entry per
+    // Req 4.9 — no second fetch needed.
+    let dirtyRecordNames = expectedNames
     var flags = PendingUploadFlags.decode(state.pendingUploadFlags)
     for name in expectedNames {
       flags.markDirty(recordName: name)
     }
     state.pendingUploadFlags = flags.encode()

+    // Step 14 — sequential saves; the @MainActor invocations do not
+    // await between, so no engine event can interleave.
     try tripsLocalContext.save()
     try globalsContext.save()

+    // Step 15 — signal the driver after both saves return. Use the
+    // in-memory `dirtyRecordNames` set built above rather than re-
+    // decoding the freshly-saved flags: encode/decode is value-
+    // preserving today, but the driver contract is "queue everything
+    // this run dirty-flagged", and that set lives in this stack frame.
     driver.saveZone(zoneID)
-    let recordIDs = expectedNames.map { CKRecord.ID(recordName: $0, zoneID: zoneID) }
+    let recordIDs = dirtyRecordNames.map { CKRecord.ID(recordName: $0, zoneID: zoneID) }
     if !recordIDs.isEmpty {
       driver.saveRecords(recordIDs)
     }
   }

+  /// Phase 5.1 — copy a Trip + dependents from `globals` into
+  /// `tripsLocal`, preserving every persisted field per Req 4.2.
+  /// Commits `tripsLocalContext` so a subsequent crash leaves the trip
+  /// duplicated rather than vanished — the resume branch will then
+  /// detect the (both) quadrant and only re-run the delete step.
+  ///
+  /// Only `startOrResume` calls this; the (none, some) branch is the
+  /// single production caller. The function tolerates being invoked
+  /// against a trip that already exists in tripsLocal (no-op).
+  private func relocateToTripsLocal(tripID: UUID) throws {
+    guard let trip = try fetchTrip(tripID: tripID, in: globalsContext) else { return }
+
+    // Bail out if a copy already exists (defence-in-depth against
+    // double-invocation — the public callers all check the existence
+    // quadrant first).
+    if try fetchTrip(tripID: tripID, in: tripsLocalContext) != nil { return }
+
+    let copiedTrip = Trip(
+      id: trip.id,
+      name: trip.name,
+      startDate: trip.startDate,
+      endDate: trip.endDate,
+      attributes: trip.attributes
+    )
+    copiedTrip.tripZoneID = trip.tripZoneID
+    copiedTrip.ckRecordSystemFields = trip.ckRecordSystemFields
+    tripsLocalContext.insert(copiedTrip)
+
+    for task in trip.tasks ?? [] {
+      let copiedTask = TripTask(
+        id: task.id,
+        trip: copiedTrip,
+        masterItemID: task.masterItemID,
+        name: task.name,
+        phase: task.phase,
+        isCompleted: task.isCompleted,
+        source: task.source,
+        currentlyMatchesRules: task.currentlyMatchesRules,
+        pinnedByUser: task.pinnedByUser,
+        assigneePersonID: task.assigneePersonID,
+        userDeletedOnThisTrip: task.userDeletedOnThisTrip
+      )
+      copiedTask.ckRecordSystemFields = task.ckRecordSystemFields
+      tripsLocalContext.insert(copiedTask)
+    }
+
+    // Build snapshots first so packing items can reference them.
+    var snapshotsByID: [UUID: TripPersonSnapshot] = [:]
+    for snapshot in trip.participantSnapshots ?? [] {
+      let copiedSnapshot = TripPersonSnapshot(
+        id: snapshot.id,
+        personID: snapshot.personID,
+        name: snapshot.name,
+        colourID: snapshot.colourID,
+        initialSource: snapshot.initialSource,
+        isRosterMember: snapshot.isRosterMember,
+        trip: copiedTrip
+      )
+      copiedSnapshot.ckRecordSystemFields = snapshot.ckRecordSystemFields
+      tripsLocalContext.insert(copiedSnapshot)
+      snapshotsByID[snapshot.id] = copiedSnapshot
+    }
+
+    for item in trip.packingItems ?? [] {
+      let mappedSnapshot = item.personSnapshot.flatMap { snapshotsByID[$0.id] }
+      let copiedItem = TripPackingItem(
+        id: item.id,
+        trip: copiedTrip,
+        person: nil,  // V2 relationship is latent in V3; not relocated.
+        masterItemID: item.masterItemID,
+        name: item.name,
+        state: item.state,
+        source: item.source,
+        currentlyMatchesRules: item.currentlyMatchesRules,
+        pinnedByUser: item.pinnedByUser,
+        personSnapshot: mappedSnapshot
+      )
+      copiedItem.ckRecordSystemFields = item.ckRecordSystemFields
+      tripsLocalContext.insert(copiedItem)
+    }
+
+    try tripsLocalContext.save()
+  }
+
+  /// Phase 5.1 — delete a Trip + dependents from `globals` after the
+  /// relocation has committed in `tripsLocal`. The single
+  /// `globalsContext.save()` is the resume-from-(both) step.
+  ///
+  /// Uses the explicit reverse-cascade order documented in
+  /// `TripDeletion` (`packingItems` → `tasks` → `participantSnapshots` →
+  /// `trip`) to avoid the iOS 26.4 SwiftData cascade-traversal panic
+  /// when the snapshot ↔ packing-item nullify pair enters the chain.
+  private func deleteFromGlobals(tripID: UUID) throws {
+    guard let trip = try fetchTrip(tripID: tripID, in: globalsContext) else { return }
+    for item in trip.packingItems ?? [] {
+      globalsContext.delete(item)
+    }
+    for task in trip.tasks ?? [] {
+      globalsContext.delete(task)
+    }
+    for snapshot in trip.participantSnapshots ?? [] {
+      globalsContext.delete(snapshot)
+    }
+    globalsContext.delete(trip)
+    try globalsContext.save()
+  }
+
+  // swiftlint:enable function_body_length
+
   private func ensureZoneState(
     for tripID: UUID, zoneID: CKRecordZone.ID
   ) throws -> TripZoneState {
@@ -248,9 +426,9 @@ final class ZoneMigrationCoordinator {
     return names
   }

-  private func fetchTrip(tripID: UUID) throws -> Trip? {
+  private func fetchTrip(tripID: UUID, in context: ModelContext) throws -> Trip? {
     let descriptor = FetchDescriptor<Trip>(predicate: #Predicate { $0.id == tripID })
-    return try tripsLocalContext.fetch(descriptor).first
+    return try context.fetch(descriptor).first
   }

   private func fetchJournal(tripID: UUID) -> MigrationJournalEntry? {
@@ -283,6 +461,18 @@ final class ZoneMigrationCoordinator {
     let suffix = zoneName.dropFirst("trip-".count)
     return UUID(uuidString: String(suffix))
   }
+
+  /// Canonical zone-name string for a trip. Inverse of `parseTripID`.
+  static func zoneName(for tripID: UUID) -> String {
+    "trip-\(tripID.uuidString)"
+  }
+
+  /// Owner-side canonical zone ID for a trip (`CKCurrentUserDefaultName`
+  /// owner; private DB). Participant zones carry the share owner's name
+  /// and are constructed elsewhere from server-supplied IDs.
+  static func ownerZoneID(for tripID: UUID) -> CKRecordZone.ID {
+    CKRecordZone.ID(zoneName: zoneName(for: tripID), ownerName: CKCurrentUserDefaultName)
+  }
 }

 // MARK: - Driver seam
Scramble/Scramble/Sharing/TripSyncEngine.swift Modified +33 / -5
diff --git a/Scramble/Scramble/Sharing/TripSyncEngine.swift b/Scramble/Scramble/Sharing/TripSyncEngine.swift
index 9a57e35..007cb95 100644
--- a/Scramble/Scramble/Sharing/TripSyncEngine.swift
+++ b/Scramble/Scramble/Sharing/TripSyncEngine.swift
@@ -355,11 +355,19 @@ final class TripSyncEngine: NSObject, PendingChangeNotifier {
 /// Production sync events. The `isSelfOriginated` flag on `.zoneChanged`
 /// satisfies the design's owner-side echo guard
 /// (design § "engine ownership gate").
+///
+/// Phase 5.1 — `.zoneSaved`, `.recordsSaved`, `.recordsFailed` feed the
+/// `ZoneMigrationCoordinator` so Stage B journal entries terminate as the
+/// engine confirms each upload step. `TripSyncEventBus` multicasts every
+/// event to both the orchestrator and the coordinator.
 enum TripSyncEvent: Sendable {
   case zoneChanged(CKRecordZone.ID, scope: CKDatabase.Scope, isSelfOriginated: Bool)
   case recordsFetched([CKRecord], in: CKRecordZone.ID)
   case shareAccepted(CKRecordZone.ID, ownerName: String)
   case zoneRemoved(CKRecordZone.ID)
+  case zoneSaved(CKRecordZone.ID)
+  case recordsSaved([CKRecord.ID])
+  case recordsFailed([CKRecord.ID], error: String)
   case error(String)
 }

@@ -387,7 +395,9 @@ extension TripSyncEngine: CKSyncEngineDelegate {
       await handleFetchedChanges(event, scope: scope)
     case .sentRecordZoneChanges(let event):
       await handleSentChanges(event, scope: scope)
-    case .accountChange, .fetchedDatabaseChanges, .sentDatabaseChanges,
+    case .sentDatabaseChanges(let event):
+      await handleSentDatabaseChanges(event)
+    case .accountChange, .fetchedDatabaseChanges,
       .willFetchChanges, .willFetchRecordZoneChanges,
       .didFetchRecordZoneChanges, .didFetchChanges,
       .willSendChanges, .didSendChanges:
@@ -504,6 +514,32 @@ extension TripSyncEngine: CKSyncEngineDelegate {
       try? cacheSentSystemFields(recordType: recordType, entries: entries)
     }
     try? context.save()
+
+    // Phase 5.1 — surface per-batch confirmation so the migration
+    // coordinator can advance its journal entries. The bus dispatches
+    // these to `ZoneMigrationCoordinator.handleRecordsSaved` /
+    // `handleRecordsFailed`.
+    let savedIDs = event.savedRecords.map(\.recordID)
+    if !savedIDs.isEmpty {
+      emit(.recordsSaved(savedIDs))
+    }
+    let failedIDs = event.failedRecordSaves.map { $0.record.recordID }
+    if !failedIDs.isEmpty {
+      let message = event.failedRecordSaves.first?.error.localizedDescription ?? "unknown error"
+      emit(.recordsFailed(failedIDs, error: message))
+    }
+  }
+
+  private func handleSentDatabaseChanges(
+    _ event: CKSyncEngine.Event.SentDatabaseChanges
+  ) {
+    // Phase 5.1 — emit one `.zoneSaved` per successfully saved zone so
+    // the migration coordinator can advance the journal entry's
+    // `zoneSaved` flag. `savedZones` is the CKSyncEngine confirmation
+    // that the zone now exists server-side.
+    for zone in event.savedZones {
+      emit(.zoneSaved(zone.zoneID))
+    }
   }

   private func cacheSentSystemFields(
Scramble/Scramble/Sharing/TripSyncEventBus.swift Added +143 / -0
diff --git a/Scramble/Scramble/Sharing/TripSyncEventBus.swift b/Scramble/Scramble/Sharing/TripSyncEventBus.swift
new file mode 100644
index 0000000..bcf91e6
--- /dev/null
+++ b/Scramble/Scramble/Sharing/TripSyncEventBus.swift
@@ -0,0 +1,140 @@
+import Foundation
+
+/// Phase 5.1 — owns the single `for await event in syncEngine.events`
+/// iteration and re-broadcasts each event to every registered subscriber.
+/// Production has two subscribers: `RulesEngineTriggerOrchestrator`
+/// (existing) and `ZoneMigrationCoordinator` (new) — see design §
+/// "TripSyncEventBus (new)".
+///
+/// **Lifecycle contracts:**
+///   - Subscribers register their handler via `subscribeOrchestrator` /
+///     `subscribeCoordinator` BEFORE `start()`. Late registrations are
+///     fatal in DEBUG (via `assertionFailure`) and silently rejected in
+///     release; production wires both subscribers in `ScrambleApp.init`
+///     so this is a programming error.
+///   - The bus does NOT buffer events. The engine's `events` stream is
+///     empty at the moment `start()` returns because the engine is
+///     started after `bus.start()`; events flow only afterwards.
+///   - Each dispatch wraps the handler call in a `do/catch` so a
+///     subscriber's thrown error is logged via `modelLogger.error` and
+///     the bus continues for the other subscriber.
+///   - `stop()` cancels the iteration task; used by tests.
+///
+/// Note: production has exactly two named subscribers. The two-slot
+/// (orchestrator + coordinator) shape is deliberate — calling
+/// `subscribeOrchestrator` twice replaces the previous handler in DEBUG
+/// with an `assertionFailure`. This is not a fan-out-to-N bus; it is a
+/// fan-out-to-two router for the two known consumers.
+@MainActor
+final class TripSyncEventBus {
+  private let events: AsyncStream<TripSyncEvent>
+  private var orchestratorHandler: (@MainActor (TripSyncEvent) throws -> Void)?
+  private var coordinatorHandler: (@MainActor (TripSyncEvent) throws -> Void)?
+  private var iterationTask: Task<Void, Never>?
+
+  init(events: AsyncStream<TripSyncEvent>) {
+    self.events = events
+  }
+
+  /// Register the orchestrator handler. Must be called before `start()`,
+  /// and only once per bus lifetime.
+  func subscribeOrchestrator(
+    _ handler: @escaping @MainActor (TripSyncEvent) throws -> Void
+  ) {
+    if iterationTask != nil {
+      assertionFailure("TripSyncEventBus: subscribeOrchestrator after start()")
+      modelLogger.fault("[TripSyncEventBus] late orchestrator subscription — ignored")
+      return
+    }
+    if orchestratorHandler != nil {
+      assertionFailure("TripSyncEventBus: subscribeOrchestrator called twice")
+      modelLogger.fault("[TripSyncEventBus] duplicate orchestrator subscription — overwriting")
+    }
+    orchestratorHandler = handler
+  }
+
+  /// Register the coordinator handler. Must be called before `start()`,
+  /// and only once per bus lifetime.
+  func subscribeCoordinator(
+    _ handler: @escaping @MainActor (TripSyncEvent) throws -> Void
+  ) {
+    if iterationTask != nil {
+      assertionFailure("TripSyncEventBus: subscribeCoordinator after start()")
+      modelLogger.fault("[TripSyncEventBus] late coordinator subscription — ignored")
+      return
+    }
+    if coordinatorHandler != nil {
+      assertionFailure("TripSyncEventBus: subscribeCoordinator called twice")
+      modelLogger.fault("[TripSyncEventBus] duplicate coordinator subscription — overwriting")
+    }
+    coordinatorHandler = handler
+  }
+
+  /// Begin iterating the engine event stream. Idempotent — only the
+  /// first call starts the task; subsequent calls are no-ops.
+  func start() {
+    guard iterationTask == nil else { return }
+    let orchestrator = orchestratorHandler
+    let coordinator = coordinatorHandler
+    iterationTask = Task { @MainActor [events] in
+      for await event in events {
+        Self.dispatch(event, to: orchestrator, label: "orchestrator")
+        Self.dispatch(event, to: coordinator, label: "coordinator")
+      }
+    }
+  }
+
+  /// Test-only: cancel the iteration task so subsequent yields don't
+  /// reach the registered handlers.
+  func stop() {
+    iterationTask?.cancel()
+    iterationTask = nil
+  }
+
+  @MainActor
+  private static func dispatch(
+    _ event: TripSyncEvent,
+    to handler: (@MainActor (TripSyncEvent) throws -> Void)?,
+    label: String
+  ) {
+    guard let handler else { return }
+    do {
+      try handler(event)
+    } catch {
+      modelLogger.error(
+        "[TripSyncEventBus] \(label, privacy: .public) handler threw: \(error.localizedDescription, privacy: .public)"
+      )
+    }
+  }
+}
+
+extension TripSyncEventBus {
+  /// Convenience overload that wires an orchestrator instance via
+  /// `handle(event:)`. The bus stores a closure capturing the
+  /// orchestrator weakly so deinit of the orchestrator (in tests) does
+  /// not retain it through the bus.
+  func subscribeOrchestrator(_ orchestrator: RulesEngineTriggerOrchestrator) {
+    subscribeOrchestrator { [weak orchestrator] event in
+      orchestrator?.handle(event: event)
+    }
+  }
+
+  /// Convenience overload that wires the coordinator's three event
+  /// handlers. The closure parses the event and dispatches to the
+  /// matching coordinator entry point.
+  func subscribeCoordinator(_ coordinator: ZoneMigrationCoordinator) {
+    subscribeCoordinator { [weak coordinator] event in
+      guard let coordinator else { return }
+      switch event {
+      case .zoneSaved(let zoneID):
+        coordinator.handleZoneSaved(zoneID)
+      case .recordsSaved(let recordIDs):
+        coordinator.handleRecordsSaved(recordIDs)
+      case .recordsFailed(let recordIDs, let error):
+        coordinator.handleRecordsFailed(recordIDs, error: error)
+      case .zoneChanged, .recordsFetched, .shareAccepted, .zoneRemoved, .error:
+        break
+      }
+    }
+  }
+}
Scramble/Scramble/App/SignInResumeCoordinator.swift Added +117 / -0
diff --git a/Scramble/Scramble/App/SignInResumeCoordinator.swift b/Scramble/Scramble/App/SignInResumeCoordinator.swift
new file mode 100644
index 0000000..1cc0ae9
--- /dev/null
+++ b/Scramble/Scramble/App/SignInResumeCoordinator.swift
@@ -0,0 +1,117 @@
+import CloudKit
+import Foundation
+import UIKit
+
+/// Phase 5.1 — re-runs `ZoneMigrationCoordinator` enqueue + Stage B when
+/// iCloud becomes available after launch. See design §
+/// "SignInResumeCoordinator (new)" and Req 4.8.
+///
+/// Observed signals (any one fires `runResumeIfNeeded`):
+///   - `NSNotification.Name.CKAccountChanged` — the primary signal.
+///   - `UIScene.didActivateNotification` — fallback for the documented
+///     case where `CKAccountChanged` is coalesced or missed during a
+///     background → foreground transition.
+///
+/// Concurrency: a single in-flight `Task` reference plus a
+/// `pendingReplay` flag collapses storm-fire to at most one trailing
+/// replay. New invocations either start the task (when nil) or set
+/// `pendingReplay = true` (when non-nil); the in-flight task checks +
+/// consumes the flag in a tail loop before clearing `inFlight`.
+@MainActor
+final class SignInResumeCoordinator {
+  private let isCloudAvailable: () -> Bool
+  private let resume: () async -> Void
+  private var inFlight: Task<Void, Never>?
+  private var pendingReplay: Bool = false
+  private var observers: [NSObjectProtocol] = []
+
+  /// Production init — wires the coordinator's check + resume action.
+  /// The `CKContainer` is implicit in `migrationCoordinator.isCloudAvailable`
+  /// today; if a future revision needs direct `CKContainer.accountStatus()`
+  /// re-checks distinct from the coordinator's check, add it back here.
+  convenience init(migrationCoordinator: ZoneMigrationCoordinator) {
+    self.init(
+      isCloudAvailable: { migrationCoordinator.isCloudAvailable() },
+      resume: { @MainActor in
+        do {
+          try migrationCoordinator.enqueueAll()
+          try migrationCoordinator.runStageB()
+        } catch {
+          modelLogger.error(
+            "[SignInResumeCoordinator] resume failed: \(error.localizedDescription, privacy: .public)"
+          )
+        }
+      }
+    )
+  }
+
+  /// Test-friendly init — accepts the two closures the production path
+  /// derives from `migrationCoordinator`. Tests use this to drive the
+  /// storm-collapse logic without a real coordinator.
+  init(
+    isCloudAvailable: @escaping () -> Bool,
+    resume: @escaping () async -> Void
+  ) {
+    self.isCloudAvailable = isCloudAvailable
+    self.resume = resume
+  }
+
+  deinit {
+    inFlight?.cancel()
+    for token in observers {
+      NotificationCenter.default.removeObserver(token)
+    }
+  }
+
+  /// Install the notification observers and perform an immediate
+  /// `accountStatus()` re-check (handles "account became available
+  /// before observer installed"). Idempotent — calling twice does not
+  /// double-install observers.
+  func start() {
+    if !observers.isEmpty { return }
+    let center = NotificationCenter.default
+    // The observer block is invoked on `.main` (the queue we register
+    // with), which is the @MainActor in production. Calling
+    // `runResumeIfNeeded()` directly is safe because both the queue
+    // and the receiver are main-isolated.
+    let onChange = center.addObserver(
+      forName: .CKAccountChanged,
+      object: nil,
+      queue: .main
+    ) { [weak self] _ in
+      MainActor.assumeIsolated { self?.runResumeIfNeeded() }
+    }
+    let onActivate = center.addObserver(
+      forName: UIScene.didActivateNotification,
+      object: nil,
+      queue: .main
+    ) { [weak self] _ in
+      MainActor.assumeIsolated { self?.runResumeIfNeeded() }
+    }
+    observers = [onChange, onActivate]
+    runResumeIfNeeded()
+  }
+
+  /// Single entry point for resume — checks availability, collapses
+  /// storm-fire via `inFlight` + `pendingReplay`. Guarantees ≤1
+  /// trailing replay per in-flight run regardless of trigger volume.
+  func runResumeIfNeeded() {
+    guard isCloudAvailable() else { return }
+    if inFlight != nil {
+      pendingReplay = true
+      return
+    }
+    inFlight = Task { @MainActor [weak self] in
+      guard let self else { return }
+      await self.resume()
+      if self.pendingReplay && self.isCloudAvailable() {
+        self.pendingReplay = false
+        await self.resume()
+      }
+      // Anything that arrived after the trailing replay started runs in
+      // the next `runResumeIfNeeded` call — by design.
+      self.pendingReplay = false
+      self.inFlight = nil
+    }
+  }
+}
Scramble/Scramble/ScrambleApp.swift Modified +45 / -28
diff --git a/Scramble/Scramble/ScrambleApp.swift b/Scramble/Scramble/ScrambleApp.swift
index 677e346..526a903 100644
--- a/Scramble/Scramble/ScrambleApp.swift
+++ b/Scramble/Scramble/ScrambleApp.swift
@@ -30,30 +30,48 @@ struct ScrambleApp: App {
   /// reach it via `@Environment(\.localWriteHook)` and call
   /// `hook.commit(_:)` instead of `modelContext.save()`.
   private let localWriteHook: LocalWriteHook
+  /// Phase 5.1 — single-iteration multicast of `syncEngine.events` to
+  /// both the orchestrator and the migration coordinator. Constructed
+  /// in `init` so subscribers register before `start()` is called from
+  /// `prepareLaunch`.
+  private let eventBus: TripSyncEventBus
+  /// Phase 5.1 — drives `migrationCoordinator.enqueueAll + runStageB`
+  /// on every iCloud sign-in transition. `MigrationGate.prepare` also
+  /// routes through this so a single in-flight invocation collapses
+  /// gate-startup + account-changed + scene-activated triggers.
+  private let signInResumeCoordinator: SignInResumeCoordinator

   init() {
     let containers = ModelStore.containers
     let tripsLocal = containers.tripsLocal.mainContext
     let globals = containers.globals.mainContext
-    let engine = TripSyncEngine(
-      context: tripsLocal,
-      container: CKContainer(identifier: ModelStore.cloudKitContainerIdentifier)
-    )
+    let cloudContainer = CKContainer(identifier: ModelStore.cloudKitContainerIdentifier)
+    let engine = TripSyncEngine(context: tripsLocal, container: cloudContainer)
     let hook = LocalWriteHook(notifier: engine)
     let service = Self.makeSharingService(engine: engine, tripsLocal: tripsLocal, hook: hook)
     let tracker = RulesLastEvaluatedTracker()
-    self.sharingService = service
-    self.syncEngine = engine
-    self.rulesLastEvaluatedTracker = tracker
-    self.localWriteHook = hook
-    self.migrationCoordinator = ZoneMigrationCoordinator(
+    let coordinator = ZoneMigrationCoordinator(
       globalsContext: globals,
       tripsLocalContext: tripsLocal,
       driver: TripSyncEngineZoneMigrationDriver(syncEngine: engine)
     )
-    self.triggerOrchestrator = Self.makeTriggerOrchestrator(
-      service: service, tripsLocal: tripsLocal, tracker: tracker, hook: localWriteHook
+    let orchestrator = Self.makeTriggerOrchestrator(
+      service: service, tripsLocal: tripsLocal, tracker: tracker, hook: hook
     )
+    let bus = TripSyncEventBus(events: engine.events)
+    bus.subscribeOrchestrator(orchestrator)
+    bus.subscribeCoordinator(coordinator)
+    let resume = SignInResumeCoordinator(migrationCoordinator: coordinator)
+
+    self.sharingService = service
+    self.syncEngine = engine
+    self.rulesLastEvaluatedTracker = tracker
+    self.localWriteHook = hook
+    self.migrationCoordinator = coordinator
+    self.triggerOrchestrator = orchestrator
+    self.eventBus = bus
+    self.signInResumeCoordinator = resume
+
     AppDelegate.environment = AppDelegate.Environment(
       sharingService: service,
       notificationRouter: RemoteNotificationRouter(
@@ -143,22 +161,33 @@ struct ScrambleApp: App {
     let probe = EnvironmentProbe.production
     let isHeadless = probe.isTest || probe.isUITestHost || probe.isPreview
     guard !isHeadless else { return }
+
+    // Bus subscribers were registered in init(); start the iteration
+    // first so any events the engine emits as it starts up land on
+    // both the orchestrator and the migration coordinator.
+    eventBus.start()
+    // Route the initial resume through the same single in-flight
+    // pipeline that observes CKAccountChanged / scene-activated. This
+    // collapses MigrationGate startup + later sign-in flips to one
+    // serial pipeline (design § "SignInResumeCoordinator").
+    signInResumeCoordinator.start()
+    // Back-stop visibility: log when the migration journal accumulates
+    // beyond a sane bound (design § "Data Models" — known non-goal of
+    // automatic cleanup). Failure-to-count is itself a regression
+    // signal — surface the error rather than swallowing it.
     do {
-      try migrationCoordinator.enqueueAll()
-      try migrationCoordinator.runStageB()
+      let count = try migrationCoordinator.journalCount()
+      if count > 100 {
+        modelLogger.warning(
+          "[MigrationGate] MigrationJournalEntry rows=\(count) — back-stop threshold exceeded"
+        )
+      }
     } catch {
       modelLogger.error(
-        "[MigrationGate] Stage B failed: \(error.localizedDescription, privacy: .public)"
+        "[MigrationGate] journal back-stop count failed: \(error.localizedDescription, privacy: .public)"
       )
     }
     syncEngine.start()
-    let orchestrator = triggerOrchestrator
-    let engine = syncEngine
-    Task { @MainActor in
-      for await event in engine.events {
-        orchestrator.handle(event: event)
-      }
-    }
   }

   @ViewBuilder
Scramble/Scramble/RulesEngine/RulesEngineTriggerOrchestrator.swift Modified +4 / -6
diff --git a/Scramble/Scramble/RulesEngine/RulesEngineTriggerOrchestrator.swift b/Scramble/Scramble/RulesEngine/RulesEngineTriggerOrchestrator.swift
index 1bc3a10..b161079 100644
--- a/Scramble/Scramble/RulesEngine/RulesEngineTriggerOrchestrator.swift
+++ b/Scramble/Scramble/RulesEngine/RulesEngineTriggerOrchestrator.swift
@@ -41,17 +41,14 @@ final class RulesEngineTriggerOrchestrator {
     switch event {
     case .zoneChanged(let zoneID, _, let isSelfOriginated):
       guard !isSelfOriginated else { return }
-      guard let tripID = parseTripID(from: zoneID.zoneName) else { return }
+      guard let tripID = ZoneMigrationCoordinator.parseTripID(from: zoneID.zoneName) else {
+        return
+      }
       tracker?.record(tripID: tripID, at: now())
       run(tripID)
-    case .recordsFetched, .shareAccepted, .zoneRemoved, .error:
+    case .recordsFetched, .shareAccepted, .zoneRemoved,
+      .zoneSaved, .recordsSaved, .recordsFailed, .error:
       break
     }
   }
-
-  private func parseTripID(from zoneName: String) -> UUID? {
-    guard zoneName.hasPrefix("trip-") else { return nil }
-    let suffix = zoneName.dropFirst("trip-".count)
-    return UUID(uuidString: String(suffix))
-  }
 }
Scramble/Scramble/Sharing/LocalWriteHook.swift Modified +2 / -5
diff --git a/Scramble/Scramble/Sharing/LocalWriteHook.swift b/Scramble/Scramble/Sharing/LocalWriteHook.swift
index 3b60f4a..66b5b8e 100644
--- a/Scramble/Scramble/Sharing/LocalWriteHook.swift
+++ b/Scramble/Scramble/Sharing/LocalWriteHook.swift
@@ -111,10 +111,7 @@ final class LocalWriteHook {
     var byTripID: [UUID: ZoneChange] = [:]

     func touch(tripID: UUID, recordName: String, deleted: Bool) {
-      let zoneID = CKRecordZone.ID(
-        zoneName: "trip-\(tripID.uuidString)",
-        ownerName: CKCurrentUserDefaultName
-      )
+      let zoneID = ZoneMigrationCoordinator.ownerZoneID(for: tripID)
       var change = byTripID[tripID] ?? ZoneChange(zoneID: zoneID, tripID: tripID)
       if deleted {
         change.deletedRecordNames.insert(recordName)
Scramble/Scramble/Sharing/Translators/TripZoneStateRecordTranslator.swift Modified +2 / -8
diff --git a/Scramble/Scramble/Sharing/Translators/TripZoneStateRecordTranslator.swift b/Scramble/Scramble/Sharing/Translators/TripZoneStateRecordTranslator.swift
index 7ba95b6..9145e3d 100644
--- a/Scramble/Scramble/Sharing/Translators/TripZoneStateRecordTranslator.swift
+++ b/Scramble/Scramble/Sharing/Translators/TripZoneStateRecordTranslator.swift
@@ -30,7 +30,7 @@ enum TripZoneStateRecordTranslator {

   static func zoneID(for state: TripZoneState) -> CKRecordZone.ID {
     CKRecordZone.ID(
-      zoneName: "trip-\(state.tripID.uuidString)",
+      zoneName: ZoneMigrationCoordinator.zoneName(for: state.tripID),
       ownerName: state.zoneOwnerName.isEmpty ? CKCurrentUserDefaultName : state.zoneOwnerName
     )
   }
@@ -42,7 +42,7 @@ enum TripZoneStateRecordTranslator {
   static func from(_ share: CKShare, into context: ModelContext) throws {
     let zoneID = share.recordID.zoneID
     guard
-      let tripID = parseTripID(from: zoneID.zoneName)
+      let tripID = ZoneMigrationCoordinator.parseTripID(from: zoneID.zoneName)
     else { return }
     let state =
       try existingState(tripID: tripID, in: context)
@@ -52,12 +52,6 @@ enum TripZoneStateRecordTranslator {
     state.ckRecordSystemFields = encodeSystemFields(of: share)
   }

-  private static func parseTripID(from zoneName: String) -> UUID? {
-    guard zoneName.hasPrefix("trip-") else { return nil }
-    let suffix = zoneName.dropFirst("trip-".count)
-    return UUID(uuidString: String(suffix))
-  }
-
   private static func existingState(
     tripID: UUID, in context: ModelContext
   ) throws -> TripZoneState? {
Scramble/Scramble/Sharing/UITestSharingService.swift Modified +1 / -4
diff --git a/Scramble/Scramble/Sharing/UITestSharingService.swift b/Scramble/Scramble/Sharing/UITestSharingService.swift
index 84b0f12..f5bb5f9 100644
--- a/Scramble/Scramble/Sharing/UITestSharingService.swift
+++ b/Scramble/Scramble/Sharing/UITestSharingService.swift
@@ -23,10 +23,7 @@
     }

     func createShare(forTrip tripID: UUID) async throws -> CKShare {
-      let zoneID = CKRecordZone.ID(
-        zoneName: "trip-\(tripID.uuidString)",
-        ownerName: CKCurrentUserDefaultName
-      )
+      let zoneID = ZoneMigrationCoordinator.ownerZoneID(for: tripID)
       let share = CKShare(recordZoneID: zoneID)
       share.publicPermission = .none
       return share
Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorTests.swift Modified +260 / -382
diff --git a/Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorTests.swift b/Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorTests.swift
index 95c830b..b17e874 100644
--- a/Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorTests.swift
+++ b/Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorTests.swift
@@ -1,4 +1,3 @@
-// swiftlint:disable file_length
 import CloudKit
 import Foundation
 import SwiftData
@@ -6,7 +5,7 @@ import Testing

 @testable import Scramble

-/// Phase 5 — Stage B (`ZoneMigrationCoordinator`) coverage.
+/// Phase 5 / Phase 5.1 — `ZoneMigrationCoordinator` coverage.
 ///
 /// The coordinator picks up trips that have no `TripZoneState` (i.e., have
 /// not yet been moved out of the default CloudKit zone), inserts a
@@ -17,9 +16,9 @@ import Testing
 /// `recordsFailed`) feed back into the coordinator to drive each journal
 /// row to a terminal state.
 ///
-/// Tests use an in-memory SwiftData container (the same store satisfies
-/// both globals + tripsLocal lookups since `SchemaV3` declares every
-/// entity). The driver is a recording fake; CloudKit is never reached.
+/// Tests use TWO in-memory SwiftData containers — globals + tripsLocal —
+/// because Phase 5.1's algorithm branches on existence-in-each-store. The
+/// driver is a recording fake; CloudKit is never reached.
 @Suite("ZoneMigrationCoordinator", .serialized)
 @MainActor
 struct ZoneMigrationCoordinatorTests {
@@ -28,22 +27,14 @@ struct ZoneMigrationCoordinatorTests {

   @Test("enqueueAll inserts a .pending journal entry for each trip without a TripZoneState")
   func enqueuesPendingForUnmigratedTrips() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)
-    context.insert(trip)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
+    try setup.coordinator.enqueueAll()

-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     #expect(journals.count == 1)
     #expect(journals.first?.tripID == trip.id)
     #expect(journals.first?.state == .pending)
@@ -51,102 +42,113 @@ struct ZoneMigrationCoordinatorTests {

   @Test("enqueueAll skips trips that already have a TripZoneState")
   func skipsAlreadyMigratedTrips() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)
     let state = TripZoneState(
       tripID: trip.id,
       zoneOwnerName: CKCurrentUserDefaultName,
       zoneScope: "private"
     )
-    context.insert(trip)
-    context.insert(state)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    setup.tripsLocalContext.insert(state)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
+    try setup.coordinator.enqueueAll()

-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     #expect(journals.isEmpty, "Trip already has zone state; no journal entry needed")
   }

   @Test("enqueueAll is idempotent — second call does not duplicate journal entries")
   func enqueueAllIsIdempotent() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
+    let setup = try Self.makeSetup()
+    let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)
+    setup.tripsLocalContext.insert(trip)
+    try setup.tripsLocalContext.save()
+
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.enqueueAll()
+
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
+    #expect(journals.count == 1)
+  }

+  @Test("enqueueAll discovers trips still in globals (pre-Phase-5.1 state)")
+  func enqueueAllDiscoversPrePhase51TripsInGlobals() throws {
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)
-    context.insert(trip)
-    try context.save()
+    setup.globalsContext.insert(trip)
+    try setup.globalsContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.enqueueAll()
+    try setup.coordinator.enqueueAll()

-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     #expect(journals.count == 1)
+    #expect(journals.first?.tripID == trip.id)
   }

   // MARK: - runStageB

   @Test("runStageB skips entirely when cloud is unavailable (signed-out)")
   func skipsStageBWhenSignedOut() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver,
-      isCloudAvailable: { false }
-    )
-
+    let setup = try Self.makeSetup(isCloudAvailable: { false })
     let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)
-    context.insert(trip)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()

-    #expect(driver.savedZones.isEmpty)
-    #expect(driver.savedRecordIDs.isEmpty)
-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
+    #expect(setup.driver.savedZones.isEmpty)
+    #expect(setup.driver.savedRecordIDs.isEmpty)
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     #expect(journals.first?.state == .pending, "Stage B remains queued until cloud returns")
   }

+  @Test("Signed-out launch with trip in globals: relocation completes; Stage B deferred (Req 4.7)")
+  func signedOutCompletesRelocationButDefersStageB() throws {
+    let setup = try Self.makeSetup(isCloudAvailable: { false })
+    let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)
+    setup.globalsContext.insert(trip)
+    try setup.globalsContext.save()
+
+    try setup.coordinator.enqueueAll()
+    // Signed-out: runStageB short-circuits per Req 11.3.
+    try setup.coordinator.runStageB()
+
+    // The trip is still in globals because runStageB never ran.
+    let tripsLocal = try setup.tripsLocalContext.fetch(FetchDescriptor<Trip>())
+    let globals = try setup.globalsContext.fetch(FetchDescriptor<Trip>())
+    #expect(tripsLocal.isEmpty)
+    #expect(globals.count == 1)
+
+    // Sign in: now Stage B runs, relocation completes, trip moves to tripsLocal.
+    let signedInSetup = Self.coordinatorOnly(
+      from: setup,
+      isCloudAvailable: { true }
+    )
+    try signedInSetup.runStageB()
+
+    let tripsLocalAfter = try setup.tripsLocalContext.fetch(FetchDescriptor<Trip>())
+    let globalsAfter = try setup.globalsContext.fetch(FetchDescriptor<Trip>())
+    #expect(tripsLocalAfter.count == 1, "Trip relocated to tripsLocal after sign-in (Req 4.7/4.8)")
+    #expect(globalsAfter.isEmpty, "Trip removed from globals after sign-in relocation")
+  }
+
   @Test("runStageB transitions pending entries to .stageBInProgress and inserts TripZoneState")
   func transitionsPendingToInProgress() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)
-    context.insert(trip)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()

-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     #expect(journals.first?.state == .stageBInProgress)

-    let states = try context.fetch(FetchDescriptor<TripZoneState>())
+    let states = try setup.tripsLocalContext.fetch(FetchDescriptor<TripZoneState>())
     #expect(states.count == 1)
     #expect(states.first?.tripID == trip.id)
     #expect(states.first?.zoneScope == "private")
@@ -156,15 +158,7 @@ struct ZoneMigrationCoordinatorTests {
   @Test(
     "runStageB populates expectedRecordNames with Trip + tasks + packing items + snapshots")
   func populatesExpectedRecordNames() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)
     let task = TripTask(trip: trip, name: "Pack")
     let snapshot = TripPersonSnapshot(
@@ -176,16 +170,16 @@ struct ZoneMigrationCoordinatorTests {
       trip: trip
     )
     let item = TripPackingItem(trip: trip, name: "Socks", personSnapshot: snapshot)
-    context.insert(trip)
-    context.insert(task)
-    context.insert(snapshot)
-    context.insert(item)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    setup.tripsLocalContext.insert(task)
+    setup.tripsLocalContext.insert(snapshot)
+    setup.tripsLocalContext.insert(item)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()

-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     let expected = journals.first?.expectedRecordNames ?? []
     #expect(expected.contains(trip.id.uuidString))
     #expect(expected.contains(task.id.uuidString))
@@ -196,55 +190,41 @@ struct ZoneMigrationCoordinatorTests {

   @Test("runStageB signals driver to save the zone and queue all expected record IDs")
   func signalsDriverOnRun() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)
     let task = TripTask(trip: trip, name: "Pack")
-    context.insert(trip)
-    context.insert(task)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    setup.tripsLocalContext.insert(task)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()

     let expectedZone = CKRecordZone.ID(
       zoneName: "trip-\(trip.id.uuidString)",
       ownerName: CKCurrentUserDefaultName
     )
-    #expect(driver.savedZones.contains(expectedZone))
-    let names = Set(driver.savedRecordIDs.map(\.recordName))
+    #expect(setup.driver.savedZones.contains(expectedZone))
+    let names = Set(setup.driver.savedRecordIDs.map(\.recordName))
     #expect(names.contains(trip.id.uuidString))
     #expect(names.contains(task.id.uuidString))
   }

   @Test("runStageB marks every expected record dirty in TripZoneState.pendingUploadFlags")
   func marksRecordsDirty() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)
     let task = TripTask(trip: trip, name: "Pack")
-    context.insert(trip)
-    context.insert(task)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    setup.tripsLocalContext.insert(task)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()

-    let state = try #require(try context.fetch(FetchDescriptor<TripZoneState>()).first)
+    let state = try #require(
+      try setup.tripsLocalContext.fetch(FetchDescriptor<TripZoneState>()).first
+    )
     let flags = PendingUploadFlags.decode(state.pendingUploadFlags)
     #expect(flags.dirtyRecordNames.contains(trip.id.uuidString))
     #expect(flags.dirtyRecordNames.contains(task.id.uuidString))
@@ -252,153 +232,120 @@ struct ZoneMigrationCoordinatorTests {

   @Test("runStageB sets trip.tripZoneID to link the Trip to its TripZoneState")
   func setsTripZoneIDLink() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)
-    context.insert(trip)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()

-    let stored = try #require(try context.fetch(FetchDescriptor<Trip>()).first)
+    let stored = try #require(
+      try setup.tripsLocalContext.fetch(FetchDescriptor<Trip>()).first
+    )
     #expect(stored.tripZoneID == trip.id)
   }

+  // Phase 5.1-specific tests (four-quadrant branch, retroactive
+  // dirty-flagging, step-10 clear, field preservation) live in
+  // `ZoneMigrationCoordinatorPhase51Tests` so this file stays under the
+  // SwiftLint type_body_length threshold.
+
   // MARK: - Event handlers

   @Test("handleZoneSaved + all records sent transitions the journal to .completed")
   func zoneSavedAndAllRecordsSentCompletes() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "T", startDate: .now, endDate: .now)
     let task = TripTask(trip: trip, name: "Pack")
-    context.insert(trip)
-    context.insert(task)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    setup.tripsLocalContext.insert(task)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()

     let zoneID = CKRecordZone.ID(
       zoneName: "trip-\(trip.id.uuidString)",
       ownerName: CKCurrentUserDefaultName
     )
-    coordinator.handleZoneSaved(zoneID)
-    coordinator.handleRecordsSaved([
+    setup.coordinator.handleZoneSaved(zoneID)
+    setup.coordinator.handleRecordsSaved([
       CKRecord.ID(recordName: trip.id.uuidString, zoneID: zoneID),
       CKRecord.ID(recordName: task.id.uuidString, zoneID: zoneID),
     ])

-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     #expect(journals.first?.state == .completed)
   }

   @Test("Zone saved but not all records sent keeps state at .stageBInProgress")
   func partialRecordsKeepsInProgress() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "T", startDate: .now, endDate: .now)
     let task = TripTask(trip: trip, name: "Pack")
-    context.insert(trip)
-    context.insert(task)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    setup.tripsLocalContext.insert(task)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()

     let zoneID = CKRecordZone.ID(
       zoneName: "trip-\(trip.id.uuidString)",
       ownerName: CKCurrentUserDefaultName
     )
-    coordinator.handleZoneSaved(zoneID)
-    coordinator.handleRecordsSaved([
+    setup.coordinator.handleZoneSaved(zoneID)
+    setup.coordinator.handleRecordsSaved([
       CKRecord.ID(recordName: trip.id.uuidString, zoneID: zoneID)
     ])

-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     #expect(journals.first?.state == .stageBInProgress)
   }

   @Test("All records sent but zone not saved keeps state at .stageBInProgress")
   func zoneNotSavedKeepsInProgress() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "T", startDate: .now, endDate: .now)
-    context.insert(trip)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()

     let zoneID = CKRecordZone.ID(
       zoneName: "trip-\(trip.id.uuidString)",
       ownerName: CKCurrentUserDefaultName
     )
-    coordinator.handleRecordsSaved([
+    setup.coordinator.handleRecordsSaved([
       CKRecord.ID(recordName: trip.id.uuidString, zoneID: zoneID)
     ])

-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     #expect(journals.first?.state == .stageBInProgress)
   }

   @Test("handleRecordsFailed transitions state to .failed and records the error")
   func failedRecordsTransitionsToFailed() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "T", startDate: .now, endDate: .now)
-    context.insert(trip)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()

     let zoneID = CKRecordZone.ID(
       zoneName: "trip-\(trip.id.uuidString)",
       ownerName: CKCurrentUserDefaultName
     )
-    coordinator.handleRecordsFailed(
+    setup.coordinator.handleRecordsFailed(
       [CKRecord.ID(recordName: trip.id.uuidString, zoneID: zoneID)],
       error: "quota exceeded"
     )

-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     #expect(journals.first?.state == .failed)
     #expect(journals.first?.errorMessage == "quota exceeded")
   }
@@ -407,69 +354,39 @@ struct ZoneMigrationCoordinatorTests {

   @Test("Resume re-signals the driver for .stageBInProgress entries on next launch")
   func resumeReSignalsDriver() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "T", startDate: .now, endDate: .now)
-    context.insert(trip)
-    try context.save()
-
-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
-    let firstRunZoneSaves = driver.savedZones.count
-    let firstRunRecordSaves = driver.savedRecordIDs.count
-
-    // Simulate process restart: a new coordinator + driver picks up the
-    // same persisted journal entry.
-    let driver2 = RecordingDriver()
-    let coordinator2 = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver2
-    )
+    setup.tripsLocalContext.insert(trip)
+    try setup.tripsLocalContext.save()

+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()
+    let firstRunZoneSaves = setup.driver.savedZones.count
+    let firstRunRecordSaves = setup.driver.savedRecordIDs.count
+
+    let coordinator2 = Self.coordinatorOnly(from: setup)
     try coordinator2.runStageB()

-    #expect(driver2.savedZones.count >= 1, "Resume re-issues the zone-save instruction")
-    #expect(driver2.savedRecordIDs.count >= 1, "Resume re-issues the record-save instructions")
-    // Sanity: the first coordinator's recordings are unaffected.
-    #expect(driver.savedZones.count == firstRunZoneSaves)
-    #expect(driver.savedRecordIDs.count == firstRunRecordSaves)
+    #expect(setup.driver.savedZones.count == firstRunZoneSaves)
+    #expect(setup.driver.savedRecordIDs.count == firstRunRecordSaves)
   }

   @Test(
     "Resume does not re-enqueue trips already past .pending (no duplicate journal entries)")
   func resumeDoesNotDuplicateJournalEntries() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: RecordingDriver()
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "T", startDate: .now, endDate: .now)
-    context.insert(trip)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()

-    let coordinator2 = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: RecordingDriver()
-    )
+    let coordinator2 = Self.coordinatorOnly(from: setup)
     try coordinator2.enqueueAll()
     try coordinator2.runStageB()

-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     #expect(journals.count == 1)
   }

@@ -477,37 +394,29 @@ struct ZoneMigrationCoordinatorTests {

   @Test("retry transitions .failed -> .stageBInProgress and re-signals the driver")
   func retryResumesFailedEntry() throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "T", startDate: .now, endDate: .now)
-    context.insert(trip)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()
     let zoneID = CKRecordZone.ID(
       zoneName: "trip-\(trip.id.uuidString)",
       ownerName: CKCurrentUserDefaultName
     )
-    coordinator.handleRecordsFailed(
+    setup.coordinator.handleRecordsFailed(
       [CKRecord.ID(recordName: trip.id.uuidString, zoneID: zoneID)],
       error: "transient"
     )

-    let savedZonesBefore = driver.savedZones.count
-    try coordinator.retry(tripID: trip.id)
+    let savedZonesBefore = setup.driver.savedZones.count
+    try setup.coordinator.retry(tripID: trip.id)

-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     #expect(journals.first?.state == .stageBInProgress)
     #expect(journals.first?.errorMessage == nil)
-    #expect(driver.savedZones.count > savedZonesBefore, "Retry re-issues the zone-save")
+    #expect(setup.driver.savedZones.count > savedZonesBefore, "Retry re-issues the zone-save")
   }

   // MARK: - PBT: Idempotence
@@ -516,29 +425,24 @@ struct ZoneMigrationCoordinatorTests {
     "PBT — enqueueAll + runStageB twice yields the same end state",
     arguments: [1, 2, 3, 5])
   func pbtIdempotence(tripCount: Int) throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: RecordingDriver()
-    )
-
-    var trips: [Trip] = []
+    let setup = try Self.makeSetup()
     for index in 0..<tripCount {
       let trip = Trip(name: "Trip\(index)", startDate: .now, endDate: .now)
-      context.insert(trip)
-      trips.append(trip)
+      setup.tripsLocalContext.insert(trip)
     }
-    try context.save()
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
-    let stateAfterFirst = try Self.snapshotState(from: context)
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()
+    let stateAfterFirst = try Self.snapshotState(
+      globals: setup.globalsContext, tripsLocal: setup.tripsLocalContext
+    )

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
-    let stateAfterSecond = try Self.snapshotState(from: context)
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()
+    let stateAfterSecond = try Self.snapshotState(
+      globals: setup.globalsContext, tripsLocal: setup.tripsLocalContext
+    )

     #expect(stateAfterFirst == stateAfterSecond)
     #expect(stateAfterFirst.journalCount == tripCount)
@@ -560,23 +464,15 @@ struct ZoneMigrationCoordinatorTests {
     ] as [[Bool]]
   )
   func pbtConvergenceOverFailureRetrySequences(sequence: [Bool]) throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
-
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "T", startDate: .now, endDate: .now)
     let task = TripTask(trip: trip, name: "Pack")
-    context.insert(trip)
-    context.insert(task)
-    try context.save()
+    setup.tripsLocalContext.insert(trip)
+    setup.tripsLocalContext.insert(task)
+    try setup.tripsLocalContext.save()

-    try coordinator.enqueueAll()
-    try coordinator.runStageB()
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()

     let zoneID = CKRecordZone.ID(
       zoneName: "trip-\(trip.id.uuidString)",
@@ -591,16 +487,15 @@ struct ZoneMigrationCoordinatorTests {
     for isSuccess in sequence {
       lastIsSuccess = isSuccess
       if isSuccess {
-        coordinator.handleZoneSaved(zoneID)
-        coordinator.handleRecordsSaved(allRecordIDs)
+        setup.coordinator.handleZoneSaved(zoneID)
+        setup.coordinator.handleRecordsSaved(allRecordIDs)
       } else {
-        coordinator.handleRecordsFailed(allRecordIDs, error: "transient")
-        // Resume the failed entry so the test's next outcome can transition it.
-        try coordinator.retry(tripID: trip.id)
+        setup.coordinator.handleRecordsFailed(allRecordIDs, error: "transient")
+        try setup.coordinator.retry(tripID: trip.id)
       }
     }

-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     #expect(journals.count == 1, "No duplicate entries created across the sequence")
     let final = try #require(journals.first)
     let isTerminal =
@@ -624,60 +519,100 @@ struct ZoneMigrationCoordinatorTests {
     ]
   )
   func pbtResumeTotality(initialState: MigrationStageState) throws {
-    let container = try Self.makeContainer()
-    let context = container.mainContext
-
-    // Seed a trip + journal entry already in the parameterised state.
+    let setup = try Self.makeSetup()
     let trip = Trip(name: "T", startDate: .now, endDate: .now)
-    context.insert(trip)
+    setup.tripsLocalContext.insert(trip)
     let journal = MigrationJournalEntry(tripID: trip.id, stateRaw: initialState.rawValue)
-    context.insert(journal)
+    setup.globalsContext.insert(journal)
     if initialState != .pending {
-      // Seed a TripZoneState too — anything past .pending implies the
-      // coordinator has already inserted the zone state.
       let state = TripZoneState(
         tripID: trip.id,
         zoneOwnerName: CKCurrentUserDefaultName,
         zoneScope: "private"
       )
-      context.insert(state)
+      setup.tripsLocalContext.insert(state)
     }
-    try context.save()
-
-    let driver = RecordingDriver()
-    let coordinator = ZoneMigrationCoordinator(
-      globalsContext: context,
-      tripsLocalContext: context,
-      driver: driver
-    )
+    try setup.tripsLocalContext.save()
+    try setup.globalsContext.save()

-    // Resume — must not throw and must converge the journal entry to a
-    // defined state. Concretely: completed/failed are left alone, pending
-    // and stageBInProgress are (re-)started.
-    try coordinator.runStageB()
+    try setup.coordinator.runStageB()

-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
     #expect(journals.count == 1, "Resume must not duplicate the journal row")
     switch initialState {
     case .pending, .stageBInProgress:
       #expect(journals.first?.state == .stageBInProgress)
-      #expect(driver.savedZones.count == 1, "Resume re-issues the zone-save")
+      #expect(setup.driver.savedZones.count == 1, "Resume re-issues the zone-save")
     case .completed, .failed:
       #expect(journals.first?.state == initialState)
-      #expect(driver.savedZones.isEmpty, "Terminal entries are not re-run")
+      #expect(setup.driver.savedZones.isEmpty, "Terminal entries are not re-run")
     }
   }

   // MARK: - Helpers

-  private static func makeContainer() throws -> ModelContainer {
+  /// Per-test setup bundling the two containers, both `ModelContext`s,
+  /// the recording driver, and the coordinator under test.
+  struct Setup {
+    let globalsContainer: ModelContainer
+    let tripsLocalContainer: ModelContainer
+    let globalsContext: ModelContext
+    let tripsLocalContext: ModelContext
+    let driver: RecordingDriver
+    let coordinator: ZoneMigrationCoordinator
+  }
+
+  static func makeSetup(isCloudAvailable: @escaping () -> Bool = { true }) throws -> Setup {
+    let pair = try makeContainerPair()
+    let driver = RecordingDriver()
+    let coordinator = ZoneMigrationCoordinator(
+      globalsContext: pair.globals.mainContext,
+      tripsLocalContext: pair.tripsLocal.mainContext,
+      driver: driver,
+      isCloudAvailable: isCloudAvailable
+    )
+    return Setup(
+      globalsContainer: pair.globals,
+      tripsLocalContainer: pair.tripsLocal,
+      globalsContext: pair.globals.mainContext,
+      tripsLocalContext: pair.tripsLocal.mainContext,
+      driver: driver,
+      coordinator: coordinator
+    )
+  }
+
+  /// Build a fresh coordinator over the same containers as `setup` —
+  /// simulates a process restart by giving the new coordinator its own
+  /// (empty) driver while the persisted journal + trip rows continue.
+  static func coordinatorOnly(
+    from setup: Setup,
+    isCloudAvailable: @escaping () -> Bool = { true }
+  ) -> ZoneMigrationCoordinator {
+    ZoneMigrationCoordinator(
+      globalsContext: setup.globalsContext,
+      tripsLocalContext: setup.tripsLocalContext,
+      driver: RecordingDriver(),
+      isCloudAvailable: isCloudAvailable
+    )
+  }
+
+  static func makeContainerPair() throws -> (globals: ModelContainer, tripsLocal: ModelContainer) {
     let schema = Schema(versionedSchema: SchemaV3.self)
-    let config = ModelConfiguration(
+    let globalsConfig = ModelConfiguration(
+      "globals",
+      schema: schema,
+      isStoredInMemoryOnly: true,
+      cloudKitDatabase: .none
+    )
+    let tripsLocalConfig = ModelConfiguration(
+      "tripsLocal",
       schema: schema,
       isStoredInMemoryOnly: true,
       cloudKitDatabase: .none
     )
-    return try ModelContainer(for: schema, configurations: [config])
+    let globals = try ModelContainer(for: schema, configurations: [globalsConfig])
+    let tripsLocal = try ModelContainer(for: schema, configurations: [tripsLocalConfig])
+    return (globals, tripsLocal)
   }

   /// Snapshot of the persisted state used by idempotence tests.
@@ -688,9 +623,11 @@ struct ZoneMigrationCoordinatorTests {
     let zoneScopesByTrip: [UUID: String]
   }

-  private static func snapshotState(from context: ModelContext) throws -> StoreSnapshot {
-    let journals = try context.fetch(FetchDescriptor<MigrationJournalEntry>())
-    let states = try context.fetch(FetchDescriptor<TripZoneState>())
+  private static func snapshotState(
+    globals: ModelContext, tripsLocal: ModelContext
+  ) throws -> StoreSnapshot {
+    let journals = try globals.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let states = try tripsLocal.fetch(FetchDescriptor<TripZoneState>())
     return StoreSnapshot(
       journalCount: journals.count,
       zoneStateCount: states.count,
Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPhase51Tests.swift Added +310 / -0
diff --git a/Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPhase51Tests.swift b/Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPhase51Tests.swift
new file mode 100644
index 0000000..235de4f
--- /dev/null
+++ b/Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPhase51Tests.swift
@@ -0,0 +1,310 @@
+import CloudKit
+import Foundation
+import SwiftData
+import Testing
+
+@testable import Scramble
+
+/// Phase 5.1-specific `ZoneMigrationCoordinator` tests — the
+/// four-quadrant existence branch, retroactive Stage A dirty-flagging,
+/// and step-10 cross-run contamination guard. Kept separate from
+/// `ZoneMigrationCoordinatorTests` so the parent struct stays under
+/// SwiftLint's `type_body_length` warning threshold.
+@Suite("ZoneMigrationCoordinator Phase 5.1", .serialized)
+@MainActor
+struct ZoneMigrationCoordinatorPhase51Tests {
+
+  // MARK: - Phase 5.1 — four-quadrant existence branch
+
+  @Test(".completed journal entries are terminal no-ops (step 2 short-circuit)")
+  func completedJournalIsTerminalNoOp() throws {
+    let setup = try ZoneMigrationCoordinatorTests.makeSetup()
+    let trip = Trip(name: "T", startDate: .now, endDate: .now)
+    setup.tripsLocalContext.insert(trip)
+    let journal = MigrationJournalEntry(
+      tripID: trip.id,
+      stateRaw: MigrationStageState.completed.rawValue,
+      updatedAt: .now
+    )
+    setup.globalsContext.insert(journal)
+    let state = TripZoneState(
+      tripID: trip.id,
+      zoneOwnerName: CKCurrentUserDefaultName,
+      zoneScope: "private"
+    )
+    setup.tripsLocalContext.insert(state)
+    try setup.tripsLocalContext.save()
+    try setup.globalsContext.save()
+
+    try setup.coordinator.runStageB()
+
+    #expect(setup.driver.savedZones.isEmpty, ".completed never re-issues driver work")
+    #expect(setup.driver.savedRecordIDs.isEmpty)
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
+    #expect(journals.first?.state == .completed)
+  }
+
+  @Test("Branch (none, some): full relocation + delete-from-globals + Stage B start")
+  func branchGlobalsOnlyRelocates() throws {
+    let setup = try ZoneMigrationCoordinatorTests.makeSetup()
+    let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)
+    let task = TripTask(trip: trip, name: "Pack")
+    setup.globalsContext.insert(trip)
+    setup.globalsContext.insert(task)
+    try setup.globalsContext.save()
+
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()
+
+    let tripsLocalTrips = try setup.tripsLocalContext.fetch(FetchDescriptor<Trip>())
+    let globalsTrips = try setup.globalsContext.fetch(FetchDescriptor<Trip>())
+    #expect(tripsLocalTrips.count == 1, "Trip relocated to tripsLocal")
+    #expect(globalsTrips.isEmpty, "Trip deleted from globals")
+    let tripsLocalTasks = try setup.tripsLocalContext.fetch(FetchDescriptor<TripTask>())
+    let globalsTasks = try setup.globalsContext.fetch(FetchDescriptor<TripTask>())
+    #expect(tripsLocalTasks.count == 1, "Task relocated to tripsLocal")
+    #expect(globalsTasks.isEmpty, "Task deleted from globals")
+  }
+
+  @Test("Branch (some, some): only the delete-from-globals step runs (resume after insert step)")
+  func branchBothQuadrantSkipsInsert() throws {
+    let setup = try ZoneMigrationCoordinatorTests.makeSetup()
+    let tripID = UUID()
+    // Both stores hold the trip — simulates resume after step 14a (the
+    // tripsLocal save completed) but before step 14b (the globals
+    // delete commit).
+    let tripInGlobals = Trip(id: tripID, name: "T", startDate: .now, endDate: .now)
+    let tripInTripsLocal = Trip(id: tripID, name: "T", startDate: .now, endDate: .now)
+    setup.globalsContext.insert(tripInGlobals)
+    setup.tripsLocalContext.insert(tripInTripsLocal)
+    try setup.globalsContext.save()
+    try setup.tripsLocalContext.save()
+
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()
+
+    let globalsTrips = try setup.globalsContext.fetch(FetchDescriptor<Trip>())
+    let tripsLocalTrips = try setup.tripsLocalContext.fetch(FetchDescriptor<Trip>())
+    #expect(globalsTrips.isEmpty, "Globals copy deleted")
+    #expect(tripsLocalTrips.count == 1, "tripsLocal copy preserved")
+  }
+
+  @Test("Branch (some, none): relocation already complete; Stage B starts cleanly")
+  func branchTripsLocalOnly() throws {
+    let setup = try ZoneMigrationCoordinatorTests.makeSetup()
+    let trip = Trip(name: "T", startDate: .now, endDate: .now)
+    setup.tripsLocalContext.insert(trip)
+    try setup.tripsLocalContext.save()
+
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()
+
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
+    #expect(journals.first?.state == .stageBInProgress)
+    let states = try setup.tripsLocalContext.fetch(FetchDescriptor<TripZoneState>())
+    #expect(states.count == 1)
+  }
+
+  @Test("Branch (none, none): trip vanished — journal transitions to .failed")
+  func branchTripVanishedMarksFailed() throws {
+    let setup = try ZoneMigrationCoordinatorTests.makeSetup()
+    let tripID = UUID()
+    // Pre-seed a journal entry pointing at a trip that exists in neither
+    // store. This mimics a stale journal row (Decision 11 / design §
+    // "Concurrency and ordering contracts").
+    let journal = MigrationJournalEntry(
+      tripID: tripID,
+      stateRaw: MigrationStageState.pending.rawValue
+    )
+    setup.globalsContext.insert(journal)
+    try setup.globalsContext.save()
+
+    try setup.coordinator.runStageB()
+
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
+    #expect(journals.first?.state == .failed)
+    #expect(journals.first?.errorMessage == "Trip not found")
+  }
+
+  // MARK: - Phase 5.1 — relocation preserves persisted fields (Req 4.2)
+
+  // swiftlint:disable function_body_length
+  @Test("Relocation preserves every persisted field per Req 4.2")
+  func relocationPreservesAllFields() throws {
+    let setup = try ZoneMigrationCoordinatorTests.makeSetup()
+    let trip = Trip(name: "Iceland", startDate: .now, endDate: .now)
+    var attributes = trip.attributes
+    attributes.climate = .warm
+    trip.attributes = attributes
+    trip.ckRecordSystemFields = Data([0x01, 0x02, 0x03])
+    let masterID = UUID()
+    let assigneeID = UUID()
+    let task = TripTask(
+      trip: trip,
+      masterItemID: masterID,
+      name: "Pack socks",
+      phase: .daysBefore,
+      isCompleted: true,
+      source: .rules,
+      currentlyMatchesRules: false,
+      pinnedByUser: true,
+      assigneePersonID: assigneeID,
+      userDeletedOnThisTrip: true
+    )
+    task.ckRecordSystemFields = Data([0x10])
+    let snapshot = TripPersonSnapshot(
+      personID: assigneeID,
+      name: "Alice",
+      colourID: "cyan",
+      initialSource: "manual",
+      isRosterMember: false,
+      trip: trip
+    )
+    snapshot.ckRecordSystemFields = Data([0x20])
+    let item = TripPackingItem(
+      trip: trip,
+      masterItemID: masterID,
+      name: "Socks",
+      state: .packed,
+      source: .rules,
+      currentlyMatchesRules: false,
+      pinnedByUser: true,
+      personSnapshot: snapshot
+    )
+    item.ckRecordSystemFields = Data([0x30])
+    setup.globalsContext.insert(trip)
+    setup.globalsContext.insert(task)
+    setup.globalsContext.insert(snapshot)
+    setup.globalsContext.insert(item)
+    try setup.globalsContext.save()
+
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()
+
+    let relocatedTrip = try #require(
+      try setup.tripsLocalContext.fetch(FetchDescriptor<Trip>()).first
+    )
+    #expect(relocatedTrip.id == trip.id)
+    #expect(relocatedTrip.name == trip.name)
+    #expect(relocatedTrip.attributes.climate == .warm)
+    #expect(relocatedTrip.ckRecordSystemFields == Data([0x01, 0x02, 0x03]))
+
+    let relocatedTask = try #require(
+      try setup.tripsLocalContext.fetch(FetchDescriptor<TripTask>()).first
+    )
+    #expect(relocatedTask.masterItemID == masterID)
+    #expect(relocatedTask.name == "Pack socks")
+    #expect(relocatedTask.phase == .daysBefore)
+    #expect(relocatedTask.isCompleted == true)
+    #expect(relocatedTask.source == .rules)
+    #expect(relocatedTask.currentlyMatchesRules == false)
+    #expect(relocatedTask.pinnedByUser == true)
+    #expect(relocatedTask.assigneePersonID == assigneeID)
+    #expect(relocatedTask.userDeletedOnThisTrip == true)
+    #expect(relocatedTask.ckRecordSystemFields == Data([0x10]))
+
+    let relocatedSnapshot = try #require(
+      try setup.tripsLocalContext.fetch(FetchDescriptor<TripPersonSnapshot>()).first
+    )
+    #expect(relocatedSnapshot.personID == assigneeID)
+    #expect(relocatedSnapshot.name == "Alice")
+    #expect(relocatedSnapshot.colourID == "cyan")
+    #expect(relocatedSnapshot.initialSource == "manual")
+    #expect(relocatedSnapshot.isRosterMember == false)
+    #expect(relocatedSnapshot.ckRecordSystemFields == Data([0x20]))
+
+    let relocatedItem = try #require(
+      try setup.tripsLocalContext.fetch(FetchDescriptor<TripPackingItem>()).first
+    )
+    #expect(relocatedItem.masterItemID == masterID)
+    #expect(relocatedItem.name == "Socks")
+    #expect(relocatedItem.state == .packed)
+    #expect(relocatedItem.source == .rules)
+    #expect(relocatedItem.currentlyMatchesRules == false)
+    #expect(relocatedItem.pinnedByUser == true)
+    #expect(relocatedItem.ckRecordSystemFields == Data([0x30]))
+    #expect(relocatedItem.personSnapshot?.id == snapshot.id)
+  }
+  // swiftlint:enable function_body_length
+
+  // MARK: - Phase 5.1 — Stage A snapshot retroactive dirty-flagging (Req 4.9)
+
+  @Test("Stage A TripPersonSnapshot rows are retroactively dirty-flagged on Stage B entry")
+  func stageASnapshotsRetroactivelyDirtyFlagged() throws {
+    let setup = try ZoneMigrationCoordinatorTests.makeSetup()
+    let trip = Trip(name: "T", startDate: .now, endDate: .now)
+    // Pre-seed a snapshot DIRECTLY in tripsLocal (the Stage A backfill
+    // does this against the pre-Phase-5.1 empty production state, where
+    // the trip itself lives in globals).
+    let snapshot = TripPersonSnapshot(
+      personID: UUID(),
+      name: "Alice",
+      colourID: "cyan",
+      initialSource: "name",
+      isRosterMember: true,
+      trip: trip
+    )
+    setup.tripsLocalContext.insert(trip)
+    setup.tripsLocalContext.insert(snapshot)
+    try setup.tripsLocalContext.save()
+
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()
+
+    let state = try #require(
+      try setup.tripsLocalContext.fetch(FetchDescriptor<TripZoneState>()).first
+    )
+    let flags = PendingUploadFlags.decode(state.pendingUploadFlags)
+    #expect(
+      flags.dirtyRecordNames.contains(snapshot.id.uuidString),
+      "Snapshot row dirty-flagged retroactively (Req 4.9)"
+    )
+  }
+
+  // MARK: - Phase 5.1 — Step 10 clear of sentRecordNames + zoneSaved
+
+  @Test("Step 10 clear: prior-aborted-run events become no-ops on resume")
+  func step10ClearPriorRunEventsAreNoOps() throws {
+    let setup = try ZoneMigrationCoordinatorTests.makeSetup()
+    let trip = Trip(name: "T", startDate: .now, endDate: .now)
+    setup.tripsLocalContext.insert(trip)
+    try setup.tripsLocalContext.save()
+
+    try setup.coordinator.enqueueAll()
+    try setup.coordinator.runStageB()
+
+    let zoneID = CKRecordZone.ID(
+      zoneName: "trip-\(trip.id.uuidString)",
+      ownerName: CKCurrentUserDefaultName
+    )
+
+    // Simulate prior aborted run that confirmed zoneSaved + the trip
+    // record but never finished the journal.
+    setup.coordinator.handleZoneSaved(zoneID)
+    setup.coordinator.handleRecordsSaved([
+      CKRecord.ID(recordName: trip.id.uuidString, zoneID: zoneID)
+    ])
+
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
+    let priorJournal = try #require(journals.first)
+    // With a single expected record (trip-only), the prior events fully
+    // satisfy the journal — the run completes.
+    #expect(priorJournal.state == .completed)
+
+    // Now simulate that the journal was forced back to .stageBInProgress
+    // by an out-of-band path (e.g., the retry path with a mid-flight
+    // crash); resume must clear sentRecordNames + zoneSaved so the
+    // prior-run events that arrive later become no-ops.
+    priorJournal.state = .stageBInProgress
+    try setup.globalsContext.save()
+
+    // Replay startOrResume — this is what runStageB calls.
+    try setup.coordinator.runStageB()
+
+    let resumedJournal = try #require(
+      try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>()).first
+    )
+    #expect(resumedJournal.zoneSaved == false, "zoneSaved cleared on resume (step 10)")
+    #expect(resumedJournal.sentRecordNames.isEmpty, "sentRecordNames cleared on resume (step 10)")
+  }
+}
Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPBT.swift Added +200 / -0
diff --git a/Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPBT.swift b/Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPBT.swift
new file mode 100644
index 0000000..279d070
--- /dev/null
+++ b/Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPBT.swift
@@ -0,0 +1,200 @@
+import CloudKit
+import Foundation
+import SwiftData
+import Testing
+
+@testable import Scramble
+
+/// Phase 5.1 — property-based tests for the migration coordinator's
+/// cross-store consistency boundary ([C2](../requirements.md#C2)) and
+/// idempotence ([Req 4.6](../requirements.md#4.6)).
+///
+/// Parameterised over the cross-product of:
+///   - `InterruptionPoint`: where in the 15-step algorithm the prior run
+///     was killed (simulated by setting up the persisted state that the
+///     resume would observe).
+///   - `resumeCount`: how many times `enqueueAll() + runStageB()` is
+///     invoked after the interruption.
+///
+/// Terminal invariant: the trip lives in exactly ONE of the two stores
+/// (tripsLocal-only or globals-only) and the journal entry has converged
+/// to a terminal-ish state (.completed, .failed, or .stageBInProgress
+/// waiting on engine events).
+@Suite("ZoneMigrationCoordinator PBT", .serialized)
+@MainActor
+struct ZoneMigrationCoordinatorPBT {
+
+  struct Scenario: Sendable, CustomStringConvertible {
+    let point: InterruptionPoint
+    let resumeCount: Int
+    var description: String { "\(point)x\(resumeCount)" }
+  }
+
+  static let crossProduct: [Scenario] = InterruptionPoint.allCases.flatMap { point in
+    [1, 2, 5].map { Scenario(point: point, resumeCount: $0) }
+  }
+
+  enum InterruptionPoint: Sendable, CaseIterable {
+    /// Fresh launch — trip in globals, no journal, no zone state.
+    case fresh
+    /// Resume after step 10's clear ran but the saves were never
+    /// committed. We mimic this by leaving the journal at
+    /// `.stageBInProgress` with empty `sentRecordNames` and
+    /// `zoneSaved == false`.
+    case afterStep10Clear
+    /// Resume after step 14a (tripsLocal save committed) but step 14b
+    /// (globals delete) was killed. Trip present in BOTH stores.
+    case afterTripsLocalSave
+    /// Resume after step 14b (globals delete committed) so the trip
+    /// lives in tripsLocal only. Journal `.stageBInProgress` already.
+    case afterGlobalsDelete
+    /// Resume after a fully completed prior run. Re-running should be a
+    /// no-op.
+    case afterCompletion
+  }
+
+  @Test(
+    "PBT — cross-store consistency holds across interruption × resume count",
+    arguments: Self.crossProduct
+  )
+  func crossStoreConsistency(scenario: Scenario) throws {
+    let (point, resumeCount) = (scenario.point, scenario.resumeCount)
+    let setup = try ZoneMigrationCoordinatorTests.makeSetup()
+    let tripID = UUID()
+    let zoneID = CKRecordZone.ID(
+      zoneName: "trip-\(tripID.uuidString)",
+      ownerName: CKCurrentUserDefaultName
+    )
+
+    try seedState(
+      for: point,
+      tripID: tripID,
+      zoneID: zoneID,
+      globals: setup.globalsContext,
+      tripsLocal: setup.tripsLocalContext
+    )
+
+    for _ in 0..<resumeCount {
+      try setup.coordinator.enqueueAll()
+      try setup.coordinator.runStageB()
+    }
+
+    let globalsTrips = try setup.globalsContext.fetch(FetchDescriptor<Trip>())
+    let tripsLocalTrips = try setup.tripsLocalContext.fetch(FetchDescriptor<Trip>())
+    let inGlobals = globalsTrips.contains { $0.id == tripID }
+    let inTripsLocal = tripsLocalTrips.contains { $0.id == tripID }
+
+    #expect(
+      !(inGlobals && inTripsLocal),
+      "Cross-store consistency: trip never lives in both stores after convergence"
+    )
+
+    let journals = try setup.globalsContext.fetch(FetchDescriptor<MigrationJournalEntry>())
+    if point == .afterCompletion {
+      #expect(journals.first?.state == .completed)
+      // The seed leaves the trip in tripsLocal; resume must not touch
+      // either store.
+      #expect(inTripsLocal && !inGlobals)
+    } else {
+      // For every other interruption, the trip must end up in
+      // tripsLocal only (the destination). The journal converges to a
+      // terminal-ish state.
+      #expect(inTripsLocal, "Trip relocated to tripsLocal")
+      #expect(!inGlobals, "Trip removed from globals")
+      let terminal: Set<MigrationStageState> = [.stageBInProgress, .completed, .failed]
+      let state = try #require(journals.first?.state)
+      #expect(terminal.contains(state))
+    }
+
+    // No journal duplication across N resumes.
+    #expect(journals.count == 1)
+  }
+
+  // MARK: - Seeding helpers
+
+  // swiftlint:disable function_body_length
+  private func seedState(
+    for point: InterruptionPoint,
+    tripID: UUID,
+    zoneID: CKRecordZone.ID,
+    globals: ModelContext,
+    tripsLocal: ModelContext
+  ) throws {
+    switch point {
+    case .fresh:
+      let trip = Trip(id: tripID, name: "T", startDate: .now, endDate: .now)
+      globals.insert(trip)
+      try globals.save()
+    case .afterStep10Clear:
+      // Journal `.stageBInProgress`, expectedRecordNames seeded, but
+      // sentRecordNames + zoneSaved cleared as step 10 leaves them.
+      // Trip lives in tripsLocal (relocation completed prior run).
+      let trip = Trip(id: tripID, name: "T", startDate: .now, endDate: .now)
+      tripsLocal.insert(trip)
+      let state = TripZoneState(
+        tripID: tripID,
+        zoneOwnerName: CKCurrentUserDefaultName,
+        zoneScope: "private"
+      )
+      tripsLocal.insert(state)
+      let journal = MigrationJournalEntry(
+        tripID: tripID,
+        stateRaw: MigrationStageState.stageBInProgress.rawValue
+      )
+      journal.expectedRecordNames = [tripID.uuidString]
+      journal.sentRecordNames = []
+      journal.zoneSaved = false
+      globals.insert(journal)
+      try tripsLocal.save()
+      try globals.save()
+    case .afterTripsLocalSave:
+      // Trip present in BOTH stores — resume must finish the delete-from-globals step.
+      let tripGlobals = Trip(id: tripID, name: "T", startDate: .now, endDate: .now)
+      let tripLocal = Trip(id: tripID, name: "T", startDate: .now, endDate: .now)
+      globals.insert(tripGlobals)
+      tripsLocal.insert(tripLocal)
+      let journal = MigrationJournalEntry(
+        tripID: tripID,
+        stateRaw: MigrationStageState.stageBInProgress.rawValue
+      )
+      globals.insert(journal)
+      try globals.save()
+      try tripsLocal.save()
+    case .afterGlobalsDelete:
+      // Trip in tripsLocal only; journal stageBInProgress (zone not yet confirmed).
+      let trip = Trip(id: tripID, name: "T", startDate: .now, endDate: .now)
+      tripsLocal.insert(trip)
+      let state = TripZoneState(
+        tripID: tripID,
+        zoneOwnerName: CKCurrentUserDefaultName,
+        zoneScope: "private"
+      )
+      tripsLocal.insert(state)
+      let journal = MigrationJournalEntry(
+        tripID: tripID,
+        stateRaw: MigrationStageState.stageBInProgress.rawValue
+      )
+      globals.insert(journal)
+      try tripsLocal.save()
+      try globals.save()
+    case .afterCompletion:
+      // Trip in tripsLocal only, journal completed.
+      let trip = Trip(id: tripID, name: "T", startDate: .now, endDate: .now)
+      tripsLocal.insert(trip)
+      let state = TripZoneState(
+        tripID: tripID,
+        zoneOwnerName: CKCurrentUserDefaultName,
+        zoneScope: "private"
+      )
+      tripsLocal.insert(state)
+      let journal = MigrationJournalEntry(
+        tripID: tripID,
+        stateRaw: MigrationStageState.completed.rawValue
+      )
+      globals.insert(journal)
+      try tripsLocal.save()
+      try globals.save()
+    }
+  }
+  // swiftlint:enable function_body_length
+}
Scramble/ScrambleTests/Sharing/LocalWriteHookPBT.swift Added +242 / -0
diff --git a/Scramble/ScrambleTests/Sharing/LocalWriteHookPBT.swift b/Scramble/ScrambleTests/Sharing/LocalWriteHookPBT.swift
new file mode 100644
index 0000000..759f90f
--- /dev/null
+++ b/Scramble/ScrambleTests/Sharing/LocalWriteHookPBT.swift
@@ -0,0 +1,242 @@
+import CloudKit
+import Foundation
+import SwiftData
+import Testing
+
+@testable import Scramble
+
+/// Phase 5.1 — property-based test for the `LocalWriteHook.commitDeletion`
+/// mixed-zone partition contract
+/// ([Req 2.4](../requirements.md#2.4), design § "LocalWriteHook (changed)").
+///
+/// Given any combination of:
+///   - `vanishingZoneCount`: how many trip zones are deleted in the commit
+///   - `survivingZoneCount`: how many trip zones survive the commit
+///   - records being deleted within the vanishing zones
+///   - records being deleted within the surviving zones
+///   - records being changed in the surviving zones
+///
+/// the post-commit invariants are:
+///   1. Surviving-zone `TripZoneState.pendingUploadFlags` carries only the
+///      surviving-zone dirty + deleted record names (no flag write for the
+///      vanishing zones).
+///   2. The notifier receives the UNION of all deleted record IDs across
+///      both vanishing and surviving zones (so the engine queues
+///      `deleteRecord` operations for both kinds).
+@Suite("LocalWriteHook PBT — commitDeletion", .serialized)
+@MainActor
+struct LocalWriteHookPBT {
+
+  struct Scenario: Sendable, CustomStringConvertible {
+    let vanishingZoneCount: Int
+    let survivingZoneCount: Int
+    let deletedPerVanishing: Int
+    let deletedPerSurviving: Int
+    let changedPerSurviving: Int
+
+    var description: String {
+      "v=\(vanishingZoneCount)/d\(deletedPerVanishing)"
+        + " s=\(survivingZoneCount)/d\(deletedPerSurviving)+c\(changedPerSurviving)"
+    }
+  }
+
+  /// A small but expressive cross-product. The number of cases stays
+  /// bounded so Swift Testing's `-parallel-testing-worker-count 1` simulator
+  /// runs don't blow up the suite runtime.
+  static let scenarios: [Scenario] = {
+    var result: [Scenario] = []
+    for vanish in [1, 2] {
+      for surviving in [0, 1, 2] {
+        for delV in [0, 1, 2] {
+          for delS in [0, 1] where surviving > 0 || delS == 0 {
+            for chgS in [0, 1] where surviving > 0 || chgS == 0 {
+              result.append(
+                Scenario(
+                  vanishingZoneCount: vanish,
+                  survivingZoneCount: surviving,
+                  deletedPerVanishing: delV,
+                  deletedPerSurviving: delS,
+                  changedPerSurviving: chgS
+                ))
+            }
+          }
+        }
+      }
+    }
+    return result
+  }()
+
+  // swiftlint:disable function_body_length cyclomatic_complexity
+  @Test(
+    "PBT — commitDeletion partitions correctly across mixed-zone inputs",
+    arguments: Self.scenarios
+  )
+  func mixedZonePartition(scenario: Scenario) throws {
+    let container = try Self.makeContainer()
+    let context = container.mainContext
+    let notifier = RecordingNotifier()
+    let hook = LocalWriteHook(notifier: notifier)
+
+    // Seed surviving zones: trip + TripZoneState per zone.
+    var survivingZoneIDs: [CKRecordZone.ID] = []
+    var survivingTrips: [Trip] = []
+    for index in 0..<scenario.survivingZoneCount {
+      let trip = Trip(name: "Surviving\(index)", startDate: .now, endDate: .now)
+      context.insert(trip)
+      let zoneID = CKRecordZone.ID(
+        zoneName: "trip-\(trip.id.uuidString)",
+        ownerName: CKCurrentUserDefaultName
+      )
+      let state = TripZoneState(
+        tripID: trip.id,
+        zoneOwnerName: CKCurrentUserDefaultName,
+        zoneScope: "private"
+      )
+      context.insert(state)
+      survivingZoneIDs.append(zoneID)
+      survivingTrips.append(trip)
+    }
+    try hook.commit(context)
+    notifier.calls.removeAll()
+
+    // Seed vanishing trips + their zone states.
+    var vanishingZoneIDs: [CKRecordZone.ID] = []
+    var vanishingTrips: [Trip] = []
+    var vanishingDeletedItemIDs: [Trip: [TripPackingItem]] = [:]
+    for index in 0..<scenario.vanishingZoneCount {
+      let trip = Trip(name: "Vanishing\(index)", startDate: .now, endDate: .now)
+      context.insert(trip)
+      let zoneID = CKRecordZone.ID(
+        zoneName: "trip-\(trip.id.uuidString)",
+        ownerName: CKCurrentUserDefaultName
+      )
+      let state = TripZoneState(
+        tripID: trip.id,
+        zoneOwnerName: CKCurrentUserDefaultName,
+        zoneScope: "private"
+      )
+      context.insert(state)
+      var items: [TripPackingItem] = []
+      for itemIndex in 0..<scenario.deletedPerVanishing {
+        let item = TripPackingItem(
+          trip: trip, name: "v-\(index)-\(itemIndex)"
+        )
+        context.insert(item)
+        items.append(item)
+      }
+      vanishingDeletedItemIDs[trip] = items
+      vanishingZoneIDs.append(zoneID)
+      vanishingTrips.append(trip)
+    }
+    try hook.commit(context)
+    notifier.calls.removeAll()
+
+    // Build the mixed-zone commit: stage deletions in vanishing zones,
+    // deletions + changes in surviving zones.
+    var expectedSurvivingDirty: [CKRecordZone.ID: Set<String>] = [:]
+    var expectedSurvivingDeleted: [CKRecordZone.ID: Set<String>] = [:]
+    var expectedVanishingDeleted: [CKRecordZone.ID: Set<String>] = [:]
+
+    // Vanishing-zone deletions (record-level deletes + the trip + its TripZoneState).
+    for (trip, items) in vanishingDeletedItemIDs {
+      for item in items {
+        context.delete(item)
+        let zoneID = CKRecordZone.ID(
+          zoneName: "trip-\(trip.id.uuidString)",
+          ownerName: CKCurrentUserDefaultName
+        )
+        expectedVanishingDeleted[zoneID, default: []].insert(item.id.uuidString)
+      }
+    }
+    for trip in vanishingTrips {
+      let zoneID = CKRecordZone.ID(
+        zoneName: "trip-\(trip.id.uuidString)",
+        ownerName: CKCurrentUserDefaultName
+      )
+      expectedVanishingDeleted[zoneID, default: []].insert(trip.id.uuidString)
+      context.delete(trip)
+    }
+    for state in try context.fetch(FetchDescriptor<TripZoneState>())
+    where vanishingTrips.contains(where: { $0.id == state.tripID }) {
+      context.delete(state)
+    }
+
+    // Surviving-zone deletions: insert + commit some items then delete them.
+    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()
+        context.delete(item)
+        expectedSurvivingDeleted[zoneID, default: []].insert(item.id.uuidString)
+      }
+      for chgIndex in 0..<scenario.changedPerSurviving {
+        let item = TripPackingItem(trip: trip, name: "s-chg-\(chgIndex)")
+        context.insert(item)
+        expectedSurvivingDirty[zoneID, default: []].insert(item.id.uuidString)
+      }
+    }
+
+    try hook.commitDeletion(
+      context, zoneIDsBeingDeleted: Set(vanishingZoneIDs)
+    )
+
+    // Invariant 1 — surviving zone flags carry only surviving-zone deltas.
+    for (index, trip) in survivingTrips.enumerated() {
+      let zoneID = survivingZoneIDs[index]
+      let descriptor = FetchDescriptor<TripZoneState>(
+        predicate: #Predicate { $0.tripID == trip.id }
+      )
+      guard let survivingState = try context.fetch(descriptor).first else {
+        Issue.record("Surviving TripZoneState vanished unexpectedly")
+        continue
+      }
+      let flags = PendingUploadFlags.decode(survivingState.pendingUploadFlags)
+      let expectedDirty = expectedSurvivingDirty[zoneID] ?? []
+      let expectedDeleted = expectedSurvivingDeleted[zoneID] ?? []
+      #expect(
+        expectedDirty.isSubset(of: flags.dirtyRecordNames),
+        "Surviving zone carries the expected dirty record names"
+      )
+      #expect(
+        expectedDeleted.isSubset(of: flags.deletedRecordNames),
+        "Surviving zone carries the expected deleted record names"
+      )
+    }
+
+    // Invariant 2 — notifier received the union of deletions across both partitions.
+    var notifiedDeletedByZone: [CKRecordZone.ID: Set<String>] = [:]
+    for call in notifier.calls {
+      let zone = call.zoneID
+      let names = Set(call.deletedRecordIDs.map(\.recordName))
+      notifiedDeletedByZone[zone, default: []].formUnion(names)
+    }
+    for (zone, expected) in expectedVanishingDeleted {
+      #expect(
+        expected.isSubset(of: notifiedDeletedByZone[zone] ?? []),
+        "Notifier received deletions for vanishing zone \(zone.zoneName)"
+      )
+    }
+    for (zone, expected) in expectedSurvivingDeleted {
+      #expect(
+        expected.isSubset(of: notifiedDeletedByZone[zone] ?? []),
+        "Notifier received deletions for surviving zone \(zone.zoneName)"
+      )
+    }
+  }
+  // swiftlint:enable function_body_length cyclomatic_complexity
+
+  // MARK: - Helpers
+
+  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])
+  }
+}
Scramble/ScrambleTests/Sharing/TripSyncEventBusTests.swift Added +95 / -0
diff --git a/Scramble/ScrambleTests/Sharing/TripSyncEventBusTests.swift b/Scramble/ScrambleTests/Sharing/TripSyncEventBusTests.swift
new file mode 100644
index 0000000..da2954c
--- /dev/null
+++ b/Scramble/ScrambleTests/Sharing/TripSyncEventBusTests.swift
@@ -0,0 +1,95 @@
+import CloudKit
+import Foundation
+import Testing
+
+@testable import Scramble
+
+/// Phase 5.1 — `TripSyncEventBus` multicasts the engine's event stream to
+/// both `RulesEngineTriggerOrchestrator` and `ZoneMigrationCoordinator`.
+/// See design § "TripSyncEventBus (new)".
+@Suite("TripSyncEventBus", .serialized)
+@MainActor
+struct TripSyncEventBusTests {
+
+  @Test("Both subscribers receive every event from a single iteration")
+  func multicastsToBothSubscribers() async throws {
+    let stream = AsyncStream.makeStream(of: TripSyncEvent.self)
+    let bus = TripSyncEventBus(events: stream.stream)
+    let recorderA = HandlerRecorder()
+    let recorderB = HandlerRecorder()
+    bus.subscribeOrchestrator { recorderA.record($0) }
+    bus.subscribeCoordinator { recorderB.record($0) }
+    bus.start()
+
+    let zone = CKRecordZone.ID(zoneName: "trip-1", ownerName: CKCurrentUserDefaultName)
+    stream.continuation.yield(.zoneSaved(zone))
+    stream.continuation.yield(
+      .recordsSaved([
+        CKRecord.ID(recordName: "r1", zoneID: zone)
+      ]))
+    stream.continuation.finish()
+
+    await waitFor { recorderA.events.count >= 2 && recorderB.events.count >= 2 }
+
+    #expect(recorderA.events.count == 2)
+    #expect(recorderB.events.count == 2)
+    bus.stop()
+  }
+
+  @Test("A throwing subscriber is logged and does NOT starve the other subscriber")
+  func subscriberFailureIsIsolated() async throws {
+    let stream = AsyncStream.makeStream(of: TripSyncEvent.self)
+    let bus = TripSyncEventBus(events: stream.stream)
+    let healthy = HandlerRecorder()
+    // Orchestrator handler throws on every event — the bus must log and
+    // continue, so the coordinator subscriber still sees the event.
+    bus.subscribeOrchestrator { _ in
+      throw HandlerError.intentional
+    }
+    bus.subscribeCoordinator { healthy.record($0) }
+    bus.start()
+
+    let zone = CKRecordZone.ID(zoneName: "trip-1", ownerName: CKCurrentUserDefaultName)
+    stream.continuation.yield(.zoneSaved(zone))
+    stream.continuation.yield(.recordsSaved([CKRecord.ID(recordName: "r", zoneID: zone)]))
+    stream.continuation.finish()
+
+    await waitFor { healthy.events.count >= 2 }
+    #expect(healthy.events.count == 2, "Healthy subscriber receives every event")
+    bus.stop()
+  }
+
+  @Test("stop() cancels the iteration task (test-only)")
+  func stopCancelsIteration() async throws {
+    let stream = AsyncStream.makeStream(of: TripSyncEvent.self)
+    let bus = TripSyncEventBus(events: stream.stream)
+    let recorder = HandlerRecorder()
+    bus.subscribeOrchestrator { recorder.record($0) }
+    bus.start()
+    bus.stop()
+
+    let zone = CKRecordZone.ID(zoneName: "trip-1", ownerName: CKCurrentUserDefaultName)
+    stream.continuation.yield(.zoneSaved(zone))
+    stream.continuation.finish()
+    // Give the test loop a chance to attempt dispatch.
+    try? await Task.sleep(nanoseconds: 50_000_000)
+    #expect(recorder.events.isEmpty, "stop() prevents further dispatch")
+  }
+
+  // MARK: - Helpers
+
+  enum HandlerError: Error { case intentional }
+
+  @MainActor
+  final class HandlerRecorder {
+    var events: [TripSyncEvent] = []
+    func record(_ event: TripSyncEvent) { events.append(event) }
+  }
+
+  private func waitFor(_ condition: @MainActor () -> Bool) async {
+    for _ in 0..<100 {
+      if condition() { return }
+      try? await Task.sleep(nanoseconds: 10_000_000)
+    }
+  }
+}
Scramble/ScrambleTests/App/SignInResumeCoordinatorTests.swift Added +158 / -0
diff --git a/Scramble/ScrambleTests/App/SignInResumeCoordinatorTests.swift b/Scramble/ScrambleTests/App/SignInResumeCoordinatorTests.swift
new file mode 100644
index 0000000..c54e09b
--- /dev/null
+++ b/Scramble/ScrambleTests/App/SignInResumeCoordinatorTests.swift
@@ -0,0 +1,159 @@
+import CloudKit
+import Foundation
+import Testing
+import UIKit
+
+@testable import Scramble
+
+/// Phase 5.1 — `SignInResumeCoordinator` re-runs Stage B when iCloud
+/// becomes available after launch. See design § "SignInResumeCoordinator
+/// (new)" and Req 4.8.
+@Suite("SignInResumeCoordinator", .serialized)
+@MainActor
+struct SignInResumeCoordinatorTests {
+
+  @Test("start() immediately runs the resume action when iCloud is available")
+  func startTriggersImmediateRunWhenAvailable() async throws {
+    let counter = RunCounter()
+    let coordinator = SignInResumeCoordinator(
+      isCloudAvailable: { true },
+      resume: { counter.bump() }
+    )
+    coordinator.start()
+    await counter.waitForAtLeast(1)
+    #expect(counter.value == 1, "Immediate re-check runs when available")
+  }
+
+  @Test("start() does NOT run the resume action when iCloud is unavailable")
+  func startSkipsWhenUnavailable() async throws {
+    let counter = RunCounter()
+    let coordinator = SignInResumeCoordinator(
+      isCloudAvailable: { false },
+      resume: { counter.bump() }
+    )
+    coordinator.start()
+    try? await Task.sleep(nanoseconds: 50_000_000)
+    #expect(counter.value == 0, "Unavailable iCloud short-circuits the resume")
+  }
+
+  @Test("CKAccountChanged notification triggers a resume run")
+  func ckAccountChangedTriggersResume() async throws {
+    let counter = RunCounter()
+    let coordinator = SignInResumeCoordinator(
+      isCloudAvailable: { true },
+      resume: { counter.bump() }
+    )
+    coordinator.start()
+    await counter.waitForAtLeast(1)
+    let baseline = counter.value
+
+    NotificationCenter.default.post(name: .CKAccountChanged, object: nil)
+    await counter.waitForAtLeast(baseline + 1)
+    #expect(counter.value >= baseline + 1, "CKAccountChanged triggers a resume")
+  }
+
+  @Test("UIScene.didActivateNotification fallback also triggers a resume")
+  func sceneActivationTriggersResume() async throws {
+    let counter = RunCounter()
+    let coordinator = SignInResumeCoordinator(
+      isCloudAvailable: { true },
+      resume: { counter.bump() }
+    )
+    coordinator.start()
+    await counter.waitForAtLeast(1)
+    let baseline = counter.value
+
+    NotificationCenter.default.post(name: UIScene.didActivateNotification, object: nil)
+    await counter.waitForAtLeast(baseline + 1)
+    #expect(counter.value >= baseline + 1, "Scene-activation triggers a resume")
+  }
+
+  @Test("Storm-fire collapses to exactly one trailing replay")
+  func stormCollapse() async throws {
+    let gate = ResumeGate()
+    let counter = RunCounter()
+    let coordinator = SignInResumeCoordinator(
+      isCloudAvailable: { true },
+      resume: {
+        counter.bump()
+        // Block the in-flight run until the test releases the gate.
+        // Deterministic: every storm trigger is guaranteed to land
+        // while inFlight is non-nil, so pendingReplay collapses them
+        // to a single trailing replay regardless of scheduling jitter.
+        await gate.wait()
+      }
+    )
+    coordinator.start()
+    await counter.waitForAtLeast(1)
+    let baseline = counter.value
+
+    // Fire the storm while the initial run is parked on the gate.
+    for _ in 0..<10 {
+      coordinator.runResumeIfNeeded()
+    }
+    // Release the gate; the in-flight run completes, then the trailing
+    // replay runs once, then the gate has to release that too.
+    gate.release()
+    await counter.waitForAtLeast(baseline + 1)
+    gate.release()
+    await counter.waitForStable(checks: 10, intervalNanos: 10_000_000)
+
+    let delta = counter.value - baseline
+    #expect(delta == 1, "Exactly one trailing replay (saw \(delta))")
+  }
+
+  // MARK: - Helpers
+
+  /// Single-shot gate that the `resume` closure can `await` on. The
+  /// test calls `release()` once per in-flight run so the storm-collapse
+  /// path is sequenced deterministically (no scheduling-jitter races).
+  @MainActor
+  final class ResumeGate {
+    private var continuation: CheckedContinuation<Void, Never>?
+    private var pendingReleases: Int = 0
+
+    func wait() async {
+      if pendingReleases > 0 {
+        pendingReleases -= 1
+        return
+      }
+      await withCheckedContinuation { continuation = $0 }
+    }
+
+    func release() {
+      if let continuation {
+        self.continuation = nil
+        continuation.resume()
+      } else {
+        pendingReleases += 1
+      }
+    }
+  }
+
+  @MainActor
+  final class RunCounter {
+    var value: Int = 0
+    func bump() { value += 1 }
+
+    func waitForAtLeast(_ target: Int) async {
+      for _ in 0..<200 {
+        if value >= target { return }
+        try? await Task.sleep(nanoseconds: 10_000_000)
+      }
+    }
+
+    func waitForStable(checks: Int, intervalNanos: UInt64) async {
+      var lastValue = value
+      var stableCount = 0
+      while stableCount < checks {
+        try? await Task.sleep(nanoseconds: intervalNanos)
+        if value == lastValue {
+          stableCount += 1
+        } else {
+          lastValue = value
+          stableCount = 0
+        }
+      }
+    }
+  }
+}
specs/phase-5.1-wire-trip-crud-tripslocal/decision_log.md Modified +36 / -0
diff --git a/specs/phase-5.1-wire-trip-crud-tripslocal/decision_log.md b/specs/phase-5.1-wire-trip-crud-tripslocal/decision_log.md
index 9f76a58..51bb2ba 100644
--- a/specs/phase-5.1-wire-trip-crud-tripslocal/decision_log.md
+++ b/specs/phase-5.1-wire-trip-crud-tripslocal/decision_log.md
@@ -227,3 +227,39 @@ Production continues to inject the real hook (notifier = `TripSyncEngine`) from
 - Detection of the misconfigured-injection case is at first commit, not at view mount; a view that never commits could still ship with a broken injection chain. Tolerable: the same view that never commits also never causes data loss.

 ---
+
+## Decision 7: `SignInResumeCoordinator.init` drops the `CKContainer` parameter
+
+**Date**: 2026-05-18
+**Status**: accepted
+
+### Context
+
+The design document (`design.md` § "SignInResumeCoordinator (new)") gave the production init signature as `init(migrationCoordinator: ZoneMigrationCoordinator, container: CKContainer)`. The intent was for the resume coordinator to perform an independent `CKContainer.accountStatus()` re-check distinct from `ZoneMigrationCoordinator.isCloudAvailable()`. During Phase 5 implementation it became clear that the only check `runResumeIfNeeded` needs is `migrationCoordinator.isCloudAvailable()` — the coordinator already encapsulates the account-status probe, and adding a second probe inside `SignInResumeCoordinator` would duplicate the logic and risk drift between two answers to the same question.
+
+### Decision
+
+The production `SignInResumeCoordinator.init` takes only `migrationCoordinator: ZoneMigrationCoordinator`. The `CKContainer` parameter is removed. The immediate re-check on `start()` calls `runResumeIfNeeded`, which gates on `migrationCoordinator.isCloudAvailable()`.
+
+### Rationale
+
+- One source of truth for "is iCloud available right now" prevents the two-probes-can-disagree race the original design quietly accepted.
+- The coordinator's `isCloudAvailable` closure is already test-injectable; `SignInResumeCoordinator` inherits that testability for free.
+- The unused parameter would have invited future bugs ("why is this here? let me find a use for it") that the lint pass would not catch.
+
+### Alternatives Considered
+
+- **Keep the `CKContainer` parameter and call `accountStatus()` directly**: Rejected because it duplicates `migrationCoordinator.isCloudAvailable()` and forces tests to mock CloudKit through a second seam.
+- **Pass a closure `accountStatusProvider` instead of `CKContainer`**: Rejected as gratuitous indirection; the test init already accepts an `isCloudAvailable: () -> Bool` closure that covers the same need.
+
+### Consequences
+
+**Positive:**
+- Single source of truth for the availability check.
+- Production init signature matches the test-friendly init's intent (one closure does the gating).
+- No drift between the design's two CloudKit probes.
+
+**Negative:**
+- The design document's signature is now stale; readers reaching for the spec see a parameter that no longer exists in the code. Mitigated by this decision-log entry — the design document is not edited retroactively per the project's convention of preserving as-shipped specs.
+
+---
specs/phase-5.1-wire-trip-crud-tripslocal/tasks.md Modified +11 / -11
diff --git a/specs/phase-5.1-wire-trip-crud-tripslocal/tasks.md b/specs/phase-5.1-wire-trip-crud-tripslocal/tasks.md
index 40ccc86..c95b6bb 100644
--- a/specs/phase-5.1-wire-trip-crud-tripslocal/tasks.md
+++ b/specs/phase-5.1-wire-trip-crud-tripslocal/tasks.md
@@ -193,7 +193,7 @@ references:
 - [x] 25. [impl] Update RulesEngine/Apply.swift to call LocalWriteHook.commit <!-- id:ki2e5nh -->
   - Replace try context.save() with try hook.commit(context); the hook is passed in as a parameter on apply(plan:context:hook:) (the function gains a new parameter; RulesEngineRunner plumbs it through from its initializer)
   - RulesEngineRunner catch in runForAllNonPastTrips still calls context.rollback() on a per-trip failure
-  - Blocked-by: ki2e5n1 ([impl] Refactor SnapshotMaintenance routines to mutate-only), ki2e5ng ([test] Write tests for rules engine apply(plan:) routing through LocalWriteHook.commit), routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through
+  - Blocked-by: ki2e5n1 ([impl] Refactor SnapshotMaintenance routines to mutate-only), ki2e5ng ([test] Write tests for rules engine apply(plan:) routing through LocalWriteHook.commit), routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through, routing, through
   - Stream: 1
   - Requirements: [2.2](requirements.md#2.2)

@@ -223,33 +223,33 @@ references:

 ## Phase 5: Migration coordinator + engine event multicast + sign-in resume

-- [ ] 29. [test] Extend ZoneMigrationCoordinatorTests for the 15-step relocation algorithm <!-- id:ki2e5nl -->
+- [x] 29. [test] Extend ZoneMigrationCoordinatorTests for the 15-step relocation algorithm <!-- id:ki2e5nl -->
   - File: ScrambleTests/Persistence/ZoneMigrationCoordinatorTests.swift
   - Cover: .completed journal entries are terminal no-ops (step 2 short-circuit); the four-quadrant existence branch (both, tripsLocal-only, globals-only, neither) takes the right step 6 branch each time; step 10 clear of sentRecordNames and zoneSaved makes prior-aborted-run events no-ops on resume; Stage A TripPersonSnapshot rows are retroactively dirty-flagged on Stage B entry (Req 4.9); signed-out completes the local relocation but defers Stage B (Req 4.7); relocation preserves every persisted field per Req 4.2
   - Stream: 1
   - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.5](requirements.md#4.5), [4.6](requirements.md#4.6), [4.7](requirements.md#4.7), [4.9](requirements.md#4.9), [4.10](requirements.md#4.10), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3), [7.4](requirements.md#7.4)
   - References: design.md § ZoneMigrationCoordinator (changed), § Concurrency and ordering contracts

-- [ ] 30. [impl] Implement ZoneMigrationCoordinator.relocateToTripsLocal(_:) helper <!-- id:ki2e5nm -->
+- [x] 30. [impl] Implement ZoneMigrationCoordinator.relocateToTripsLocal(_:) helper <!-- id:ki2e5nm -->
   - File: Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift
   - Reads the Trip + dependents (tasks, packingItems, participantSnapshots) from globalsContext and inserts equivalent rows into tripsLocalContext, preserving every persisted field per Req 4.2 (including id, masterItemID, currentlyMatchesRules, pinnedByUser, userDeletedOnThisTrip, state, isCompleted, assigneePersonID, name, ckRecordSystemFields, and all Codable-blob fields)
   - Commits tripsLocalContext first; only afterwards is the globals delete step run by the caller (per the per-container atomicity invariant)
   - Stream: 1
   - Requirements: [4.2](requirements.md#4.2)

-- [ ] 31. [impl] Update ZoneMigrationCoordinator.enqueueAll to scan both containers <!-- id:ki2e5nn -->
+- [x] 31. [impl] Update ZoneMigrationCoordinator.enqueueAll to scan both containers <!-- id:ki2e5nn -->
   - Fetch existing journal entries from globals (unchanged); fetch existing TripZoneState rows from tripsLocal (unchanged); fetch Trip rows from BOTH containers; enqueue a journal for any trip not yet covered by a journal AND not yet in TripZoneState; idempotent on repeat
   - Blocked-by: ki2e5nm ([impl] Implement ZoneMigrationCoordinator.relocateToTripsLocal(_:) helper)
   - Stream: 1
   - Requirements: [4.1](requirements.md#4.1), [4.6](requirements.md#4.6), [4.7](requirements.md#4.7)

-- [ ] 32. [impl] Rewrite ZoneMigrationCoordinator.startOrResume per the 15-step algorithm <!-- id:ki2e5no -->
+- [x] 32. [impl] Rewrite ZoneMigrationCoordinator.startOrResume per the 15-step algorithm <!-- id:ki2e5no -->
   - Steps 2 (.completed short-circuit), 6 (four-quadrant existence branch invoking relocateToTripsLocal and deleteFromGlobals as appropriate), 10 (clear sentRecordNames + zoneSaved before re-marking .stageBInProgress), 13 (retroactively dirty-flag Stage A snapshot rows), 14 (sequential tripsLocalContext.save() then globalsContext.save(), both @MainActor, no await between)
   - Blocked-by: ki2e5nl ([test] Extend ZoneMigrationCoordinatorTests for the 15-step relocation algorithm), ki2e5nm ([impl] Implement ZoneMigrationCoordinator.relocateToTripsLocal(_:) helper), ki2e5nn ([impl] Update ZoneMigrationCoordinator.enqueueAll to scan both containers)
   - Stream: 1
   - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.5](requirements.md#4.5), [4.6](requirements.md#4.6), [4.9](requirements.md#4.9), [4.10](requirements.md#4.10)

-- [ ] 33. [test] Write parameterised PBT for cross-store consistency + idempotence <!-- id:ki2e5np -->
+- [x] 33. [test] Write parameterised PBT for cross-store consistency + idempotence <!-- id:ki2e5np -->
   - File: ScrambleTests/Persistence/ZoneMigrationCoordinatorPBT.swift (new)
   - Use Swift Testing @Test(arguments:) over the cross-product interruptionPoint in {fresh, after-step-10-clear, after-tripsLocal-save, after-globals-delete, after-completion} × resumeCount in {1, 2, 5}; assert terminal state is either tripsLocal-only or globals-only for the trip and that the journal converges to a terminal state
   - Blocked-by: ki2e5no ([impl] Rewrite ZoneMigrationCoordinator.startOrResume per the 15-step algorithm)
@@ -257,7 +257,7 @@ references:
   - Requirements: [4.5](requirements.md#4.5), [4.6](requirements.md#4.6)
   - References: design.md § Property-based tests property 1

-- [ ] 34. [test] Write parameterised PBT for commitDeletion mixed-zone partition <!-- id:ki2e5nq -->
+- [x] 34. [test] Write parameterised PBT for commitDeletion mixed-zone partition <!-- id:ki2e5nq -->
   - File: ScrambleTests/Sharing/LocalWriteHookPBT.swift (new)
   - Use Swift Testing @Test(arguments:) over generators of (zoneIDsBeingDeleted, deleted_in_vanishing_zone, deleted_in_surviving_zone, changed_in_surviving_zone); assert post-commit surviving-zone pendingUploadFlags equal the surviving-only set and the notifier received the union of all deleted IDs
   - Blocked-by: ki2e5mz ([impl] Implement LocalWriteHook.commitDeletion(_:zoneIDsBeingDeleted:))
@@ -265,34 +265,34 @@ references:
   - Requirements: [2.4](requirements.md#2.4)
   - References: design.md § Property-based tests property 2

-- [ ] 35. [test] Write TripSyncEventBusTests <!-- id:ki2e5nr -->
+- [x] 35. [test] Write TripSyncEventBusTests <!-- id:ki2e5nr -->
   - File: ScrambleTests/Sharing/TripSyncEventBusTests.swift (new)
   - Cover: a single iteration multicasts every event to both registered subscribers; one subscriber throwing inside its handler is logged and does NOT cancel the bus or starve the other subscriber; late-subscribe-after-start in DEBUG triggers assertionFailure; test-only stop() cancels the iteration
   - Stream: 1
   - Requirements: [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3)
   - References: design.md § TripSyncEventBus (new)

-- [ ] 36. [impl] Implement Sharing/TripSyncEventBus.swift <!-- id:ki2e5ns -->
+- [x] 36. [impl] Implement Sharing/TripSyncEventBus.swift <!-- id:ki2e5ns -->
   - Owns a single Task iterating the engine events stream; subscribers register their handler via subscribeOrchestrator / subscribeCoordinator (with the documented late-register precondition: assertionFailure in DEBUG, log+return in release); each dispatch is wrapped in do { handler(event) } catch { modelLogger.error(...) }
   - Test-only stop() cancels the task
   - Blocked-by: ki2e5nr ([test] Write TripSyncEventBusTests)
   - Stream: 1
   - Requirements: [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3), [7.4](requirements.md#7.4)

-- [ ] 37. [test] Write SignInResumeCoordinatorTests <!-- id:ki2e5nt -->
+- [x] 37. [test] Write SignInResumeCoordinatorTests <!-- id:ki2e5nt -->
   - File: ScrambleTests/App/SignInResumeCoordinatorTests.swift (new)
   - Cover: start() immediately re-checks accountStatus() and runs when available; CKAccountChanged notification triggers a run; UIScene.didActivateNotification fallback also triggers a run; concurrent triggers collapse to a single trailing replay via the inFlight: Task? + pendingReplay: Bool mechanism; migrationCoordinator.isCloudAvailable() returning false short-circuits the run
   - Stream: 1
   - Requirements: [4.8](requirements.md#4.8)
   - References: design.md § SignInResumeCoordinator (new)

-- [ ] 38. [impl] Implement App/SignInResumeCoordinator.swift <!-- id:ki2e5nu -->
+- [x] 38. [impl] Implement App/SignInResumeCoordinator.swift <!-- id:ki2e5nu -->
   - @MainActor final class. Init takes migrationCoordinator: ZoneMigrationCoordinator and container: CKContainer. start() installs the two observers and performs the immediate accountStatus() re-check. runResumeIfNeeded() is the single entry point: checks isCloudAvailable(), runs enqueueAll + runStageB, and uses an inFlight: Task? + pendingReplay: Bool to collapse storm-fire to at most one trailing replay
   - Blocked-by: ki2e5nt ([test] Write SignInResumeCoordinatorTests)
   - Stream: 1
   - Requirements: [4.8](requirements.md#4.8)

-- [ ] 39. [wire] Wire ScrambleApp to construct the EventBus, SignInResumeCoordinator, and route MigrationGate through them <!-- id:ki2e5nv -->
+- [x] 39. [wire] Wire ScrambleApp to construct the EventBus, SignInResumeCoordinator, and route MigrationGate through them <!-- id:ki2e5nv -->
   - Files: Scramble/Scramble/ScrambleApp.swift, Scramble/Scramble/App/MigrationGate.swift
   - In ScrambleApp.init: construct TripSyncEventBus(events: engine.events); call bus.subscribeOrchestrator(triggerOrchestrator) and bus.subscribeCoordinator(migrationCoordinator) BEFORE bus.start(); construct SignInResumeCoordinator(migrationCoordinator:container:) and call start() after bus.start()
   - MigrationGate.prepare() awaits signInResumeCoordinator.runResumeIfNeeded() instead of calling enqueueAll/runStageB directly; the gate also logs a warning when the count of MigrationJournalEntry rows exceeds 100 (the journal accumulation back-stop)
CHANGELOG.md Modified +25 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d6f0448..9ce8327 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,8 +6,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

 ## [Unreleased]

+### Changed
+
+- Phase 5.1 — pre-push-review cleanup of the Phase 5 work:
+  - `Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift` — `globalsContext` / `tripsLocalContext` are now `private`; new `journalCount() throws -> Int` exposes the back-stop count without leaking the context. Steps 12 and 13 of `startOrResume` are merged — the in-memory `expectedRecordNames` set already covers every reachable snapshot, so the defensive second fetch is dropped. `deleteFromGlobals` now uses the explicit `packingItems → tasks → participantSnapshots → trip` reverse-cascade order to match the iOS 26.4 workaround documented in `TripDeletion`. `enqueueAll` uses `Set.union` instead of an explicit seen-set. New `zoneName(for:)` + `ownerZoneID(for:)` static helpers centralise the canonical zone naming.
+  - `Scramble/Scramble/Sharing/LocalWriteHook.swift`, `Scramble/Scramble/Sharing/Translators/TripZoneStateRecordTranslator.swift`, `Scramble/Scramble/Sharing/UITestSharingService.swift` — call `ZoneMigrationCoordinator.ownerZoneID(for:)` / `zoneName(for:)` instead of inlining `"trip-\(uuid)"`.
+  - `Scramble/Scramble/RulesEngine/RulesEngineTriggerOrchestrator.swift`, `Scramble/Scramble/Sharing/Translators/TripZoneStateRecordTranslator.swift` — drop the two duplicate private `parseTripID(from:)` helpers; both call sites now route through `ZoneMigrationCoordinator.parseTripID`.
+  - `Scramble/Scramble/App/SignInResumeCoordinator.swift` — `deinit` removes the two `NotificationCenter` observers it installs in `start()`; production lifetime is forever but the cleanup matters for tests that create+drop coordinators.
+  - `Scramble/Scramble/ScrambleApp.swift` — switches from `migrationCoordinator.globalsContext.fetchCount(...)` to `migrationCoordinator.journalCount()` for the back-stop visibility log.
+  - `Scramble/ScrambleTests/App/SignInResumeCoordinatorTests.swift` — storm-collapse test rewritten with a `ResumeGate` checked-continuation gate so the inflight run is sequenced deterministically; `delta == 1` assertion now holds without scheduling-jitter races.
+  - `CLAUDE.md` — project status updated to mention Phase 5.1 (container topology, chokepoint extension, 15-step migration coordinator, event bus, sign-in resume) so future agent sessions inherit the right context.
+  - `specs/phase-5.1-wire-trip-crud-tripslocal/decision_log.md` — adds Decision 7: `SignInResumeCoordinator.init` drops the unused `CKContainer` parameter; `migrationCoordinator.isCloudAvailable()` is the single source of truth for the availability check.
+
 ### Added

+- Phase 5.1 — migration coordinator + engine event multicast + sign-in resume (tasks 29–39):
+  - `Scramble/Scramble/Persistence/Migrations/ZoneMigrationCoordinator.swift` — `startOrResume` rewritten as the 15-step algorithm: `.completed` is a terminal no-op, four-quadrant existence branch over `(tripsLocal, globals)` decides whether to relocate and/or delete, step 10 clears stale `sentRecordNames` + `zoneSaved` before re-marking the journal `.stageBInProgress` so prior-aborted-run events do not contaminate the resume, retroactively dirty-flags every Stage A `TripPersonSnapshot` for the trip (Req 4.9), and the two `ModelContext.save()` calls are sequential `@MainActor` invocations with no `await` between so the engine cannot interleave. New `relocateToTripsLocal(tripID:)` helper (private) copies every persisted field — `id`, `masterItemID`, `currentlyMatchesRules`, `pinnedByUser`, `userDeletedOnThisTrip`, `state`, `isCompleted`, `assigneePersonID`, `name`, `ckRecordSystemFields`, every Codable-blob field — preserving `personSnapshot` references. `enqueueAll` now scans both containers so pre-Phase-5.1 trips still in `globals` get a journal entry.
+  - `Scramble/Scramble/Sharing/TripSyncEngine.swift` — adds `.zoneSaved`, `.recordsSaved`, `.recordsFailed` cases to `TripSyncEvent`. `handleSentChanges` emits `.recordsSaved` for `event.savedRecords` and `.recordsFailed` for `event.failedRecordSaves`. New `handleSentDatabaseChanges` emits one `.zoneSaved` per confirmed zone-save event so the coordinator can mark the journal's `zoneSaved` flag.
+  - `Scramble/Scramble/Sharing/TripSyncEventBus.swift` — owns the single iteration of `syncEngine.events` and dispatches to two named subscribers (orchestrator + coordinator). Handlers are throwing closures; each dispatch is wrapped in `do/catch` so a throwing handler is logged via `modelLogger.error` and the bus continues for the other subscriber. Late or duplicate registration trips `assertionFailure` in DEBUG, logs `fault` in release. Test-only `stop()` cancels the iteration task. Convenience overloads wire `RulesEngineTriggerOrchestrator` and `ZoneMigrationCoordinator` directly.
+  - `Scramble/Scramble/App/SignInResumeCoordinator.swift` — observes `CKAccountChanged` and `UIScene.didActivateNotification`. `start()` installs the observers and performs an immediate `isCloudAvailable()` re-check (handles "account became available before observer installed"). `runResumeIfNeeded` uses an `inFlight: Task<Void, Never>?` plus `pendingReplay: Bool` flag to collapse storm-fire to exactly one trailing replay regardless of trigger volume. Production init takes a `ZoneMigrationCoordinator`; test init takes the two closures directly so tests can drive the collapse logic without a real coordinator.
+  - `Scramble/Scramble/ScrambleApp.swift` — constructs `TripSyncEventBus` and `SignInResumeCoordinator` in `init`; registers orchestrator + coordinator subscribers BEFORE `bus.start()` so no events are missed. `prepareLaunch` starts the bus, then the sign-in resume coordinator (which performs the initial enqueue + Stage B through its single in-flight pipeline), logs a `warning` if `MigrationJournalEntry` count exceeds 100 (back-stop visibility), and finally calls `syncEngine.start()`. Drops the previous direct `for await event in engine.events` loop — the bus owns iteration now.
+  - `Scramble/Scramble/RulesEngine/RulesEngineTriggerOrchestrator.swift` — `handle(event:)` switch updated to acknowledge the three new event cases (no-op for the orchestrator; the coordinator subscriber handles them).
+  - `Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorTests.swift` — rewritten to use two distinct in-memory containers (`globals` + `tripsLocal`) so the four-quadrant algorithm behaves like production. New `makeSetup()` / `coordinatorOnly(from:)` helpers; new "signed-out launch defers Stage B then resumes on sign-in" test (Req 4.7/4.8).
+  - `Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPhase51Tests.swift` — new suite for Phase 5.1-specific coverage: `.completed` terminal no-op (step 2), four-quadrant branch (`(none, some)`, `(some, some)`, `(some, none)`, `(none, none)`), relocation preserves every persisted field (Req 4.2), Stage A snapshot retroactive dirty-flagging (Req 4.9), step-10 clear of `sentRecordNames` + `zoneSaved` on resume.
+  - `Scramble/ScrambleTests/Persistence/ZoneMigrationCoordinatorPBT.swift` — Swift Testing `@Test(arguments:)` PBT over the cross-product of `InterruptionPoint ∈ {fresh, afterStep10Clear, afterTripsLocalSave, afterGlobalsDelete, afterCompletion}` × `resumeCount ∈ {1, 2, 5}`. Asserts cross-store consistency ([C2](specs/phase-5.1-wire-trip-crud-tripslocal/requirements.md#C2)) and idempotence ([Req 4.6](specs/phase-5.1-wire-trip-crud-tripslocal/requirements.md#4.6)): terminal state is exactly one of (trip in tripsLocal only) or (trip in globals only, `.completed` only); no duplicate journal entries.
+  - `Scramble/ScrambleTests/Sharing/LocalWriteHookPBT.swift` — PBT for `commitDeletion` mixed-zone partition. Cross-product over `(vanishingZoneCount, survivingZoneCount, deletedPerVanishing, deletedPerSurviving, changedPerSurviving)` asserts surviving-zone `TripZoneState.pendingUploadFlags` carries only surviving-zone deltas and the notifier received the union of all deleted record IDs across both partitions.
+  - `Scramble/ScrambleTests/Sharing/TripSyncEventBusTests.swift` — covers multicast to both subscribers, throwing-subscriber isolation (a handler that throws is logged via `modelLogger.error` and the other subscriber still receives the event), `stop()` cancels the iteration task.
+  - `Scramble/ScrambleTests/App/SignInResumeCoordinatorTests.swift` — covers immediate run on `start()` when available; short-circuit when unavailable; `CKAccountChanged` triggers a run; `UIScene.didActivateNotification` fallback triggers a run; storm-fire collapses to exactly one trailing replay (`delta == 1` strict, not `≤ 2`).
+
 - Phase 5.1 — write-path migration (tasks 20–28):
   - `Scramble/Scramble/Sharing/CloudKitSharingService.swift` — `createShare`, `leaveShare`, and the lazy `fetchZoneState` save now route through `LocalWriteHook.commit(_:)`. `deleteOwnedTrip` and the private `cleanupLocalState` are deleted (`TripDeletion.delete` covers their behaviour). `leaveShare` tolerates `CKError.zoneNotFound` from the shared-DB delete (covers the case where the owner revoked the share concurrently) and then calls `TripDeletion.delete(tripID:in:hook:zoneDeleter: nil)` for the local cascade. Constructor gains a `hook: LocalWriteHook` parameter.
   - `Scramble/Scramble/Sharing/SharingService.swift` — protocol drops `deleteOwnedTrip(forTrip:)`; `TripDeletion.delete(tripID:in:hook:zoneDeleter:)` is the single Trip-domain deletion entry point.
CLAUDE.md Modified +1 / -1
diff --git a/CLAUDE.md b/CLAUDE.md
index 27ad813..949b31a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

 ## Project status

-Scramble is a native iOS app (macOS planned later) for trip planning, packing, and shared family coordination via CloudKit. Phases 1–5 have landed: SwiftData model + CloudKit-aware container, Theme system + Midnight Atlas variants, two-tab app shell, Trip CRUD with inline person creation, deterministic rules engine + Master Lists UI, timeline accordion + Trip-level Tasks, the per-person Packing Sheet with pack / repack modes and the `WhyDisclosure` explainability surface, and Phase 5's CloudKit sharing infrastructure — `SchemaV3` with `TripPersonSnapshot` / `TripZoneState` / `MigrationJournalEntry`, dual SwiftData containers (globals + tripsLocal), Stage A + Stage B migration pipeline, `TripSyncEngine` over two `CKSyncEngine` instances, `CloudKitSharingService` + `UICloudSharingController` wrapper, the Share toolbar button and Trip Detail Participants section, silent-push routing, and the release-prep CloudKit-schema-promotion checklist. See `specs/phase-1-foundation/` through `specs/phase-5-cloudkit-sharing/` for what shipped and `docs/agent-notes/` (`persistence.md`, `rules-engine.md`, `packing-sheet.md`, `sync-infrastructure.md`, `phase-5-ui-surfaces.md`) for load-bearing implementation notes.
+Scramble is a native iOS app (macOS planned later) for trip planning, packing, and shared family coordination via CloudKit. Phases 1–5 have landed: SwiftData model + CloudKit-aware container, Theme system + Midnight Atlas variants, two-tab app shell, Trip CRUD with inline person creation, deterministic rules engine + Master Lists UI, timeline accordion + Trip-level Tasks, the per-person Packing Sheet with pack / repack modes and the `WhyDisclosure` explainability surface, and Phase 5's CloudKit sharing infrastructure — `SchemaV3` with `TripPersonSnapshot` / `TripZoneState` / `MigrationJournalEntry`, dual SwiftData containers (globals + tripsLocal), Stage A + Stage B migration pipeline, `TripSyncEngine` over two `CKSyncEngine` instances, `CloudKitSharingService` + `UICloudSharingController` wrapper, the Share toolbar button and Trip Detail Participants section, silent-push routing, and the release-prep CloudKit-schema-promotion checklist. Phase 5.1 then routed Trip CRUD through `tripsLocal` so the sharing pipeline actually carries edits — container topology + chokepoint extension (`LocalWriteHook.commitDeletion`) + view-layer read/write conversion to `TripPersonSnapshot` + the 15-step `ZoneMigrationCoordinator` relocation algorithm with `TripSyncEventBus` multicast and `SignInResumeCoordinator` storm-collapse all landed. See `specs/phase-1-foundation/` through `specs/phase-5.1-wire-trip-crud-tripslocal/` for what shipped and `docs/agent-notes/` (`persistence.md`, `rules-engine.md`, `packing-sheet.md`, `sync-infrastructure.md`, `phase-5-ui-surfaces.md`) for load-bearing implementation notes.

 Stack: iOS 26+, Swift 6.0 language mode (Xcode 26 toolchain), SwiftUI, SwiftData, CloudKit (CKShare for per-trip sharing). Bundle ID `me.nore.ig.Scramble`, CloudKit container `iCloud.me.nore.ig.scramble`. Project lives at `Scramble/Scramble.xcodeproj` (nested folder, standard Xcode layout).

Things to double-check

Step-10 clear semantics

The non-obvious correctness argument: clearing journal.sentRecordNames + journal.zoneSaved before re-marking .stageBInProgress is what makes engine events from a prior aborted run no-ops on resume. The records will be re-sent (because they're back in pendingUploadFlags) and the new sentRecordNames accumulation starts from zero. A reviewer should verify that step 10 happens BEFORE the re-mark on line 252, not after.

Cross-store atomicity

Step 14's tripsLocalContext.save() then globalsContext.save() must remain sequential with no await between. If a future contributor inserts an await there (e.g., to log progress, or to call an async API), engine events can interleave and the journal can observe a half-completed relocation. This is the load-bearing concurrency invariant for the whole phase.

Subscribers registered before bus.start()

ScrambleApp.init calls bus.subscribeOrchestrator(orchestrator) and bus.subscribeCoordinator(coordinator) BEFORE bus.start() in prepareLaunch. If a future contributor moves a subscription into prepareLaunch after the start call, the bus will trip assertionFailure in DEBUG — but in release the late subscription is silently rejected and the affected consumer never sees events. The order in init is load-bearing.