Skip to content

feat(settings): finish the Applications pane and fix its wire contract - #102

Merged
Adron merged 2 commits into
devfrom
feat/app-settings-devices-g17
Sep 17, 2026
Merged

Adron merged 2 commits into
devfrom
feat/app-settings-devices-g17

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes out the verify-then-build in #56. Do not merge before #98 — this branch has fix/xcode27-document-ambiguity merged into it so the App-target gate could run; that commit vanishes from the diff once #98 lands on dev.

What the verify pass found

The pane exists, but "some of the six may already be present" turned out to understate the problem in one direction and overstate it in the other. Three actions had UI that could not work, and three had no UI at all.

Action UI before? Worked against live?
Set as main workstation yes noPATCH {"isMainWorkstation":true} → 400
Rename a machine yes noPATCH {"name":…} → 400
Remove a machine yes yes, but discarded promotedDeviceId and never refetched
View settings (shared + per-machine, last-updated, size) no
Copy a machine's settings to shared no noPUT without baseVersion → 400
Delete shared settings no Kit builder existed; no Domain method, no UI

All six Kit route builders were already present, which is why this read as further along than it was. The routes were right; the bodies and the decoders were not. Nothing caught it because nothing in production consumed the settings halfderegisterDevice was the only method wired to a view, so the broken paths were never exercised.

Probe results (2026-09-16, test account, verbatim)

First-run state, exactly as #56 describes — preserved, not "fixed":

GET  /api/user/app-settings/interlinedlist-macos          -> 404 {"error":"Not found","code":"not_found"}
GET  /api/user/app-settings/interlinedlist-macos/devices  -> 200 {"devices":[]}
GET  .../bootstrap?deviceId=probe-recon-device            -> 404 {"source":"none"}

Then I stored real settings — the main verification value in the issue — and re-probed. Five DTO guesses were wrong:

1. Writes require baseVersion; the whole family is compare-and-set.

PUT /api/user/app-settings/interlinedlist-macos  {"settings":{…}}
-> 400 {"error":"baseVersion must be an integer >= 0","code":"bad_request"}

PUT ... {"settings":{"theme":"dark","sidebarWidth":280},"baseVersion":0}
-> 200 {"appKey":"interlinedlist-macos","scope":"account","deviceId":null,
        "version":1,"updatedAt":"2026-09-16T19:39:07.135Z","schemaVersion":1,
        "settings":{"theme":"dark","sidebarWidth":280}}

PUT ... {"settings":{"theme":"light"},"baseVersion":0}   # stale
-> 409 {"error":"version_conflict","code":"version_conflict","current":{…version 1…}}

Note the document is returned bare, scope is "account" (the OpenAPI example says "user" — live wins), and the write replaces: sidebarWidth was gone after a PUT carrying only theme. That is the direct evidence behind the destructive copy-to-shared confirmation.

2. Devices use deviceName / isDefault, not name / isMainWorkstation.

GET .../devices -> 200 {"devices":[
  {"deviceId":"probe-mac-alpha","deviceName":"Probe Alpha","platform":"macos",
   "isDefault":true,"lastSeenAt":"2026-09-16T19:38:42.214Z",
   "appVersion":null,"osVersion":null,"hasDeviceSettings":true}]}

The shipped decoder looked for name/deviceLabel and isMainWorkstation/isMain. None exist — so every row would have displayed its raw UUID and the Main badge would never have appeared, silently, because the fields were optional. hasDeviceSettings is new and list-only.

3. PATCH names its own contract when you get it wrong.

PATCH .../devices/probe-mac-beta  {"name":"…"}               -> 400
PATCH .../devices/probe-mac-beta  {"isMainWorkstation":true} -> 400
{"error":"at least one of deviceName or isDefault is required","code":"bad_request"}

PATCH .../devices/probe-mac-beta  {"isDefault":true}         -> 200

Promoting beta demoted alpha on the next list — promote/demote confirmed server-side.

