# Current

> Keep this file short. One active step, one ordered backlog. Completed work moves to
> [progress.md](progress.md). If this file starts reading like a changelog, it has
> drifted — that's exactly what went wrong last time.

## Active: Milestone 5b — Deployable by anyone

**Goal:** a stranger can install Steid on a fresh Linux box in one command and reach it
over HTTPS, and the author can publish an instance and push Steid's own source to it.
Distributed as **a binary plus its assets**, not a container.

**Out of scope:** push-to-release and any CI/CD (8+ — see
[ROADMAP.md](ROADMAP.md#milestone-ladder)), multi-user registration (7), and hosting
release artefacts on Steid itself, which it has no feature for.

### Steps

- [x] Release build producing `steid-<version>-<target>.tar.gz` — binary, `assets/`,
      README — and verified to boot from a clean extraction
- [x] `install.sh` — download, systemd unit, Caddy with automatic HTTPS, idempotent
- [x] `README.md` — the repo has none, and it is the front door of a portfolio project
- [x] Operability: `/healthz`, graceful shutdown on SIGTERM
- [x] `STEID_SETUP_TOKEN` as an optional override, so claiming is not a race with
      `journalctl`
- [x] Rate limiting on `/auth/login` and `/auth/setup`
- [x] Backup and restore, documented — two paths, rsync is enough

### Done when

`curl … | sh -s -- --domain git.example.com` on a fresh VPS yields a working HTTPS
instance, and Steid's own source is pushed to it and browsable there.

### Settled

- **A binary plus `assets/`, not a container.** Topcoat can embed the asset *manifest*
  (`Manifest::parse` + `include_str!`, for WASM) but not the asset *bytes* — that path
  pairs with `AssetConfig::hosted_at`, which expects a CDN. A single self-contained file
  would mean fighting the framework, so the artifact is a directory. The Dockerfile stays
  as a secondary path.
- **A TLS proxy is mandatory, not conventional.** Topcoat 0.5 has no TLS: no rustls, no
  ACME, no HTTPS listener — checked, not assumed. Since git authenticates over HTTP
  Basic, without TLS a personal access token crosses the network in cleartext on every
  push. Caddy is chosen because automatic certificates are its headline feature and the
  config is two lines.
- **glibc, built on Debian bullseye — not static musl.** Two reasons, the second
  decisive. musl failed on `ring` (Debian's `musl-gcc` wrapper rejects `-m64`) and would
  need a real cross toolchain. But **musl's whole point is a binary with no runtime
  dependencies, and Steid hard-requires `git` on `PATH`** — anyone installing it already
  has a package manager, so the portability musl buys cannot be used. Building on
  bullseye pins the glibc floor at 2.31, covering Debian 11+ and Ubuntu 20.04+; building
  on bookworm would need 2.36 and silently exclude Ubuntu 22.04, which is still
  everywhere. Verified: a release build in `rust:1.97-slim-bullseye` succeeds, 11.5 MB.
- **The runtime binary links a TLS stack it never uses.** `ring` ← `rustls` ← `ureq` ←
  Topcoat's `icon-iconify`/`tailwind` features, whose `ureq` exists to download the
  Tailwind CLI *at build time*. Those features are enabled on the normal dependency as
  well as the build one, so the crypto comes along for the ride. Moving them to
  build-dependencies only would shrink the binary and drop an unused dependency from the
  attack surface — worth trying, not yet attempted, and it is what made the musl attempt
  fail where it did.
- **Rsync is the deployment mechanism for now**, and that is enough while the artifact is
  two paths. Push-to-release is the eventual answer and is parked at 8+.

### Open

- **Whether to add `STEID_TRUSTED_PROXY`.** The rate limiter keys on `X-Forwarded-For`
  because **Topcoat 0.5 discards the peer address at accept time and never exposes it** —
  a handler sees headers and nothing else. Behind a proxy that is fine. Exposed directly,
  a caller can vary the header for a fresh budget, leaving only the global cap. An
  explicit "trust forwarded headers" flag defaulting to off would close it, at the cost
  of one more thing an operator must get right. Not picked.
- **`MAX_RAW_BYTES` is 10 MiB**, chosen rather than derived. It bounds what one raw
  request can hold in memory, because `GitQuery` reads bytes rather than streaming them.
  Raising it means a handful of concurrent requests can hold that much each.
- **Renaming a repository** is still impossible, and now needs its own use case plus an
  answer for moving the directory under every existing clone.
- **The profile has no links.** `Organization` carries a display name and a bio and
  nothing else, so the design's links row is not backed by data and was left out rather
  than faked. A `links` field plus a settings control is the small feature that fixes it.
- **`create_repo` and `serve_git` read the clock internally** rather than taking a `now`,
  which is what `issue_token` does. Two call-site edits, or a `Clock` port if a third
  case appears.
- **A push touches `updated_at` twice** — once for the advertisement, once for the RPC.
  Harmless, same second, but two writes per push.
- **A licence.** A public portfolio repository probably wants one, and the README
  deliberately says nothing about licensing rather than guessing.
- ~~Blocked on a domain transfer~~ — **transferred 2026-08-29**, so Phases 1–6 of the
  deployment runbook are unblocked.
- **(historic) Blocked on a domain transfer** (noted 2026-08-29). `jpgill.dev` is the
  intended host for both the instance and the release downloads; the transfer is in
  flight. Phases 1–6 of the deployment runbook cannot start until DNS resolves, because
  Caddy requests a certificate on startup. Nothing else is blocked by it — the artifact
  is built and verified, and `install.sh` still needs its container dry-run.
- **A domain.** Caddy needs a real hostname to obtain a certificate. This is the one
  blocker that is DNS rather than code.

### Open

- **Whether an absent *public* repository can 404 while private ones still 401.** The
  uniform 401 from [0007](decisions/0007-tokens-over-http-basic.md) is what makes a
  private repository indistinguishable from one that does not exist — but it also turns
  every mistyped URL into "Authentication failed", which cost real time on the first
  deployment. The leak being prevented concerns *private* names only, so answering 404 for
  a repository that would be public if it existed may cost nothing. Needs thought about
  whether that is actually true.
- **Zero-downtime deploys — deferred, deliberately.** `deploy.sh` currently stops the
  service, swaps the binary and starts it: about two seconds. The plan, when it is worth
  building: a systemd **template unit** `steid@.service` where the instance name *is* the
  port, with `/opt/steid/3000` and `/opt/steid/3001` as separate install directories.
  Deploy alternates — install to the idle port, start it, health-check it **directly on
  loopback**, rewrite Caddy's upstream and `caddy reload` (graceful; existing connections
  finish), then stop and disable the old one and enable the new so a reboot brings back
  the right one.

  Two things make this more than a shell script, and they are the reason it is deferred
  rather than half-built:

  - **Both processes share one SQLite database during the overlap.** WAL allows that, and
    Steid's writes are short, so the overlap itself is fine. **Migrations are not.** The
    new binary migrates on boot while the old one is still serving, so any schema change
    that the old binary cannot tolerate — a new `NOT NULL` column, a dropped column —
    breaks the still-live version. That is a constraint on how migrations are written
    (**expand, deploy, contract**), not something the deploy script can solve.
  - **An in-flight `git push` is cut when the old process stops.** `topcoat::start`
    already drains on SIGTERM, so the fix is to stop the old one *after* Caddy has
    repointed and let it finish what it has.

  Worth building when instances belong to other people. At two seconds on a personal site,
  it buys nothing today.
- **No scheduled backups.** `/var/lib/steid` is the whole of the state and copies of it
  exist only because they were taken by hand. This is the largest gap now that the
  instance is live.
- **Releases are not served from the instance yet.** The Caddy blocks are written and
  commented in `deploy/Caddyfile`, and `install.sh`'s `RELEASE_BASE_URL` points at
  `/jamesgill/repos/steid/releases`, but nothing has been rsynced there — so the
  one-command install a stranger would run does not work yet.

### Opened by the section 1 wave

- **Highlighting caches nothing.** The same file is re-highlighted on every view at
  ~87 ms per thousand lines in release. Cache per blob object id when it shows. Blame
  now pays this too, on top of being the most expensive read in `GitQuery` — the two
  costs land on the same page, which is where it will show first.
- **Diffs, READMEs and markdown code fences are unhighlighted.** The README goes through
  `web/markdown.rs`, which writes its own `<pre><code>`; wiring `highlight.rs` into it is
  a small follow-up. Blame was the fourth of these and is now done, so the adapter is
  reached from two pages and the pattern for a third is set: build the file's text in
  line order, call `source_lines` **once**, render `Classed` through `Unescaped`.
- ~~`.jsx` is plain~~ — **aliased onto JavaScript.** `highlight.rs` gained an `ALIASES`
  table, consulted only after the syntax set's own answer, so an alias can never
  overrule a real grammar. It has one entry and should stay small.
- **`\r\n` files keep the `\r` inside highlighted markup.** Invisible under
  `white-space: pre`; noted rather than fixed.
- **The blob header does not name the detected language.** It is only visible as colour.

- **No ahead/behind counts on the branches page**, by decision: a `rev-list` per branch
  is twenty forks for twenty branches. Waits for a kept-alive git.
- **Neither refs page is paginated.** A thousand branches render a thousand rows.
- **A repository with tags but no branches** says "No branches"; real but exotic, not
  fixture-tested.
- **`web::browse::{encode, timestamp}` are now `pub(super)`**, and `repo_stats` takes
  `handle` and `name` so the sidebar counts can link. `timed_out` has since moved to
  `web::context`, as this said it should.

- **`git grep` output is collected whole before it is capped.** A one-letter query on a
  big repository allocates all of git's output to keep 200 hits. Bounded by the 20 s
  timeout, not by anything deliberate.
- **The archive endpoint has no rate limiting** and is outside the read timeout, on
  purpose (a large repository legitimately takes longer to pack). It is the most
  expensive anonymous request in Steid: a full pack per hit. First thing to look at under
  load. A late `git archive` failure is a truncated download, logged like the protocol
  server's.
- **Search has no revision switcher on the results page**, no paging, `trim()`s the
  query, cuts matched lines at 500 characters (which can remove the match on a minified
  line), and ignores `GrepHit::column` when marking.

- **The commit page's timeout state has never been rendered**, like search's and the
  refs pages'. The predicate itself is now `web::context::timed_out`, shared by `refs`,
  `commit` and `blame`; each page still writes its own panel, and `search_repo` still
  answers in the application layer.
- **`Diff::files` is emptied wholesale when the patch is truncated**, falling back to the
  numstat list, so a 10 MiB commit shows no lines rather than the first few files.
  numstat itself could in principle be truncated at ~300,000 changed files.
- **A merge commit is diffed against its first parent only**, and merge rendering was
  compiled, not seen: no fixture contains one.
- **`MAX_RAW_BYTES` now does a second job** as the diff cap; retune both together.
- **`bg-surface` is nearly `bg-background` in light mode**, so a file header reads only
  by its border there. Pre-existing, but the commit page has one per file.
- **No paging anywhere in history**: `/log` shows 50, a comparison 100, both say so.
- **The compare form's `<datalist>` costs a `list_refs`** rendered inline on every visit.
- ~~Nothing links to a commit by its sha yet~~ — done.

- ~~The blame page has no revision switcher~~ — **added**, and `Switch` in
  `web/browse.rs` now has a `Blame` arm, so switching branch keeps you on blame at the
  same path. It costs the page one `list_refs`, which is what the wave deferred.
- **`BlameCommit::boundary` and `author_name` reach only the tooltip.** The row is sha,
  summary, date and must stay one line. On a multi-user instance the author is the first
  thing to revisit.
- **Blame is the most expensive read in `GitQuery`** and the likeliest to meet the 20 s
  timeout. Nothing but this page calls it.
- ~~Cross-branch links were verified only after the merge~~ — **walked, 2026-09-05**,
  see the integration pass below.

#### The integration pass — 2026-09-05

The five branches merged and run as one app, on a real instance holding Steid's own
history: 9 branches, 2 annotated tags, one public repository and one private. Every
link that crosses a feature boundary was followed in a browser, and **nothing was
broken at a seam.** Landing sha → commit page; log sha → commit; commit's "Browse
files" → tree at that sha and its parent sha → the parent's page; the counts and the
sidebar → branches and tags; a branch row → tree, log and a compare with a real diff
(a merged branch correctly says there is nothing to compare); a search hit's line
number → `blob#L<n>` with the anchor present and landing; `Code · Blame · Raw` in both
directions; blame's sha → the commit page and its line number → the blob anchor;
`zip` and `tar.gz` for `main` and for a tag, both extracting under `steid-<rev>/`.
**A private repository 404s on all eleven routes anonymously** — landing, log,
branches, tags, commit, search, tree, blame, raw, archive, compare. Highlighting still
renders after the blame toggle and the anchor edits landed on the same table: Rust and
TOML coloured, a plain file not. Screenshots of the whole flow in dark and light are
in `target/shots/`.

Two things the pass found, neither a break:

- **The sidebar's Download links can only ever offer the default branch.**
  `clone_block` takes a revision and its doc says the links are "for the revision being
  viewed", but the sidebar renders only on the landing page, which has no `{rev}` — and
  the tree and blob pages, which do, are single-column by design. So a tag's tarball is
  reachable only by typing `/archive/v0.2.0.zip`. Both formats work at both revisions;
  it is the entry point that is missing, and putting it on the tree page means deciding
  whether that page gets a sidebar, which [ui.md](ui.md#the-repository-page) settled the
  other way.
- ~~Blame renders its lines unhighlighted while the blob highlights them~~ — **closed.**
  Blame's code cell now goes through `source_lines`, the blob's own adapter, so the two
  views share classes, caps and plain fallback. Both render the same file to an
  identical set of `hl-` spans, and rows stay 19.5px — the only variance is at a run's
  hairline, which is `border-collapse` splitting that 1px, not the markup.

### Carried over — small, unblocked

- **A client that disappears mid-request leaves the body-copy task waiting.** The copy
  into git's stdin runs in its own task and nothing cancels it if the connection drops.
  Bounded by the backend exiting and closing the pipe, but not by anything deliberate.
- **`REMOTE_USER` is not set on the backend**, so a push is recorded in the repository's
  reflog without naming who made it. Steid knows the actor by then; it simply is not
  passed through. Small, and worth doing before anything reads reflogs.
- **No automated test asserts the security headers.** There is no HTTP-level test
  harness, so losing the CSP would be silent. The likeliest regression is someone using
  `insert()` instead of `or_insert()` and flattening the raw endpoint's stricter policy.
- **`cargo audit` has never been run**, and the dependency tree has not been reviewed.
- **No rate limiting on token authentication.** A token is 256 bits so guessing is not
  the worry; unbounded hashing on an open endpoint is.
- **Tokens have no expiry and no last-used timestamp.** Both deliberate omissions for
  now — see [0007](decisions/0007-tokens-over-http-basic.md) — but a token list with no
  "last used" makes it hard to know which are safe to revoke.
- **A subprocess per git request.** Unlike Milestone 3's once-per-creation, this is on a
  hot path and has not been measured. Milestone 5 is where that bill comes due.
- **Streaming is by construction, not by measurement.** The response body is never
  collected, but no clone large enough to prove it has been run.
- **An orphaned repo directory is possible** if the process dies between the record
  write and the filesystem write, and it then blocks re-creating that name. The durable
  fix is a reconciliation sweep on boot
  ([architecture.md](architecture.md#db-plus-filesystem-writes)); clearing one is a
  manual `rm` today, since repo deletion does not exist.
- **The duplicate-name check races.** The loser is caught by `init_bare` or the unique
  constraint, but surfaces as an opaque storage error rather than "name taken".
- **Bare repos created on macOS carry `ignorecase = true`.** A migration gotcha if the
  data directory ever moves to Linux.
- ~~**Light mode is still untested**~~ — **looked at 2026-09-05** on the repository
  landing page, a tree and a blob, by temporarily flipping the layout's `class="dark"`
  and screenshotting. Nothing was wrong: every colour on these pages already comes from
  a token. The profile, the settings pages and the log have still not been checked, and
  **there is still no way for a visitor to choose** — the layout hardcodes `dark`.
- **Submodule rendering was never seen**, only compiled: no fixture contained one.
- **The `/log` page's switcher opens with nothing marked current** when no revision is
  in the URL, because `repo_log` still does not report the revision it resolved. Now more
  visible than before, since there is a switcher to look wrong.
- **Task-list items keep their bullet** and footnotes render in place rather than
  collected at the end. Both cosmetic.
- **The landing page now makes 15 `git` processes** for a repository with a README and
  a licence (10 without, 1 for an empty one), up from 7. Five are the About sidebar's
  and run concurrently, so the *latency* is roughly one call — but it is fifteen forks
  per view, and this is now the most expensive page in Steid. The fix is 0006's
  kept-alive `cat-file --batch`, not trimming the sidebar.
- **`count_commits` costs two processes, not one.** It resolves the revision before
  running `rev-list --count`, because `rev-list` is fatal on an empty repository and on
  an unknown branch, and this module's rule is that a non-zero exit from git is always a
  real fault. `--ignore-missing` was tried: it covers a bad object id, not a bad
  revision *name*.
- **`bg-muted` is not a token and never was.** Three `<pre>` blocks used it, so they
  have been rendering with no background at all — silently, exactly as `ui.md` warns
  about classes Tailwind never ships. Fixed on the clone block, the empty-repository
  push snippet and, last, `token.rs`'s new-token block. All three now use `bg-surface`,
  and `bg-muted` appears nowhere in `src/` — only `bg-muted-foreground`, which is real.
- **The licence sniffer reads 1 KiB, not the 200 bytes first sketched.** BSD-2 and
  BSD-3 differ only by a third clause about 900 bytes in. `LICENSE-MIT` and other
  suffixed spellings are not detected: the filename list is deliberately short, because
  every extra candidate is another speculative blob read.
- **A per-file last-commit column is still absent**, deliberately — see
  [0006](decisions/0006-git-binary-behind-narrow-ports.md#amendment--20260829-the-milestone-5-read-path).
  Wanting it is the trigger to move to a kept-alive `cat-file --batch`, not to reopen
  `gix`.
- **Fonts are not loaded.** The theme names Geist and IBM Plex Mono; both fall back
  today. Topcoat's `font-fontsource` feature handles it.
- **The auth pages are unstyled.** `/auth/setup` and `/auth/login` are still bare
  milestone-1 HTML, and the styled top bar now sits right above them making it obvious.
- **The top bar's wordmark says "steid"**, not the owner's identity — see the open
  question at the end of [ui.md](ui.md#the-shell).
- **Light mode is untested.** The palette defines it; nobody has looked at it.
- **No rate limiting** on `/auth/login` or `/auth/setup`.
- **`sweep_expired` is never called**, so expired session rows accumulate. Expiry is
  enforced on read, so this is tidiness, not a hole.
- **CSRF.** `SameSite=Lax` covers the common case. Forms now exist, so this is decidable
  rather than hypothetical.

## Backlog

Ordered. Pull from the top.

1. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Open when it
   starts: which markdown crate, and whether raw HTML in markdown is trusted — safe for a
   single author, a stored-XSS hole the moment Milestone 7 adds a second user. A
   repository's README rendering on its page falls out of the same pipeline.

### Forge feature candidates (surveyed 2026-09-05)

Not yet ordered against Milestone 6. Grouped by what they touch, because the plan is to
run several agents at once and the grouping is what decides what can run together.
**Section 1 is being tackled first.** Shared hotspots: `GitQuery` in `port.rs` (every
browsing feature appends a method), `git_query.rs`, the repo sub-nav, and `plans/`
itself. New tables touch `sqlite.rs` + `in_memory.rs` and should merge one at a time.

**Section 1 is done**, apart from the per-file last-commit column — which is not a
gap but a deliberate wait for the kept-alive `cat-file --batch`
([0006](decisions/0006-git-binary-behind-narrow-ports.md)). Shipped: the two-column
repository landing page, syntax highlighting, branches and tags pages, archive
download, code search, the commit page, compare, and blame. All eight were merged and
then walked together as one app; see the integration pass above. See
[ui.md](ui.md#the-repository-page) for the layout and the entry-point map, and
[progress.md](progress.md) for what each cost.

1. ~~**Read-only browsing**~~ — **done**, except the **per-file last-commit column**,
   which stays blocked on the
   [0006](decisions/0006-git-binary-behind-narrow-ports.md) amendment: wanting it is the
   trigger for a kept-alive `cat-file --batch`, not for reopening `gix`.
2. **Repo model** (a column or use case each; merge serially): rename · default branch
   setting · archived flag · topics and pinned repos on the profile · orphan-directory
   reconciliation sweep on boot.
3. **Collaboration** (8+): issues · pull requests (needs the commit and compare pages,
   plus a merge on the bare repo) · labels and milestones.
4. **Transport and git ops**: import from URL (mirror clone) · post-receive events and
   webhooks (first brick of CI) · `REMOTE_USER` passthrough · LFS (not yet) · SSH
   (would reverse [0001](decisions/0001-git-over-http-not-ssh.md)).
5. **Identity and auth**: token expiry and last-used · rate limiting on token auth ·
   CSRF (touches every form — run alone) · Milestone 7 multi-user (run alone).
6. **Portfolio**: posts (Milestone 6) · profile links · Atom feeds for posts and commits.
7. **Platform and quality**: scheduled backups and served releases (closes 5b) ·
   HTTP-level test harness plus security-header tests and `cargo audit` · `/api`
   coverage for existing use cases · styled auth pages, light mode, fonts.

Process decisions, now answered by the wave rather than open: agents **do** get
worktrees and short-lived branches, which is a deliberate bend in the commit-to-main
rule and worth keeping for a wave; and agents do **not** edit `plans/` — each writes a
handover the merging session folds in, because five agents appending to `current.md`
conflict every time. The integration pass afterwards is not optional: it is the only
place a cross-feature link is ever exercised.

## Open questions

- **Topcoat is early** (v0.5.0, first released 2026-07-22, breaking changes expected
  by its own authors). Expect churn that isn't feature work.
- Topcoat ships Tailwind without Node, which reopens the design system attempt #1
  dropped purely to avoid an npm build step — see [ui.md](ui.md).

## Routing findings (Milestone 0)

- **Topcoat 0.5 requires rustc ≥ 1.95.** On an older toolchain `cargo add topcoat`
  silently resolves to an empty `topcoat v0.0.0` placeholder instead of failing. Local
  stable is now 1.97.1.
- `Router::builder().discover()` collects `#[page]`-annotated items **at link time**,
  so pages can live in any module. Layering is our choice, not the framework's.
- `module_router!` derives each URL from the module tree rather than a path string.
  Still deferred. Application routes now group cleanly (`auth/login`, `api/me`), but
  handles sit at the root ([0004](decisions/0004-root-handles-grouped-routes.md)), so a
  parameterised root segment still has to coexist with static ones. Worth checking how
  `module_router!` handles that before committing to it.
- Path and query params are read from `Cx` via `path_param!` / `#[query_params]`, not
  injected as handler arguments. Parses are memoized per request.
- Layouts wrap by path prefix and nest outermost-first, and a layout can catch a page's
  `NotFoundError` to render a branded 404.
- `HOST` / `PORT` configure the bind address, so `STEID_LISTEN_ADDR` is gone.
- `Body` is a boxed `http_body::Body` used for both requests and responses, with
  `into_data_stream()` to read and `Body::new()` to wrap a stream — pack data can
  stream both directions without buffering. This is what makes Milestone 4 viable.
