Skip to content

Add multi-device recording controls - #160

Open
kyleve wants to merge 38 commits into
mainfrom
codex/multi-device-recording
Open

Add multi-device recording controls#160
kyleve wants to merge 38 commits into
mainfrom
codex/multi-device-recording

Conversation

@kyleve

@kyleve kyleve commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Posted by an AI agent on kyleve's behalf.

Summary

  • keep automatic-recording consent local to each installation instead of syncing a global recorder assignment
  • default iPhone onboarding to On only when no other active device reported recording or permission-required in the previous 24 hours; default iPad, other devices, and explicit rejoins to Off
  • sync immutable device profiles, nickname changes, advisory status check-ins, and append-only removal tombstones through CloudKit
  • let every installation review and correct history or upload evidence, while only the current installation can change its own recording choice
  • add a global Remove from Where action that stops the target when it next syncs and hides its automatic samples at and after the earliest removal timestamp
  • preserve earlier history, local consent, and existing removals through backup and reset flows
  • give a removed installation a blocking rejoin flow that creates a fresh identity and asks for recording consent again, defaulted Off

Why

A device left at home can record a stationary location that contradicts the user's actual travel. The first implementation solved that with an account-wide, append-only assignment DAG that selected Off or exactly one recorder. That was technically strong, but it made ordinary consent, CloudKit merging, backup restore, transfer, acknowledgement, and conflict UX much more complicated than the product needs.

This version keeps the useful 99%: each installation owns its own recording consent, onboarding makes a conservative recommendation, other devices expose read-only status, and a user can still permanently remove a lost, sold, or retired identity from anywhere. Apple Lost Mode or remote erase remains the security boundary for a stolen device.

Product behavior

  • Onboarding: phones recommend On unless another non-removed device checked in as Recording or Permission Required during the last 24 hours; tablets and other hardware recommend Off. The user always verifies the toggle before continuing, and a changed choice on a failed onboarding retry is persisted before the next scope opens.
  • Devices settings: the current device has the only editable Automatic Recording toggle. Other devices show their last advisory status and can be renamed or removed.
  • Local choice: changing the toggle persists in the backup-excluded installation sidecar together with the current On interval's enable timestamp. CloudKit refreshes update presentation state but can never manufacture a local toggle command, and a relaunch cannot admit a late visit from an earlier Off interval.
  • Removal: a tombstone is permanent, account-synced, and global across data epochs. The target stops and discards pending automatic fixes when it observes the tombstone. User-facing history excludes that identity's automatic samples at and after the earliest removal date; earlier, legacy, manual, and evidence-derived history remains visible.
  • Reset: account reset retires every causally pre-reset installation, including a profile that was offline and reached CloudKit only after the resetter took its snapshot.
  • Rejoin: a removed current installation shows a blocking recovery screen. Rejoin retires its old runtime, creates a new identity, returns through recording onboarding, and recommends Off.
  • Two fresh phones: simultaneous first-run installs can both recommend On before either check-in arrives. This is an accepted advisory race; the Devices screen makes both statuses visible and each phone can be turned off locally.

Architecture and safety

  • DeviceRecordingController serializes this installation's local choice, physical GPS transition, profile registration, advisory check-in, removal observation, and failed-Off cleanup barrier.
  • Physical recording changes complete before a Recording check-in is published. Any store/read failure revokes GPS and exposes an unavailable state; a failed Off cleanup prevents re-enabling until the retry backlog is successfully discarded.
  • RecordingDeviceRemoval is an immutable global tombstone. Unreadable rows invalidate the removal read; identical CloudKit duplicates collapse across epochs, while conflicting same-ID payloads fail closed.
  • WhereDataEpoch.resetBarrier uses the causal epoch graph to identify installations registered before an observed account reset, including concurrent-reset synthetic frontiers.
  • InstallationRecordingContext.RecordingChoice makes unconfirmed, Off, and On-with-cutoff mutually exclusive persisted states.
  • LocationHistoryReader is the shared removal-aware boundary used by reports, widgets, recent activity, issue scans, and foreground duplicate checks.
  • location ingestion and its durable retry outbox remain data-epoch-pinned, so reset, Replace, or cleanup races cannot replay fixes into another generation.
  • persistent-history transaction authors still distinguish local saves from external imports before remote changes are reconciled.
  • the assignment DAG, authority snapshot/frontier, remote toggle commands, acknowledgement UX, and assignment-based sample filter are removed.

