| 1 | # Review — DDD and organisation, 2026-09-06 |
| 2 | |
| 3 | Read-only review of `src/` (65,541 lines) against `plans/architecture.md`, the seven |
| 4 | ADRs, and the conventions in `CLAUDE.md`. No code was changed by this review. |
| 5 | |
| 6 | ## Verdict |
| 7 | |
| 8 | The layer rules survived four waves of parallel agents essentially intact: `domain/` |
| 9 | imports nothing from `application/` or `infrastructure/` anywhere, `application/` |
| 10 | reaches into `infrastructure/` only inside three `mod tests` blocks, and **not one of |
| 11 | the 34 POST handlers writes through a repository port** — every mutation goes through a |
| 12 | use case that takes an `Actor`. What did not survive is *shape at the seams*. The |
| 13 | application 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 |
| 16 | methods across a 450-line trait; `web/repo.rs` and `web/browse.rs` have become the |
| 17 | grab-bags twelve and thirteen other page modules import from; and four files are over |
| 18 | 2,000 lines mostly because their `mod tests` block is two to three times the code it |
| 19 | tests. None of this is rot — it is the predictable cost of many hands adding to the same |
| 20 | files without anyone owning the boundary between them. Everything below is |
| 21 | addressable 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() }` |
| 41 | helper 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) — |
| 43 | plus three more inline copies in `repo.rs:108`, `settings.rs:55`, `token.rs:58`. Each |
| 44 | carries a comment explaining the same 404-not-403 rule. The use cases re-check, so this |
| 45 | is duplication, not a hole. It belongs in `web::context` next to `current_actor`. |
| 46 | |
| 47 | ## 2 · Aggregates and ports |
| 48 | |
| 49 | |
| 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 | |
| 64 | Twelve aggregates, twelve traits, twenty-four implementations. **The two-implementation |
| 65 | rule 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, |
| 68 | 450 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 |
| 76 | change. It is worth doing when a *third* implementor appears, not before; the cheaper |
| 77 | and 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 |
| 79 | two 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 |
| 86 | sequence 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 |
| 88 | method on `RepoRepository`), which also removes one port from four PR use cases. |
| 89 | |
| 90 | Similarly, `readable_repo` and `writable_repo` — the resolution every issue, label, |
| 91 | pull and webhook use case starts with — live in `application/issue.rs:483, 511` and are |
| 92 | imported by `label.rs:29` and `pull_request.rs:39`. Correct behaviour, wrong home. |
| 93 | |
| 94 | ## 3 · File size and cohesion |
| 95 | |
| 96 | |
| 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 |
| 110 | 737 lines of eight coherent use cases behind a 2,382-line test module. Moving the block |
| 111 | to a sibling with `#[cfg(test)] #[path = "repo_tests.rs"] mod tests;` is mechanical, |
| 112 | zero-risk, and takes ~9,000 lines out of the four biggest application/adapter files |
| 113 | without touching a line of logic. |
| 114 | |
| 115 | Genuinely 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; |
| 148 | they only want splitting once the test blocks move out and the remainder is still large. |
| 149 | One placement slip: `InMemorySessionRepo` and `InMemoryRepoRepo` were appended *after* |
| 150 | `mod tests` (`in_memory.rs:1589, 1626`; tests at `:906`) — the `CLAUDE.md` gotcha, |
| 151 | caught by the compiler rather than by review, but it means those two are the only |
| 152 | in-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 | |
| 181 | 1,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 | |
| 260 | Ordered. 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 | |
| 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 |
| 280 | time. Steps 1, 2, 3, 6, 7, 8 are disjoint and can go in any order or together. Steps |
| 281 | 12 and 13 are features wearing refactor clothes and belong on the ladder, not in a |
| 282 | tidy-up. |
| 283 | |
| 284 | **ADRs owed:** 11 (an amendment to |
| 285 | [0006](decisions/0006-git-binary-behind-narrow-ports.md), which claims narrow ports and |
| 286 | now has a 24-method one) and 12 (a new record — an event payload is expensive to |
| 287 | reverse once webhooks emit it). Step 5 does not need one; it is a call-convention change |
| 288 | inside a rule `architecture.md` already states. |
| 289 | |
| 290 | **Docs owed regardless of any refactor:** `architecture.md`'s DB-plus-filesystem section |
| 291 | still 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. |