Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .bumper/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ through injection. Add another owner only when a genuinely separate process is
introduced and its module documentation names that composition root. Delete the
rule if production store construction becomes compiler-enforced by the API.

## Compatibility transport ownership

`where.compatibility_transport_ownership` confines production construction of `CloudKitDataCompatibilityTransport` to `RegularApplicationRuntime`.
Inject its coordinator into consumers. The mutation test accepts runtime construction and rejects a competing UI owner with error severity.
This rule checks construction ownership, not runtime admission or shutdown ordering. `WhereCompatibilityModelTests` and Core compatibility tests cover those decisions.
Remove the rule if the type system enforces construction ownership.

## Checked concurrency boundaries

`where.checked_concurrency_boundaries` rejects `@preconcurrency` throughout
Expand Down
6 changes: 6 additions & 0 deletions .bumper/Sources/WhereProjectRules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ let whereProjectRules = RuleSet {
owner: whereLoggingScope.union(nonWhereProductionScope),
id: "where.logging_type_ownership",
)
Rules.constructionOwnership(
"CloudKitDataCompatibilityTransport",
allowed: RuleScope.files(["Where/Where/Sources/RegularApplicationRuntime.swift"])
.union(nonWhereProductionScope),
id: "where.compatibility_transport_ownership",
)
reservedRegionLayoutOwnershipRule
productionStoreOpeningRule
checkedConcurrencyBoundaryRule
Expand Down
20 changes: 20 additions & 0 deletions .bumper/Tests/WhereProjectRulesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@ import BumperBowlingTestSupport
import Testing

struct WhereProjectRulesTests {
@Test
func `compatibility transport belongs to the regular runtime`() throws {
let source = "func assemble() { _ = CloudKitDataCompatibilityTransport(containerIdentifier: \"test\") }"
let allowed = try evaluate(
path: "Where/Where/Sources/RegularApplicationRuntime.swift",
component: .app,
source: source,
)
let rejected = try evaluate(
path: "Where/WhereUI/Sources/Model/CompetingCompatibilityOwner.swift",
component: .whereUI,
source: source,
)
#expect(allowed.violations.isEmpty)
let violation = try #require(rejected.violations.first)
#expect(rejected.violations.count == 1)
#expect(violation.rule.id == "where.compatibility_transport_ownership")
#expect(violation.rule.severity == .error)
}

@Test
func `system reserved regions use one geometry adapter`() throws {
let source = "let regions = proxy.reservedRegions(kind: .division, options: .includeInactive, layoutDirectionBehavior: .fixed)"
Expand Down
4 changes: 4 additions & 0 deletions Where/Guidance/launch.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Scopes and the launch

- Verify data compatibility before onboarding discovery, interrupted import recovery, or production scope construction.
Keep the compatibility gate active for foreground and headless launches. Local-only and demo worlds bypass CloudKit verification.
A live lockout stops recording and retires the whole scope. Guards: `WhereCompatibilityModelTests` and `WhereLaunchTests`.

- **A `WhereScope` is what the app is logged in *to*.** It is one open store's
`WhereServices`, the `WherePreferences` driving it, and the durable log store
they record into. It is created whole. `WhereSession` is built from one. A
Expand Down
5 changes: 5 additions & 0 deletions Where/Where/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ layering, and the domain rules this target merely starts up.

## Invariants

- Construct the CloudKit compatibility transport only in the regular runtime. Inject its coordinator into the model and every bootstrap.
Local-only and demo worlds must not verify or publish CloudKit metadata.
Forward compatibility subscription notifications to the process-owned monitor, including headless launches.
Guard: `where.compatibility_transport_ownership`.

- Construct InstallerKit’s update service and model once in the regular runtime.
Require the Development audience, valid local or published InstallerKit metadata, and a non-test
process. Inject through `RootView`; foreground UI owns checking.
Expand Down
26 changes: 26 additions & 0 deletions Where/Where/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,32 @@ report the build/install/launch plan without performing it.

## CloudKit rollout and device validation

The regular runtime verifies a separate compatibility record before opening SwiftData.
Local-only Development builds and demo scopes bypass this protocol.
The control record uses the private `WhereStoreCompatibility` zone and record type, with `requiredVersion` (Int64) and `accountID` (String).
Its `where-store-compatibility` zone subscription requests silent notifications. Notifications are advisory; foreground polling also refreshes the decision.

Before the first incompatible release:

1. Use a recording-enabled validation build with Always permission to create the control record in the Development CloudKit environment.
2. Deploy its record schema to Production through CloudKit Console before distributing the cloud-enabled build.
3. Ship `DataModelVersion.current == 1` and the compatibility gate to all supported installations.
4. Increase `DataModelVersion.current` only when a later release changes data incompatibly.

Builds that predate the gate cannot obey it. A version increase does not make an existing unsupported build safe.
The protocol stops the entire session after detection. It does not make metadata and SwiftData synchronization atomic across devices.

Compatibility acceptance on two devices:

1. Keep one recording device on version 1. Install a version-2 validation build on a viewer and verify that it waits without opening history.
2. Install version 2 on the recording device. Verify that it publishes before opening history and that the viewer recovers.
3. Keep a version-1 device open during that publication. Verify that it hides history, stops GPS, and releases its scope.
4. Repeat through foreground entry, a control-record push, a remote import, and Retry after a network outage.
5. After successful verification, test offline relaunch on the same account and version. CloudKit must still resolve the account identity.
6. Test an unverified upgrade, account change, missing permission, and malformed receipt. Each must remain blocked.
7. During lockout, invoke an intent and save from the share sheet. Verify an actionable error and no new store open.
8. Restore network access and the matching version. Verify one fresh scope, preserved consent, and the retained location outbox.

The app target owns `iCloud.com.stuff.where`, the Push Notifications
entitlement, and the remote-notification background mode. Widgets and the share
extension intentionally have only the App Group entitlement: they write/read
Expand Down
7 changes: 7 additions & 0 deletions Where/Where/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,4 +142,11 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
}
return runtime.didFinishLaunching(application: application, options: options)
}

