Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
01a9311
Add multi-device recording controls
kyleve Jul 30, 2026
8e1c3bc
Merge remote-tracking branch 'origin/main' into codex/multi-device-re…
kyleve Jul 30, 2026
1e5aea0
Fix device status row layout
kyleve Jul 31, 2026
fffa664
Commit and push completed work eagerly
kyleve Jul 31, 2026
ddf5af9
Merge remote-tracking branch 'origin/main' into codex/multi-device-re…
kyleve Jul 31, 2026
496d019
Enable CloudKit push notifications
kyleve Aug 1, 2026
5a01f35
Keep recording device IDs local
kyleve Aug 1, 2026
4803b0b
Subscribe before loading recording devices
kyleve Aug 1, 2026
a49634c
Merge remote-tracking branch 'origin/main' into codex/multi-device-re…
kyleve Aug 2, 2026
89ab6ef
Merge remote-tracking branch 'origin/main' into codex/multi-device-re…
kyleve Aug 2, 2026
68df147
Test remote recording imports end to end
kyleve Aug 2, 2026
404f8ee
Merge remote-tracking branch 'origin/main' into codex/multi-device-re…
kyleve Aug 2, 2026
77a4e5d
Confirm per-device recording during onboarding
kyleve Aug 2, 2026
8eadc79
Merge remote-tracking branch 'origin/main' into codex/multi-device-re…
kyleve Aug 2, 2026
3b5dfac
Redesign multi-device recording authority
kyleve Aug 3, 2026
3ff815c
Merge remote-tracking branch 'origin/main' into codex/multi-device-re…
kyleve Aug 3, 2026
8aa00fd
Model one global recording assignment
kyleve Aug 3, 2026
ca97e8b
Reconcile one account-wide recorder
kyleve Aug 3, 2026
125feb2
Discover recording authority during onboarding
kyleve Aug 3, 2026
eb99e2b
Show one automatic recorder in Settings
kyleve Aug 3, 2026
56e0ddb
Preserve recording authority across sync and backup
kyleve Aug 3, 2026
620645b
Remove obsolete per-device recording policies
kyleve Aug 3, 2026
5c40f15
Keep recording off after account reset
kyleve Aug 3, 2026
719b3f4
Fail closed on unreadable recording assignments
kyleve Aug 3, 2026
97e331e
Canonicalize duplicate recording assignments
kyleve Aug 3, 2026
d7b4812
Validate persisted recording assignments safely
kyleve Aug 3, 2026
8c1c09f
Separate recorder refreshes from user choices
kyleve Aug 3, 2026
dab0a1a
Require a fresh recorder choice after replace
kyleve Aug 3, 2026
23ab717
Simplify multi-device recording ownership
kyleve Aug 4, 2026
a763847
Keep device removals global across epochs
kyleve Aug 4, 2026
e1b5d12
Retire installations across account reset
kyleve Aug 4, 2026
e1b6786
Persist recording choice changes on onboarding retry
kyleve Aug 4, 2026
f11178b
Persist the current recording enable cutoff
kyleve Aug 4, 2026
8bab2c3
Fail closed when recording cleanup fails
kyleve Aug 4, 2026
fea92b7
Refresh multi-device validation documentation
kyleve Aug 4, 2026
40eb37d
Align epoch upsert test with global removals
kyleve Aug 4, 2026
42f3025
Merge remote-tracking branch 'origin/main' into codex/multi-device-re…
kyleve Aug 4, 2026
27bf4ef
Verify current tracking reconciliation protocol
kyleve Aug 4, 2026
99bfe6c
Persist location outbox with JournalKit
kyleve Aug 5, 2026
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
4 changes: 2 additions & 2 deletions .agents/skills/github-workflow/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ always-on commit and test invariants — this skill assumes those.

- **Multi-step work lands one commit per step**, so history stays bisectable and
can land piecewise — including pure-groundwork steps, which say so in the body.
- **Commit when asked, or when working through a plan.** If it's unclear whether
a commit is wanted, make the change and ask rather than committing silently.
- **Commit completed work eagerly.** Once a coherent change is verified, commit
it unless the user explicitly asks to keep it uncommitted.
- Push each commit as it lands once a PR is open.
- **When working through a plan, open a PR once the plan is complete** — push
the branch and open it ready-for-review rather than leaving finished work
Expand Down
22 changes: 13 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ The executables in the repo root are the dev scripts — `ide`, `test`,
hand-rolling its job: `test` is the only way tests should be run (see [Running
tests](#running-tests)), and `icons`, `attribution`, and `simulator` in particular own state that is
easy to corrupt by hand — `./simulator` owns a per-checkout device (see the
[`running-tests`](../.agents/skills/running-tests/SKILL.md) skill).
[`running-tests`](.agents/skills/running-tests/SKILL.md) skill).

### Managing app icons

Expand Down Expand Up @@ -264,7 +264,7 @@ A few files outside the module pair carry *state* rather than rules:
it touches, up to root. Read that file before adding an item, and have a new
area's file link to it rather than copying the header. Anything deliberately
deferred is filed rather than dropped (see the
[`github-workflow`](../.agents/skills/github-workflow/SKILL.md) skill), and a completed
[`github-workflow`](.agents/skills/github-workflow/SKILL.md) skill), and a completed
item moves to "Completed issues" — never deleted.
- **`INBOX.md`** — the root drop-box for raw, unverified human notes. Agents
**read from it and promote out of it**; they never file new items there
Expand Down Expand Up @@ -528,15 +528,17 @@ flag is needed there.
## Running tests