4. POST/PATCH wrap the device. -> 200 {"device":{…}}, not bare, so register and rename both threw a decoding error on success. (POST answers 200, not the spec's 201.)

5. bootstrap is one document plus a source tag, not the shared+device pair the DTO modelled — so it decoded to empty on every launch. The full precedence chain, each branch driven live:

source "self"           -> this device's own document
source "default-device" -> the MAIN WORKSTATION's document, + defaultDeviceId/defaultDeviceName
source "account"        -> the ACCOUNT document
source "none"           -> 404, nothing stored anywhere

Remove semantics — better than the issue assumed. The server names the successor, so there is nothing to guess:

DELETE .../devices/probe-mac-beta   (beta was main, alpha remained)
-> 200 {"deleted":true,"promotedDeviceId":"probe-mac-alpha"}
DELETE .../devices/probe-mac-alpha  (last device)
-> 200 {"deleted":true,"promotedDeviceId":null}

Also confirmed: removing a machine deletes its per-device settings (re-registered the same id, its settings read back 404) and leaves shared settings untouched. DELETE on shared is idempotent — {"deleted":true} then {"deleted":false}, never a 404.

One trap found while checking whether to auto-register this Mac: POST …/devices is an upsert keyed on deviceId — re-posting an existing id does not duplicate the row, it overwrites deviceName. Registering unconditionally on pane load would have reset the machine's name to its hostname every visit, silently undoing any rename. Registration is therefore guarded on absence.

Test account left clean{"devices":[]} and 404 on shared, the exact state I found it in. Everything written (probe-mac-alpha, probe-mac-beta, probe-idem, and the account document) was deleted.

What was built

  • Kit — DTOs rewritten against captured payloads; baseVersion is a non-optional initialiser parameter, since no valid write exists without one. Speculative key aliases removed: they never matched anything, and keeping them would hide the next mismatch as well as they hid these. New AppDeviceEnvelope, DeleteDeviceResponse, DeleteAppSettingsResponse, AppSettingsSource.
  • DomainAppSettingsDocument carries version so callers can legally write; copyDeviceSettingsToShared reads the destination's version (not the source's — independent counters); 409 → AppSettingsError.versionConflict; a 404 from the device-settings write.deviceNotRegistered, because there it means the device is missing, not the document.
  • App — the three missing actions, plus consequence-stating confirmations. Badge suppressed as "unknown" only when the server named no successor and the reconcile read failed. Tab relabelled Applications to match /help/app-settings.
  • Test supportStubAPIClient now records request bodies. A wrong body is as breaking as a wrong path and far quieter: this surface shipped for weeks sending {"name":…} while every path-and-method assertion passed.

Not gated — this section is free, per the issue.

Deliberately NOT built

  • The 409's current document. APIClient reduces every non-2xx body to a message string, so surfacing current means teaching it typed error payloads — a change to every endpoint's error path, far outside this pane. Re-reading costs one request and the blob is opaque, so there is nothing to merge field-by-field. Conflicts surface as "reload and try again".
  • Migrating the Sync Agent's UserDefaults state. feat(settings): finish the Applications pane - main workstation, rename, remove, copy to shared #56 scopes this in "if the pane's read/write half is solid — otherwise split it out." It was not solid; it was non-functional. Now that it works this is worth its own issue, on top of a foundation that has been exercised against the live API.

Gate (actual output)

xcodebuild build  -> ** BUILD SUCCEEDED **
xcodebuild test   -> Executed 984 tests, with 0 failures (0 unexpected) in 17.444 seconds
                     ** TEST SUCCEEDED **
swift test InterlinedKit --skip ContractTests
                  -> Executed 484 tests, with 0 failures (0 unexpected)
swift test InterlinedDomain
                  -> Executed 1022 tests, with 0 failures (0 unexpected)
swift test InterlinedPersistence
                  -> Executed 140 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. Every other number above was observed.

DevicesViewModelTests is 26 tests covering the required quartet: happy (rename / promote / remove / copy-to-shared / delete-shared / inspect round-trip), invalid (promote a device that no longer exists; re-register guard), upstream-failure (remove succeeds but refetch fails → row goes, badge reads unknown, no error banner), boundary (exactly one device removed; first-run 404s map to empty).

Refs #56

The verify pass this issue asked for found the Applications/Devices pane in
worse shape than "some actions missing". Storing real settings on the test
account and re-probing every route exposed that the read/write half had never
worked: the DTOs were written from the gap definition before any populated
payload existed, and five of those guesses were wrong. Nothing caught it
because nothing in production consumed them — only `deregisterDevice` was ever
wired to a view.

What the live probe (2026-09-16) actually showed:

  * `PUT` requires `baseVersion` in the body. Without it the server answers
    400 outright, so every settings write this app could make was broken.
    The whole family is compare-and-set: a stale version answers 409 and
    writes nothing.
  * Devices carry `deviceName` and `isDefault`. The decoder looked for
    `name`/`deviceLabel` and `isMainWorkstation`/`isMain`, so every row would
    have rendered as its raw UUID with no main-workstation badge, silently.
  * `PATCH` accepts `deviceName` and `isDefault` — and says so when you get
    it wrong. Both shipped mutations, rename and promote, were sending fields
    the server rejects with a 400.
  * `POST` and `PATCH` wrap the device in a `device` key. Decoding it bare
    threw a decoding error on a successful 200.
  * `bootstrap` returns one document plus a `source` tag, not the
    shared/device pair the DTO modelled. Every field decoded to empty, always.

So the DTOs are rewritten against captured payloads rather than tightened, and
the speculative key aliases are gone: they never matched anything, and keeping
them would hide the next mismatch just as well as they hid these.