Backup and compatibility

  • backup format v3 includes device profiles, nickname history, removal tombstones, and sample provenance; it contains no local recording consent or advisory check-ins
  • Merge imports restorable device/removal history without changing the current installation's local choice
  • Replace rotates the data epoch, preserves every existing removal tombstone, imports the archive, and restores the same local choice after commit
  • Where/Tools/upgrade-backup.rb upgrades shipped v1/v2 archives to v3 without inventing an installation or recording consent
  • the unshipped assignment format was removed rather than retained as compatibility baggage

Validation

  • ./test --all: 1,773 unit/integration tests passed
  • backup upgrader: 5 tests / 11 assertions
  • ./swiftformat --lint
  • ./xcstrings --lint
  • swift run bumper config .
  • swift run bumper test . (15 architecture tests)
  • swift run bumper lint . --timings
  • git diff --check

Rollout

Before shipping, deploy the additive CloudKit schema to Production and complete the two-device validation checklist in Where/Where/README.md. Use ./Where/install --cloudkit for that validation; an ordinary Debug install intentionally remains local-only.

@kyleve
kyleve force-pushed the codex/multi-device-recording branch from 1bcb8d1 to 01a9311 Compare July 30, 2026 23:08
@kyleve
kyleve marked this pull request as ready for review July 30, 2026 23:13
Comment thread Where/Where/README.md
CloudKit mirroring. Debug uses `.localOnly`; exercise sync with a Release-signed
build.

Before shipping a schema change:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should like part of a validation test script or tool that we should perhaps automate in some way.

We recently added support via the Inspector module for booting the app into different modes, maybe we add some sort of iCloud inspect/test mode to that?

Comment thread Where/WhereCore/Sources/Devices/DeviceRecordingController.swift
Comment thread Where/WhereCore/Sources/Devices/RecordingDevice.swift
Comment thread Where/WhereCore/Sources/Persistence/SwiftDataStore.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/CurrentRecordingDeviceProvider.swift Outdated
Comment thread Where/WhereUI/Sources/Launch/WhereLaunchSteps.swift Outdated
.padding(.bottom, stylesheet.spacing.xxxLarge)
}

