fabrica

hanna/fabrica

40056 bytes

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 <user> <name> 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 <redacted>. 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<Permission>, 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 Strings); 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 <user> <index> 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 rustlsring, 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<Assets>.

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 visibilitypublic (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.