PR #234 by @ArjenSchwarz · merging T-1936/bugfix-fallback-container-sync-mode → main · view on GitHub
Ready to merge
No findings. The fallback path now records the actual CloudKit mode selected by ContainerFactory before constructing display-ID services. Production, regression-test, and bugfix-report blobs are byte-identical to reviewed f7b0b00; the only review-resolution change is the T-1936 changelog placement on the current base.
Shown verbatim — the markdown the author wrote, unmodified.
## Summary - Derive the effective CloudKit mode after `ContainerFactory` selects the live container. - Record fallback mode as inactive before display-ID allocator and connectivity-promotion wiring. - Add real-fallback regression coverage proving interactive writes remain provisional and both counters stay untouched. ## Root cause The bootstrap previously captured the sync preference before primary container creation. A fallback replaced that configuration with CloudKit-free in-memory storage but left display-ID services active. ## Validation - `make build-macos` ✅ - `make lint` ✅ - `make test-quick` ⚠️ blocked before execution by unrelated untracked `AddTaskSaveLifecycleTests.swift` compilation errors in the shared checkout (mutating calls inside `#expect` autoclosures). See `specs/bugfixes/fallback-container-sync-mode/report.md`.
c8c7425 T-1936: Fix fallback container sync mode 4113cfe T-1936: Place changelog entry under Unreleased If Transit cannot open its normal database, it uses temporary storage. This change ensures tasks created there get temporary IDs too, instead of accidentally using permanent CloudKit IDs.
The bootstrap now calculates the effective sync mode from both the requested preference and the actual ContainerFactory outcome. That one value gates both task/milestone allocators and connectivity-driven ID promotion.
ContainerOutcome.error != nil is the authoritative fallback signal: ContainerFactory always constructs its fallback with cloudKitDatabase: .none. Recording false before allocator construction preserves existing fail-closed guards without adding parallel fallback state.
Transit/Transit/Services/CloudSyncBootstrap.swift
Why it matters. Prevents direct CloudKit counter access when the requested CloudKit container has been replaced by a temporary fallback.
What to look at. CloudSyncBootstrap.effectiveActiveCloudSync(_:containerOutcome:)
Transit/Transit/TransitApp.swift
Why it matters. Both display-ID allocators and connectivity promotion receive the same fail-closed mode before they can perform CloudKit work.
What to look at. TransitApp.init() container bootstrap
Transit/TransitTests/SyncDisabledGatingTests.swift
Why it matters. Proves fallback task and milestone writes remain provisional and neither counter store is accessed, including promotion paths.
What to look at. fallbackOutcomeDisablesCounterUseWhileInteractiveWritesRemainAvailable()
Do not add a separate fallback flag to allocators or promotion callers. The existing isCloudSyncActive invariant already governs every relevant path, so derive it from the selected container once.
Temporary interactive storage remains usable, but records stay provisional. Existing persistence-availability protections continue to reject automation writes.
Click to expand.
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 8c4c943..f545349 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -1,90 +1,91 @@ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] - T-1820: MCP JSON-RPC notification regressions now prove that valid mutating `tools/call` notifications execute their normal side effects while suppressing only their response. Coverage spans direct handler dispatch, successful and failing single HTTP notifications that return 202/no body, ordered mixed batches where a following valid request observes the mutation while notification failures remain response-suppressed, and a batched `initialize` notification that cannot block the following valid request. The MCP route, batch, and notification suites now share one in-process HTTP transport harness. Explicit-null invalid requests, standalone lifecycle rules, all-notification HTTP semantics, and valid request responses remain covered. - T-1898: macOS New Task now retains its in-flight save task and cancels it only when the window/view disappears before persistence completes. The shared T-1858 create-save lifecycle records success before dismissal, so successful creation is not cancelled by its own disappearance; `CancellationError` returns silently while real failures retain the form for retry. iOS dismissal blocking, T-2037 milestone validation, and `TaskService` cancellation safeguards remain intact. A reopened singleton window visibly disables Save while a prior cancellation settles, rather than accepting a Save action that cannot start. Deterministic gated-allocation tests prove a dismissed create inserts no task, alongside successful-dismissal, cancellation-pending, and retry coverage.+- T-1936: When the primary SwiftData store cannot open, Transit now derives the effective CloudKit mode from `ContainerFactory`’s fallback outcome rather than retaining the requested sync preference. It records sync inactive before constructing display-ID allocators and promotion wiring, so temporary interactive task/milestone writes remain provisional and cannot advance CloudKit counters. Existing fallback automation-write restrictions and healthy/preference-disabled launch behavior are unchanged. - T-1816: Regression coverage now directly proves that `query_tasks` validates malformed `status`, `not_status`, `type`, `priority`, `unfinished`, `search`, and `displayId` filters before either a missing milestone name or missing milestone display ID can return a successful no-match `[]`. This pins the validation order introduced by T-1608 / PR #217 without changing its production behavior. - T-1749: Visual Add Task no longer reports `NO_PROJECTS` when it cannot read the project table while resolving an unselected project. The existence check now propagates its SwiftData error through the existing injectable fetch seam, and the visual intent returns `INTERNAL_ERROR` with the storage-failure guidance. Genuine empty stores still return `NO_PROJECTS`; stale selected projects remain on the separate `PROJECT_NOT_FOUND` / storage-failure path. - T-1711: MCP `get_projects` now returns the exact tool error `Failed to fetch milestones: <error>` when a project-scoped milestone fetch fails, rather than a successful partial list that omits milestone metadata. The scoped service fetch preserves its injected storage error; deterministic regressions cover the failure, an empty project's omitted `milestones` field, and correctly scoped milestones for another project. - T-1858: New Milestone now retains its asynchronous create operation for the editor lifetime. iOS disables its back and interactive dismissal controls while a create is in flight; iOS/macOS disappearance cancels only a still-pending create, allowing T-1765's persistence-boundary cancellation check to prevent a ghost milestone. A successful create transitions to a distinct pre-dismissal state before calling `dismiss()`, so the successful dismissal never cancels itself. Focused lifecycle tests cover in-flight disappearance, successful dismissal, save-error retry, and cancellation completion; edit-mode merge/conflict behavior is unchanged. - T-1699: Sync heartbeat fetch failures no longer create duplicate `SyncHeartbeat` singleton rows. `SyncManager` now logs and skips a beat when its singleton fetch fails, then retries on the existing next timer interval; a successful empty fetch still creates the record and a successful existing fetch still updates it. The save remains best-effort and timer lifecycle is unchanged. Deterministic `SyncManagerTests` verify zero insertion, later recovery, and the existing/missing cases. - T-1838: Open report views now refresh relative ranges at the next relevant local calendar boundary and whenever the scene returns active. Report filtering and labels share one captured time/calendar snapshot, so they cannot straddle a boundary; scheduling is DST- and calendar-aware, uses a cancellable one-shot task rather than a polling timer, and leaves App Intent and absolute-range behavior unchanged. - T-1675: Project-scoped milestone-name lookups now preserve the exact SwiftData fetch failure through shared assignment as `INTERNAL_ERROR` (`Failed to look up milestone: <error>`), matching JSON/MCP create, update, and scoped-query adapters. The milestone service's injected fetcher is now available in MCP test setup; deterministic regressions prove no-match and ambiguity remain distinct, cross-project unscoped filtering is untouched, and failed lookups create or mutate nothing. - T-1825: Dashboard milestone filters now live-observe local, MCP, and CloudKit milestone changes, retaining selected Done or Abandoned rows within the active project scope so they remain visible and individually deselectable. Open rows remain first and follow their displayed title order; terminal selections are appended deterministically and deduplicated by UUID. Selected rows expose the native selected accessibility trait once, while inaccessible or deleted selections still leave Clear available. Add Task remains open-only. - T-1657: Project lookup storage failures now remain distinguishable from missing projects across JSON intents, MCP create/query/milestone tools, and visual Add Task. `QueryMilestonesIntent` no longer returns a successful empty array when its project-name lookup is unreadable, while valid missing/ambiguous names and project-ID precedence retain their prior behavior. Visual Add Task now reports `INTERNAL_ERROR` for a failed project read rather than a stale-selection `PROJECT_NOT_FOUND`; deterministic failing-fetch regressions verify cross-surface parity and no task or milestone insertion. - T-1800: Open task details now observe a task-scoped SwiftData comment query instead of a one-time snapshot, so MCP and local insertions/deletions update both the visible comment list and Share export without reopening the view. The query filters through the CloudKit-compatible optional child relationship and uses creation date plus UUID for stable ordering. - T-1770: JSON mutation intents now preserve their established typed validation and domain-error payloads while mapping untyped SwiftData fetch/save failures to `INTERNAL_ERROR`. Shared task and milestone identifier resolution uses deterministic injected fetchers; milestone mutations also expose an injected saver. Regression coverage spans create, status update, milestone update, and deletion paths, asserting both the internal-error envelope and no persisted mutation on failure. - T-1803: `UpdateStatusIntent` now includes a missing requested `displayId` in its `TASK_NOT_FOUND` hint (`No task with displayId N`), while missing UUIDs retain the existing generic lookup hint and malformed identifiers, duplicate IDs, status validation, and atomic mutation behavior remain unchanged. ### Fixed - T-1628: `make test-quick` and every relevant source-building Makefile target now keep Clang module caches and SwiftPM manifest modules/diagnostics workspace-local. `CLANG_MODULE_CACHE_PATH` is passed as an Xcode build setting, `SWIFTPM_MODULECACHE_OVERRIDE` is exported for manifest compilation, and the cache guard now asserts these supported controls while explicitly preserving the intentional omission of `-clonedSourcePackagesDirPath` and `-packageCachePath` so package resolution continues to work. Xcode recipes also explicitly enable `pipefail`, preventing `xcbeautify` from masking build failures on macOS's bundled GNU Make 3.81. - T-1613: MCP `query_tasks` now returns the exact tool error `Failed to fetch comments: <error>` when comment serialization cannot read storage, rather than reporting a successful task with `comments: []`. The detailed display-ID and task-list paths share this throwing serialization boundary; genuine empty comment collections retain their successful `comments: []` response. `update_task_status` continues serializing the `Comment` returned by its atomic mutation directly (T-1823), so it performs no post-commit comment fetch that could prompt a retry or duplicate a persisted comment. Deterministic MCP regressions cover both query errors, legitimate empty comments, and zero status-response fetches. - T-1620: `GenerateReportIntent` now returns the established `INTERNAL_ERROR` JSON envelope with a source-specific stable hint when terminal task or milestone fetches fail, instead of false empty-report Markdown. Successful empty reports retain their existing Markdown and date-range formatting; deterministic regressions cover both failures and the valid-empty response. - T-2037: Add Task observes the selected project's milestones and clears a selection that leaves the open picker options. It also revalidates an optional milestone at the final pre-insertion boundary using both live and fresh committed SwiftData state. A milestone closed in another window/context while display-ID allocation awaits now rejects with a user-facing error before any task insertion; nil milestones, same-project validation, one-save task/milestone creation, and dashboard terminal-filter behavior remain unchanged. Deterministic two-context regressions cover both Done and Abandoned transitions and verify no pending or committed task survives. - T-1608: MCP `query_tasks` now surfaces a tool error rather than a false successful `[]` when either unscoped milestone-name resolution (`Failed to fetch milestones: <error>`) or `milestoneDisplayId` resolution (`Failed to look up milestone: <error>`) cannot read storage. It validates every filter shape before milestone resolution, so malformed filters return their validation error before any milestone lookup outcome. Valid no-match arrays, cross-project same-name aggregation, project-scoped milestone lookup and T-1938 ambiguity handling, response shape, and later task-fetch errors are unchanged; deterministic MCP coverage asserts both exact errors and the legitimate no-match response. - T-1607: Visual App Entity project/task queries and task-creation result resolution now rethrow storage fetch failures through their existing `async throws` contract instead of returning successful empty arrays. Deterministic regression coverage spans `entities(for:)` and `suggestedEntities()` for all three query types, while valid empty/invalid identifier results, ordering, suggestion limits, and partial relationship skipping remain unchanged. - Display-ID collision guards now build candidate-blocking sets from committed task/milestone IDs fetched through a fresh transient `ModelContext`, unioned with live/pending registered main-context values and allocator-issued IDs (T-1939). The shared guard applies to task and milestone creation, provisional promotion, and duplicate repair, so a stale registered bystander can no longer hide a peer-synced committed ID. Regression coverage commits the peer value through an independent context, proves the receiving bystander remains clean and unrefreshed, and separately verifies unsaved IDs remain blocked. Either store view failing still fails closed; allocation serialization, cancellation, maintenance stale-loser probes, and selective save recovery are unchanged. - MCP `create_task` and JSON `CreateTaskIntent` now reject present non-object `metadata` values (string, array, number, boolean, or null) with field-specific errors before task insertion, while omitted metadata and T-723's tolerant handling of values inside valid objects remain unchanged (T-1991). - T-2036: `QueryTasksIntent` now treats empty `completionDate` and `lastStatusChangeDate` objects as valid no-op filters, including when both are present. Raw-object emptiness is preserved before Codable decoding, while nested nulls, malformed non-empty objects, strict dates, reversed ranges, open bounds, and relative precedence remain covered by regression tests. - MCP request validation now rejects explicit JSON `id: null` as JSON-RPC `-32600 Invalid Request` under protocol `2025-03-26`, while preserving omitted-ID notifications and valid string/integer IDs across single and batch requests (T-1863). - MCP `tools/call` now returns JSON-RPC `-32602 Invalid Params` for unknown tool names and maintenance tools disabled in Settings (T-1883), while retaining useful messages, omitting disabled tools from `tools/list`, and preserving `-32601 Method Not Found` for unsupported top-level methods. Handler and HTTP-route regressions cover the classification boundaries. - T-1819: `QueryTasksIntent` now rejects reversed absolute `from`/`to` date ranges for both `completionDate` and `lastStatusChangeDate` with `INVALID_INPUT`, matching visual `FindTasksIntent` behavior. One-sided/open bounds, equal same-day inclusive ranges, relative-filter precedence, strict T-1799 calendar-date parsing, and current calendar/time-zone semantics remain unchanged; cross-surface regressions cover both date fields. - T-1799: QueryTasksIntent absolute date filters now require exact ASCII `YYYY-MM-DD` values representing valid dates in the user's current calendar. Local `Calendar.current` parsing, non-lenient formatter round-trip validation, and regressions reject normalized invalid days/months, non-padded values, and non-ASCII input while preserving inclusive ranges and relative filters. - MCP Streamable HTTP now returns `405 Method Not Allowed` with `Allow: POST` for `GET /mcp` when no SSE listening stream is offered, while preserving POST handling, origin validation, and unrelated-route 404 behavior (T-1835). - MCP `query_tasks` now verifies that a well-formed `projectId` identifies an existing project before filtering (T-1783). Nonexistent project IDs return the established project-not-found error, while malformed UUID validation and other filters remain unchanged; regression coverage also checks parity with `QueryTasksIntent`. - `AddTaskIntent` now distinguishes an empty-database/no-selection path (`NO_PROJECTS`) from a stale selected `ProjectEntity` (`PROJECT_NOT_FOUND`) even when the deleted project was the last project in the store (T-1814). The required App Intents project parameter contract is preserved; missing project input against a non-empty store returns `INVALID_INPUT`. Entity resolution happens before task creation, and focused regressions assert exact error cases and no task mutation. - MCP `create_task`, `create_milestone`, `CreateTaskIntent`, and `CreateMilestoneIntent` now distinguish missing required fields from present non-string `name`/`type` values (T-1598). Numeric, boolean, array, object, and null inputs return field-specific `name must be a string` / `type must be a string` validation errors before mutation, while missing-field and invalid-string-type behavior remains unchanged. Symmetric regression coverage verifies all four create paths and confirms malformed requests create no records. - `QueryTasksIntent` now rejects explicit JSON `null` in nested `completionDate` and `lastStatusChangeDate` fields (`relative`, `from`, and `to`) before Codable decoding can erase presence. Malformed nested date-filter shapes retain the established `INVALID_INPUT` contract, omitted fields remain unchanged, and regressions cover both filters and no-broadening behavior (T-1644). - `Color.hexString` now rounds resolved RGB components to the nearest byte after finite-checking and clamping instead of truncating values just below byte boundaries (T-1807). Project colors no longer darken when an editor saves an unchanged color. Regression coverage exercises every `0...255` RGB value, deterministic sRGB resolved components immediately below each boundary, and the existing uppercase six-digit RGB/no-alpha format. - T-1802: Compact-width portrait dashboard layouts now always use the segmented single-column view, including widths at and above 400 points. Landscape, iPad, and Mac continue to use geometry-based column adaptation through a focused layout-selection helper and regression coverage. - MCP `update_task_status` now returns the exact comment created by its atomic status-plus-comment mutation (T-1823). `TaskService.updateStatus` propagates the created `Comment` to the handler for direct serialization instead of refetching every task comment and taking the last `creationDate`; a future-dated existing or concurrently imported comment can no longer replace the mutation result. If the atomic save fails, the newly inserted comment is deleted before the task state is rolled back so a later save cannot resurrect it. Regressions verify exact UUID propagation, future-clock-skew behavior, and service/MCP failure recovery. - Cross-device task and milestone promotion now re-probes committed SwiftData state after display-ID allocation and skips assignment when a peer has already supplied a permanent ID (T-2020). Missing or unreadable records fail closed, save-failure recovery preserves peer-merged values, and deterministic two-context tests pin both record types. Because SwiftData has no field-level compare-and-set, this closes the peer-merge-during-await window but cannot prevent two devices whose final local probes both precede either CloudKit merge from racing; the limitation and owner-reservation alternative are documented. - MCP `initialize` now requires object params with string `protocolVersion`, object `capabilities`, and typed `clientInfo` identity fields (T-1778). Missing or malformed method parameters return JSON-RPC `-32602 Invalid Params`. JSON-RPC request decoding now rejects scalar/null top-level `params` server-wide with `-32600 Invalid Request`. Supported `2025-03-26` requests are echoed and unsupported versions receive the server's latest advertised supported version. Focused handshake tests cover absent params/fields, wrong types, malformed client information, request-shape errors, and both negotiation paths. - MCP server start, stop, and same-port restart now run through one async desired-state lifecycle coordinator (T-1826). Each active listener owns its Hummingbird `ServiceGroup` and run task; teardown explicitly triggers graceful shutdown and awaits the group before rebinding, rather than assuming cancellation completion releases the socket. Graceful shutdown is time-bounded so a stalled request cannot wedge the coordinator, and the group installs no SIGTERM/SIGINT traps (`runService()`'s default) because those change the process's signal disposition permanently. Requests arriving during teardown replace the pending target so a burst converges on the latest state, Settings uses an explicit restart operation, and live loopback regressions cover stop, 20 serialized same-port repetitions, different-port restart, rapid off/on, an occupied port surfacing the bind failure, an invalid port releasing the running listener, bounded shutdown escalation, empty signal configuration, and reusable bind-probe behavior. - Already-cancelled task and milestone creates now stop before an uncontended display-ID allocation, and cancellation during a successful non-cooperative allocation is re-checked before SwiftData insertion (T-1765). `AllocationGate` checks cancellation once it holds the lock and before running the allocation body, while both create services guard their persistence boundary; existing selective `insertOrDelete` save-failure cleanup is unchanged. The gate check is shared by every allocator caller, so a cancelled display-ID promotion or duplicate-cleanup pass now stops at the current record and retries on the next pass. If the counter advance already succeeded, cancellation may leave a harmless gap in the display-ID sequence rather than persisting a ghost record. Four deterministic regressions cover the new timing windows across tasks and milestones, and the two retained queued-cancellation cases now prove that their contender entered and was removed from the gate queue. - The MCP Streamable HTTP endpoint now accepts non-empty JSON-RPC request/notification batches for advertised protocol `2025-03-26` (T-1834). It decodes each member independently, dispatches requests and notifications sequentially, omits notification responses, preserves response-array semantics, returns HTTP 202 with no body for all-notification batches, emits per-member `-32600` errors for invalid entries, handles empty batches as one invalid-request object, and rejects lifecycle-invalid batched `initialize` calls. Route-level regression tests cover each behavior and unchanged single-request responses. - MCP `create_task`, `CreateTaskIntent`, and `AddTaskSheet.persist` now create a task plus optional milestone atomically in one `TaskService` save (T-1768). The relationship is validated and attached before insertion; failed saves delete the pending aggregate instead of relying on a second, fallible compensating deletion. Surface-level failure regressions verify no task remains or is resurrected by a later save. `CreateTaskIntent` maps the merged save's failures per error kind, so a storage failure that was previously reported as `INTERNAL_ERROR` by the separate milestone-assignment step still reports `INTERNAL_ERROR` rather than collapsing into `INVALID_INPUT`; `milestoneProjectMismatch` from the service boundary surfaces as `MILESTONE_PROJECT_MISMATCH`. - Duplicate cleanup now re-probes a task or milestone loser's committed display ID after asynchronous allocation and immediately before mutation (T-2019). Peer changes that land during allocation are preserved and reported as `stale-id`; task cleanup no longer emits a false audit comment for an overwritten peer repair. Deterministic gated regressions cover both record types and the task audit path. - Editor conflict choices now protect against unseen concurrent changes across task, project, and milestone editors (T-1935). **Use Updated Values** rebases the full draft onto current live values while preserving only genuine non-conflicting user edits, and **Keep My Changes** authorizes only the exact conflict field values shown; either action re-alerts when the conflict snapshot changes. - SwiftData test fixtures now retain their backing `ModelContainer` for the full test lifetime (T-2003). `TestModelContainer` is a container-owning fixture instead of a bare-context factory, 207 context-acquisition call sites across 92 test files were migrated, and bespoke context helpers in task-entity, task-creation-result, and report tests now delegate to the shared fixture. An escaped-context lifetime regression plus an executable ownership guard with positive/negative fixtures prevent raw container/context factories from recurring. - Cross-device milestone name conflicts no longer make name-based operations target an arbitrary record (T-1938). `MilestoneService.findByName` now throws on multiple project-scoped matches; all MCP and App Intent callers report the ambiguity (`AMBIGUOUS_MILESTONE` for intents). Launch/foreground/connectivity maintenance deterministically keeps the oldest milestone name and renames other UUID-distinct records with UUID-derived suffixes, preserving records and task assignments. Cross-context tests simulate CloudKit imports and verify ambiguity reporting, reconciliation, idempotence, and data preservation. - Task mutation surfaces no longer hide duplicate task display IDs behind a not-found error (T-1837). `TaskService.findByDisplayID` already threw `duplicateDisplayID` when several tasks share a `permanentDisplayId` (possible under CloudKit, which cannot enforce uniqueness), but MCP `update_task_status` / `update_task` / `add_comment`, `UpdateStatusIntent`, `IntentHelpers.resolveTask`, and the visual `AddCommentIntent` all collapsed it into `TASK_NOT_FOUND` / "Provide either displayId or taskId", so operators could not tell missing data from store corruption needing display-ID maintenance. Duplicates now report `INTERNAL_ERROR` with the hint `Duplicate task identifier for displayId N` (App Intents — matching the milestone paths and the T-1097 `QueryTasksIntent` fix) or an `isError` result reading `Duplicate task identifier detected for displayId N` (MCP — matching the milestone tools). MCP `query_tasks` uses the same wording instead of leaking `Lookup failed: duplicateDisplayID`. No new error code was introduced. The three MCP call sites now share `resolveTaskArgument`, `UpdateStatusIntent` routes through `IntentHelpers.resolveTask` instead of its own copy of the mapping, and `VisualIntentError` gains a `duplicateIdentifier` case (code `INTERNAL_ERROR`). Regression coverage in `DuplicateTaskDisplayIDIntentTests` and `MCPDuplicateTaskDisplayIDTests`. - The MCP `add_comment` handler now distinguishes a missing required field from a present-but-non-string one for `content` and `authorName` (T-1579). Previously `guard let value = args[key] as? String, !value.isEmpty` collapsed numbers, booleans, arrays, and null into `Missing required argument: content` / `Missing required argument: authorName`, misleading callers into thinking they omitted a field they actually supplied with the wrong type. A present non-string value now returns the field-specific `content must be a string` / `authorName must be a string` before any task resolution or comment creation, matching the hardened pattern used by `update_task_status` after T-1205. Extracted a `requiredString(_:key:)` helper to keep `handleAddComment` within the cyclomatic-complexity limit. Regression coverage added in `MCPAddCommentTypeValidationTests` (numeric/boolean/array/null `content` and `authorName`). - `IntentHelpers.resolveMilestone` now rejects a present-but-non-string `name` identifier (number, boolean, null, array) with `INVALID_INPUT` and the field-specific hint `name must be a string`, instead of treating the failed `as? String` cast as an absent identifier and returning the generic `Provide displayId, milestoneId, or name with project` hint (T-1572). This only affects the `UpdateMilestoneIntent` name+project identifier path; no update fields are applied when the identifier is malformed. The MCP `update_milestone` tool is unaffected (it does not support name-based identification). Regression tests added to `MilestoneRenameTypeValidationTests`. - The MCP `update_milestone` handler and `UpdateMilestoneIntent` now support clearing a milestone description by passing an empty or whitespace-only `description` string, mirroring `update_task` and the milestone edit UI (T-1555). Previously a present `description` was applied verbatim, so `""`/whitespace persisted an empty string and the MCP response kept emitting a `description` key. Non-empty values are now trimmed before storage, and a cleared description sets `milestoneDescription` to nil (the MCP `milestoneToDict` serializer already omits the key when nil). For cross-surface parity, `UpdateMilestoneIntent`'s response now also emits `description` when set and omits it when cleared (previously it never emitted the key). T-343 had fixed only the edit-UI path. Cross-surface regression tests added in `MCPMilestoneClearDescriptionTests` and `UpdateMilestoneClearDescIntentTests`. - MCP `update_task` apply/save failure messages are now uniformly worded `"Update failed: \(error)"` (T-650 phase 4). Previously the handler emitted three separate templates (`"Failed to save: ..."`, `"Failed to set milestone: ..."`, `"Failed to clear milestone: ..."`); none of these are referenced by existing tests, but agents matching the old strings will see new wording. Validation, identifier-resolution, and milestone error messages are unchanged. - `QueryTasksIntentTests` no longer fails to compile after T-1146 added a required `milestoneService:` argument to `QueryTasksIntent.execute`; the missing argument is now supplied at the one stale call site so the test target builds. - Milestone status updates are now idempotent: re-sending the current status no longer rewrites `lastStatusChangeDate` and `completionDate`, so old terminal milestones no longer re-enter the current report window via `ReportLogic`'s `completionDate ?? lastStatusChangeDate` fallback. Applied across `MilestoneService.updateStatus`, `UpdateMilestoneIntent.applyUpdate`, and `MCPToolHandler.applyMilestoneUpdate` (T-923). - `DisplayIDMaintenanceService.reassignDuplicates` now surfaces a fetch failure as a counter-advance warning on both record types instead of silently no-oping the run with empty arrays. - `DisplayIDMaintenanceService` group processing no longer fabricates winner identity (`UUID()` / `""`) for the unreachable empty-group case; the invariant is asserted via `preconditionFailure` and the existing `RecordRef` is propagated. - `IntentHelpers.resolveMilestone` now rejects malformed `milestoneId` and `projectId` with `INVALID_INPUT` instead of silently falling back to name-based lookup (T-753) - Sync toggle in Settings now updates `SyncManager` runtime state immediately instead of only taking effect on next launch (T-699) ### Changed - **`update_task` response shape changed (T-650)**. The new `IntentHelpers.taskUpdateResponseDict` builder (used by both the MCP tool and `UpdateTaskIntent`) no longer emits `comments`, `lastStatusChangeDate`, or `completionDate`, and now emits `description` and `metadata` only when non-empty (previously `description` was present as `null` and `metadata` was absent). The new shape is fixed by AC 9.1; clients parsing those removed keys from `update_task` responses must instead call `query_tasks` to fetch them. Identifier-resolution and error envelopes are unchanged. - `DisplayIDMaintenanceService` init no longer takes `taskCounterStore`/`milestoneCounterStore`; the stores are read from each `DisplayIDAllocator.counterStore` (single source of truth). - `IntentHelpers.encodeAsJSONString(_:)` is the shared JSON encoding helper used by `MCPToolHandler` and the maintenance App Intents, replacing three per-site copies of the same `JSONEncoder()` + UTF-8 guard. - `MCPToolHandler` now derives the gated maintenance tool name set from `MCPToolDefinitions.maintenanceToolNames` so the gate stays in sync with the definitions list. - Service-level error messages in `DisplayIDMaintenanceService` use `error.localizedDescription` rather than `"\(error)"` interpolation for more actionable output in the result envelope. - Decision 11 in the duplicate-displayid-cleanup decision log records the choice to use `FetchDescriptor` for the stale-ID guard (instead of `ModelContext.refresh(_:mergeChanges:)`) since the service hands `RecordRef` value types across the scan/reassign boundary rather than `@Model` references. ### Added - Effective-priority invariant cross-surface regression test (T-1463, Verification phase). `EffectivePriorityInvariantTests` creates a legacy task with `priorityRawValue = ""` (set directly, bypassing the `priority` setter) and asserts it reads/serializes as `medium` and is matched by a `medium` priority filter on all five read surfaces — board `DashboardLogic`, `MCPQueryFilters.matches`, `IntentHelpers.taskToDict`, `QueryTasksIntent` `applyFilters`, and `IntentHelpers.taskUpdateResponseDict` — plus the accessor itself. This is the guard against any surface reading `priorityRawValue` directly instead of the computed `priority` accessor and silently breaking Req 1.4. - Task priority in the app UI (T-1463, UI phase). The kanban dashboard gains a `PriorityFilterMenu` (multi-select high/medium/low, displayed high→medium→low, ephemeral state that resets on launch) wired into `DashboardView`'s filter row and `DashboardLogic` predicate, matching on the computed `task.priority` so legacy/unset tasks read as medium. Task cards show a new `PriorityIndicator` glyph for high/low only (medium suppressed, read from `TaskPriority.glyphSymbol`). The create sheet (`AddTaskSheet`) and edit screen (`TaskEditView`) get priority pickers (default medium / current value, both iOS and macOS layouts), and `TaskDetailView` shows a priority row. Adds `TaskPriority.displayOrder` (`[.high, .medium, .low]`) for consistent picker/filter ordering. `DashboardView`'s filter inputs were bundled into a `FilterSelection` value type as part of this. - Task priority across the MCP server and App Intents (T-1463, Automation phase). New dedicated `INVALID_PRIORITY` error code (its own `IntentError` case + hint, shared by both surfaces). `TaskUpdateValidator.validatePriority` mirrors `validateType` and threads an optional, non-clearable priority through `ValidatedTaskUpdate` (Decision 8). MCP `create_task` parses/defaults priority (invalid → error, no task created — Req 5.5) and echoes it; `query_tasks` filters by a multi-value `priority` array (mirrors `status`) via `MCPQueryFilters.priorities`; schemas updated in `MCPToolDefinitions`. App Intents: `CreateTaskIntent` validates optional-with-default priority (absent → medium, invalid → `INVALID_PRIORITY`) and echoes it in the response; `QueryTasksIntent` gains a scalar `priority` filter; `UpdateTaskIntent` sets priority via the validator. `IntentHelpers.taskToDict`/`taskUpdateResponseDict` (and the milestone task sub-dicts) serialize `task.priority.rawValue` — always the computed accessor, never `priorityRawValue`, upholding the effective-priority invariant (Req 1.4). The `createTaskIntentJsonContractRemainsCompatible` contract test was updated for the new `priority` key (existing keys unchanged, so callers ignoring new keys stay compatible). - Task priority foundation (T-1463, Foundation phase): new `TaskPriority` enum (low/medium/high) mirroring `TaskType` — `tintColor` for all three, `glyphSymbol` (`nil` only for medium, the single source of truth for "no card glyph"), and `accessibilityLabel`. `TransitTask` gains a CloudKit-safe `priorityRawValue` stored property (default `medium`, same shape as `statusRawValue`/`typeRawValue`) and a computed `priority` accessor that reads any absent/empty/unrecognized raw value as `.medium` (the effective-priority invariant, Req 1.4 — no migration needed). `TaskService.createTask` (both overloads) accepts a defaulted `priority`, and `updateTask` accepts an optional `priority` where omitting it leaves the value unchanged (Decision 8, non-clearable). Automation and UI wiring follow in later phases. - Spec for the task priority feature (T-1463): requirements, design, decision log, and a 20-task implementation plan written to `specs/task-priority/`, with `specs/OVERVIEW.md` updated. Plans a low/medium/high priority field (default medium) shown as a board-card glyph for high/low (medium suppressed), a board filter, in-app create/edit/detail editing, and MCP + App Intent read/set/filter support — using a default-value + computed-accessor backfill (no migration) and a dedicated `INVALID_PRIORITY` error code. Planning only; not yet implemented. - The dashboard now shows SwiftUI's `ContentUnavailableView.search` empty state ("No Results for '<query>'") when text search is the only active filter and no task matches (T-198). Project/type/milestone filters keep the existing "No matching tasks. Clear filters" message, and an empty database still shows "No tasks yet". The search bar stays visible so the query can be edited or cleared. The choice of empty state is a pure `DashboardLogic.emptyStateKind` function (empty-DB > search-only > generic-filter precedence) with unit-test coverage in `DashboardSearchTests` and UI coverage in `SearchEmptyStateUITests`.
diff --git a/Transit/Transit/Services/CloudSyncBootstrap.swift b/Transit/Transit/Services/CloudSyncBootstrap.swiftnew file mode 100644index 0000000..c7ebcc4--- /dev/null+++ b/Transit/Transit/Services/CloudSyncBootstrap.swift@@ -0,0 +1,14 @@+/// Derives the CloudKit mode for services that depend on the live `ModelContainer`.+///+/// A requested CloudKit configuration only becomes active when `ContainerFactory`+/// successfully opens it. Its in-memory fallback is always CloudKit-free, regardless+/// of the launch preference, so direct CloudKit clients must remain inactive.+enum CloudSyncBootstrap {++ static func effectiveActiveCloudSync(+ requestedCloudSyncActive: Bool,+ containerOutcome: ContainerFactory.ContainerOutcome+ ) -> Bool {+ requestedCloudSyncActive && containerOutcome.error == nil+ }+}
diff --git a/Transit/Transit/TransitApp.swift b/Transit/Transit/TransitApp.swiftindex 55d2bf5..6176c98 100644--- a/Transit/Transit/TransitApp.swift+++ b/Transit/Transit/TransitApp.swift@@ -1,147 +1,150 @@ import AppIntents import CloudKit import SwiftData import SwiftUI #if os(iOS) import UIKit #endif @main struct TransitApp: App { #if os(iOS) @UIApplicationDelegateAdaptor private var appDelegate: QuickActionAppDelegate #endif private let container: ModelContainer /// Non-nil when the primary ModelContainer failed and an in-memory fallback is in use. private let containerError: (any Error)? private let taskService: TaskService private let projectService: ProjectService private let commentService: CommentService private let milestoneService: MilestoneService private let maintenanceService: DisplayIDMaintenanceService private let displayIDAllocator: DisplayIDAllocator private let milestoneIDAllocator: DisplayIDAllocator private let syncManager: SyncManager private let connectivityMonitor: ConnectivityMonitor #if os(macOS) private let mcpSettings: MCPSettings private let mcpServer: MCPServer #endif #if os(iOS) private let quickActionService: QuickActionService #endif /// True when the app is launched as a unit test host (not UI tests, which set their own scenario). private static let isUnitTestHost: Bool = NSClassFromString("XCTestCase") != nil && ProcessInfo.processInfo.environment["TRANSIT_UI_TEST_SCENARIO"] == nil private static var uiTestScenario: UITestScenario? { UITestScenario(rawValue: ProcessInfo.processInfo.environment["TRANSIT_UI_TEST_SCENARIO"] ?? "") } // swiftlint:disable:next function_body_length init() { let isInert = Self.isUnitTestHost let syncManager = SyncManager() self.syncManager = syncManager let schema = Schema([Project.self, TransitTask.self, Comment.self, Milestone.self, SyncHeartbeat.self]) let config: ModelConfiguration if isInert || Self.uiTestScenario != nil { config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none) // Test hosts bypass makeModelConfiguration, so record the mode explicitly — // otherwise the display-ID subsystem would think CloudKit is live [T-1797]. syncManager.recordActiveCloudSync(false) } else { config = syncManager.makeModelConfiguration(schema: schema) }- // The CloudKit mode the live container actually runs in, fixed for this launch.- // Everything that touches the CloudKit display-ID counter gates on this rather- // than on `syncManager.isSyncEnabled`, which the user can flip at any time- // without the container changing underneath it [T-1797, T-1857].- let cloudSyncActive = syncManager.isCloudSyncActive let containerResult = ContainerFactory.makeContainer(schema: schema, configuration: config)+ // A fallback container is always CloudKit-free, even if the requested+ // configuration enabled sync. Derive and record the effective mode only+ // after the factory has selected the live container [T-1936].+ let cloudSyncActive = CloudSyncBootstrap.effectiveActiveCloudSync(+ requestedCloudSyncActive: syncManager.isCloudSyncActive,+ containerOutcome: containerResult+ )+ syncManager.recordActiveCloudSync(cloudSyncActive) let container = containerResult.container self.container = container self.containerError = containerResult.error _showContainerError = State(initialValue: containerResult.error != nil) // Single persistence-availability signal, derived once from the container outcome and // consulted by both automation surfaces. The in-app alert above only reaches an // interactive user; MCP clients and Shortcuts/CLI callers rely on this flag to learn // that their writes would not survive a restart [T-1818, T-1836]. let persistence = PersistenceAvailability.shared persistence.update(from: containerResult) if !isInert && Self.uiTestScenario == nil && containerResult.error == nil { syncManager.initializeCloudKitSchemaIfNeeded(container: container) } let context = container.mainContext let allocator = DisplayIDAllocator(isCloudSyncActive: cloudSyncActive) self.displayIDAllocator = allocator let milestoneAllocator = DisplayIDAllocator( counterRecordName: "milestone-counter", isCloudSyncActive: cloudSyncActive ) self.milestoneIDAllocator = milestoneAllocator let taskService = TaskService(modelContext: context, displayIDAllocator: allocator) let projectService = ProjectService(modelContext: context) self.taskService = taskService self.projectService = projectService let milestoneService = MilestoneService(modelContext: context, displayIDAllocator: milestoneAllocator) self.milestoneService = milestoneService let connectivityMonitor = ConnectivityMonitor() self.connectivityMonitor = connectivityMonitor if !isInert { // Wire up connectivity restore to trigger display ID promotion. // The closure is @MainActor @Sendable, and context (container.mainContext) // is MainActor-isolated, so it can be captured directly. // Only wired when the container is CloudKit-backed: with sync off, regaining // connectivity must not start reaching for the CloudKit counter [T-1797]. if cloudSyncActive { connectivityMonitor.onRestore = { @Sendable in await allocator.promoteProvisionalTasks(in: context) await milestoneService.promoteProvisionalMilestones() } } connectivityMonitor.start() } let commentService = CommentService(modelContext: context) self.commentService = commentService let maintenanceService = DisplayIDMaintenanceService( modelContext: context, taskAllocator: allocator, milestoneAllocator: milestoneAllocator, commentService: commentService ) self.maintenanceService = maintenanceService AppDependencyManager.shared.add(dependency: taskService) AppDependencyManager.shared.add(dependency: projectService) AppDependencyManager.shared.add(dependency: commentService) AppDependencyManager.shared.add(dependency: milestoneService) AppDependencyManager.shared.add(dependency: maintenanceService) #if os(iOS) let quickActionService = QuickActionService() self.quickActionService = quickActionService appDelegate.quickActionService = quickActionService #endif #if os(macOS) let mcpSettings = MCPSettings() self.mcpSettings = mcpSettings let mcpToolHandler = MCPToolHandler( taskService: taskService, projectService: projectService,
diff --git a/Transit/TransitTests/SyncDisabledGatingTests.swift b/Transit/TransitTests/SyncDisabledGatingTests.swiftindex 6463daa..8e8e6ca 100644--- a/Transit/TransitTests/SyncDisabledGatingTests.swift+++ b/Transit/TransitTests/SyncDisabledGatingTests.swift@@ -1,145 +1,217 @@ import Foundation import SwiftData import Testing @testable import Transit /// Regression coverage for T-1797 and T-1857. /// /// T-1797: when the live `ModelContainer` was created with `cloudKitDatabase: .none` /// (iCloud Sync off at launch), nothing in the display-ID subsystem may reach the /// CloudKit counter record. Every test here asserts on `InMemoryCounterStore` /// access counts — the counter store must be *untouched*, not merely unsuccessful. /// /// T-1857: the sync preference and the mode the container was actually built with /// are two different things. `SyncManager` must expose both so Settings can tell the /// user a change only lands after a relaunch. @MainActor @Suite(.serialized) struct SyncDisabledGatingTests { // MARK: - Helpers private func makeProject(in context: ModelContext) -> Project { let project = Project(name: "Test", description: "", gitRepo: nil, colorHex: "#FF0000") context.insert(project) return project } /// Saves and restores the "syncEnabled" default around a body that mutates it. private func withSavedDefaults(_ body: () -> Void) { let key = "syncEnabled" let previous = UserDefaults.standard.object(forKey: key) defer { if let previous { UserDefaults.standard.set(previous, forKey: key) } else { UserDefaults.standard.removeObject(forKey: key) } } body() } // MARK: - T-1797: allocator never reaches the counter store @Test func allocateNextID_withCloudSyncInactive_throwsWithoutTouchingCounterStore() async { let store = InMemoryCounterStore(initialNextDisplayID: 7) let allocator = DisplayIDAllocator(store: store, isCloudSyncActive: false) await #expect(throws: DisplayIDAllocator.Error.cloudSyncInactive) { try await allocator.allocateNextID() } let untouched = await store.wasNeverAccessed #expect(untouched, "Counter store must not be read or written while iCloud Sync is off") } @Test func allocateNextID_withCloudSyncActive_stillAllocates() async throws { let store = InMemoryCounterStore(initialNextDisplayID: 7) let allocator = DisplayIDAllocator(store: store, isCloudSyncActive: true) let id = try await allocator.allocateNextID() #expect(id == 7) } + private enum InjectedContainerFailure: Error { case primaryCreationFailed }++ @Test("Fallback container outcome disables counters while preserving interactive writes")+ func fallbackOutcomeDisablesCounterUseWhileInteractiveWritesRemainAvailable() async throws {+ let schema = Schema([Project.self, TransitTask.self, Comment.self, Milestone.self, SyncHeartbeat.self])+ let config = ModelConfiguration(+ schema: schema,+ isStoredInMemoryOnly: true,+ cloudKitDatabase: .none+ )+ let outcome = ContainerFactory.makeContainer(schema: schema, configuration: config) { _, _ in+ throw InjectedContainerFailure.primaryCreationFailed+ }+ #expect(outcome.error != nil, "The test must derive its mode from a real fallback outcome")++ let syncManager = SyncManager()+ syncManager.recordActiveCloudSync(true)+ let cloudSyncActive = CloudSyncBootstrap.effectiveActiveCloudSync(+ requestedCloudSyncActive: syncManager.isCloudSyncActive,+ containerOutcome: outcome+ )+ syncManager.recordActiveCloudSync(cloudSyncActive)+ #expect(syncManager.isCloudSyncActive == false)++ let testContainer = try TestModelContainer()+ let context = testContainer.context+ let project = makeProject(in: context)+ let taskStore = InMemoryCounterStore(initialNextDisplayID: 1)+ let milestoneStore = InMemoryCounterStore(initialNextDisplayID: 1)+ let taskAllocator = DisplayIDAllocator(store: taskStore, isCloudSyncActive: cloudSyncActive)+ let milestoneAllocator = DisplayIDAllocator(store: milestoneStore, isCloudSyncActive: cloudSyncActive)+ let taskService = TaskService(modelContext: context, displayIDAllocator: taskAllocator)+ let milestoneService = MilestoneService(modelContext: context, displayIDAllocator: milestoneAllocator)++ let task = try await taskService.createTask(+ name: "Interactive fallback task", description: nil, type: .feature, project: project+ )+ let milestone = try await milestoneService.createMilestone(+ name: "Interactive fallback milestone", description: nil, project: project+ )+ await taskAllocator.promoteProvisionalTasks(in: context)+ await milestoneService.promoteProvisionalMilestones()++ #expect(task.permanentDisplayId == nil, "Interactive fallback writes remain provisional")+ #expect(milestone.permanentDisplayId == nil, "Interactive fallback writes remain provisional")+ let taskStoreUntouched = await taskStore.wasNeverAccessed+ let milestoneStoreUntouched = await milestoneStore.wasNeverAccessed+ #expect(taskStoreUntouched, "Fallback mode must never read the task counter")+ #expect(milestoneStoreUntouched, "Fallback mode must never read the milestone counter")+ }++ @Test("Bootstrap derivation preserves active and preference-disabled modes after a healthy outcome")+ func bootstrapDerivationPreservesHealthyModes() {+ let schema = Schema([Project.self, TransitTask.self, Comment.self, Milestone.self, SyncHeartbeat.self])+ let config = ModelConfiguration(+ schema: schema,+ isStoredInMemoryOnly: true,+ cloudKitDatabase: .none+ )+ let outcome = ContainerFactory.makeContainer(schema: schema, configuration: config)+ #expect(outcome.error == nil)++ #expect(CloudSyncBootstrap.effectiveActiveCloudSync(+ requestedCloudSyncActive: true,+ containerOutcome: outcome+ ))+ #expect(CloudSyncBootstrap.effectiveActiveCloudSync(+ requestedCloudSyncActive: false,+ containerOutcome: outcome+ ) == false)+ }+ @Test func promoteProvisionalTasks_withCloudSyncInactive_leavesTasksProvisional() async throws { let testContainer = try TestModelContainer() let context = testContainer.context let project = makeProject(in: context) let store = InMemoryCounterStore(initialNextDisplayID: 1) let allocator = DisplayIDAllocator(store: store, isCloudSyncActive: false) let task = TransitTask(name: "Offline", type: .feature, project: project, displayID: .provisional) context.insert(task) try context.save() await allocator.promoteProvisionalTasks(in: context) #expect(task.permanentDisplayId == nil, "Provisional IDs must accumulate while sync is disabled") let untouched = await store.wasNeverAccessed #expect(untouched, "Scene/connectivity promotion must not reach the CloudKit counter") } @Test func promoteProvisionalMilestones_withCloudSyncInactive_leavesMilestonesProvisional() async throws { let testContainer = try TestModelContainer() let context = testContainer.context let project = makeProject(in: context) let store = InMemoryCounterStore(initialNextDisplayID: 1) let allocator = DisplayIDAllocator(store: store, isCloudSyncActive: false) let service = MilestoneService(modelContext: context, displayIDAllocator: allocator) let milestone = Milestone(name: "Offline", description: nil, project: project, displayID: .provisional) context.insert(milestone) try context.save() await service.promoteProvisionalMilestones() #expect(milestone.permanentDisplayId == nil) let untouched = await store.wasNeverAccessed #expect(untouched, "Milestone promotion must not reach the CloudKit counter") } // MARK: - T-1797: creation paths fall back to provisional IDs @Test func createTask_withCloudSyncInactive_assignsProvisionalID() async throws { let testContainer = try TestModelContainer() let context = testContainer.context let project = makeProject(in: context) let store = InMemoryCounterStore(initialNextDisplayID: 1) let allocator = DisplayIDAllocator(store: store, isCloudSyncActive: false) let service = TaskService(modelContext: context, displayIDAllocator: allocator) let task = try await service.createTask( name: "New", description: nil, type: .feature, project: project ) #expect(task.permanentDisplayId == nil, "Task created while sync is off must keep a provisional ID") let untouched = await store.wasNeverAccessed #expect(untouched, "Task creation must not read or write the CloudKit counter while sync is off") } @Test func createMilestone_withCloudSyncInactive_assignsProvisionalID() async throws { let testContainer = try TestModelContainer() let context = testContainer.context let project = makeProject(in: context) let store = InMemoryCounterStore(initialNextDisplayID: 1) let allocator = DisplayIDAllocator(store: store, isCloudSyncActive: false) let service = MilestoneService(modelContext: context, displayIDAllocator: allocator) let milestone = try await service.createMilestone( name: "New", description: nil, project: project ) #expect(milestone.permanentDisplayId == nil) let untouched = await store.wasNeverAccessed #expect(untouched, "Milestone creation must not read or write the CloudKit counter while sync is off") } // MARK: - T-1797: maintenance counter-advance fence @Test
diff --git a/specs/bugfixes/fallback-container-sync-mode/report.md b/specs/bugfixes/fallback-container-sync-mode/report.mdnew file mode 100644index 0000000..d4a9c43--- /dev/null+++ b/specs/bugfixes/fallback-container-sync-mode/report.md@@ -0,0 +1,82 @@+# Bugfix Report: Fallback Container Sync Mode++**Date:** 2026-08-05+**Status:** Implemented — full unit-test execution blocked by unrelated concurrent changes++## Description of the Issue++`TransitApp` recorded the requested CloudKit mode before `ContainerFactory` attempted to open the primary SwiftData store. If the primary store failed, `ContainerFactory` switched to an in-memory `cloudKitDatabase: .none` container, but the precomputed mode stayed active. Task and milestone allocators could therefore access the real private CloudKit display-ID counters for records that would disappear at restart.++**Reproduction steps:**+1. Launch Transit with iCloud Sync enabled while forcing primary `ModelContainer` construction to fail.+2. Continue in the interactive temporary-storage UI.+3. Create a task or milestone, or trigger lifecycle/connectivity promotion.+4. Observe the fallback record can consume a permanent CloudKit display ID despite being ephemeral.++**Impact:** Temporary interactive data can advance durable shared counters and produce permanent IDs for data that cannot persist. Automation mutations remain independently rejected by existing fallback-storage guards.++## Investigation Summary++- **Symptoms examined:** Container fallback changes the actual persistence mode after `SyncManager` had already supplied `cloudSyncActive` to both allocators and connectivity wiring.+- **Code inspected:** `TransitApp`, `ContainerFactory`, `SyncManager`, `DisplayIDAllocator`, `MilestoneService`, scene promotion, and existing fallback/disabled-sync tests.+- **Hypotheses tested:** The allocation and promotion layers already fail closed when constructed inactive. The missing behavior is deriving that inactive mode from the factory outcome and recording it back to `SyncManager`.++## Discovered Root Cause++The bootstrap sequence captured `syncManager.isCloudSyncActive` before `ContainerFactory.makeContainer(...)` returned. A failed outcome means the live container is in-memory and CloudKit-free, but no subsequent derivation reset the active mode.++**Defect type:** Bootstrap state-ordering logic error.++**Why it occurred:** Configuration preference and live container outcome were treated as equivalent even though the fallback path can replace the requested configuration.++**Contributing factors:** Existing T-1797 guards covered preference-disabled launches, while T-1818/T-1836 covered automation writes in fallback storage; neither connected a real fallback outcome to allocator construction.++## Resolution for the Issue++**Changes made:**+- `Transit/Transit/Services/CloudSyncBootstrap.swift` - Added a testable derivation: CloudKit is effective only when it was requested and `ContainerFactory` did not fall back.+- `Transit/Transit/TransitApp.swift` - Derives this effective mode after container creation, records it in `SyncManager`, and supplies it to both display-ID allocators and connectivity promotion wiring.+- `Transit/TransitTests/SyncDisabledGatingTests.swift` - Adds a real injected-fallback regression covering task/milestone interactive writes, lifecycle promotion, and both untouched counter stores; adds healthy active/inactive derivation coverage.++**Approach rationale:** Existing allocators and promotion routines already fail closed before counter access when `isCloudSyncActive` is false. Correcting the one bootstrap value makes every existing creation, scene, connectivity, and maintenance guard observe the actual live container mode without changing interactive fallback writes or the separate automation availability gate.++**Alternatives considered:**+- Add a second fallback flag to every allocator and promotion caller - rejected because it duplicates the established active-mode invariant and risks divergent gating.+- Block interactive fallback writes - rejected because the app deliberately keeps warned interactive temporary storage usable; only automation writes are prohibited.++## Regression Test++**Test file:** `Transit/TransitTests/SyncDisabledGatingTests.swift`+**Test name:** `fallbackOutcomeDisablesCounterUseWhileInteractiveWritesRemainAvailable`++**What it verifies:** A real injected `ContainerFactory` fallback forces the effective active mode off; task and milestone interactive writes remain allowed but provisional, and allocation/promotion never touches either counter store. A companion test preserves active and preference-disabled modes after a healthy factory outcome.++**Run command:** `make test-quick`++## Affected Files++| File | Change |+|------|--------|+| `Transit/Transit/TransitApp.swift` | Derive and record the effective active sync mode after container creation. |+| `Transit/Transit/Services/CloudSyncBootstrap.swift` | Testable derivation of the active CloudKit mode from the factory outcome. |+| `Transit/TransitTests/SyncDisabledGatingTests.swift` | Real-fallback allocator and lifecycle gating regression tests. |++## Verification++**Automated:**+- [x] `make build-macos` passes.+- [x] `make lint` passes.+- [ ] `make test-quick` is blocked before test execution by concurrently edited, untracked `Transit/TransitTests/AddTaskSaveLifecycleTests.swift`: Swift Testing rejects mutating `beginSave`, `cancelForDisappearance`, and `completeSave` calls inside `#expect` autoclosures. The T-1936 test source compiles without diagnostics before this unrelated failure stops the target.++**Manual verification:**+- The regression builds a real injected `ContainerFactory` fallback, derives inactive mode, creates interactive task/milestone records, invokes both promotion APIs, and asserts both records remain provisional with zero counter-store access. Runtime execution remains blocked by the unrelated test-host compilation failure above.++## Prevention++- Treat a requested persistence configuration and the mode of the container actually in use as separate bootstrap values.+- Derive CloudKit-dependent service wiring only after the `ContainerFactory` outcome is known.++## Related++- T-1936+- T-1797, T-1818, T-1836
Verified identical Git blob IDs between f7b0b00 and 4113cfe for production (CloudSyncBootstrap.swift, TransitApp.swift), regression tests (SyncDisabledGatingTests.swift), and specs/bugfixes/fallback-container-sync-mode/report.md. The intervening base commits are T-1820 and T-1898; only the T-1936 changelog entry differs in the review-resolution diff.
make lint passed with 0 violations and all SwiftData ownership guard fixtures passed. make test-quick passed the full macOS TransitTests suite, including the new fallback regression. GitHub Actions claude-review is successful for the requested head.