krz/hutch
clone: git clone https://gitbay.org/krz/hutch.git
main: ROADMAP.txt · raw
1# Roadmap
2
3Planned work for Hutch, ordered by dependency. Feature gaps below were
4identified by diffing the GraphQL schema dumps in `Docs/API` against actual
5call sites in the Swift source.
6
7See [SCOPE.txt](SCOPE.txt) for features that are intentionally out of scope.
8
9## SourceHut API traps
10
11Things the schema does not tell you, each of which has already cost real time.
12
13- **`Thread.updated` is not the thread's activity.** It is the root email's
14 insert time and never advances when a reply arrives, despite the name and
15 despite the schema describing `MailingList.threads` as ordered "most recently
16 bumped". sr.ht returns `updated` seven seconds after `root.date` on a thread
17 carrying four replies. Anything built on it silently treats thread creation as
18 activity. Use `MailingList.emails`, which is reverse-chronological arrival
19 data — see `MailingListActivity`. Prefer `Email.received` over `Email.date`:
20 `received` is server-side and non-null, `date` comes from the sender's header
21 and is neither.
22- **The schema dumps in `Docs/API` are partial.** They were captured with an
23 introspection query that omits `inputFields` and `enumValues`, so they cannot
24 answer what a mutation's input looks like or what an enum accepts — both come
25 back as empty arrays rather than as an error. For input shapes and enum cases,
26 read the real SDL instead:
27 `git clone --depth 1 https://git.sr.ht/~sircmpwn/<service>.sr.ht` and look at
28 `api/graph/schema.graphqls`. Regenerating the dumps with a full introspection
29 query would remove the trap.
30- **`MailingList.subscription` does not report your subscription.** The field
31 exists and is typed `MailingListSubscription`, but it returns null even
32 immediately after a `mailingListSubscribe` that hands you back a real
33 subscription id — verified live against `~hutch`, for both owned and
34 non-owned lists. Do not gate subscribe-state on it. The authoritative source
35 is membership in the `subscriptions` query (correct: true after subscribe,
36 false after unsubscribe); the mutations take `listID: Int!`, read from
37 `list(rid:){ id }`. Cost the v3.11.0 subscribe toggle a full afternoon of the
38 "looks right, isn't" variety.
39
40## Phase 0: Unblock CI — done (v3.5.0)
41
42Nothing downstream is trustworthy until the build badge means something.
43
44- ~~Fix `repo-structure-check` in `builds/swift-ci.yml`~~. It asserted
45 `test -d "website"`, but `website/` was removed in `24c8bc6` (2026-04-10), so
46 the check had failed since then.
47- ~~Add a macOS CI job that runs `xcodebuild test`~~. builds.sr.ht has no macOS
48 image and its maintainer has ruled them out, so `xcodebuild` cannot run there.
49 The test plan now runs on the GitHub mirror via `.github/workflows/test.yml`;
50 builds.sr.ht keeps secret scanning and structure checks.
51
52Turning the gate on first required making the suite green. All 214 tests had
53been running only on demand in Xcode, and ten had rotted:
54
55- The `Hutch` scheme referenced `container:HutchTests` without the
56 `.xctestplan` extension, so `xcodebuild test -scheme Hutch` — the path the
57 README sends contributors down — could not run at all.
58- Five were test-side rot: uppercase GraphQL enum rawValues asserted as
59 lowercase, an ordering expectation predating `sortBuildItemsForTriage`,
60 `request.httpBody` read inside a `URLProtocol` (always nil; the body lives on
61 `httpBodyStream`), an incident fixture contradicting its own RSS input, and an
62 image assertion that treated the correct `&` attribute encoding as a bug.
63- Three were real bugs the suite had been right about all along: repository
64 descriptions could not be cleared (a nil subscript assignment drops the key
65 instead of sending JSON null), `serviceNotProvisioned` was unreachable behind
66 a broader `no such` match, and code spans rendered their contents as live
67 markup.
68- One was neither. `keepsDistinctThreadsDistinctByRootMessageID` asserted that
69 two same-subject threads get distinct `id`s, and `eff81f3` obliged by keying
70 `id` on the root Message-ID. The commit message claims this fixed an
71 `Identifiable` collision; it did not, because `deduplicateThreads` merges
72 same-subject threads into one summary before anything renders, so the
73 collision is unreachable. The test constructed summaries by hand and skipped
74 that step. The change is harmless and separating identity from grouping reads
75 better, but the stated reason was wrong.
76
77## Phase 1: Close the write gaps — done (v3.6.0)
78
79Small, independently shippable mutations that already existed in the API but
80were never called. Each removes a "why can't I do this here?" moment.
81
82- ~~`updateTicket`~~ — edit a ticket's subject and body after creation.
83- ~~`deleteTicket`~~ — delete a ticket, behind a confirmation.
84- ~~`ticketSubscribe` / `ticketUnsubscribe`, `trackerSubscribe` /
85 `trackerUnsubscribe`~~ — `Ticket.subscription` and `Tracker.subscription` are
86 null when not subscribed, so both toggles reflect real server state.
87- ~~`mailingListUnsubscribe`~~ — see the caveat below.
88- ~~`updatePreferences`~~ (todo.sr.ht and lists.sr.ht) — `notifySelf` and
89 `copySelf`, surfaced as an Email section in Settings.
90
91`mailingListSubscribe` was left unwired here on the view that per-list state was
92only knowable from the `subscriptions` query, and subscribing needs a list you
93are *not* subscribed to. **Shipped in v3.11.0** once live testing clarified two
94things: a specific list is reachable without a discovery API (Lookup, a project's
95lists, patchsets), and the `subscriptions` query *is* the reliable state source —
96membership in it answers "am I subscribed to this rid?". `MailingList.subscription`
97looked like a shortcut but is a trap (see API traps); it is not used. See
98"mailing list subscribe" below.
99
100### Refactors folded in
101
102- ~~Collapse `SRHTClient`'s duplicated request paths~~. Extracted
103 `makeAuthorizedRequest`, `send`, and `encodedGraphQLBody`; `executeMultipart`
104 became the single-file case of `executeMultipartFiles`. The `#if DEBUG`
105 logging block went from five copies to one. 938 lines to 569.
106- ~~Unify the two `executeCached` overloads~~. The memory-only overload and
107 `executeAndCache` turned out to be dead — all 38 call sites already used the
108 TTL-aware path — so both were removed rather than merged. `responseCache`
109 remains as the in-memory layer behind `cachedPayload`.
110
111Known follow-up: three view models still read `client.responseCache` directly.
112Tracked under Phase 3.
113
114## Phase 2: Patchsets — done (v3.7.0)
115
116The flagship gap. Sending and reviewing patches over email is the SourceHut
117contribution model, and Hutch had no reference to `patchset` anywhere.
118
119Scoped as review-and-triage, not submission:
120
121- ~~Patchset list per mailing list~~ — see the caveat below.
122- ~~Patchset detail~~: cover letter, per-patch diffs (via the existing
123 `DiffView`), checks, and the version / superseded-by chain.
124- ~~Status transitions via `updatePatchset`~~.
125
126Two schema facts shaped the result, and are worth knowing before extending this:
127
128- **`MailingList` has no `patchsets` field.** A list's patchsets cannot be
129 queried directly; they are reachable only through thread roots. The existing
130 threads query now also selects `root.patchset`, so the Patches tab costs no
131 extra request — but it also means patchsets cannot be filtered by status
132 server-side, and only patchsets whose thread appears in the current page are
133 listed.
134- **`Patch` carries no diff.** It has only `index`, `count`, `version`,
135 `prefix`, `subject`, and `trailers`. The diff exists solely inside the email
136 body, so it is recovered with `InboxThreadUtilities.segmentMessageBody` — the
137 same splitter the inbox thread view uses.
138
139Patch *submission* remains out of reach: it is a `git send-email` flow, not a
140GraphQL mutation. Treat that boundary as explicit rather than half-building it.
141
142## Phase 3: Polish and reach
143
144Unlike Phases 1 and 2, this is not one shippable thing. It is several, and they
145are sized very differently — measure before committing to one.
146
147### Release plan
148
149Hutch is an app with a `MARKETING_VERSION`, not a library with an API contract,
150so "breaking change" does not apply. These buckets track *user-visible scale*.
151
152| Version | Contents | Why here |
153| --- | --- | --- |
154| v3.8.1 | SonarCloud triage; housekeeping | No behaviour change at all |
155| v3.8.2 | Home system status moved to a title-bar status badge | Small UI relocation, no new surface |
156| v3.9.0 | ~~hub.sr.ht project writes + discovery (#12–#15); multi-language highlighting (#16); App Intents expansion (#17); man-page catalog sync (#7); checklist / recent-activity / pull-to-refresh fixes (#18, #11, #9)~~ | Shipped — the cut this session |
157| v3.10.0 | ~~git.sr.ht deploy keys~~ (shipped); ~~"What's cooking" ingest + doc truth-up~~ (done) | Ships one feature, corrects the map |
158| v3.11.0 | ~~Mailing list subscribe/unsubscribe toggle~~ (shipped) | Ingest-surfaced; state via the `subscriptions` query (the `subscription` field is a trap) |
159| v3.12.0 | ~~Accessibility~~ (done in code) | Still wants a VoiceOver pass on a device |
160| v4.0.0 | Localization *with* translations | The only true re-presentation |
161| — | ~~Swift 6 language mode~~ (done); cache reads | Internal; ride along, no tag |
162
1633.9.0 was cut this session, bundling the hub.sr.ht writes with the other
164features listed. That reorders the original plan: the "What's cooking" ingest
165and deploy keys — once slated for 3.9.0 — move to 3.10.0, and the hub.sr.ht
166writes that were provisionally 3.10.0 landed early, because the SDL, once
167actually read, turned out to have the mutations (it was not the empty bucket the
168sequencing had guarded against). The ingest still leads 3.10.0: its real output
169is a `SCOPE.txt` that is true.
170
171`KeychainHelper` is deliberately unbucketed; see the SonarCloud hotspots below.
172
173### API features — done (v3.8.0)
174
175- ~~`uploadArtifact` / `deleteArtifact`~~ — artifacts were read-only.
176- ~~`auditLog` (meta.sr.ht)~~ — surfaced under the tokens in Profile.
177- ~~Mailing list creation and settings~~ (`createMailingList`,
178 `updateMailingList`, `deleteMailingList`).
179
180Three of the six planned. The other three did not survive contact:
181
182- `archiveMessage` is `@internal` and inaccessible.
183- The `events` feed was built, then removed: todo.sr.ht's root `events` resolver
184 joins `event.participant_id` against `participant.user_id`, which are
185 different id spaces, so it returns an empty list for everyone. See
186 [SCOPE.txt](SCOPE.txt).
187- Webhook management, `shareSecret`, and build groups are reachable but declined
188 on judgement — see [SCOPE.txt](SCOPE.txt) for the reasoning, so they do not get
189 re-proposed.
190
191### Localization — v4.0.0, and only with translations
192
193The project sets `LOCALIZATION_PREFERS_STRING_CATALOGS = YES` but ships no
194string catalog, so every user-facing string is hardcoded English. Roughly 634
195literals: 239 `Text(`, 150 `Label(`, 117 `Button(`, 77 `Section(`, 51
196`navigationTitle(`.
197
198Worth knowing before starting: a catalog containing only English changes nothing
199for users until translations exist. It is groundwork, and it is the largest diff
200in the roadmap — it touches nearly every view, with the regression risk that
201implies.
202
203That combination is why this is bucketed at v4.0.0 *bundled with at least one
204real translation*, rather than shipped alone. An English-only catalog would earn
205the major number on regression risk while delivering nothing — the wrong trade.
206Hold the catalog until a translation lands. If it ever ships unbundled, it is
207groundwork and belongs in a quiet minor, not a 4.0.
208
209### Accessibility — done in code (v3.12.0)
210
211The earlier count here (17 of 89 view files) was wrong on both numbers: 22 of 95
212carried a modifier, and of those, labels were the whole story — one hint in the
213whole app, no traits at all. Counting files also hid the shape of the gap, which
214was not spread evenly but concentrated in three kinds of control:
215
216- **Icon-only controls**, which VoiceOver reaches with nothing to announce.
217 Eleven of them: the error banner's dismiss, log search next/previous match,
218 account switching, four `plus` creates (paste, tracker, ACL, label), artifact
219 download, the system-status info button, and assignee unassign.
220- **Selection state carried only by a checkmark or a tint.** Five: applied
221 ticket labels in two views, the label filter, the saved-filter chip, and the
222 paste visibility picker. These now carry `.isSelected` rather than relying on
223 an icon VoiceOver does not read.
224- **Disclosure state carried only by a chevron.** Three: diff file, diff hunk,
225 and inbox message. `PatchsetDetailView` already had the hint; the others now
226 match it.
227
228Two more: the project external-link row says it leaves the app, and the commit
229SHA button says it copies — both read as bare text before.
230
231Decorative chevrons inside a control that already carries text were left alone.
232An unlabelled SF Symbol contributes nothing to a combined label, so hiding them
233would be churn with no announced difference.
234
235Two checks now hold this, because either alone is insufficient:
236
237- `scripts/check_accessibility.py` reads every view file and fails on an
238 icon-only control with no label. It runs on Linux in seconds, needs no
239 simulator and no credentials, and covers screens no test navigates to. What it
240 cannot see is whether a label survives to the accessibility tree.
241- `HutchUITests/AccessibilityUITests.swift` walks the controls actually on
242 screen and fails on one that announces nothing, or that announces an SF Symbol
243 name. Both checks were confirmed against a deliberately unlabelled button; the
244 first draft of the UI sweep passed it, which is why the check now compares the
245 label against the identifier rather than guessing at symbol-name shape.
246
247The UI sweep covers the auth screen unconditionally. The signed-in tabs need a
248real token — the app has no stub session, so there is no way to reach them
249offline — and that test skips unless `HUTCH_TEST_TOKEN` is set. Adding a launch
250argument that fakes the API would remove that gap and is worth doing before the
251next accessibility pass.
252
253Still open: **this is not device-verified.** Neither check is VoiceOver. They
254prove a control has something to announce, not that the announcement is
255sensible in order, with rotor navigation, at Dynamic Type sizes.
256
257### SonarCloud backlog — done in code (v3.8.1)
258
259The live count is **53 issues / 10 rules**, not the 51 / 5 an earlier pass
260recorded — a reminder that this section rots like everything else, so query the
261API before budgeting. **0 bugs, 0 vulnerabilities**; everything is a code smell
262or hotspot. What the code side of v3.8.1 actually did:
263
264Fixed (`e93972f`):
265
266- **`swift:S1871`** — `RootView` had byte-identical `.home` / `.recentActivity`
267 deep-link cases. Merged; recent activity is a *section* of Home, not a screen,
268 so both correctly land on the Home tab.
269- **3× `swift:S1186` (empty closure/function, CRITICAL)** — two are
270 `Button("Cancel", role: .cancel) {}` (dialog dismissal needs no body); the
271 third is an empty `URLProtocol.stopLoading()` override in a test. All three now
272 carry a nested comment. Note the earlier claim that "all three are Cancel
273 buttons" was wrong — only two are.
274- **`swift:S108`** — the expected-miss `catch` in `APICacheTests` is commented.
275- **`swift:S1172`** — the unused `url` in `mimeType(for:)` is now `_`.
276- **2× `javascript:S4624`** — the nested template literal in the deep-link
277 builders (`background.js`, `content.js`) is extracted to a `pathSegment` var.
278
279Fixed as a real bug instead (`65412ee`), not silenced:
280
281- **2× `swift:S1172` on `forceRefresh`** — `HomeViewModel.loadProjects` and
282 `loadSystemStatusSnapshot` took the flag and dropped it, so dashboard
283 pull-to-refresh returned cached projects and status. This is the trap named at
284 the top of this file. `ProjectsListView` carried the same defect via its own
285 `.refreshable`. Both fixed at the root in `ProjectService.fetchProjects`.
286
287Won't Fix, with reasons (resolve in SonarCloud's web UI, not in code):
288
289- **35× `swift:S1075` (hardcoded URI)** — 28 in `SourceHutWebDeepLinkMapperTests`,
290 the rest in `HutchDeepLinkURLs`. A deep-link mapper's tests exist to assert
291 literal URLs, and a one-forge client has fixed endpoints. "Fixing" them makes
292 the code worse.
293- **`swift:S107`** — `executeCached` has 8 params across **38 call sites**. A
294 param object would rewrite the hottest networking method for no behaviour or
295 correctness gain against an arbitrary 7-param line. Not worth the regression
296 surface.
297- **`swift:S1481`** — `ArtifactsView`'s `@Bindable var vm` is flagged unused, but
298 `$vm.error` is used at line 134; Sonar's Swift analyzer misses the projected
299 value. False positive — removing it breaks the build.
300- **`javascript:S7785`** — prefers top-level `await` for `injectBannerIfEnabled()`,
301 but `content.js` is a classic content script, not a module. Top-level `await`
302 would be a syntax error. Not applicable.
303- **5× `swift:S1135`** — TODO comments (INFO). The two in `HutchIntents` named
304 real gaps and are now promoted to "App Intent gaps" below, with the inline
305 `TODO`s replaced by plain references — so those two clear. The remaining three
306 (`DeepLink`, `NotificationPreferencesViewModel` ×2) stay until addressed.
307
308The 3 hotspots are the part actually worth thought:
309
310- `KeychainHelper:33` and `:80` (**HIGH**) — the token is stored
311 `kSecAttrAccessibleWhenUnlockedThisDeviceOnly` with no
312 `SecAccessControl`, so it does not require biometric or passcode
313 authentication to read. That is a genuine product decision — should a stolen,
314 unlocked phone hand over a sr.ht token? — not a lint nit. **Unbucketed on
315 purpose:** adding `SecAccessControl` changes what a user must do to read their
316 own token, so it needs a decision first. If the answer is yes, it is a minor
317 bump of its own — a visible auth change should not hide inside a feature
318 release.
319- `ReadmeView:1922` (**LOW**) — unrestricted WebView navigation. Probably a false
320 positive: `isAllowedReadmeNavigationURL` enforces a scheme allowlist. Verify,
321 then annotate.
322
323Query it with:
324`https://sonarcloud.io/api/issues/search?componentKeys=krazywarez_hutch&resolved=false`
325
326This was scoped as a patch on the assumption nothing executes differently — and
327that mostly held: the cosmetic fixes are comments, a merge, and a rename. The one
328exception earns the release its own line: the `forceRefresh` fix changes what
329pull-to-refresh does, so it needs a manual pass on a device before v3.8.1 ships,
330not just a green suite.
331
332### Ingest "What's cooking on SourceHut?" — v3.10.0
333
334sr.ht posts a quarterly update to `~sircmpwn/sr.ht-announce`, mirrored at
335<https://sourcehut.org/blog/>. Nothing in Hutch tracks it, so the API grows and
336this repo's assumptions quietly rot. Read each quarter's post, diff it against
337`Docs/API`, `SCOPE.txt`, and the call sites, and file what changed.
338
339That this is worth doing is already proven: **`SCOPE.txt` claims pronouns are
340"not in GraphQL schema", while `AppState` queries `pronouns` and
341`UserProfileView` displays them.** sr.ht shipped it, the doc never caught up,
342and it has been discouraging work that is in fact already done.
343
344[Q2 2026](https://sourcehut.org/blog/2026-05-28-whats-cooking-q2-2026/) alone
345flags two openings:
346
347- **hub.sr.ht gained a writable GraphQL API** for managing projects and project
348 resources. ~~Rechecked and shipped~~: project create/edit, resource
349 link/unlink, and public discovery landed (#12–#15) — see "hub.sr.ht writes"
350 below. `SCOPE.txt`'s "hub has no public API / no discovery" claim has since
351 been corrected.
352- ~~git.sr.ht deploy keys are complete~~ — **shipped** (v3.10.0).
353 `createDeployKey` / `deleteDeployKey` (and `Repository.deployKeys`) are wired
354 into the repository actions menu, owner-only, alongside ACLs.
355
356Start from Q1 2026 forward — that is roughly when the current `Docs/API` dumps
357were captured.
358
359Deploy keys — the one self-contained feature the ingest had already surfaced and
360that the SDL confirmed — shipped in v3.10.0.
361
362**Ingest run (2026-08, Q1–Q2 posts + live schema introspection with a test
363token):** everything else the posts flagged is already in Hutch — RIDs (used
364throughout), pronouns and avatars (queried and displayed, avatar upload/delete
365in Settings), hub project writes and discovery (shipped), deploy keys (shipped).
366Planned-but-not-yet-shipped upstream, so nothing to build: anonymous API access
367and "standardized / connections-spec" GraphQL (Q2 named both as future work).
368The one *new* opening the introspection turned up is below.
369
370### Mailing list subscribe — done (v3.11.0)
371
372A subscribe / unsubscribe toggle now sits in the mailing-list detail toolbar
373(`MailingListDetailView`, which backs both Lookup results and
374`ProjectMailingListView`). It is hidden for lists you own and while state is
375unknown.
376
377Live testing rewrote the plan. `MailingList.subscription` looked like the state
378source but is a trap — it returns null even right after a successful
379`mailingListSubscribe` that hands back a subscription id (see API traps). So
380state comes from membership in the `subscriptions` query, which *is* reliable,
381and the numeric `listID` the mutations require comes from `list(rid:){ id }`.
382The mutations themselves (`mailingListSubscribe` / `mailingListUnsubscribe`,
383`listID: Int!`) work as expected. Phase 1's "no discovery API" worry was moot:
384a specific list is reachable via Lookup, a project's lists, or patchsets, and
385that is all subscribing needs.
386
387### hub.sr.ht writes — projects and discovery done
388
389Reading `api/graph/schema.graphqls` in `hub.sr.ht` settled the Q2 2026 claim:
390the master schema does expose the project write API — `createProject`,
391`updateProject`, `deleteProject`, the `link*` / `unlink*` resource mutations,
392and a public `projects` discovery query. Two of the three items this bucket
393tracked shipped against it:
394
395- ~~Project writes~~ — create (#13), edit (#14), and manage linked
396 repositories, trackers, and mailing lists (#15).
397- ~~Discovery~~ — a browsable directory of public projects (#12).
398
399Built against the master SDL; live deployment on `sr.ht/query` could not be
400confirmed without a token (introspection there is auth-gated), so the mutations
401degrade to a visible error rather than a crash if a field is not yet deployed.
402Verify on a signed-in device.
403
404`mailingListSubscribe` is now buildable: the ingest found `MailingList` gained a
405`subscription` field, so per-list state is readable and the subscribe/unsubscribe
406toggle can reflect it — see the "mailing list subscribe" bucket below. `SCOPE.txt`
407has since had its "hub has no public API / no discovery" claim corrected.
408
409### App Intent gaps — unscheduled
410
411Two App Intents in `HutchIntents.swift` are placeholders for features Hutch does
412not have yet. Both are gated on the same missing capability — a global
413search/persistence layer — so neither is schedulable until that lands. (These
414were the two `swift:S1135` TODOs; promoted here so the code carries a reference
415rather than a bare `TODO`.)
416
417- **Global content search.** `SearchHutchIntent` accepts a query — and now a
418 search *type* (#17) — but still routes to the Lookup screen, sourcehut entity
419 resolution, because Hutch has no full-text search across tickets, repos, and
420 lists. When a real search exists, repoint the `.search` route in
421 `SearchHutchIntent.route`. (#17 also completed Check Status / Check Builds
422 dialogs and added the Clear Recent Activity and Unpin Resource mutating
423 intents; those were shipped, not gaps.)
424- **`OpenSavedSearchIntent`.** Saved searches are per-tracker only
425 (`TicketSavedFilterStore`, `ScopedSearchHistoryStore`); there is no global
426 saved-search store for an intent to open. Add the intent once global
427 saved-search persistence exists.
428
429### Swift 6 language mode — done
430
431`SWIFT_VERSION` is now `6.0` (keeping `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`).
432The migration was mostly mechanical, in a few buckets:
433
434- **Models are `nonisolated`.** Under MainActor-default, every value-type model
435 was implicitly `@MainActor`; the pure data types in `Models/` (and utility
436 extensions like `Date.relativeDescription`, `DateFormatter+SRHT`, `SRHTWebURL`)
437 are now `nonisolated`, so the nonisolated networking layer can use them.
438- **App Intents statics.** `AppIntent`/`AppEntity`/`AppEnum` `static var`s were
439 "global shared mutable state"; the stored ones are now `static let`.
440- **Sendable dictionaries.** `nil as String? as Any` in `[String: any Sendable]`
441 GraphQL variable dicts became `nil as String? as any Sendable`.
442- **`UserDefaults`** gets a retroactive `@unchecked Sendable` (documented
443 thread-safe) since it threads through account sessions and stores.
444- **WidgetKit** completion handlers are rebound `nonisolated(unsafe)` to cross
445 into their `Task {}`; the `@Observable` `TipStoreViewModel` task handle is
446 `@ObservationIgnored nonisolated(unsafe)` for its nonisolated `deinit`.
447- **Tests** run on `@MainActor` (they exercise MainActor app code), with a few
448 constant fixtures marked `nonisolated` for use inside `@Sendable` stub
449 closures.
450
451Builds and the full suite are clean in Swift 6 mode with no behaviour change.
452
453### Cache reads that bypass the client — no release of its own
454
455`BuildListViewModel`, `RepositoryListViewModel`, and `PasteService` still read
456`client.responseCache` directly, each falling back across two different cache
457keys. That predates `APICacheKeys` and should be folded into `cachedPayload`,
458which already consults the persistent cache before the memory layer.
459
460Like Swift 6 above, this is internal and rides along with whatever release
461already touches that area. Neither justifies a tag.
462
463## Housekeeping
464
465- ~~`Hutch/Hutch/App/AccountSession.swift` sits in a stray nested directory;
466 `Hutch/HutchTests/` is empty.~~ Done (v3.8.1, `9834b78`). Moved beside the rest
467 of `App/`; both stray dirs removed. No pbxproj change — the target is a
468 synchronized root group, so the file compiled by path all along.