**Use [`./test`](test)** — the only way to run tests. Never hand-roll `tuist
test` or `xcodebuild`. **Validate in proportion to risk:** run
test` or `xcodebuild`. It runs the host-side backup-upgrader regression before
selecting an iOS bundle, so tool-only changes remain covered by the same entry
point. **Validate in proportion to risk:** run
`./swiftformat --lint` when the changed files are in its scope, and run the
narrowest applicable `./test` tier for code, build, tooling, or behavior
changes. Pure documentation or comment-only changes may skip checks that
cannot exercise them; record skipped checks in the commit or PR validation.
Semantic changes to configuration, scripts, generator inputs, executable
examples, or app-rendered copy are not documentation-only.

Load the [`running-tests`](../.agents/skills/running-tests/SKILL.md) skill for
Load the [`running-tests`](.agents/skills/running-tests/SKILL.md) skill for
test tiers, snapshot opt-in, why not `tuist test`, and per-checkout simulator
management (`./simulator` resolves a UDID — never pass a device name to
`simctl`).
Expand All @@ -547,16 +549,18 @@ management (`./simulator` resolves a UDID — never pass a device name to
every commit for one piece of work on that one branch.
- **Validate in proportion to risk.** Follow [Running tests](#running-tests),
never commit a known-red tree, and load the
[`running-tests`](../.agents/skills/running-tests/SKILL.md) skill to choose
[`running-tests`](.agents/skills/running-tests/SKILL.md) skill to choose
the applicable checks.
- **Multi-step work lands one commit per step**, so history stays bisectable and
can land piecewise — including pure-groundwork steps, which say so in the body.
- **Commit when asked, or when working through a plan.** If it's unclear whether
a commit is wanted, make the change and ask rather than committing silently.
- **Commit completed work eagerly.** Once a coherent change is verified, commit
it without waiting for a separate request; never hand back a finished task
with task-related changes left local and uncommitted. Honor an explicit
request to keep work uncommitted.

### GitHub

Load the [`github-workflow`](../.agents/skills/github-workflow/SKILL.md) skill
Load the [`github-workflow`](.agents/skills/github-workflow/SKILL.md) skill
for PRs, pushes, review feedback, CI, and posting as the user. Always-on: use
`gh`; open PRs ready-for-review; mark AI-posted comments.

Expand Down Expand Up @@ -636,5 +640,5 @@ being written off as untestable from a cloud agent.
### Full build & test (macOS only)

Matches CI `.github/workflows/ci.yml` — see the
[`running-tests`](../.agents/skills/running-tests/SKILL.md) skill for simulator
[`running-tests`](.agents/skills/running-tests/SKILL.md) skill for simulator
setup and the full validation recipe.
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ let package = Package(
name: "WhereCore",
dependencies: [
.target(name: "CreditKit"),
.target(name: "JournalKit"),
.target(name: "PeriscopeCore"),
.target(name: "RegionKit"),
.product(name: "ZIPFoundation", package: "ZIPFoundation"),
Expand Down
22 changes: 21 additions & 1 deletion Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,25 @@ let whereAppGroupEntitlements: Entitlements = .dictionary([
"com.apple.security.application-groups": .array([.string("group.com.stuff.where")]),
])

/// The app additionally owns the CloudKit container that mirrors its
/// SwiftData store. Extensions deliberately keep the App Group-only
/// entitlement above: they write the shared local store and let the app's
/// CloudKit-backed container publish those changes when it next opens.
let whereAppEntitlements: Entitlements = .dictionary([
// Xcode replaces this development placeholder with the environment from
// the selected provisioning profile. Keeping the entitlement in the
// target is what makes automatic signing request Push Notifications.
"aps-environment": .string("development"),
"com.apple.security.application-groups": .array([.string("group.com.stuff.where")]),
"com.apple.developer.icloud-container-identifiers": .array([
.string("iCloud.com.stuff.where"),
]),
"com.apple.developer.icloud-services": .array([.string("CloudKit")]),
"com.apple.developer.ubiquity-kvstore-identifier": .string(
"$(TeamIdentifierPrefix)com.stuff.where",
),
])

/// The environment the LFS reference images were recorded on, and the single
/// source of truth for it.
///
Expand Down Expand Up @@ -166,6 +185,7 @@ let project = Project(
infoPlist: .extendingDefault(with: [
"UILaunchScreen": .dictionary([:]),
"UIApplicationSupportsIndirectInputEvents": .boolean(true),
"UIBackgroundModes": .array([.string("remote-notification")]),
// Stated explicitly rather than left to Tuist's `1.0` / `1`
// defaults, because Settings > About shows them: the version a
// user reads off the screen should be one this manifest chose.
Expand All @@ -180,7 +200,7 @@ let project = Project(
]),
sources: ["Where/Where/Sources/**"],
resources: ["Where/Where/Resources/**"],
entitlements: whereAppGroupEntitlements,
entitlements: whereAppEntitlements,
// Writes `WhereGitSHA` / `WhereGitStatus` into the built Info.plist
// for Settings > About. A *post* script so it lands after "Process
// Info.plist" and before signing, and `basedOnDependencyAnalysis:
Expand Down
3 changes: 3 additions & 0 deletions Shared/Periscope/PeriscopeCore/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ the build system, formatting, and global conventions. Read that first.
sessions but skip ingest (ingest deletes journals; an extension launch must
not eat the live app's). Concurrently live processes sharing one on-disk
store is unsupported; see [`TODOs.md`](../TODOs.md).
- **Periscope storage is local-only.** Every on-disk `ModelConfiguration`
explicitly sets `cloudKitDatabase: .none`; a host app's iCloud entitlement
must never opt the logging schema into CloudKit implicitly.
- **Payloads persist as versioned JSON** (`eventName` + `eventVersion`) — an
event shape change must not require a SwiftData migration. While the app is
pre-release, shape changes need no decode tolerance either: the store is
Expand Down
2 changes: 2 additions & 0 deletions Shared/Periscope/PeriscopeCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ Periscope.shared.startDefaultAmbientSources()
Inspector runtime needs without starting a logging session or exposing the
internal SwiftData model classes. The recovery URLs include the crash
journals that would otherwise replay deleted history into a fresh store.
Periscope storage is always local-only; its model configurations disable
CloudKit explicitly even when the host application has iCloud entitlements.

## How it works

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ public actor PeriscopeStore: LogSink {
"Periscope",
schema: schema,
isStoredInMemoryOnly: storage == .inMemory,
cloudKitDatabase: .none,
)
}

