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