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