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 `&amp;` 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.