| 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" // M0 accepts only "isolated" |
| 28 |
} |
| 29 |
|
| 30 |
job "lint" { |
| 31 |
sandbox "alpine" |
| 32 |
scope "src/**" "README.md" // recorded; the job runs on every push in M0 |
| 33 |
env { LANG "C.UTF-8" } |
| 34 |
exec "grep" "-rn" "TODO" "src" |
| 35 |
} |
| 36 |
|
| 37 |
job "test" { |
| 38 |
sandbox "alpine" |
| 39 |
depends-on "lint" |
| 40 |
exec "sh" "-eu" "test.sh" |
| 41 |
} |
| 42 |
``` |
| 43 |
|
| 44 |
Rules, all checked by `flow check` and again by the hub before anything |
| 45 |
runs (spec §2.2 I2, §3.2): |
| 46 |
|
| 47 |
- Unknown nodes, arguments or properties are errors, with the line. |
| 48 |
- Every string stays a string; `cpu-limit` is an integer from 1 to 16; |
| 49 |
`memory-limit` parses to 64 MiB to 16 GiB. |
| 50 |
- A job names a declared sandbox, has a non-empty `exec` of literal |
| 51 |
strings, and `depends-on` only declared jobs; the graph has no cycle. |
| 52 |
- Names are 1 to 64 characters from `[A-Za-z0-9._-]`. |
| 53 |
- `exec` becomes `argv` unchanged: metacharacters are bytes (spec §10.1). |
| 54 |
A job that wants a shell names one, as `sh` above; nothing adds one. |
| 55 |
- `output`, `publish` and `secret` are recognised and refused with |
| 56 |
"not in M0", so a pipeline written for the full spec fails clearly. |
| 57 |
- `mount` is accepted for **ephemeral scratch** only (up to 4 per job): |
| 58 |
`mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`. |
| 59 |
`ephemeral://tmpfs` is memory in the guest and counts against its |
| 60 |
memory-limit; `ephemeral://disk` is a fresh sparse ext4 disk on the |
| 61 |
runner, attached after the workspace, for build trees larger than |
| 62 |
memory. The target is a clean absolute path strictly under `/src` or |
| 63 |
`/tmp`, mounts may not nest, sizes run from 16 MiB to 64 GiB, and any |
| 64 |
other source (`oxy://workspace` and the rest) or setting (`readonly`, |
| 65 |
`include`) is refused as not in M0. Both are empty when the job starts |
| 66 |
and deleted when it ends, so nothing passes between jobs. Mounts are |
| 67 |
part of the job's canonical form (field 6, present only when a job has |
| 68 |
mounts, so pipelines without them keep their hashes) and of the |
| 69 |
receipt's environment. |
| 70 |
|
| 71 |
The pipeline compiles to a canonical CBOR job graph (sorted keys, sandboxes |
| 72 |
and jobs in name order). Its BLAKE3 is the `pipeline_hash` used in receipts |
| 73 |
and shown by the hub. |
| 74 |
|
| 75 |
## 2. Tier 2: Firecracker (default) |
| 76 |
|
| 77 |
Per job the runner: |
| 78 |
|
| 79 |
1. Verifies the rootfs image: `BLAKE3(file)` must equal the pinned hash. |
| 80 |
Images live in `$FLOW_HOME/cas/<hash>.ext4`; an unknown hash fails the |
| 81 |
job ("rootfs not on this runner"). |
| 82 |
2. Materializes the state at its heads with `oxy` into a scratch |
| 83 |
directory (a clone at the state), removes `.oxy`, and builds a |
| 84 |
workspace image with `mkfs.ext4 -d` sized to the tree plus 512 MiB. |
| 85 |
3. Boots Firecracker with no network device (so `isolated` holds by |
| 86 |
construction), `vcpu_count` and `mem_size_mib` from the sandbox, the |
| 87 |
pinned guest kernel, the rootfs as a **read-only** `vda`, the |
| 88 |
workspace as a read-write `vdb`, and an initramfs holding `flow-init`. |
| 89 |
4. `flow-init` (a static binary, PID 1 in the guest) mounts `/proc`, |
| 90 |
`/sys`, `/dev`, the rootfs at `/newroot` read-only, `vdb` at |
| 91 |
`/newroot/src`, and tmpfs at `/newroot/tmp` and `/newroot/run`, then the |
| 92 |
job's scratch mounts (creating their mount points, owned by the job's |
| 93 |
user); it |
| 94 |
switches root, then runs `argv` with exactly the job's `env` (plus |
| 95 |
`PATH`, `HOME=/tmp`) in `/src` as uid 1000, with a job timeout. |
| 96 |
5. `flow-init` frames the child's output on the serial console as |
| 97 |
`\x1eO <line>` (stdout) and `\x1eE <line>` (stderr), then |
| 98 |
`\x1eX <code>`, and powers off. The kernel runs `quiet` so the host sees |
| 99 |
only frames; anything else is kept as `console` records. |
| 100 |
6. The host reads frames, streams them as log records, and kills the VM |
| 101 |
at the timeout (the guest cannot extend it). Scratch files are deleted |
| 102 |
afterwards. Guest memory is released with the process. |
| 103 |
|
| 104 |
The guest kernel, `flow-init` and Firecracker itself are pinned by BLAKE3 |
| 105 |
in the runner's configuration; their hashes and the rootfs hash make the |
| 106 |
receipt's `environment` together with `argv` and `env`. |
| 107 |
|
| 108 |
## 3. Tier 1: syscall sandbox |
| 109 |
|
| 110 |
`runtime "syscall"`: a child in new user, mount, PID, network, IPC and |
| 111 |
UTS namespaces (no interfaces but loopback), the rootfs image's tree |
| 112 |
(unpacked once per hash, read-only) as `/`, the workspace directory at |
| 113 |
`/src`, tmpfs at `/tmp`; Landlock limits writes to `/src` and `/tmp`; a |
| 114 |
seccomp filter refuses `socket` for anything but `AF_UNIX`, plus |
| 115 |
`ptrace`, `mount`, `bpf`, `perf_event_open`, `keyctl` and module calls; |
| 116 |
`cpu-limit` and `memory-limit` apply through a cgroup v2 scope when the |
| 117 |
runner has a delegated cgroup, and the job fails to start otherwise |
| 118 |
rather than run unbounded. Same frames and receipts as tier 2. Done after |
| 119 |
tier 2 works on Earth. |
| 120 |
|
| 121 |
## 4. Receipts |
| 122 |
|
| 123 |
Every finished job gets a receipt (spec §6.1): `job_name`, |
| 124 |
`pipeline_hash`, `scope_tree_hash` (BLAKE3 over the sorted |
| 125 |
`(path, content hash)` of the files `scope` matches; the whole tree when |
| 126 |
there is no scope), `inputs` (empty in M0), `environment`, `outputs` |
| 127 |
(empty), `exit_code`, `duration_ms`. It is encoded as canonical CBOR and |
| 128 |
hashed as `BLAKE3("oxy1 receipt " || len || 0x00 || body)`. M0 stores it on |
| 129 |
the hub with the job; storing receipts as Oxygen objects needs the |
| 130 |
`receipt` object type in the Oxygen spec, and cache reuse (§6.2) follows. |
| 131 |
|
| 132 |
## 5. Hub side (OxygenHub) |
| 133 |
|
| 134 |
- Configuration: `[flow] runner_tokens = [...]` and `repos = ["owner/*"]`, |
| 135 |
the repositories whose pushes may start runs. No proposals trigger runs |
| 136 |
in M0, so code from people without write access never runs. |
| 137 |
- On a push that moves a bookmark of an allowed repository, the hub reads |
| 138 |
`.flow/pipeline.kdl` from the new state, validates it with `flow-core`, |
| 139 |
and records a **run** (repository, bookmark, state address, pipeline |
| 140 |
hash, pusher) with one **job** per pipeline job. An invalid pipeline |
| 141 |
records a failed run with the error and its line. |
| 142 |
- Runner API, bearer runner token: |
| 143 |
- `POST /api/v1/flow/claim` → the oldest queued job whose dependencies |
| 144 |
passed, with its run's state heads, repository clone URL, the job's |
| 145 |
compiled form and its sandbox, and a **job token**: read access to that |
| 146 |
repository only, valid until the job finishes or 2 hours pass. `204` |
| 147 |
when there is nothing. |
| 148 |
- `POST /api/v1/flow/jobs/{id}/log` appends records `{t, stream, text}`. |
| 149 |
- `POST /api/v1/flow/jobs/{id}/finish` with the exit code, duration and |
| 150 |
receipt; a job whose dependency failed becomes `skipped`. |
| 151 |
- Each job is also a check named `flow/<job>` on the state (hub spec §13), |
| 152 |
so proposals, the status bar in VS Code and required checks see it. |
| 153 |
- Pages: a **Flow** tab with runs (status, bookmark, state, pusher, |
| 154 |
duration); a run page with the job graph drawn as SVG (columns by |
| 155 |
dependency depth, status by colour and glyph), each job's log folded |
| 156 |
by default and the failed one open, refreshed while the run is live. |
| 157 |
|
| 158 |
## 6. Earth |
| 159 |
|
| 160 |
Under `/home/claude/flow`: `bin/firecracker` (release binary, pinned), |
| 161 |
`kernel/vmlinux` (pinned), `cas/` (rootfs images), `work/` (scratch), and |
| 162 |
`runner.toml`. `flow runner` runs as the `flow-runner` systemd service |
| 163 |
under the `claude` user (member of `kvm`), polling the hub at |
| 164 |
`https://earth.andymccall.xyz:7343`. |
| 165 |
|
| 166 |
## 7. Not in M0 |
| 167 |
|
| 168 |
WASM tier; FastCDC-backed rootfs and `casfs` mounts (M0 copies the tree |
| 169 |
into an image); mounts other than ephemeral scratch, `output` and artifacts; receipt reuse; provenance |
| 170 |
attestations and composite signatures; policy gates; secrets; proposal |
| 171 |
runs; scope-based skipping; re-run and cancel from the hub. |