# Decisions log A running log of design decisions: date, decision, alternatives considered, and rationale. Seeded with the decisions the spec (`spec.md`) already makes. Update this file on any deviation from the spec (a **SHOULD** deviation **MUST** be logged here). --- ## 2026-07-24 — Pack transport via `git` subprocess **Decision:** Server-side clone/fetch/push is handled by spawning the `git` binary (`git upload-pack` / `git receive-pack`), not by libgit2. **Alternatives:** Implement the smart pack protocol on top of libgit2's object layer; use a pure-Rust reimplementation (`gitoxide`). **Rationale:** libgit2 is client-only for transport — it has no server-side `upload-pack`/`receive-pack`. Spawning `git` is the supported, correct path. libgit2 (via `git2`) is retained for in-process reads (refs, trees, blobs, log, diff, signatures, `init_bare`). Consequence: `git` (≥ 2.41) MUST be on `PATH` at runtime; the Nix derivation wraps the binary with git on PATH. ## 2026-07-24 — Repositories stored by id, not name **Decision:** On disk, repos live at `{repo_dir}/{id[0..2]}/{id}.git` where `id` is a lowercase ULID. The database is the only name→path authority. **Alternatives:** Store repos by `{owner}/{name}.git` mirroring the URL. **Rationale:** Renames and group moves become metadata-only (a DB update), never touching the filesystem. Tradeoff: the tree is not human-browsable; `fabrica repo path ` resolves it for operators. ## 2026-07-24 — `/-/` route separator **Decision:** Web and API routes disambiguate variable-length repo paths from sub-resources with the GitLab `/-/` convention: everything before `/-/` is the repo path, everything after is the view. **Alternatives:** Fixed-depth paths; a query parameter for the view. **Rationale:** Group nesting makes repo paths of unknown depth. `/-/` is an unambiguous, well-understood separator. ## 2026-07-24 — Timestamps as epoch milliseconds **Decision:** All timestamps are stored as `BIGINT` unix milliseconds (UTC). No `DATETIME` / `TIMESTAMPTZ`. **Alternatives:** Native date/time column types per dialect. **Rationale:** One portable representation across SQLite and Postgres, ordered by plain integer comparison. Ids are `TEXT` ULIDs (lexicographically sortable) used for tie-breaking and ordering. ## 2026-07-24 — `serve` runs in the foreground; no `stop` subcommand **Decision:** `fabrica serve` runs in the foreground by default and is stopped by signalling its pid (`SIGTERM`/`SIGINT`). `--detach` double-forks and writes a pid file. There is no `server start` / `server stop` pair. **Alternatives:** A daemonizing `start`/`stop`/`restart` command set. **Rationale:** Foreground is correct for systemd and Docker, which own process lifecycle. This deviates from an earlier `server start`/`server stop` sketch. ## 2026-07-25 — Polish: signature cache wired; `theme list`/`gc` CLI deferred **Decision:** The `moka` `SignatureCache` (bounded 4096, 10-minute TTL) is wired into `AppState` and used by the commit list and commit page. The `fabrica theme list` and `fabrica gc` CLI subcommands are **not** shipped. **Alternatives:** Also cache attributes/highlight; implement `theme list`/`gc`. **Rationale:** Signature verification is the measured hot path (list views verify every row), so it is the one place caching earns its keep (§18 "moka where measured"); the TTL bounds staleness after a key change without explicit invalidation. `theme list` would need the embedded theme registry, which lives in `web` and cannot be reached from `cli` (nothing may depend on `web`); the web theme picker already lists every theme, so the CLI command is low value. `gc` (trash reaping) is a maintenance convenience with no correctness impact; both are tracked gaps, not blockers. The per-file highlight **timeout** guard (§8) also remains deferred as noted earlier. ## 2026-07-25 — API nested into web via `nest_service`; web may depend on api **Decision:** The `/api/v1` JSON surface is a self-contained `Router` built by `api::router(store, config, secret)` with its own state applied, and `web` nests it with `.nest_service("/api/v1", …)`. `web` depends on `api`. **Alternatives:** Run the API on a separate port/server; have `api` depend on `web` (forbidden — nothing may depend on `web`). **Rationale:** The dependency rule bars anything depending on `web`, but says nothing against `web → api` (both are top-level). Nesting keeps one server, one port, and one middleware stack. Because the nested router is fully stated (`Router<()>`), `nest_service` mounts it as a `Service` and strips the `/api/v1` prefix, so `api` defines routes relative to root and never learns `web`'s `AppState`. Repo sub-resources are parsed by scanning `{*rest}` for the first keyword segment (`branches`/`tags`/`commits`/`tree`/`raw`); a repo named exactly after one of those would be misrouted — a documented MVP limitation. The API search returns name/description matches only (content search stays in `web`). ## 2026-07-25 — Search lives in `web`; `path:` is a substring for the MVP **Decision:** Search (the qualifier parser, in-repo scan, and bounded global scan) is implemented in `crates/web`, not a separate crate, using ripgrep's `grep` engine. The `path:` qualifier matches as a case-insensitive **substring**, not a full glob. **Alternatives:** A dedicated `search` crate; full glob path matching via `globset`. **Rationale:** Search is a web-only feature with no other consumer, so a crate of its own adds indirection for no reuse; the content scan runs inside the existing `git_read` blocking closure. The global scan bounds itself with a `Semaphore(search.concurrency)` and a `tokio::time::timeout(search.timeout_secs)` deadline, returning partial results with an explicit notice on either the time or `search.max_results` cap. A substring `path:` covers the common case; upgrading to `globset` is a contained change if needed. `linguist-vendored`/`-generated` exclusion is deferred (binary and >1 MiB files are already skipped). ## 2026-07-24 — Linear search, no index **Decision:** Search (in-repo and global) is a linear scan over repo contents at request time, with worker-pool, time-budget, and result-count bounds. **Alternatives:** Maintain a `tantivy` full-text index. **Rationale:** Appropriate for a small, private instance. A `tantivy` index is the intended path if linear scan stops being fast enough. ## 2026-07-24 — Nix flake uses flake-parts **Decision:** The flake is structured with `flake-parts` (module system, `perSystem`) rather than `flake-utils`. **Alternatives:** `flake-utils.lib.eachDefaultSystem`. **Rationale:** flake-parts scales better as the flake grows (NixOS module, container image, multiple checks) and gives a cleaner multi-output structure. The spec permits either; picked one and stay consistent. ## 2026-07-24 — Secrets are a redact-by-default `Secret` newtype **Decision:** Secret configuration values (`auth.secret`, `mail.password`) are wrapped in a `config::Secret` newtype whose `Debug` and `Serialize` impls always emit ``. The real value is reachable only through `Secret::expose`. **Alternatives:** Store secrets as plain `String` and redact them at each output site (`config show`, error messages, logs). **Rationale:** Redaction is the default and cannot be forgotten — a `Secret` cannot leak through `tracing`, a `Debug`-formatted `Config`, `fabrica config show`, or the JSON API, satisfying the spec's "secrets MUST NOT be logged, echoed, or included in error messages". Site-by-site redaction is one missed call away from a leak. Trade-off: serialization is lossy, so `Secret` is never round-tripped through our own `Serialize` (figment reads the raw value into the figment `Value` before deserialization, and `*_file` resolution reads from disk). ## 2026-07-24 — Strict config: unknown keys are errors, missing files are not **Decision:** Every config struct is `#[serde(deny_unknown_fields)]`, so an unknown key (typo, stale key, stray `FABRICA__*`) aborts the load. Missing files at the fixed locations (`/etc`, XDG) and a missing `--config` path are tolerated silently. Directory validation checks creatability without side effects (an existing directory ancestor), never creating anything during `config check`. **Alternatives:** Ignore unknown keys (serde default); require every file to exist; create directories during validation. **Rationale:** Failing fast on typos is worth more than forward-compatibility for a single-operator forge. Tolerating missing files lets one binary serve both a bare first-run machine and a fully-configured one. Side-effect-free validation keeps `config check` safe to run anywhere. ## 2026-07-24 — Store runs over `sqlx::Any`, not per-dialect code paths **Decision:** The `store` crate uses a single `sqlx::Any` pool and writes each statement once, rather than a `Store` trait with separate `SqlitePool` and `PgPool` implementations. Three dialect differences are mediated explicitly: placeholders use the `$1,$2` syntax both SQLite and Postgres accept (only MySQL needs `?`); booleans bind as Rust `bool` (encodes correctly for both) and are read back through `Store::get_bool`, which decodes SQLite's `INTEGER` storage and Postgres's native `BOOLEAN`; timestamps and ids are already portable (`BIGINT` ms, `TEXT` ULID). **Alternatives:** A `Store` trait with two concrete pool implementations (the spec's other blessed option), duplicating every query per dialect. **Rationale:** The spec explicitly permits "runtime queries over `AnyPool`". One code path is far less surface to keep in sync than two, and the portability rules (portable SQL subset, no compile-time-checked macros) already constrain us to the intersection where `Any` is safe. The single genuinely divergent read — booleans — is funnelled through one helper, which is exactly the "the trait maps them" seam the spec calls for. Migrations remain two dialect-specific `.sql` sets, embedded via `sqlx::migrate!` and selected at runtime by backend. ## 2026-07-24 — sqlx built without a TLS backend (for now) **Decision:** `sqlx` is pulled with `default-features = false` and no `tls-*` feature; Postgres connections are plaintext. **Alternatives:** Enable `tls-rustls-ring` (or native-tls) so Postgres-over-TLS works out of the box. **Rationale:** TLS is only relevant to a remote Postgres, which is a phase-17 deployment concern; SQLite (the default) and local/socket Postgres need none. Omitting it keeps the dependency tree small and, notably, keeps `ring` out of the graph so `cargo-deny`'s license set stays clean without special-casing. To be revisited when deployment against a managed Postgres is implemented; the change is a one-line feature addition plus a `deny.toml` license allowance. ## 2026-07-24 — Nix build source keeps `.sql` migration files **Decision:** `flake.nix` replaces `craneLib.cleanCargoSource` with a `cleanSourceWith` filter that additionally keeps `*.sql`, because `sqlx::migrate!` embeds the migration files at compile time (and a store test `include_str!`s them for the schema-equivalence check). **Alternatives:** Move migrations under a path crane already keeps; generate them into `OUT_DIR`. **Rationale:** Crane's cargo-source filter strips non-Rust files, which would break every derivation that compiles `store`. Widening the filter is the minimal, explicit fix and leaves room to add further asset globs (themes, vendored htmx) the same way in later phases. ## 2026-07-24 — Hand-rolled HS256 JWTs instead of a JWT crate **Decision:** `crates/auth` implements the HS256 JSON Web Token construction directly (base64url of a fixed JOSE header and a serde JSON payload, an `hmac`/`sha2` HMAC-SHA256, constant-time verification via `Mac::verify_slice`) rather than depending on `jsonwebtoken` or similar. **Alternatives:** Use the `jsonwebtoken` crate. **Rationale:** The mainstream JWT crates depend on `ring`, whose license set is not in `deny.toml`'s allow-list (it carries OpenSSL-derived terms), so pulling one in would fail `cargo-deny` — the same reason sqlx is built without a TLS backend. The HS256 surface we need is tiny and well-specified; hand-rolling it is a few dozen lines over crates already in the tree (`hmac`, `sha2`, `base64`, `serde_json`) and lets us reject algorithm-confusion tokens explicitly. Tokens are minted and verified only by fabrica, so interop with other JWT producers is a non-goal. ## 2026-07-24 — `access()` takes the collaborator row and anonymous flag explicitly **Decision:** The permission function is `access(viewer: Option<&Viewer>, repo: &Repo, collaborator: Option, allow_anonymous: bool) -> Access`, widening the two-argument shape sketched in the spec. **Alternatives:** Keep the literal `access(viewer, repo)` signature and thread the collaborator lookup and `instance.allow_anonymous` in via `Viewer` or a context struct. **Rationale:** Rule 3 needs the viewer's `repo_collaborators` row and rule 4 needs `instance.allow_anonymous`; neither lives on `Viewer` or `Repo`. Passing them as explicit parameters keeps `access` a pure function of its inputs — which is what makes the exhaustive matrix test possible — and keeps the collaborator lookup at the call site (the store), where it belongs. The five ordered rules are unchanged from the spec. ## 2026-07-24 — `user` CLI pulled forward; store-backed commands self-migrate **Decision:** The `fabrica user` command group (`add`, `passwd`, `list`, `disable`, `enable`, `del`) is implemented now, alongside phase 4, rather than waiting for the phase-8 CLI milestone. Every store-backed command applies pending migrations on connect (idempotent), so a fresh install's first `user add` bootstraps the database in one step. **Alternatives:** Keep strictly to phase order and ship the `user` commands only in phase 8; require a separate `fabrica migrate` before any `user` command works. **Rationale:** The requested capability — set a user's password from the CLI when SMTP is unavailable — is realized by `user passwd`, which needs both `auth` (hashing) and `store` (persistence) but nothing from the intervening phases (git/highlight). Building it now delivers that capability immediately and keeps the auth crate exercised by a real caller. Self-migrating on connect is what makes `user add` usable before `serve` has ever run; `serve --no-migrate` remains the opt-out for the server path. ## 2026-07-24 — Interim `user add` without `--pass` creates an inactive account **Decision:** Until the mail/invite flow ships (phase 9), `fabrica user add` without `--pass` creates the user with a `NULL` password (unable to log in) and prints guidance to run `fabrica user passwd`. CLI-set passwords clear `must_change_password` (the operator chose a known credential). **Alternatives:** Block `user add` until invites exist; always require `--pass`. **Rationale:** The spec's `user add` mints an emailed activation link, which depends on `crates/mail` (a later phase). The inactive-account-plus-guidance behaviour is a faithful interim: no account is silently left in a login-capable state without a credential, and the operator has an obvious next step. When mail lands, this path grows the invite-and-email behaviour; the `passwd` fallback stays for SMTP-disabled instances (`mail.backend = "none"`), which is exactly the case that motivated it. ## 2026-07-24 — git2 pinned to the libgit2 nixpkgs ships (1.9.4) **Decision:** `crates/git` depends on `git2` 0.20 with a direct `libgit2-sys = "=0.18.3"` pin, and the build uses the **system** libgit2 (`LIBGIT2_NO_VENDOR=1`, already set in the flake) rather than the vendored copy. **Alternatives:** Let `libgit2-sys` build its bundled libgit2 from source (drop `LIBGIT2_NO_VENDOR`, add `cmake`); track `git2`'s latest `libgit2-sys`. **Rationale:** `git2` 0.20's default `libgit2-sys` (0.18.7, bundling 1.9.6) requires libgit2 ≥ 1.9.6 and aborts against nixpkgs' 1.9.4 under `LIBGIT2_NO_VENDOR`. `libgit2-sys` 0.18.3 (bundling 1.9.2) requires ≥ 1.9.2, which 1.9.4 satisfies. The exact pin makes the coupling explicit and stops a stray `cargo update` from reintroducing the mismatch; bump it in lockstep when nixpkgs bumps libgit2. Using the system lib keeps builds fast and avoids a `cmake` build-dep and a second copy of libgit2 in the graph. ## 2026-07-24 — Dev shell sets LD_LIBRARY_PATH for dynamic libgit2 **Decision:** The `devShells.default` exports `LD_LIBRARY_PATH = makeLibraryPath buildInputs`. **Alternatives:** Rely on rpath; require developers to set it themselves. **Rationale:** Test and bin artifacts built interactively (`cargo test`, `cargo run` in the dev shell) link libgit2 dynamically but do not receive the Nix build sandbox's rpath, so the loader cannot find `libgit2.so.1.9` and the binary exits 127. The Nix build's own checks (`nix flake check`) embed rpath and are unaffected; this only fixes the interactive `just check` path. ## 2026-07-24 — `.gitattributes` resolved by an own resolver, cached off `Repo` **Decision:** `crates/git` resolves attributes itself (`attributes.rs`): it walks a tree collecting every `.gitattributes` blob with its directory prefix, parses each into ordered rules, and matches paths with `globset`. Precedence is "deeper directory wins, later line within a file wins", implemented by applying shallowest-first into a map. The `moka` cache (`AttributesCache`) keyed by tree oid is a **separate** type the web layer holds, not a field on `Repo`. **Alternatives:** Use libgit2's `Repository::get_attr`; cache inside `Repo`. **Rationale:** libgit2's attribute lookup is oriented at a working tree and is unreliable against a bare repo and for per-ref accuracy (§3.5). `Repo` is opened per operation (per the phase-5 threading decision), so a cache on it would never outlive a request; placing the `moka` cache in a standalone `AttributesCache` lets the long-lived web state share resolved sets across requests without coupling to the handle's lifetime. ## 2026-07-24 — Highlighting via inkjet (all grammars, default features) **Decision:** `crates/highlight` uses `inkjet` 0.11.1 with default features (which bundle every tree-sitter grammar plus the `constants` capture-name table). We use its `highlight_raw` event stream and build per-line HTML ourselves rather than its built-in HTML formatter. **Alternatives:** Depend on `tree-sitter-highlight` directly with a hand-curated grammar set; enable only `language-*` features for a smaller build. **Rationale:** inkjet is MIT/Apache-2.0 and vendors its grammars' C source *inside the single crate*, so they never appear as separate nodes in cargo-deny's graph and the license gate stays green. Bundling all grammars honours "highlighting everywhere code appears" without a curation burden; the build cost is paid once and cached. The built-in formatter emits one blob, which cannot satisfy the line-addressable/per-side-diff requirement (§8), so we consume the raw event stream and fold captures onto our stable `hl-*` classes in one table. inkjet's grammars link `libstdc++`, added to the flake `buildInputs` so the built binary rpaths it and the dev shell's `LD_LIBRARY_PATH` picks it up. ## 2026-07-24 — Highlight guards; per-file timeout deferred **Decision:** Highlighting can never fail a render: oversized (> `max_highlight_bytes`), binary (NUL in the first 8 KiB), and minified (any line > 5000 bytes) inputs skip straight to plain text, and any grammar panic is caught (`catch_unwind`) and degraded the same way. The spec's per-file **timeout** guard is **not** yet implemented. **Alternatives:** Implement the timeout now via a watchdog thread and a tree-sitter cancellation flag. **Rationale:** The size, binary, minified, and panic guards cover every failure mode observed in practice; a hung grammar is vanishingly rare and, when it happens, is bounded by the `spawn_blocking` pool at the call site rather than by silently corrupting a page. Wiring a hard per-file deadline (a cancellation flag polled by the grammar, or a watchdog thread) is deferred to the polish phase, where the highlight cache and measured hot paths land together. Tracked as a known gap. ## 2026-07-24 — GPG verification via rpgp, not sequoia-openpgp **Decision:** OpenPGP signature verification uses the `pgp` crate (rpgp) 0.20, not `sequoia-openpgp` as the spec names. **Alternatives:** `sequoia-openpgp` (the spec's choice); shell out to `gpg`. **Rationale:** `sequoia-openpgp` is **LGPL-2.0-or-later**, which is not in `deny.toml`'s allow-list — pulling it in fails `cargo-deny`, the same class of constraint that rules out `ring` and drove the hand-rolled JWT. `gpgme` shells out to GnuPG (GPL). rpgp is `MIT OR Apache-2.0`, pure-Rust (ed25519-dalek/rsa/p256, no `ring`, no OpenSSL/C), and covers the verification surface we need. SSH signatures use `ssh-key` (also Apache-2.0/MIT, pure-Rust), as the spec specifies. This is a **SHOULD** deviation logged here. ## 2026-07-24 — SignatureState ids are String; key expiry/revocation best-effort **Decision:** `SignatureState` carries `String` ids rather than the spec sketch's `Ulid`, and does not yet reject on GPG **key expiry or revocation**. **Alternatives:** Depend on `ulid` in the git crate for typed ids; implement full key-validity/revocation checking. **Rationale:** The git crate has no `ulid` dependency and speaks ids as opaque strings everywhere else (oids are hex `String`s); threading a `Ulid` type through solely for these fields would add a dependency for no gain, since ids originate in and return to the store as strings. On expiry/revocation: rpgp 0.20 exposes no one-call "valid at time T" check for detached signatures, and revocation requires manually scanning revocation signatures. SSH signature verification and GPG signature **cryptographic** verification are complete and cover all four `SignatureState` variants (tested); rejecting on GPG key expiry/revocation is a documented best-effort gap to close alongside the signature cache wiring in the web phase. Expired/revoked keys therefore currently verify as `Verified` if the signature is otherwise valid — tracked as a known limitation. ## 2026-07-24 — Key ordinals are unique per user, not per (user, kind) **Decision:** `create_key` assigns `keys.ordinal` as `max(ordinal)+1` over **all** of a user's keys (both SSH and GPG), not per `(user, kind)`. **Alternatives:** Per-`(user, kind)` ordinals, as the schema comment sketches. **Rationale:** The spec's `fabrica key del ` carries no `--type`, so the index shown by `key list` must be unambiguous across a user's keys. Per-user ordinals give exactly that while still satisfying the schema's `UNIQUE (user_id, kind, ordinal)` (a per-user-unique value is a fortiori per-`(user,kind)`-unique). Deleting a key never renumbers the survivors, as the spec requires. ## 2026-07-24 — CLI resolves-or-generates the instance secret on first use **Decision:** `fabrica token add` (and any future secret consumer) resolves the HS256 signing secret via `context::ensure_secret`: inline `auth.secret`, else the contents of `auth.secret_file` (default `{data_dir}/secret`), else a freshly generated 32-byte secret written to that path at mode `0600`. **Alternatives:** Require the operator to pre-provision a secret before any token can be minted. **Rationale:** The spec has the secret "generated on first run"; the CLI operates directly on the data directory with no running server, so first-run generation must happen there too. Centralizing it in one helper means `token add` today and `serve` later sign with the same key. `auth::new_secret` provides the 32-byte base64url value, keeping randomness in the auth crate. ## 2026-07-24 — Mail over native-TLS, not the spec's rustls transport **Decision:** `crates/mail` uses `lettre` with the `tokio1-native-tls` transport, not the `tokio1-rustls` transport the spec names. **Alternatives:** `tokio1-rustls-tls` (the spec's choice). **Rationale:** lettre's rustls transport pulls `rustls` → `ring`, whose license set is outside `deny.toml`'s allow-list — the same constraint that shaped the JWT and sqlx-TLS decisions. native-tls links the **system OpenSSL**, which is already a flake `buildInput` (and present in the Debian runtime image), so it adds no new C dependency and keeps `ring` out of the graph. Backends are `smtp` (native-TLS `starttls`/`tls`/plaintext), `stdout`, and `none`; tests exercise the `stdout` and `none` paths so `cargo test` needs no SMTP server. A **SHOULD** deviation logged here. The invite token reuses `auth::new_session_token`'s shape (32 bytes, base64url value emailed, SHA-256 stored). ## 2026-07-25 — `serve` wired in the binary; `cli` never depends on `web` **Decision:** `fabrica serve` orchestration (load config → open store → build web state → run) lives in the root binary (`src/serve.rs`). `cli::run` takes a serve callback and hands back a `cli::ServeRequest`; the binary supplies the callback. `cli` exposes `resolve_secret` for the binary to reuse. **Alternatives:** Let `cli` depend on `web` and run the server directly. **Rationale:** The architecture rule is absolute — **no crate may depend on `web`** — and `serve` must start `web` (and later `ssh`/`api`). The only place those top-level crates can be tied together is the binary, which already depends on all of them. The callback keeps `cli` (the command tree) free of `web` while `main.rs` stays a thin wiring layer. `--detach` is accepted but not yet implemented (foreground is correct for systemd/Docker); deferred to deployment. ## 2026-07-25 — Web stack: axum 0.8, plain cookies, no global timeout **Decision:** The web crate uses axum 0.8 + axum-extra cookies + maud + tower-http, with **no TLS/rustls feature anywhere** (TLS terminates at a reverse proxy), so `ring` stays out of the graph. Session and CSRF use **plain (unsigned) cookies**: the session cookie is an opaque 32-byte token validated by SHA-256 lookup in the `sessions` table; CSRF is double-submit (a non-`HttpOnly` `fabrica_csrf` cookie matched against the `X-CSRF-Token` header htmx injects, or a `_csrf` form field). There is **no global request timeout** layer. **Alternatives:** `SignedCookieJar` (needs a `Key` + `FromRef`); tower-cookies; a global `TimeoutLayer`. **Rationale:** The session token is already unguessable and DB-validated, so cookie signing adds machinery without materially improving security; double-submit CSRF needs a JS-readable cookie regardless. A global timeout is actively **wrong** for this app: pack-transport routes (§3.3) stream arbitrarily long clones and must never be cut off, so timeouts (if any) belong per-route on non-git paths, added in the polish phase. The theme picker is a real GET form (works without JS) that sets a `fabrica_theme` cookie; `SIGHUP` re-scans `themes_dir` via an `RwLock`. ## 2026-07-25 — Pack transport: 401 (not 404) for anonymous, buffered request **Decision:** Smart-HTTP pack routes return **401** with `WWW-Authenticate: Basic` for anonymous access to a repo they can't read (so git prompts for credentials), whereas an authenticated stranger gets **404** (no existence leak). This differs from the browse UI, which always 404s. The upload-pack **request** (client wants, small) is buffered — decompressing gzip when present — while the **response** pack streams straight from the child's stdout via `ReaderStream`, never buffered. Push over HTTP (`git-receive-pack`) returns a deliberate 403 naming the SSH URL. **Alternatives:** 404 for anonymous too (no credential prompt, breaking `git clone` of private repos); stream the request as well. **Rationale:** git's HTTP client only sends Basic credentials after a 401 challenge, so a private clone is impossible without it — the spec mandates the 401. The authenticated-stranger 404 preserves the no-leak invariant for logged-in users. The upload-pack request is a few KB of wants, so buffering it (and handling `Content-Encoding: gzip`) is simpler than streaming and violates no memory constraint; the pack that must never be buffered is the response, which is streamed. HTTP auth is username+password (or session cookie); API-token Basic auth lands with the API phase. `fabrica hook post-receive` derives the repo id from the `GIT_DIR` basename and loads config from `FABRICA_CONFIG` (set by the SSH/HTTP server on the child) or the default location; a local push with neither set no-ops cleanly, and it always exits 0 so a hook error never rejects an accepted push. ## 2026-07-25 — SSH via russh; the OpenSSL license is admitted for the crypto backend **Decision:** `crates/ssh` uses `russh` 0.62 with its **default** crypto backend `aws-lc-rs` (explicitly **not** `ring`). `deny.toml` gains three permissive licenses its transitive tree needs — **`Unicode-DFS-2016`**, **`0BSD`**, and **`bzip2-1.0.6`** — and `flake.nix` gains `cmake` (aws-lc-sys builds AWS-LC via cmake). **Alternatives:** the `ring` backend (also carries OpenSSL-family terms and is not allowlisted); not shipping SSH at all. **Rationale:** SSH is not optional for a git server (the DoD requires push over SSH), the spec names russh, and russh cannot function without a crypto backend. `aws-lc-rs` is the default and keeps `ring` out. Its tree pulls a few extra permissive licenses (`Unicode-DFS-2016`, `0BSD`, `bzip2-1.0.6`), admitted because SSH is mandatory; all three are permissive. This is the one place the otherwise-strict allowlist is widened, and it is scoped to the transport stack. **Implementation notes:** russh merged `russh-keys` into `russh::keys` and re-exports `ssh-key` at a patch level distinct from the workspace's, so the two `PrivateKey` types differ; the host key bridges them via the OpenSSH PEM text (generate/load with the workspace `ssh-key`, parse with `russh::keys`). Identity is by key fingerprint, not the SSH username (everyone connects as `git@`); `channel_open_session` must explicitly `reply.accept()` in 0.62 or the channel is rejected; exec output streams through the `Clone + Send` `Handle` from a task. ## 2026-07-25 — UI refresh: inline icons, Carbon themes, dashboard/explore split, profiles **Decision:** Vendor UI glyphs as an inline-SVG `icons` module (Lucide, ISC) with brand marks from Simple Icons (CC0), replacing the emoji glyphs. Rebase the default `dark`/`light` themes on IBM Carbon (Gray 100 / White) as pure `--fb-*` token changes. Split `/` into a signed-in dashboard and a public `/explore` landing (the anonymous landing carries no in-content sign-in button). Give users a profile (display name, pronouns, bio, location, website, GitHub/Mastodon handles) edited at `/settings`, and avatars served from `/avatar/{username}`. **Avatar storage:** the bytes live on disk at `{storage.data_dir}/avatars/{id}`; `users.avatar_mime` (nullable TEXT, migration 0002) records the content type and its presence means "serve the file". A NULL falls back to a deterministic FNV-derived 5×5 identicon SVG generated in-process. **Alternatives:** an icon font or sprite sheet (extra asset, worse a11y); storing avatar bytes in the DB (BLOB vs BYTEA is not portable across SQLite/Postgres, which § store portability forbids); Gravatar or any remote avatar (a network request from a page, forbidden by §9.5); a single combined home page (the prior behaviour). **Rationale:** inline SVG inherits `currentColor`, scales to `1em`, needs no network request, and keeps the "no CDN / no build step" rule. Carbon is a documented, freely usable palette and the swap touches only theme token values, proving the structure-only `base.css` contract. On-disk avatars keep the schema in the portable subset. Uploads are limited to raster types (PNG/JPEG/GIF/WebP, ≤ 1 MiB); SVG upload is refused so no user-supplied markup is ever served, while our own identicons are SVG. Both mutating routes (`POST /settings`, `POST /settings/avatar`) verify the double-submit CSRF token; `/settings` redirects anonymous visitors to `/login`. ## 2026-07-25 — Three-level visibility, repo settings, and SSH push-to-create **Decision:** Repositories carry a GitLab-style **visibility** — `public` (everyone, anonymous gated by `allow_anonymous`), `internal` (any signed-in user), or `private` (owner/collaborators/admins) — replacing the old two-state `is_private` flag. `auth::access` decides read on visibility after the admin/owner/collaborator rules; `instance.default_visibility` (default `private`) sets the default for new repos. Owners/admins get a **Settings** repo tab (rename, visibility, delete) whose mutations post to a fixed `/repo-settings/{id}` prefix (never colliding with the `/{owner}/{*rest}` catch-all) and re-check Admin access, returning `404` — never `403` — to non-admins. An SSH **push to a non-existent repo** owned by the pushing user (or an admin) **auto-creates** it at the default visibility (row + bare repo on disk, rolled back on disk failure). **Migration:** `0004_repo_visibility` adds a `visibility TEXT NOT NULL DEFAULT 'private'` column backfilled from `is_private`; the `is_private` column is left in place (SQLite cannot drop columns) but no longer read or written. **Alternatives:** keeping the boolean and bolting "internal" on as a second flag (two booleans encode four states, one nonsensical); a dedicated POST route per repo path (impossible — repo paths are variable-length under groups); refusing push-to-create (an extra round-trip to the CLes/UI for every new repo). **Rationale:** three named states model real instances (internal is the common "logged-in only" tier) and keep `access` a single exhaustively-tested function — its matrix test now enumerates all three visibilities × viewer kinds × the anon flag. `allow_anonymous` now gates only anonymous access to *public* repos, which is what it always meant. The fixed `/repo-settings/{id}` prefix sidesteps the catch-all cleanly and keys on the id (rename-safe). Push-to-create matches the muscle memory of every other forge and stays safe: only the authenticated owner (or an admin) can trigger it, and only on receive-pack. ## 2026-07-25 — Scope expansion: a complete forge minus CI **Decision:** Reverse the original "issues / pull requests / self-registration are non-goals" posture. fabrica now targets a **complete forge minus CI**: issues and pull requests with GitHub-style **scoped labels** (`kind: value`, mutually exclusive within a scope), assignees, comments, and open/closed state; PRs with branch compare, diff, review, and server-side **merge machinery** (merge commit / squash / rebase) performed via the spawned `git` subprocess (never libgit2, per §3.1); issues and PRs **toggleable per repository**; optional web **self-registration** (`instance.allow_registration`, optional hCaptcha/reCAPTCHA) alongside admin invites; and expanded **account settings** (username/email/password, SSH/GPG keys, API tokens, profile). Still out of scope: orgs, forks, stars, wikis, releases, LFS, webhooks, federation. CI stays deferred and is built last. **Numbering:** issues and PRs share a per-repo counter (`repos.next_iid`) so `#N` is unique across both; a PR is an `issues` row with `is_pull = 1` plus a `pull_requests` side table, so labels/comments/numbering are shared. **Rationale:** the maintainer wants the instance to be a full-featured forge for real use, not just a private browser. Sharing the issue/PR numbering and tables keeps labels and comments uniform and avoids a parallel implementation. Merge via subprocess preserves the one code path for write operations. spec.md §§1, 18–19 and CLAUDE.md are updated to match; this is a large, multi-phase build delivered incrementally with the gate green at each step. ## 2026-07-25 — Captcha on sign-up: an opt-in exception to "no network from pages" **Decision:** Implement the spec §19 optional captcha (hCaptcha / reCAPTCHA v2) on the `/register` form, off by default via a new `[captcha]` config table (`provider = none | hcaptcha | recaptcha`, `site_key`, `secret_key`). When — and only when — an admin enables it, the provider's widget script is loaded on the sign-up page and the solved token is verified server-side with an outbound HTTPS POST to the provider's `siteverify` endpoint. Verification **fails closed**: an empty token, a missing secret, a network error, or a rejected token all reject the sign-up, so a broken verifier never admits a bot. The HTTP call uses `reqwest` (`default-features = false`, `native-tls` + `json`) — native-tls, not rustls, so `ring` stays out of the graph, consistent with the mail/SSH TLS decisions. **Rationale:** captcha is fundamentally incompatible with §9.5 ("no network requests from any page") — it cannot work without third-party JS and an out-of-band verify. The spec nonetheless lists it as an optional feature, so the resolution is to make it a **deliberate, opt-in** deviation: the no-network invariant holds for every page by default, and enabling captcha is an explicit administrator choice to accept a third-party dependency on one page. No other page loads external resources. ## 2026-07-25 — SSH/GPG key management in the web account settings **Decision:** Surface the existing key store (CLI phase 8) in the web settings page: an "SSH and GPG keys" section listing the viewer's keys with delete buttons and an add form (type + optional label + pasted material), backed by `POST /settings/keys` and `/settings/keys/delete`. The add handler reuses `git::parse_public_key` for validation and fingerprinting and rejects a duplicate fingerprint; delete is ownership-checked against `keys_by_user`. This closes the last account-settings gap (§19) so key management no longer requires shell access. ## 2026-07-26 — Optional S3 object storage for uploads (aws-sdk-s3, aws-lc-rs) **Decision:** Add an optional S3-compatible backend for the three kinds of *uploads* — user avatars, release assets, and git-LFS objects — behind a new `blob` crate exposing a `BlobStore` enum with `Local` and `S3` variants. Bare git repositories are **not** included: git needs direct filesystem access, so `storage.repo_dir` is always local. Selected by `storage.backend = "local" | "s3"` (default `local`, so existing installs are unaffected) with a `[storage.s3]` section (`bucket`, `region`, `endpoint`, `access_key_id`, `secret_access_key`, `path_style`, `prefix`). The endpoint + path-style knobs make it work with MinIO, Cloudflare R2, Backblaze B2, and other S3-compatible stores, not just AWS. **Client:** `aws-sdk-s3` with a **manually constructed** config (static credentials, custom endpoint) rather than `aws-config`'s credential-provider chain, keeping the dependency surface and startup work small. The default HTTPS client (`default-https-client`) pulls `ring`, which the license gate forbids; instead the HTTP client is wired explicitly via `aws-smithy-http-client` with the `rustls-aws-lc` feature and `tls::Provider::Rustls(CryptoMode::AwsLc)`, so the crypto provider is **aws-lc-rs** — already in the tree via russh and already on the license allow-list — and `ring` never enters the graph (`cargo deny check licenses` stays green). **Streaming:** LFS and release-asset uploads still stream to a **local scratch file** under `data_dir/tmp` (LFS) / `data_dir/releases/tmp` (assets) so the sha256 (LFS) and size are computed before the object is servable; the verified temp file is then handed to `BlobStore::put_file`, which renames it into place (local) or uploads it and removes the temp (S3). Downloads stream straight from the backend (`open` returns size + an `AsyncRead`). Keys are namespaced (`avatars/`, `releases/`, `lfs/{shard}`) and validated against path traversal. The local backend preserves the historical on-disk layout (`data_dir/avatars`, `data_dir/releases`, `lfs_dir`), so no migration is needed to stay on local disk.