# Architecture

## Layers

DDD + clean architecture. Dependencies point inward. The domain knows nothing about
HTTP, SQL, git, or Topcoat.

```
web / ssh  (interface)   →  application (use cases)  →  domain (entities, ports)
                                    ↑
                         infrastructure (sqlite, git binary, argon2)
                         implements the ports the domain/application declare
```

| Layer | Holds | Must not import |
|---|---|---|
| `domain` | entities, value objects, typed IDs, repository traits, `DomainError` | anything infrastructural |
| `application` | use cases, ports (`GitStorage`, `GitProtocolServer`, `PasswordHasher`), `AppConfig` | concrete adapters |
| `infrastructure` | SQLite repos, `git` subprocess wrappers, Argon2, sessions, web handlers | — |

**Every operation takes an `Actor`.** Authorization is checked in the use case, before
any side effect. Not in the handler, not in the adapter. The previous attempt got this
right and it paid off — `serve_push` could enforce `Role::Owner` in one place
regardless of transport.

## Conventions

These come from two prior attempts. They earned their place.

### Typed IDs — never raw strings for entity references

```rust
// CORRECT — the compiler catches a swapped argument
fn get_member(&self, org_id: &OrgId, user_id: &UserId) -> ...

// WRONG — silently compiles, fails at runtime
fn get_member(&self, org_id: &str, user_id: &str) -> ...
```

`UserId`, `OrgId`, `MembershipId`, `RepoId`, `InviteCodeId`, `SshKeyId`.

### `new()` validates, `from_trusted()` doesn't

`new()` is for user input and returns `Result`. `from_trusted()` is for rows loaded
out of the database, which were validated on the way in. Infrastructure repos should
always use `from_trusted()` — re-validating DB rows means a validation-rule change
turns old rows unreadable.

### `from_str` returns `Result`, never `Option`

A silently-defaulted enum parse is a bug that surfaces days later as wrong
permissions. Applies to `Role`, `Visibility`, `RegistrationPolicy`.

### Every repository port gets two implementations

An in-memory one and a SQLite one. The in-memory one is what makes use cases testable
without a database — attempt #2 reached 60 tests this way and they ran fast enough to
stay in the loop.

### Ports live where they're consumed

Repository traits in `domain/repository/`. Service ports the application needs
(`GitStorage`, `GitProtocolServer`, `PasswordHasher`) in `application/port.rs`. Use
cases take `&impl Port`, not a boxed trait object.

Git is deliberately several narrow ports rather than one service, and how the binary is
actually invoked lives in one place in `infrastructure/git.rs` —
[0006](decisions/0006-git-binary-behind-narrow-ports.md).

## DB-plus-filesystem writes

Creating a repo writes to two places that can't share a transaction: the
`repositories` row and the bare repo on disk. Neither prior attempt fully solved this.

Attempt #1 used a **compensating transaction** — create on disk first, and if the DB
insert fails, delete the directory:

```rust
let repo = self.repository.create(&id).await?;
if let Err(e) = self.repo_record_repo.create(&record).await {
    let _ = self.repository.delete(&id).await;  // compensate
    return Err(e.into());
}
```

That leaks an orphaned directory if the process dies between the two calls. Good
enough to ship; write down that it's a known hole rather than rediscovering it. The
durable fix is a reconciliation sweep on boot, or marking rows pending and committing
after the filesystem write lands.

## Data access

Topcoat's grain is async components that query the database directly and check
permissions inline, with `#[memoize]` deduplicating calls per request. State comes from
`app_context::<T>(cx)`, and the framework's own guidance is to "prefer composable
`cx: &Cx` functions over middleware/extractors for auth and request-scoped data."

That reads as a view layer reaching for the data layer, which the rules above forbid.
It was a genuinely hard question while SSH was the git transport, because an SSH
channel handler has no `Cx` — so authorization expressed in a component was invisible
to `serve_push`, and the two had to share something.

[Decision 0001](decisions/0001-git-over-http-not-ssh.md) removed that transport. Every
caller now has a `Cx`, so nothing *forces* a transport-neutral use case layer. The
position is therefore a choice, taken deliberately:

**Writes and authorization go through use cases. Reads may go direct.**

- Any mutation, and any decision about what an actor is allowed to do, lives in an
  application use case that takes an `Actor` and the ports it needs. One place, one
  answer, reachable from a page, an `/api` route, or a future SSH adapter alike.
- Straightforward reads for display may query through the pool in a component, which
  is where memoization and per-component fetching earn their keep.

The tell for when a read has outgrown that: if it starts deciding whether the viewer is
allowed to see something, it isn't a read any more — move it.

Revisit if `/api` and the pages start duplicating query logic. That's the signal the
line is in the wrong place.

## Safety

Safe Rust only. No `unsafe`.
