From 36c7723b5885504f850e5dec21bc8e796cfd7ba1 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sun, 6 Sep 2026 22:27:13 -0700 Subject: [PATCH 1/2] fix(app-settings): treat first-run 404s as empty, and enable G17 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A read-only live probe settled the G17 "register an appKey" prerequisite and exposed a real defect behind it. The appKey needs no registration. The gap definition said to "pick and register an `appKey` with the backend owner", but the live API treats the segment as a free-form namespace: `GET /api/user/app-settings//devices` answers `200 {"devices":[]}` for a key the server has never seen, and `OPTIONS` on the parent reports `allow: DELETE, GET, HEAD, OPTIONS, PUT`. `appSettingsKey` is therefore set to `interlinedlist-macos` and the feature is on. The key's only job is to keep this app's settings separate from other companion apps on the account, so it must stay stable — changing it orphans whatever was stored under the old one. The defect: 404 is the ordinary first-run state, not a failure. An app key with nothing stored yet 404s, and `bootstrap` answers `404 {"source":"none"}` for a device the server has not seen. Both reads threw, so a fresh install would have shown an error in the Devices pane and on every settings read instead of empty settings — on exactly the first launch the feature exists to serve. The three read paths now map 404 to an empty bag / an empty snapshot flagged `isNewDevice`, which is the signal the caller registers off. Every other status still propagates: a 401 or a 500 is a real failure and must reach the UI. This also retires a test that asserted the opposite (that a 404 must throw as an unknown-key rejection); it now covers 403, the meaningful auth failure. Live probing additionally confirmed G18/G19/G20 shapes match the DTOs exactly, so G20's autocomplete hedge is downgraded from "unpinned" to "verified wrapped, tolerant anyway". G18's per-event channels genuinely vary (`reply` offers only email; `follow` offers email+push but not inApp), which validates rendering only the channels the server sends — a fixed three-switch pane would show dead controls on six of the eight events. Domain 646 -> 650 tests. Full gate green: build, App 660, Kit 342, Persistence 135, Decision-0003 guard 0. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GpH4PQZhdYiCfsk5PQmwzq --- App/Composition/AppEnvironment.swift | 29 +++++---- .../Services/AppSettingsService.swift | 40 ++++++++++-- .../AppSettingsServiceTests.swift | 62 ++++++++++++++++++- .../Sources/InterlinedKit/DTOs/TagDTO.swift | 9 +-- 4 files changed, 115 insertions(+), 25 deletions(-) diff --git a/App/Composition/AppEnvironment.swift b/App/Composition/AppEnvironment.swift index a3420db..46a5c00 100644 --- a/App/Composition/AppEnvironment.swift +++ b/App/Composition/AppEnvironment.swift @@ -192,9 +192,9 @@ final class AppEnvironment: ObservableObject { /// Synced app settings + the per-machine device registry /// (work-consolidation.md G17) — the platform's own mechanism for companion /// apps, and the sanctioned home for this app's preferences and the - /// Document Sync Agent's per-machine configuration. Optional because it is - /// only usable once an `appKey` is registered with the backend owner; the - /// Settings panes render an explicit unavailable state while it is nil. + /// Document Sync Agent's per-machine configuration. Optional so the feature + /// can be switched off via `appSettingsKey`; the panes render an explicit + /// unavailable state while it is nil. let appSettings: AppSettingsServicing? /// The server-driven notification-preferences catalogue @@ -315,16 +315,21 @@ final class AppEnvironment: ObservableObject { self.tags = tags } - /// The app-settings key this client registers under (work-consolidation.md - /// G17). + /// The app-settings namespace this client stores its settings under + /// (work-consolidation.md G17). /// - /// ⚠️ **Nil until an `appKey` is registered with the backend owner** — that - /// registration is a stated prerequisite of G17, and calling the routes with - /// an unregistered key 404s. While nil, `AppEnvironment.appSettings` is nil - /// and the Settings panes render an explicit "not configured" state instead - /// of failing opaquely. Set this to the agreed key to switch the feature on; - /// it is deliberately the single edit required. - static let appSettingsKey: String? = nil + /// **Verified live 2026-09-06: no registration is required.** The G17 gap + /// definition said to "pick and register an `appKey` with the backend + /// owner", but the live API treats the segment as a free-form namespace — + /// `GET /api/user/app-settings//devices` answers `200 {"devices":[]}` + /// for a key the server has never seen, and `OPTIONS` on the parent reports + /// `allow: DELETE, GET, HEAD, OPTIONS, PUT`. The key's only job is to keep + /// this app's settings separate from other companion apps on the same + /// account, so it must simply stay stable — changing it orphans whatever was + /// stored under the old one. + /// + /// Kept optional so the feature can still be switched off in one edit. + static let appSettingsKey: String? = "interlinedlist-macos" /// Builds the production service graph: /// diff --git a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/AppSettingsService.swift b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/AppSettingsService.swift index 413284f..b758895 100644 --- a/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/AppSettingsService.swift +++ b/Packages/InterlinedDomain/Sources/InterlinedDomain/Services/AppSettingsService.swift @@ -48,14 +48,28 @@ public final class AppSettingsService: AppSettingsServicing { // MARK: - Bootstrap public func bootstrap(deviceID: String) async throws -> AppSettingsSnapshot { - let dto = try await api.send(AppSettings.bootstrap(appKey: appKey, deviceId: deviceID)) - return AppSettingsSnapshot(from: dto) + do { + let dto = try await api.send(AppSettings.bootstrap(appKey: appKey, deviceId: deviceID)) + return AppSettingsSnapshot(from: dto) + } catch let error as APIError { + // Verified live 2026-09-06: an unregistered device answers + // `404 {"source":"none"}`. That is the ordinary first-run state, not + // a failure — a brand-new Mac has nothing stored yet — so it maps to + // an empty snapshot flagged `isNewDevice`, and the caller registers. + guard case .notFound = error else { throw error } + return AppSettingsSnapshot(isNewDevice: true) + } } // MARK: - Shared settings public func sharedSettings() async throws -> AppSettingsBag { - AppSettingsBag(from: try await api.send(AppSettings.shared(appKey: appKey))) + // Verified live 2026-09-06: an app key with nothing stored yet answers + // 404, while `OPTIONS` on the same path reports + // `allow: DELETE, GET, HEAD, OPTIONS, PUT` — the route exists, the + // bucket is simply empty. Treating that as an error would make every + // fresh account show a failure instead of empty settings. + try await emptyOnNotFound { AppSettings.shared(appKey: self.appKey) } } public func writeSharedSettings(_ bag: AppSettingsBag) async throws -> AppSettingsBag { @@ -68,9 +82,23 @@ public final class AppSettingsService: AppSettingsServicing { // MARK: - Per-device settings public func deviceSettings(deviceID: String) async throws -> AppSettingsBag { - AppSettingsBag( - from: try await api.send(AppSettings.deviceSettings(appKey: appKey, deviceId: deviceID)) - ) + try await emptyOnNotFound { + AppSettings.deviceSettings(appKey: self.appKey, deviceId: deviceID) + } + } + + /// Sends a settings read, mapping the "nothing stored yet" 404 to an empty + /// bag. Every other error still propagates — a 401 or a 500 is a real + /// failure and must reach the UI. + private func emptyOnNotFound( + _ build: @Sendable () -> Request + ) async throws -> AppSettingsBag { + do { + return AppSettingsBag(from: try await api.send(build())) + } catch let error as APIError { + guard case .notFound = error else { throw error } + return AppSettingsBag() + } } public func writeDeviceSettings( diff --git a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AppSettingsServiceTests.swift b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AppSettingsServiceTests.swift index c619384..a994801 100644 --- a/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AppSettingsServiceTests.swift +++ b/Packages/InterlinedDomain/Tests/InterlinedDomainTests/AppSettingsServiceTests.swift @@ -127,14 +127,70 @@ final class AppSettingsServiceTests: XCTestCase { // MARK: - Upstream failure - func test_givenUnregisteredAppKey_whenFetching_thenThrows() async { + func test_givenForbidden_whenFetching_thenThrows() async { + // Replaces an earlier test that asserted a 404 must throw. The live probe + // showed 404 is the empty-bucket state, not an unknown-key rejection, so + // the meaningful auth failure to cover here is 403. let api = StubAPIClient() - await api.enqueue(failure: .notFound(serverMessage: "unknown app key")) + await api.enqueue(failure: .forbidden(serverMessage: "nope")) let service = makeService(api) do { _ = try await service.sharedSettings() - XCTFail("An unregistered appKey must surface, not silently yield empty settings") + XCTFail("Expected the failure to propagate") + } catch { + // expected + } + } + + // MARK: - First-run 404s + // + // Verified live 2026-09-06: the server answers 404 for an app key with + // nothing stored yet, and 404 `{"source":"none"}` for a device it has not + // seen. Both are ordinary first-run states — treating them as errors made a + // fresh install show a failure instead of empty settings. + + func test_givenNothingStoredYet_whenReadingSharedSettings_thenReturnsEmptyBagNotAnError() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "Not found")) + let service = makeService(api) + + let bag = try await service.sharedSettings() + + XCTAssertTrue(bag.isEmpty) + } + + func test_givenUnregisteredDevice_whenBootstrapping_thenReturnsEmptySnapshotFlaggedNew() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "Not found")) + let service = makeService(api) + + let snapshot = try await service.bootstrap(deviceID: "dev-new") + + XCTAssertTrue(snapshot.shared.isEmpty) + XCTAssertTrue(snapshot.device.isEmpty) + XCTAssertTrue(snapshot.isNewDevice, "the caller registers off this flag") + } + + func test_givenNothingStoredYet_whenReadingDeviceSettings_thenReturnsEmptyBag() async throws { + let api = StubAPIClient() + await api.enqueue(failure: .notFound(serverMessage: "Not found")) + let service = makeService(api) + + let bag = try await service.deviceSettings(deviceID: "dev-1") + + XCTAssertTrue(bag.isEmpty) + } + + func test_givenRealFailure_whenReadingSettings_thenStillThrows() async { + // Only 404 is benign. A 500 or a 401 must still reach the UI. + let api = StubAPIClient() + await api.enqueue(failure: .httpStatus(code: 500, serverMessage: "boom")) + let service = makeService(api) + + do { + _ = try await service.sharedSettings() + XCTFail("a server error is not a first-run state") } catch { // expected } diff --git a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/TagDTO.swift b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/TagDTO.swift index 6b3c3d3..f07622c 100644 --- a/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/TagDTO.swift +++ b/Packages/InterlinedKit/Sources/InterlinedKit/DTOs/TagDTO.swift @@ -38,10 +38,11 @@ public struct TrendingTagDTO: Decodable, Sendable, Equatable { /// `GET /api/tags/autocomplete` response — prefix matches over public messages. /// -/// The gap definition does not pin this shape, so the decoder accepts the three -/// plausible forms rather than guessing one: a bare string array -/// (`["swift","swiftui"]`), a bare object array (`[{"tag":"swift"}]`), or either -/// wrapped under `tags`. All collapse to `[String]`. +/// **Verified live 2026-09-06:** the server returns the wrapped form, +/// `{"tags":[...]}`. The decoder still accepts a bare string array +/// (`["swift","swiftui"]`) and a bare object array (`[{"tag":"swift"}]`) as +/// well, since those cost nothing and this API has been seen to wrap some +/// collections and not others. All collapse to `[String]`. public struct TagSuggestionsResponse: Decodable, Sendable, Equatable { public let tags: [String] From 5d28058a181eb487bab5e0f8ce5295a67f1cf21c Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Sun, 6 Sep 2026 22:38:57 -0700 Subject: [PATCH 2/2] docs(plan): record G17-G20 shipped and correct the appKey prerequisite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The G17 entry told every future reader to "pick and register an `appKey` with the backend owner before building". That instruction is wrong, and it cost real time: it was reported to the owner as a hard blocker before anyone checked it. A read-only probe disproved it in about two minutes. No registration mechanism exists. The segment is a free-form namespace — `GET /api/user/app-settings//devices` answers `200 {"devices":[]}` for a key the server has never seen, and `OPTIONS` on the parent reports `allow: DELETE, GET, HEAD, OPTIONS, PUT`. The correction is recorded inline rather than by deleting the sentence, so the next reader sees both the original claim and why it does not hold. Also records what the same probe established: 404 is the ordinary first-run state on these routes, the G18/G19/G20 response shapes are now live-verified rather than correct-by-construction, and G17's populated payloads remain unverified because nothing is stored under the key yet. G17-G20 marked shipped (PR #25 + #30). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GpH4PQZhdYiCfsk5PQmwzq --- work-consolidation.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/work-consolidation.md b/work-consolidation.md index 19bb9e2..b27a757 100644 --- a/work-consolidation.md +++ b/work-consolidation.md @@ -120,19 +120,19 @@ Live and available to the test account: `GET /api/ai/status` returns `{"subscrib **G17 · Applications: synced settings + device registry — HIGH for a native client specifically. Size M.** -`GET/PUT/DELETE /api/user/app-settings/{appKey}`, `GET /api/user/app-settings/{appKey}/bootstrap?deviceId=…`, `GET/POST /api/user/app-settings/{appKey}/devices`, `GET/PUT /api/user/app-settings/{appKey}/devices/{deviceId}/settings`, `PATCH/DELETE /api/user/app-settings/{appKey}/devices/{deviceId}`. Per `/help/app-settings` this is the platform's *own* mechanism for companion apps: **shared settings** follow the account to every machine, **per-machine settings** stay pinned to one computer, one machine is the "main workstation" whose config seeds a brand-new device on first sign-in, and devices can be renamed or deregistered. This is the sanctioned home for the macOS app's preferences **and** the Document Sync Agent's per-machine configuration, replacing purely local `UserDefaults` state. Pick and register an `appKey` with the backend owner before building. +`GET/PUT/DELETE /api/user/app-settings/{appKey}`, `GET /api/user/app-settings/{appKey}/bootstrap?deviceId=…`, `GET/POST /api/user/app-settings/{appKey}/devices`, `GET/PUT /api/user/app-settings/{appKey}/devices/{deviceId}/settings`, `PATCH/DELETE /api/user/app-settings/{appKey}/devices/{deviceId}`. Per `/help/app-settings` this is the platform's *own* mechanism for companion apps: **shared settings** follow the account to every machine, **per-machine settings** stay pinned to one computer, one machine is the "main workstation" whose config seeds a brand-new device on first sign-in, and devices can be renamed or deregistered. This is the sanctioned home for the macOS app's preferences **and** the Document Sync Agent's per-machine configuration, replacing purely local `UserDefaults` state. **✅ SHIPPED 2026-09-06** (PR #25 + #30). ⚠️ **The "register an `appKey`" instruction above was wrong — no registration mechanism exists.** A read-only live probe found the segment is a free-form namespace: `GET /api/user/app-settings//devices` → `200 {"devices":[]}` for a key the server has never seen, and `OPTIONS` on the parent → `allow: DELETE, GET, HEAD, OPTIONS, PUT`. The client uses `interlinedlist-macos` (`AppEnvironment.appSettingsKey`); it must stay **stable**, since changing it orphans stored settings. **Also learned live:** 404 is the ordinary first-run state, not a failure — an app key with nothing stored 404s, and `bootstrap` returns `404 {"source":"none"}` for an unseen device; both now map to empty rather than throwing. **Still unverified:** the *populated* payload shapes, since nothing is stored yet — the DTOs stay tolerant. **G18 · Notification preferences — MEDIUM. Size S.** -`GET /api/user/notification-preferences` returns a typed event catalogue — `{"events":[{"key":"dig","label":"Digs on your messages","description":"…","channels":{"push":true,"inApp":true}}, …]}` — and `PATCH` writes it. Server-driven labels and descriptions mean the pane renders itself from the payload; drop it into Settings beside Preferences. Also the per-event `channels.push` flags are the switchboard [G9 push](#2b-spike-first-native-gaps) will need. +`GET /api/user/notification-preferences` returns a typed event catalogue — `{"events":[{"key":"dig","label":"Digs on your messages","description":"…","channels":{"push":true,"inApp":true}}, …]}` — and `PATCH` writes it. **✅ SHIPPED 2026-09-06** (PR #25) as Settings ▸ Notifications, and the shape is **live-verified**: 8 events, and their `channels` genuinely vary per event (`reply` offers only email; `follow` offers email+push but not inApp), so the pane renders only the channels the server sends — a fixed three-switch pane would show dead controls on six of the eight. Server-driven labels and descriptions mean the pane renders itself from the payload. Also the per-event `channels.push` flags are the switchboard [G9 push](#2b-spike-first-native-gaps) will need. **G19 · Active sessions & token revocation — MEDIUM. Size S. (Closes the client half of [P3-D](#p3-d-sessions-revocation).)** -`GET /api/user/sessions` is **Bearer-reachable** and returns `{"sessions":[{"id","deviceLabel","createdAt","lastUsedAt","isCurrent"}…]}`; `DELETE /api/user/sessions/{id}` revokes one. A Settings ▸ Security pane listing sessions with a Revoke action is a small, self-contained slice — and it is the honest complement to a never-expiring sync token. +`GET /api/user/sessions` is **Bearer-reachable** and returns `{"sessions":[{"id","deviceLabel","createdAt","lastUsedAt","isCurrent"}…]}`; `DELETE /api/user/sessions/{id}` revokes one. **✅ SHIPPED 2026-09-06** (PR #25) as Settings ▸ Security, shape **live-verified** (`{sessions:[{id, deviceLabel, createdAt, lastUsedAt, isCurrent}]}`). Revoking the *current* session signs this Mac out, so that row alone is gated behind a confirmation. It is the honest complement to a never-expiring sync token. **G20 · Tags: trending + autocomplete — MEDIUM. Size S.** -`GET /api/tags/trending` (verified: `{"tags":[{"tag","count","lastUsedAt"}…]}`) and `GET /api/tags/autocomplete` (prefix match on public messages). Feeds a composer tag-completion popover and a trending strip on the timeline. Pairs naturally with G15's "suggest tags" assistant. +`GET /api/tags/trending` (verified: `{"tags":[{"tag","count","lastUsedAt"}…]}`) and `GET /api/tags/autocomplete` (prefix match on public messages). **✅ SHIPPED 2026-09-06** (PR #25): a composer tag-completion popover and a trending strip on the timeline that reuses the existing tag filter. Both shapes **live-verified** — trending is `{tags:[{tag, count, lastUsedAt}]}` and autocomplete returns the wrapped `{tags:[…]}` form. Pairs naturally with G15's "suggest tags" assistant. **G21 · Link metadata / previews — MEDIUM. Size S–M.**