| 1 |
# OxygenFlow M0: first pipeline on Earth |
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
| **Status** | Plan, 2026-09-28 | |
| 6 |
| **Implements** | a subset of `docs/spec.md` (Draft 0.1) | |
| 7 |
| **Goal** | A push to a repository on OxygenHub (Earth) runs `.flow/pipeline.kdl` in Firecracker microVMs, and the hub shows the run as a graph of jobs with live status and logs. | |
| 8 |
|
| 9 |
M0 is the smallest slice that exercises the real architecture: a typed |
| 10 |
pipeline compiled to a canonical form, host-side execution in the default |
| 11 |
isolation tier, receipts computed by the host, and results reported to the |
| 12 |
hub as checks. Everything M0 leaves out is listed at the end. |
| 13 |
|
| 14 |
## 1. Pipeline subset |
| 15 |
|
| 16 |
The file is `.flow/pipeline.kdl` in the state being built (KDL 2.0). M0 |
| 17 |
accepts: |
| 18 |
|
| 19 |
```kdl |
| 20 |
schema-version 1 |
| 21 |
|
| 22 |
sandbox "alpine" { |
| 23 |
runtime "microvm" // "microvm" (default) | "syscall"; "wasm" is refused in M0 |
| 24 |
rootfs "cas://blob/<64 hex>" // BLAKE3 of an ext4 image the runner holds |
| 25 |
memory-limit "1GiB" // KiB, MiB, GiB |
| 26 |
cpu-limit 2 |
| 27 |
network "isolated" // "isolated" or "loopback" in M0 |
| 28 |
env { PATH "/opt/tool/bin:/usr/bin:/bin" } // every job's starting environment |
| 29 |
} |
| 30 |
|
| 31 |
job "lint" { |
| 32 |
sandbox "alpine" |
| 33 |
scope "src/**" "README.md" // recorded; the job runs on every push in M0 |
| 34 |
env { LANG "C.UTF-8" } |
| 35 |
exec "grep" "-rn" "TODO" "src" |
| 36 |
} |
| 37 |
|
| 38 |
job "test" { |
| 39 |
sandbox "alpine" |
| 40 |
depends-on "lint" |
| 41 |
exec "sh" "-eu" "test.sh" |
| 42 |
} |
| 43 |
``` |
| 44 |
|
| 45 |
Rules, all checked by `flow check` and again by the hub before anything |
| 46 |
runs (spec §2.2 I2, §3.2): |
| 47 |
|
| 48 |
- Unknown nodes, arguments or properties are errors, with the line. |
| 49 |
- Every string stays a string; `cpu-limit` is an integer from 1 to 16; |
| 50 |
`memory-limit` parses to 64 MiB to 16 GiB. |
| 51 |
- A job names a declared sandbox, has a non-empty `exec` of literal |
| 52 |
strings, and `depends-on` only declared jobs; the graph has no cycle. |
| 53 |
- Names are 1 to 64 characters from `[A-Za-z0-9._-]`. |
| 54 |
- `exec` becomes `argv` unchanged: metacharacters are bytes (spec §10.1). |
| 55 |
A job that wants a shell names one, as `sh` above; nothing adds one. |
| 56 |
- `publish` and a job's `attest` are recognised and refused with "not in |
| 57 |
M0", so a pipeline written for the full spec fails clearly. |
| 58 |
- `secret "token" { mount-path "/run/secrets/token" }` on a job (up to 8) |
| 59 |
names a secret the hub holds for the repository (its own, its |
| 60 |
organisation's or the site's; oxygenhub#107) and where the job reads it: |
| 61 |
a clean path under `/run/secrets/`. The claim carries each as `secrets` |
| 62 |
and `flow-init` writes it there, 0400 and the job's user's, on a tmpfs |
| 63 |
of its own, before the job starts; it is never in the environment, the |
| 64 |
receipt or an artifact (spec §9). Locally, `flow run --secret |
| 65 |
NAME=VALUE` supplies it. `vault-ref` is not in M0: the hub is the vault. |
| 66 |
- `identity "sts.amazonaws.com" { mount-path "/run/secrets/aws-token" }` on |
| 67 |
a job (up to 4) asks the hub for an OIDC token for that audience, naming |
| 68 |
the repository, bookmark, run and job (OxygenHub spec §13.5, |
| 69 |
oxygenhub#103); it comes with the claim and is mounted exactly as a |
| 70 |
secret is. A local run has no hub, so `flow run` says the file will be |
| 71 |
missing. |
| 72 |
- `output { artifact "oxy" { path "/src/target/release/oxy" } }` declares |
| 73 |
up to 16 **artifacts** (spec §5.2). The path is clean, absolute, under |
| 74 |
`/src`, and not on a tmpfs mount, which is gone when the job ends. After |
| 75 |
a job passes, the runner reads each artifact from the job's disks with |
| 76 |
`debugfs`: the workspace, or the `ephemeral://disk` mount holding it. An |
| 77 |
artifact is at most 512 MiB. The runner uploads each one with |
| 78 |
`POST /api/v1/flow/jobs/{id}/artifacts/{name}` (raw bytes). The hub keeps |
| 79 |
it as a blob of the repository, and the receipt's `outputs` name those |
| 80 |
blobs in artifact order. A declared artifact that cannot be kept fails |
| 81 |
the job, and the job gets no receipt. |
| 82 |
- `mount` is accepted for **ephemeral scratch** only (up to 4 per job): |
| 83 |
`mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`. |
| 84 |
`ephemeral://tmpfs` is memory in the guest and counts against its |
| 85 |
memory-limit; `ephemeral://disk` is a fresh sparse ext4 disk on the |
| 86 |
runner, attached after the workspace, for build trees larger than |
| 87 |
memory. The target is a clean absolute path strictly under `/src` or |
| 88 |
`/tmp`, mounts may not nest, sizes run from 16 MiB to 64 GiB, and any |
| 89 |
other source (`oxy://workspace` and the rest) or setting (`readonly`, |
| 90 |
`include`) is refused as not in M0. Both are empty when the job starts |
| 91 |
and deleted when it ends, so nothing passes between jobs. Mounts are |
| 92 |
part of the job's canonical form (field 6, present only when a job has |
| 93 |
mounts, so pipelines without them keep their hashes) and of the |
| 94 |
receipt's environment. |
| 95 |
|
| 96 |
- A sandbox's `env` is where each of its jobs' environments starts; a |
| 97 |
job's own `env` overrides it name by name. It is part of the sandbox's |
| 98 |
canonical form (field 6, present only when set). Below both is the |
| 99 |
image's own environment: `flow rootfs import --env NAME=value` |
| 100 |
(repeatable) writes `/etc/flow/env` into the image, for what a |
| 101 |
Dockerfile's `ENV` sets and `docker export` loses, such as a toolchain's |
| 102 |
`PATH`. It is part of the image, so of its hash. Under all three are |
| 103 |
`PATH` (the usual directories) and `HOME=/tmp`. |
| 104 |
|
| 105 |
The pipeline compiles to a canonical CBOR job graph (sorted keys, sandboxes |
| 106 |
and jobs in name order). Its BLAKE3 is the `pipeline_hash` used in receipts |
| 107 |
and shown by the hub. |
| 108 |
|
| 109 |
## 2. Tier 2: Firecracker (default) |
| 110 |
|
| 111 |
Per job the runner: |
| 112 |
|
| 113 |
1. Verifies the rootfs image: `BLAKE3(file)` must equal the pinned hash. |
| 114 |
Images live in `$FLOW_HOME/cas/<hash>.ext4`; an unknown hash fails the |
| 115 |
job ("rootfs not on this runner"). |
| 116 |
2. Materializes the state at its heads with `oxy` into a scratch |
| 117 |
directory (a clone at the state), removes `.oxy`, and builds a |
| 118 |
workspace image with `mkfs.ext4 -d` sized to the tree plus 512 MiB. |
| 119 |
3. Boots Firecracker with no network device (so `isolated` holds by |
| 120 |
construction; `loopback` only brings up the guest's `lo`, so tests can |
| 121 |
serve and connect on 127.0.0.1 while nothing leaves the guest), `vcpu_count` and `mem_size_mib` from the sandbox, the |
| 122 |
pinned guest kernel, the rootfs as a **read-only** `vda`, the |
| 123 |
workspace as a read-write `vdb`, and an initramfs holding `flow-init`. |
| 124 |
4. `flow-init` (a static binary, PID 1 in the guest) mounts `/proc`, |
| 125 |
`/sys`, `/dev`, the rootfs at `/newroot` read-only, `vdb` at |
| 126 |
`/newroot/src`, and tmpfs at `/newroot/tmp` and `/newroot/run`, then the |
| 127 |
job's scratch mounts (creating their mount points, owned by the job's |
| 128 |
user); it |
| 129 |
switches root, then runs `argv` (its program found in the guest's `PATH` by |
| 130 |
`flow-init` itself) with exactly the job's `env` (plus |
| 131 |
`PATH`, `HOME=/tmp`) in `/src` as uid 1000, with a job timeout. |
| 132 |
`/dev` gets the links devtmpfs lacks, `fd`, `stdin`, `stdout` and |
| 133 |
`stderr` into `/proc/self/fd`, so shell process substitution works. A job |
| 134 |
is told what it builds: `FLOW_REPO`, `FLOW_STATE` (the hub's state hash), |
| 135 |
`FLOW_HEADS` (full hashes joined with `+`), `FLOW_BOOKMARK`, `FLOW_RUN` |
| 136 |
(the run's number in its repository) and `FLOW_JOB`; `flow run` sets |
| 137 |
`FLOW_JOB` and `FLOW_RUN=local`. After them come the **variables** the |
| 138 |
hub holds for the repository (site, organisation, repository; the |
| 139 |
claim's `env`; `flow run --var NAME=VALUE` locally), never secrets. A |
| 140 |
variable the pipeline sets keeps its value. They are part of the job's |
| 141 |
environment, so of its receipt. |
| 142 |
5. `flow-init` frames the child's output on the serial console as |
| 143 |
`\x1eO <line>` (stdout) and `\x1eE <line>` (stderr), then |
| 144 |
`\x1eX <code>`, and powers off. The kernel runs `quiet` so the host sees |
| 145 |
only frames; anything else is kept as `console` records. |
| 146 |
6. The host reads frames, streams them as log records, and kills the VM |
| 147 |
at the timeout (the guest cannot extend it). Scratch files are deleted |
| 148 |
afterwards. Guest memory is released with the process. |
| 149 |
|
| 150 |
The guest kernel, `flow-init` and Firecracker itself are pinned by BLAKE3 |
| 151 |
in the runner's configuration; their hashes and the rootfs hash make the |
| 152 |
receipt's `environment` together with `argv` and `env`. |
| 153 |
|
| 154 |
## 3. Tier 1: syscall sandbox |
| 155 |
|
| 156 |
`runtime "syscall"`: a child in new user, mount, PID, network, IPC and |
| 157 |
UTS namespaces (no interfaces but loopback), the rootfs image's tree |
| 158 |
(unpacked once per hash, read-only) as `/`, the workspace directory at |
| 159 |
`/src`, tmpfs at `/tmp`; Landlock limits writes to `/src` and `/tmp`; a |
| 160 |
seccomp filter refuses `socket` for anything but `AF_UNIX`, plus |
| 161 |
`ptrace`, `mount`, `bpf`, `perf_event_open`, `keyctl` and module calls; |
| 162 |
`cpu-limit` and `memory-limit` apply through a cgroup v2 scope when the |
| 163 |
runner has a delegated cgroup, and the job fails to start otherwise |
| 164 |
rather than run unbounded. Same frames and receipts as tier 2. Done after |
| 165 |
tier 2 works on Earth. |
| 166 |
|
| 167 |
## 4. Receipts |
| 168 |
|
| 169 |
Every finished job gets a receipt (spec §6.1): `job_name`, |
| 170 |
`pipeline_hash`, `scope_tree_hash` (BLAKE3 over the sorted |
| 171 |
`(path, content hash)` of the files `scope` matches; the whole tree when |
| 172 |
there is no scope), `inputs` (empty in M0), `environment`, `outputs` |
| 173 |
(empty), `exit_code`, `duration_ms`. It is encoded as canonical CBOR and |
| 174 |
hashed as `BLAKE3("oxy1 receipt " || len || 0x00 || body)`, the `receipt` |
| 175 |
object of the Oxygen spec (§14.6). |
| 176 |
|
| 177 |
**Registering a runner.** |
| 178 |
- **Token:** a hub administrator creates a one-time registration token on Admin → Runners. |
| 179 |
- **Registering:** `flow runner register https://hub --token … [--name N] [--ca PATH] [--label L]…` sends the runner's signing identity, architecture, OS, version and labels (`POST /api/v1/flow/runners/register`). It gets back the runner's own credential and writes hub, credential, name, CA and labels to `runner.toml` (owner-only). That file stands in for a `[runner]` section `flow.toml` lacks, and `flow runner` then starts from it. |
| 180 |
- **Key:** registration binds the key; the hub accepts receipts from that runner signed by no other. |
| 181 |
- **Claims:** every claim carries the runner's description (`info`: architecture, OS, version, labels, capacity) for the Runners page. |
| 182 |
- **Old configuration:** runners configured with a `hub.toml` token still work. Their key is bound on their first signed receipt. |
| 183 |
|
| 184 |
**Signed receipts.** A runner keeps a composite key in its home |
| 185 |
(`runner.key`, owner-only, and `runner.identity`), made on first start and |
| 186 |
named after the runner. After each job it binds the receipt to the state of |
| 187 |
the heads it fetched (computed by the runner, not taken from the claim) and |
| 188 |
to the run's URL, signs that attestation, and sends the signed receipt with |
| 189 |
`finish` as `signed_receipt` (hex). The hub checks it and keeps it in the |
| 190 |
repository; anyone can then check it with `oxy receipt verify`. Cache reuse |
| 191 |
(§6.2) follows. |
| 192 |
|
| 193 |
## 5. Hub side (OxygenHub) |
| 194 |
|
| 195 |
- Configuration: `[flow] runner_tokens = [...]` and `repos = ["owner/*"]`, |
| 196 |
the repositories whose pushes may start runs. No proposals trigger runs |
| 197 |
in M0, so code from people without write access never runs. |
| 198 |
- On a push that moves a bookmark of an allowed repository, the hub reads |
| 199 |
`.flow/pipeline.kdl` from the new state, validates it with `flow-core`, |
| 200 |
and records a **run** (repository, bookmark, state address, pipeline |
| 201 |
hash, pusher) with one **job** per pipeline job. An invalid pipeline |
| 202 |
records a failed run with the error and its line. |
| 203 |
- Runner API, bearer runner token: |
| 204 |
- `POST /api/v1/flow/claim` → the oldest queued job whose dependencies |
| 205 |
passed, with its run's state heads, repository clone URL, the job's |
| 206 |
compiled form and its sandbox, and a **job token**: read access to that |
| 207 |
repository only, valid until the job finishes or 2 hours pass. `204` |
| 208 |
when there is nothing. |
| 209 |
- `POST /api/v1/flow/jobs/{id}/log` appends records `{t, stream, text}`. |
| 210 |
- `POST /api/v1/flow/jobs/{id}/finish` with the exit code, duration and |
| 211 |
receipt; a job whose dependency failed becomes `skipped`. |
| 212 |
- Each job is also a check named `flow/<job>` on the state (hub spec §13), |
| 213 |
so proposals, the status bar in VS Code and required checks see it. |
| 214 |
- Pages: a **Flow** tab with runs (status, bookmark, state, pusher, |
| 215 |
duration); a run page with the job graph drawn as SVG (columns by |
| 216 |
dependency depth, status by colour and glyph), each job's log folded |
| 217 |
by default and the failed one open, refreshed while the run is live. |
| 218 |
|
| 219 |
## 6. Earth |
| 220 |
|
| 221 |
Under `/home/claude/flow`: `bin/firecracker` (release binary, pinned), |
| 222 |
`kernel/vmlinux` (pinned), `cas/` (rootfs images), `work/` (scratch), and |
| 223 |
`runner.toml`. `flow runner` runs as the `flow-runner` systemd service |
| 224 |
under the `claude` user (member of `kvm`), polling the hub at |
| 225 |
`https://earth.andymccall.xyz:7343`. |
| 226 |
|
| 227 |
## 7. Not in M0 |
| 228 |
|
| 229 |
WASM tier; FastCDC-backed rootfs and `casfs` mounts (M0 copies the tree |
| 230 |
into an image); mounts other than ephemeral scratch; artifacts as inputs of later jobs; receipt reuse; provenance |
| 231 |
attestations; policy gates; secrets; proposal |
| 232 |
runs; scope-based skipping; re-run and cancel from the hub. |