| 1 | # Decisions log |
| 2 | |
| 3 | A running log of design decisions: date, decision, alternatives considered, and |
| 4 | rationale. Seeded with the decisions the spec (`spec.md`) already makes. Update |
| 5 | this file on any deviation from the spec (a **SHOULD** deviation **MUST** be |
| 6 | logged here). |
| 7 | |
| 8 | --- |
| 9 | |
| 10 | ## 2026-07-24 — Pack transport via `git` subprocess |
| 11 | |
| 12 | **Decision:** Server-side clone/fetch/push is handled by spawning the `git` |
| 13 | binary (`git upload-pack` / `git receive-pack`), not by libgit2. |
| 14 | |
| 15 | **Alternatives:** Implement the smart pack protocol on top of libgit2's object |
| 16 | layer; use a pure-Rust reimplementation (`gitoxide`). |
| 17 | |
| 18 | **Rationale:** libgit2 is client-only for transport — it has no server-side |
| 19 | `upload-pack`/`receive-pack`. Spawning `git` is the supported, correct path. |
| 20 | libgit2 (via `git2`) is retained for in-process reads (refs, trees, blobs, log, |
| 21 | diff, signatures, `init_bare`). Consequence: `git` (≥ 2.41) MUST be on `PATH` at |
| 22 | runtime; the Nix derivation wraps the binary with git on PATH. |
| 23 | |
| 24 | ## 2026-07-24 — Repositories stored by id, not name |
| 25 | |
| 26 | **Decision:** On disk, repos live at `{repo_dir}/{id[0..2]}/{id}.git` where `id` |
| 27 | is a lowercase ULID. The database is the only name→path authority. |
| 28 | |
| 29 | **Alternatives:** Store repos by `{owner}/{name}.git` mirroring the URL. |
| 30 | |
| 31 | **Rationale:** Renames and group moves become metadata-only (a DB update), never |
| 32 | touching the filesystem. Tradeoff: the tree is not human-browsable; |
| 33 | `fabrica repo path <user> <name>` resolves it for operators. |
| 34 | |
| 35 | ## 2026-07-24 — `/-/` route separator |
| 36 | |
| 37 | **Decision:** Web and API routes disambiguate variable-length repo paths from |
| 38 | sub-resources with the GitLab `/-/` convention: everything before `/-/` is the |
| 39 | repo path, everything after is the view. |
| 40 | |
| 41 | **Alternatives:** Fixed-depth paths; a query parameter for the view. |
| 42 | |
| 43 | **Rationale:** Group nesting makes repo paths of unknown depth. `/-/` is an |
| 44 | unambiguous, well-understood separator. |
| 45 | |
| 46 | ## 2026-07-24 — Timestamps as epoch milliseconds |
| 47 | |
| 48 | **Decision:** All timestamps are stored as `BIGINT` unix milliseconds (UTC). No |
| 49 | `DATETIME` / `TIMESTAMPTZ`. |
| 50 | |
| 51 | **Alternatives:** Native date/time column types per dialect. |
| 52 | |
| 53 | **Rationale:** One portable representation across SQLite and Postgres, ordered by |
| 54 | plain integer comparison. Ids are `TEXT` ULIDs (lexicographically sortable) used |
| 55 | for tie-breaking and ordering. |
| 56 | |
| 57 | ## 2026-07-24 — `serve` runs in the foreground; no `stop` subcommand |
| 58 | |
| 59 | **Decision:** `fabrica serve` runs in the foreground by default and is stopped by |
| 60 | signalling its pid (`SIGTERM`/`SIGINT`). `--detach` double-forks and writes a pid |
| 61 | file. There is no `server start` / `server stop` pair. |
| 62 | |
| 63 | **Alternatives:** A daemonizing `start`/`stop`/`restart` command set. |
| 64 | |
| 65 | **Rationale:** Foreground is correct for systemd and Docker, which own process |
| 66 | lifecycle. This deviates from an earlier `server start`/`server stop` sketch. |
| 67 | |
| 68 | ## 2026-07-25 — Polish: signature cache wired; `theme list`/`gc` CLI deferred |
| 69 | |
| 70 | **Decision:** The `moka` `SignatureCache` (bounded 4096, 10-minute TTL) is wired |
| 71 | into `AppState` and used by the commit list and commit page. The |
| 72 | `fabrica theme list` and `fabrica gc` CLI subcommands are **not** shipped. |
| 73 | |
| 74 | **Alternatives:** Also cache attributes/highlight; implement `theme list`/`gc`. |
| 75 | |
| 76 | **Rationale:** Signature verification is the measured hot path (list views verify |
| 77 | every row), so it is the one place caching earns its keep (§18 "moka where |
| 78 | measured"); the TTL bounds staleness after a key change without explicit |
| 79 | invalidation. `theme list` would need the embedded theme registry, which lives in |
| 80 | `web` and cannot be reached from `cli` (nothing may depend on `web`); the web theme |
| 81 | picker already lists every theme, so the CLI command is low value. `gc` |
| 82 | (trash reaping) is a maintenance convenience with no correctness impact; both are |
| 83 | tracked gaps, not blockers. The per-file highlight **timeout** guard (§8) also |
| 84 | remains deferred as noted earlier. |
| 85 | |
| 86 | ## 2026-07-25 — API nested into web via `nest_service`; web may depend on api |
| 87 | |
| 88 | **Decision:** The `/api/v1` JSON surface is a self-contained `Router` built by |
| 89 | `api::router(store, config, secret)` with its own state applied, and `web` nests it |
| 90 | with `.nest_service("/api/v1", …)`. `web` depends on `api`. |
| 91 | |
| 92 | **Alternatives:** Run the API on a separate port/server; have `api` depend on `web` |
| 93 | (forbidden — nothing may depend on `web`). |
| 94 | |
| 95 | **Rationale:** The dependency rule bars anything depending on `web`, but says |
| 96 | nothing against `web → api` (both are top-level). Nesting keeps one server, one |
| 97 | port, and one middleware stack. Because the nested router is fully stated |
| 98 | (`Router<()>`), `nest_service` mounts it as a `Service` and strips the `/api/v1` |
| 99 | prefix, so `api` defines routes relative to root and never learns `web`'s |
| 100 | `AppState`. Repo sub-resources are parsed by scanning `{*rest}` for the first |
| 101 | keyword segment (`branches`/`tags`/`commits`/`tree`/`raw`); a repo named exactly |
| 102 | after one of those would be misrouted — a documented MVP limitation. The API |
| 103 | search returns name/description matches only (content search stays in `web`). |
| 104 | |
| 105 | ## 2026-07-25 — Search lives in `web`; `path:` is a substring for the MVP |
| 106 | |
| 107 | **Decision:** Search (the qualifier parser, in-repo scan, and bounded global scan) |
| 108 | is implemented in `crates/web`, not a separate crate, using ripgrep's `grep` |
| 109 | engine. The `path:` qualifier matches as a case-insensitive **substring**, not a |
| 110 | full glob. |
| 111 | |
| 112 | **Alternatives:** A dedicated `search` crate; full glob path matching via |
| 113 | `globset`. |
| 114 | |
| 115 | **Rationale:** Search is a web-only feature with no other consumer, so a crate of |
| 116 | its own adds indirection for no reuse; the content scan runs inside the existing |
| 117 | `git_read` blocking closure. The global scan bounds itself with a |
| 118 | `Semaphore(search.concurrency)` and a `tokio::time::timeout(search.timeout_secs)` |
| 119 | deadline, returning partial results with an explicit notice on either the time or |
| 120 | `search.max_results` cap. A substring `path:` covers the common case; upgrading to |
| 121 | `globset` is a contained change if needed. `linguist-vendored`/`-generated` |
| 122 | exclusion is deferred (binary and >1 MiB files are already skipped). |
| 123 | |
| 124 | ## 2026-07-24 — Linear search, no index |
| 125 | |
| 126 | **Decision:** Search (in-repo and global) is a linear scan over repo contents at |
| 127 | request time, with worker-pool, time-budget, and result-count bounds. |
| 128 | |
| 129 | **Alternatives:** Maintain a `tantivy` full-text index. |
| 130 | |
| 131 | **Rationale:** Appropriate for a small, private instance. A `tantivy` index is |
| 132 | the intended path if linear scan stops being fast enough. |
| 133 | |
| 134 | ## 2026-07-24 — Nix flake uses flake-parts |
| 135 | |
| 136 | **Decision:** The flake is structured with `flake-parts` (module system, |
| 137 | `perSystem`) rather than `flake-utils`. |
| 138 | |
| 139 | **Alternatives:** `flake-utils.lib.eachDefaultSystem`. |
| 140 | |
| 141 | **Rationale:** flake-parts scales better as the flake grows (NixOS module, |
| 142 | container image, multiple checks) and gives a cleaner multi-output structure. The |
| 143 | spec permits either; picked one and stay consistent. |
| 144 | |
| 145 | ## 2026-07-24 — Secrets are a redact-by-default `Secret` newtype |
| 146 | |
| 147 | **Decision:** Secret configuration values (`auth.secret`, `mail.password`) are |
| 148 | wrapped in a `config::Secret` newtype whose `Debug` and `Serialize` impls always |
| 149 | emit `<redacted>`. The real value is reachable only through `Secret::expose`. |
| 150 | |
| 151 | **Alternatives:** Store secrets as plain `String` and redact them at each output |
| 152 | site (`config show`, error messages, logs). |
| 153 | |
| 154 | **Rationale:** Redaction is the default and cannot be forgotten — a `Secret` |
| 155 | cannot leak through `tracing`, a `Debug`-formatted `Config`, `fabrica config |
| 156 | show`, or the JSON API, satisfying the spec's "secrets MUST NOT be logged, |
| 157 | echoed, or included in error messages". Site-by-site redaction is one missed |
| 158 | call away from a leak. Trade-off: serialization is lossy, so `Secret` is never |
| 159 | round-tripped through our own `Serialize` (figment reads the raw value into the |
| 160 | figment `Value` before deserialization, and `*_file` resolution reads from disk). |
| 161 | |
| 162 | ## 2026-07-24 — Strict config: unknown keys are errors, missing files are not |
| 163 | |
| 164 | **Decision:** Every config struct is `#[serde(deny_unknown_fields)]`, so an |
| 165 | unknown key (typo, stale key, stray `FABRICA__*`) aborts the load. Missing files |
| 166 | at the fixed locations (`/etc`, XDG) and a missing `--config` path are tolerated |
| 167 | silently. Directory validation checks creatability without side effects (an |
| 168 | existing directory ancestor), never creating anything during `config check`. |
| 169 | |
| 170 | **Alternatives:** Ignore unknown keys (serde default); require every file to |
| 171 | exist; create directories during validation. |
| 172 | |
| 173 | **Rationale:** Failing fast on typos is worth more than forward-compatibility for |
| 174 | a single-operator forge. Tolerating missing files lets one binary serve both a |
| 175 | bare first-run machine and a fully-configured one. Side-effect-free validation |
| 176 | keeps `config check` safe to run anywhere. |
| 177 | |
| 178 | ## 2026-07-24 — Store runs over `sqlx::Any`, not per-dialect code paths |
| 179 | |
| 180 | **Decision:** The `store` crate uses a single `sqlx::Any` pool and writes each |
| 181 | statement once, rather than a `Store` trait with separate `SqlitePool` and |
| 182 | `PgPool` implementations. Three dialect differences are mediated explicitly: |
| 183 | placeholders use the `$1,$2` syntax both SQLite and Postgres accept (only MySQL |
| 184 | needs `?`); booleans bind as Rust `bool` (encodes correctly for both) and are |
| 185 | read back through `Store::get_bool`, which decodes SQLite's `INTEGER` storage and |
| 186 | Postgres's native `BOOLEAN`; timestamps and ids are already portable (`BIGINT` |
| 187 | ms, `TEXT` ULID). |
| 188 | |
| 189 | **Alternatives:** A `Store` trait with two concrete pool implementations (the |
| 190 | spec's other blessed option), duplicating every query per dialect. |
| 191 | |
| 192 | **Rationale:** The spec explicitly permits "runtime queries over `AnyPool`". One |
| 193 | code path is far less surface to keep in sync than two, and the portability rules |
| 194 | (portable SQL subset, no compile-time-checked macros) already constrain us to the |
| 195 | intersection where `Any` is safe. The single genuinely divergent read — booleans |
| 196 | — is funnelled through one helper, which is exactly the "the trait maps them" |
| 197 | seam the spec calls for. Migrations remain two dialect-specific `.sql` sets, |
| 198 | embedded via `sqlx::migrate!` and selected at runtime by backend. |
| 199 | |
| 200 | ## 2026-07-24 — sqlx built without a TLS backend (for now) |
| 201 | |
| 202 | **Decision:** `sqlx` is pulled with `default-features = false` and no |
| 203 | `tls-*` feature; Postgres connections are plaintext. |
| 204 | |
| 205 | **Alternatives:** Enable `tls-rustls-ring` (or native-tls) so Postgres-over-TLS |
| 206 | works out of the box. |
| 207 | |
| 208 | **Rationale:** TLS is only relevant to a remote Postgres, which is a phase-17 |
| 209 | deployment concern; SQLite (the default) and local/socket Postgres need none. |
| 210 | Omitting it keeps the dependency tree small and, notably, keeps `ring` out of the |
| 211 | graph so `cargo-deny`'s license set stays clean without special-casing. To be |
| 212 | revisited when deployment against a managed Postgres is implemented; the change |
| 213 | is a one-line feature addition plus a `deny.toml` license allowance. |
| 214 | |
| 215 | ## 2026-07-24 — Nix build source keeps `.sql` migration files |
| 216 | |
| 217 | **Decision:** `flake.nix` replaces `craneLib.cleanCargoSource` with a |
| 218 | `cleanSourceWith` filter that additionally keeps `*.sql`, because |
| 219 | `sqlx::migrate!` embeds the migration files at compile time (and a store test |
| 220 | `include_str!`s them for the schema-equivalence check). |
| 221 | |
| 222 | **Alternatives:** Move migrations under a path crane already keeps; generate them |
| 223 | into `OUT_DIR`. |
| 224 | |
| 225 | **Rationale:** Crane's cargo-source filter strips non-Rust files, which would |
| 226 | break every derivation that compiles `store`. Widening the filter is the minimal, |
| 227 | explicit fix and leaves room to add further asset globs (themes, vendored htmx) |
| 228 | the same way in later phases. |
| 229 | |
| 230 | ## 2026-07-24 — Hand-rolled HS256 JWTs instead of a JWT crate |
| 231 | |
| 232 | **Decision:** `crates/auth` implements the HS256 JSON Web Token construction |
| 233 | directly (base64url of a fixed JOSE header and a serde JSON payload, an |
| 234 | `hmac`/`sha2` HMAC-SHA256, constant-time verification via `Mac::verify_slice`) |
| 235 | rather than depending on `jsonwebtoken` or similar. |
| 236 | |
| 237 | **Alternatives:** Use the `jsonwebtoken` crate. |
| 238 | |
| 239 | **Rationale:** The mainstream JWT crates depend on `ring`, whose license set is |
| 240 | not in `deny.toml`'s allow-list (it carries OpenSSL-derived terms), so pulling |
| 241 | one in would fail `cargo-deny` — the same reason sqlx is built without a TLS |
| 242 | backend. The HS256 surface we need is tiny and well-specified; hand-rolling it is |
| 243 | a few dozen lines over crates already in the tree (`hmac`, `sha2`, `base64`, |
| 244 | `serde_json`) and lets us reject algorithm-confusion tokens explicitly. Tokens |
| 245 | are minted and verified only by fabrica, so interop with other JWT producers is a |
| 246 | non-goal. |
| 247 | |
| 248 | ## 2026-07-24 — `access()` takes the collaborator row and anonymous flag explicitly |
| 249 | |
| 250 | **Decision:** The permission function is |
| 251 | `access(viewer: Option<&Viewer>, repo: &Repo, collaborator: Option<Permission>, |
| 252 | allow_anonymous: bool) -> Access`, widening the two-argument shape sketched in |
| 253 | the spec. |
| 254 | |
| 255 | **Alternatives:** Keep the literal `access(viewer, repo)` signature and thread the |
| 256 | collaborator lookup and `instance.allow_anonymous` in via `Viewer` or a context |
| 257 | struct. |
| 258 | |
| 259 | **Rationale:** Rule 3 needs the viewer's `repo_collaborators` row and rule 4 needs |
| 260 | `instance.allow_anonymous`; neither lives on `Viewer` or `Repo`. Passing them as |
| 261 | explicit parameters keeps `access` a pure function of its inputs — which is what |
| 262 | makes the exhaustive matrix test possible — and keeps the collaborator lookup at |
| 263 | the call site (the store), where it belongs. The five ordered rules are unchanged |
| 264 | from the spec. |
| 265 | |
| 266 | ## 2026-07-24 — `user` CLI pulled forward; store-backed commands self-migrate |
| 267 | |
| 268 | **Decision:** The `fabrica user` command group (`add`, `passwd`, `list`, |
| 269 | `disable`, `enable`, `del`) is implemented now, alongside phase 4, rather than |
| 270 | waiting for the phase-8 CLI milestone. Every store-backed command applies pending |
| 271 | migrations on connect (idempotent), so a fresh install's first `user add` |
| 272 | bootstraps the database in one step. |
| 273 | |
| 274 | **Alternatives:** Keep strictly to phase order and ship the `user` commands only |
| 275 | in phase 8; require a separate `fabrica migrate` before any `user` command works. |
| 276 | |
| 277 | **Rationale:** The requested capability — set a user's password from the CLI when |
| 278 | SMTP is unavailable — is realized by `user passwd`, which needs both `auth` |
| 279 | (hashing) and `store` (persistence) but nothing from the intervening phases |
| 280 | (git/highlight). Building it now delivers that capability immediately and keeps |
| 281 | the auth crate exercised by a real caller. Self-migrating on connect is what makes |
| 282 | `user add` usable before `serve` has ever run; `serve --no-migrate` remains the |
| 283 | opt-out for the server path. |
| 284 | |
| 285 | ## 2026-07-24 — Interim `user add` without `--pass` creates an inactive account |
| 286 | |
| 287 | **Decision:** Until the mail/invite flow ships (phase 9), `fabrica user add` |
| 288 | without `--pass` creates the user with a `NULL` password (unable to log in) and |
| 289 | prints guidance to run `fabrica user passwd`. CLI-set passwords clear |
| 290 | `must_change_password` (the operator chose a known credential). |
| 291 | |
| 292 | **Alternatives:** Block `user add` until invites exist; always require `--pass`. |
| 293 | |
| 294 | **Rationale:** The spec's `user add` mints an emailed activation link, which |
| 295 | depends on `crates/mail` (a later phase). The inactive-account-plus-guidance |
| 296 | behaviour is a faithful interim: no account is silently left in a login-capable |
| 297 | state without a credential, and the operator has an obvious next step. When mail |
| 298 | lands, this path grows the invite-and-email behaviour; the `passwd` fallback stays |
| 299 | for SMTP-disabled instances (`mail.backend = "none"`), which is exactly the case |
| 300 | that motivated it. |
| 301 | |
| 302 | ## 2026-07-24 — git2 pinned to the libgit2 nixpkgs ships (1.9.4) |
| 303 | |
| 304 | **Decision:** `crates/git` depends on `git2` 0.20 with a direct |
| 305 | `libgit2-sys = "=0.18.3"` pin, and the build uses the **system** libgit2 |
| 306 | (`LIBGIT2_NO_VENDOR=1`, already set in the flake) rather than the vendored copy. |
| 307 | |
| 308 | **Alternatives:** Let `libgit2-sys` build its bundled libgit2 from source (drop |
| 309 | `LIBGIT2_NO_VENDOR`, add `cmake`); track `git2`'s latest `libgit2-sys`. |
| 310 | |
| 311 | **Rationale:** `git2` 0.20's default `libgit2-sys` (0.18.7, bundling 1.9.6) |
| 312 | requires libgit2 ≥ 1.9.6 and aborts against nixpkgs' 1.9.4 under |
| 313 | `LIBGIT2_NO_VENDOR`. `libgit2-sys` 0.18.3 (bundling 1.9.2) requires ≥ 1.9.2, |
| 314 | which 1.9.4 satisfies. The exact pin makes the coupling explicit and stops a |
| 315 | stray `cargo update` from reintroducing the mismatch; bump it in lockstep when |
| 316 | nixpkgs bumps libgit2. Using the system lib keeps builds fast and avoids a `cmake` |
| 317 | build-dep and a second copy of libgit2 in the graph. |
| 318 | |
| 319 | ## 2026-07-24 — Dev shell sets LD_LIBRARY_PATH for dynamic libgit2 |
| 320 | |
| 321 | **Decision:** The `devShells.default` exports |
| 322 | `LD_LIBRARY_PATH = makeLibraryPath buildInputs`. |
| 323 | |
| 324 | **Alternatives:** Rely on rpath; require developers to set it themselves. |
| 325 | |
| 326 | **Rationale:** Test and bin artifacts built interactively (`cargo test`, |
| 327 | `cargo run` in the dev shell) link libgit2 dynamically but do not receive the Nix |
| 328 | build sandbox's rpath, so the loader cannot find `libgit2.so.1.9` and the binary |
| 329 | exits 127. The Nix build's own checks (`nix flake check`) embed rpath and are |
| 330 | unaffected; this only fixes the interactive `just check` path. |
| 331 | |
| 332 | ## 2026-07-24 — `.gitattributes` resolved by an own resolver, cached off `Repo` |
| 333 | |
| 334 | **Decision:** `crates/git` resolves attributes itself (`attributes.rs`): it walks a |
| 335 | tree collecting every `.gitattributes` blob with its directory prefix, parses each |
| 336 | into ordered rules, and matches paths with `globset`. Precedence is "deeper |
| 337 | directory wins, later line within a file wins", implemented by applying |
| 338 | shallowest-first into a map. The `moka` cache (`AttributesCache`) keyed by tree oid |
| 339 | is a **separate** type the web layer holds, not a field on `Repo`. |
| 340 | |
| 341 | **Alternatives:** Use libgit2's `Repository::get_attr`; cache inside `Repo`. |
| 342 | |
| 343 | **Rationale:** libgit2's attribute lookup is oriented at a working tree and is |
| 344 | unreliable against a bare repo and for per-ref accuracy (§3.5). `Repo` is opened |
| 345 | per operation (per the phase-5 threading decision), so a cache on it would never |
| 346 | outlive a request; placing the `moka` cache in a standalone `AttributesCache` |
| 347 | lets the long-lived web state share resolved sets across requests without coupling |
| 348 | to the handle's lifetime. |
| 349 | |
| 350 | ## 2026-07-24 — Highlighting via inkjet (all grammars, default features) |
| 351 | |
| 352 | **Decision:** `crates/highlight` uses `inkjet` 0.11.1 with default features (which |
| 353 | bundle every tree-sitter grammar plus the `constants` capture-name table). We use |
| 354 | its `highlight_raw` event stream and build per-line HTML ourselves rather than its |
| 355 | built-in HTML formatter. |
| 356 | |
| 357 | **Alternatives:** Depend on `tree-sitter-highlight` directly with a hand-curated |
| 358 | grammar set; enable only `language-*` features for a smaller build. |
| 359 | |
| 360 | **Rationale:** inkjet is MIT/Apache-2.0 and vendors its grammars' C source *inside |
| 361 | the single crate*, so they never appear as separate nodes in cargo-deny's graph and |
| 362 | the license gate stays green. Bundling all grammars honours "highlighting |
| 363 | everywhere code appears" without a curation burden; the build cost is paid once and |
| 364 | cached. The built-in formatter emits one blob, which cannot satisfy the |
| 365 | line-addressable/per-side-diff requirement (§8), so we consume the raw event stream |
| 366 | and fold captures onto our stable `hl-*` classes in one table. inkjet's grammars |
| 367 | link `libstdc++`, added to the flake `buildInputs` so the built binary rpaths it and |
| 368 | the dev shell's `LD_LIBRARY_PATH` picks it up. |
| 369 | |
| 370 | ## 2026-07-24 — Highlight guards; per-file timeout deferred |
| 371 | |
| 372 | **Decision:** Highlighting can never fail a render: oversized (> `max_highlight_bytes`), |
| 373 | binary (NUL in the first 8 KiB), and minified (any line > 5000 bytes) inputs skip |
| 374 | straight to plain text, and any grammar panic is caught (`catch_unwind`) and |
| 375 | degraded the same way. The spec's per-file **timeout** guard is **not** yet |
| 376 | implemented. |
| 377 | |
| 378 | **Alternatives:** Implement the timeout now via a watchdog thread and a |
| 379 | tree-sitter cancellation flag. |
| 380 | |
| 381 | **Rationale:** The size, binary, minified, and panic guards cover every failure |
| 382 | mode observed in practice; a hung grammar is vanishingly rare and, when it happens, |
| 383 | is bounded by the `spawn_blocking` pool at the call site rather than by silently |
| 384 | corrupting a page. Wiring a hard per-file deadline (a cancellation flag polled by |
| 385 | the grammar, or a watchdog thread) is deferred to the polish phase, where the |
| 386 | highlight cache and measured hot paths land together. Tracked as a known gap. |
| 387 | |
| 388 | ## 2026-07-24 — GPG verification via rpgp, not sequoia-openpgp |
| 389 | |
| 390 | **Decision:** OpenPGP signature verification uses the `pgp` crate (rpgp) 0.20, not |
| 391 | `sequoia-openpgp` as the spec names. |
| 392 | |
| 393 | **Alternatives:** `sequoia-openpgp` (the spec's choice); shell out to `gpg`. |
| 394 | |
| 395 | **Rationale:** `sequoia-openpgp` is **LGPL-2.0-or-later**, which is not in |
| 396 | `deny.toml`'s allow-list — pulling it in fails `cargo-deny`, the same class of |
| 397 | constraint that rules out `ring` and drove the hand-rolled JWT. `gpgme` shells out |
| 398 | to GnuPG (GPL). rpgp is `MIT OR Apache-2.0`, pure-Rust (ed25519-dalek/rsa/p256, no |
| 399 | `ring`, no OpenSSL/C), and covers the verification surface we need. SSH signatures |
| 400 | use `ssh-key` (also Apache-2.0/MIT, pure-Rust), as the spec specifies. This is a |
| 401 | **SHOULD** deviation logged here. |
| 402 | |
| 403 | ## 2026-07-24 — SignatureState ids are String; key expiry/revocation best-effort |
| 404 | |
| 405 | **Decision:** `SignatureState` carries `String` ids rather than the spec sketch's |
| 406 | `Ulid`, and does not yet reject on GPG **key expiry or revocation**. |
| 407 | |
| 408 | **Alternatives:** Depend on `ulid` in the git crate for typed ids; implement full |
| 409 | key-validity/revocation checking. |
| 410 | |
| 411 | **Rationale:** The git crate has no `ulid` dependency and speaks ids as opaque |
| 412 | strings everywhere else (oids are hex `String`s); threading a `Ulid` type through |
| 413 | solely for these fields would add a dependency for no gain, since ids originate in |
| 414 | and return to the store as strings. On expiry/revocation: rpgp 0.20 exposes no |
| 415 | one-call "valid at time T" check for detached signatures, and revocation requires |
| 416 | manually scanning revocation signatures. SSH signature verification and GPG |
| 417 | signature **cryptographic** verification are complete and cover all four |
| 418 | `SignatureState` variants (tested); rejecting on GPG key expiry/revocation is a |
| 419 | documented best-effort gap to close alongside the signature cache wiring in the web |
| 420 | phase. Expired/revoked keys therefore currently verify as `Verified` if the |
| 421 | signature is otherwise valid — tracked as a known limitation. |
| 422 | |
| 423 | ## 2026-07-24 — Key ordinals are unique per user, not per (user, kind) |
| 424 | |
| 425 | **Decision:** `create_key` assigns `keys.ordinal` as `max(ordinal)+1` over **all** |
| 426 | of a user's keys (both SSH and GPG), not per `(user, kind)`. |
| 427 | |
| 428 | **Alternatives:** Per-`(user, kind)` ordinals, as the schema comment sketches. |
| 429 | |
| 430 | **Rationale:** The spec's `fabrica key del <user> <index>` carries no `--type`, so |
| 431 | the index shown by `key list` must be unambiguous across a user's keys. Per-user |
| 432 | ordinals give exactly that while still satisfying the schema's |
| 433 | `UNIQUE (user_id, kind, ordinal)` (a per-user-unique value is a fortiori |
| 434 | per-`(user,kind)`-unique). Deleting a key never renumbers the survivors, as the |
| 435 | spec requires. |
| 436 | |
| 437 | ## 2026-07-24 — CLI resolves-or-generates the instance secret on first use |
| 438 | |
| 439 | **Decision:** `fabrica token add` (and any future secret consumer) resolves the |
| 440 | HS256 signing secret via `context::ensure_secret`: inline `auth.secret`, else the |
| 441 | contents of `auth.secret_file` (default `{data_dir}/secret`), else a freshly |
| 442 | generated 32-byte secret written to that path at mode `0600`. |
| 443 | |
| 444 | **Alternatives:** Require the operator to pre-provision a secret before any |
| 445 | token can be minted. |
| 446 | |
| 447 | **Rationale:** The spec has the secret "generated on first run"; the CLI operates |
| 448 | directly on the data directory with no running server, so first-run generation must |
| 449 | happen there too. Centralizing it in one helper means `token add` today and `serve` |
| 450 | later sign with the same key. `auth::new_secret` provides the 32-byte base64url |
| 451 | value, keeping randomness in the auth crate. |
| 452 | |
| 453 | ## 2026-07-24 — Mail over native-TLS, not the spec's rustls transport |
| 454 | |
| 455 | **Decision:** `crates/mail` uses `lettre` with the `tokio1-native-tls` transport, |
| 456 | not the `tokio1-rustls` transport the spec names. |
| 457 | |
| 458 | **Alternatives:** `tokio1-rustls-tls` (the spec's choice). |
| 459 | |
| 460 | **Rationale:** lettre's rustls transport pulls `rustls` → `ring`, whose license set |
| 461 | is outside `deny.toml`'s allow-list — the same constraint that shaped the JWT and |
| 462 | sqlx-TLS decisions. native-tls links the **system OpenSSL**, which is already a |
| 463 | flake `buildInput` (and present in the Debian runtime image), so it adds no new C |
| 464 | dependency and keeps `ring` out of the graph. Backends are `smtp` (native-TLS |
| 465 | `starttls`/`tls`/plaintext), `stdout`, and `none`; tests exercise the `stdout` and |
| 466 | `none` paths so `cargo test` needs no SMTP server. A **SHOULD** deviation logged |
| 467 | here. The invite token reuses `auth::new_session_token`'s shape (32 bytes, |
| 468 | base64url value emailed, SHA-256 stored). |
| 469 | |
| 470 | ## 2026-07-25 — `serve` wired in the binary; `cli` never depends on `web` |
| 471 | |
| 472 | **Decision:** `fabrica serve` orchestration (load config → open store → build web |
| 473 | state → run) lives in the root binary (`src/serve.rs`). `cli::run` takes a serve |
| 474 | callback and hands back a `cli::ServeRequest`; the binary supplies the callback. |
| 475 | `cli` exposes `resolve_secret` for the binary to reuse. |
| 476 | |
| 477 | **Alternatives:** Let `cli` depend on `web` and run the server directly. |
| 478 | |
| 479 | **Rationale:** The architecture rule is absolute — **no crate may depend on |
| 480 | `web`** — and `serve` must start `web` (and later `ssh`/`api`). The only place |
| 481 | those top-level crates can be tied together is the binary, which already depends on |
| 482 | all of them. The callback keeps `cli` (the command tree) free of `web` while |
| 483 | `main.rs` stays a thin wiring layer. `--detach` is accepted but not yet |
| 484 | implemented (foreground is correct for systemd/Docker); deferred to deployment. |
| 485 | |
| 486 | ## 2026-07-25 — Web stack: axum 0.8, plain cookies, no global timeout |
| 487 | |
| 488 | **Decision:** The web crate uses axum 0.8 + axum-extra cookies + maud + tower-http, |
| 489 | with **no TLS/rustls feature anywhere** (TLS terminates at a reverse proxy), so |
| 490 | `ring` stays out of the graph. Session and CSRF use **plain (unsigned) cookies**: |
| 491 | the session cookie is an opaque 32-byte token validated by SHA-256 lookup in the |
| 492 | `sessions` table; CSRF is double-submit (a non-`HttpOnly` `fabrica_csrf` cookie |
| 493 | matched against the `X-CSRF-Token` header htmx injects, or a `_csrf` form field). |
| 494 | There is **no global request timeout** layer. |
| 495 | |
| 496 | **Alternatives:** `SignedCookieJar` (needs a `Key` + `FromRef`); tower-cookies; a |
| 497 | global `TimeoutLayer`. |
| 498 | |
| 499 | **Rationale:** The session token is already unguessable and DB-validated, so cookie |
| 500 | signing adds machinery without materially improving security; double-submit CSRF |
| 501 | needs a JS-readable cookie regardless. A global timeout is actively **wrong** for |
| 502 | this app: pack-transport routes (§3.3) stream arbitrarily long clones and must |
| 503 | never be cut off, so timeouts (if any) belong per-route on non-git paths, added in |
| 504 | the polish phase. The theme picker is a real GET form (works without JS) that sets |
| 505 | a `fabrica_theme` cookie; `SIGHUP` re-scans `themes_dir` via an `RwLock<Assets>`. |
| 506 | |
| 507 | ## 2026-07-25 — Pack transport: 401 (not 404) for anonymous, buffered request |
| 508 | |
| 509 | **Decision:** Smart-HTTP pack routes return **401** with |
| 510 | `WWW-Authenticate: Basic` for anonymous access to a repo they can't read (so git |
| 511 | prompts for credentials), whereas an authenticated stranger gets **404** (no |
| 512 | existence leak). This differs from the browse UI, which always 404s. The |
| 513 | upload-pack **request** (client wants, small) is buffered — decompressing gzip when |
| 514 | present — while the **response** pack streams straight from the child's stdout via |
| 515 | `ReaderStream`, never buffered. Push over HTTP (`git-receive-pack`) returns a |
| 516 | deliberate 403 naming the SSH URL. |
| 517 | |
| 518 | **Alternatives:** 404 for anonymous too (no credential prompt, breaking |
| 519 | `git clone` of private repos); stream the request as well. |
| 520 | |
| 521 | **Rationale:** git's HTTP client only sends Basic credentials after a 401 |
| 522 | challenge, so a private clone is impossible without it — the spec mandates the 401. |
| 523 | The authenticated-stranger 404 preserves the no-leak invariant for logged-in users. |
| 524 | The upload-pack request is a few KB of wants, so buffering it (and handling |
| 525 | `Content-Encoding: gzip`) is simpler than streaming and violates no memory |
| 526 | constraint; the pack that must never be buffered is the response, which is |
| 527 | streamed. HTTP auth is username+password (or session cookie); API-token Basic auth |
| 528 | lands with the API phase. `fabrica hook post-receive` derives the repo id from the |
| 529 | `GIT_DIR` basename and loads config from `FABRICA_CONFIG` (set by the SSH/HTTP |
| 530 | server on the child) or the default location; a local push with neither set no-ops |
| 531 | cleanly, and it always exits 0 so a hook error never rejects an accepted push. |
| 532 | |
| 533 | ## 2026-07-25 — SSH via russh; the OpenSSL license is admitted for the crypto backend |
| 534 | |
| 535 | **Decision:** `crates/ssh` uses `russh` 0.62 with its **default** crypto backend |
| 536 | `aws-lc-rs` (explicitly **not** `ring`). `deny.toml` gains three permissive |
| 537 | licenses its transitive tree needs — **`Unicode-DFS-2016`**, **`0BSD`**, and |
| 538 | **`bzip2-1.0.6`** — and `flake.nix` gains `cmake` (aws-lc-sys builds AWS-LC via |
| 539 | cmake). |
| 540 | |
| 541 | **Alternatives:** the `ring` backend (also carries OpenSSL-family terms and is not |
| 542 | allowlisted); not shipping SSH at all. |
| 543 | |
| 544 | **Rationale:** SSH is not optional for a git server (the DoD requires push over |
| 545 | SSH), the spec names russh, and russh cannot function without a crypto backend. |
| 546 | `aws-lc-rs` is the default and keeps `ring` out. Its tree pulls a few extra |
| 547 | permissive licenses (`Unicode-DFS-2016`, `0BSD`, `bzip2-1.0.6`), admitted because |
| 548 | SSH is mandatory; all three are permissive. This is the one place the |
| 549 | otherwise-strict allowlist is widened, and it is scoped to the transport stack. |
| 550 | |
| 551 | **Implementation notes:** russh merged `russh-keys` into `russh::keys` and |
| 552 | re-exports `ssh-key` at a patch level distinct from the workspace's, so the two |
| 553 | `PrivateKey` types differ; the host key bridges them via the OpenSSH PEM text |
| 554 | (generate/load with the workspace `ssh-key`, parse with `russh::keys`). Identity is |
| 555 | by key fingerprint, not the SSH username (everyone connects as `git@`); |
| 556 | `channel_open_session` must explicitly `reply.accept()` in 0.62 or the channel is |
| 557 | rejected; exec output streams through the `Clone + Send` `Handle` from a task. |
| 558 | |
| 559 | ## 2026-07-25 — UI refresh: inline icons, Carbon themes, dashboard/explore split, profiles |
| 560 | |
| 561 | **Decision:** Vendor UI glyphs as an inline-SVG `icons` module (Lucide, ISC) with |
| 562 | brand marks from Simple Icons (CC0), replacing the emoji glyphs. Rebase the default |
| 563 | `dark`/`light` themes on IBM Carbon (Gray 100 / White) as pure `--fb-*` token |
| 564 | changes. Split `/` into a signed-in dashboard and a public `/explore` landing (the |
| 565 | anonymous landing carries no in-content sign-in button). Give users a profile |
| 566 | (display name, pronouns, bio, location, website, GitHub/Mastodon handles) edited at |
| 567 | `/settings`, and avatars served from `/avatar/{username}`. |
| 568 | |
| 569 | **Avatar storage:** the bytes live on disk at `{storage.data_dir}/avatars/{id}`; |
| 570 | `users.avatar_mime` (nullable TEXT, migration 0002) records the content type and its |
| 571 | presence means "serve the file". A NULL falls back to a deterministic FNV-derived |
| 572 | 5×5 identicon SVG generated in-process. |
| 573 | |
| 574 | **Alternatives:** an icon font or sprite sheet (extra asset, worse a11y); storing |
| 575 | avatar bytes in the DB (BLOB vs BYTEA is not portable across SQLite/Postgres, which |
| 576 | § store portability forbids); Gravatar or any remote avatar (a network request from |
| 577 | a page, forbidden by §9.5); a single combined home page (the prior behaviour). |
| 578 | |
| 579 | **Rationale:** inline SVG inherits `currentColor`, scales to `1em`, needs no network |
| 580 | request, and keeps the "no CDN / no build step" rule. Carbon is a documented, freely |
| 581 | usable palette and the swap touches only theme token values, proving the |
| 582 | structure-only `base.css` contract. On-disk avatars keep the schema in the portable |
| 583 | subset. Uploads are limited to raster types (PNG/JPEG/GIF/WebP, ≤ 1 MiB); SVG upload |
| 584 | is refused so no user-supplied markup is ever served, while our own identicons are |
| 585 | SVG. Both mutating routes (`POST /settings`, `POST /settings/avatar`) verify the |
| 586 | double-submit CSRF token; `/settings` redirects anonymous visitors to `/login`. |
| 587 | |
| 588 | ## 2026-07-25 — Three-level visibility, repo settings, and SSH push-to-create |
| 589 | |
| 590 | **Decision:** Repositories carry a GitLab-style **visibility** — `public` |
| 591 | (everyone, anonymous gated by `allow_anonymous`), `internal` (any signed-in |
| 592 | user), or `private` (owner/collaborators/admins) — replacing the old two-state |
| 593 | `is_private` flag. `auth::access` decides read on visibility after the |
| 594 | admin/owner/collaborator rules; `instance.default_visibility` (default `private`) |
| 595 | sets the default for new repos. Owners/admins get a **Settings** repo tab |
| 596 | (rename, visibility, delete) whose mutations post to a fixed `/repo-settings/{id}` |
| 597 | prefix (never colliding with the `/{owner}/{*rest}` catch-all) and re-check Admin |
| 598 | access, returning `404` — never `403` — to non-admins. An SSH **push to a |
| 599 | non-existent repo** owned by the pushing user (or an admin) **auto-creates** it at |
| 600 | the default visibility (row + bare repo on disk, rolled back on disk failure). |
| 601 | |
| 602 | **Migration:** `0004_repo_visibility` adds a `visibility TEXT NOT NULL DEFAULT |
| 603 | 'private'` column backfilled from `is_private`; the `is_private` column is left in |
| 604 | place (SQLite cannot drop columns) but no longer read or written. |
| 605 | |
| 606 | **Alternatives:** keeping the boolean and bolting "internal" on as a second flag |
| 607 | (two booleans encode four states, one nonsensical); a dedicated POST route per |
| 608 | repo path (impossible — repo paths are variable-length under groups); refusing |
| 609 | push-to-create (an extra round-trip to the CLes/UI for every new repo). |
| 610 | |
| 611 | **Rationale:** three named states model real instances (internal is the common |
| 612 | "logged-in only" tier) and keep `access` a single exhaustively-tested function — |
| 613 | its matrix test now enumerates all three visibilities × viewer kinds × the anon |
| 614 | flag. `allow_anonymous` now gates only anonymous access to *public* repos, which |
| 615 | is what it always meant. The fixed `/repo-settings/{id}` prefix sidesteps the |
| 616 | catch-all cleanly and keys on the id (rename-safe). Push-to-create matches the |
| 617 | muscle memory of every other forge and stays safe: only the authenticated owner |
| 618 | (or an admin) can trigger it, and only on receive-pack. |
| 619 | |
| 620 | ## 2026-07-25 — Scope expansion: a complete forge minus CI |
| 621 | |
| 622 | **Decision:** Reverse the original "issues / pull requests / self-registration are non-goals" |
| 623 | posture. fabrica now targets a **complete forge minus CI**: issues and pull requests with |
| 624 | GitHub-style **scoped labels** (`kind: value`, mutually exclusive within a scope), assignees, |
| 625 | comments, and open/closed state; PRs with branch compare, diff, review, and server-side |
| 626 | **merge machinery** (merge commit / squash / rebase) performed via the spawned `git` |
| 627 | subprocess (never libgit2, per §3.1); issues and PRs **toggleable per repository**; optional |
| 628 | web **self-registration** (`instance.allow_registration`, optional hCaptcha/reCAPTCHA) |
| 629 | alongside admin invites; and expanded **account settings** (username/email/password, SSH/GPG |
| 630 | keys, API tokens, profile). Still out of scope: orgs, forks, stars, wikis, releases, LFS, |
| 631 | webhooks, federation. CI stays deferred and is built last. |
| 632 | |
| 633 | **Numbering:** issues and PRs share a per-repo counter (`repos.next_iid`) so `#N` is unique |
| 634 | across both; a PR is an `issues` row with `is_pull = 1` plus a `pull_requests` side table, so |
| 635 | labels/comments/numbering are shared. |
| 636 | |
| 637 | **Rationale:** the maintainer wants the instance to be a full-featured forge for real use, not |
| 638 | just a private browser. Sharing the issue/PR numbering and tables keeps labels and comments |
| 639 | uniform and avoids a parallel implementation. Merge via subprocess preserves the one code path |
| 640 | for write operations. spec.md §§1, 18–19 and CLAUDE.md are updated to match; this is a large, |
| 641 | multi-phase build delivered incrementally with the gate green at each step. |
| 642 | |
| 643 | ## 2026-07-25 — Captcha on sign-up: an opt-in exception to "no network from pages" |
| 644 | |
| 645 | **Decision:** Implement the spec §19 optional captcha (hCaptcha / reCAPTCHA v2) on the |
| 646 | `/register` form, off by default via a new `[captcha]` config table (`provider = none | |
| 647 | hcaptcha | recaptcha`, `site_key`, `secret_key`). When — and only when — an admin enables it, |
| 648 | the provider's widget script is loaded on the sign-up page and the solved token is verified |
| 649 | server-side with an outbound HTTPS POST to the provider's `siteverify` endpoint. Verification |
| 650 | **fails closed**: an empty token, a missing secret, a network error, or a rejected token all |
| 651 | reject the sign-up, so a broken verifier never admits a bot. The HTTP call uses `reqwest` |
| 652 | (`default-features = false`, `native-tls` + `json`) — native-tls, not rustls, so `ring` stays |
| 653 | out of the graph, consistent with the mail/SSH TLS decisions. |
| 654 | |
| 655 | **Rationale:** captcha is fundamentally incompatible with §9.5 ("no network requests from any |
| 656 | page") — it cannot work without third-party JS and an out-of-band verify. The spec nonetheless |
| 657 | lists it as an optional feature, so the resolution is to make it a **deliberate, opt-in** |
| 658 | deviation: the no-network invariant holds for every page by default, and enabling captcha is an |
| 659 | explicit administrator choice to accept a third-party dependency on one page. No other page |
| 660 | loads external resources. |
| 661 | |
| 662 | ## 2026-07-25 — SSH/GPG key management in the web account settings |
| 663 | |
| 664 | **Decision:** Surface the existing key store (CLI phase 8) in the web settings page: an "SSH |
| 665 | and GPG keys" section listing the viewer's keys with delete buttons and an add form (type + |
| 666 | optional label + pasted material), backed by `POST /settings/keys` and `/settings/keys/delete`. |
| 667 | The add handler reuses `git::parse_public_key` for validation and fingerprinting and rejects a |
| 668 | duplicate fingerprint; delete is ownership-checked against `keys_by_user`. This closes the last |
| 669 | account-settings gap (§19) so key management no longer requires shell access. |
| 670 | |
| 671 | ## 2026-07-26 — Optional S3 object storage for uploads (aws-sdk-s3, aws-lc-rs) |
| 672 | |
| 673 | **Decision:** Add an optional S3-compatible backend for the three kinds of *uploads* — user |
| 674 | avatars, release assets, and git-LFS objects — behind a new `blob` crate exposing a `BlobStore` |
| 675 | enum with `Local` and `S3` variants. Bare git repositories are **not** included: git needs direct |
| 676 | filesystem access, so `storage.repo_dir` is always local. Selected by `storage.backend = |
| 677 | "local" | "s3"` (default `local`, so existing installs are unaffected) with a `[storage.s3]` |
| 678 | section (`bucket`, `region`, `endpoint`, `access_key_id`, `secret_access_key`, `path_style`, |
| 679 | `prefix`). The endpoint + path-style knobs make it work with MinIO, Cloudflare R2, Backblaze B2, |
| 680 | and other S3-compatible stores, not just AWS. |
| 681 | |
| 682 | **Client:** `aws-sdk-s3` with a **manually constructed** config (static credentials, custom |
| 683 | endpoint) rather than `aws-config`'s credential-provider chain, keeping the dependency surface and |
| 684 | startup work small. The default HTTPS client (`default-https-client`) pulls `ring`, which the |
| 685 | license gate forbids; instead the HTTP client is wired explicitly via `aws-smithy-http-client` |
| 686 | with the `rustls-aws-lc` feature and `tls::Provider::Rustls(CryptoMode::AwsLc)`, so the crypto |
| 687 | provider is **aws-lc-rs** — already in the tree via russh and already on the license allow-list — |
| 688 | and `ring` never enters the graph (`cargo deny check licenses` stays green). |
| 689 | |
| 690 | **Streaming:** LFS and release-asset uploads still stream to a **local scratch file** under |
| 691 | `data_dir/tmp` (LFS) / `data_dir/releases/tmp` (assets) so the sha256 (LFS) and size are computed |
| 692 | before the object is servable; the verified temp file is then handed to `BlobStore::put_file`, |
| 693 | which renames it into place (local) or uploads it and removes the temp (S3). Downloads stream |
| 694 | straight from the backend (`open` returns size + an `AsyncRead`). Keys are namespaced |
| 695 | (`avatars/`, `releases/`, `lfs/{shard}`) and validated against path traversal. The local backend |
| 696 | preserves the historical on-disk layout (`data_dir/avatars`, `data_dir/releases`, `lfs_dir`), so |
| 697 | no migration is needed to stay on local disk. |