steid

@jamesgill /

steid/plans/architecture.md
4.9 KBCode·Blame·Raw
ab7fea9chore: plans setup1mo
1# Architecture
2
3## Layers
4
5DDD + clean architecture. Dependencies point inward. The domain knows nothing about
6HTTP, SQL, git, or Topcoat.
7
8```
9web / ssh (interface) → application (use cases) → domain (entities, ports)
10
11 infrastructure (sqlite, git binary, argon2)
12 implements the ports the domain/application declare
13```
14
15| Layer | Holds | Must not import |
16|---|---|---|
17| `domain` | entities, value objects, typed IDs, repository traits, `DomainError` | anything infrastructural |
18| `application` | use cases, ports (`GitStorage`, `GitProtocolServer`, `PasswordHasher`), `AppConfig` | concrete adapters |
88583f2docs: bring tracking docs up to date with milestone 11mo
19| `infrastructure` | SQLite repos, `git` subprocess wrappers, Argon2, sessions, web handlers ||
ab7fea9chore: plans setup1mo
20
21**Every operation takes an `Actor`.** Authorization is checked in the use case, before
22any side effect. Not in the handler, not in the adapter. The previous attempt got this
23right and it paid off — `serve_push` could enforce `Role::Owner` in one place
24regardless of transport.
25
26## Conventions
27
28These come from two prior attempts. They earned their place.
29
30### Typed IDs — never raw strings for entity references
31
32```rust
33// CORRECT — the compiler catches a swapped argument
34fn get_member(&self, org_id: &OrgId, user_id: &UserId) -> ...
35
36// WRONG — silently compiles, fails at runtime
37fn get_member(&self, org_id: &str, user_id: &str) -> ...
38```
39
40`UserId`, `OrgId`, `MembershipId`, `RepoId`, `InviteCodeId`, `SshKeyId`.
41
42### `new()` validates, `from_trusted()` doesn't
43
44`new()` is for user input and returns `Result`. `from_trusted()` is for rows loaded
45out of the database, which were validated on the way in. Infrastructure repos should
46always use `from_trusted()` — re-validating DB rows means a validation-rule change
47turns old rows unreadable.
48
49### `from_str` returns `Result`, never `Option`
50
51A silently-defaulted enum parse is a bug that surfaces days later as wrong
52permissions. Applies to `Role`, `Visibility`, `RegistrationPolicy`.
53
54### Every repository port gets two implementations
55
56An in-memory one and a SQLite one. The in-memory one is what makes use cases testable
57without a database — attempt #2 reached 60 tests this way and they ran fast enough to
58stay in the loop.
59
60### Ports live where they're consumed
61
62Repository traits in `domain/repository/`. Service ports the application needs
63(`GitStorage`, `GitProtocolServer`, `PasswordHasher`) in `application/port.rs`. Use
64cases take `&impl Port`, not a boxed trait object.
65
66## DB-plus-filesystem writes
67
68Creating a repo writes to two places that can't share a transaction: the
69`repositories` row and the bare repo on disk. Neither prior attempt fully solved this.
70
71Attempt #1 used a **compensating transaction** — create on disk first, and if the DB
72insert fails, delete the directory:
73
74```rust
75let repo = self.repository.create(&id).await?;
76if let Err(e) = self.repo_record_repo.create(&record).await {
77 let _ = self.repository.delete(&id).await; // compensate
78 return Err(e.into());
79}
80```
81
82That leaks an orphaned directory if the process dies between the two calls. Good
83enough to ship; write down that it's a known hole rather than rediscovering it. The
84durable fix is a reconciliation sweep on boot, or marking rows pending and committing
85after the filesystem write lands.
86
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo
87## Data access
ab7fea9chore: plans setup1mo
88
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo
89Topcoat's grain is async components that query the database directly and check
90permissions inline, with `#[memoize]` deduplicating calls per request. State comes from
91`app_context::<T>(cx)`, and the framework's own guidance is to "prefer composable
ab7fea9chore: plans setup1mo
92`cx: &Cx` functions over middleware/extractors for auth and request-scoped data."
93
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo
94That reads as a view layer reaching for the data layer, which the rules above forbid.
95It was a genuinely hard question while SSH was the git transport, because an SSH
96channel handler has no `Cx` — so authorization expressed in a component was invisible
97to `serve_push`, and the two had to share something.
98
99[Decision 0001]decisions/0001-git-over-http-not-ssh.md removed that transport. Every
100caller now has a `Cx`, so nothing *forces* a transport-neutral use case layer. The
101position is therefore a choice, taken deliberately:
102
103**Writes and authorization go through use cases. Reads may go direct.**
ab7fea9chore: plans setup1mo
104
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo
105- Any mutation, and any decision about what an actor is allowed to do, lives in an
106 application use case that takes an `Actor` and the ports it needs. One place, one
107 answer, reachable from a page, an `/api` route, or a future SSH adapter alike.
108- Straightforward reads for display may query through the pool in a component, which
109 is where memoization and per-component fetching earn their keep.
ab7fea9chore: plans setup1mo
110
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo
111The tell for when a read has outgrown that: if it starts deciding whether the viewer is
112allowed to see something, it isn't a read any more — move it.
ab7fea9chore: plans setup1mo
113
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo
114Revisit if `/api` and the pages start duplicating query logic. That's the signal the
115line is in the wrong place.
ab7fea9chore: plans setup1mo
116
117## Safety
118
119Safe Rust only. No `unsafe`.