diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000000000000000000000000000000000000..bbd1c8fe6185e2239795afca1df6f51fe4a063af
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -1,0 +1,33 @@
+name: CI
+
+on:
+  pull_request:
+  push:
+
+permissions:
+  contents: read
+
+jobs:
+  quality:
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+      - run: cargo --version
+      - run: cargo install --locked cargo-deny --version 0.20.2
+      - run: ./scripts/check
+
+  release:
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ubuntu-latest, macos-latest]
+    runs-on: ${{ matrix.os }}
+    steps:
+      - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+        with:
+          persist-credentials: false
+      - run: cargo --version
+      - run: cargo test --locked --all-targets --all-features
+      - run: cargo build --locked --release
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..734f4fd4c644a89e94042a59191184d90bdb1f3e
--- /dev/null
+++ b/.gitignore
@@ -1,0 +1,5 @@
+/target/
+/.idea/
+/.vscode/
+*.swp
+*~
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000000000000000000000000000000000000..302f755cd64ec29443c5a2ee10c960eec3ad0098
--- /dev/null
+++ b/AGENTS.md
@@ -1,0 +1,7 @@
+# Agent instructions
+
+Before you change code, read all of `PLAN.md` and `CONTRIBUTING.md`.
+Obey all rules in `CONTRIBUTING.md`.
+Only work on the current milestone in `PLAN.md`.
+Do not start the next milestone before the current acceptance gate passes.
+For all documentation, use ASD-STE100 Simplified Technical English, Issue 9.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000000000000000000000000000000000000..42c4c74831f67197bbdd810f2c3c5b29900eb636
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -1,0 +1,203 @@
+# Contributor rules
+
+Build the smallest correct version of `tit`. The laziness rule tells you not to
+write code that is not necessary. You must include validation, security
+boundaries, failure handling, and necessary tests.
+
+Portability and security have the same highest priority. Do not decrease
+portability or security to make performance better or a change easier. First,
+make sure that a design has the necessary behavior. Then, select the design with
+minimum code, state, dependencies, and maintenance.
+
+Correct code is more important than speed or an easy method. Do not use an easier
+or faster method to bypass a defect. Identify the incorrect invariant. Correct
+the applicable layer. Do a test of the failure that caused the defect. The correct
+design has only necessary code.
+
+## Scope
+
+- Read `PLAN.md` before you change code.
+- Work only on the current milestone and its specified acceptance gate.
+- Do not implement subsequent features, compatibility layers, extension points,
+  or configuration settings for subsequent milestones.
+- Make a small completed change. Do not make a large framework that is not
+  completed.
+- Preserve the one-binary, no-runtime-Git, no-runtime-OpenSSH design.
+- Keep platform-specific code in isolated modules. Record the requirement for
+  each module. Portable code must operate without Linux APIs, GNU userland,
+  filesystem behavior, path encoding, or a shell.
+
+## Commits
+
+Before each commit, run these commands from the repository root:
+
+```text
+cargo fmt --check
+cargo clippy --all-targets --all-features -- -D warnings
+```
+
+Do not make the commit if a command fails. The initial Cargo setup must make
+these commands available before its commit.
+
+Use this format for each commit message:
+
+```text
+type(module): short description
+```
+
+Use a lowercase type. Approved types are `build`, `chore`, `ci`, `docs`, `feat`,
+`fix`, `init`, `lint`, `perf`, `refactor`, `security`, and `test`. Use the Rust
+module name for `module` when one module owns the change. Otherwise, use a short
+project area such as `docs`, `web`, `git`, `ssh`, `store`, or `release`. Write
+the description in lowercase, use the imperative form, and do not add a final
+period.
+
+## Documentation
+
+Write all project documentation in ASD-STE100 Simplified Technical English,
+Issue 9. This rule includes `AGENTS.md`, `CONTRIBUTING.md`, `PLAN.md`, the
+README, manuals, help text, configuration descriptions, and Rust documentation.
+
+Use the approved STE dictionary and writing rules. Treat established Git, Rust,
+protocol, command, and project terms as technical names or technical verbs only
+when STE lets you use them. Use one term for one meaning. Do not use an automated
+language check as the only proof of conformance. Review each documentation
+change against the standard.
+
+### Project terms
+
+Use the technical nouns in this list:
+
+- account, administrator, application, archive, asset, backup, cache, client,
+  code, command, configuration, database, executable, feed, and file.
+- framework, instance, interface, key, log, module, package, repository,
+  role, route, server, session, token, username, Web UI, and workflow.
+- behavior, boundary, build, compatibility, content, crate, dependency,
+  environment, feature, host, identity, implementation, and invariant.
+- license, metadata, milestone, model, owner, permission, platform, portability,
+  priority, scope, state, validation, version, and visibility.
+- browser, description, design, documentation, enhancement, form, library,
+  reader, rule, sanitizer, subset, summary, user, and writer.
+- agent and contributor.
+- action, admin, blob, checkout, collaboration, default, development, download,
+  encoding, federation, filesystem, frontend, installation, link, organization,
+  product, redirect, registry, requirement, secret, setting, and view.
+- API, CDE, CLI, CSS, DNS, HTML, HTTP, HTTPS, JavaScript, JSON, Markdown, RSS,
+  SSH, TOML, URL, and the names of operating systems.
+- branch, clone, commit, diff, fetch, Git object, issue, merge, pack, pull
+  request, push, ref, tag, and tree.
+- Cargo, Git, OpenSSH, Rust, SQLite, and the exact names of crates, commands,
+  types, files, paths, and configuration fields.
+- acceptance gate, audit event, collaborator role, domain event, feed token,
+  invite code, login nonce, recovery credential, and security boundary.
+- architectural decision record, built-in SSH server, canonical URL,
+  client-side router, progressive enhancement, recoverable runtime failure,
+  server-rendered HTML, server-side validation, symbolic logo, and tit bird.
+- first-class feed, operating-system library, platform-specific code, read-only
+  repository, self-hosted CDE, shared library, and plain-text Markdown.
+- blocking job, cgit-like interface, laziness rule, portable code, semantic
+  HTML, and statically linked code.
+- dependency audit, external test driver, fixture repository, loopback address,
+  quality gate, Rust toolchain, standard error, and standard output.
+- advertised hostname, hostname, onion hostname, and proxy command.
+
+Use the technical verbs in this list:
+
+- archive, authenticate, authorize, browse, cache, clone, commit, configure,
+  fetch, lint, and merge.
+- migrate, push, refactor, render, restore, review, serve, sign, subscribe, and
+  validate.
+- build, conform, create, discuss, implement, own, pass, preserve, rewrite,
+  spawn, and store.
+- blame, download, publish, search, and watch.
+
+Regular plural forms of the technical nouns are permitted. Use the technical
+verbs only in the forms that STE permits. A compound technical noun must use
+approved project terms and obey the STE multi-word-noun rules.
+
+Names are proper nouns. Examples are ASD-STE100, BSD, cgit, GitHub, Linux,
+macOS, MIT License, OAuth, OpenBSD, OpenSSH, and Tor. Keep exact quoted text for
+code identifiers, commands, paths, configuration fields, and protocol values.
+
+Do not use a different term for an item or operation in these lists. Add a term
+to this section before you use a new project-specific term in documentation.
+
+## Rust
+
+- Write clear, idiomatic stable Rust. Use readable control flow and standard
+  data types. Do not use macros, type tricks, or combinators that make the code
+  difficult to read.
+- Keep one Cargo package until a concrete build or ownership boundary justifies
+  another crate.
+- Add a type, trait, generic parameter, or module only for an applicable
+  invariant or boundary. Do not abstract a single use only because a second use
+  can appear subsequently.
+- Keep public APIs small. Make items private by default. Give each caller only
+  the necessary capability.
+- Borrow data when this keeps the code clear. You can use a low-cost clone when
+  it makes ownership clear. Do not make code difficult to read to remove small
+  allocations.
+- Use iterators when they improve clarity and loops when they do not.
+- Keep domain rules below HTTP, SSH, and CLI handlers. Handlers translate input
+  and output. They do not contain persistence, authorization, or Git policy.
+- Do not hold locks, database transactions, or borrowed guards across `.await`.
+- Use typed errors inside modules. Add context at process and transport
+  boundaries. Do not panic on user input or recoverable runtime failures.
+  You can use `unwrap` and `expect` in tests and for proven startup
+  invariants with explanatory messages.
+- Do not write unsafe Rust or custom cryptographic primitives without an
+  explicit plan amendment and review.
+
+## Dependencies
+
+- Use the standard library and selected dependencies when they are sufficient.
+- Add a crate only when it removes more code and risk than it adds. Record the
+  requirement for the crate. Disable unused features and commit `Cargo.lock`
+  changes.
+- Use established parsing and cryptographic implementations, but keep them
+  behind narrow local interfaces where the plan specifies a replaceable
+  boundary.
+
+## Web UI
+
+- Each user-facing workflow must work with JavaScript disabled and in browsers
+  that do not implement JavaScript. Server-rendered HTML forms, links, and HTTP
+  semantics are the full interface.
+- JavaScript can only be an optional progressive enhancement. It must not own
+  canonical state, give the only validation path, or gate an operation.
+- Use the minimum semantic HTML and embedded CSS for an interface that is clear
+  and easy to use. Do not add a frontend framework, asset pipeline, client-side
+  router, duplicated templates, or decorative markup without a current
+  requirement.
+- The server must validate all input. Return useful pages after correct and
+  incorrect form submissions.
+
+## Tests
+
+- Do tests of public contracts, domain invariants, regressions, and risky
+  failure paths. Do not add tests for trivial getters or implementation details.
+- Use a real process and a stock Git, SSH, or `ssh-keygen` client for an
+  interoperability test. Mocks do not prove a wire protocol.
+- Add malformed-input, limit, and restart tests for unauthenticated parsers,
+  persistence, ref updates, authentication, and cross-store operations.
+- Keep fixtures small and intentional. Each snapshot must be readable enough
+  to review as an assertion.
+- Before you give code to another person, run `cargo fmt --check` and
+  `cargo clippy --all-targets --all-features -- -D warnings`. Run locked tests
+  and the applicable release build. A zero-test success is not validation.
+
+## Maintenance
+
+- Do not mix unrelated cleanup with the requested change.
+- Do not bypass an issue with special cases, weak validation, duplicate state,
+  ignored errors, or retries that hide repeatable failures. When the code can
+  enforce the correct behavior, do not use a manual operation instead.
+- Delete dead code instead of commenting it out. Do not leave speculative TODOs,
+  placeholder implementations, or flags that do nothing.
+- Comments explain constraints and non-obvious reasons, not what the syntax
+  already says.
+- Update `PLAN.md` when a product or architectural decision changes. Do not
+  rewrite it to support an easy but incorrect method.
+- When two designs are correct, select the design with minimum state and
+  dependencies. Also select the design with the minimum number of parts and a
+  recovery process that is clear.
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000000000000000000000000000000000000..9747c722d0194da1f1025d554074c5baf6246dd1
--- /dev/null
+++ b/Cargo.lock
@@ -1,0 +1,620 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "clap"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b"
+dependencies = [
+ "anstyle",
+ "clap_lex",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tit-cde"
+version = "0.1.0"
+dependencies = [
+ "clap",
+ "serde",
+ "tempfile",
+ "thiserror",
+ "toml",
+ "url",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000000000000000000000000000000000000..09769cebfafbb4c2851bfb940069895610ab1631
--- /dev/null
+++ b/Cargo.toml
@@ -1,0 +1,29 @@
+[package]
+name = "tit-cde"
+version = "0.1.0"
+edition = "2024"
+rust-version = "1.96"
+license = "MIT"
+description = "A small self-hosted collaborative development environment"
+
+[[bin]]
+name = "tit"
+path = "src/main.rs"
+
+[dependencies]
+clap = { version = "4.6", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
+serde = { version = "1.0", features = ["derive"] }
+thiserror = "2.0"
+toml = { version = "1.1", default-features = false, features = ["parse", "serde", "std"] }
+url = { version = "2.5", features = ["serde"] }
+
+[dev-dependencies]
+tempfile = "3.27"
+
+[lints.rust]
+unsafe_code = "forbid"
+
+[profile.release]
+codegen-units = 1
+lto = "thin"
+strip = true
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..fe02c78abb6a3f9fbb73ceed2defd32bb18465b6
--- /dev/null
+++ b/LICENSE
@@ -1,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 tit contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000000000000000000000000000000000000..053be9896cc8f15547a5e14064729750482ebc64
--- /dev/null
+++ b/PLAN.md
@@ -1,0 +1,828 @@
+# tit
+
+`tit` (tiny git) is a small, self-hosted collaborative development environment
+(CDE) for Git. It is written in Rust. Its logo is a small symbolic tit bird.
+Its interface is equivalent to a small cgit interface. It also supplies a full
+collaboration workflow: browse, clone, discuss, review, merge, publish, and subscribe.
+
+The MIT License applies to `tit`.
+The Cargo package is `tit-cde`, short for “the tit collaborative development
+environment”, and it installs the `tit` executable.
+
+Portability and security have the same highest priority. First, make sure that a
+design has these properties and the necessary behavior. Then, select the design
+with minimum code, state, dependencies, and operational surface.
+
+All project documentation must conform to ASD-STE100 Simplified Technical
+English, Issue 9. Git, Rust, protocol, command, and project terms are technical
+terms where the standard lets writers use them.
+
+The deployed application must be one executable with no necessary runtime
+services, project-specific shared libraries, Git executable, or independently installed static
+assets. `tit` can use Rust crate dependencies at build time. It can also use
+statically linked native code and operating-system libraries. Repositories and
+application data stay as standard files. An operator can make a backup of these files
+while the application does not run.
+
+System installations keep the full instance in `/srv/tit` by default.
+This includes `config.toml`, the metadata database, bare repositories, SSH host
+keys, instance secrets, and recoverable operational state.
+
+## Product boundaries
+
+The initial users are persons and small groups. They want a CDE that is clear
+and that they can operate. The primary features are an SSH-native
+identity, a small cgit-like web interface, first-class feeds, and interoperable
+Git workflows.
+
+The first release will not include organizations, CI or actions, package
+registries, project boards, chat, OAuth, or federation. These features will
+increase the permission model and operational surface before the core CDE
+passes its tests.
+
+Accounts own repositories directly. New repositories use `main` as their
+initial branch and are public by default. An owner can make a repository
+private.
+
+## Interfaces
+
+### Web
+
+The full web interface operates without JavaScript. It also operates in
+browsers that do not implement JavaScript. Each operation uses server-rendered
+semantic HTML, normal links, and HTML forms. The server validates all input.
+
+Subsequent JavaScript is an optional progressive enhancement. It must not own
+canonical state, give the only validation path, or gate a workflow. Use the
+minimum HTML and embedded CSS for an interface that is clear and easy to use.
+Version 1 has no frontend framework, client-side router, or asset pipeline.
+
+Repository views must include:
+
+- repository summary and rendered README.
+- trees, blobs, raw files, commits, diffs, blame, branches, and tags.
+- read-only repository search.
+- archive downloads.
+- stable clone, raw-file, issue, pull-request, and feed URLs.
+- correct `GET` and `HEAD` behavior, redirects, cache validators, and content
+  types.
+
+The database stores user content as the plain-text Markdown that the user
+supplied. The documentation specifies a small Markdown subset. An HTML sanitizer
+must remove active content from the rendered HTML.
+
+### SSH
+
+The built-in SSH server gives Git transport without an external `git` process.
+It will subsequently supply a small CDE command interface. This interface
+uses the same SSH identity:
+
+```text
+ssh -p 2222 tit.example repo create NAME
+ssh -p 2222 tit.example issue list OWNER/REPO
+ssh -p 2222 tit.example issue create OWNER/REPO
+ssh -p 2222 tit.example pr checkout OWNER/REPO NUMBER
+```
+
+Command output must be stable for scripts. A specified machine-readable format
+supplies this output. Scripts must not use terminal text as data.
+
+Repository clone endpoints accept these two formats:
+
+- `https://host/owner/repo{,.git}`.
+- `ssh://host[:port]/owner/repo{,.git}`.
+
+The server ignores the SSH username for
+identity and authorization. The public key identifies the account. Thus,
+canonical URLs do not include a username. The built-in SSH service listens on
+port 2222 by default. Thus,
+
+`ssh://host:2222/owner/repo` is the normal clone URL. A configured port 22 does
+not occur in the URL.
+
+`ssh.public_host` sets the hostname in advertised SSH clone URLs. By default,
+it uses the host from `public_url`. It accepts a DNS hostname, an IP address, or
+an `.onion` hostname. `tit` validates and normalizes the hostname without
+resolving it. A Tor client supplies the necessary SSH `ProxyCommand`
+configuration.
+
+### Git transport
+
+Bare Git repositories are the canonical source-code storage. The server
+publishes pull-request heads as ordinary refs, such as `refs/pull/42/head`.
+Standard Git tools can fetch and review these refs when the web interface is
+not available.
+
+Git object handling is hash-agile from the first release and supports SHA-1 and
+SHA-256 repositories without hard-coded object-ID lengths. New repositories
+initially default to SHA-1 for compatibility, while repository creation and
+instance configuration can select SHA-256. Version 1 serves public repositories
+through HTTPS and all authorized repositories through SSH. Private Git clone and fetch
+use SSH. Version 1 does not support Git push through HTTP.
+
+## Accounts and authentication
+
+Account creation asks for a username and an SSH public key. Users can register
+multiple keys, label them, inspect their fingerprints and last use, and revoke
+them. The server normalizes the case of each username and reserves it
+permanently. Thus, it cannot assign links and attribution for that username to
+a new account.
+
+Signup must have a random, single-use invitation from `tit invite-code`.
+The server stores only the code hash, applies an expiry, and consumes the code in
+the same transaction that creates the account.
+
+This offline command creates the first administrator:
+
+```text
+tit setup admin <username> "<ssh-public-key>"
+```
+
+The command is valid only for an uninitialized instance, creates exactly one
+administrator, and prints its recovery code once.
+
+Web login uses the standard SSH signature envelope rather than a custom
+cryptographic format. The login page presents a challenge and an exact command
+with `ssh-keygen -Y sign` and the dedicated `tit-auth` namespace. The
+challenge contains these minimum items:
+
+- protocol version and purpose.
+- canonical CDE origin.
+- normalized username and selected key fingerprint.
+- cryptographically random nonce.
+- issue and expiry times.
+
+The server stores only a hash of each short-lived nonce and atomically consumes
+it after successful verification. An attacker can replay a stateless challenge
+before its expiry. Thus, the server keeps this small state. Successful login
+issues a short-lived `Secure`, `HttpOnly`, `SameSite` session cookie. The server
+changes this cookie regularly. State-changing requests also require CSRF
+protection.
+
+The loss of the sole SSH key must not require a manual database change. Initial
+recovery uses a one-time offline recovery code. The server generates this code
+during signup. The audit log records recovery, key addition, key removal, and
+session revocation. A subsequent version can support SSH certificates. The
+first release does not use them.
+
+Authorization is independent from authentication. Repository visibility,
+collaborator roles, protected branches, force pushes, and pull-request merges
+have explicit rules. The server enforces these rules on the HTTP and SSH paths.
+
+## Collaboration model
+
+Issues support a title, Markdown body, open or closed state, author, assignees,
+labels, comments, and an append-only timeline. Keep labels repository-local. Do
+not add milestones or project boards initially.
+
+Pull requests add base and head refs, review comments, approvals or requested
+changes, mergeability, and merge state. Review comments must anchor to a
+commit and file position so subsequent pushes cannot silently change what a comment
+referred to. The initial merge strategies are merge commit and fast-forward.
+Add squash and rebase subsequently only if their attribution and audit semantics
+are clear.
+
+Metadata mutations append their domain event in the same database transaction.
+Git mutations use the durable intent protocol because refs and `native_db`
+cannot share a transaction. Feeds, audit history, notifications, and subsequent
+webhooks derive from completed events rather than each feature inventing a
+second history.
+
+## Feeds and notifications
+
+Atom is the canonical feed model, and the same events are also rendered as RSS.
+Stable feeds must include:
+
+- repository activity.
+- branch or tag updates.
+- issues and issue comments.
+- pull requests, reviews, and merges.
+- user activity.
+- personalized activity such as assignments and mentions.
+
+Watching a repository is granular: pushes, issues, pull requests, or everything.
+Public feeds require no authentication. Private and personalized
+feeds use random, scoped, revocable bearer URLs and must never expose unrelated
+private events. The UI warns that feed URLs are credentials.
+
+Version 1 does not include an in-application notification inbox. Add an inbox
+only if feeds do not give sufficient information.
+Webhooks can subsequently consume the same event stream, with signed deliveries,
+bounded retries, and per-repository scopes.
+
+## Storage
+
+Application metadata belongs in one `native_db` database. Source code belongs
+in bare Git repositories. Do not store issues and reviews as Git refs or
+commits. Their transactions, indexes, permissions, and stable IDs belong to the
+CDE.
+
+Application-level IDs must not expose reusable storage keys. Public objects
+receive stable, non-reassignable identifiers. Relations such as issue comments,
+repository collaborators, and pull-request reviews use explicit IDs and
+secondary indexes. Because the database does not enforce foreign keys or joins,
+the storage layer must validate references and do related mutations in one
+write transaction. Orphan and index-consistency checks belong in `tit doctor`.
+
+Metadata search starts with bounded scans or explicit, derived term-index
+records. If that stops being adequate, add an embedded full-text index that can
+be rebuilt entirely from canonical `native_db` records. Source search remains an
+isolated bounded traversal of Git objects. Treat a subsequent index as
+derivable state.
+
+### Rust persistence layer
+
+Use **`native_db` with `redb`** for the first implementation. Each CDE record
+is a versioned Rust type with specified primary and secondary indexes. Rust
+conversions define the model upgrades. Database transactions, models, and
+generated key types must stay behind repository-specific application
+interfaces rather than leaking into HTTP, SSH, feed, or Git protocol handlers.
+
+This choice accepts a new API and ecosystem to get a pure-Rust, SQL-free
+persistence model. Pin the exact crate versions for each `tit` release. Treat
+the database file format as private. Do not support copies of a database file
+between arbitrary `tit` versions. Before the data model expands, the first
+feasibility spike must prove these properties:
+
+- crash recovery.
+- concurrent reads and writes.
+- snapshots.
+- model upgrades across historical schemas.
+- index consistency.
+- restoration into the next application version.
+
+Alternatives considered:
+
+- **Diesel** supplies compile-time query checks and SQLite support,
+  but its type-heavy query DSL and synchronous SQLite path add friction to a
+  small async server. Reconsider it if compile-time SQL correctness becomes
+  more valuable than concise persistence code.
+- **rusqlite** is a small and mature SQLite driver. It is not an ORM. If `tit`
+  uses it, SQL is necessary for almost all database operations.
+- **SQLx** has query checks and good async integration. Developers must write
+  SQL directly. Thus, it does not satisfy the primary database
+  requirement.
+- **redb directly** supplies a stable pure-Rust transactional key-value store.
+  In this design, `tit` must implement model serialization, secondary indexes,
+  and schema changes. `native_db` already supplies these functions.
+- **Turso's in-process Rust database** aims for SQLite compatibility without C,
+  but it is currently beta. At this time, it documents important compatibility gaps.
+  Revisit it once its production and file-format guarantees match SQLite's.
+
+Model upgrades run automatically on startup only after taking an application
+lock and making a recoverable pre-upgrade snapshot. Destructive or long
+upgrades must instead be explicit `tit admin migrate` operations with a
+dry-run or status view. Each stable release keeps the historical model
+definitions and conversions that it needs. These items must support an upgrade
+from the oldest supported release. An unsupported gap stops the upgrade and
+gives a clear diagnostic.
+
+## Packaging and operation
+
+The executable embeds templates, CSS, model definitions, and other static
+resources.
+It gives these minimum command groups:
+
+```text
+tit serve
+tit setup
+tit invite-code
+tit admin
+tit backup
+tit restore
+tit doctor
+```
+
+Administrative commands that must mutate a running instance, such as
+`tit invite-code`, use an owner-only Unix control socket beneath `/srv/tit`.
+They never open the live `native_db` database from a second process.
+An offline command must first acquire the exclusive instance lock. If
+`tit serve` owns that lock, the command uses the control socket. If this is not
+possible, the command stops and gives a clear instruction.
+
+`serve` runs HTTP and SSH listeners from one process, with each listener
+independently configurable. HTTP binds to `127.0.0.1:3000` by default and uses a
+necessary canonical `public_url`. A reverse proxy usually terminates TLS.
+The system configuration path is `/srv/tit/config.toml`, alongside all other
+instance data. Command-line overrides and environment variables for secrets are
+supported, and precedence must be deterministic.
+
+`backup` briefly establishes a consistent boundary. It creates a `native_db` hot
+snapshot. It copies or bundles all repositories and necessary configuration
+into an archive with a specified format. `restore` verifies archive format, database
+compatibility, and repository integrity before replacing live state. `doctor`
+does checks of database records and indexes, model versions, repository object
+integrity, filesystem permissions, key configuration, and listener readiness.
+
+The application produces structured logs without external infrastructure. It
+keeps a durable audit trail for security operations. Access logs and metrics are
+operational data. An operator can redirect or disable them.
+
+## Remaining decisions
+
+- exact recovery-code and key-rotation flows.
+- Git library after the protocol spike.
+- the exact release architectures and the meaning of a standalone executable on
+  Linux, macOS, FreeBSD, OpenBSD, and NetBSD.
+
+## Implementation plan
+
+Work proceeds through milestone gates. Do not start a milestone before the
+preceding gate passes. If a feasibility gate fails, change the design before
+you continue the feature work.
+
+**Current milestone:** Milestone 1A — `native_db` durability.
+Update this marker only after the current milestone gate passes.
+
+### Architecture
+
+Start as one Cargo package, not a workspace of small crates. Module boundaries
+isolate the code without making each internal change a cross-crate API:
+
+```text
+src/
+  main.rs             process startup and top-level error reporting
+  cli.rs              command-line contract
+  config.rs           configuration loading and validation
+  domain/             IDs, entities, invariants, and domain operations
+  store/              native_db models, indexes, upgrades, and repositories
+  git/                object access, refs, wire protocol, and quarantine
+  auth/               SSH keys, SSHSIG challenges, sessions, and recovery
+  http/               routes, handlers, templates, and response policy
+  ssh/                SSH server, authentication, and command dispatch
+  control.rs          local administrative control socket
+  feeds/              event projection into Atom and RSS
+  ops/                backup, restore, doctor, and maintenance
+  telemetry.rs        structured logs and request correlation
+templates/            compile-time embedded HTML templates
+assets/               compile-time embedded CSS and optional small images
+tests/                 black-box and protocol integration tests
+```
+
+HTTP handlers, SSH handlers, and CLI commands call application services. They
+must not open `native_db`, manipulate repository paths, or update refs directly.
+The `store` and `git` modules expose narrow interfaces so neither their concrete
+crates nor generated types become part of the rest of the application.
+
+`native_db` is synchronous. Thus, storage operations run as bounded blocking
+jobs. A database transaction cannot stay active across an `.await`.
+Begin with `tokio::task::spawn_blocking` behind a semaphore. Replace it with a
+dedicated storage executor only if measurements show scheduling overhead or
+write starvation.
+
+#### Cross-store mutations
+
+Git refs and `native_db` cannot participate in one atomic transaction. Each
+operation that changes Git refs and `native_db` uses a durable intent record:
+
+1. Validate authorization and write an operation intent containing repository,
+   initial refs, proposed refs, actor, and event payload.
+2. Receive objects into quarantine and validate size, object format,
+   connectivity, and proposed ref policy.
+3. Promote validated objects into the repository while they stay unreachable.
+4. Atomically update all affected Git refs.
+5. In one database transaction, mark the intent completed and append its domain
+   and audit events.
+6. During startup, compare incomplete intents with the actual refs. Finalize a
+   full update. Abandon an update that did not start. During maintenance,
+   prune unreachable promoted objects. Stop startup if the ref state is mixed.
+
+Tests must inject process termination after each boundary. Feeds and webhooks
+consume only completed events. Thus, recovery cannot announce a push that did
+not make refs reachable.
+
+### Technical choices to prove, not assume
+
+Tentative dependencies are Tokio, Axum, `native_db`, `ssh-key`, Russh, Askama,
+`pulldown-cmark`, an HTML sanitizer, and selected `gix-*` crates. Pin the exact
+version in `Cargo.lock` when you add a dependency.
+
+Two capabilities are already plausible but require interoperability tests at
+this time. `ssh-key` can parse and verify OpenSSH SSHSIG envelopes. Russh gives
+an asynchronous SSH server. Git serving is the blocking uncertainty. Current
+`gix-protocol` functionality is client-oriented. libgit2 does not give a full
+server-side replacement for `git-upload-pack` and `git-receive-pack`.
+
+Consequently, milestone 1 must implement a small standards-based server that
+uses reusable Git primitives. Initially, the server can advertise only the
+capabilities that it fully supports. Current Git clients must clone, fetch, and
+push without special configuration. If the spike cannot do this safely in its
+time limit, stop the work. Select a larger Git implementation or remove the
+no-Git-executable constraint.
+
+Do not silently introduce a subprocess. Do not declare that a partial protocol
+is ready for production.
+
+### Core data model
+
+All persistent records carry a model version, creation time, and stable typed
+ID. Use random, non-reassignable IDs internally and per-repository monotonically
+increasing issue and pull-request numbers for human-facing URLs. Normalize
+usernames and repository slugs once at the domain boundary. Store the canonical
+form and display form where necessary.
+
+Initial record families are:
+
+- identity: `Account`, `SshKey`, `RecoveryCredential`, `InviteCode`,
+  `LoginNonce`, `Session`.
+- repositories: `Repository`, `Collaborator`, `BranchRule`, `RepoCounter`.
+- issues: `Issue`, `IssueComment`, `Label`, `IssueLabel`, `Watch`.
+- pull requests: `PullRequest`, `Review`, `ReviewComment`.
+- delivery and history: `DomainEvent`, `FeedToken`, `AuditEvent`,
+  `GitOperationIntent`.
+
+Usernames are lowercase ASCII and match
+`[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?`. Repository slugs are lowercase ASCII,
+one to 100 characters, can additionally contain internal `.`, `_`, and `-`, and
+must not end in `.git`. Reject case variants rather than silently aliasing them.
+Reserve route and operational names such as `admin`, `api`, `assets`, `feeds`,
+`issues`, and `setup`.
+
+Name each access pattern before you add its index. The store needs these unique
+indexes:
+
+- a normalized username.
+- an SSH fingerprint.
+- an owner and repository slug.
+- a repository and issue number.
+- a repository and pull-request number.
+
+Secondary indexes cover child records, open items, events, active sessions, and
+incomplete operation intents. Invite-code hashes are unique and indexed. The
+store does not keep the clear text.
+
+The store layer enforces relationships because `native_db` has no foreign keys.
+Initially, do not delete an account or repository. Suspend an account. Archive
+a repository. Do not reassign comments, events, usernames, or public numbers.
+
+`tit doctor` scans each relation and rebuildable index. It reports the exact
+record IDs in each inconsistency.
+
+Model upgrades live beside all historical models necessary for the supported
+upgrade window. Each release fixture includes a database created by the prior
+stable version, and CI restores and upgrades it before running integrity checks.
+
+### Milestones
+
+#### Milestone 0 — repository and engineering baseline
+
+Goal: establish a small codebase with repeatable quality gates before protocol
+experiments create permanent structure.
+
+- **M0.1 Bootstrap Cargo.** Create the `tit-cde` binary package with the MIT
+  License, producing the `tit` executable. Pin the Rust toolchain, commit
+  `Cargo.lock`, add `.gitignore` and `LICENSE`, and a minimum README that points
+  to `PLAN.md` and `CONTRIBUTING.md`.
+- **M0.2 Establish the CLI.** Implement `tit --version` and useful `--help`, then
+  define stable exit-code, standard-output, and standard-error conventions.
+  Add each operational command only in the milestone that implements it. Do not
+  ship placeholder subcommands. Reserve the bootstrap syntax in this document:
+  `tit setup admin <username> "<ssh-public-key>"` and invitation syntax
+  `tit invite-code` without adding inert handlers.
+- **M0.3 Establish configuration.** Define a versioned TOML configuration with
+  CLI overrides. System installations use `/srv/tit/config.toml` and keep each
+  other instance file beneath `/srv/tit`. Per-user installations keep the same
+  self-contained layout beneath the platform's XDG data directory. Validate the
+  necessary canonical `public_url`, instance directory, listener addresses,
+  signup policy, limits, and proxy trust explicitly. Reject unknown fields.
+  Bind HTTP to `127.0.0.1:3000` and SSH to port 2222 by default. Generate clone
+  URLs from the effective externally advertised addresses rather than assuming
+  they match the bind addresses.
+- **M0.4 Establish CI.** Require locked builds, `cargo fmt --check`,
+  `cargo clippy --all-targets --all-features -- -D warnings`, and unit and
+  integration tests. Require checks for dependency licenses and advisories.
+  Require release builds on each available operating-system runner. Keep each
+  operating-system exclusion explicit and temporary.
+- **M0.5 Establish test infrastructure.** Add temporary data-directory helpers,
+  real-process tests, fixture repositories, free-port allocation, and structured
+  log capture. Use the stock `git`, `ssh`, and `ssh-keygen` clients as external
+  test drivers. These clients are absent at runtime.
+- **M0.6 Establish contribution checks.** Require `cargo fmt --check` and
+  `cargo clippy --all-targets --all-features -- -D warnings` before each
+  commit. Require commit messages to use `type(module): short description` as
+  specified in `CONTRIBUTING.md`. Review all project documentation for
+  conformance with ASD-STE100 Issue 9. Record the approved project-specific
+  technical terms that the documentation uses.
+
+Gate: a clean checkout passes each check with one command in the documentation. The
+release binary starts when no Git executable is on its `PATH`. All documentation
+passes the ASD-STE100 review.
+
+#### Milestone 1 — feasibility and architectural gate
+
+Goal: remove the risks that can make the single-binary design invalid.
+
+##### M1A — native_db durability
+
+- Define two small versioned fixture models with unique and non-unique indexes.
+- Prove insert, update, delete, indexed range scan, concurrent reads, serialized
+  writes, rollback, hot snapshot, restore, and compaction behavior.
+- Generate fixtures for earlier database versions with committed fixture programs. Then,
+  upgrade through a minimum of two schema versions with conversion checks.
+- Kill a child process during representative writes and upgrades. Open the
+  database again. Make sure that it has the initial or new full state.
+- Verify exclusive-open, file-locking, `fsync`, atomic rename, snapshot, and
+  recovery behavior separately on each supported filesystem and operating
+  system. Do not infer BSD behavior from Linux or macOS results.
+- Use a workload of 10,000 issues and 1,000,000 events. Measure database growth,
+  snapshot time, and request latency.
+
+Gate: restore and each supported upgrade fixture pass `doctor`. No handler
+needs direct access to a `native_db` type.
+
+##### M1B — SSH identity
+
+- Parse and normalize supported OpenSSH public keys and fingerprints.
+- Support Ed25519 first. Add ECDSA P-256 and RSA-SHA2 only after interoperability
+  tests. Reject DSA, RSA-SHA1, malformed keys, and undersized RSA keys.
+- Produce a versioned canonical challenge, verify a real
+  `ssh-keygen -Y sign -n tit-auth` signature, enforce origin and namespace, and
+  atomically consume the nonce.
+- Start a Russh server with a generated host key. Authenticate a stock OpenSSH
+  client. Restrict sessions to recognized Git or `tit` commands. Reject
+  shells, PTYs, forwarding, agents, and arbitrary exec. Reject environment
+  requests except for a strictly parsed `GIT_PROTOCOL` value necessary to negotiate
+  supported Git protocol versions.
+
+Gate: positive interoperability tests and negative tests for replay, expiry,
+wrong origin, wrong namespace, wrong key, malformed envelopes, and unsupported
+algorithms all pass.
+
+##### M1C — read-side Git protocol
+
+- Open, create, and inspect bare SHA-1 and SHA-256 repositories without invoking
+  Git. Parameterize object IDs, empty-tree IDs, ref parsing, packet fields, and
+  display formatting by repository object format.
+- Implement packet-line parsing with strict length and allocation limits.
+- Implement the minimum upload-pack negotiation and pack generation necessary
+  for ordinary clone and fetch. First, establish SHA-1 interoperability with
+  protocol v0/v1. Then, implement SHA-256 protocol negotiation with independent
+  fixtures before this milestone passes.
+- Expose the same service through SSH exec channels and smart HTTP, with identical
+  repository resolution and authorization decisions.
+- Use stock Git clients to do tests of empty repositories, branches, tags, deltas, and
+  repeated fetches. Also do tests of limited large blobs, non-ASCII filenames, and
+  damaged repositories.
+
+Gate: HTTP and SSH clones reproduce each SHA-1 and SHA-256 fixture ref and
+object. Fetches do the same. Malformed and oversized requests do not cause
+excessive memory growth. Process instrumentation proves that the `tit` server
+does not invoke Git. The black-box test driver can invoke Git.
+
+##### M1D — write-side Git protocol
+
+- Implement receive-pack command parsing for SHA-1 and SHA-256 repositories and
+  advertise only supported capabilities.
+- Stream incoming objects into a per-push quarantine directory with byte,
+  object-count, depth, and wall-clock limits.
+- Resolve deltas. Validate object hashes, types, reachability, connectivity, ref
+  names, initial object IDs, fast-forward policy, and permissions. Do these
+  checks before clients can see the refs.
+- Promote validated objects, update multiple refs atomically, produce accurate
+  per-ref status, and clean each abandoned quarantine.
+- Exercise durable operation intents and kill/restart reconciliation at each
+  cross-store boundary.
+
+Gate: stock Git can create, update, delete, and reject refs correctly through SSH
+for the two object formats. After a failed push, clients cannot see new objects or
+partial refs.
+The server rejects non-fast-forward and unauthorized updates. Fuzzed packet and
+pack inputs do not cause a panic.
+
+##### Milestone 1 decision record
+
+Write an architecture decision record. Include the protocol versions,
+capabilities, selected crates, custom protocol code, binary size, resource
+measurements, and known omissions. Continue the project only if all four gates
+pass. Otherwise, change the product constraints.
+
+#### Milestone 2 — read-only CDE
+
+Goal: ship a useful cgit-like browser before multi-user accounts and
+collaboration expand the security model.
+
+- **M2.1 Instance bootstrap.** Implement
+  `tit setup admin <username> "<ssh-public-key>"` for an uninitialized instance.
+  Validate and normalize the username and key. Create one administrator. Store
+  a hashed recovery credential. Show the recovery code one time. Do not run this
+  command after bootstrap.
+- **M2.2 Repository administration.** Implement local admin commands to create,
+  import, rename, archive, and inspect repositories. Canonicalize each path and
+  keep filesystem names derived from immutable repository IDs, not user input.
+  Accounts own repositories. New repositories use `main` as the initial branch
+  and are public by default. Version 1 has no organizations.
+- **M2.3 Repository reads.** Implement bounded services for refs, commits,
+  history, trees, blobs, raw content, diffs, blame, README selection, and
+  archive streaming. Expensive traversals receive limits and cancellation.
+- **M2.4 HTTP shell.** Add semantic templates, embedded CSS, consistent
+  navigation, and easy-to-use forms. Add security headers, request IDs, useful
+  404/405 responses, and correct `GET`/`HEAD` behavior. Run the full black-box Web
+  UI suite without JavaScript. Each acceptance test must operate without a
+  script engine.
+- **M2.5 Public routes.** Add repository summary, tree, blob, raw, commit, diff,
+  blame, refs, archive, and clone-discovery routes with stable canonical URLs.
+- **M2.6 Markdown.** Render the subset specified in the documentation. Sanitize
+  links and HTML. Prevent repository content from injecting active markup into
+  CDE pages.
+- **M2.7 Public feeds.** Create Atom and RSS entries for repository creation,
+  imports, refs, tags, and pushes. Give stable IDs, timestamps, pagination, and
+  conditional requests. Validate each format with an independent feed parser.
+- **M2.8 Source search.** Implement bounded search over the selected ref without
+  a permanent index first. Enforce file-count, byte, result, and time limits.
+  Add a derivable index only when measured repository sizes require it.
+
+Gate: an operator can import a public repository. A user can browse, download,
+and search it. A user can clone it through HTTP and SSH. Atom and RSS show its
+events. Route and rendering snapshots cover empty, binary, large, malformed,
+and non-UTF-8 repository content.
+
+#### Milestone 3 — accounts, sessions, and authenticated push
+
+Goal: establish one identity and authorization model shared by the web and SSH
+interfaces.
+
+- **M3.1 Account lifecycle.** Implement `/srv/tit/control.sock` with owner-only
+  permissions, refusing symlink and non-socket replacements. `tit invite-code`
+  submits an owner-authorized request to the running process through that
+  socket. The server creates a random, single-use, expiring signup code, prints
+  it once, and stores only its hash. Signup must have a valid code, username, and
+  first SSH key. Successful signup atomically consumes the invitation, stores a
+  hashed recovery credential, and presents the recovery code once. Implement
+  use of recovery credentials, key addition and revocation, account suspension,
+  and username reservation. Open signup is outside version 1.
+- **M3.2 Web login.** Implement challenge display and upload/paste of the SSHSIG
+  envelope. Consume each nonce one time. Store opaque session hashes on the
+  server. Change sessions after privilege changes. Add CSRF tokens and a
+  function that ends all sessions.
+- **M3.3 Repository authorization.** Add public/private visibility and the
+  `owner`, `maintainer`, `writer`, and `reader` roles. One policy service must
+  answer HTTP and SSH decisions.
+- **M3.4 Authenticated Git.** Bind SSH public-key authentication to accounts.
+  Enforce read/write/ref policy before Git services start and before ref
+  updates. HTTP remains read-only in the first stable release. A separate
+  credential design can change this rule after project approval.
+- **M3.5 Audit history.** Record login, recovery, key, collaborator, repository,
+  visibility, and ref-policy mutations. Record the actor, target, outcome, time,
+  and request correlation ID. Do not store challenge responses or secrets.
+- **M3.6 SSH repository commands.** Implement `repo create` through the same
+  application service and authorization policy as the Web UI. Give stable
+  human-readable output plus an explicit machine-readable mode.
+
+Gate: the browser and SSH map the same key to the same account. Private
+repositories never appear in anonymous pages, feeds, archives, or Git discovery.
+The full account recovery and revocation flows pass black-box tests.
+
+#### Milestone 4 — issues, events, and subscriptions
+
+Goal: add the smallest full issue workflow and make events the durable
+source for subscriptions.
+
+- **M4.1 Event service.** Assign a monotonically increasing repository event
+  sequence and append each event in the same transaction as its metadata
+  mutation. Define stable event types and versioned payloads.
+- **M4.2 Issues.** Implement create, edit, comment, close, reopen, label, assign,
+  and timeline operations. Each mutation validates repository visibility and
+  role. It preserves the Markdown that the user supplied and emits one coherent event.
+- **M4.3 Watches.** Store granular watch preferences for pushes, issues, and
+  pull requests. Do not build an inbox or background mailer.
+- **M4.4 Feeds.** Add public issue feeds plus scoped private and personalized
+  Atom and RSS feeds for watched activity, assignments, and mentions. Store only
+  token hashes, show each token once, permit rotation and revocation, and keep
+  secrets out of logs and referrers.
+- **M4.5 Search.** Begin with bounded metadata scans behind a search interface.
+  Add a derivable embedded index only after representative benchmarks exceed a
+  specified latency or memory threshold.
+- **M4.6 SSH issue commands.** Implement `issue list` and `issue create` through
+  the same issue service used by HTTP. Include the machine-readable output
+  contract and identical authorization tests.
+
+Gate: the issue state and its event are in one transaction. Public tokens cannot
+retrieve private events. A token cannot retrieve events outside its scope. Feed
+order stays stable after edits and restarts.
+
+#### Milestone 5 — pull requests and review
+
+Goal: complete the collaboration loop without turning the CDE into a project
+management suite.
+
+- **M5.1 Pull-request refs.** Create immutable numbered PR records and maintain
+  `refs/pull/<number>/head`. Record base and head object IDs for each revision
+  so reviews keep the context of their initial revision.
+- **M5.2 Comparison.** Compute merge bases, commit ranges, diffs, changed paths,
+  and mergeability with specified work and output limits. Cache only values
+  that Git and PR records can regenerate.
+- **M5.3 Review.** Add general comments, line comments anchored to commit/path/
+  side/line, approvals, requested changes, outdated-comment display, and a
+  chronological timeline.
+- **M5.4 Merge.** Implement fast-forward first. Add server-created merge commits
+  only after a worktree-free merge spike. The spike must handle renames, modes,
+  conflicts, attribution, deterministic parents, and concurrent base changes.
+  It must use the same intent recovery as a push.
+- **M5.5 Branch rules.** Enforce protected refs, fast-forward-only policy,
+  force-push prohibition, deletion prohibition, and merge permissions in the
+  common ref-policy service.
+- **M5.6 SSH pull-request commands.** Implement `pr checkout` as a stable
+  command. Return the standard Git fetch and checkout instructions for the
+  pull-request ref. Add a machine-readable mode.
+
+Gate: a contributor can push a branch, open a PR, revise it, receive anchored
+review, and merge it. Concurrent updates do not cause different refs and
+metadata. Injected process termination does not cause this difference.
+
+#### Milestone 6 — operations and stable release
+
+Goal: make failure recovery as deliberate as normal operation.
+
+- **M6.1 Process lifecycle.** Add graceful shutdown, bounded drain, and listener
+  readiness. Lock the data directory without a PID. Do not start a second
+  writer.
+- **M6.2 Backup and restore.** First, give an offline backup procedure. Then
+  add online backup. Take the global write and maintenance gate. Make a
+  `native_db` snapshot. Pause ref mutations and repacking while you copy the
+  repositories. Copy the configuration, keys, and secrets into a checksummed
+  manifest.
+  Create archives with owner-only permissions and state plainly that they
+  contain credentials. Restore always targets an empty instance directory
+  before an explicit activation step.
+- **M6.3 Doctor.** Do checks of configuration, permissions, model versions,
+  record relations, indexes, and incomplete intents. Do checks of Git refs, reachable objects,
+  quarantine debris, host keys, and backup manifests. Repair is a different,
+  explicit command and never the default behavior. Add typed read-only inspect
+  commands. Add a deterministic JSON Lines dump. These tools let operators
+  examine `native_db` records without its private file format.
+- **M6.4 Limits and abuse resistance.** Enforce request sizes, timeouts,
+  concurrency, and login and SSH attempt rates. Enforce pack, diff, archive, and
+  Markdown limits. Use safe defaults for slow clients.
+- **M6.5 Observability.** Emit structured logs, request and operation IDs,
+  bounded metrics, and audit records. Sensitive values, authorization headers,
+  cookies, feed tokens, recovery codes, and raw signatures are always redacted.
+- **M6.6 Release packaging.** Produce checksummed Linux and macOS binaries first,
+  followed by FreeBSD, OpenBSD, and NetBSD as their platform gates pass. Include
+  applicable native service examples and a minimum Caddy example. Include shell
+  completions, a man page, an upgrade guide, and a disaster-recovery exercise.
+  Verify each artifact on a clean host without Git or development libraries.
+
+Gate: a new operator can use only the supplied documentation to install,
+configure, make a backup, restore, upgrade, and remove `tit`. The operator can
+also cause damage to a disposable copy and restore it. All security and recovery
+tests use the release artifact, not a debug build.
+
+### Test strategy
+
+Unit tests cover canonicalization, policies, model conversions, challenge
+encoding, event projection, packet framing, and ref rules. Property tests cover
+path and ref parsing, packet round-trips, state-machine transitions, and model
+conversion invariants. Fuzz targets cover these unauthenticated parsers:
+
+- HTTP input that the framework processes.
+- SSH commands.
+- SSHSIG envelopes.
+- Git packet lines.
+- pack indexes and streams.
+- refs.
+- Markdown.
+
+Integration tests start the actual binary with temporary storage and drive it
+through HTTP, OpenSSH, `ssh-keygen`, and stock Git. They assert results that a user can see,
+filesystem state, database records through public diagnostics, logs, exit
+status, and restart behavior. No integration test substitutes a handler call
+for the process boundary for which it is a test.
+
+Security tests use an access matrix for each resource type and transport. The
+matrix includes each role, suspended accounts, revoked keys, expired sessions,
+and scoped feed tokens. Add each new route and SSH command to this matrix.
+
+Performance tests prevent regressions. Use fixed fixtures to measure idle
+memory, binary size, startup time, and page latency. Also measure clone
+throughput, push memory, feed latency, backup time, and model-upgrade time.
+
+### Definition of done for each task
+
+A task is done only when the documentation describes its public behavior. The
+applicable domain layer enforces its invariants. Errors have types and stable
+transport results. Limits are explicit. Applicable unit tests and black-box
+tests pass.
+`cargo fmt --check`,
+`cargo clippy --all-targets --all-features -- -D warnings`, locked tests,
+dependency checks, and the release build must pass.
+
+Changes to persistence, authentication, authorization, Git refs, backup, or
+recovery also require a failure-path test and an entry in the threat model or
+operations documentation. Review generated snapshots as assertions. Do not
+accept them mechanically.
+
+### Initial execution order
+
+The first implementation cycle must complete only these tasks:
+
+1. M0.1 through M0.6.
+2. M1A. It gives the first `native_db` decision record and fixtures.
+3. M1B. It gives real OpenSSH and SSHSIG interoperability tests.
+4. M1C before any HTML repository browser is built.
+5. M1D before account, issue, or pull-request work begins.
+6. The milestone 1 decision record and an explicit go/no-go review.
+
+This order starts with the highest risks. Web and issue work has less technical
+risk than replacement of `git-upload-pack` and `git-receive-pack`. Do a test of
+the write protocol first. This shows a design that cannot operate before the
+CDE shell is completed.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..9403fc1c97bc2ed81bf60f992102497bd9a39f04
--- /dev/null
+++ b/README.md
@@ -1,0 +1,55 @@
+# tit
+
+`tit` is a small, self-hosted collaborative development environment (CDE) for
+Git. The project is in Milestone 1A and does not serve repositories at this time.
+
+Read [PLAN.md](PLAN.md) for the product design and implementation gates. Read
+[CONTRIBUTING.md](CONTRIBUTING.md) before you change code.
+
+## Build
+
+Install the Rust toolchain that `rust-toolchain.toml` specifies. Then, run:
+
+```text
+cargo build --locked
+```
+
+## Quality gate
+
+Install `cargo-deny` version 0.20.2. Then, run this command from the repository
+root:
+
+```text
+./scripts/check
+```
+
+This command formats, lints, tests, audits, and builds the release executable.
+
+## Configuration validation
+
+Copy `config.example.toml` into an empty instance directory. Change
+`public_url` to the canonical HTTPS URL of the instance. Then, run:
+
+```text
+tit --config /absolute/path/to/tit/config.toml
+```
+
+Successful validation writes no output and returns exit code 0. A configuration
+error writes a diagnostic to standard error and returns exit code 1. A CLI
+syntax error returns exit code 2. Help and version output use standard output.
+
+The directory that contains `config.toml` is the instance directory. HTTP clone
+URLs use `public_url`. SSH clone URLs use `ssh.public_host` and
+`ssh.public_port`. By default, `ssh.public_host` uses the host from `public_url`.
+Listener addresses do not change public clone URLs.
+
+`ssh.public_host` accepts a DNS hostname, an IP address, or an `.onion`
+hostname. `tit` validates and normalizes this value without resolving it. A Tor
+client supplies the necessary SSH `ProxyCommand` configuration.
+
+Use `--user` instead of `--config` to read the configuration from
+`$XDG_DATA_HOME/tit/config.toml`. If `XDG_DATA_HOME` is not set, `tit` uses
+`$HOME/.local/share/tit/config.toml`.
+
+The executable does not run Git or OpenSSH. Tests can use stock clients as
+external test drivers.
diff --git a/config.example.toml b/config.example.toml
new file mode 100644
index 0000000000000000000000000000000000000000..bbb06a4f6afd0476959d0aa2119e5ef1d721a517
--- /dev/null
+++ b/config.example.toml
@@ -1,0 +1,20 @@
+version = 1
+public_url = "https://tit.example/"
+
+[http]
+listen = "127.0.0.1:3000"
+
+[ssh]
+listen = "0.0.0.0:2222"
+public_host = "tit.example"
+public_port = 2222
+
+[signup]
+policy = "invite"
+
+[limits]
+max_request_bytes = 1048576
+max_connections = 1024
+
+[proxy]
+trusted_addresses = []
diff --git a/deny.toml b/deny.toml
new file mode 100644
index 0000000000000000000000000000000000000000..ad1443a5358b146c2ae9070439666ac06faecdbd
--- /dev/null
+++ b/deny.toml
@@ -1,0 +1,21 @@
+[advisories]
+version = 2
+yanked = "deny"
+
+[licenses]
+version = 2
+confidence-threshold = 0.93
+allow = [
+    "Apache-2.0",
+    "MIT",
+    "Unicode-3.0",
+]
+
+[bans]
+multiple-versions = "warn"
+wildcards = "deny"
+
+[sources]
+unknown-registry = "deny"
+unknown-git = "deny"
+allow-registry = ["https://github.com/rust-lang/crates.io-index"]
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 0000000000000000000000000000000000000000..80e6a12026ae1d03cdc5018509980746bfbe9420
--- /dev/null
+++ b/rust-toolchain.toml
@@ -1,0 +1,4 @@
+[toolchain]
+channel = "1.96.0"
+components = ["clippy", "rustfmt"]
+profile = "minimal"
diff --git a/scripts/check b/scripts/check
new file mode 100755
index 0000000000000000000000000000000000000000..54a57395cbc15b19ac3a0ea05ede7038c83f7de7
--- /dev/null
+++ b/scripts/check
@@ -1,0 +1,8 @@
+#!/bin/sh
+set -eu
+
+cargo fmt --check
+cargo clippy --all-targets --all-features -- -D warnings
+cargo test --locked --all-targets --all-features
+cargo deny check advisories licenses sources
+cargo build --locked --release
diff --git a/src/cli.rs b/src/cli.rs
new file mode 100644
index 0000000000000000000000000000000000000000..cd699f4d1c08c0fd864bcb24daee66dcf4664a2a
--- /dev/null
+++ b/src/cli.rs
@@ -1,0 +1,42 @@
+use std::net::SocketAddr;
+use std::path::PathBuf;
+
+use clap::Parser;
+use url::Url;
+
+#[derive(Debug, Parser)]
+#[command(
+    name = "tit",
+    version,
+    about = "A small self-hosted collaborative development environment",
+    arg_required_else_help = true
+)]
+pub(crate) struct Cli {
+    /// Read configuration from FILE
+    #[arg(long, value_name = "FILE", conflicts_with = "user")]
+    pub(crate) config: Option<PathBuf>,
+
+    /// Read configuration from the XDG data directory
+    #[arg(long)]
+    pub(crate) user: bool,
+
+    /// Override the canonical public URL
+    #[arg(long, value_name = "URL")]
+    pub(crate) public_url: Option<Url>,
+
+    /// Override the HTTP listener address
+    #[arg(long, value_name = "ADDRESS")]
+    pub(crate) http_listen: Option<SocketAddr>,
+
+    /// Override the SSH listener address
+    #[arg(long, value_name = "ADDRESS")]
+    pub(crate) ssh_listen: Option<SocketAddr>,
+
+    /// Override the advertised SSH hostname
+    #[arg(long, value_name = "HOST")]
+    pub(crate) ssh_public_host: Option<String>,
+
+    /// Override the public SSH port
+    #[arg(long, value_name = "PORT")]
+    pub(crate) ssh_public_port: Option<u16>,
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000000000000000000000000000000000000..855737c2837666fe958618eaf35df689f6a387ec
--- /dev/null
+++ b/src/config.rs
@@ -1,0 +1,596 @@
+use std::collections::HashSet;
+use std::env;
+use std::fs;
+use std::net::{IpAddr, SocketAddr};
+use std::path::{Component, Path, PathBuf};
+
+use serde::Deserialize;
+use thiserror::Error;
+use url::{Host, Url};
+
+use crate::cli::Cli;
+
+const CONFIG_VERSION: u32 = 1;
+const SYSTEM_CONFIG_PATH: &str = "/srv/tit/config.toml";
+
+#[derive(Debug)]
+pub(crate) struct Config {
+    pub(crate) public_url: Url,
+    pub(crate) instance_dir: PathBuf,
+    pub(crate) http_listen: SocketAddr,
+    pub(crate) ssh_listen: SocketAddr,
+    pub(crate) ssh_public_host: Host<String>,
+    pub(crate) ssh_public_port: u16,
+    pub(crate) signup_policy: SignupPolicy,
+    pub(crate) max_request_bytes: u64,
+    pub(crate) max_connections: u32,
+    pub(crate) trusted_addresses: Vec<IpAddr>,
+}
+
+impl Config {
+    fn validate(&self, path: &Path) -> Result<(), ConfigError> {
+        validate_clean_absolute_path(&self.instance_dir, false)?;
+        if path.parent() != Some(self.instance_dir.as_path()) {
+            return Err(ConfigError::ConfigOutsideInstance);
+        }
+        validate_public_url(&self.public_url)?;
+        if self.http_listen.port() == 0 {
+            return Err(ConfigError::ZeroListenerPort("http.listen"));
+        }
+        if self.ssh_listen.port() == 0 {
+            return Err(ConfigError::ZeroListenerPort("ssh.listen"));
+        }
+        let advertised_address = match &self.ssh_public_host {
+            Host::Ipv4(address) => Some(IpAddr::V4(*address)),
+            Host::Ipv6(address) => Some(IpAddr::V6(*address)),
+            Host::Domain(_) => None,
+        };
+        if let Some(address) = advertised_address
+            && (address.is_unspecified() || address.is_multicast())
+        {
+            return Err(ConfigError::InvalidSshPublicAddress(address));
+        }
+        if self.ssh_public_port == 0 {
+            return Err(ConfigError::ZeroSshPort);
+        }
+        if self.max_request_bytes == 0 {
+            return Err(ConfigError::ZeroLimit("max_request_bytes"));
+        }
+        if self.max_connections == 0 {
+            return Err(ConfigError::ZeroLimit("max_connections"));
+        }
+        if self.signup_policy != SignupPolicy::Invite {
+            return Err(ConfigError::UnsupportedSignupPolicy);
+        }
+
+        let mut addresses = HashSet::new();
+        for address in &self.trusted_addresses {
+            if address.is_unspecified() || address.is_multicast() {
+                return Err(ConfigError::InvalidTrustedAddress(*address));
+            }
+            if !addresses.insert(address) {
+                return Err(ConfigError::DuplicateTrustedAddress(*address));
+            }
+        }
+        self.clone_urls("owner", "repository")?;
+        Ok(())
+    }
+
+    pub(crate) fn clone_urls(
+        &self,
+        owner: &str,
+        repository: &str,
+    ) -> Result<(Url, Url), ConfigError> {
+        let mut http = self.public_url.clone();
+        http.path_segments_mut()
+            .map_err(|()| ConfigError::CloneUrlBase)?
+            .extend([owner, repository]);
+
+        let ssh_origin = if self.ssh_public_port == 22 {
+            format!("ssh://{}/", self.ssh_public_host)
+        } else {
+            format!("ssh://{}:{}/", self.ssh_public_host, self.ssh_public_port)
+        };
+        let mut ssh = Url::parse(&ssh_origin).map_err(ConfigError::CloneUrl)?;
+        ssh.path_segments_mut()
+            .map_err(|()| ConfigError::CloneUrlBase)?
+            .extend([owner, repository]);
+        Ok((http, ssh))
+    }
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)]
+#[serde(rename_all = "kebab-case")]
+pub(crate) enum SignupPolicy {
+    Invite,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ConfigFile {
+    version: u32,
+    public_url: Url,
+    #[serde(default)]
+    http: HttpConfig,
+    #[serde(default)]
+    ssh: SshConfig,
+    #[serde(default)]
+    signup: SignupConfig,
+    #[serde(default)]
+    limits: LimitsConfig,
+    #[serde(default)]
+    proxy: ProxyConfig,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+struct HttpConfig {
+    listen: SocketAddr,
+}
+
+impl Default for HttpConfig {
+    fn default() -> Self {
+        Self {
+            listen: "127.0.0.1:3000"
+                .parse()
+                .expect("the default HTTP address is valid"),
+        }
+    }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+struct SshConfig {
+    listen: SocketAddr,
+    public_host: Option<String>,
+    public_port: u16,
+}
+
+impl Default for SshConfig {
+    fn default() -> Self {
+        Self {
+            listen: "0.0.0.0:2222"
+                .parse()
+                .expect("the default SSH address is valid"),
+            public_host: None,
+            public_port: 2222,
+        }
+    }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+struct SignupConfig {
+    policy: SignupPolicy,
+}
+
+impl Default for SignupConfig {
+    fn default() -> Self {
+        Self {
+            policy: SignupPolicy::Invite,
+        }
+    }
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+struct LimitsConfig {
+    max_request_bytes: u64,
+    max_connections: u32,
+}
+
+impl Default for LimitsConfig {
+    fn default() -> Self {
+        Self {
+            max_request_bytes: 1_048_576,
+            max_connections: 1_024,
+        }
+    }
+}
+
+#[derive(Debug, Default, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+struct ProxyConfig {
+    trusted_addresses: Vec<IpAddr>,
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum ConfigError {
+    #[error("cannot determine the user data directory")]
+    UserDataDirectory,
+    #[error("configuration path must be absolute: {0}")]
+    RelativeConfigPath(PathBuf),
+    #[error("configuration path must not contain '.' or '..': {0}")]
+    UncleanConfigPath(PathBuf),
+    #[error("cannot read configuration {path}: {source}")]
+    Read {
+        path: PathBuf,
+        source: std::io::Error,
+    },
+    #[error("cannot parse configuration {path}: {source}")]
+    Parse {
+        path: PathBuf,
+        source: toml::de::Error,
+    },
+    #[error("configuration version {0} is not supported")]
+    UnsupportedVersion(u32),
+    #[error("instance directory must be absolute: {0}")]
+    RelativeInstanceDirectory(PathBuf),
+    #[error("instance directory must not contain '.' or '..': {0}")]
+    UncleanInstanceDirectory(PathBuf),
+    #[error("configuration file must be directly inside the instance directory")]
+    ConfigOutsideInstance,
+    #[error("public URL must not contain credentials, a query, or a fragment")]
+    PublicUrlComponents,
+    #[error("public URL path must be '/'")]
+    PublicUrlPath,
+    #[error("public URL must contain a hostname")]
+    PublicUrlHost,
+    #[error("public URL must use HTTPS unless its host is loopback")]
+    InsecurePublicUrl,
+    #[error("public SSH port must not be zero")]
+    ZeroSshPort,
+    #[error("public SSH hostname is not valid: {value}")]
+    InvalidSshPublicHost {
+        value: String,
+        source: url::ParseError,
+    },
+    #[error("public SSH hostname must not be an unspecified or multicast address: {0}")]
+    InvalidSshPublicAddress(IpAddr),
+    #[error("listener port {0} must not be zero")]
+    ZeroListenerPort(&'static str),
+    #[error("configuration limit {0} must not be zero")]
+    ZeroLimit(&'static str),
+    #[error("signup policy is not supported")]
+    UnsupportedSignupPolicy,
+    #[error("trusted proxy address is not a unicast address: {0}")]
+    InvalidTrustedAddress(IpAddr),
+    #[error("trusted proxy address occurs more than once: {0}")]
+    DuplicateTrustedAddress(IpAddr),
+    #[error("advertised endpoint cannot be a base URL")]
+    CloneUrlBase,
+    #[error("cannot generate an SSH clone URL: {0}")]
+    CloneUrl(url::ParseError),
+}
+
+pub(crate) fn load(cli: &Cli) -> Result<Config, ConfigError> {
+    let path = config_path(cli)?;
+    validate_clean_absolute_path(&path, true)?;
+
+    let contents = fs::read_to_string(&path).map_err(|source| ConfigError::Read {
+        path: path.clone(),
+        source,
+    })?;
+    let file: ConfigFile = toml::from_str(&contents).map_err(|source| ConfigError::Parse {
+        path: path.clone(),
+        source,
+    })?;
+
+    if file.version != CONFIG_VERSION {
+        return Err(ConfigError::UnsupportedVersion(file.version));
+    }
+
+    let instance_dir = path
+        .parent()
+        .expect("an absolute file has a parent")
+        .to_owned();
+    let public_url = cli.public_url.clone().unwrap_or(file.public_url);
+    let http_listen = cli.http_listen.unwrap_or(file.http.listen);
+    let ssh_listen = cli.ssh_listen.unwrap_or(file.ssh.listen);
+    let ssh_public_host = cli
+        .ssh_public_host
+        .as_deref()
+        .or(file.ssh.public_host.as_deref())
+        .or_else(|| public_url.host_str())
+        .ok_or(ConfigError::PublicUrlHost)?;
+    let ssh_public_host =
+        Host::parse(ssh_public_host).map_err(|source| ConfigError::InvalidSshPublicHost {
+            value: ssh_public_host.to_owned(),
+            source,
+        })?;
+    let ssh_public_port = cli.ssh_public_port.unwrap_or(file.ssh.public_port);
+
+    let config = Config {
+        public_url,
+        instance_dir,
+        http_listen,
+        ssh_listen,
+        ssh_public_host,
+        ssh_public_port,
+        signup_policy: file.signup.policy,
+        max_request_bytes: file.limits.max_request_bytes,
+        max_connections: file.limits.max_connections,
+        trusted_addresses: file.proxy.trusted_addresses,
+    };
+    config.validate(&path)?;
+    Ok(config)
+}
+
+fn config_path(cli: &Cli) -> Result<PathBuf, ConfigError> {
+    if let Some(path) = &cli.config {
+        return Ok(path.clone());
+    }
+    if cli.user {
+        return user_config_path();
+    }
+    Ok(PathBuf::from(SYSTEM_CONFIG_PATH))
+}
+
+fn user_config_path() -> Result<PathBuf, ConfigError> {
+    if let Some(path) = env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
+        let path = PathBuf::from(path);
+        if path.is_absolute() {
+            return Ok(path.join("tit/config.toml"));
+        }
+        return Err(ConfigError::UserDataDirectory);
+    }
+
+    let home = env::var_os("HOME")
+        .filter(|value| !value.is_empty())
+        .map(PathBuf::from)
+        .filter(|path| path.is_absolute())
+        .ok_or(ConfigError::UserDataDirectory)?;
+    Ok(home.join(".local/share/tit/config.toml"))
+}
+
+fn validate_clean_absolute_path(path: &Path, config: bool) -> Result<(), ConfigError> {
+    if !path.is_absolute() {
+        return if config {
+            Err(ConfigError::RelativeConfigPath(path.to_owned()))
+        } else {
+            Err(ConfigError::RelativeInstanceDirectory(path.to_owned()))
+        };
+    }
+    if path
+        .components()
+        .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
+    {
+        return if config {
+            Err(ConfigError::UncleanConfigPath(path.to_owned()))
+        } else {
+            Err(ConfigError::UncleanInstanceDirectory(path.to_owned()))
+        };
+    }
+    Ok(())
+}
+
+fn validate_public_url(url: &Url) -> Result<(), ConfigError> {
+    if !url.username().is_empty()
+        || url.password().is_some()
+        || url.query().is_some()
+        || url.fragment().is_some()
+    {
+        return Err(ConfigError::PublicUrlComponents);
+    }
+    if url.path() != "/" {
+        return Err(ConfigError::PublicUrlPath);
+    }
+
+    let loopback = match url.host() {
+        Some(Host::Ipv4(address)) => address.is_loopback(),
+        Some(Host::Ipv6(address)) => address.is_loopback(),
+        Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
+        None => false,
+    };
+    if url.scheme() != "https" && !(url.scheme() == "http" && loopback) {
+        return Err(ConfigError::InsecurePublicUrl);
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use std::fs;
+
+    use clap::Parser;
+    use tempfile::TempDir;
+
+    use super::*;
+
+    fn write_config(contents: &str) -> (TempDir, PathBuf) {
+        let directory = TempDir::new().expect("create a temporary directory");
+        let path = directory.path().join("config.toml");
+        fs::write(&path, contents).expect("write the configuration");
+        (directory, path)
+    }
+
+    fn cli(path: &Path) -> Cli {
+        Cli::try_parse_from(["tit", "--config", path.to_str().expect("a UTF-8 path")])
+            .expect("parse CLI options")
+    }
+
+    #[test]
+    fn loads_defaults_and_explicit_security_policy() {
+        let (_directory, path) = write_config(
+            r#"
+version = 1
+public_url = "https://tit.example/"
+
+[signup]
+policy = "invite"
+
+[proxy]
+trusted_addresses = []
+"#,
+        );
+
+        let config = load(&cli(&path)).expect("load the configuration");
+
+        assert_eq!(config.public_url.as_str(), "https://tit.example/");
+        assert_eq!(config.instance_dir, path.parent().expect("a parent"));
+        assert_eq!(config.http_listen.to_string(), "127.0.0.1:3000");
+        assert_eq!(config.ssh_listen.to_string(), "0.0.0.0:2222");
+        assert_eq!(
+            config.ssh_public_host,
+            Host::Domain("tit.example".to_owned())
+        );
+        assert_eq!(config.ssh_public_port, 2222);
+        assert_eq!(config.signup_policy, SignupPolicy::Invite);
+        assert_eq!(config.max_request_bytes, 1_048_576);
+        assert_eq!(config.max_connections, 1_024);
+        assert!(config.trusted_addresses.is_empty());
+    }
+
+    #[test]
+    fn rejects_unknown_fields() {
+        let (_directory, path) =
+            write_config("version = 1\npublic_url = \"https://tit.example/\"\nunknown = true\n");
+
+        assert!(matches!(load(&cli(&path)), Err(ConfigError::Parse { .. })));
+    }
+
+    #[test]
+    fn rejects_unsupported_versions() {
+        let (_directory, path) =
+            write_config("version = 2\npublic_url = \"https://tit.example/\"\n");
+
+        assert!(matches!(
+            load(&cli(&path)),
+            Err(ConfigError::UnsupportedVersion(2))
+        ));
+    }
+
+    #[test]
+    fn rejects_remote_plain_http() {
+        let (_directory, path) =
+            write_config("version = 1\npublic_url = \"http://tit.example/\"\n");
+
+        assert!(matches!(
+            load(&cli(&path)),
+            Err(ConfigError::InsecurePublicUrl)
+        ));
+    }
+
+    #[test]
+    fn permits_loopback_plain_http() {
+        let (_directory, path) =
+            write_config("version = 1\npublic_url = \"http://127.0.0.1:3000/\"\n");
+
+        load(&cli(&path)).expect("permit a loopback development URL");
+    }
+
+    #[test]
+    fn applies_cli_overrides() {
+        let (_directory, path) =
+            write_config("version = 1\npublic_url = \"https://tit.example/\"\n");
+        let options = Cli::try_parse_from([
+            "tit",
+            "--config",
+            path.to_str().expect("a UTF-8 path"),
+            "--public-url",
+            "https://code.example/",
+            "--http-listen",
+            "127.0.0.1:4000",
+            "--ssh-listen",
+            "127.0.0.1:4001",
+            "--ssh-public-host",
+            "ssh.example",
+            "--ssh-public-port",
+            "4002",
+        ])
+        .expect("parse CLI overrides");
+
+        let config = load(&options).expect("load the overridden configuration");
+
+        assert_eq!(config.public_url.as_str(), "https://code.example/");
+        assert_eq!(config.http_listen.to_string(), "127.0.0.1:4000");
+        assert_eq!(config.ssh_listen.to_string(), "127.0.0.1:4001");
+        assert_eq!(
+            config.ssh_public_host,
+            Host::Domain("ssh.example".to_owned())
+        );
+        assert_eq!(config.ssh_public_port, 4002);
+        let (http, ssh) = config
+            .clone_urls("alice", "project")
+            .expect("generate clone URLs");
+        assert_eq!(http.as_str(), "https://code.example/alice/project");
+        assert_eq!(ssh.as_str(), "ssh://ssh.example:4002/alice/project");
+    }
+
+    #[test]
+    fn accepts_an_onion_ssh_hostname() {
+        let hostname = format!("{}.onion", "a".repeat(56));
+        let (_directory, path) = write_config(&format!(
+            "version = 1\npublic_url = \"https://tit.example/\"\n[ssh]\npublic_host = \"{hostname}\"\n"
+        ));
+
+        let config = load(&cli(&path)).expect("load the onion SSH hostname");
+
+        assert_eq!(config.ssh_public_host, Host::Domain(hostname.to_owned()));
+    }
+
+    #[test]
+    fn rejects_an_invalid_ssh_hostname() {
+        let (_directory, path) = write_config(
+            "version = 1\npublic_url = \"https://tit.example/\"\n[ssh]\npublic_host = \"bad host\"\n",
+        );
+
+        assert!(matches!(
+            load(&cli(&path)),
+            Err(ConfigError::InvalidSshPublicHost { .. })
+        ));
+    }
+
+    #[test]
+    fn rejects_an_unspecified_ssh_address() {
+        let (_directory, path) = write_config(
+            "version = 1\npublic_url = \"https://tit.example/\"\n[ssh]\npublic_host = \"0.0.0.0\"\n",
+        );
+
+        assert!(matches!(
+            load(&cli(&path)),
+            Err(ConfigError::InvalidSshPublicAddress(address)) if address.is_unspecified()
+        ));
+    }
+
+    #[test]
+    fn omits_the_standard_ssh_port_from_clone_urls() {
+        let (_directory, path) = write_config(
+            "version = 1\npublic_url = \"https://tit.example/\"\n[ssh]\npublic_port = 22\n",
+        );
+        let config = load(&cli(&path)).expect("load the configuration");
+
+        let (_http, ssh) = config
+            .clone_urls("alice", "project")
+            .expect("generate clone URLs");
+
+        assert_eq!(ssh.as_str(), "ssh://tit.example/alice/project");
+    }
+
+    #[test]
+    fn rejects_zero_limits() {
+        let (_directory, path) = write_config(
+            r#"
+version = 1
+public_url = "https://tit.example/"
+
+[limits]
+max_request_bytes = 0
+max_connections = 1
+"#,
+        );
+
+        assert!(matches!(
+            load(&cli(&path)),
+            Err(ConfigError::ZeroLimit("max_request_bytes"))
+        ));
+    }
+
+    #[test]
+    fn rejects_an_unspecified_trusted_proxy() {
+        let (_directory, path) = write_config(
+            r#"
+version = 1
+public_url = "https://tit.example/"
+
+[proxy]
+trusted_addresses = ["0.0.0.0"]
+"#,
+        );
+
+        assert!(matches!(
+            load(&cli(&path)),
+            Err(ConfigError::InvalidTrustedAddress(address)) if address.is_unspecified()
+        ));
+    }
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000000000000000000000000000000000000..2dd220a09ea7e901fd5cede9e0da5f86a9656e64
--- /dev/null
+++ b/src/main.rs
@@ -1,0 +1,27 @@
+mod cli;
+mod config;
+
+use std::process::ExitCode;
+
+use clap::Parser;
+
+use crate::cli::Cli;
+
+fn main() -> ExitCode {
+    let cli = match Cli::try_parse() {
+        Ok(cli) => cli,
+        Err(error) => {
+            let code = error.exit_code();
+            let _ = error.print();
+            return ExitCode::from(u8::try_from(code).unwrap_or(2));
+        }
+    };
+
+    match config::load(&cli) {
+        Ok(_config) => ExitCode::SUCCESS,
+        Err(error) => {
+            eprintln!("tit: {error}");
+            ExitCode::FAILURE
+        }
+    }
+}
diff --git a/tests/cli.rs b/tests/cli.rs
new file mode 100644
index 0000000000000000000000000000000000000000..5de9f1298aa677142ca6aa8cef3a0f84f6d74c9b
--- /dev/null
+++ b/tests/cli.rs
@@ -1,0 +1,108 @@
+mod support;
+
+use std::fs;
+use std::process::Command;
+
+use support::{
+    TestInstance, create_bare_git_fixture, create_ssh_key_fixture, free_address,
+    read_stock_ssh_configuration,
+};
+use tempfile::TempDir;
+
+#[test]
+fn help_and_version_use_standard_output() {
+    for argument in ["--help", "--version"] {
+        let output = Command::new(env!("CARGO_BIN_EXE_tit"))
+            .arg(argument)
+            .output()
+            .expect("run tit");
+        assert!(output.status.success());
+        assert!(!output.stdout.is_empty());
+        assert!(output.stderr.is_empty());
+    }
+}
+
+#[test]
+fn a_cli_error_uses_exit_code_two_and_standard_error() {
+    let output = Command::new(env!("CARGO_BIN_EXE_tit"))
+        .arg("--unknown")
+        .output()
+        .expect("run tit");
+
+    assert_eq!(output.status.code(), Some(2));
+    assert!(output.stdout.is_empty());
+    assert!(!output.stderr.is_empty());
+}
+
+#[test]
+fn validates_configuration_without_output() {
+    let instance = TestInstance::new();
+    let config = instance.config().to_str().expect("a UTF-8 path");
+    let output = instance.run(&["--config", config]);
+
+    assert!(output.status.success());
+    assert!(output.stdout.is_empty());
+    assert!(output.stderr.is_empty());
+}
+
+#[test]
+fn a_configuration_error_uses_exit_code_one_and_standard_error() {
+    let instance = TestInstance::new();
+    let missing = instance.path().join("missing.toml");
+    let output = instance.run(&["--config", missing.to_str().expect("a UTF-8 path")]);
+
+    assert_eq!(output.status.code(), Some(1));
+    assert!(output.stdout.is_empty());
+    assert!(String::from_utf8_lossy(&output.stderr).starts_with("tit: "));
+}
+
+#[test]
+fn starts_without_git_or_openssh_on_the_path() {
+    let instance = TestInstance::new();
+    let config = instance.config().to_str().expect("a UTF-8 path");
+    let output = Command::new(env!("CARGO_BIN_EXE_tit"))
+        .args(["--config", config])
+        .env("PATH", "")
+        .output()
+        .expect("run tit");
+
+    assert!(output.status.success());
+}
+
+#[test]
+fn reads_user_configuration_from_the_xdg_data_directory() {
+    let data = TempDir::new().expect("create a temporary data directory");
+    let instance = data.path().join("tit");
+    fs::create_dir(&instance).expect("create the instance directory");
+    fs::write(
+        instance.join("config.toml"),
+        "version = 1\npublic_url = \"https://tit.example/\"\n",
+    )
+    .expect("write the configuration");
+
+    let output = Command::new(env!("CARGO_BIN_EXE_tit"))
+        .arg("--user")
+        .env("XDG_DATA_HOME", data.path())
+        .output()
+        .expect("run tit");
+
+    assert!(output.status.success());
+    assert!(output.stdout.is_empty());
+    assert!(output.stderr.is_empty());
+}
+
+#[test]
+fn test_support_allocates_a_port_and_external_client_fixtures() {
+    let instance = TestInstance::new();
+    assert_ne!(free_address().port(), 0);
+
+    for object_format in ["sha1", "sha256"] {
+        create_bare_git_fixture(
+            &instance.path().join(format!("{object_format}.git")),
+            object_format,
+        );
+    }
+
+    create_ssh_key_fixture(&instance.path().join("test-key"));
+    read_stock_ssh_configuration();
+}
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..fd00b5045b0dc87b1f3ac2cd9f9d3f49834b5b67
--- /dev/null
+++ b/tests/support/mod.rs
@@ -1,0 +1,83 @@
+use std::fs;
+use std::net::{SocketAddr, TcpListener};
+use std::path::{Path, PathBuf};
+use std::process::{Command, Output};
+
+use tempfile::TempDir;
+
+pub(crate) struct TestInstance {
+    directory: TempDir,
+    config: PathBuf,
+}
+
+impl TestInstance {
+    pub(crate) fn new() -> Self {
+        let directory = TempDir::new().expect("create a temporary instance");
+        let config = directory.path().join("config.toml");
+        fs::write(
+            &config,
+            "version = 1\npublic_url = \"https://tit.example/\"\n",
+        )
+        .expect("write the configuration");
+        Self { directory, config }
+    }
+
+    pub(crate) fn path(&self) -> &Path {
+        self.directory.path()
+    }
+
+    pub(crate) fn config(&self) -> &Path {
+        &self.config
+    }
+
+    pub(crate) fn run(&self, arguments: &[&str]) -> Output {
+        Command::new(env!("CARGO_BIN_EXE_tit"))
+            .args(arguments)
+            .output()
+            .expect("run tit")
+    }
+}
+
+pub(crate) fn free_address() -> SocketAddr {
+    let listener = TcpListener::bind("127.0.0.1:0").expect("bind a temporary port");
+    listener.local_addr().expect("read the temporary address")
+}
+
+pub(crate) fn create_bare_git_fixture(path: &Path, object_format: &str) {
+    let output = Command::new("git")
+        .args(["init", "--bare", "--object-format", object_format])
+        .arg(path)
+        .output()
+        .expect("run the stock Git client");
+    assert!(
+        output.status.success(),
+        "create a bare Git fixture: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+}
+
+pub(crate) fn create_ssh_key_fixture(path: &Path) {
+    let output = Command::new("ssh-keygen")
+        .args(["-q", "-t", "ed25519", "-N", "", "-f"])
+        .arg(path)
+        .output()
+        .expect("run the stock ssh-keygen client");
+    assert!(
+        output.status.success(),
+        "create an SSH key fixture: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+}
+
+pub(crate) fn read_stock_ssh_configuration() {
+    let output = Command::new("ssh")
+        .args(["-G", "-F", "/dev/null", "localhost"])
+        .output()
+        .expect("run the stock SSH client");
+    assert!(
+        output.status.success(),
+        "read the SSH configuration: {}",
+        String::from_utf8_lossy(&output.stderr)
+    );
+    assert!(!output.stdout.is_empty());
+}