On top of that the three documented actions with no UI at all are now built —
inspecting the shared and per-machine documents with their last-updated and
size, copying a machine's settings to shared, and deleting the shared
settings. Each confirmation states the real consequence, because neither
"removal takes the machine's own settings with it" nor "copy replaces rather
than merges" is inferable from the button.

Two semantics are driven by evidence rather than assumption. Removing the main
workstation returns `promotedDeviceId` naming the successor, so the client
applies it instead of guessing; only when the server names nobody and the
reconcile read also fails does the badge fall back to "unknown", which is the
one case where showing the old flag would assert something now false.
And this Mac is registered on first open of the pane but never again: `POST`
is an upsert keyed on `deviceId`, so re-registering would overwrite
`deviceName` and quietly undo the user's rename on every visit.

The 409's `current` document is deliberately not plumbed through. Surfacing it
means teaching `APIClient` to carry typed error bodies, which touches every
endpoint's error path; re-reading costs one request and the blob is opaque, so
there is nothing to merge field-by-field anyway.

`appSettingsKey` is unchanged, as required — it is the namespace every stored
setting lives under.

Refs #56

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two files conflicted. One was a true duplicate, and resolving it by keeping both
sides would have compiled and been wrong.

StubAPIClient.swift: this branch and #87 (now on dev) independently added request-
body recording to the test stub, for the same reason, within days of each other.
Near-identical code, different local names, different comments. The resolution is
one implementation, not two — but each side had a piece the other lacked, so it
is not simply "take one".

dev's encode also handles `.raw` bodies, which this branch's did not; strictly
more complete, so that is the one kept. This branch's `bodyJSON` accessor is what
its own AppSettingsServiceTests read (four call sites), and dev has no equivalent,
so that is kept too. Both motivating examples stay in the comment because both are
real shipped defects: a schema sent as a string where the server demands an object
(#85), and `{"name":…}` where the server demands `{"deviceName":…}` (#56). In both
cases every path-and-method assertion passed the whole time, which is the argument
for recording bodies at all.

SettingsRootView.swift auto-merged, and this time the auto-merge is genuinely
correct — verified rather than assumed, because the same file merged cleanly and
wrongly on #92. All eleven tabs carry a `.tag(SettingsTab...)`, including this
branch's rename of Devices to Applications, which kept its `.devices` tag. Under
`TabView(selection:)` an untagged tab cannot be selected at all, so the check is
worth making by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Adron

Adron commented Sep 17, 2026

Copy link
Copy Markdown
Member Author

Conflict resolved

Merged current dev (now carrying #92, #96, #97, #99, #100, #101). Two files conflicted.

StubAPIClient.swift — a true duplicate, and "keep both" would have been wrong

This branch and #87 independently added request-body recording to the test stub, for the same reason, within days of each other. Near-identical code, different local names, different comments.

The resolution is one implementation, not two concatenated — but it is not simply "take one side" either, because each had a piece the other lacked:

  • dev's encode also handles .raw bodies, which this branch's did not. Strictly more complete, so that is the one kept.
  • This branch's bodyJSON accessor is what its own AppSettingsServiceTests read — four call sites — and dev has no equivalent. Kept.

Both motivating comments survive, because both name a real shipped defect: a schema sent as a string where the server demands an object (#85), and {"name":…} where the server demands {"deviceName":…} (#56). In both cases every path-and-method assertion passed the whole time — which is the argument for recording bodies at all.

SettingsRootView.swift — auto-merged, and this time correctly

Verified by hand rather than assumed, because the same file merged cleanly and wrongly on #92: tabs added on branches that predated the SettingsTab enum came through with no .tag(...), and git had no reason to flag it.

All 11 tabs carry a tag, including this branch's rename of DevicesApplications, which kept its .devices tag. Under TabView(selection:) an untagged tab cannot be selected programmatically at all, so it is worth checking rather than trusting.

Verification — on Xcode 27, after the merge

  • xcodebuild build** BUILD SUCCEEDED **
  • xcodebuild test (App) → Executed 1074 tests, with 0 failures · ** TEST SUCCEEDED **
  • swift test InterlinedDomainExecuted 1068 tests, with 0 failures
  • swift test InterlinedKit --skip ContractTestsExecuted 498 tests, with 0 failures
  • swift test InterlinedPersistenceExecuted 147 tests, with 0 failures
  • Decision 0003 (anchored) → zero hits

⚠️ The live ContractTests were skipped, not run — the account is still rate-limited from this session's recon. Unmodified by this PR.

Two notes

The #99 commit is now redundant. This branch carried the Xcode 27 Document fix so its gate could run; #99 reached dev via #92, so that commit is a no-op in the diff now. Harmless, left alone rather than rewriting history under an open review.

PR #107 is stacked on this branch and will need the same merge once this lands. Nothing about its content is affected.

@Adron
Adron merged commit 4a87754 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