feat(sync-agent): move the agent's configuration into per-machine app settings - #107
Merged
Merged
Conversation
… settings The Document Sync Agent kept its whole configuration in UserDefaults, which meant it did not survive a clean reinstall, a second Mac had to be set up from scratch with nothing to say that was expected, and the Applications pane could list a machine while telling you nothing about what that machine was doing. /help/app-settings describes per-machine storage as being for exactly this — a value meaningful on one machine only — and a sync-folder path is the textbook case. The state that moves was enumerated from PreferencesManager rather than guessed: enabled, poll interval, launch-at-login, the four notification toggles, and the sync folder. Two things the issue names are deliberately left where they are. The ledger's lastSyncAt is the delta cursor and is only meaningful beside the per-document entries in the same file, so what moves is a *reported* last-sync timestamp that nothing reads back. And there is no conflict policy to move: ConflictResolver is a fixed remote-wins table with no user-facing setting, so storing one would create a setting nothing consumes. Three things needed care. Security-scoped bookmarks are machine-local, and a document can still cross machines through copy-to-shared or a new machine seeding from the main workstation. The stored shape is therefore the bookmark plus a display path plus the device id that created it, and a reference stamped with any other machine is dropped on read — the Mac reports itself unconfigured and prompts for a folder, rather than resolving a bookmark that at best points nowhere and at worst points at an unrelated folder it would then write documents into. An unstamped bookmark counts as foreign too: "cannot prove it is ours" has to read as "not ours". Migration is one-way and gated on a confirmed write. The launch decision is a pure four-case function because it guards the one thing here that can lose settings for good, and the case that matters is the distinction between "nothing stored" and "could not ask" — collapsing those two would let an offline launch overwrite a newer server document with stale local values. Writes are compare-and-set, and the agent writes from the background where losing the race is likeliest. A 409 re-bases and retries rather than surfacing, bounded at three attempts so a lost race cannot become a request storm. The agent's own API client keeps the typed 409 body, unlike the main app's, so the retry re-bases from the winning document at no extra request. The overlay preserves every key outside the documentSync. namespace, because the PUT replaces the document wholesale and that document is shared with the main app. Nothing is stored in shared settings: shared seeds a new machine on first sign-in, which is precisely the failure this exists to prevent. The two processes have to agree on one device id or one Mac gets two registry rows and its settings split in half, and they cannot share UserDefaults across bundle identifiers. The id now travels through the shared Keychain group that already carries the bearer token, rather than adding an app-group entitlement that would mean re-provisioning both signed bundles. The app mints and publishes at launch; the agent only ever reads, and falls back to local settings when nothing has been published yet. Refs #104 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 16, 2026
Merging dev exposed a real defect, not a merge artefact: the App test host hung before establishing connection, on every run, and `xcodebuild test` failed with no test having executed. Removing the launch-time `DeviceIdentity.current()` call made 1080 tests pass; restoring it reproduced the hang. That is the whole diagnosis. `DeviceIdentity.current()` reads and writes the Keychain, which is synchronous IPC to `securityd` against a shared access group. An unsigned or wrongly entitled build has no claim on that group, and the failure mode is a stall rather than a clean `errSecMissingEntitlement` — so the process never finished launching. This is not only a test problem. Even signed and entitled, a locked or first-unlock Keychain can make the call slow, and blocking the launch path on it is wrong regardless of who is watching. The publish is now detached at utility priority. The agent reads the published id on its own schedule, so being a few hundred milliseconds late costs nothing, and the Applications pane still calls `current()` directly where it genuinely needs the value synchronously. The resolution order the type documents — UserDefaults, then the shared Keychain, then mint — is untouched. Refs #104 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This was referenced Sep 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moves the Document Sync Agent's configuration out of
UserDefaultsand into per-machine app settings (…/devices/{deviceId}/settings).Stacked. Review order is #99 → #102 → this. This branch is cut from
feat/app-settings-devices-g17(PR #102), which is where the working app-settings read/write half lives — including thebaseVersioncompare-and-set discipline this depends on and the Xcode 27Documentfix that lets the App-target gate run. It is unbuildable without it. #102 in turn asks not to merge before #99.What actually moves
Enumerated from
PreferencesManager, not guessed:syncEnabled,pollIntervalSeconds,launchAtLogin,notificationsEnabled,notifyOnCompletion,notifyOnErrors,notifyOnConflicts, and the sync folder (bookmark + path).Two things the issue names are deliberately not moved, and it is worth saying why rather than quietly omitting them:
lastSyncAt. It is the delta cursor, and it is only meaningful next to the per-document entries stored in the same file. Uploading the cursor alone would let a machine resume from a cursor its ledger cannot account for and conclude nothing had changed. What ships instead is a reporteddocumentSync.lastSyncAtthat Settings ▸ Applications reads and nothing feeds back into the engine.ConflictResolveris a fixed remote-wins decision table with no user-facing setting; inventing a stored policy to satisfy the migration would create a setting nothing reads.The three parts that needed care
Bookmarks are machine-local. The stored shape is bookmark + display path + the device id that created it. A reference stamped with any other machine is dropped on read and the Mac reports itself unconfigured (and prompts for a folder) rather than resolving a foreign bookmark — which at best points nowhere and at worst points at an unrelated same-named folder the agent would then start writing documents into. An unstamped bookmark is treated as foreign too: nothing has ever written one, so "cannot prove it is ours" has to read as "not ours".
The agent reads its own device document directly and never
bootstrap, so the ordinary path has no cross-machine hop at all. The stamp is defence against the two documented routes that do move a document between machines: copy-to-shared (shipped in #102) and a new machine seeding from the main workstation.Migration is one-way and gated on a confirmed write. The launch decision is a pure four-case function,
ConfigurationResolver.resolve, because it guards the one thing here that can lose settings for good. The case that matters is the distinction between nothing stored and could not ask:.stored.absent.absent.unavailableCollapsing
.unavailableinto.absentis exactly the "worse than no migration" failure: an offline launch would overwrite a newer server document with stale local values.didMigrateSettingsToAppSettingsis set only after a confirmed write, so an unconfirmed one leavesUserDefaultsauthoritative and retries next launch.One related rule, spelled out in the code: applying a stored configuration never clears a local folder, only adds one. There is no UI for unsetting a folder — only for changing it — so "no folder stored" is never evidence the user removed one, and clearing would throw away a folder chosen while offline. When that happens the difference is pushed back up rather than left to diverge.
Writes are compare-and-set, from the background. A 409 re-bases and retries, bounded at three attempts — an unbounded retry against a document another process is rewriting in a loop turns a lost race into a request storm. The agent's client keeps the typed 409 body (
current), which the main app'sAPIClientcannot: it reduces every non-2xx body to a message string. So the retry re-bases from the winning document at zero extra requests, with a re-read as the fallback when the body is unparseable.The overlay preserves every key outside the
documentSync.namespace. The PUT replaces the document wholesale and that document is shared with the main app's own per-machine settings — without the overlay, every agent write would silently delete settings it does not own.Shared settings are not used. Shared seeds a new machine on first sign-in; a sync-folder path arriving pre-filled from another Mac is the specific failure this feature exists to prevent.
The device id had to be solved first
The agent and the app must agree on one id per machine, or one Mac gets two registry rows and its configuration splits across two documents. They cannot share
UserDefaults: the agent has its own bundle identifier and therefore its own defaults domain.The id now travels through the shared Keychain group that already carries the bearer token, rather than adding an app-group entitlement — that would mean re-provisioning both signed bundles for a value that is not even a secret. Separate type from
KeychainTokenStoreon purpose: the handling rules for a bearer token and an opaque machine id are not the same, and one type would invite treating them as if they were.AppEnvironment.live()rather than on Applications-pane load — most users never open that pane, and the agent would be stranded on local settings forever. Resolution order isUserDefaults→ shared Keychain → mint, so a machine already registered keeps the id its registry row is keyed on, and an app whose preferences were wiped adopts the published id instead of minting a duplicate..unavailable— the agent runs on local settings exactly as before.One trap handled: a settings write to an unregistered device 404s, and
POST …/devicesis an upsert keyed ondeviceId(verified live in #102) — calling it unconditionally would reset a renamed machine to its hostname on every launch. The agent registers on exactly one path: after a write proved the device absent, where there is no existingdeviceNameto clobber. Once, then it stops.Reported status
documentSync.lastSyncAtis published on an hourly throttle, not per cycle — a settings PUT a minute per machine to move a value nobody watches in real time is not worth it. The Applications inspector gains a Document Sync section reading it through a newInterlinedDomain.DocumentSyncStatus: configured-here / not-configured-here, on / paused, last synced. The folder path is not shown, keeping #102's existing decision that the inspector lists keys and never values.That domain type reads keys written by a codebase that shares no code with it — the agent is clean-room, with no
InterlinedKit/InterlinedDomaindependency. The key list is a wire contract, called out as such in both files, and the Domain tests spell the key strings literally so a one-sided rename fails a test instead of silently emptying the pane.Probes (read-only)
The live app-settings family was exhaustively probed in #102 and the test account left clean; this branch re-confirmed that starting state rather than re-deriving shapes:
No writes were made from this branch.
appSettingsKeyis unchanged (interlinedlist-macos).Gate (actual output)
ContractTestswere skipped, not run — the live suite is rate-limited from this session's recon.LiveAPISmokeTestsin the agent package were likewise skipped (they hit the live API). Every other number above was observed.SyncAgent/is a standalone SwiftPM package and is not in the Xcode scheme, soxcodebuilddoes not compile it — the agent half is covered byswift buildandswift teston that package, which is how it has always been built (SyncAgent/scripts/build-app.sh).project.pbxprojwas not touched; every new file lands in aPBXFileSystemSynchronizedRootGroup.BDD quartet, per behaviour:
ConfigurationResolver— all four rows of the table above, including the two boundaries the issue names by hand:UserDefaultsempty on a fresh install (test_givenAFreshInstallWithNothingStoredEitherSide_…) and both sources holding a value (test_givenBothSourcesHoldAValue_whenResolving_thenTheStoredOneWinsAndNothingMigrates).RemoteConfigurationStore— happy create/read/write-against-read-version; invalid (no device id → nothing requested, save throws without a call); upstream failure (read fails →.unavailable, never.absent; repeated 409 stops at three writes); boundary (nothing stored →.absent; a document holding only the main app's keys reads as absent but survives the migration write). Plus 409 re-base from the body, 409 re-base by re-read, register-once-and-retry, and register-does-not-help.SyncAgentConfiguration— round trip; foreign and unstamped bookmarks refused; poll interval clamped on read; no-agent-keys and empty documents; foreign keys preserved; stale folder keys removed.PreferencesManager— adopting stored settings; migrating up and marking done; not re-running once migrated; server unreachable → local stands, nothing written, not marked; migration write fails → not marked; foreign folder → this Mac unconfigured; server has no folder but this Mac does → the local folder survives and is pushed up; no device id → nothing read or written; the last-sync throttle in both directions.SyncAPIClient— path, method and body asserted together for the PUT (baseVersion, schemaVersion, settings); 404-on-read → nil; 409 → typedcurrent; unparseable 409 → still a conflict; 404-on-write →deviceNotRegistered, not "empty"; register posts the live field names.DeviceIdentity(App target) — registered machine keeps its id; new machine mints once; wiped app preferences adopt the published id; empty stored/published ids treated as absent; an unwritable shared store still yields a working local id.DocumentSyncStatus(Domain) — configured/paused; foreign and unstamped folders; unparseable timestamp and wrong-typed field degrade rather than break; never-ran and empty documents report nothing.Not done, and why
com.interlinedlist.sharedaccess group to work at all. Unsigned and ad-hoc builds geterrSecMissingEntitlement, which is handled as "nothing published" — the agent stays onUserDefaultsand the app still gets a working local id. So the failure mode is correct and covered by a test, but the happy path across the process boundary has not been observed on a signed build.Refs #104