private var recordingTitle: LocalizedStringResource {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we still have (even, opt-in) access to the device name? If so I think we should request it instead of just doing this fallback.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, recording the device name alongside the events, even for single event users is useful for audits should they happen; for non-nefarious users, it shows what device the event came from.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: We'll do this later.

Comment thread Where/Where/Tests/WhereTests.swift
manualDayCount: Int,
dismissedIssueCount: Int,
trackedRegionCount: Int,
recordingDeviceCount: Int = 0,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No defaults for things like this please.

/// until the backed-up onboarding preference has been written and explicitly acknowledged.
public enum ImportPurpose: Sendable, Hashable {
case onboarding
case settings

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can likely drop the settings-based import if that helps to simplify things. I think it's entirely OK to only import from onboarding.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(The main point of import/export is for backup and development, I don't expect many users to leverage it)

/// recovery and terminal onboarding proof. Production bridges this to
/// `InstallationRecordingContextStoring`; tests can share an in-memory implementation across
/// recreated coordinators.
public struct ImportRecoveryPersistence: Sendable {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's convert this to a protocol instead.

static func makeDecoder() -> JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
decoder.dateDecodingStrategy = .custom { decoder in

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given we're pre-release, I don't think we need this either/or check.

Comment thread Where/WhereCore/Sources/Devices/DeviceRecordingController.swift Outdated
/// is insufficient because another command can enter while an actor method is suspended.
private var isExclusive = false
private var waiters: [CheckedContinuation<Void, Never>] = []
private var acceptsOperations = true

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a lot of individual properties here; could we instead model these as an enum so unrepresentable states are impossible?

/// is insufficient because another command can enter while an actor method is suspended.
private var isExclusive = false
private var waiters: [CheckedContinuation<Void, Never>] = []
private var acceptsOperations = true

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly reading this file, it also feels like there's a LOT in this file and it's pretty messy. Perhaps we should also break this up into multiple controllers somehow?

Comment thread Where/WhereCore/Sources/Devices/RecordingAssignmentChange.swift Outdated

/// Broad hardware family used to choose an icon without persisting a
/// user-visible device name supplied by the operating system.
public enum RecordingDeviceKind: String, Codable, Sendable, Hashable {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to add Mac here for the eventual catalyst app.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And watch

Comment thread Where/WhereCore/Sources/Devices/RecordingDeviceCheckIn.swift Outdated
case nickname
}

public init(from decoder: any Decoder) throws {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this custom decoder?

nickname = try container.decodeIfPresent(String.self, forKey: .nickname)
}

public func encode(to encoder: any Encoder) throws {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same do we need this custom encoder?

let day = CalendarDay(from: date, in: aggregator.calendar)
try await store.perform { try await store.clearManualDay(day) }
let epochID = try await (store.dataEpoch()).id
try await store.perform(expectedDataEpochID: epochID) {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems repetitive, could we add a store.perform override to avoid needing unwrapping the epoch id at each callsite?

/// window. Core Location can buffer callbacks before the stream consumer is installed; the
/// cutoff prevents those pre-consent / Off-period samples from becoming authorized merely
/// because they are consumed after recording turns On.
private var acceptsSamplesSince: Date?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we model these three properties in an enum to avoid allowing invalid states?

Replace the synced assignment DAG with installation-local recording consent, advisory device status, and append-only removal tombstones. Add onboarding recommendations, removed-device rejoin, backup/reset preservation, read filtering, settings UX, and end-to-end coverage.
Comment thread Where/WhereCore/Sources/Location/LocationIngestor.swift

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Along these lines, do we log / append the latest version other devices are on; so we can warn if you need to update a device, etc?

@kyleve

kyleve commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Posted by an AI agent on kyleve's behalf.

Addressed the latest six review findings and pushed each fix independently:

  • a7638472 keeps removal tombstones global across data epochs; 40eb37d4 aligns the older epoch-upsert contract test with that behavior.
  • e1b5d12e uses the causal epoch graph to retire installations whose profiles arrive after an account reset.
  • e1b67860 persists a changed recording choice when onboarding retries after a failure.
  • f11178b1 persists the current On interval's cutoff and restores it across relaunches.
  • 8bab2c3b publishes an unavailable runtime state when Off cleanup fails and blocks re-enabling until the backlog is discarded.
  • fea92b71 replaces the obsolete assignment/acknowledgement validation and architecture documentation with the local-consent design.

Validation is green: ./swiftformat --lint, all Bumper configuration/rule checks, and ./test --all (1,773 tests). I also refreshed the PR description with the resulting end state and rollout checklist. Existing review threads unrelated to these findings remain open.

kyleve added 2 commits August 4, 2026 11:43
…cording

# Conflicts:
#	Where/TODOs.md
#	Where/WhereUI/Tests/WhereSessionTrackingTests.swift
Replace the proposed coalescing-worker model with the shipped generation-token and exclusive-controller-lane design. Exercise authorized, denied, repeated, and reversed commands; retain the old race as a negative control; and prove the stale-permission branch is reachable.
@kyleve

kyleve commented Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Posted by an AI agent on kyleve’s behalf.

I updated the Tracking Reconciliation TLA+ pilot in 27bf4efd so it models the design this PR actually ships: the immediate installation-sidecar choice, the generation check after an enable permission suspension, and the FIFO exclusive transition lane in DeviceRecordingController.

Verdict: Verified for these model bounds and assumptions.

TLC results:

  • Negative control reproduced the old stale-publication race: 33 generated / 26 distinct states, depth 8.
  • The current model demonstrably reaches stale permission rejection: 5 / 5 states, depth 4.
  • Authorized enable → disable: 22 / 16 states, depth 8.
  • Unauthorized enable → disable: 22 / 16 states, depth 8.
  • Repeated enable → enable → disable: 96 / 56 states, depth 12.
  • Reversed disable → enable: 17 / 12 states, depth 8.
  • Every current-design configuration completed without an invariant, temporal-property, or deadlock error.

The deterministic Swift guard, WhereSessionTrackingTests.offWinsWhileAnEarlierEnableWaitsForPermission, also passes against the real permission suspension and presentation state.

The model uses TLC 1.7.4 / TLC2 2.19 with Temurin 21.0.8+9. It assumes a finite command list, eventual permission completion, and eventual completion of an admitted Core transition. It deliberately excludes reset/import lifecycle operations, device removal, persistence failure, cancellation, process termination, GPS samples, and unbounded command streams; those are not covered by this result.

The source mapping, properties, configurations, assumptions, and exclusions are documented in Where/Specifications/TrackingReconciliation/README.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant