diff --git a/.githooks/post-merge b/.githooks/post-merge index 788fa46..d89ec3b 100755 --- a/.githooks/post-merge +++ b/.githooks/post-merge @@ -13,6 +13,8 @@ set -eu command -v cairn >/dev/null 2>&1 || exit 0 cd "$(git rev-parse --show-toplevel)" +# A branch from before the backlog existed has nothing to settle. +[ -f cairn.toml ] || exit 0 cairn renumber --quiet cairn render --quiet diff --git a/CLAUDE.md b/CLAUDE.md index 5f20d46..41f4ef3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,3 +46,64 @@ period. The body says why. If a change touches boot, image size, listen time, connection handling or the static hot path, measure before and after and put the numbers in the pull request under "Budget impact". + + +## Roadmap and issues + +This project tracks its roadmap and issues with `cairn`. Every item is a Markdown file under `cairn/items`, described by the schema in `cairn.toml`. + +**Do not create ad-hoc TODO, PLAN or NOTES files.** Create a cairn item instead, so the work appears on the board and in the generated roadmap. + +### The loop + +1. `cairn next` — what is ready to start. It excludes anything blocked by unfinished dependencies and puts work already in progress first. +2. `cairn claim ` — take it before you start, so no one duplicates the work. `cairn claim --next` picks and claims the top-ranked unclaimed item in one step. Then read `cairn prompt `: the item with everything it rests on — the outcome it serves, what its dependencies concluded, what done means and what earlier runs learned. +3. Do the work. Record what you learn: `cairn set =` for fields, `cairn note ""` for anything that needs a sentence — why you chose something, what you tried, what to watch for. +4. `cairn tick ` as each acceptance criterion becomes true — `cairn show --criteria` lists them numbered. Tick what is true, not what would let you close. +5. `cairn close ` when it is done, or `cairn release ` to hand it back. +6. `cairn check` before you report finished. It must pass. + +### Commands + +```sh +cairn next --json # ready work, ranked +cairn claim --next # take the next ready item +cairn search --json # titles, bodies and labels +cairn list --json # all open items +cairn list --filter 'blocked=false,priority=p0' +cairn prompt # the item as a prompt, with what it rests on +cairn show --json # one item, including its body +cairn new "" --type <TYPE> --milestone <MILESTONE> +cairn set <ID> status=<STATUS> # also labels+=x, or any field below +cairn note <ID> "<TEXT>" # append reasoning; never replaces +cairn show <ID> --criteria # acceptance criteria, numbered +cairn tick <ID> <N> # tick one; --all for every one +cairn close <ID> +cairn check # validate; run before finishing +cairn render # regenerate ROADMAP.md +``` + +Claims coordinate writers in the same item directory and are seen across the worktrees of this repository: `next` leaves out what another worktree has claimed, `claim` refuses it, and `cairn worktrees` shows what each is doing. Separate clones are not read. Agree on assignments before splitting work across clones. + +Items are numbered: `0012`, and commands accept the bare number too. Write the number in `depends_on` and other id references. Each item also carries a `uid` tag; leave it alone. If a merge gives two items one number, `cairn renumber` moves the one that arrived and retargets the references that came with it. + +### Schema + +- **Types**: `feature`, `bug`, `spike`, `chore`, `docs`, `decision`, `milestone`, `budget` +- **Statuses**: `backlog` (open), `planned` (open), `doing` (active), `blocked` (open), `done` (done), `dropped` (dropped) +- **`priority`**: one of p0, p1, p2, p3 — Relative to the item's milestone. p0: the milestone does not ship without it; p1: expected in it; p2: useful; p3: optional +- **`effort`**: one of s, m, l, xl — Rough size, never an estimate. s: hours; m: a day; l: several days; xl: split it or spike it +- **`area`**: one of kernel, init, io, http, tls, router, pack, config, proxy, image, cli, deploy, bench, ops, test, security, infra, site, docs — Subsystem this touches +- **`due`**: date, YYYY-MM-DD — A committed external date, YYYY-MM-DD. Leave unset when the release is gated by evidence +- **`part_of`**: names any items, by id, several allowed — A larger piece of work this belongs to +- **Milestones**: `v0.1`, `later`, `v0.2`, `v0.3`, `v0.4`, `v1.0` +- **Saved views** (`cairn list --view NAME`): `now`, `next`, `release`, `spikes`, `waiting`, `dependencies`, `triage`, `later`, `decisions`, `history` + +### Rules + +1. Before starting work, find or create the item and set it to an active status. +2. Use the fields above rather than inventing new ones; add new fields to `cairn.toml` first. +3. Never hand-edit the generated roadmap file — change items and run `cairn render`. +4. `cairn check` must pass before the work is considered done. + +<!-- cairn:end --> diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..09e94d1 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,171 @@ +# Roadmap + +An open-source OS image for serving websites. One Rust binary as PID 1 on a stripped Linux kernel, held to published speed budgets. + +**liam 1.0** builds a `liam.toml` and a site directory into a container, microVM, or UEFI image that serves static and proxied traffic over HTTPS within published, measured budgets. + +**For** engineers who deploy websites and small services onto Firecracker hosts, Kubernetes, cloud VMs, or their own servers, and care how fast it boots and serves. + +**Not in 1.0:** + +- A general-purpose Linux. There is no shell, no SSH, and no package installs at runtime. You debug with logs, metrics, and a separate debug build. +- A language runtime manager or build system for your app. liam supervises one app process that you bring as a self-contained executable. +- A control plane. There is no fleet management, autoscaling, or dashboard. `liam deploy` puts an image on a target; scheduling belongs to something else. +- HTTP/3 and WebAssembly handlers. Both come after 1.0; see `later`. + +**Budgets**, each enforced by a CI gate (`cairn list -t budget`): + +| Budget | Promise | +|---|---| +| `boot` | Cold boot to first byte under 25 ms in a Firecracker microVM | +| `listen` | Exec to listening under 5 ms as a container | +| `size` | Base container image under 6 MB | +| `zero-drop` | No failed requests across reload and SIGTERM drain; no refused connections across a liamd crash | +| `no-alloc` | Zero heap allocations per request on the static hot path | + +Releases ship when their gate passes, not on a date. + +<!-- generated by cairn; edit the files in `cairn/items` and run `cairn render` --> + +## v0.1 — Static sites in a microVM + +`··········` 0% · 0 of 24 done + +Serve a static site from a Firecracker microVM over HTTP/1.1, from cold boot to first byte inside the boot budget, measured in CI. For sites behind a load balancer or CDN that already terminates TLS. + +### committed + +- [ ] `0012` Scaffold the Cargo workspace and the task seam <sup>chore · p0 · s · infra</sup> +- [ ] `0013` CI: format, lint and test every pull request <sup>chore · p0 · s · infra</sup> +- [ ] `0014` How fast can a stripped Linux LTS kernel boot in Firecracker? <sup>spike · p0 · m · kernel</sup> +- [ ] `0015` Which io_uring runtime does liamd build on? <sup>spike · p0 · m · io</sup> +- [ ] `0016` Where does the site live inside a microVM? <sup>spike · p0 · s · image</sup> +- [ ] `0017` Thread-per-core io layer with SO_REUSEPORT listeners <sup>feature · p0 · l · io</sup> +- [ ] `0018` HTTP/1.1 request handling with keep-alive <sup>feature · p0 · l · http</sup> +- [ ] `0019` Site pack format and builder <sup>feature · p0 · l · pack</sup> +- [ ] `0020` liam.toml v0: parse and validate at build time <sup>feature · p0 · m · config</sup> +- [ ] `0021` The liam CLI: build, run, check, --help <sup>feature · p0 · s · cli</sup> +- [ ] `0022` Kernel config and reproducible kernel build <sup>feature · p0 · l · kernel</sup> +- [ ] `0023` liam-init: PID 1 in the microVM <sup>feature · p0 · m · init</sup> +- [ ] `0024` Serve static files from the pack <sup>feature · p0 · m · pack</sup> +- [ ] `0025` liam build: assemble a Firecracker image <sup>feature · p0 · m · image</sup> +- [ ] `0026` liam run: boot the image locally in Firecracker <sup>feature · p0 · m · cli</sup> +- [ ] `0027` Ship a prebuilt kernel with each release <sup>feature · p0 · m · infra</sup> +- [ ] `0028` Release workflow and one-command install <sup>chore · p0 · m · infra</sup> +- [ ] `0029` Boot benchmark harness <sup>feature · p0 · m · bench</sup> +- [ ] `0030` Where can boot-time gates run without flaking? <sup>spike · p0 · s · bench</sup> +- [ ] `0031` CI gate: fail a pull request that misses the boot budget <sup>feature · p0 · m · bench</sup> +- [ ] `0032` Allocation gate on the static hot path <sup>feature · p1 · s · bench</sup> +- [ ] `0033` Access logs to the serial console <sup>feature · p1 · m · ops</sup> +- [ ] `0034` README quickstart: from a site directory to a running microVM <sup>docs · p0 · s · docs</sup> + +### in progress + +- [ ] `0035` Project microsite and design system <sup>feature · p2 · m · site</sup> + +## v0.2 — HTTPS and containers + +`··········` 0% · 0 of 15 done + +Serve a static site directly to the internet over HTTPS with HTTP/2, as an OCI container on Docker or Kubernetes, or as the microVM from v0.1. + +### committed + +- [ ] `0036` kTLS with io_uring and sendfile: what works on the LTS kernel? <sup>spike · p0 · m · tls</sup> +- [ ] `0037` TLS 1.3 with rustls, record layer in the kernel <sup>feature · p0 · l · tls</sup> +- [ ] `0038` Certificates from files, with SNI across domains <sup>feature · p0 · m · tls</sup> +- [ ] `0039` HTTP/2 <sup>feature · p0 · l · http</sup> +- [ ] `0040` Host routing, redirects and response headers <sup>feature · p0 · m · router</sup> +- [ ] `0041` Bound every phase of a request <sup>feature · p0 · m · http</sup> +- [ ] `0042` Range and conditional requests <sup>feature · p1 · m · pack</sup> +- [ ] `0043` zstd variants in the pack <sup>feature · p2 · s · pack</sup> +- [ ] `0044` OCI image output <sup>feature · p0 · m · image</sup> +- [ ] `0045` liam-init as a container entrypoint, with drain on SIGTERM <sup>feature · p0 · m · init</sup> +- [ ] `0046` liam run on macOS through Docker or Podman <sup>feature · p1 · s · cli</sup> +- [ ] `0047` CI gates for exec-to-listen and image size <sup>feature · p0 · m · bench</sup> +- [ ] `0048` Fuzz the HTTP/1 parser, HTTP/2 frames, TLS glue and config parser <sup>feature · p0 · m · test</sup> +- [ ] `0049` TLS and HTTP/2 benchmarks against nginx and Caddy <sup>feature · p1 · m · bench</sup> +- [ ] `0050` Serve the project site with liam <sup>feature · p2 · s · site</sup> + +## v0.3 — Apps behind liam + +`··········` 0% · 0 of 12 done + +Put a dynamic app behind liam. It reverse-proxies to upstreams or to one process it supervises, and it reloads, restarts and drains without failing a request. + +### committed + +- [ ] `0051` What zero dropped connections means <sup>decision · p0 · s · docs</sup> +- [ ] `0052` liam-init owns the listening sockets <sup>feature · p0 · m · init</sup> +- [ ] `0053` What does reload mean for an immutable image? <sup>spike · p0 · s · config</sup> +- [ ] `0054` Reload without a restart <sup>feature · p0 · m · config</sup> +- [ ] `0055` Reverse proxy to HTTP/1.1 upstreams <sup>feature · p0 · l · proxy</sup> +- [ ] `0056` WebSocket and upgrade passthrough <sup>feature · p1 · m · proxy</sup> +- [ ] `0057` Supervise one app process <sup>feature · p0 · m · init</sup> +- [ ] `0058` Named upstreams with several addresses <sup>feature · p2 · m · proxy</sup> +- [ ] `0059` Prometheus metrics on an admin port <sup>feature · p1 · m · ops</sup> +- [ ] `0060` Zero-drop gate: load through reload, drain and crash <sup>feature · p0 · m · bench</sup> +- [ ] `0061` Can the io layer run under deterministic simulation? <sup>spike · p1 · m · test</sup> +- [ ] `0062` Secrets and overrides from the environment <sup>feature · p1 · m · config</sup> + +## v0.4 — Anywhere with a kernel + +`··········` 0% · 0 of 14 done + +Run liam anywhere with a kernel: a UEFI image on AWS, GCP and bare metal, on x86_64 and aarch64, updated through A/B slots with automatic rollback, and with ACME certificates. + +### committed + +- [ ] `0063` A/B slots and rollback on UEFI without a bootloader <sup>spike · p0 · m · deploy</sup> +- [ ] `0064` Unified kernel image and disk layout <sup>feature · p0 · l · image</sup> +- [ ] `0065` Cloud drivers: ENA, gVNIC and NVMe <sup>feature · p0 · m · kernel</sup> +- [ ] `0066` aarch64 kernel and binaries <sup>feature · p0 · l · kernel</sup> +- [ ] `0067` What does liam deploy do on AWS and GCP? <sup>spike · p0 · m · deploy</sup> +- [ ] `0068` Hardware and virtio watchdog <sup>feature · p0 · m · ops</sup> +- [ ] `0069` A/B update with health-gated rollback <sup>feature · p0 · l · deploy</sup> +- [ ] `0070` liam deploy --target aws <sup>feature · p0 · l · deploy</sup> +- [ ] `0071` liam deploy --target gcp <sup>feature · p1 · l · deploy</sup> +- [ ] `0072` ACME without waiting at boot <sup>spike · p0 · s · tls</sup> +- [ ] `0073` ACME certificates with background renewal <sup>feature · p0 · l · tls</sup> +- [ ] `0074` Boot measurements on cloud VMs and bare metal <sup>feature · p1 · m · bench</sup> +- [ ] `0075` Bare-metal install from a disk image or iPXE <sup>feature · p2 · m · deploy</sup> +- [ ] `0076` Cloud Hypervisor target <sup>feature · p2 · s · image</sup> + +## v1.0 — The promise + +`··········` 0% · 0 of 11 done + +No new surface: a stable config format and CLI, every budget measured on reference hardware and published, releases a stranger can verify, and docs a stranger can deploy from. + +### committed + +- [ ] `0077` liam.toml format 1: versioned and frozen <sup>feature · p0 · m · config</sup> +- [ ] `0078` Freeze CLI commands, flags and exit codes <sup>chore · p0 · s · cli</sup> +- [ ] `0079` Every budget gated on every pull request, on reference hardware <sup>chore · p0 · m · bench</sup> +- [ ] `0080` Publish measured numbers on the site <sup>feature · p0 · s · site</sup> +- [ ] `0081` Reproducible, signed releases with an SBOM <sup>chore · p0 · m · security</sup> +- [ ] `0082` Security policy and kernel update process <sup>chore · p0 · s · security</sup> +- [ ] `0083` Deterministic simulation tests in CI <sup>chore · p1 · l · test</sup> +- [ ] `0084` 72-hour soak under mixed load <sup>chore · p0 · m · test</sup> +- [ ] `0085` Reference: every liam.toml key and CLI command <sup>docs · p0 · m · docs</sup> +- [ ] `0086` Guide: operating liam without a shell <sup>docs · p0 · s · docs</sup> +- [ ] `0087` Supported platform matrix tested for every release <sup>chore · p0 · m · infra</sup> + +## later — Later + +`··········` 0% · 0 of 9 done + +Good ideas off the path to 1.0. Items wait here instead of being argued about. Promote one by moving it to a milestone and setting it to planned. + +### not committed + +- [ ] `0088` HTTP/3 over QUIC <sup>feature · p1 · xl · http</sup> +- [ ] `0089` WebAssembly request handlers <sup>feature · p1 · xl · proxy</sup> +- [ ] `0090` Response cache for proxied routes <sup>feature · p2 · l · proxy</sup> +- [ ] `0091` OpenTelemetry traces <sup>feature · p2 · m · ops</sup> +- [ ] `0092` Zero-copy send with io_uring SEND_ZC <sup>feature · p2 · m · io</sup> +- [ ] `0093` XDP pre-filter for connection floods <sup>feature · p3 · l · security</sup> +- [ ] `0094` Debug image variant with a shell <sup>feature · p3 · m · image</sup> +- [ ] `0095` More clouds: Hetzner, DigitalOcean, Azure <sup>feature · p3 · m · deploy</sup> +- [ ] `0096` HTTP/2 and gRPC to upstreams <sup>feature · p2 · l · proxy</sup> + diff --git a/cairn.toml b/cairn.toml new file mode 100644 index 0000000..646949f --- /dev/null +++ b/cairn.toml @@ -0,0 +1,360 @@ +# cairn.toml — the schema for liam's roadmap and issues. +# +# cairn config show the resolved schema +# cairn check validate every item against it +# cairn next what is ready to start +# man cairn full reference +# +# Order is behaviour in this file: status order is board and sort order, and enum +# value order is sort order. Do not alphabetise it. + +format = 5 + +[project] +name = "liam" +description = "An open-source OS image for serving websites. One Rust binary as PID 1 on a stripped Linux kernel, held to published speed budgets." +dir = "cairn/items" +default_type = "feature" +default_status = "backlog" +# url = "https://github.com/OWNER/liam/blob/main" # set when the repo is public; enables render.link_items +claim_stale_after = 7 # surface an abandoned claim; never release it automatically +# A new project has no legacy of unticked boxes, so closing is gated on criteria +# from day one. The acceptance criteria are the definition of done. +require_criteria = true + +# ─── Item types ────────────────────────────────────────────────────────────── +# Every work type asks for acceptance criteria. Features also say which budget +# they touch, because in this project a change that is correct but slow is not done. + +[[type]] +name = "feature" +icon = "+" +color = "cyan" +template = """ +## Problem + +## Proposal + +## Budget impact + +Which budget this touches (boot, listen, size, zero-drop, no-alloc), or "none". + +## Acceptance criteria + +- [ ] +""" + +[[type]] +name = "bug" +icon = "!" +color = "red" +template = """ +## What happens + +## What should happen + +## Reproduction + +1. + +## Acceptance criteria + +- [ ] A regression test reproduces the failure and passes with the fix. +""" + +[[type]] +name = "spike" +icon = "?" +color = "magenta" +description = "A timeboxed question. Close it with --result; the result is what dependent items are handed" +template = """ +## Question + +## Why it blocks + +## Timebox + +## Approach + +## Acceptance criteria + +- [ ] The question is answered with evidence: numbers, commands, and links someone else can rerun. +- [ ] The items it unblocks are updated or created to match the answer. +- [ ] Closed with `--result` stating the answer in one paragraph. +""" + +[[type]] +name = "chore" +icon = "~" +color = "gray" +template = """ +## Purpose + +## Approach + +## Acceptance criteria + +- [ ] +""" + +[[type]] +name = "docs" +icon = "*" +color = "blue" +template = """ +## Reader and question + +## Change + +## Acceptance criteria + +- [ ] The reader can complete the described task with the current release. +""" + +[[type]] +name = "decision" +icon = "=" +color = "yellow" +description = "A choice worth remembering after the work is finished" +template = """ +## Context + +## Options and tradeoffs + +## Decision + +## Revisit when + +## Acceptance criteria + +- [ ] The choice, its evidence, and its consequences are recorded. +""" + +# A release. Work ships in exactly one. Milestones are gated by evidence (the +# release gate below), not by dates, so `due` stays unset unless a real external +# date exists. +[[type]] +name = "milestone" +groups = "one" +inverse = "scheduled" +description = "A release somebody could install and use" +template = """ +One paragraph: what a user can do when this ships. + +## Release gate + +- [ ] + +## Explicitly not in this milestone + +- +""" + +# A published speed or reliability promise. Work that exists to meet, measure or +# protect a budget is filed under it with `budget=<key>`; `cairn list -t budget` +# shows each promise with the progress of the work behind it. Many-valued: a +# change to the static path can serve both `no-alloc` and `listen`. +[[type]] +name = "budget" +groups = "many" +inverse = "protected_by" +description = "A number liam promises to stay under, enforced by a CI gate" +template = """ +## Promise + +## How it is measured + +## Gate + +## Measured + +Not yet measured. +""" + +# ─── Statuses ──────────────────────────────────────────────────────────────── +# backlog: not committed. planned: committed to its milestone. + +[[status]] +name = "backlog" +category = "open" +color = "gray" +label = "not committed" + +[[status]] +name = "planned" +category = "open" +color = "blue" +label = "committed" + +[[status]] +name = "doing" +label = "in progress" +category = "active" +color = "yellow" + +[[status]] +name = "blocked" +category = "open" # waiting is not work in progress +color = "red" +label = "waiting" + +[[status]] +name = "done" +category = "done" +color = "green" + +[[status]] +name = "dropped" +category = "dropped" +color = "gray" +board = false + +# ─── Fields ────────────────────────────────────────────────────────────────── + +[[field]] +name = "priority" +kind = "enum" +values = ["p0", "p1", "p2", "p3"] +default = "p2" +column = true +description = "Relative to the item's milestone. p0: the milestone does not ship without it; p1: expected in it; p2: useful; p3: optional" + +[[field]] +name = "effort" +kind = "enum" +values = ["s", "m", "l", "xl"] +column = true +description = "Rough size, never an estimate. s: hours; m: a day; l: several days; xl: split it or spike it" + +# The subsystems, matching the planned crates and directories. +[[field]] +name = "area" +kind = "enum" +values = [ + "kernel", # Linux config, boot path, drivers + "init", # liam-init: PID 1, sockets, supervision + "io", # io_uring runtime, thread-per-core, sockets + "http", # HTTP/1.1, HTTP/2, HTTP/3 + "tls", # rustls, kTLS, certificates, ACME + "router", # host and path matching, redirects, headers + "pack", # site pack: build, precompression, static serving + "config", # liam.toml parsing, validation, reload + "proxy", # upstreams, pooling, WebSockets + "image", # OCI, Firecracker, UKI assembly + "cli", # the liam command + "deploy", # liam deploy, cloud targets, A/B updates + "bench", # benchmark harness and budget gates + "ops", # logs, metrics, watchdog + "test", # fuzzing, simulation, soak + "security", # release signing, SBOM, policy + "infra", # repo, CI, release pipeline + "site", # the project website and design system + "docs", +] +column = true +description = "Subsystem this touches" + +[[field]] +name = "due" +kind = "date" +description = "A committed external date, YYYY-MM-DD. Leave unset when the release is gated by evidence" + +[[field]] +name = "part_of" +kind = "ref" +target = "*" +cardinality = "many" +acyclic = true +rollup = true +inverse = "contains" +description = "A larger piece of work this belongs to" + +# ─── Saved views ───────────────────────────────────────────────────────────── +# `cairn list --view NAME`, `cairn board --view NAME` + +[[view]] +name = "now" +description = "Work someone is doing now" +filter = "category=active" +sort = "priority,id" +columns = ["id", "assignee", "priority", "area", "title"] + +[[view]] +name = "next" +description = "Committed work with nothing unfinished in front of it" +filter = "status=planned,blocked=false" +sort = "milestone,priority,id" +columns = ["id", "milestone", "priority", "effort", "area", "title"] + +[[view]] +name = "release" +description = "What stands between us and each release: open p0 work" +filter = "priority=p0,category=open|active" +sort = "milestone,blocked,id" +columns = ["id", "milestone", "status", "area", "title"] + +[[view]] +name = "spikes" +description = "Open questions; answer these first, other work waits on them" +filter = "type=spike,category=open|active" +sort = "milestone,priority,id" +columns = ["id", "milestone", "priority", "title"] + +[[view]] +name = "waiting" +description = "Needs an external action or a decision; read the item's last note" +filter = "status=blocked" +sort = "priority,id" + +[[view]] +name = "dependencies" +description = "Work held by an unfinished dependency" +filter = "blocked=true,category=open|active" +sort = "milestone,priority,id" +columns = ["id", "milestone", "priority", "blockers", "title"] + +[[view]] +name = "triage" +description = "Filed but not scheduled: accept into a milestone, defer to later, or drop" +filter = "milestone=,category=open" +columns = ["id", "type", "area", "title", "created"] + +[[view]] +name = "later" +description = "Good ideas off the path to 1.0" +filter = "milestone=later,category=open|active" +sort = "priority,id" + +[[view]] +name = "decisions" +description = "Choices worth remembering, newest first" +filter = "type=decision|spike,category=done" +sort = "-updated,id" + +[[view]] +name = "history" +description = "Finished and dropped work, by last edit" +filter = "category=done|dropped" +sort = "-updated,id" + +# ─── Hooks ─────────────────────────────────────────────────────────────────── +# Array form: no shell, portable. Keeps ROADMAP.md in step with the items. + +[hooks] +after-create = ["cairn", "render", "-q"] +after-change = ["cairn", "render", "-q"] +after-remove = ["cairn", "render", "-q"] + +# ─── Rendered roadmap ──────────────────────────────────────────────────────── + +[render] +target = "ROADMAP.md" +title = "Roadmap" +group_by = "milestone" +include = "category!=dropped" +checkbox = true +show_ids = true +progress = true +group_by_status = true +link_items = false # needs project.url +header = "cairn/roadmap-intro.md" diff --git a/cairn/items/.gitattributes b/cairn/items/.gitattributes new file mode 100644 index 0000000..1ba084f --- /dev/null +++ b/cairn/items/.gitattributes @@ -0,0 +1,3 @@ +# Item files are LF regardless of platform, so they do not churn +# when a repository is shared across systems. +*.md text eol=lf diff --git a/cairn/items/.gitignore b/cairn/items/.gitignore new file mode 100644 index 0000000..566e65d --- /dev/null +++ b/cairn/items/.gitignore @@ -0,0 +1,2 @@ +# cairn's transient write lock. +.lock diff --git a/cairn/items/0001-static-sites-in-a-microvm.md b/cairn/items/0001-static-sites-in-a-microvm.md new file mode 100644 index 0000000..98316a7 --- /dev/null +++ b/cairn/items/0001-static-sites-in-a-microvm.md @@ -0,0 +1,25 @@ +--- +id: 1 +uid: b4e943e6-4513-4d35-a2f8-2686e6950fc4 +key: v0.1 +title: Static sites in a microVM +type: milestone +status: doing +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +--- + +Serve a static site from a Firecracker microVM over HTTP/1.1, from cold boot to first byte inside the boot budget, measured in CI. For sites behind a load balancer or CDN that already terminates TLS. + +## Release gate + +- [ ] A stranger installs liam with one command, runs `liam build` and `liam run` on a Linux host with KVM, and their site is served. +- [ ] The boot gate runs on every pull request, and the latest p99 is under 25 ms. +- [ ] All p0 items in this milestone are done. + +## Explicitly not in this milestone + +- TLS, HTTP/2, host-based routing (v0.2). +- Container images and macOS `liam run` (v0.2). +- Anything dynamic: proxying, apps, reload (v0.3). diff --git a/cairn/items/0002-https-and-containers.md b/cairn/items/0002-https-and-containers.md new file mode 100644 index 0000000..4ad93d0 --- /dev/null +++ b/cairn/items/0002-https-and-containers.md @@ -0,0 +1,26 @@ +--- +id: 2 +uid: d5522663-d7ff-40f1-9d5d-4219582a3c76 +key: v0.2 +title: HTTPS and containers +type: milestone +status: planned +depends_on: +- 1 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +--- + +Serve a static site directly to the internet over HTTPS with HTTP/2, as an OCI container on Docker or Kubernetes, or as the microVM from v0.1. + +## Release gate + +- [ ] `docker run` of a built image serves a multi-domain site over TLS 1.3 and HTTP/2, with kTLS active. +- [ ] Parsers are fuzzed nightly, and every limit has a test. +- [ ] The listen and size gates pass on every pull request. + +## Explicitly not in this milestone + +- Proxying, app supervision, config reload (v0.3). +- ACME certificates (v0.4); certificates come from files. diff --git a/cairn/items/0003-apps-behind-liam.md b/cairn/items/0003-apps-behind-liam.md new file mode 100644 index 0000000..213933e --- /dev/null +++ b/cairn/items/0003-apps-behind-liam.md @@ -0,0 +1,26 @@ +--- +id: 3 +uid: 78ce6f12-c2a7-4d5b-b34d-eecae4a7018e +key: v0.3 +title: Apps behind liam +type: milestone +status: planned +depends_on: +- 2 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +--- + +Put a dynamic app behind liam. It reverse-proxies to upstreams or to one process it supervises, and it reloads, restarts and drains without failing a request. + +## Release gate + +- [ ] A Go or Node app built as a self-contained executable runs under liam-init and is proxied, including WebSockets. +- [ ] The zero-drop gate passes: no failed requests across reload and drain, no refused connections across a liamd crash. +- [ ] Metrics are scrapeable from the admin port. + +## Explicitly not in this milestone + +- Cloud VM and bare-metal images (v0.4). +- Proxy response caching (later). diff --git a/cairn/items/0004-anywhere-with-a-kernel.md b/cairn/items/0004-anywhere-with-a-kernel.md new file mode 100644 index 0000000..eaf5b15 --- /dev/null +++ b/cairn/items/0004-anywhere-with-a-kernel.md @@ -0,0 +1,25 @@ +--- +id: 4 +uid: e365e3c3-abcc-4d2a-898b-0d3a897f6c85 +key: v0.4 +title: Anywhere with a kernel +type: milestone +status: planned +depends_on: +- 3 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +--- + +Run liam anywhere with a kernel: a UEFI image on AWS, GCP and bare metal, on x86_64 and aarch64, updated through A/B slots with automatic rollback, and with ACME certificates. + +## Release gate + +- [ ] `liam deploy --target aws` puts a site on an EC2 instance, and a bad update rolls itself back. +- [ ] The same site boots on GCP, in QEMU with OVMF, and on one physical server. +- [ ] aarch64 images boot in Firecracker and on Graviton. + +## Explicitly not in this milestone + +- Other clouds beyond AWS and GCP (later). diff --git a/cairn/items/0005-the-promise.md b/cairn/items/0005-the-promise.md new file mode 100644 index 0000000..77cc524 --- /dev/null +++ b/cairn/items/0005-the-promise.md @@ -0,0 +1,26 @@ +--- +id: 5 +uid: 84d97294-6f02-474c-88bb-01c8720ef154 +key: v1.0 +title: The promise +type: milestone +status: planned +depends_on: +- 4 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +--- + +No new surface: a stable config format and CLI, every budget measured on reference hardware and published, releases a stranger can verify, and docs a stranger can deploy from. + +## Release gate + +- [ ] liam.toml format 1 and the CLI are frozen, and the compatibility policy is published. +- [ ] Every budget is gated on every pull request, and the site shows measured numbers instead of budgets. +- [ ] Releases are reproducible and signed, with an SBOM. +- [ ] A 72-hour soak passes. + +## Explicitly not in this milestone + +- Any new protocol, target or handler type. diff --git a/cairn/items/0006-later.md b/cairn/items/0006-later.md new file mode 100644 index 0000000..6fd224d --- /dev/null +++ b/cairn/items/0006-later.md @@ -0,0 +1,23 @@ +--- +id: 6 +uid: 4a6f5c8e-085e-44ad-93fd-1bff31c4e367 +key: later +title: Later +type: milestone +status: backlog +depends_on: +- 5 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +--- + +Good ideas off the path to 1.0. Items wait here instead of being argued about. Promote one by moving it to a milestone and setting it to planned. + +## Release gate + +- [ ] Never ships as a release; items move out of it. + +## Explicitly not in this milestone + +- Nothing is ruled out here. diff --git a/cairn/items/0007-cold-boot-to-first-byte-under-25-ms.md b/cairn/items/0007-cold-boot-to-first-byte-under-25-ms.md new file mode 100644 index 0000000..a55e534 --- /dev/null +++ b/cairn/items/0007-cold-boot-to-first-byte-under-25-ms.md @@ -0,0 +1,27 @@ +--- +id: 7 +uid: d1c70b77-a528-4b48-83c4-930d2cf036e5 +key: boot +title: Cold boot to first byte under 25 ms +type: budget +status: planned +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +--- + +## Promise + +A Firecracker microVM running liam answers its first HTTP request within 25 ms of the VMM process starting. + +## How it is measured + +The boot harness starts Firecracker 100 times with the example site on the reference host. It times from VMM process start to the first successful HTTP response, and uses Firecracker's boot-timer device to time the guest-side phases. It reports p50 and p99. + +## Gate + +p99 is under 25 ms, and p50 has not regressed more than 10% against main. + +## Measured + +Not yet measured. diff --git a/cairn/items/0008-exec-to-listening-under-5-ms.md b/cairn/items/0008-exec-to-listening-under-5-ms.md new file mode 100644 index 0000000..682d3cc --- /dev/null +++ b/cairn/items/0008-exec-to-listening-under-5-ms.md @@ -0,0 +1,27 @@ +--- +id: 8 +uid: 14f6d380-3102-4272-b794-b7c9cc87af4e +key: listen +title: Exec to listening under 5 ms +type: budget +status: planned +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +--- + +## Promise + +As a container on an existing host kernel, liam accepts connections within 5 ms of liam-init being executed. + +## How it is measured + +The harness runs the container 100 times with runc directly, so the Docker daemon's overhead is not counted. It times from execve of liam-init to the first successful TCP connect and HTTP response, and reports p50 and p99. + +## Gate + +p99 is under 5 ms. + +## Measured + +Not yet measured. diff --git a/cairn/items/0009-base-container-image-under-6-mb.md b/cairn/items/0009-base-container-image-under-6-mb.md new file mode 100644 index 0000000..10fef69 --- /dev/null +++ b/cairn/items/0009-base-container-image-under-6-mb.md @@ -0,0 +1,27 @@ +--- +id: 9 +uid: 694b9e83-26fb-46e3-8db8-5be84fcfb890 +key: size +title: Base container image under 6 MB +type: budget +status: planned +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +--- + +## Promise + +The OCI image for an empty site is under 6 MB: liam-init, liamd and the compiled config, statically linked. + +## How it is measured + +`liam build` on the empty-site fixture, reporting the total compressed layer size in the image manifest. + +## Gate + +The total is under 6 MB, and no single pull request grows it by more than 100 KB without a note explaining why. + +## Measured + +Not yet measured. diff --git a/cairn/items/0010-zero-dropped-requests-across-reload-drain-and-crash.md b/cairn/items/0010-zero-dropped-requests-across-reload-drain-and-crash.md new file mode 100644 index 0000000..e35091a --- /dev/null +++ b/cairn/items/0010-zero-dropped-requests-across-reload-drain-and-crash.md @@ -0,0 +1,33 @@ +--- +id: 10 +uid: 4e0c7af5-c85e-4702-8ec9-37d881a10965 +key: zero-drop +title: Zero dropped requests across reload, drain and crash +type: budget +status: planned +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +--- + +## Promise + +Under steady load: + +- A config reload fails no requests. +- SIGTERM drains every in-flight request before exit, within the grace period. +- A liamd crash refuses no new connections. Requests in flight on the crashed process are lost, and this budget does not claim otherwise. + +It does not cover VM reboots or A/B updates. Keeping traffic up across a replaced instance is the load balancer's job. + +## How it is measured + +A load generator runs at a fixed rate while the harness triggers a reload, a SIGTERM, and a SIGKILL of liamd in turn. The harness counts failed requests and refused connections separately. + +## Gate + +Zero failed requests for reload and drain. Zero refused connections for crash. + +## Measured + +Not yet measured. diff --git a/cairn/items/0011-zero-heap-allocations-per-static-request.md b/cairn/items/0011-zero-heap-allocations-per-static-request.md new file mode 100644 index 0000000..657153e --- /dev/null +++ b/cairn/items/0011-zero-heap-allocations-per-static-request.md @@ -0,0 +1,27 @@ +--- +id: 11 +uid: 45334d50-5338-44f1-94a1-830d8e9338d8 +key: no-alloc +title: Zero heap allocations per static request +type: budget +status: planned +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +--- + +## Promise + +After warm-up, serving a static file from the pack performs no heap allocation, over HTTP/1.1, HTTP/2 and TLS. + +## How it is measured + +A counting global allocator in an integration test. The test serves 10,000 requests across the fixture site after warm-up and asserts the count did not change. + +## Gate + +The count is exactly zero. + +## Measured + +Not yet measured. diff --git a/cairn/items/0012-scaffold-the-cargo-workspace-and-the-task-seam.md b/cairn/items/0012-scaffold-the-cargo-workspace-and-the-task-seam.md new file mode 100644 index 0000000..2f24a5c --- /dev/null +++ b/cairn/items/0012-scaffold-the-cargo-workspace-and-the-task-seam.md @@ -0,0 +1,33 @@ +--- +id: 12 +uid: 10481a9f-b883-4165-bd4f-a3236d394e11 +title: Scaffold the Cargo workspace and the task seam +type: chore +status: planned +milestone: v0.1 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: infra +effort: s +--- + +## Purpose + +Nothing can be built, tested or gated until the repository has its shape. Every later item assumes these crates and verbs exist. + +## Approach + +- A Cargo workspace on edition 2024 with the toolchain pinned in `rust-toolchain.toml`. Crates: `liam-io`, `liam-http`, `liam-pack`, `liam-config`, `liam-init` (bin), `liamd` (bin), `liam` (the CLI bin). +- Release profile: `lto = "fat"`, `codegen-units = 1`, `panic = "abort"`, `opt-level = 3`. mimalloc as the global allocator in the binaries, because musl's allocator is slow. +- Build `x86_64-unknown-linux-musl` static binaries. +- `scripts/task` with `fmt fmt:check lint test build check`. `lint` is clippy with `-D warnings`. +- `LICENSE-MIT` and `LICENSE-APACHE`. +- `site/` is left as it is. The microsite already lives there. + +## Acceptance criteria + +- [ ] `scripts/task check` passes on the empty workspace. +- [ ] A deliberate clippy warning makes `scripts/task lint` exit non-zero. +- [ ] `file` reports the release `liamd` and `liam-init` binaries as statically linked. +- [ ] Both license files are present, and every crate declares `MIT OR Apache-2.0`. diff --git a/cairn/items/0013-ci-format-lint-and-test-every-pull-request.md b/cairn/items/0013-ci-format-lint-and-test-every-pull-request.md new file mode 100644 index 0000000..c570ac7 --- /dev/null +++ b/cairn/items/0013-ci-format-lint-and-test-every-pull-request.md @@ -0,0 +1,29 @@ +--- +id: 13 +uid: 260e57f1-6e39-40c6-8479-549fb0dd9c25 +title: 'CI: format, lint and test every pull request' +type: chore +status: planned +milestone: v0.1 +depends_on: +- 12 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: infra +effort: s +--- + +## Purpose + +Pull requests become the only way `main` moves, so the checks have to run on them. + +## Approach + +A GitHub Actions workflow that runs `scripts/task check` on pull requests and on `main`, with cargo caching. CI knows only the task verbs, so it cannot drift from local runs. + +## Acceptance criteria + +- [ ] The workflow runs `scripts/task check` and nothing else that duplicates it. +- [ ] A pull request with a clippy warning fails CI. +- [ ] A cached run finishes in under 5 minutes. diff --git a/cairn/items/0014-how-fast-can-a-stripped-linux-lts-kernel-boot-in-firecracker.md b/cairn/items/0014-how-fast-can-a-stripped-linux-lts-kernel-boot-in-firecracker.md new file mode 100644 index 0000000..2d06297 --- /dev/null +++ b/cairn/items/0014-how-fast-can-a-stripped-linux-lts-kernel-boot-in-firecracker.md @@ -0,0 +1,41 @@ +--- +id: 14 +uid: 28fe831a-9063-4ece-a6ed-ff6ab292e349 +title: How fast can a stripped Linux LTS kernel boot in Firecracker? +type: spike +status: planned +milestone: v0.1 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: kernel +effort: m +budget: +- boot +--- + +## Question + +What is the floor for Firecracker VMM start → kernel → `/init` with a minimal config of the current Linux LTS kernel, and does it leave room for 25 ms to first byte? + +## Why it blocks + +The boot budget and the kernel config both rest on this number. If the floor is 22 ms, the budget or the approach has to change before anything is built on it. + +## Timebox + +Two days. + +## Approach + +1. Start from Firecracker's recommended microVM guest config on the latest LTS. Strip it: no modules, no PCI, virtio-mmio only, uncompressed `vmlinux`, and a built-in initramfs whose static `/init` writes to Firecracker's boot-timer device and powers off. +2. Boot it 100 times on one known host. Record p50 and p99 for VMM start → guest kernel start → `/init` → poweroff. +3. Try each separately: `quiet`, `random.trust_cpu=on`, `tsc=reliable`, removing individual initcalls (use `initcall_debug` to rank them), and LZ4 against uncompressed. +4. Record which options cost the most. + +## Acceptance criteria + +- [ ] A table of p50 and p99 per configuration, with the host CPU, host kernel and Firecracker version. +- [ ] The winning config fragment is committed under `kernel/`. +- [ ] The `boot` budget is confirmed or revised, with the reason noted on it. +- [ ] Closed with `--result` stating the floor in milliseconds. diff --git a/cairn/items/0015-which-io-uring-runtime-does-liamd-build-on.md b/cairn/items/0015-which-io-uring-runtime-does-liamd-build-on.md new file mode 100644 index 0000000..acdc2e2 --- /dev/null +++ b/cairn/items/0015-which-io-uring-runtime-does-liamd-build-on.md @@ -0,0 +1,42 @@ +--- +id: 15 +uid: 5b1b67ff-aa53-4713-ba8e-7e68c2ffc77c +title: Which io_uring runtime does liamd build on? +type: spike +status: planned +milestone: v0.1 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: io +effort: m +budget: +- listen +- no-alloc +--- + +## Question + +Should liamd use a thin runtime of our own on the `io-uring` crate, or adopt monoio, compio or glommio? + +## Why it blocks + +Every liamd crate sits on this choice. HTTP/2 (`h2`) and later QUIC (`quinn`) expect tokio-style I/O traits, so the choice also decides how those integrate. A runtime that cannot host them costs a rewrite in v0.2. + +## Timebox + +Three days. + +## Approach + +1. On each candidate, write a thread-per-core HTTP/1.1 keep-alive hello-world with `SO_REUSEPORT` and multishot accept. +2. Measure requests per second and p99 on 1 and 4 cores with the same load generator and host. +3. Check each candidate for: sendfile or splice support, registered buffers, kTLS compatibility, maintenance activity, size of its unsafe surface, and whether `h2` runs on it through a compat layer. +4. File a decision item with the choice. + +## Acceptance criteria + +- [ ] Benchmark numbers for each candidate, with the exact commands. +- [ ] `h2` compatibility demonstrated or ruled out for the chosen runtime. +- [ ] A decision item records the choice. +- [ ] Closed with `--result` naming the runtime and the reason. diff --git a/cairn/items/0016-where-does-the-site-live-inside-a-microvm.md b/cairn/items/0016-where-does-the-site-live-inside-a-microvm.md new file mode 100644 index 0000000..a875eb6 --- /dev/null +++ b/cairn/items/0016-where-does-the-site-live-inside-a-microvm.md @@ -0,0 +1,39 @@ +--- +id: 16 +uid: 2b5d136d-4a50-4332-8093-d7778866ab9f +title: Where does the site live inside a microVM? +type: spike +status: planned +milestone: v0.1 +depends_on: +- 14 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: image +effort: s +budget: +- boot +--- + +## Question + +Should the site pack ride in the initramfs, which the kernel copies into RAM at boot, or on a read-only virtio-blk device that liamd maps and the page cache fills lazily? + +## Why it blocks + +Unpacking an initramfs costs time in proportion to its size, which puts a large site directly against the boot budget. The answer shapes the image layout and `liam build`. + +## Timebox + +One day. + +## Approach + +Measure boot to first byte and the latency of the first cold request with 1 MB, 40 MB and 200 MB packs, both ways, on the spike's kernel. + +## Acceptance criteria + +- [ ] Numbers for both approaches at all three sizes. +- [ ] The image layout item is updated to match. +- [ ] Closed with `--result` giving the choice and the size where the two cross over. diff --git a/cairn/items/0017-thread-per-core-io-layer-with-so-reuseport-listeners.md b/cairn/items/0017-thread-per-core-io-layer-with-so-reuseport-listeners.md new file mode 100644 index 0000000..c1863d4 --- /dev/null +++ b/cairn/items/0017-thread-per-core-io-layer-with-so-reuseport-listeners.md @@ -0,0 +1,37 @@ +--- +id: 17 +uid: de8183b7-1284-461b-960f-81e1cb654c1a +title: Thread-per-core io layer with SO_REUSEPORT listeners +type: feature +status: planned +milestone: v0.1 +depends_on: +- 15 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: io +effort: l +budget: +- listen +- no-alloc +--- + +## Problem + +liamd needs one event loop per core with nothing shared between them, or cores wait on each other under load. + +## Proposal + +`liam-io` spawns one pinned thread per online CPU. Each thread has its own ring and its own `SO_REUSEPORT` listener, or an inherited fd once liam-init owns sockets. It uses multishot accept, runs a task per connection, and keeps a timer wheel per core. Built on the runtime the spike chose. + +## Budget impact + +`listen`: worker start is on the startup path. `no-alloc`: connection state lives in per-core slabs, allocated up front. + +## Acceptance criteria + +- [ ] N workers for N CPUs, each pinned. A test reads `Cpus_allowed_list` for each thread. +- [ ] With 4 cores and 1,000 connections, every worker accepts some. +- [ ] On shutdown, workers stop accepting and finish in-flight work. +- [ ] `unsafe` appears only in this crate, and every block has a `SAFETY:` comment. diff --git a/cairn/items/0018-http-1-1-request-handling-with-keep-alive.md b/cairn/items/0018-http-1-1-request-handling-with-keep-alive.md new file mode 100644 index 0000000..d8fc80f --- /dev/null +++ b/cairn/items/0018-http-1-1-request-handling-with-keep-alive.md @@ -0,0 +1,36 @@ +--- +id: 18 +uid: e351f2f3-3bfc-4965-a803-c6b206f44a5f +title: HTTP/1.1 request handling with keep-alive +type: feature +status: planned +milestone: v0.1 +depends_on: +- 17 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: http +effort: l +budget: +- no-alloc +--- + +## Problem + +liamd has to speak HTTP/1.1 correctly and without per-request allocation. + +## Proposal + +Parse with `httparse` into fixed per-connection buffers. Support GET and HEAD with keep-alive. Tolerate pipelined requests by answering them in order. Refuse request bodies in v0.1 with 411 or 413, because v0.1 serves static files only. Cache the `Date` header once per second per core. Honour `Connection: close`. + +## Budget impact + +`no-alloc`: this is the hot path. + +## Acceptance criteria + +- [ ] A table-driven test of at least 30 malformed requests, each answered with 400 and a closed connection. +- [ ] HEAD returns the same headers as GET with no body. +- [ ] 1,000 sequential requests on one socket reuse the connection. +- [ ] curl, wrk and oha all complete against it without errors. diff --git a/cairn/items/0019-site-pack-format-and-builder.md b/cairn/items/0019-site-pack-format-and-builder.md new file mode 100644 index 0000000..f82dafc --- /dev/null +++ b/cairn/items/0019-site-pack-format-and-builder.md @@ -0,0 +1,43 @@ +--- +id: 19 +uid: 6182756f-a13a-4fa6-b61d-fe8fdfc16cc1 +title: Site pack format and builder +type: feature +status: planned +milestone: v0.1 +depends_on: +- 12 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: pack +effort: l +budget: +- listen +--- + +## Problem + +Serving from a directory means stat calls, open calls and runtime compression on every request. + +## Proposal + +`liam-pack` writes one file: a header with a format version byte, an index, and the data. + +- The index maps path → entry. Each entry holds offsets for its identity, Brotli and gzip variants, plus content-type, ETag and size. +- Data is aligned to 4 KiB so sendfile and mmap work on it. +- Compression happens at build time: Brotli at quality 11 and gzip at level 9. A variant is skipped when it saves less than 5%. +- The ETag is a truncated BLAKE3 of the identity bytes. +- Content-type comes from an extension table. +- The compiled config is stored in the pack, so an image carries a single site artifact. + +## Budget impact + +`listen`: liamd maps the pack instead of reading it. + +## Acceptance criteria + +- [ ] Building the same directory twice yields byte-identical packs. +- [ ] Every file in the fixture site round-trips in every variant. +- [ ] A truncated or corrupt pack is rejected at load with an error, never a panic, with a test per corruption case. +- [ ] The format is described in the crate's module docs, with its version byte. diff --git a/cairn/items/0020-liam-toml-v0-parse-and-validate-at-build-time.md b/cairn/items/0020-liam-toml-v0-parse-and-validate-at-build-time.md new file mode 100644 index 0000000..57fdb99 --- /dev/null +++ b/cairn/items/0020-liam-toml-v0-parse-and-validate-at-build-time.md @@ -0,0 +1,34 @@ +--- +id: 20 +uid: 8ea1679c-263f-48fc-a748-d817600b70ea +title: 'liam.toml v0: parse and validate at build time' +type: feature +status: planned +milestone: v0.1 +depends_on: +- 12 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: config +effort: m +--- + +## Problem + +A typo in a config should fail on a laptop, never at boot in production. + +## Proposal + +Parse with serde and `toml`, keeping spans. v0.1 keys: `[site] root, compress, index, not_found, spa_fallback` and `[limits]`. Unknown keys are errors. Errors print `file:line`, the problem, and a `help:` line. The result is compiled into a binary config stored in the pack, so liamd never parses TOML at boot. + +## Budget impact + +`boot`: nothing is parsed at startup. + +## Acceptance criteria + +- [ ] Every key has a test for a valid value and an invalid one. +- [ ] An unknown key's error names it and suggests the nearest valid key. +- [ ] Snapshot tests cover the error output for five common mistakes, including a missing `spa_fallback` file. +- [ ] `liam check` reports the same errors as `liam build`. diff --git a/cairn/items/0021-the-liam-cli-build-run-check-help.md b/cairn/items/0021-the-liam-cli-build-run-check-help.md new file mode 100644 index 0000000..76f2978 --- /dev/null +++ b/cairn/items/0021-the-liam-cli-build-run-check-help.md @@ -0,0 +1,33 @@ +--- +id: 21 +uid: ed564ea7-cf00-481d-900a-63b76f740eb3 +title: 'The liam CLI: build, run, check, --help' +type: feature +status: planned +milestone: v0.1 +depends_on: +- 12 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: cli +effort: s +--- + +## Problem + +The CLI is the entire user interface in v0.1. + +## Proposal + +clap, with subcommands `build`, `run`, `check` and `version`. Exit codes: 0 for success, 1 for a user error (bad config, missing file), 2 for an internal error. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] `liam --help` lists every subcommand with one line of help each. +- [ ] `liam check` validates the config without building anything. +- [ ] A test covers each of the three exit codes. diff --git a/cairn/items/0022-kernel-config-and-reproducible-kernel-build.md b/cairn/items/0022-kernel-config-and-reproducible-kernel-build.md new file mode 100644 index 0000000..4410425 --- /dev/null +++ b/cairn/items/0022-kernel-config-and-reproducible-kernel-build.md @@ -0,0 +1,36 @@ +--- +id: 22 +uid: f75c295a-1756-4a67-90b3-037003981df9 +title: Kernel config and reproducible kernel build +type: feature +status: planned +milestone: v0.1 +depends_on: +- 14 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: kernel +effort: l +budget: +- boot +--- + +## Problem + +The kernel is a third of the image and most of the boot time. It has to be small, fast and rebuildable bit for bit. + +## Proposal + +`kernel/` holds the pinned LTS version, the config fragment from the boot spike, and a build script that runs in a container. It outputs an uncompressed `vmlinux` for Firecracker, with a built-in initramfs containing liam-init and liamd, which are fixed per liam release. Networking uses the kernel's `ip=` autoconfiguration, which costs no userspace time. + +## Budget impact + +`boot`: this is most of it. + +## Acceptance criteria + +- [ ] Two clean builds produce the same sha256, using `SOURCE_DATE_EPOCH` and `KBUILD_BUILD_*`. +- [ ] `CONFIG_MODULES` is off. +- [ ] The config fragment comments every non-obvious option. +- [ ] It boots in Firecracker to liam-init, with the size of `vmlinux` recorded. diff --git a/cairn/items/0023-liam-init-pid-1-in-the-microvm.md b/cairn/items/0023-liam-init-pid-1-in-the-microvm.md new file mode 100644 index 0000000..9d1e917 --- /dev/null +++ b/cairn/items/0023-liam-init-pid-1-in-the-microvm.md @@ -0,0 +1,43 @@ +--- +id: 23 +uid: afa44e22-3d86-42a8-a9be-9ba971770169 +title: 'liam-init: PID 1 in the microVM' +type: feature +status: planned +milestone: v0.1 +depends_on: +- 12 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: init +effort: m +budget: +- boot +--- + +## Problem + +Something has to be PID 1. systemd costs hundreds of milliseconds and a large attack surface. + +## Proposal + +A small static Rust binary that: + +- mounts `/proc`, `/sys`, `/dev` (devtmpfs) and a tmpfs `/tmp`; +- applies sysctls (`somaxconn`, TCP buffers, `file-max`); +- seeds the RNG; +- starts liamd as a child, reaps zombies, and restarts liamd with backoff if it exits; +- powers off cleanly on ctrl-alt-del. + +## Budget impact + +`boot`, `size`. + +## Acceptance criteria + +- [ ] It boots in Firecracker and starts liamd. +- [ ] A killed liamd is restarted within 1 ms, shown by log timestamps. +- [ ] A test shows an orphaned process is reaped. +- [ ] Firecracker's `SendCtrlAltDel` powers the VM off cleanly. +- [ ] The stripped binary is under 300 KB. diff --git a/cairn/items/0024-serve-static-files-from-the-pack.md b/cairn/items/0024-serve-static-files-from-the-pack.md new file mode 100644 index 0000000..c70a41b --- /dev/null +++ b/cairn/items/0024-serve-static-files-from-the-pack.md @@ -0,0 +1,45 @@ +--- +id: 24 +uid: 77c2a41f-922c-444f-be93-c7c083ae2c78 +title: Serve static files from the pack +type: feature +status: planned +milestone: v0.1 +depends_on: +- 18 +- 19 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: pack +effort: m +budget: +- no-alloc +--- + +## Problem + +The pack exists so static responses are as cheap as the kernel can make them. + +## Proposal + +Map the pack read-only. + +- Resolve path → entry, with `index.html` for directories. +- Negotiate `Accept-Encoding` (br, then gzip, then identity), honouring q-values including `q=0`. +- Answer `If-None-Match` with 304. +- Serve a custom 404 when configured. +- `spa_fallback` serves the index for paths without an extension and never for missing assets. +- Send bodies with sendfile from the pack fd at the entry's offset. + +## Budget impact + +`no-alloc`: this is the hot path. + +## Acceptance criteria + +- [ ] A table test covers encoding negotiation, including `q=0` and a missing header. +- [ ] A matching ETag returns 304 with no body. +- [ ] The trailing-slash rule is decided, written down, and tested. +- [ ] A test shows `spa_fallback` never answers `/missing.js`. +- [ ] Response bodies never pass through userspace, shown by an io_uring op counter or strace. diff --git a/cairn/items/0025-liam-build-assemble-a-firecracker-image.md b/cairn/items/0025-liam-build-assemble-a-firecracker-image.md new file mode 100644 index 0000000..967aac2 --- /dev/null +++ b/cairn/items/0025-liam-build-assemble-a-firecracker-image.md @@ -0,0 +1,44 @@ +--- +id: 25 +uid: b526be14-0333-4fe1-96b2-9313f3499725 +title: 'liam build: assemble a Firecracker image' +type: feature +status: planned +milestone: v0.1 +depends_on: +- 16 +- 19 +- 20 +- 21 +- 22 +- 23 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: image +effort: m +budget: +- boot +--- + +## Problem + +A user has a directory and a `liam.toml`, and needs something Firecracker can boot. + +## Proposal + +`liam build` validates the config, builds the pack, and writes `<name>.fc/`, which contains: + +- the release's `vmlinux`; +- the site pack, laid out as the "where does the site live" spike decided; +- a Firecracker config JSON with the kernel command line, memory and vCPU count. + +## Budget impact + +`boot`. + +## Acceptance criteria + +- [ ] `liam build` in the example directory produces a bootable `.fc` directory. +- [ ] Two builds of the same input are byte-identical. +- [ ] The build prints what it produced and each part's size. diff --git a/cairn/items/0026-liam-run-boot-the-image-locally-in-firecracker.md b/cairn/items/0026-liam-run-boot-the-image-locally-in-firecracker.md new file mode 100644 index 0000000..ae1a704 --- /dev/null +++ b/cairn/items/0026-liam-run-boot-the-image-locally-in-firecracker.md @@ -0,0 +1,33 @@ +--- +id: 26 +uid: 1b5d6586-1157-4e97-81a3-e33ee0058a12 +title: 'liam run: boot the image locally in Firecracker' +type: feature +status: planned +milestone: v0.1 +depends_on: +- 25 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: cli +effort: m +--- + +## Problem + +The first thing a new user does is run it. That has to work, or say exactly why it didn't. + +## Proposal + +`liam run` finds `firecracker` on PATH or through `--firecracker`, sets up a tap device (or uses one given with `--tap`), boots the `.fc` image, streams the serial console to the terminal, prints the URL, and shuts the VM down cleanly on Ctrl-C. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] On Linux with `/dev/kvm`, `liam run` in the example directory serves the site at the printed URL. +- [ ] A missing `/dev/kvm`, `firecracker` binary or tap permission each give an error that names the fix. +- [ ] Ctrl-C leaves no Firecracker process and no tap device that liam created. diff --git a/cairn/items/0027-ship-a-prebuilt-kernel-with-each-release.md b/cairn/items/0027-ship-a-prebuilt-kernel-with-each-release.md new file mode 100644 index 0000000..be212ee --- /dev/null +++ b/cairn/items/0027-ship-a-prebuilt-kernel-with-each-release.md @@ -0,0 +1,33 @@ +--- +id: 27 +uid: 2c87c0ab-d9a9-405c-a9ca-1d83f4954fa4 +title: Ship a prebuilt kernel with each release +type: feature +status: planned +milestone: v0.1 +depends_on: +- 22 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: infra +effort: m +--- + +## Problem + +Nobody should have to compile Linux to try liam. + +## Proposal + +The release workflow builds the `vmlinux` with liam-init and liamd for each target and uploads it with its sha256. `liam build` downloads the version matching the CLI into a cache and verifies it. `--kernel` points at a custom kernel instead. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] On a fresh machine, `liam build` fetches and verifies the kernel. +- [ ] A checksum mismatch aborts with an error, and the bad file is removed from the cache. +- [ ] With a warm cache, `liam build` works offline. diff --git a/cairn/items/0028-release-workflow-and-one-command-install.md b/cairn/items/0028-release-workflow-and-one-command-install.md new file mode 100644 index 0000000..0b66e2f --- /dev/null +++ b/cairn/items/0028-release-workflow-and-one-command-install.md @@ -0,0 +1,30 @@ +--- +id: 28 +uid: daeeec23-b26a-4fa6-b392-d8d74893ea23 +title: Release workflow and one-command install +type: chore +status: planned +milestone: v0.1 +depends_on: +- 13 +- 27 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: infra +effort: m +--- + +## Purpose + +A stranger has to be able to install liam in one command. + +## Approach + +Pushing a `v*` tag builds the CLI for x86_64 and aarch64 Linux and for aarch64 macOS (build works on macOS; run needs Linux), and attaches the binaries with checksums to a GitHub release. Check whether the `liam` name is free on crates.io and decide the install path: `cargo binstall`, a release install script, or both. + +## Acceptance criteria + +- [ ] Tagging `v0.1.0-rc.1` produces a release with binaries and checksums for all three targets. +- [ ] The install command works on a clean Ubuntu VM and a clean macOS machine. +- [ ] The crate name decision is recorded as a note on this item. diff --git a/cairn/items/0029-boot-benchmark-harness.md b/cairn/items/0029-boot-benchmark-harness.md new file mode 100644 index 0000000..9cfddaf --- /dev/null +++ b/cairn/items/0029-boot-benchmark-harness.md @@ -0,0 +1,35 @@ +--- +id: 29 +uid: 16d02226-9b8a-45e4-a360-4512e194dc91 +title: Boot benchmark harness +type: feature +status: planned +milestone: v0.1 +depends_on: +- 25 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: bench +effort: m +budget: +- boot +--- + +## Problem + +The boot budget means nothing without a measurement people can rerun. + +## Proposal + +`bench/boot` starts Firecracker N times with the example site. It times from VMM process start to the first successful HTTP response, using a tight connect-retry loop, and reads the guest-side phases from the boot-timer device. It writes JSON with p50, p99, max, and host metadata (CPU, kernel, Firecracker version). + +## Budget impact + +`boot`: this is how it is measured. + +## Acceptance criteria + +- [ ] A 100-run report includes p50, p99 and host metadata. +- [ ] Across 5 invocations on the reference host, p50 varies by less than 10%. +- [ ] The JSON schema is described in the harness's `--help`. diff --git a/cairn/items/0030-where-can-boot-time-gates-run-without-flaking.md b/cairn/items/0030-where-can-boot-time-gates-run-without-flaking.md new file mode 100644 index 0000000..73f33f8 --- /dev/null +++ b/cairn/items/0030-where-can-boot-time-gates-run-without-flaking.md @@ -0,0 +1,39 @@ +--- +id: 30 +uid: 94d7f60f-71c6-4234-91ef-b3b06bf10a54 +title: Where can boot-time gates run without flaking? +type: spike +status: planned +milestone: v0.1 +depends_on: +- 29 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: bench +effort: s +budget: +- boot +--- + +## Question + +Do GitHub-hosted Linux runners expose `/dev/kvm` with low enough timing variance for a 25 ms gate, or does the gate need a dedicated self-hosted machine? + +## Why it blocks + +A flaky gate gets ignored, and then the budget is decoration. + +## Timebox + +One day, plus runs spread over 24 hours. + +## Approach + +Run the boot harness 20 times on hosted runners at different times of day, and on one dedicated bare-metal host. Compare the spread of p50 and p99. Price the dedicated option. + +## Acceptance criteria + +- [ ] The variance of both options, with raw numbers. +- [ ] A decision on where gates run, with the monthly cost. +- [ ] Closed with `--result`. diff --git a/cairn/items/0031-ci-gate-fail-a-pull-request-that-misses-the-boot-budget.md b/cairn/items/0031-ci-gate-fail-a-pull-request-that-misses-the-boot-budget.md new file mode 100644 index 0000000..2c68d34 --- /dev/null +++ b/cairn/items/0031-ci-gate-fail-a-pull-request-that-misses-the-boot-budget.md @@ -0,0 +1,37 @@ +--- +id: 31 +uid: 739582a5-7a3d-4aae-8d87-fb1692d05fda +title: 'CI gate: fail a pull request that misses the boot budget' +type: feature +status: planned +milestone: v0.1 +depends_on: +- 13 +- 29 +- 30 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: bench +effort: m +budget: +- boot +--- + +## Problem + +A budget that is only measured by hand drifts. + +## Proposal + +A CI job runs the boot harness on the runner the spike chose. It compares p99 to the budget and p50 to main's last result, and fails on either. Thresholds live in one `budgets.toml` that every gate reads. The numbers go in the job summary. + +## Budget impact + +`boot`: this enforces it. + +## Acceptance criteria + +- [ ] A pull request that adds a 10 ms sleep to liam-init fails the gate. +- [ ] Every run's numbers appear in the job summary. +- [ ] `budgets.toml` is the only place the thresholds are written. diff --git a/cairn/items/0032-allocation-gate-on-the-static-hot-path.md b/cairn/items/0032-allocation-gate-on-the-static-hot-path.md new file mode 100644 index 0000000..5e526e8 --- /dev/null +++ b/cairn/items/0032-allocation-gate-on-the-static-hot-path.md @@ -0,0 +1,34 @@ +--- +id: 32 +uid: cbab5ef1-ce01-48c9-9143-ea2f362df0b1 +title: Allocation gate on the static hot path +type: feature +status: planned +milestone: v0.1 +depends_on: +- 24 +created: 2026-09-28 +updated: 2026-09-28 +priority: p1 +area: bench +effort: s +budget: +- no-alloc +--- + +## Problem + +Allocations creep into hot paths one reasonable-looking change at a time. + +## Proposal + +An integration test with a counting global allocator warms up, serves 10,000 requests across the fixture site, and asserts zero new allocations. It runs in `scripts/task test`. + +## Budget impact + +`no-alloc`: this enforces it. + +## Acceptance criteria + +- [ ] The test passes on the static path. +- [ ] A scratch change that allocates per request fails it. diff --git a/cairn/items/0033-access-logs-to-the-serial-console.md b/cairn/items/0033-access-logs-to-the-serial-console.md new file mode 100644 index 0000000..f1ab7be --- /dev/null +++ b/cairn/items/0033-access-logs-to-the-serial-console.md @@ -0,0 +1,33 @@ +--- +id: 33 +uid: 274c9868-bdbd-42d4-9928-95bddcd19f18 +title: Access logs to the serial console +type: feature +status: planned +milestone: v0.1 +depends_on: +- 18 +created: 2026-09-28 +updated: 2026-09-28 +priority: p1 +area: ops +effort: m +--- + +## Problem + +With no shell, logs are the only window into a running instance. + +## Proposal + +One JSON line per request, with ts, method, path, status, bytes, duration in µs, and core. Each core writes to its own ring buffer, and a low-priority thread flushes them to stdout or the serial console. When a ring is full, lines are dropped and counted rather than blocking the request. + +## Budget impact + +`no-alloc`: logging uses preallocated rings. + +## Acceptance criteria + +- [ ] Every request produces one JSON line. +- [ ] A saturated console raises request p99 by less than 5%, measured. +- [ ] The count of dropped lines is logged every 10 seconds when non-zero. diff --git a/cairn/items/0034-readme-quickstart-from-a-site-directory-to-a-running-microvm.md b/cairn/items/0034-readme-quickstart-from-a-site-directory-to-a-running-microvm.md new file mode 100644 index 0000000..5dba694 --- /dev/null +++ b/cairn/items/0034-readme-quickstart-from-a-site-directory-to-a-running-microvm.md @@ -0,0 +1,29 @@ +--- +id: 34 +uid: 66f5663b-11b9-4b03-be5f-0773d0820c2d +title: 'README quickstart: from a site directory to a running microVM' +type: docs +status: planned +milestone: v0.1 +depends_on: +- 26 +- 28 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: docs +effort: s +--- + +## Reader and question + +Someone with a Linux host with KVM and a built static site asks: how do I get this served by liam? + +## Change + +A README with host requirements, the install command, a minimal `liam.toml`, `liam build`, `liam run`, the budgets, and a link to the latest gate results. + +## Acceptance criteria + +- [ ] Someone who did not write it follows it on a clean Ubuntu host and gets their site served. +- [ ] Every command in it is copied from a run against the release. diff --git a/cairn/items/0035-project-microsite-and-design-system.md b/cairn/items/0035-project-microsite-and-design-system.md new file mode 100644 index 0000000..a55b823 --- /dev/null +++ b/cairn/items/0035-project-microsite-and-design-system.md @@ -0,0 +1,36 @@ +--- +id: 35 +uid: 7e768140-8252-4f28-bdea-5cdeebceffaa +title: Project microsite and design system +type: feature +status: doing +milestone: v0.1 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +area: site +effort: m +--- + +## Problem + +People who care about fast infrastructure need to understand liam in one page, and the project needs a visual identity it can reuse. + +## Proposal + +An Astro static site in `site/` that ships zero framework JavaScript. It is built from a small design system of tokens, layout primitives and components, with a `/design` reference page. Content follows the published landing page. A static build is also what liam itself will serve later. + +## Budget impact + +None. The site is a static build, so liam can serve it. + +## Acceptance criteria + +- [ ] `pnpm check` and `pnpm build` pass in `site/`. +- [ ] The landing page renders the same sections as the published artifact. +- [ ] `/design` shows every token and component. +- [ ] Pages ship no JavaScript except the boot-trace replay. + +## 2026-09-28 + +Built on branch feat/site (worktree ../.worktrees/liam/feat/site): Astro 7, zero framework JS, pnpm check and build pass. Rough: roadmap section hard-coded, some raw px values outside tokens. diff --git a/cairn/items/0036-ktls-with-io-uring-and-sendfile-what-works-on-the-lts-kernel.md b/cairn/items/0036-ktls-with-io-uring-and-sendfile-what-works-on-the-lts-kernel.md new file mode 100644 index 0000000..e0230b1 --- /dev/null +++ b/cairn/items/0036-ktls-with-io-uring-and-sendfile-what-works-on-the-lts-kernel.md @@ -0,0 +1,39 @@ +--- +id: 36 +uid: 1564d00f-a078-4196-9c09-ee9a94dbb7c1 +title: 'kTLS with io_uring and sendfile: what works on the LTS kernel?' +type: spike +status: planned +milestone: v0.2 +depends_on: +- 15 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: tls +effort: m +budget: +- no-alloc +--- + +## Question + +Can rustls hand both TX and RX keys to kernel TLS for TLS 1.3 with AES-GCM and ChaCha20-Poly1305 on our kernel? Does sendfile of a pack range over a kTLS socket work through io_uring? How are key updates, `close_notify` and alerts handled once the kernel owns the record layer? + +## Why it blocks + +kTLS is how HTTPS static responses avoid copying through userspace. If a piece is missing, the TLS design changes. + +## Timebox + +Three days. + +## Approach + +Prototype with rustls secret extraction and the `ktls` crate on the chosen runtime. Measure throughput and CPU against plain userspace rustls. Exercise a key update, `close_notify`, and a fatal alert. + +## Acceptance criteria + +- [ ] A table of what works per cipher suite and direction, with the kernel options it needs added to the kernel config fragment. +- [ ] Throughput and CPU for kTLS against userspace rustls. +- [ ] Closed with `--result`. diff --git a/cairn/items/0037-tls-1-3-with-rustls-record-layer-in-the-kernel.md b/cairn/items/0037-tls-1-3-with-rustls-record-layer-in-the-kernel.md new file mode 100644 index 0000000..e077895 --- /dev/null +++ b/cairn/items/0037-tls-1-3-with-rustls-record-layer-in-the-kernel.md @@ -0,0 +1,41 @@ +--- +id: 37 +uid: 438ac892-b9d2-4802-8c0c-74245737abb3 +title: TLS 1.3 with rustls, record layer in the kernel +type: feature +status: planned +milestone: v0.2 +depends_on: +- 18 +- 36 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: tls +effort: l +budget: +- no-alloc +--- + +## Problem + +v0.2 faces the internet directly, so it needs TLS, and TLS must not cost the static path its zero-copy. + +## Proposal + +- rustls does the handshake: TLS 1.3, plus TLS 1.2 with ECDHE and AEAD suites only. +- ALPN offers `h2` and `http/1.1`. +- After the handshake, the keys go to the kernel and responses use sendfile. +- When kTLS is unavailable, fall back to userspace rustls and log it once. +- Session tickets rotate on a schedule. + +## Budget impact + +`no-alloc` on the static path after the handshake. + +## Acceptance criteria + +- [ ] `/proc/net/tls_stat` shows TX and RX offload on a served connection. +- [ ] The fallback is tested by disabling kTLS in the test kernel. +- [ ] testssl.sh reports no weak protocols or ciphers. +- [ ] The handshake path is covered by the fuzzing item's targets. diff --git a/cairn/items/0038-certificates-from-files-with-sni-across-domains.md b/cairn/items/0038-certificates-from-files-with-sni-across-domains.md new file mode 100644 index 0000000..4f4ebc0 --- /dev/null +++ b/cairn/items/0038-certificates-from-files-with-sni-across-domains.md @@ -0,0 +1,34 @@ +--- +id: 38 +uid: 0ffe118b-21cb-4551-a3f3-e0c33760e86c +title: Certificates from files, with SNI across domains +type: feature +status: planned +milestone: v0.2 +depends_on: +- 20 +- 37 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: tls +effort: m +--- + +## Problem + +A site with several domains needs the right certificate for each, and a bad certificate should fail the build rather than the first visitor. + +## Proposal + +`[tls] cert` and `key` take PEM paths, or a table per domain. The certificate is chosen by SNI. At build time, liam checks that the key matches the certificate, that the chain is complete, and that the certificate covers its domains, and warns when it expires within 14 days. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] Two domains with separate certificates are each served the right one, in a test. +- [ ] A mismatched key, an incomplete chain and a domain the certificate doesn't cover each fail `liam build` with a clear error. +- [ ] An expiring certificate warns without failing. diff --git a/cairn/items/0039-http-2.md b/cairn/items/0039-http-2.md new file mode 100644 index 0000000..8c9fc91 --- /dev/null +++ b/cairn/items/0039-http-2.md @@ -0,0 +1,35 @@ +--- +id: 39 +uid: af5edb95-a76d-4163-a624-14941cca838e +title: HTTP/2 +type: feature +status: planned +milestone: v0.2 +depends_on: +- 37 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: http +effort: l +budget: +- no-alloc +--- + +## Problem + +Browsers expect HTTP/2 over TLS, and it removes head-of-line blocking at the connection level for asset-heavy pages. + +## Proposal + +HTTP/2 over the runtime the spike chose, using `h2` if the spike showed it works there, otherwise our own framing. Stream concurrency and flow-control windows are bounded. It is protected against rapid reset (CVE-2023-44487). + +## Budget impact + +`no-alloc` on the static path, measured over HTTP/2 as well. + +## Acceptance criteria + +- [ ] h2spec passes in full. +- [ ] A rapid-reset flood test leaves the server responsive to other connections. +- [ ] h2load numbers against HTTP/1.1 are recorded in `bench/results`. diff --git a/cairn/items/0040-host-routing-redirects-and-response-headers.md b/cairn/items/0040-host-routing-redirects-and-response-headers.md new file mode 100644 index 0000000..6534667 --- /dev/null +++ b/cairn/items/0040-host-routing-redirects-and-response-headers.md @@ -0,0 +1,43 @@ +--- +id: 40 +uid: 43a6afc8-8a09-425f-a8b3-e81a383974bf +title: Host routing, redirects and response headers +type: feature +status: planned +milestone: v0.2 +depends_on: +- 20 +- 24 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: router +effort: m +budget: +- no-alloc +--- + +## Problem + +One image serves several domains, needs canonical redirects, and must set caching and security headers per route. + +## Proposal + +`liam build` compiles the routes. Hosts go into a perfect hash and paths into a radix tree. Supported: + +- redirect rules: http → https, www → apex, and arbitrary 301 and 308; +- per-route `headers`; +- `cache = "immutable"`, which sets `Cache-Control`; +- HSTS. + +An unknown `Host` gets 421 Misdirected Request. + +## Budget impact + +`no-alloc`: route lookup must not allocate. + +## Acceptance criteria + +- [ ] A table test covers routing across three hosts, with redirects and header rules. +- [ ] Route lookup is allocation-free, checked by the allocation gate. +- [ ] Overlapping or unreachable routes fail `liam build`, with the conflicting lines named. diff --git a/cairn/items/0041-bound-every-phase-of-a-request.md b/cairn/items/0041-bound-every-phase-of-a-request.md new file mode 100644 index 0000000..7562a9c --- /dev/null +++ b/cairn/items/0041-bound-every-phase-of-a-request.md @@ -0,0 +1,40 @@ +--- +id: 41 +uid: acda9d48-eca9-40f4-be1b-e8526a72fe22 +title: Bound every phase of a request +type: feature +status: planned +milestone: v0.2 +depends_on: +- 18 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: http +effort: m +--- + +## Problem + +Facing the internet, one slow or hostile client must not hold a core's resources. + +## Proposal + +Configurable in `[limits]`, all with defaults: + +- header bytes (16 KiB), header count, and request-line length; +- timeouts for header read (10 s), keep-alive idle (60 s) and write stall (30 s); +- maximum connections per worker and per client IP. + +Exceeding a limit returns 431 or 408, or closes the connection. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] A slowloris client sending one byte per second is closed at the header timeout. +- [ ] Oversized headers get 431. +- [ ] Every limit has a test and a default written in the config schema. +- [ ] Connections over the per-IP cap are closed immediately without affecting other clients. diff --git a/cairn/items/0042-range-and-conditional-requests.md b/cairn/items/0042-range-and-conditional-requests.md new file mode 100644 index 0000000..f8066cb --- /dev/null +++ b/cairn/items/0042-range-and-conditional-requests.md @@ -0,0 +1,32 @@ +--- +id: 42 +uid: 319e9390-b733-434b-927b-97f184e349ff +title: Range and conditional requests +type: feature +status: planned +milestone: v0.2 +depends_on: +- 24 +created: 2026-09-28 +updated: 2026-09-28 +priority: p1 +area: pack +effort: m +--- + +## Problem + +Video, resumable downloads and some CDNs need byte ranges. + +## Proposal + +A single range returns 206 Partial Content. `If-Range` and `If-Modified-Since` are supported. A request for multiple ranges gets 200 with the full body, which the spec allows. Ranges are served from the identity variant only. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] A table test covers valid, suffix, unsatisfiable (416) and multi-range requests. +- [ ] A range read over kTLS still uses sendfile. diff --git a/cairn/items/0043-zstd-variants-in-the-pack.md b/cairn/items/0043-zstd-variants-in-the-pack.md new file mode 100644 index 0000000..f2ba2ec --- /dev/null +++ b/cairn/items/0043-zstd-variants-in-the-pack.md @@ -0,0 +1,32 @@ +--- +id: 43 +uid: 2e26f238-7bdd-4854-8d58-fa3f623b2dd1 +title: zstd variants in the pack +type: feature +status: planned +milestone: v0.2 +depends_on: +- 19 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +area: pack +effort: s +--- + +## Problem + +Current browsers accept zstd, which decompresses faster than Brotli for similar size. + +## Proposal + +Add a zstd variant at build time, at level 19 by default. Order negotiation as br, zstd, gzip, unless q-values say otherwise. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] A zstd variant round-trips in the pack tests. +- [ ] Negotiation tests include zstd. diff --git a/cairn/items/0044-oci-image-output.md b/cairn/items/0044-oci-image-output.md new file mode 100644 index 0000000..373159c --- /dev/null +++ b/cairn/items/0044-oci-image-output.md @@ -0,0 +1,37 @@ +--- +id: 44 +uid: 8a7866bf-4aae-49a7-a1e3-2c870d86b7d8 +title: OCI image output +type: feature +status: planned +milestone: v0.2 +depends_on: +- 19 +- 20 +- 23 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: image +effort: m +budget: +- size +--- + +## Problem + +Most people deploy containers. liam has to be a container image before it is anything else to them. + +## Proposal + +`liam build` writes an OCI image layout tarball for `targets = ["oci"]`. It is built from scratch, with liam-init as the entrypoint, plus liamd and the pack. It has no shell and no libc. The digest is reproducible. + +## Budget impact + +`size`. + +## Acceptance criteria + +- [ ] `docker load` and `podman load` accept it, and `docker run -p 8080:8080` serves the site. +- [ ] Two builds of the same input produce the same digest. +- [ ] The image size is printed by `liam build`. diff --git a/cairn/items/0045-liam-init-as-a-container-entrypoint-with-drain-on-sigterm.md b/cairn/items/0045-liam-init-as-a-container-entrypoint-with-drain-on-sigterm.md new file mode 100644 index 0000000..dfade6f --- /dev/null +++ b/cairn/items/0045-liam-init-as-a-container-entrypoint-with-drain-on-sigterm.md @@ -0,0 +1,35 @@ +--- +id: 45 +uid: 3a00598d-393d-41a6-903a-7b3fff160015 +title: liam-init as a container entrypoint, with drain on SIGTERM +type: feature +status: planned +milestone: v0.2 +depends_on: +- 23 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: init +effort: m +budget: +- zero-drop +--- + +## Problem + +In a container, liam-init cannot mount or set most sysctls. Kubernetes sends SIGTERM and expects a drain before the grace period ends. + +## Proposal + +liam-init detects that it is in a container and skips privileged setup. On SIGTERM, liamd stops accepting, finishes in-flight requests, and exits before a configurable deadline (default 25 s, under Kubernetes' 30 s). It runs as a non-root user on a read-only root filesystem with no capabilities, listening on 8080 by default. + +## Budget impact + +`zero-drop` for drain. + +## Acceptance criteria + +- [ ] `docker stop` under load fails no in-flight requests and exits 0. +- [ ] It runs with `--read-only --cap-drop=ALL --user 65532`. +- [ ] Requests still in flight at the deadline are counted in the exit log. diff --git a/cairn/items/0046-liam-run-on-macos-through-docker-or-podman.md b/cairn/items/0046-liam-run-on-macos-through-docker-or-podman.md new file mode 100644 index 0000000..55ba60b --- /dev/null +++ b/cairn/items/0046-liam-run-on-macos-through-docker-or-podman.md @@ -0,0 +1,32 @@ +--- +id: 46 +uid: 69dc54b9-f2bc-4209-87e7-85ba5f0ee7bb +title: liam run on macOS through Docker or Podman +type: feature +status: planned +milestone: v0.2 +depends_on: +- 44 +created: 2026-09-28 +updated: 2026-09-28 +priority: p1 +area: cli +effort: s +--- + +## Problem + +Many people build sites on macOS, where Firecracker does not run. + +## Proposal + +On macOS, or with `--oci`, `liam run` loads the OCI image into Docker or Podman, whichever it finds, runs it, and prints the URL. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] `liam run` on macOS with Docker Desktop serves the example site. +- [ ] When neither Docker nor Podman is found, the error says which to install. diff --git a/cairn/items/0047-ci-gates-for-exec-to-listen-and-image-size.md b/cairn/items/0047-ci-gates-for-exec-to-listen-and-image-size.md new file mode 100644 index 0000000..3eaa40e --- /dev/null +++ b/cairn/items/0047-ci-gates-for-exec-to-listen-and-image-size.md @@ -0,0 +1,37 @@ +--- +id: 47 +uid: cf6560be-0c00-407b-9b7f-518143b19e47 +title: CI gates for exec-to-listen and image size +type: feature +status: planned +milestone: v0.2 +depends_on: +- 31 +- 44 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: bench +effort: m +budget: +- listen +- size +--- + +## Problem + +The `listen` and `size` budgets need the same enforcement the `boot` budget has. + +## Proposal + +A harness runs the image with runc directly and times from execve of liam-init to the first HTTP response, 100 times. A size check reads the compressed layer total from the manifest of the empty-site fixture. Both read their thresholds from `budgets.toml`. + +## Budget impact + +`listen`, `size`: this enforces them. + +## Acceptance criteria + +- [ ] A pull request that adds 200 KB to liamd fails the size gate. +- [ ] A 10 ms sleep before listen fails the listen gate. +- [ ] Both report their numbers in the job summary. diff --git a/cairn/items/0048-fuzz-the-http-1-parser-http-2-frames-tls-glue-and-config-parser.md b/cairn/items/0048-fuzz-the-http-1-parser-http-2-frames-tls-glue-and-config-parser.md new file mode 100644 index 0000000..1827342 --- /dev/null +++ b/cairn/items/0048-fuzz-the-http-1-parser-http-2-frames-tls-glue-and-config-parser.md @@ -0,0 +1,35 @@ +--- +id: 48 +uid: e71c831d-b460-4ede-a0be-e78adf76be07 +title: Fuzz the HTTP/1 parser, HTTP/2 frames, TLS glue and config parser +type: feature +status: planned +milestone: v0.2 +depends_on: +- 18 +- 20 +- 39 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: test +effort: m +--- + +## Problem + +Parsers facing the internet get attacked. Unit tests only cover the inputs someone thought of. + +## Proposal + +cargo-fuzz targets for HTTP/1 requests, HTTP/2 frames, the TLS-to-kTLS handoff, `liam.toml`, and pack loading. Each target has a committed seed corpus. A nightly CI job runs each target for 30 minutes. Each crash becomes a bug item with a regression test. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] Five targets exist, each with a seed corpus. +- [ ] The nightly job runs them and keeps the corpus it grows. +- [ ] A planted panic in the HTTP/1 parser is found within the nightly run. diff --git a/cairn/items/0049-tls-and-http-2-benchmarks-against-nginx-and-caddy.md b/cairn/items/0049-tls-and-http-2-benchmarks-against-nginx-and-caddy.md new file mode 100644 index 0000000..71339a5 --- /dev/null +++ b/cairn/items/0049-tls-and-http-2-benchmarks-against-nginx-and-caddy.md @@ -0,0 +1,33 @@ +--- +id: 49 +uid: 5b8ee25b-3d2d-45fc-8e8e-5dc460015aa0 +title: TLS and HTTP/2 benchmarks against nginx and Caddy +type: feature +status: planned +milestone: v0.2 +depends_on: +- 37 +- 39 +created: 2026-09-28 +updated: 2026-09-28 +priority: p1 +area: bench +effort: m +--- + +## Problem + +A speed pitch needs comparisons people can rerun, on the same hardware, with the other side configured fairly. + +## Proposal + +`bench/compare` runs oha and h2load against liam, nginx and Caddy on one host for four cases: a small file, a large file, many assets over HTTP/2, and new TLS handshakes per second. The configs for the other servers are committed and tuned from their own docs. + +## Budget impact + +None. Informational. + +## Acceptance criteria + +- [ ] Results are in `bench/results` with the raw output and host metadata. +- [ ] The nginx and Caddy configs are committed, with the source of each tuning setting. diff --git a/cairn/items/0050-serve-the-project-site-with-liam.md b/cairn/items/0050-serve-the-project-site-with-liam.md new file mode 100644 index 0000000..9e8bf1f --- /dev/null +++ b/cairn/items/0050-serve-the-project-site-with-liam.md @@ -0,0 +1,34 @@ +--- +id: 50 +uid: 20843469-eaa7-4c87-a49a-485a7982d67d +title: Serve the project site with liam +type: feature +status: planned +milestone: v0.2 +depends_on: +- 35 +- 38 +- 44 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +area: site +effort: s +--- + +## Problem + +The best evidence that liam works is the project's own site running on it. + +## Proposal + +Build `site/` with liam into an OCI image and deploy it wherever is cheapest. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] The public site is served by liam over HTTPS and HTTP/2. +- [ ] Its footer says which liam version serves it. diff --git a/cairn/items/0051-what-zero-dropped-connections-means.md b/cairn/items/0051-what-zero-dropped-connections-means.md new file mode 100644 index 0000000..e4f8c8f --- /dev/null +++ b/cairn/items/0051-what-zero-dropped-connections-means.md @@ -0,0 +1,39 @@ +--- +id: 51 +uid: 64ea6b4a-0844-4c4f-a300-7150fcf191eb +title: What zero dropped connections means +type: decision +status: planned +milestone: v0.3 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: docs +effort: s +budget: +- zero-drop +--- + +## Context + +The landing page promises "0 dropped connections on config reload, binary upgrade, or a liamd crash". Two parts of that cannot be true as written: + +- A crashed process loses the requests it was serving. +- An immutable image has no in-place binary upgrade. A new image means a new VM or container. + +## Options and tradeoffs + +1. Keep the claim and add in-place upgrades by pushing binaries to a running instance. This breaks immutability, which is the stability story. +2. Narrow the claim to what can be proven: no failed requests across reload and SIGTERM drain, and no refused connections across a liamd crash. Leave instance replacement to the load balancer. + +## Decision + +Option 2. The `zero-drop` budget states it that way, and the site copy changes to match. + +## Revisit when + +liam gains a supported way to change a running instance's pack, such as a pack swap in the reload spike. + +## Acceptance criteria + +- [ ] The budget item and the site copy match this decision. diff --git a/cairn/items/0052-liam-init-owns-the-listening-sockets.md b/cairn/items/0052-liam-init-owns-the-listening-sockets.md new file mode 100644 index 0000000..6555485 --- /dev/null +++ b/cairn/items/0052-liam-init-owns-the-listening-sockets.md @@ -0,0 +1,36 @@ +--- +id: 52 +uid: 950358fd-2d10-4598-ad9f-f44679193ecf +title: liam-init owns the listening sockets +type: feature +status: planned +milestone: v0.3 +depends_on: +- 17 +- 23 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: init +effort: m +budget: +- zero-drop +--- + +## Problem + +When liamd exits, its listening sockets close, and new connections are refused until it is back. + +## Proposal + +liam-init binds every listener with `SO_REUSEPORT`, one per core. It passes them to liamd by inheritance, using `LISTEN_FDS`-style environment variables, and keeps its own copies open. While liamd restarts, the kernel queues new connections in the accept backlog. + +## Budget impact + +`zero-drop` for a crash. + +## Acceptance criteria + +- [ ] `kill -9` of liamd under a steady 10k req/s load produces zero refused connections. +- [ ] A test shows the backlog is sized from `somaxconn` and a config key. +- [ ] liamd started without inherited fds still binds its own, for development. diff --git a/cairn/items/0053-what-does-reload-mean-for-an-immutable-image.md b/cairn/items/0053-what-does-reload-mean-for-an-immutable-image.md new file mode 100644 index 0000000..a62179c --- /dev/null +++ b/cairn/items/0053-what-does-reload-mean-for-an-immutable-image.md @@ -0,0 +1,43 @@ +--- +id: 53 +uid: a973fb3b-afe7-4560-b649-ed2cfc1f2634 +title: What does reload mean for an immutable image? +type: spike +status: planned +milestone: v0.3 +depends_on: +- 20 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: config +effort: s +budget: +- zero-drop +--- + +## Question + +The config and site are baked into the image. What changes at runtime that a reload must pick up, and where does it come from? Candidates: + +1. Certificates and secrets rotated in mounted files, which ACME renewal needs anyway. +2. `LIAM_*` environment overrides. These cannot change in a running process, so probably not. +3. A new pack and config pushed over the admin socket, for site updates in milliseconds without a reboot. + +## Why it blocks + +The reload feature and the zero-drop gate's reload case both depend on what reload means. + +## Timebox + +One day. + +## Approach + +Write down the operator stories for each candidate: Kubernetes secret rotation, ACME renewal, and a site deploy. Check candidate 3 against the immutability promise and the `zero-drop` decision. + +## Acceptance criteria + +- [ ] Each candidate is accepted or rejected, with the reason. +- [ ] The reload item's proposal is rewritten to match. +- [ ] Closed with `--result`. diff --git a/cairn/items/0054-reload-without-a-restart.md b/cairn/items/0054-reload-without-a-restart.md new file mode 100644 index 0000000..b7d30eb --- /dev/null +++ b/cairn/items/0054-reload-without-a-restart.md @@ -0,0 +1,36 @@ +--- +id: 54 +uid: ea81c6c5-9e4b-4281-bfa2-b2462554acd2 +title: Reload without a restart +type: feature +status: planned +milestone: v0.3 +depends_on: +- 40 +- 53 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: config +effort: m +budget: +- zero-drop +--- + +## Problem + +Rotating a certificate or secret should not cost a restart. + +## Proposal + +A reload is triggered by SIGHUP or the admin socket, for the sources the reload spike accepted. The new state is compiled and validated, then swapped in atomically per core (arc-swap). In-flight requests finish on the old state. A reload that fails validation keeps the old state and logs the error. + +## Budget impact + +`zero-drop` for reload. + +## Acceptance criteria + +- [ ] Reloading a rotated certificate under load fails no requests. +- [ ] An invalid new config is rejected, the old one keeps serving, and the error is logged in the build error format. +- [ ] The swap adds no allocation to the hot path. diff --git a/cairn/items/0055-reverse-proxy-to-http-1-1-upstreams.md b/cairn/items/0055-reverse-proxy-to-http-1-1-upstreams.md new file mode 100644 index 0000000..daaa778 --- /dev/null +++ b/cairn/items/0055-reverse-proxy-to-http-1-1-upstreams.md @@ -0,0 +1,41 @@ +--- +id: 55 +uid: 2d4b76ab-d44b-4fc9-9060-b3488e479a5a +title: Reverse proxy to HTTP/1.1 upstreams +type: feature +status: planned +milestone: v0.3 +depends_on: +- 18 +- 40 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: proxy +effort: l +--- + +## Problem + +Most sites have an API or an app server behind them. + +## Proposal + +`proxy = "<upstream>"` on a route forwards the request. + +- Each core keeps its own keep-alive pool per upstream. +- Connect and response timeouts are separate. +- A request is retried only when it is idempotent and nothing has been sent upstream yet. +- `X-Forwarded-For`, `-Proto` and `-Host` are set, and `Host` is preserved by default. +- Bodies stream in both directions with backpressure, capped by `body_bytes`. + +## Budget impact + +None directly. Proxy requests are not on the static path. + +## Acceptance criteria + +- [ ] An upstream that dies mid-response gives the client a clean 502 without leaking the connection. +- [ ] A 1 GB upload streams through with bounded memory, measured. +- [ ] Retry rules are tested for GET, for POST, and after partial send. +- [ ] Pooled connections are reused, shown by the upstream's connection count under load. diff --git a/cairn/items/0056-websocket-and-upgrade-passthrough.md b/cairn/items/0056-websocket-and-upgrade-passthrough.md new file mode 100644 index 0000000..f0aa33e --- /dev/null +++ b/cairn/items/0056-websocket-and-upgrade-passthrough.md @@ -0,0 +1,32 @@ +--- +id: 56 +uid: 450d1708-7919-4385-b518-f4f30879205f +title: WebSocket and upgrade passthrough +type: feature +status: planned +milestone: v0.3 +depends_on: +- 55 +created: 2026-09-28 +updated: 2026-09-28 +priority: p1 +area: proxy +effort: m +--- + +## Problem + +Apps behind a proxy expect WebSockets to work. + +## Proposal + +On `Upgrade: websocket` for a proxied route, forward the handshake. After 101 Switching Protocols, splice bytes in both directions until either side closes, with an idle timeout. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] An echo WebSocket app behind liam passes the Autobahn client suite's framing tests. +- [ ] An idle upgraded connection closes at the configured timeout. diff --git a/cairn/items/0057-supervise-one-app-process.md b/cairn/items/0057-supervise-one-app-process.md new file mode 100644 index 0000000..e93e0cc --- /dev/null +++ b/cairn/items/0057-supervise-one-app-process.md @@ -0,0 +1,40 @@ +--- +id: 57 +uid: 45d255b6-2cc7-4776-8ca5-4846e9cc39cd +title: Supervise one app process +type: feature +status: planned +milestone: v0.3 +depends_on: +- 23 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: init +effort: m +--- + +## Problem + +Most dynamic sites are one app server. Running a second container just to supervise it undoes liam's point. + +## Proposal + +The `[app]` section takes `exec`, `args`, `env`, `user`, `workdir` and `port`, plus `include` paths that `liam build` copies into the image. liam-init: + +- starts the app and routes to it only once a TCP connect to its port succeeds; +- restarts it with exponential backoff if it exits; +- tags its stdout and stderr lines as `source=app` in the logs; +- forwards SIGTERM with a grace period. + +The app must be a self-contained executable, because liam provides no language runtimes. + +## Budget impact + +None. The app's own startup is outside the budgets. + +## Acceptance criteria + +- [ ] A static Go binary and a bundled Node single-executable app each run and serve through a proxy route. +- [ ] An app that crashes in a loop backs off to a capped interval, and the reason appears in the logs. +- [ ] Requests to the app before it is ready get 503 with `Retry-After`, not a connection error. diff --git a/cairn/items/0058-named-upstreams-with-several-addresses.md b/cairn/items/0058-named-upstreams-with-several-addresses.md new file mode 100644 index 0000000..4cba1aa --- /dev/null +++ b/cairn/items/0058-named-upstreams-with-several-addresses.md @@ -0,0 +1,32 @@ +--- +id: 58 +uid: 76dffa6f-c21b-4eb7-a828-fb191bb5ce90 +title: Named upstreams with several addresses +type: feature +status: planned +milestone: v0.3 +depends_on: +- 55 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +area: proxy +effort: m +--- + +## Problem + +Some apps run as several instances behind one name. + +## Proposal + +`[upstream.<name>]` takes a list of addresses, uses round-robin, and ejects an address that fails passively for a cooldown period. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] Load spreads evenly across three addresses. +- [ ] A dead address is ejected and later retried. diff --git a/cairn/items/0059-prometheus-metrics-on-an-admin-port.md b/cairn/items/0059-prometheus-metrics-on-an-admin-port.md new file mode 100644 index 0000000..2625784 --- /dev/null +++ b/cairn/items/0059-prometheus-metrics-on-an-admin-port.md @@ -0,0 +1,41 @@ +--- +id: 59 +uid: df2597c2-e26a-4c19-924a-a541c68ef657 +title: Prometheus metrics on an admin port +type: feature +status: planned +milestone: v0.3 +depends_on: +- 17 +created: 2026-09-28 +updated: 2026-09-28 +priority: p1 +area: ops +effort: m +--- + +## Problem + +Operators need rates, errors and latency without a shell. + +## Proposal + +Per-core counters are summed when scraped: + +- requests by status class; +- a latency histogram with fixed buckets; +- open connections and TLS handshakes; +- dropped log lines; +- boot phase timings. + +They are served on a separate admin port that is off by default and never on a public listener. + +## Budget impact + +`no-alloc`: counters are per-core atomics with no allocation. + +## Acceptance criteria + +- [ ] The metrics follow Prometheus naming conventions and pass `promtool check metrics`. +- [ ] Scraping under load does not move request p99, measured. +- [ ] The admin port cannot be set to a public listener's port. diff --git a/cairn/items/0060-zero-drop-gate-load-through-reload-drain-and-crash.md b/cairn/items/0060-zero-drop-gate-load-through-reload-drain-and-crash.md new file mode 100644 index 0000000..dbdaa90 --- /dev/null +++ b/cairn/items/0060-zero-drop-gate-load-through-reload-drain-and-crash.md @@ -0,0 +1,37 @@ +--- +id: 60 +uid: 2ec6b564-e1e5-4e26-a9c6-7577f79e5eac +title: 'Zero-drop gate: load through reload, drain and crash' +type: feature +status: planned +milestone: v0.3 +depends_on: +- 31 +- 45 +- 52 +- 54 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: bench +effort: m +budget: +- zero-drop +--- + +## Problem + +`zero-drop` is the budget most likely to regress quietly. + +## Proposal + +The harness drives a fixed-rate load with oha and, in turn, triggers a reload, sends SIGTERM, and sends SIGKILL to liamd. It counts failed requests and refused connections separately, and reads its thresholds from `budgets.toml`. + +## Budget impact + +`zero-drop`: this enforces it. + +## Acceptance criteria + +- [ ] The gate runs on every pull request. +- [ ] A scratch change that closes listeners on reload fails it. diff --git a/cairn/items/0061-can-the-io-layer-run-under-deterministic-simulation.md b/cairn/items/0061-can-the-io-layer-run-under-deterministic-simulation.md new file mode 100644 index 0000000..e86ba0b --- /dev/null +++ b/cairn/items/0061-can-the-io-layer-run-under-deterministic-simulation.md @@ -0,0 +1,37 @@ +--- +id: 61 +uid: ddb09c71-7bec-4b02-8126-b4665ca1ebb8 +title: Can the io layer run under deterministic simulation? +type: spike +status: planned +milestone: v0.3 +depends_on: +- 17 +created: 2026-09-28 +updated: 2026-09-28 +priority: p1 +area: test +effort: m +--- + +## Question + +Can `liam-io`'s reactor be swapped for a simulated one, with a controlled clock, network and disk, so a failing test replays exactly from a seed? + +## Why it blocks + +The v1.0 simulation-testing item. The stability promise names it. + +## Timebox + +Three days. + +## Approach + +Look at how turmoil and madsim approach it. Try putting the reactor behind a trait at the narrowest seam, and measure what the indirection costs in the release build (it should be zero with generics). Inject one fault: a peer that stalls mid-headers. + +## Acceptance criteria + +- [ ] A prototype replays one fault deterministically from a seed. +- [ ] The release-build overhead is measured. +- [ ] Closed with `--result`, saying go or no-go. diff --git a/cairn/items/0062-secrets-and-overrides-from-the-environment.md b/cairn/items/0062-secrets-and-overrides-from-the-environment.md new file mode 100644 index 0000000..442b8ce --- /dev/null +++ b/cairn/items/0062-secrets-and-overrides-from-the-environment.md @@ -0,0 +1,33 @@ +--- +id: 62 +uid: 70156628-15f6-4de0-9bcd-6ccc9973ef01 +title: Secrets and overrides from the environment +type: feature +status: planned +milestone: v0.3 +depends_on: +- 20 +created: 2026-09-28 +updated: 2026-09-28 +priority: p1 +area: config +effort: m +--- + +## Problem + +One image should run in staging and production, and secrets do not belong in images. + +## Proposal + +Any key can be overridden with `LIAM_<SECTION>_<KEY>`. Values can be `file:/path` references, read at start. Overrides are validated at start with the same error format as the build. + +## Budget impact + +`boot`: overrides are read once at start. Keep it cheap. + +## Acceptance criteria + +- [ ] An override and a `file:` reference each have a test. +- [ ] An invalid override fails start with the build error format, naming the variable. +- [ ] Secret values never appear in logs, including in errors. diff --git a/cairn/items/0063-a-b-slots-and-rollback-on-uefi-without-a-bootloader.md b/cairn/items/0063-a-b-slots-and-rollback-on-uefi-without-a-bootloader.md new file mode 100644 index 0000000..0199e0e --- /dev/null +++ b/cairn/items/0063-a-b-slots-and-rollback-on-uefi-without-a-bootloader.md @@ -0,0 +1,35 @@ +--- +id: 63 +uid: bfd0e33e-6dcb-4cd7-802c-9cf80ff95179 +title: A/B slots and rollback on UEFI without a bootloader +type: spike +status: planned +milestone: v0.4 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: deploy +effort: m +--- + +## Question + +Can a unified kernel image that firmware boots directly do A/B updates with automatic rollback, using EFI `BootNext`, `BootOrder` and a health confirmation? Or does it need a bootloader with boot counting, such as systemd-boot? + +## Why it blocks + +The UKI layout and A/B updates both depend on the answer. Firmware behaviour varies by vendor, so this is the likeliest place for v0.4 to slip. + +## Timebox + +Four days. + +## Approach + +Try it in QEMU with OVMF, on AWS Nitro, on GCP, and on one physical server. For each, check that `BootNext` survives exactly one boot, that EFI variables are writable from the running image, and what happens when the new slot hangs before confirming. + +## Acceptance criteria + +- [ ] A table of what works on each of the four platforms. +- [ ] A chosen mechanism, with its failure modes written down. +- [ ] Closed with `--result`. diff --git a/cairn/items/0064-unified-kernel-image-and-disk-layout.md b/cairn/items/0064-unified-kernel-image-and-disk-layout.md new file mode 100644 index 0000000..a23d53c --- /dev/null +++ b/cairn/items/0064-unified-kernel-image-and-disk-layout.md @@ -0,0 +1,39 @@ +--- +id: 64 +uid: 9cde3b56-9d90-495c-93ab-2ab2e6a9b221 +title: Unified kernel image and disk layout +type: feature +status: planned +milestone: v0.4 +depends_on: +- 22 +- 63 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: image +effort: l +budget: +- boot +--- + +## Problem + +Cloud VMs and physical servers boot from disks through UEFI, not from a VMM's direct kernel boot. + +## Proposal + +`liam build` for `targets = ["aws", "gcp", "metal"]` produces: + +- a `.efi` UKI containing the kernel, initramfs and command line; +- a GPT raw disk with an EFI system partition holding slots A and B, and a read-only pack partition per slot. + +## Budget impact + +`boot`: everything after firmware handoff is held to the same phases as Firecracker. + +## Acceptance criteria + +- [ ] The disk boots in QEMU with OVMF, on AWS x86_64, and on GCP. +- [ ] Two builds of the same input produce identical images. +- [ ] Time from firmware handoff to first byte is logged from kernel timestamps. diff --git a/cairn/items/0065-cloud-drivers-ena-gvnic-and-nvme.md b/cairn/items/0065-cloud-drivers-ena-gvnic-and-nvme.md new file mode 100644 index 0000000..7a53055 --- /dev/null +++ b/cairn/items/0065-cloud-drivers-ena-gvnic-and-nvme.md @@ -0,0 +1,33 @@ +--- +id: 65 +uid: 7170ed34-8b91-4418-ad10-1bc89a48ad5d +title: 'Cloud drivers: ENA, gVNIC and NVMe' +type: feature +status: planned +milestone: v0.4 +depends_on: +- 22 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: kernel +effort: m +--- + +## Problem + +Cloud VMs need their NIC and disk drivers. Each one added costs boot time and size. + +## Proposal + +Build in AWS ENA, Google gVNIC and NVMe, and nothing else from the cloud driver list. Measure what each adds to boot. + +## Budget impact + +`boot`, `size`. + +## Acceptance criteria + +- [ ] Boots with networking on AWS Nitro and GCP. +- [ ] The boot cost of each driver is recorded in the kernel config comments. +- [ ] The Firecracker kernel is unaffected. A separate config fragment is fine. diff --git a/cairn/items/0066-aarch64-kernel-and-binaries.md b/cairn/items/0066-aarch64-kernel-and-binaries.md new file mode 100644 index 0000000..6b798ed --- /dev/null +++ b/cairn/items/0066-aarch64-kernel-and-binaries.md @@ -0,0 +1,35 @@ +--- +id: 66 +uid: 1ec00da7-fefc-48a6-9cde-68271e76e8de +title: aarch64 kernel and binaries +type: feature +status: planned +milestone: v0.4 +depends_on: +- 22 +- 28 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: kernel +effort: l +--- + +## Problem + +Graviton and Ampere machines are often the cheapest way to serve. + +## Proposal + +Cross-build the kernel, liam-init and liamd for aarch64. Boot on Firecracker on Graviton bare metal, and as a UKI on Graviton EC2. Releases carry both architectures, and OCI output becomes a multi-arch index. + +## Budget impact + +`boot`, `size`, `listen`: every gate runs on aarch64 too. + +## Acceptance criteria + +- [ ] Firecracker on a Graviton metal instance serves the example site. +- [ ] A Graviton EC2 instance boots the UKI. +- [ ] The OCI index holds amd64 and arm64, and `docker run` picks the right one. +- [ ] The budget gates run on aarch64. diff --git a/cairn/items/0067-what-does-liam-deploy-do-on-aws-and-gcp.md b/cairn/items/0067-what-does-liam-deploy-do-on-aws-and-gcp.md new file mode 100644 index 0000000..81e9eb4 --- /dev/null +++ b/cairn/items/0067-what-does-liam-deploy-do-on-aws-and-gcp.md @@ -0,0 +1,39 @@ +--- +id: 67 +uid: fef11ed2-540f-4378-a3e0-558965b615b4 +title: What does liam deploy do on AWS and GCP? +type: spike +status: planned +milestone: v0.4 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: deploy +effort: m +--- + +## Question + +What is the fastest reliable path from a built disk to a running instance on each cloud? Consider: + +- image import: EBS direct APIs such as coldsnap, the AWS VM import service, or GCP image create from a tar.gz; +- credentials: the SDK default chains; +- updates: replace the instance, or write the idle slot of a running one. + +## Why it blocks + +Both deploy targets, and how A/B updates are triggered. + +## Timebox + +Three days. + +## Approach + +Time each import path end to end with a 50 MB image. Write down which IAM permissions each needs. Prototype the idle-slot write over the admin socket against replacing the instance. + +## Acceptance criteria + +- [ ] Timings and required permissions per path and per cloud. +- [ ] The deploy items are rewritten to match. +- [ ] Closed with `--result`. diff --git a/cairn/items/0068-hardware-and-virtio-watchdog.md b/cairn/items/0068-hardware-and-virtio-watchdog.md new file mode 100644 index 0000000..fe53c49 --- /dev/null +++ b/cairn/items/0068-hardware-and-virtio-watchdog.md @@ -0,0 +1,32 @@ +--- +id: 68 +uid: 8a95620c-5cfc-4bdd-b2e8-0469ef9a91dd +title: Hardware and virtio watchdog +type: feature +status: planned +milestone: v0.4 +depends_on: +- 23 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: ops +effort: m +--- + +## Problem + +A hung machine with no shell can only be fixed by rebooting it, and something has to notice the hang. + +## Proposal + +liam-init opens `/dev/watchdog` when present and pets it only while liamd's workers report progress. Boot takes milliseconds, so a reboot is a cheap recovery. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] In QEMU with i6300esb, a deliberately wedged liamd causes a reboot within the watchdog timeout. +- [ ] A clean shutdown disarms the watchdog. diff --git a/cairn/items/0069-a-b-update-with-health-gated-rollback.md b/cairn/items/0069-a-b-update-with-health-gated-rollback.md new file mode 100644 index 0000000..be58fba --- /dev/null +++ b/cairn/items/0069-a-b-update-with-health-gated-rollback.md @@ -0,0 +1,35 @@ +--- +id: 69 +uid: 96f2e913-06c1-4521-9c71-a77d4bf1604c +title: A/B update with health-gated rollback +type: feature +status: planned +milestone: v0.4 +depends_on: +- 63 +- 64 +- 68 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: deploy +effort: l +--- + +## Problem + +A bad image on a VM must undo itself without a person logging in, because nobody can log in. + +## Proposal + +The new image is written to the idle slot and booted once with `BootNext`. liam-init confirms the slot once `/healthz` passes N times within T seconds, by making it the permanent boot entry. If there is no confirmation, the watchdog or the timeout reboots into the old slot. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] In QEMU and on AWS, an image whose health check fails returns to the old slot without anyone acting. +- [ ] An image that hangs before liam-init starts also returns, through the watchdog. +- [ ] The slot history is visible in logs after rollback. diff --git a/cairn/items/0070-liam-deploy-target-aws.md b/cairn/items/0070-liam-deploy-target-aws.md new file mode 100644 index 0000000..c1a1197 --- /dev/null +++ b/cairn/items/0070-liam-deploy-target-aws.md @@ -0,0 +1,35 @@ +--- +id: 70 +uid: e05009ba-52fe-4dca-a929-3d466e4efc19 +title: liam deploy --target aws +type: feature +status: planned +milestone: v0.4 +depends_on: +- 64 +- 67 +- 69 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: deploy +effort: l +--- + +## Problem + +Getting a custom image onto EC2 by hand takes a dozen steps. It should take one. + +## Proposal + +As the deploy spike decided: upload the image, register it, and either launch or update instances. The update goes through the A/B path, and the command waits for the health confirmation before reporting success. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] A fresh AWS account with the documented IAM policy deploys the example site in one command. +- [ ] A deploy whose `/healthz` fails rolls back and reports it. +- [ ] Credentials come only from the standard AWS chain. diff --git a/cairn/items/0071-liam-deploy-target-gcp.md b/cairn/items/0071-liam-deploy-target-gcp.md new file mode 100644 index 0000000..321f9fc --- /dev/null +++ b/cairn/items/0071-liam-deploy-target-gcp.md @@ -0,0 +1,34 @@ +--- +id: 71 +uid: 637a9c16-fd81-4f29-a40d-752b4975aa09 +title: liam deploy --target gcp +type: feature +status: planned +milestone: v0.4 +depends_on: +- 64 +- 67 +- 69 +created: 2026-09-28 +updated: 2026-09-28 +priority: p1 +area: deploy +effort: l +--- + +## Problem + +The same as AWS, for Google Cloud. + +## Proposal + +The GCP path from the deploy spike: image create, instance create or update, and the same A/B and health flow. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] One command deploys the example site to GCP. +- [ ] A failing health check rolls back. diff --git a/cairn/items/0072-acme-without-waiting-at-boot.md b/cairn/items/0072-acme-without-waiting-at-boot.md new file mode 100644 index 0000000..16fa3c1 --- /dev/null +++ b/cairn/items/0072-acme-without-waiting-at-boot.md @@ -0,0 +1,40 @@ +--- +id: 72 +uid: f4d28bd8-dd3a-4537-ae96-1368e7c0fd35 +title: ACME without waiting at boot +type: spike +status: planned +milestone: v0.4 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: tls +effort: s +--- + +## Question + +Where are ACME certificates issued, and where are they kept, in an image with no writable persistent storage? Options: + +- TLS-ALPN-01 answered by liamd itself; +- DNS-01 through a provider API at deploy time; +- a cache on a small writable partition, or in the cloud's secret store. + +Boot must never wait on the network. + +## Why it blocks + +The ACME feature. + +## Timebox + +Two days. + +## Approach + +Walk through first deploy, renewal, and cold boot after renewal for each option, with Let's Encrypt staging. + +## Acceptance criteria + +- [ ] The chosen issuance and storage path, and why the others were rejected. +- [ ] Closed with `--result`. diff --git a/cairn/items/0073-acme-certificates-with-background-renewal.md b/cairn/items/0073-acme-certificates-with-background-renewal.md new file mode 100644 index 0000000..f7c821e --- /dev/null +++ b/cairn/items/0073-acme-certificates-with-background-renewal.md @@ -0,0 +1,34 @@ +--- +id: 73 +uid: fcacb2a9-a1be-46a2-9a74-723ffef384f6 +title: ACME certificates with background renewal +type: feature +status: planned +milestone: v0.4 +depends_on: +- 38 +- 72 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: tls +effort: l +--- + +## Problem + +Managing certificate files by hand is the thing people most want a server to do for them. + +## Proposal + +`[tls] acme = "letsencrypt"` issues through the path the ACME spike chose. Renewal starts 30 days before expiry, in the background, and the new certificate is swapped in through reload. A boot always uses the cached certificate. + +## Budget impact + +`boot`: no network wait. + +## Acceptance criteria + +- [ ] First issuance and renewal work against Let's Encrypt staging. +- [ ] Boot with a cached certificate makes no ACME call before serving. +- [ ] A failed renewal is retried with backoff and shows in metrics. diff --git a/cairn/items/0074-boot-measurements-on-cloud-vms-and-bare-metal.md b/cairn/items/0074-boot-measurements-on-cloud-vms-and-bare-metal.md new file mode 100644 index 0000000..bfd206d --- /dev/null +++ b/cairn/items/0074-boot-measurements-on-cloud-vms-and-bare-metal.md @@ -0,0 +1,34 @@ +--- +id: 74 +uid: 02462e81-ebdd-428c-bfa3-56262ef47a05 +title: Boot measurements on cloud VMs and bare metal +type: feature +status: planned +milestone: v0.4 +depends_on: +- 64 +created: 2026-09-28 +updated: 2026-09-28 +priority: p1 +area: bench +effort: m +budget: +- boot +--- + +## Problem + +Firmware takes seconds, and the site says so. What liam controls after firmware handoff should still be measured and published. + +## Proposal + +The harness reads kernel and liam-init timestamps from the serial console on AWS, GCP and one physical server. It reports firmware handoff → first byte separately from total wall time. + +## Budget impact + +`boot`: informational outside Firecracker. + +## Acceptance criteria + +- [ ] Numbers for three platforms, with instance types. +- [ ] Results are in `bench/results`. diff --git a/cairn/items/0075-bare-metal-install-from-a-disk-image-or-ipxe.md b/cairn/items/0075-bare-metal-install-from-a-disk-image-or-ipxe.md new file mode 100644 index 0000000..f7f5715 --- /dev/null +++ b/cairn/items/0075-bare-metal-install-from-a-disk-image-or-ipxe.md @@ -0,0 +1,32 @@ +--- +id: 75 +uid: d8aaaae0-d903-48e8-b5b7-f5445bba87f7 +title: Bare-metal install from a disk image or iPXE +type: feature +status: planned +milestone: v0.4 +depends_on: +- 64 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +area: deploy +effort: m +--- + +## Problem + +People with their own servers need a way to put liam on them. + +## Proposal + +Write the raw disk with `dd` or any imaging tool, or boot the UKI over HTTP through iPXE. Document one worked example. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] One physical x86_64 server boots from disk and serves the site. +- [ ] iPXE boot works in QEMU. diff --git a/cairn/items/0076-cloud-hypervisor-target.md b/cairn/items/0076-cloud-hypervisor-target.md new file mode 100644 index 0000000..204b35a --- /dev/null +++ b/cairn/items/0076-cloud-hypervisor-target.md @@ -0,0 +1,32 @@ +--- +id: 76 +uid: 4226d056-3038-4e5b-b81b-1e9352efd63d +title: Cloud Hypervisor target +type: feature +status: planned +milestone: v0.4 +depends_on: +- 25 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +area: image +effort: s +--- + +## Problem + +Cloud Hypervisor is the other common microVM monitor. + +## Proposal + +Emit a Cloud Hypervisor launch config next to the Firecracker one. The kernel boots the same way through PVH. + +## Budget impact + +`boot`: measured by the same harness. + +## Acceptance criteria + +- [ ] `cloud-hypervisor` boots the `.fc` artifacts and serves the site. +- [ ] Boot numbers are recorded. diff --git a/cairn/items/0077-liam-toml-format-1-versioned-and-frozen.md b/cairn/items/0077-liam-toml-format-1-versioned-and-frozen.md new file mode 100644 index 0000000..80a7ce2 --- /dev/null +++ b/cairn/items/0077-liam-toml-format-1-versioned-and-frozen.md @@ -0,0 +1,34 @@ +--- +id: 77 +uid: 114244c4-be07-4d5f-bcaa-a2dcc1c2f510 +title: 'liam.toml format 1: versioned and frozen' +type: feature +status: planned +milestone: v1.0 +depends_on: +- 20 +- 54 +- 62 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: config +effort: m +--- + +## Problem + +A config format people cannot rely on across upgrades is not stable, however fast the server is. + +## Proposal + +A `format = 1` key is required. The policy is published: within 1.x, keys are only added, never removed or given a new meaning. `liam check` warns on deprecated keys and names the replacement. A pre-1.0 config gets an error naming each key that changed and what it became. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] The compatibility policy is written down, and a test holds every 1.0 key's meaning fixed. +- [ ] Each 0.4 config in the fixtures either loads or gets a per-key upgrade message. diff --git a/cairn/items/0078-freeze-cli-commands-flags-and-exit-codes.md b/cairn/items/0078-freeze-cli-commands-flags-and-exit-codes.md new file mode 100644 index 0000000..34044ef --- /dev/null +++ b/cairn/items/0078-freeze-cli-commands-flags-and-exit-codes.md @@ -0,0 +1,28 @@ +--- +id: 78 +uid: d4550d21-1fb6-4781-88b3-28fc14f0c950 +title: Freeze CLI commands, flags and exit codes +type: chore +status: planned +milestone: v1.0 +depends_on: +- 21 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: cli +effort: s +--- + +## Purpose + +Scripts and CI pipelines call `liam`. Breaking them is breaking users. + +## Approach + +Review every command and flag, and remove or rename what is wrong now, before the freeze. Snapshot-test the `--help` output and the exit codes so a change shows up in review. + +## Acceptance criteria + +- [ ] `--help` snapshots cover every subcommand. +- [ ] Exit codes are listed in the `--help` footer and tested. diff --git a/cairn/items/0079-every-budget-gated-on-every-pull-request-on-reference-hardware.md b/cairn/items/0079-every-budget-gated-on-every-pull-request-on-reference-hardware.md new file mode 100644 index 0000000..30c6863 --- /dev/null +++ b/cairn/items/0079-every-budget-gated-on-every-pull-request-on-reference-hardware.md @@ -0,0 +1,37 @@ +--- +id: 79 +uid: 6175291f-21bb-4e76-89f3-416d928e2884 +title: Every budget gated on every pull request, on reference hardware +type: chore +status: planned +milestone: v1.0 +depends_on: +- 31 +- 32 +- 47 +- 60 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: bench +effort: m +budget: +- boot +- listen +- size +- zero-drop +- no-alloc +--- + +## Purpose + +The promise is that every published number is enforced. By v1.0, none may be measured only by hand. + +## Approach + +Run all five gates on the reference host chosen by the gate spike, for x86_64 and aarch64. Keep a history of results per commit on `main`. + +## Acceptance criteria + +- [ ] Five gates on two architectures run on every pull request. +- [ ] A history of results for `main` is kept and readable, for example as JSON published with the site. diff --git a/cairn/items/0080-publish-measured-numbers-on-the-site.md b/cairn/items/0080-publish-measured-numbers-on-the-site.md new file mode 100644 index 0000000..67ff3bf --- /dev/null +++ b/cairn/items/0080-publish-measured-numbers-on-the-site.md @@ -0,0 +1,33 @@ +--- +id: 80 +uid: 634d107f-a892-4e04-888e-05c183b90b6e +title: Publish measured numbers on the site +type: feature +status: planned +milestone: v1.0 +depends_on: +- 35 +- 79 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: site +effort: s +--- + +## Problem + +The site shows design budgets. At 1.0 it has to show measurements. + +## Proposal + +At site build time, read the latest gate results for `main` and render the measured p50 and p99 next to each budget, with the date, commit and hardware. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] Every budget tile shows a measured number and where it came from. +- [ ] The "budget, not a measurement" wording is gone wherever a measurement exists. diff --git a/cairn/items/0081-reproducible-signed-releases-with-an-sbom.md b/cairn/items/0081-reproducible-signed-releases-with-an-sbom.md new file mode 100644 index 0000000..ffa9dba --- /dev/null +++ b/cairn/items/0081-reproducible-signed-releases-with-an-sbom.md @@ -0,0 +1,30 @@ +--- +id: 81 +uid: 5bd69436-46fc-4fa0-90e3-b0a5bf30b9da +title: Reproducible, signed releases with an SBOM +type: chore +status: planned +milestone: v1.0 +depends_on: +- 27 +- 28 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: security +effort: m +--- + +## Purpose + +People running liam as their whole OS need to verify what they are running. + +## Approach + +Reproducible builds for the CLI, kernel, initramfs and OCI images. Keyless cosign signatures, SLSA provenance, and a CycloneDX SBOM covering the Rust crates and the kernel config. + +## Acceptance criteria + +- [ ] An independent rebuild from the tag matches the release checksums. +- [ ] `cosign verify` succeeds on every artifact, with the command documented. +- [ ] An SBOM is attached to each release. diff --git a/cairn/items/0082-security-policy-and-kernel-update-process.md b/cairn/items/0082-security-policy-and-kernel-update-process.md new file mode 100644 index 0000000..cba152d --- /dev/null +++ b/cairn/items/0082-security-policy-and-kernel-update-process.md @@ -0,0 +1,28 @@ +--- +id: 82 +uid: 3e41ebeb-611a-4f52-ac1d-fb2bc29d3b31 +title: Security policy and kernel update process +type: chore +status: planned +milestone: v1.0 +depends_on: +- 22 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: security +effort: s +--- + +## Purpose + +liam ships a kernel, so kernel CVEs are liam's to answer. + +## Approach + +A SECURITY.md with private reporting and a response target. An automated pull request bumps the kernel to each new LTS point release, and the budget gates run on it. A patch release goes out within 7 days of a kernel fix that is relevant to the config. + +## Acceptance criteria + +- [ ] SECURITY.md is published, and private reporting is enabled on the repository. +- [ ] The kernel bump bot opened at least one pull request that merged through the gates. diff --git a/cairn/items/0083-deterministic-simulation-tests-in-ci.md b/cairn/items/0083-deterministic-simulation-tests-in-ci.md new file mode 100644 index 0000000..ddeeeac --- /dev/null +++ b/cairn/items/0083-deterministic-simulation-tests-in-ci.md @@ -0,0 +1,29 @@ +--- +id: 83 +uid: b7f17f57-c85c-4a0c-adae-fd93beadf566 +title: Deterministic simulation tests in CI +type: chore +status: planned +milestone: v1.0 +depends_on: +- 61 +created: 2026-09-28 +updated: 2026-09-28 +priority: p1 +area: test +effort: l +--- + +## Purpose + +The stability section promises the event loop runs under simulation with injected faults. + +## Approach + +Follow the simulation spike's result. Build a seed-driven simulation for the io layer and the HTTP state machines with dropped packets, slow peers, full disks and clock jumps. CI runs a fixed seed set, and a nightly job runs random seeds. A failing seed becomes a bug item that includes the seed. + +## Acceptance criteria + +- [ ] Fixed seeds run in `scripts/task test`. +- [ ] Any failing seed reproduces locally with one command. +- [ ] If the spike said no-go, this item is dropped with the reason, and the site copy changes to match. diff --git a/cairn/items/0084-72-hour-soak-under-mixed-load.md b/cairn/items/0084-72-hour-soak-under-mixed-load.md new file mode 100644 index 0000000..d83a93e --- /dev/null +++ b/cairn/items/0084-72-hour-soak-under-mixed-load.md @@ -0,0 +1,31 @@ +--- +id: 84 +uid: 400658b6-9f2e-4dff-aff6-e395ac8fb68d +title: 72-hour soak under mixed load +type: chore +status: planned +milestone: v1.0 +depends_on: +- 44 +- 55 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: test +effort: m +--- + +## Purpose + +Leaks and slow degradation only show up over days. + +## Approach + +Run 72 hours of mixed static, TLS, HTTP/2, proxy and WebSocket load, with reloads every hour and a liamd kill every 6 hours. + +## Acceptance criteria + +- [ ] RSS grows by less than 5% after the first hour. +- [ ] Open fds at the end equal those at hour one. +- [ ] p99 in the last hour is within 10% of the second hour. +- [ ] The raw data is in `bench/results`. diff --git a/cairn/items/0085-reference-every-liam-toml-key-and-cli-command.md b/cairn/items/0085-reference-every-liam-toml-key-and-cli-command.md new file mode 100644 index 0000000..dbe8531 --- /dev/null +++ b/cairn/items/0085-reference-every-liam-toml-key-and-cli-command.md @@ -0,0 +1,29 @@ +--- +id: 85 +uid: 49de789e-2389-43e2-83d8-743fb54d767d +title: 'Reference: every liam.toml key and CLI command' +type: docs +status: planned +milestone: v1.0 +depends_on: +- 77 +- 78 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: docs +effort: m +--- + +## Reader and question + +Someone configuring liam asks what a key does, what its default is, and what happens at its limit. + +## Change + +A generated reference that the config schema and clap produce, so it cannot drift, with hand-written explanations for each section. + +## Acceptance criteria + +- [ ] Every key and flag appears, with its type, default and an example. +- [ ] CI fails if the generated reference is out of date. diff --git a/cairn/items/0086-guide-operating-liam-without-a-shell.md b/cairn/items/0086-guide-operating-liam-without-a-shell.md new file mode 100644 index 0000000..a4f8057 --- /dev/null +++ b/cairn/items/0086-guide-operating-liam-without-a-shell.md @@ -0,0 +1,28 @@ +--- +id: 86 +uid: 59d899f4-de11-4d5a-bc8c-47eb0e53f3e4 +title: 'Guide: operating liam without a shell' +type: docs +status: planned +milestone: v1.0 +depends_on: +- 33 +- 59 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: docs +effort: s +--- + +## Reader and question + +An operator used to `ssh` and `top` asks how to find out what is wrong with an instance. + +## Change + +A guide to what exists instead: logs, metrics, the admin port, boot phase timings, the watchdog, and rollback. It includes worked examples of four failures: a crash loop, a slow upstream, certificate expiry, and running out of memory. + +## Acceptance criteria + +- [ ] Each worked failure is reproduced with a real image, and its steps are copied from that run. diff --git a/cairn/items/0087-supported-platform-matrix-tested-for-every-release.md b/cairn/items/0087-supported-platform-matrix-tested-for-every-release.md new file mode 100644 index 0000000..24e4dcb --- /dev/null +++ b/cairn/items/0087-supported-platform-matrix-tested-for-every-release.md @@ -0,0 +1,31 @@ +--- +id: 87 +uid: 47fe5d23-315a-4373-bc96-eaeaecc28f40 +title: Supported platform matrix tested for every release +type: chore +status: planned +milestone: v1.0 +depends_on: +- 25 +- 44 +- 64 +- 66 +created: 2026-09-28 +updated: 2026-09-28 +priority: p0 +area: infra +effort: m +--- + +## Purpose + +"Runs on X" is only true if X is tested for each release. + +## Approach + +The release workflow boots and smoke-tests every target: OCI on amd64 and arm64, Firecracker on x86_64 and aarch64, QEMU with OVMF, AWS, and GCP. A release fails if any target fails. + +## Acceptance criteria + +- [ ] The matrix runs for the release candidate, with results attached to the release. +- [ ] The supported-platforms table on the site is generated from this matrix. diff --git a/cairn/items/0088-http-3-over-quic.md b/cairn/items/0088-http-3-over-quic.md new file mode 100644 index 0000000..b8efabf --- /dev/null +++ b/cairn/items/0088-http-3-over-quic.md @@ -0,0 +1,31 @@ +--- +id: 88 +uid: ed25532c-7410-489c-ad6f-fe235264c561 +title: HTTP/3 over QUIC +type: feature +status: backlog +milestone: later +depends_on: +- 39 +created: 2026-09-28 +updated: 2026-09-28 +priority: p1 +area: http +effort: xl +--- + +## Problem + +HTTP/3 improves latency for clients on lossy or mobile networks. It is also the most expensive protocol to serve well: QUIC runs in userspace, so there is no kTLS or sendfile. + +## Proposal + +quinn on the liam runtime, using UDP GSO and GRO, with `Alt-Svc` advertisement. Spike the runtime integration and the CPU cost per request first. + +## Budget impact + +`no-alloc` is unlikely to hold for QUIC. Decide whether the budget excludes it. + +## Acceptance criteria + +- [ ] Split into a spike and features before it is scheduled. diff --git a/cairn/items/0089-webassembly-request-handlers.md b/cairn/items/0089-webassembly-request-handlers.md new file mode 100644 index 0000000..65e3bd4 --- /dev/null +++ b/cairn/items/0089-webassembly-request-handlers.md @@ -0,0 +1,31 @@ +--- +id: 89 +uid: e3d14212-3097-412b-a5c0-0ec66bf1c309 +title: WebAssembly request handlers +type: feature +status: backlog +milestone: later +depends_on: +- 55 +created: 2026-09-28 +updated: 2026-09-28 +priority: p1 +area: proxy +effort: xl +--- + +## Problem + +Small pieces of dynamic logic (auth checks, rewrites, form handlers) don't justify an app process. + +## Proposal + +Handlers compiled ahead of time with wasmtime at `liam build`, running on the same core as the request, with a WASI HTTP interface and fuel and memory limits. + +## Budget impact + +`boot`, `size`: wasmtime is large. Measure it before committing. + +## Acceptance criteria + +- [ ] Split into a spike and features before it is scheduled. diff --git a/cairn/items/0090-response-cache-for-proxied-routes.md b/cairn/items/0090-response-cache-for-proxied-routes.md new file mode 100644 index 0000000..587e4a3 --- /dev/null +++ b/cairn/items/0090-response-cache-for-proxied-routes.md @@ -0,0 +1,31 @@ +--- +id: 90 +uid: a1576848-de4f-4510-b692-e8d26ad23b81 +title: Response cache for proxied routes +type: feature +status: backlog +milestone: later +depends_on: +- 55 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +area: proxy +effort: l +--- + +## Problem + +Many apps behind liam produce cacheable responses. + +## Proposal + +A per-core in-memory cache that honours `Cache-Control` and `Vary`, with a size bound and a purge on the admin socket. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] Split before it is scheduled. diff --git a/cairn/items/0091-opentelemetry-traces.md b/cairn/items/0091-opentelemetry-traces.md new file mode 100644 index 0000000..acb13eb --- /dev/null +++ b/cairn/items/0091-opentelemetry-traces.md @@ -0,0 +1,31 @@ +--- +id: 91 +uid: 903160e5-1baa-43d5-921b-37623d8f2dcc +title: OpenTelemetry traces +type: feature +status: backlog +milestone: later +depends_on: +- 59 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +area: ops +effort: m +--- + +## Problem + +Proxied requests cross services. Traces show where the time goes. + +## Proposal + +OTLP export with W3C trace-context propagation to upstreams, sampled, and off by default. + +## Budget impact + +`no-alloc` must hold when tracing is off. + +## Acceptance criteria + +- [ ] Split before it is scheduled. diff --git a/cairn/items/0092-zero-copy-send-with-io-uring-send-zc.md b/cairn/items/0092-zero-copy-send-with-io-uring-send-zc.md new file mode 100644 index 0000000..a99a680 --- /dev/null +++ b/cairn/items/0092-zero-copy-send-with-io-uring-send-zc.md @@ -0,0 +1,33 @@ +--- +id: 92 +uid: 4644c3fe-de2c-425b-9550-195d1eeb9588 +title: Zero-copy send with io_uring SEND_ZC +type: feature +status: backlog +milestone: later +depends_on: +- 17 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +area: io +effort: m +budget: +- no-alloc +--- + +## Problem + +Large proxied responses and non-kTLS paths still copy. + +## Proposal + +Use `IORING_OP_SEND_ZC` where the kernel supports it, and measure where it beats plain send. It usually wins only above a size threshold. + +## Budget impact + +`no-alloc`. + +## Acceptance criteria + +- [ ] Benchmark showing the crossover size. diff --git a/cairn/items/0093-xdp-pre-filter-for-connection-floods.md b/cairn/items/0093-xdp-pre-filter-for-connection-floods.md new file mode 100644 index 0000000..804a32d --- /dev/null +++ b/cairn/items/0093-xdp-pre-filter-for-connection-floods.md @@ -0,0 +1,29 @@ +--- +id: 93 +uid: 46773de0-344f-4b3f-8197-583089534c97 +title: XDP pre-filter for connection floods +type: feature +status: backlog +milestone: later +created: 2026-09-28 +updated: 2026-09-28 +priority: p3 +area: security +effort: l +--- + +## Problem + +A SYN or UDP flood reaches the kernel's TCP stack before liam can act. + +## Proposal + +An optional XDP program that drops traffic by rate per source before the stack sees it. + +## Budget impact + +`boot`: program load time. + +## Acceptance criteria + +- [ ] Split before it is scheduled. diff --git a/cairn/items/0094-debug-image-variant-with-a-shell.md b/cairn/items/0094-debug-image-variant-with-a-shell.md new file mode 100644 index 0000000..dd62d3c --- /dev/null +++ b/cairn/items/0094-debug-image-variant-with-a-shell.md @@ -0,0 +1,29 @@ +--- +id: 94 +uid: 61734e69-e7b0-427b-b060-934ba46ec4bf +title: Debug image variant with a shell +type: feature +status: backlog +milestone: later +created: 2026-09-28 +updated: 2026-09-28 +priority: p3 +area: image +effort: m +--- + +## Problem + +Some failures are hard to diagnose from logs and metrics alone. + +## Proposal + +`liam build --debug` adds a static busybox and a serial-console shell. It is marked in its name, its metrics and its boot banner, and refuses the production targets unless forced. + +## Budget impact + +None. It is never the gated image. + +## Acceptance criteria + +- [ ] Split before it is scheduled. diff --git a/cairn/items/0095-more-clouds-hetzner-digitalocean-azure.md b/cairn/items/0095-more-clouds-hetzner-digitalocean-azure.md new file mode 100644 index 0000000..2fef8c6 --- /dev/null +++ b/cairn/items/0095-more-clouds-hetzner-digitalocean-azure.md @@ -0,0 +1,31 @@ +--- +id: 95 +uid: 101b579e-019b-4173-8ce5-b3aaae93ecf4 +title: 'More clouds: Hetzner, DigitalOcean, Azure' +type: feature +status: backlog +milestone: later +depends_on: +- 70 +created: 2026-09-28 +updated: 2026-09-28 +priority: p3 +area: deploy +effort: m +--- + +## Problem + +AWS and GCP are not where everyone runs. + +## Proposal + +A deploy target per provider, following the AWS pattern. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] One item per provider before scheduling. diff --git a/cairn/items/0096-http-2-and-grpc-to-upstreams.md b/cairn/items/0096-http-2-and-grpc-to-upstreams.md new file mode 100644 index 0000000..52dbe67 --- /dev/null +++ b/cairn/items/0096-http-2-and-grpc-to-upstreams.md @@ -0,0 +1,32 @@ +--- +id: 96 +uid: 7a4d74c2-a827-4c29-a0c6-8263fe01f108 +title: HTTP/2 and gRPC to upstreams +type: feature +status: backlog +milestone: later +depends_on: +- 39 +- 55 +created: 2026-09-28 +updated: 2026-09-28 +priority: p2 +area: proxy +effort: l +--- + +## Problem + +gRPC services need HTTP/2 end to end. + +## Proposal + +HTTP/2 upstream connections with trailers passed through, and h2c for plaintext upstreams. + +## Budget impact + +None. + +## Acceptance criteria + +- [ ] Split before it is scheduled. diff --git a/cairn/roadmap-intro.md b/cairn/roadmap-intro.md new file mode 100644 index 0000000..ba23246 --- /dev/null +++ b/cairn/roadmap-intro.md @@ -0,0 +1,22 @@ +**liam 1.0** builds a `liam.toml` and a site directory into a container, microVM, or UEFI image that serves static and proxied traffic over HTTPS within published, measured budgets. + +**For** engineers who deploy websites and small services onto Firecracker hosts, Kubernetes, cloud VMs, or their own servers, and care how fast it boots and serves. + +**Not in 1.0:** + +- A general-purpose Linux. There is no shell, no SSH, and no package installs at runtime. You debug with logs, metrics, and a separate debug build. +- A language runtime manager or build system for your app. liam supervises one app process that you bring as a self-contained executable. +- A control plane. There is no fleet management, autoscaling, or dashboard. `liam deploy` puts an image on a target; scheduling belongs to something else. +- HTTP/3 and WebAssembly handlers. Both come after 1.0; see `later`. + +**Budgets**, each enforced by a CI gate (`cairn list -t budget`): + +| Budget | Promise | +|---|---| +| `boot` | Cold boot to first byte under 25 ms in a Firecracker microVM | +| `listen` | Exec to listening under 5 ms as a container | +| `size` | Base container image under 6 MB | +| `zero-drop` | No failed requests across reload and SIGTERM drain; no refused connections across a liamd crash | +| `no-alloc` | Zero heap allocations per request on the static hot path | + +Releases ship when their gate passes, not on a date.