func application(
_: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
) async -> UIBackgroundFetchResult {
await runtime.didReceiveRemoteNotification(userInfo)
}
}
41 changes: 39 additions & 2 deletions Where/Where/Sources/RegularApplicationRuntime.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import AppIntents
import CloudKit
import InstallerKit
import LifecycleKit
import PeriscopeCore
Expand Down Expand Up @@ -114,6 +115,17 @@ final class RegularApplicationRuntime: WhereApplicationRuntime {
) -> WhereModel {
let installationContextStore = FileInstallationRecordingContextStore()
let locationOutbox = FileLocationOutbox.applicationSupport()
let coordinator = storeStorage.usesCloudKit
? DataCompatibilityCoordinator(
transport: CloudKitDataCompatibilityTransport(
containerIdentifier: "iCloud.com.stuff.where",
),
receipts: AppGroupDataCompatibilityReceipt(appGroupIdentifier: buildEnvironment
.appGroupIdentifier),
version: .current,
)
: DataCompatibilityCoordinator()
let compatibility = WhereCompatibilityModel(coordinator: coordinator)
return WhereModel(
preferences: preferences,
installationContextStore: installationContextStore,
Expand All @@ -123,23 +135,39 @@ final class RegularApplicationRuntime: WhereApplicationRuntime {
storeStorage: storeStorage,
widgetRefresher: buildEnvironment.makeWidgetRefresher(),
locationOutbox: locationOutbox,
compatibility: coordinator,
)
},
logSystem: .shared,
compatibility: compatibility,
effectiveDiagnosticReportingConfiguration: effectiveDiagnosticReportingConfiguration,
applyRemoteLogging: applyRemoteLogging,
)
}

