Skip to content

feat(sync-agent): move the agent's configuration into per-machine app settings - #107

Merged
Adron merged 3 commits into
devfrom
feat/sync-agent-per-machine-settings
Sep 17, 2026
Merged

Adron merged 3 commits into
devfrom
feat/sync-agent-per-machine-settings

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Moves the Document Sync Agent's configuration out of UserDefaults and 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 the baseVersion compare-and-set discipline this depends on and the Xcode 27 Document fix 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:

  • The ledger's 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 reported documentSync.lastSyncAt that Settings ▸ Applications reads and nothing feeds back into the engine.
  • A conflict policy. There is not one. ConflictResolver is 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:

remote migrated already? result
.stored — the stored document wins, nothing is written
.absent no local migrates up
.absent yes local stands, not re-created (the machine was deregistered, or the settings deleted on purpose)
.unavailable — local stands, nothing is written

Collapsing .unavailable into .absent is exactly the "worse than no migration" failure: an offline launch would overwrite a newer server document with stale local values. didMigrateSettingsToAppSettings is set only after a confirmed write, so an unconfirmed one leaves UserDefaults authoritative 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's APIClient cannot: 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 KeychainTokenStore on 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.

  • The app mints and publishes, at 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 is UserDefaults → 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.
  • The agent only ever reads. No published id means "not addressable yet", which resolves to .unavailable — the agent runs on local settings exactly as before.

One trap handled: a settings write to an unregistered device 404s, and POST …/devices is an upsert keyed on deviceId (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 existing deviceName to clobber. Once, then it stops.

Reported status

documentSync.lastSyncAt is 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 new InterlinedDomain.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/InterlinedDomain dependency. 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:

GET /api/user/app-settings/interlinedlist-macos/devices  -> 200 {"devices":[]}
GET /api/user/app-settings/interlinedlist-macos          -> 404 {"error":"Not found","code":"not_found"}

No writes were made from this branch. appSettingsKey is unchanged (interlinedlist-macos).

Gate (actual output)

xcodebuild build  -> ** BUILD SUCCEEDED **
xcodebuild test   -> Executed 990 tests, with 0 failures (0 unexpected) in 16.091 seconds
                     ** TEST SUCCEEDED **
swift test InterlinedKit --skip ContractTests
                  -> Executed 484 tests, with 0 failures (0 unexpected)
swift test InterlinedDomain
                  -> Executed 1032 tests, with 0 failures (0 unexpected)
swift test InterlinedPersistence
                  -> Executed 140 tests, with 0 failures (0 unexpected)
swift test SyncAgent --skip LiveAPISmokeTests
                  -> Executed 96 tests, with 0 failures (0 unexpected)
grep -rn "^import InterlinedKit" App/Features App/Navigation App/MenuCommands
                  -> 0 hits

ContractTests were skipped, not run — the live suite is rate-limited from this session's recon. LiveAPISmokeTests in 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, so xcodebuild does not compile it — the agent half is covered by swift build and swift test on that package, which is how it has always been built (SyncAgent/scripts/build-app.sh). project.pbxproj was not touched; every new file lands in a PBXFileSystemSynchronizedRootGroup.

BDD quartet, per behaviour:

  • ConfigurationResolver — all four rows of the table above, including the two boundaries the issue names by hand: UserDefaults empty 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 → typed current; 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

  • No two-machine verification. The foreign-bookmark rule is proven by unit tests over the stored payload, not by a second Mac — I do not have one. The mechanism it protects against (copy-to-shared, or bootstrap seeding from the main workstation) is live-verified in feat(settings): finish the Applications pane and fix its wire contract #102; what is unverified is the end-to-end "folder chosen on Mac A does not appear configured on Mac B" walkthrough.
  • No notarised build. The shared-Keychain device-id channel needs both bundles signed with the com.interlinedlist.shared access group to work at all. Unsigned and ad-hoc builds get errSecMissingEntitlement, which is handled as "nothing published" — the agent stays on UserDefaults and 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.
  • No live write from this branch. The write path is exercised against a fake with the server's real compare-and-set semantics rather than against the account, which feat(settings): finish the Applications pane and fix its wire contract #102 left clean.

Refs #104

… 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>
Adron and others added 2 commits September 17, 2026 01:20
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>
@Adron
Adron merged commit e39e9ab into dev Sep 17, 2026
8 checks passed
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