fabrica

hanna/fabrica

40056 bytes
1# Decisions log
2
3A running log of design decisions: date, decision, alternatives considered, and
4rationale. Seeded with the decisions the spec (`spec.md`) already makes. Update
5this file on any deviation from the spec (a **SHOULD** deviation **MUST** be
6logged 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`
13binary (`git upload-pack` / `git receive-pack`), not by libgit2.
14
15**Alternatives:** Implement the smart pack protocol on top of libgit2's object
16layer; 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.
20libgit2 (via `git2`) is retained for in-process reads (refs, trees, blobs, log,
21diff, signatures, `init_bare`). Consequence: `git` (≥ 2.41) MUST be on `PATH` at
22runtime; 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`
27is 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
32touching 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
38sub-resources with the GitLab `/-/` convention: everything before `/-/` is the
39repo 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
44unambiguous, 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
54plain integer comparison. Ids are `TEXT` ULIDs (lexicographically sortable) used
55for 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
60signalling its pid (`SIGTERM`/`SIGINT`). `--detach` double-forks and writes a pid
61file. 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
66lifecycle. 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
71into `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
77every row), so it is the one place caching earns its keep (§18 "moka where
78measured"); the TTL bounds staleness after a key change without explicit
79invalidation. `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
81picker 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
83tracked gaps, not blockers. The per-file highlight **timeout** guard (§8) also
84remains 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
90with `.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
96nothing against `web → api` (both are top-level). Nesting keeps one server, one
97port, 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`
99prefix, 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
101keyword segment (`branches`/`tags`/`commits`/`tree`/`raw`); a repo named exactly
102after one of those would be misrouted — a documented MVP limitation. The API
103search 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)
108is implemented in `crates/web`, not a separate crate, using ripgrep's `grep`
109engine. The `path:` qualifier matches as a case-insensitive **substring**, not a
110full 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
116its 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)`
119deadline, 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`
122exclusion 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
127request 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
132the 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,
142container image, multiple checks) and gives a cleaner multi-output structure. The
143spec 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
148wrapped in a `config::Secret` newtype whose `Debug` and `Serialize` impls always
149emit `<redacted>`. The real value is reachable only through `Secret::expose`.
150
151**Alternatives:** Store secrets as plain `String` and redact them at each output
152site (`config show`, error messages, logs).
153
154**Rationale:** Redaction is the default and cannot be forgotten — a `Secret`
155cannot leak through `tracing`, a `Debug`-formatted `Config`, `fabrica config
156show`, or the JSON API, satisfying the spec's "secrets MUST NOT be logged,
157echoed, or included in error messages". Site-by-site redaction is one missed
158call away from a leak. Trade-off: serialization is lossy, so `Secret` is never
159round-tripped through our own `Serialize` (figment reads the raw value into the
160figment `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
165unknown key (typo, stale key, stray `FABRICA__*`) aborts the load. Missing files
166at the fixed locations (`/etc`, XDG) and a missing `--config` path are tolerated
167silently. Directory validation checks creatability without side effects (an
168existing directory ancestor), never creating anything during `config check`.
169
170**Alternatives:** Ignore unknown keys (serde default); require every file to
171exist; create directories during validation.
172
173**Rationale:** Failing fast on typos is worth more than forward-compatibility for
174a single-operator forge. Tolerating missing files lets one binary serve both a
175bare first-run machine and a fully-configured one. Side-effect-free validation
176keeps `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
181statement once, rather than a `Store` trait with separate `SqlitePool` and
182`PgPool` implementations. Three dialect differences are mediated explicitly:
183placeholders use the `$1,$2` syntax both SQLite and Postgres accept (only MySQL
184needs `?`); booleans bind as Rust `bool` (encodes correctly for both) and are
185read back through `Store::get_bool`, which decodes SQLite's `INTEGER` storage and
186Postgres's native `BOOLEAN`; timestamps and ids are already portable (`BIGINT`
187ms, `TEXT` ULID).
188
189**Alternatives:** A `Store` trait with two concrete pool implementations (the
190spec's other blessed option), duplicating every query per dialect.
191
192**Rationale:** The spec explicitly permits "runtime queries over `AnyPool`". One
193code 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
195intersection where `Any` is safe. The single genuinely divergent read — booleans
196— is funnelled through one helper, which is exactly the "the trait maps them"
197seam the spec calls for. Migrations remain two dialect-specific `.sql` sets,
198embedded 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
206works out of the box.
207
208**Rationale:** TLS is only relevant to a remote Postgres, which is a phase-17
209deployment concern; SQLite (the default) and local/socket Postgres need none.
210Omitting it keeps the dependency tree small and, notably, keeps `ring` out of the
211graph so `cargo-deny`'s license set stays clean without special-casing. To be
212revisited when deployment against a managed Postgres is implemented; the change
213is 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
223into `OUT_DIR`.
224
225**Rationale:** Crane's cargo-source filter strips non-Rust files, which would
226break every derivation that compiles `store`. Widening the filter is the minimal,
227explicit fix and leaves room to add further asset globs (themes, vendored htmx)
228the 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
233directly (base64url of a fixed JOSE header and a serde JSON payload, an
234`hmac`/`sha2` HMAC-SHA256, constant-time verification via `Mac::verify_slice`)
235rather 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
240not in `deny.toml`'s allow-list (it carries OpenSSL-derived terms), so pulling
241one in would fail `cargo-deny` — the same reason sqlx is built without a TLS
242backend. The HS256 surface we need is tiny and well-specified; hand-rolling it is
243a few dozen lines over crates already in the tree (`hmac`, `sha2`, `base64`,
244`serde_json`) and lets us reject algorithm-confusion tokens explicitly. Tokens
245are minted and verified only by fabrica, so interop with other JWT producers is a
246non-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>,
252allow_anonymous: bool) -> Access`, widening the two-argument shape sketched in
253the spec.
254
255**Alternatives:** Keep the literal `access(viewer, repo)` signature and thread the
256collaborator lookup and `instance.allow_anonymous` in via `Viewer` or a context
257struct.
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
261explicit parameters keeps `access` a pure function of its inputs — which is what
262makes the exhaustive matrix test possible — and keeps the collaborator lookup at
263the call site (the store), where it belongs. The five ordered rules are unchanged
264from 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
270waiting for the phase-8 CLI milestone. Every store-backed command applies pending
271migrations on connect (idempotent), so a fresh install's first `user add`
272bootstraps the database in one step.
273
274**Alternatives:** Keep strictly to phase order and ship the `user` commands only
275in 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
278SMTP 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
281the 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
283opt-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`
288without `--pass` creates the user with a `NULL` password (unable to log in) and
289prints 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
295depends on `crates/mail` (a later phase). The inactive-account-plus-guidance
296behaviour is a faithful interim: no account is silently left in a login-capable
297state without a credential, and the operator has an obvious next step. When mail
298lands, this path grows the invite-and-email behaviour; the `passwd` fallback stays
299for SMTP-disabled instances (`mail.backend = "none"`), which is exactly the case
300that 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)
312requires 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,
314which 1.9.4 satisfies. The exact pin makes the coupling explicit and stops a
315stray `cargo update` from reintroducing the mismatch; bump it in lockstep when
316nixpkgs bumps libgit2. Using the system lib keeps builds fast and avoids a `cmake`
317build-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
328build sandbox's rpath, so the loader cannot find `libgit2.so.1.9` and the binary
329exits 127. The Nix build's own checks (`nix flake check`) embed rpath and are
330unaffected; 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
335tree collecting every `.gitattributes` blob with its directory prefix, parses each
336into ordered rules, and matches paths with `globset`. Precedence is "deeper
337directory wins, later line within a file wins", implemented by applying
338shallowest-first into a map. The `moka` cache (`AttributesCache`) keyed by tree oid
339is 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
344unreliable against a bare repo and for per-ref accuracy (§3.5). `Repo` is opened
345per operation (per the phase-5 threading decision), so a cache on it would never
346outlive a request; placing the `moka` cache in a standalone `AttributesCache`
347lets the long-lived web state share resolved sets across requests without coupling
348to 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
353bundle every tree-sitter grammar plus the `constants` capture-name table). We use
354its `highlight_raw` event stream and build per-line HTML ourselves rather than its
355built-in HTML formatter.
356
357**Alternatives:** Depend on `tree-sitter-highlight` directly with a hand-curated
358grammar 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
361the single crate*, so they never appear as separate nodes in cargo-deny's graph and
362the license gate stays green. Bundling all grammars honours "highlighting
363everywhere code appears" without a curation burden; the build cost is paid once and
364cached. The built-in formatter emits one blob, which cannot satisfy the
365line-addressable/per-side-diff requirement (§8), so we consume the raw event stream
366and fold captures onto our stable `hl-*` classes in one table. inkjet's grammars
367link `libstdc++`, added to the flake `buildInputs` so the built binary rpaths it and
368the 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`),
373binary (NUL in the first 8 KiB), and minified (any line > 5000 bytes) inputs skip
374straight to plain text, and any grammar panic is caught (`catch_unwind`) and
375degraded the same way. The spec's per-file **timeout** guard is **not** yet
376implemented.
377
378**Alternatives:** Implement the timeout now via a watchdog thread and a
379tree-sitter cancellation flag.
380
381**Rationale:** The size, binary, minified, and panic guards cover every failure
382mode observed in practice; a hung grammar is vanishingly rare and, when it happens,
383is bounded by the `spawn_blocking` pool at the call site rather than by silently
384corrupting a page. Wiring a hard per-file deadline (a cancellation flag polled by
385the grammar, or a watchdog thread) is deferred to the polish phase, where the
386highlight 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
397constraint that rules out `ring` and drove the hand-rolled JWT. `gpgme` shells out
398to 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
400use `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
409key-validity/revocation checking.
410
411**Rationale:** The git crate has no `ulid` dependency and speaks ids as opaque
412strings everywhere else (oids are hex `String`s); threading a `Ulid` type through
413solely for these fields would add a dependency for no gain, since ids originate in
414and return to the store as strings. On expiry/revocation: rpgp 0.20 exposes no
415one-call "valid at time T" check for detached signatures, and revocation requires
416manually scanning revocation signatures. SSH signature verification and GPG
417signature **cryptographic** verification are complete and cover all four
418`SignatureState` variants (tested); rejecting on GPG key expiry/revocation is a
419documented best-effort gap to close alongside the signature cache wiring in the web
420phase. Expired/revoked keys therefore currently verify as `Verified` if the
421signature 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**
426of 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
431the index shown by `key list` must be unambiguous across a user's keys. Per-user
432ordinals give exactly that while still satisfying the schema's
433`UNIQUE (user_id, kind, ordinal)` (a per-user-unique value is a fortiori
434per-`(user,kind)`-unique). Deleting a key never renumbers the survivors, as the
435spec 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
440HS256 signing secret via `context::ensure_secret`: inline `auth.secret`, else the
441contents of `auth.secret_file` (default `{data_dir}/secret`), else a freshly
442generated 32-byte secret written to that path at mode `0600`.
443
444**Alternatives:** Require the operator to pre-provision a secret before any
445token can be minted.
446
447**Rationale:** The spec has the secret "generated on first run"; the CLI operates
448directly on the data directory with no running server, so first-run generation must
449happen there too. Centralizing it in one helper means `token add` today and `serve`
450later sign with the same key. `auth::new_secret` provides the 32-byte base64url
451value, 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,
456not 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
461is outside `deny.toml`'s allow-list — the same constraint that shaped the JWT and
462sqlx-TLS decisions. native-tls links the **system OpenSSL**, which is already a
463flake `buildInput` (and present in the Debian runtime image), so it adds no new C
464dependency 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
467here. The invite token reuses `auth::new_session_token`'s shape (32 bytes,
468base64url 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
473state → run) lives in the root binary (`src/serve.rs`). `cli::run` takes a serve
474callback 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
481those top-level crates can be tied together is the binary, which already depends on
482all 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
484implemented (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,
489with **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**:
491the 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
493matched against the `X-CSRF-Token` header htmx injects, or a `_csrf` form field).
494There is **no global request timeout** layer.
495
496**Alternatives:** `SignedCookieJar` (needs a `Key` + `FromRef`); tower-cookies; a
497global `TimeoutLayer`.
498
499**Rationale:** The session token is already unguessable and DB-validated, so cookie
500signing adds machinery without materially improving security; double-submit CSRF
501needs a JS-readable cookie regardless. A global timeout is actively **wrong** for
502this app: pack-transport routes (§3.3) stream arbitrarily long clones and must
503never be cut off, so timeouts (if any) belong per-route on non-git paths, added in
504the polish phase. The theme picker is a real GET form (works without JS) that sets
505a `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
511prompts for credentials), whereas an authenticated stranger gets **404** (no
512existence leak). This differs from the browse UI, which always 404s. The
513upload-pack **request** (client wants, small) is buffered — decompressing gzip when
514present — while the **response** pack streams straight from the child's stdout via
515`ReaderStream`, never buffered. Push over HTTP (`git-receive-pack`) returns a
516deliberate 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
522challenge, so a private clone is impossible without it — the spec mandates the 401.
523The authenticated-stranger 404 preserves the no-leak invariant for logged-in users.
524The 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
526constraint; the pack that must never be buffered is the response, which is
527streamed. HTTP auth is username+password (or session cookie); API-token Basic auth
528lands 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
530server on the child) or the default location; a local push with neither set no-ops
531cleanly, 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
537licenses 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
539cmake).
540
541**Alternatives:** the `ring` backend (also carries OpenSSL-family terms and is not
542allowlisted); not shipping SSH at all.
543
544**Rationale:** SSH is not optional for a git server (the DoD requires push over
545SSH), 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
547permissive licenses (`Unicode-DFS-2016`, `0BSD`, `bzip2-1.0.6`), admitted because
548SSH is mandatory; all three are permissive. This is the one place the
549otherwise-strict allowlist is widened, and it is scoped to the transport stack.
550
551**Implementation notes:** russh merged `russh-keys` into `russh::keys` and
552re-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
555by 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
557rejected; 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
562brand 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
564changes. Split `/` into a signed-in dashboard and a public `/explore` landing (the
565anonymous 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
571presence means "serve the file". A NULL falls back to a deterministic FNV-derived
5725×5 identicon SVG generated in-process.
573
574**Alternatives:** an icon font or sprite sheet (extra asset, worse a11y); storing
575avatar 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
577a 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
580request, and keeps the "no CDN / no build step" rule. Carbon is a documented, freely
581usable palette and the swap touches only theme token values, proving the
582structure-only `base.css` contract. On-disk avatars keep the schema in the portable
583subset. Uploads are limited to raster types (PNG/JPEG/GIF/WebP, ≤ 1 MiB); SVG upload
584is refused so no user-supplied markup is ever served, while our own identicons are
585SVG. Both mutating routes (`POST /settings`, `POST /settings/avatar`) verify the
586double-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
592user), or `private` (owner/collaborators/admins) — replacing the old two-state
593`is_private` flag. `auth::access` decides read on visibility after the
594admin/owner/collaborator rules; `instance.default_visibility` (default `private`)
595sets 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}`
597prefix (never colliding with the `/{owner}/{*rest}` catch-all) and re-check Admin
598access, returning `404` — never `403` — to non-admins. An SSH **push to a
599non-existent repo** owned by the pushing user (or an admin) **auto-creates** it at
600the 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
604place (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
608repo path (impossible — repo paths are variable-length under groups); refusing
609push-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 —
613its matrix test now enumerates all three visibilities × viewer kinds × the anon
614flag. `allow_anonymous` now gates only anonymous access to *public* repos, which
615is what it always meant. The fixed `/repo-settings/{id}` prefix sidesteps the
616catch-all cleanly and keys on the id (rename-safe). Push-to-create matches the
617muscle 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"
623posture. fabrica now targets a **complete forge minus CI**: issues and pull requests with
624GitHub-style **scoped labels** (`kind: value`, mutually exclusive within a scope), assignees,
625comments, 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`
627subprocess (never libgit2, per §3.1); issues and PRs **toggleable per repository**; optional
628web **self-registration** (`instance.allow_registration`, optional hCaptcha/reCAPTCHA)
629alongside admin invites; and expanded **account settings** (username/email/password, SSH/GPG
630keys, API tokens, profile). Still out of scope: orgs, forks, stars, wikis, releases, LFS,
631webhooks, 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
634across both; a PR is an `issues` row with `is_pull = 1` plus a `pull_requests` side table, so
635labels/comments/numbering are shared.
636
637**Rationale:** the maintainer wants the instance to be a full-featured forge for real use, not
638just a private browser. Sharing the issue/PR numbering and tables keeps labels and comments
639uniform and avoids a parallel implementation. Merge via subprocess preserves the one code path
640for write operations. spec.md §§1, 18–19 and CLAUDE.md are updated to match; this is a large,
641multi-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 |
647hcaptcha | recaptcha`, `site_key`, `secret_key`). When — and only when — an admin enables it,
648the provider's widget script is loaded on the sign-up page and the solved token is verified
649server-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
651reject 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
653out 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
656page") — it cannot work without third-party JS and an out-of-band verify. The spec nonetheless
657lists it as an optional feature, so the resolution is to make it a **deliberate, opt-in**
658deviation: the no-network invariant holds for every page by default, and enabling captcha is an
659explicit administrator choice to accept a third-party dependency on one page. No other page
660loads 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
665and GPG keys" section listing the viewer's keys with delete buttons and an add form (type +
666optional label + pasted material), backed by `POST /settings/keys` and `/settings/keys/delete`.
667The add handler reuses `git::parse_public_key` for validation and fingerprinting and rejects a
668duplicate fingerprint; delete is ownership-checked against `keys_by_user`. This closes the last
669account-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
674avatars, release assets, and git-LFS objects — behind a new `blob` crate exposing a `BlobStore`
675enum with `Local` and `S3` variants. Bare git repositories are **not** included: git needs direct
676filesystem 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]`
678section (`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,
680and other S3-compatible stores, not just AWS.
681
682**Client:** `aws-sdk-s3` with a **manually constructed** config (static credentials, custom
683endpoint) rather than `aws-config`'s credential-provider chain, keeping the dependency surface and
684startup work small. The default HTTPS client (`default-https-client`) pulls `ring`, which the
685license gate forbids; instead the HTTP client is wired explicitly via `aws-smithy-http-client`
686with the `rustls-aws-lc` feature and `tls::Provider::Rustls(CryptoMode::AwsLc)`, so the crypto
687provider is **aws-lc-rs** — already in the tree via russh and already on the license allow-list —
688and `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
692before the object is servable; the verified temp file is then handed to `BlobStore::put_file`,
693which renames it into place (local) or uploads it and removes the temp (S3). Downloads stream
694straight from the backend (`open` returns size + an `AsyncRead`). Keys are namespaced
695(`avatars/`, `releases/`, `lfs/{shard}`) and validated against path traversal. The local backend
696preserves the historical on-disk layout (`data_dir/avatars`, `data_dir/releases`, `lfs_dir`), so
697no migration is needed to stay on local disk.