func didFinishLaunching(
application _: UIApplication,
application: UIApplication,
options _: [UIApplication.LaunchOptionsKey: Any]?,
) -> Bool {
AppDependencyManager.shared
.add(dependency: { [intentServices = self.intentServices] in intentServices })

WhereLaunch.startAmbientLogging(on: .shared)
model.onLoggedOut = { [intentServices] in await intentServices.clear() }
model.compatibility
.onBlocked = { [intentServices] failure in await intentServices.block(failure) }
model.onLoggedOut = { [intentServices, weak compatibility = model.compatibility] in
if case let .blocked(failure) = compatibility?.coordinator.state {
await intentServices.block(failure)
} else { await intentServices.clear() }
}
#if DEBUG
let usesCloudKit = buildEnvironment.storage(
forCloudKitValidationBuild: Self.isCloudKitValidationBuild,
).usesCloudKit
#else
let usesCloudKit = buildEnvironment.storage.usesCloudKit
#endif
if usesCloudKit { application.registerForRemoteNotifications() }
model.onThemeChanged = { [intentServices, widgetPresentationPublisher] theme in
await widgetPresentationPublisher.publish(theme)
guard !Task.isCancelled else { return }
Expand All @@ -162,6 +190,15 @@ final class RegularApplicationRuntime: WhereApplicationRuntime {
return true
}

func didReceiveRemoteNotification(_ userInfo: [AnyHashable: Any]) async
-> UIBackgroundFetchResult
{
guard CKNotification(fromRemoteNotificationDictionary: userInfo)?.subscriptionID
== CloudKitDataCompatibilityTransport.subscriptionID else { return .noData }
await model.compatibility.refresh(model: model)
return .newData
}

func makeRootView() -> AnyView {
#if DEBUG
AnyView(RootView(
Expand Down
11 changes: 11 additions & 0 deletions Where/Where/Sources/WhereApplicationRuntime.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,16 @@ protocol WhereApplicationRuntime: AnyObject {
options: [UIApplication.LaunchOptionsKey: Any]?,
) -> Bool

func didReceiveRemoteNotification(_ userInfo: [AnyHashable: Any]) async
-> UIBackgroundFetchResult

func makeRootView() -> AnyView
}

extension WhereApplicationRuntime {
func didReceiveRemoteNotification(_: [AnyHashable: Any]) async
-> UIBackgroundFetchResult
{
.noData
}
}
7 changes: 7 additions & 0 deletions Where/WhereCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ Read the matching instructions before changing or reviewing that behavior, inclu

## Shape & invariants

- Keep compatibility metadata outside SwiftData. Inject one process-owned coordinator before every production store open.
Only local recording On with Always permission can advance the shared version. Never lower a server or receipt version.
Keep the receipt atomic, account-bound, App Group-specific, and excluded from backups.
Guards: `DataCompatibilityCoordinatorTests`, `DataCompatibilityReceiptTests`, `DataModelVersionTests`.
- Retire ingestion before joining recording operations. A retired scope must never restart GPS, including after an import completes.
Preserve consent and the durable location outbox. Guards: `LocationIngestorTests`, `DeviceRecordingControllerTests`.

- Do not import SwiftUI or UIKit into WhereCore.
- Use InstallerKit for local and published build identity, feed validation, and cache policy.
Keep `WherePreferences.installerUpdates` device-local. `BuildInfo` exposes an
Expand Down
18 changes: 18 additions & 0 deletions Where/WhereCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,24 @@ feature [`Where/AGENTS.md`](../AGENTS.md). This file is the human-facing tour.

## What you get

### Data compatibility

`DataCompatibilityCoordinator` decides whether the app can open history before SwiftData starts.
`DataModelVersion.current` describes data semantics independently of the app version. Version 1 also covers data from builds without this gate.
The private CloudKit control record lives outside SwiftData's zone. Conditional saves only advance its version.
Only a locally enabled installation with Always location permission can advance the version. A known removed installation cannot advance it.
Other devices wait for their recording device when the shared version is older. Any device can use an equal version.
A newer shared version requires an app update.

The atomic App Group receipt preserves the highest observed version and excludes itself from backups.
Transient failures permit offline access only after the same account and version passed verification.
CloudKit must still identify that account. Missing, corrupt, unreadable, or blocked receipts do not grant offline access.
The share extension checks this receipt before opening its local store. It does not contact CloudKit.

`WhereServices.shutdown()` permanently stops recording and drains owned observation tasks without erasing data, consent, or pending location samples.
The app discards that service scope. Recovery creates a fresh scope after verification succeeds.
This is a scope boundary: operations already in flight can finish, and existing data APIs have no new per-operation checks.

`WhereServices` is a small struct of focused collaborators — add behavior to the
one it belongs to rather than to a god-object:

Expand Down
Loading
Loading