steid

@jamesgill /

steid/plans/review-2026-09-06-organisation.md
Markdown21.0 KBRendered·Source·Blame·History·Raw
1# Review — DDD and organisation, 2026-09-06
2
3Read-only review of `src/` (65,541 lines) against `plans/architecture.md`, the seven
4ADRs, and the conventions in `CLAUDE.md`. No code was changed by this review.
5
6## Verdict
7
8The layer rules survived four waves of parallel agents essentially intact: `domain/`
9imports nothing from `application/` or `infrastructure/` anywhere, `application/`
10reaches into `infrastructure/` only inside three `mod tests` blocks, and **not one of
11the 34 POST handlers writes through a repository port** — every mutation goes through a
12use case that takes an `Actor`. What did not survive is *shape at the seams*. The
13application layer's "use cases take `&impl Port`" convention has hit its ceiling —
14`merge_pull` and `redeliver` take 17 parameters each and there are **58
15`#[allow(clippy::too_many_arguments)]` in the tree**; `GitQuery` has grown to 24
16methods across a 450-line trait; `web/repo.rs` and `web/browse.rs` have become the
17grab-bags twelve and thirteen other page modules import from; and four files are over
182,000 lines mostly because their `mod tests` block is two to three times the code it
19tests. None of this is rot — it is the predictable cost of many hands adding to the same
20files without anyone owning the boundary between them. Everything below is
21addressable in small, independently mergeable steps.
22
23## 1 · Layer discipline
24
25**Clean, and verifiably so.**
26
27- `grep -rn "crate::application\|crate::infrastructure" src/domain/` → **zero hits**.
28 Domain's only external crates are `uuid`, `sha2`, `subtle`, `rand`, `hmac` — all
29 value-object machinery (`domain/id.rs`, `domain/token.rs`, `domain/webhook.rs`
30 signatures). No `chrono`, no `sqlx`, no `topcoat`.
31- `application/` → `infrastructure/` : three hits, **all inside `mod tests`** —
32 `session.rs:73` (tests start 71), `login.rs:146` (46), `browse.rs:1381` (837).
33- `infrastructure/web/` never imports `domain::repository` and performs no repository
34 writes. The only `.insert(` calls are a `HashSet` (`import.rs:102`) and a response
35 header (`rate_limit.rs:307`).
36
37**Authorization in pages.** No page *decides* authorization; every page reads
38`RepoView::viewer_is_owner`, which `view_repo` computes via
39`authz::is_org_owner` (`application/repo.rs:265`). What pages do carry is a
40*duplicated guard*: an identical `if !repo.viewer_is_owner { return not_found() }`
41helper is written out five times — `issues.rs:227`, `labels.rs:149`, `pulls.rs:310`,
42`repo_settings.rs:96`, `webhook.rs:97` (named `owned_repo` in three, `owned` in two) —
43plus three more inline copies in `repo.rs:108`, `settings.rs:55`, `token.rs:58`. Each
44carries a comment explaining the same 404-not-403 rule. The use cases re-check, so this
45is duplication, not a hole. It belongs in `web::context` next to `current_actor`.
46
47## 2 · Aggregates and ports
48
49| Aggregate | Domain | Port | In-memory | SQLite |
50|---|---|---|---|---|
51| User | `domain/user.rs` | `user_repo.rs` (5) | `in_memory.rs:28` | `sqlite.rs:30` |
52| Org | `domain/org.rs` | `org_repo.rs` (3) | `:71` | `:115` |
53| Membership | `domain/membership.rs` | `membership_repo.rs` (3) | `:100` | `:177` |
54| Session | `domain/session.rs` | `session_repo.rs` (4) | `:1589` | `:244` |
55| Repository | `domain/repo.rs` | `repo_repo.rs` (7) | `:1626` | `:323` |
56| Token | `domain/token.rs` | `token_repo.rs` (5) | `:137` | `:488` |
57| Issue | `domain/issue.rs` | `issue_repo.rs` (8) | `:209` | `:593` |
58| Pull request | `domain/pull_request.rs` | `pull_request_repo.rs` (7) | `:779` | `:1419` |
59| Label | `domain/label.rs` | `label_repo.rs` (13) | `:537` | `:1126` |
60| Push event | `domain/push_event.rs` | `push_event_repo.rs` (4) | `:347` | `:827` |
61| Webhook | `domain/webhook.rs` | `webhook_repo.rs` (4) | `:420` | `:943` |
62| Delivery | `domain/webhook_delivery.rs` | `webhook_delivery_repo.rs` (4) | `:470` | `:1019` |
63
64Twelve aggregates, twelve traits, twenty-four implementations. **The two-implementation
65rule held everywhere.** Setup token and actor are value objects with no port, correctly.
66
67**`GitQuery` is the incoherent one.** `application/port.rs:457–906` — 24 methods,
68450 lines, spanning five unrelated families: refs (`default_branch`, `resolve`,
69`list_refs`, `branches`, `tags`, `latest_tag`, `ref_snapshot`), objects (`list_tree`,
70`read_blob`, `symlink_targets`), history (`log`, `log_entries`, `log_at_path`,
71`count_commits`, `count_commits_at_path`, `last_commits`, `commit`, `log_between`,
72`count_between`, `merge_base`), diff (`diff`), and search (`grep`, `blame`,
73`merge_preview`). A four-way split — `GitRefs`, `GitObjects`, `GitHistory`,
74`GitSearch` — costs updating two implementors (`DiskGitQuery`, `InMemoryGitQuery`) and
75**26 `queries: &impl GitQuery` parameters** across `application/`, with no behaviour
76change. It is worth doing when a *third* implementor appears, not before; the cheaper
77and more useful half is that `merge_preview` does not belong on a read port at all
78(there is already a `GitMerge` port at `port.rs:1192`), and `blame` and `grep` are the
79two methods that can time out, which is a different contract.
80
81**Where issues and pull requests duplicate a rule, they mostly already share it.**
82`PullRequest` reuses `IssueNumber` and `domain::issue::validate_content`
83(`domain/pull_request.rs:21`) — good. But **PR numbering reaches into the wrong port**:
84`create_pull` takes `issues: &impl IssueRepository` solely to call
85`issues.next_number(&repo.id)` (`application/pull_request.rs:583, 605`), because the
86sequence is per-repository and shared. That is a missing shared concept, not fine as is
87— the `repo_sequences` table wants its own narrow port (`NumberRepository::next` or a
88method on `RepoRepository`), which also removes one port from four PR use cases.
89
90Similarly, `readable_repo` and `writable_repo` — the resolution every issue, label,
91pull and webhook use case starts with — live in `application/issue.rs:483, 511` and are
92imported by `label.rs:29` and `pull_request.rs:39`. Correct behaviour, wrong home.
93
94## 3 · File size and cohesion
95
96| # | File | Total | Code | `mod tests` |
97|---|---|---|---|---|
98| 1 | `infrastructure/git_query.rs` | 4,956 | 2,056 | 2,900 |
99| 2 | `infrastructure/repository/sqlite.rs` | 3,755 | 1,646 | 2,109 |
100| 3 | `infrastructure/git.rs` | 3,586 | 2,293 | 1,293 |
101| 4 | `application/repo.rs` | 3,119 | 737 | 2,382 |
102| 5 | `application/pull_request.rs` | 2,755 | 1,204 | 1,551 |
103| 6 | `infrastructure/web/pulls.rs` | 1,957 | 1,870 | 87 |
104| 7 | `application/browse.rs` | 1,917 | 836 | 1,081 |
105| 8 | `infrastructure/repository/in_memory.rs` | 1,705 | 905 | 800 |
106| 9 | `infrastructure/web/browse.rs` | 1,654 | 1,407 | 247 |
107| 10 | `application/webhook.rs` | 1,505 | 643 | 862 |
108
109**Four of the ten are large only because of their tests.** `application/repo.rs` is
110737 lines of eight coherent use cases behind a 2,382-line test module. Moving the block
111to a sibling with `#[cfg(test)] #[path = "repo_tests.rs"] mod tests;` is mechanical,
112zero-risk, and takes ~9,000 lines out of the four biggest application/adapter files
113without touching a line of logic.
114
115Genuinely several things:
116
117- **`git_query.rs`** — one 935-line `impl GitQuery for DiskGitQuery` (`:127–1062`)
118 followed by ~1,000 lines of free parsers. Split: `git_query/mod.rs` (the impl),
119 `git_query/parse_refs.rs` (`parse_refs`, `parse_branches`, `parse_tags`,
120 `parse_latest_tag`, `parse_ref_snapshot`, `ref_fields`, `short_ref`, the five
121 `*_FORMAT` consts), `git_query/parse_log.rs` (`parse_log`, `parse_log_entries`,
122 `parse_commit_object`, `split_headers`, `split_message`, `identity`),
123 `git_query/parse_tree.rs` (`parse_tree_object`, `attribute`, `record_at`,
124 `tree_spec`), `git_query/parse_diff.rs` (`split_numstat`, `parse_merge_tree`).
125- **`git.rs`** — six ports' adapters in one file: `DiskGitStorage`,
126 `InMemoryGitStorage`, `GitHttpBackend`, `InMemoryGitProtocol`, `DiskGitArchive`,
127 `InMemoryGitArchive`, **`InMemoryGitQuery` (`:982–1588`, 600 lines)**,
128 `DiskGitImport`, `InMemoryGitImport`, `GitFeatures`, `DiskGitMerge`,
129 `InMemoryGitMerge`. The worst of it is that `InMemoryGitQuery` lives here while
130 `DiskGitQuery` lives in `git_query.rs` — the two implementations of one port are in
131 different files, which is exactly what the two-implementation convention exists to
132 make easy to compare. Split: `git/storage.rs`, `git/protocol.rs`, `git/archive.rs`,
133 `git/import.rs`, `git/merge.rs`, and move `InMemoryGitQuery` beside `DiskGitQuery`.
134- **`web/pulls.rs`** — 1,870 lines of code, five POST handlers, four page routes, and
135 its own `Found<T>`/`elsewhere_or_missing` resolution machinery (`:219, :261`). Split
136 along the section comments already in the file: `pulls/list.rs`, `pulls/open.rs`
137 (the new-PR flow), `pulls/detail.rs` (conversation/commits/files tabs),
138 `pulls/writes.rs`.
139- **`web/browse.rs`** — 1,407 lines of code holding four things: the tree/blob/log
140 pages, the revision switcher, **the URL builders** (`tree_url`, `raw_url`,
141 `log_url`, `path_log_url`, `encode`), and **the formatters** (`ago`, `timestamp`,
142 `size_of`, `civil_from_days`). See §4.
143- **`application/browse.rs`** — `browse_repo`, `repo_log`, `repo_path_log`,
144 `list_refs`, `list_branches`, `list_tags`, `read_raw_file`, `parse_gitmodules`,
145 `resolve_relative`. Three concerns: browsing a tree, walking a log, listing refs.
146
147`sqlite.rs` and `in_memory.rs` are long but cleanly sectioned one aggregate at a time;
148they only want splitting once the test blocks move out and the remainder is still large.
149One placement slip: `InMemorySessionRepo` and `InMemoryRepoRepo` were appended *after*
150`mod tests` (`in_memory.rs:1589, 1626`; tests at `:906`) — the `CLAUDE.md` gotcha,
151caught by the compiler rather than by review, but it means those two are the only
152in-memory adapters with no tests beside them.
153
154## 4 · Naming and placement drift
155
156- **`web/repo.rs` is not "the repo page".** It is `repo_for` (the resolver every repo
157 page calls), `Tab`, `repo_header`, the About sidebar, the README block,
158 `clone_block`, `new_repo_form`, `split_fragment`, `clone_url_for` — *and* the
159 landing page (`:204`) and the new-repo form pages (`:102, :134`). **Thirteen** other
160 web modules import `repo::{Tab, repo_for, repo_header}`. The frame is a shared
161 module (`web/frame.rs`); the landing page and the new-repo pages are two pages.
162- **`web/browse.rs` is imported by thirteen modules** for things that are not
163 browsing: `ago` (6 modules), `timestamp` (6), `encode` (6), `tree_url`,
164 `log_url`, `civil_from_days`, `commit_log`. It carries **25 `pub(super)` items**,
165 more than three times any other web module. `ago`/`timestamp`/`size_of`/
166 `civil_from_days` are a `web/format.rs`; `encode`/`tree_url`/`raw_url`/`log_url`/
167 `path_log_url` are a `web/urls.rs`.
168- **`application/browse.rs` holds ref listing**, which `web/refs.rs` and
169 `web/search.rs` consume; `list_branches`/`list_tags`/`list_refs`/`organise_refs`/
170 `RefList`/`RefPage` are an `application/refs.rs`.
171- `fn optional(value: &str)` is written identically three times —
172 `web/repo.rs:74`, `web/repo_settings.rs:87`, `web/settings.rs:47`.
173- `context::timed_out` was correctly promoted out of `refs.rs` (progress.md predicted
174 this) and now has four callers. That one worked.
175- `architecture.md` says memoization "earns its keep" for direct reads. There are
176 **zero `#[memoize]` in the tree**; pages thread the resolved `RepoView` through
177 instead. The doc should say so rather than describing a mechanism nobody used.
178
179## 5 · Test organisation
180
1811,152 test functions, **all in 73 inline `#[cfg(test)] mod tests` blocks**; there is no
182`tests/` directory and no route-level or HTTP-level test anywhere.
183
184- **Use-case tests run on in-memory ports throughout** — no `Sqlite*` appears anywhere
185 under `src/application/`. Exactly as intended.
186- One adapter test reaches for a use case as a *fixture*: `sqlite.rs`'s tests import
187 `application::{OwnerSpec, claim_instance}` (`:1653`). It is setup, not assertion, but
188 it means a change to `claim_instance` can redden the SQLite suite.
189- **The web layer is the thin spot.** `web/pulls.rs` — 1,870 lines of code — has 7
190 tests; `web/issues.rs` (1,130) has 3; `web/labels.rs` (892) has 0. What lives there
191 and is untested is precisely the logic §1 and §6 flag as duplicated and divergent:
192 the three `write_failure` mappings and the five ownership guards. Moving them into
193 `web::context` would also give them one place to be tested.
194- Test-to-code ratios of 3.2:1 (`application/repo.rs`), 2.1:1 (`label.rs`) and 1.4:1
195 (`git_query.rs`) are healthy; they are a file-size problem, not a testing problem.
196
197## 6 · Duplication and inconsistency across waves
198
199- **Error mapping diverges.** Three `write_failure` functions — `labels.rs:177`,
200 `issues.rs:241`, `pulls.rs:321` — share a body, except that **labels maps
201 `DomainError::Forbidden` to 404 while issues and pulls map it to 403**. Pulls adds
202 `Archived`, `Unavailable` and stale-merge arms the other two lack. One
203 `context::write_failure` returning `Result<String, _>`, with page-specific arms
204 layered before the call, should win; the 403 is the right answer (the guard has
205 already 404'd anything invisible).
206- **Ownership guard**: five near-identical private helpers plus three inline copies
207 (§1). One `context::owned_repo` should win.
208- **Timeout-as-page** is consistent — `context::timed_out` in four modules, plus
209 `search.rs:182` handling it inside the use case because search's answer is a partial
210 result rather than an absent one. Both are right.
211- **The private-address rule is unified**: one `domain::net::is_public`
212 (`domain/net.rs:27`), used by `domain/webhook.rs:16` and `domain/import.rs:10`, with
213 the escape hatch as a single `PrivateTargets` value object threaded from
214 `web/context.rs:166`. This is the pattern the other items should copy.
215- **Paging is unified**: `application::Paged<T>` for every list (repos, issues, pulls,
216 commits, deliveries, refs) and one `web::paging::Steps` renderer. No drift.
217- **The `Clock` port is adopted, `now: SystemTime` is the norm, and the two known
218 exceptions are still there**: `import.rs:103` and `:164` read `SystemTime::now()` in
219 production code. Already recorded as open in `current.md`; nothing new.
220- **`too_many_arguments` is the wave signature.** 58 allows in the tree, 50 of them in
221 `application/`. `merge_pull` and `redeliver` take 17 parameters; `view_pull`,
222 `list_pulls` and `list_issues` take 12. **66 use cases take the same
223 `orgs`/`memberships`/`repos` triple.**
224
225## 7 · What the next features will need
226
227- **`/api` has lapsed badly.** Three routes — `/api/me`, `/api/users/{handle}`,
228 `/api/users/{handle}/repos` (`web/api.rs`, 124 lines) — against ~35 page routes and
229 34 POST handlers. Issues, pull requests, labels, webhooks, browsing, commits and
230 search have no API surface at all. ROADMAP.md calls this "a standing practice"; four
231 waves shipped without it. It is also the stated tripwire for whether the read/write
232 line is in the right place, so its absence has cost the check as well as the feature.
233- **There is no event concept, only a push event.** `PushEvent` is the only thing
234 recorded; `webhook::deliver_push`/`push_payload` deliver only pushes, and
235 `feed::activity_feed` can only report pushes. Issues opened, PRs merged and comments
236 posted produce nothing. Webhooks, the activity feed and any future notification all
237 want the same shape — an append-only `RepoEvent { repo_id, kind, actor, subject,
238 at }` with `PushEvent` as one variant. **This deserves an ADR**: the payload shape is
239 expensive to change once anything consumes it.
240- **The shared "repository facts" read already exists** — `application/summary.rs`
241 (`RepoFacts`, 538 lines) — contrary to the brief's guess. What is missing is that
242 `view_repo` is reached by thirteen modules and always pays two `count(*)` queries
243 (`repo.rs:255`) plus a membership lookup, even on a raw-blob request that draws no
244 tab strip. A `visible_repo`-only path for the non-frame routes is a small win.
245- **The reconciliation sweep exists and covers the three known holes** —
246 `application/reconcile.rs` (374 lines), documented against `architecture.md`'s
247 DB-plus-filesystem section, running on boot beside the interrupted-import and reflog
248 sweeps. `architecture.md`'s "neither prior attempt fully solved this / the durable
249 fix is a reconciliation sweep on boot" is now out of date and should say so.
250- **Milestone 7 pressure.** `Role::Owner` appears in 18 application modules, but only
251 ever through `authz::is_org_owner`/`is_org_member` (46 lines, two predicates) — the
252 single place multi-user has to change. That part is ready. What is not: every write
253 use case is owner-only by construction (`writable_repo`), so "a collaborator may
254 comment on an issue but not merge" needs a third predicate and a permission argument
255 threaded through ~30 use cases that currently take none. The 17-parameter problem
256 makes that materially harder, which is the argument for fixing it first.
257
258## Proposed reshaping
259
260Ordered. Every step compiles on its own and none changes behaviour unless noted.
261**P** = safe to run in parallel with the others marked P.
262
263| # | Refactor | Files | Size | Risk | Unlocks |
264|---|---|---|---|---|---|
265| 1 **P** | Move the four biggest `mod tests` blocks to `#[path]` siblings | `application/repo.rs`, `pull_request.rs`, `browse.rs`, `webhook.rs`, `infrastructure/git_query.rs`, `repository/sqlite.rs` | small | none | ~9,000 lines out of the top files; every later split reads a real file |
266| 2 **P** | `context::owned_repo` + `context::write_failure`; delete the 8 guards and 3 mappings | 9 `web/*.rs` | small | low — settles labels' 404 to 403 | one tested answer; §5's untested logic becomes testable |
267| 3 **P** | `web/format.rs` and `web/urls.rs`; move `ago`, `timestamp`, `size_of`, `civil_from_days`, `encode`, `*_url` out of `web/browse.rs`; fold the three `fn optional` into one | 13 `web/*.rs` | small | none | `web/browse.rs` stops being everyone's dependency |
268| 4 | `web/frame.rs` — `repo_for`, `Tab`, `repo_header`, sidebar, README, `clone_block`, `clone_url_for`; `web/repo.rs` keeps the landing page, new-repo pages move to `web/new_repo.rs` | 14 `web/*.rs` | half a day | low | the hotspot 13 modules import stops being a page module |
269| 5 | Move `readable_repo`/`writable_repo` to `application/access.rs`; give the `orgs`/`memberships`/`repos` triple one struct passed by reference | most of `application/`, all call sites | half a day | medium — wide, mechanical | −3 params on 66 use cases; makes step 8 and milestone 7 tractable |
270| 6 **P** | `NumberRepository` (or `RepoRepository::next_number`) for `repo_sequences`; drop `IssueRepository` from the four PR use cases | `domain/repository/`, both adapters, `application/pull_request.rs` | small | low | issues and PRs stop reaching into each other's port |
271| 7 **P** | Split `infrastructure/git.rs` into `git/{storage,protocol,archive,import,merge}.rs`; move `InMemoryGitQuery` next to `DiskGitQuery` | `infrastructure/git.rs` → 5 files | half a day | none | both implementations of a port sit together |
272| 8 **P** | Split `git_query.rs`'s parsers into `git_query/parse_{refs,log,tree,diff}.rs` | `infrastructure/git_query.rs` | half a day | none | the 4,956-line hotspot becomes five readable files |
273| 9 | Split `web/pulls.rs` along its existing section comments into `pulls/{list,open,detail,writes}.rs` | `web/pulls.rs` → 4 | half a day | low | the largest untested web module gets seams to test at |
274| 10 | `application/refs.rs` out of `application/browse.rs`; `browse.rs` keeps tree/blob/log | `application/browse.rs`, `web/refs.rs`, `web/search.rs` | small | low | module names say what they hold |
275| 11 | Split `GitQuery` into `GitRefs`/`GitObjects`/`GitHistory`/`GitSearch`; move `merge_preview` onto `GitMerge` | `port.rs`, 2 impls, 26 call sites | a day | medium | narrow ports as 0006 intended — **do after 5**, and **worth an ADR amendment to 0006** |
276| 12 | `RepoEvent` append-only log; `PushEvent` becomes one variant; webhooks and the activity feed read it | `domain/`, both adapters, `application/{webhook,feed,push,issue,pull_request}.rs` | a day | high — schema and payload | issue/PR/comment webhooks, a real activity feed, notifications — **needs an ADR** |
277| 13 | `/api` for issues, pulls, labels, repos-write, browse | `web/api.rs` → `web/api/` | a day+ | low | the standing practice restarts, and the read/write line gets its third consumer |
278
279**Hotspots.** Steps 4, 5, 9 and 11 touch files many others import; run them one at a
280time. Steps 1, 2, 3, 6, 7, 8 are disjoint and can go in any order or together. Steps
28112 and 13 are features wearing refactor clothes and belong on the ladder, not in a
282tidy-up.
283
284**ADRs owed:** 11 (an amendment to
285[0006]decisions/0006-git-binary-behind-narrow-ports.md, which claims narrow ports and
286now has a 24-method one) and 12 (a new record — an event payload is expensive to
287reverse once webhooks emit it). Step 5 does not need one; it is a call-convention change
288inside a rule `architecture.md` already states.
289
290**Docs owed regardless of any refactor:** `architecture.md`'s DB-plus-filesystem section
291still says the reconciliation sweep is the durable fix nobody has written —
292`application/reconcile.rs` has written it. And its "Data access" section credits
293`#[memoize]` with earning its keep; nothing in the tree uses it.