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