Expand Down Expand Up @@ -147,7 +148,11 @@ public actor PeriscopeStore: LogSink {
session: LogSession,
) async throws -> PeriscopeStore {
let schema = Schema(PeriscopeSchema.models)
let configuration = ModelConfiguration(schema: schema, url: databaseURL)
let configuration = ModelConfiguration(
schema: schema,
url: databaseURL,
cloudKitDatabase: .none,
)
let container = try ModelContainer(for: schema, configurations: [configuration])
let store = PeriscopeStore(modelContainer: container)
await store.ingestRecoveredJournals()
Expand Down
20 changes: 13 additions & 7 deletions Where/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ is not a `WhereScope` and must never construct regular app services.
| Layer | Where | Owns |
|-------|-------|------|
| **Domain / services** | `WhereCore` (`WhereServices` collaborators) | Rules, detection, aggregation, persistence, side effects. Unit-test here. |
| **View model** | `WhereUI` (`WhereModel`, the `WhereSession` coordinator, the scoped `YearReportModel` / `ResolveModel` / `BackupModel` / `RemindersSettingsModel`) | Lifecycle wiring, observable mirrors of service output, UI intent methods. |
| **View model** | `WhereUI` (`WhereModel`, the `WhereSession` coordinator, the scoped `YearReportModel` / `ResolveModel` / `BackupModel` / `RemindersSettingsModel` / `DevicesSettingsModel`) | Lifecycle wiring, observable mirrors of service output, UI intent methods. |
| **Views** | `WhereUI` (`*View`) | Layout, navigation, localized copy, bindings. Never store I/O, detection, or cache/throttle policy. |

When in doubt: if the behavior would still be correct without SwiftUI, it
Expand Down Expand Up @@ -67,6 +67,12 @@ Rules the code enforces and agents must preserve:
`CoreLocationSource` in production, `ScriptedLocationSource` in
tests/previews. The one-shot `requestCurrentLocation()` returns `nil` rather
than throwing when no fix is available.
- **Automatic recording consent is installation-local.** Stamp automatic GPS samples with their
`RecordingDeviceID` and route user-facing reads through `LocationHistoryReader`. Sync profiles,
nickname events, advisory check-ins, and global removal tombstones, but never another device's
recording toggle. Keep consent beside the backup-excluded installation identity; phone
onboarding recommends On only when no other active device recently reported recording, while
tablet/other and explicit rejoins recommend Off.
- **Manual entries carry a `ManualEntryAudit`**; `DayJournal`'s write methods
take an explicit `audit:` (no default). An additive backfill can't downgrade
an authoritative row's regions, but the newer audit always wins.
Expand Down Expand Up @@ -119,10 +125,9 @@ slow.
`WhereServices`, the `WherePreferences` driving it, and the durable log store
they record into. Created whole; `WhereSession` is built from one, so a
surface can't read one world's store against another's preferences.
- **Nothing opens until the user picks a world.** The trunk is rooted at the
onboarding gate, so an install that never onboards creates no store file,
contacts no CloudKit, and opens no log store. Guard:
`WhereLaunchTests.firstRunForegroundLaunchParksOnTheOnboardingGateBeforeOpeningAnything`.
- **Onboarding may prepare the real store only for recording-authority discovery.** Retain that
exact store for scope resolution; do not construct services, expose App Intents, start GPS, or
open the log store until the user finishes choosing a world.
- **At most one scope is active and log-routing at a time.** Logging out — a
reset, or leaving a demo — releases and tears down the scope; logging back in
builds a fresh one. Flyover is the narrow exception to "one open world": it
Expand All @@ -132,8 +137,9 @@ slow.
`WhereResetTests.loggingOutReleasesTheScopeBeforeTheNextLoginOpensOne`.
`WhereFlyoverWorldTests.buildsASeededSiblingWithoutActivatingIt`.
- **The onboarding gate declares `modes: .all`,** not the `.foreground`
default: parking a headless launch is the point. A background wake needs the
permission this flow asks for, so `isNeeded` is false by then.
default: parking a headless launch is the point. Keep recording confirmation
in the backup-excluded installation sidecar, so restoring backed-up
`hasOnboarded` onto another device parks at the final choice page.
- **A gate carries no value,** so a choice made *at* it reaches `resolve-scope`
through `WhereModel` — the one step that reads model state rather than the
trunk.
Expand Down
2 changes: 1 addition & 1 deletion Where/Specifications/TrackingReconciliation/Broken.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ INVARIANTS

PROPERTY EventuallySettled

CHECK_DEADLOCK FALSE
CHECK_DEADLOCK TRUE
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
SPECIFICATION Spec

CONSTANTS
Implementation = "coalesced"
Implementation = "current"
Commands <- EnableThenDisable
Authorized = TRUE

INVARIANTS
TypeOK
FixedIntentIsImmediate
CurrentIntentIsImmediate
CorrectAtQuiescence

PROPERTY EventuallySettled

CHECK_DEADLOCK FALSE
CHECK_DEADLOCK TRUE
15 changes: 15 additions & 0 deletions Where/Specifications/TrackingReconciliation/CurrentDenied.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
SPECIFICATION Spec

CONSTANTS
Implementation = "current"
Commands <- EnableThenDisable
Authorized = FALSE

INVARIANTS
TypeOK
CurrentIntentIsImmediate
CorrectAtQuiescence

PROPERTY EventuallySettled

CHECK_DEADLOCK TRUE
15 changes: 15 additions & 0 deletions Where/Specifications/TrackingReconciliation/CurrentRepeated.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
SPECIFICATION Spec

CONSTANTS
Implementation = "current"
Commands <- EnableEnableDisable
Authorized = TRUE

INVARIANTS
TypeOK
CurrentIntentIsImmediate
CorrectAtQuiescence

PROPERTY EventuallySettled

CHECK_DEADLOCK TRUE
15 changes: 15 additions & 0 deletions Where/Specifications/TrackingReconciliation/CurrentReversed.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
SPECIFICATION Spec

CONSTANTS
Implementation = "current"
Commands <- DisableThenEnable
Authorized = TRUE

INVARIANTS
TypeOK
CurrentIntentIsImmediate
CorrectAtQuiescence

PROPERTY EventuallySettled

CHECK_DEADLOCK TRUE
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
SPECIFICATION Spec

CONSTANTS
Implementation = "current"
Commands <- EnableThenDisable
Authorized = TRUE

INVARIANTS
TypeOK
StalePermissionNotObserved

CHECK_DEADLOCK FALSE
Loading
Loading