OXYGENHUB
# 85275e1ef3b7 oxygenflow / docs / m0.md
16.5 KiB · Markdown · last changed Prune build caches before they are kept 16 days ago renderedsource blame history raw
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
- `arch "riscv64"` on a sandbox asks for that architecture (spec §11.3);
67
  without it the rootfs image's decides. `flow rootfs import` reads the
68
  image's architecture from its shell's ELF header, refuses an image for
69
  another architecture than the runner's, and records it in
70
  `cas/<hash>.json`; a claim reports each image's architecture
71
  (`image_archs`) so the hub never offers a job to a runner of the wrong
72
  one (spec §11.2).
73
- `identity "sts.amazonaws.com" { mount-path "/run/secrets/aws-token" }` on
74
  a job (up to 4) asks the hub for an OIDC token for that audience, naming
75
  the repository, bookmark, run and job (OxygenHub spec §13.5,
76
  oxygenhub#103); it comes with the claim and is mounted exactly as a
77
  secret is. A local run has no hub, so `flow run` says the file will be
78
  missing.
79
- `output { artifact "oxy" { path "/src/target/release/oxy" } }` declares
80
  up to 16 **artifacts** (spec §5.2). The path is clean, absolute, under
81
  `/src`, and not on a tmpfs mount, which is gone when the job ends. After
82
  a job passes, the runner reads each artifact from the job's disks with
83
  `debugfs`: the workspace, or the `ephemeral://disk` mount holding it. An
84
  artifact is at most 512 MiB. The runner uploads each one with
85
  `POST /api/v1/flow/jobs/{id}/artifacts/{name}` (raw bytes). The hub keeps
86
  it as a blob of the repository, and the receipt's `outputs` name those
87
  blobs in artifact order. A declared artifact that cannot be kept fails
88
  the job, and the job gets no receipt.
89
- `mount` is accepted for **ephemeral scratch** only (up to 4 per job):
90
  `mount { source "ephemeral://disk"; target "/src/target"; size "8GiB" }`.
91
  `ephemeral://tmpfs` is memory in the guest and counts against its
92
  memory-limit; `ephemeral://disk` is a fresh sparse ext4 disk on the
93
  runner, attached after the workspace, for build trees larger than
94
  memory. The target is a clean absolute path strictly under `/src` or
95
  `/tmp`, mounts may not nest, sizes run from 16 MiB to 64 GiB, and any
96
  other source (`oxy://workspace` and the rest) or setting (`readonly`,
97
  `include`) is refused as not in M0. Both are empty when the job starts
98
  and deleted when it ends, so nothing passes between jobs. Mounts are
99
  part of the job's canonical form (field 6, present only when a job has
100
  mounts, so pipelines without them keep their hashes) and of the
101
  receipt's environment.
102
103
- A sandbox's `env` is where each of its jobs' environments starts; a
104
  job's own `env` overrides it name by name. It is part of the sandbox's
105
  canonical form (field 6, present only when set). Below both is the
106
  image's own environment: `flow rootfs import --env NAME=value`
107
  (repeatable) writes `/etc/flow/env` into the image, for what a
108
  Dockerfile's `ENV` sets and `docker export` loses, such as a toolchain's
109
  `PATH`. It is part of the image, so of its hash. Under all three are
110
  `PATH` (the usual directories) and `HOME=/tmp`.
111
112
The pipeline compiles to a canonical CBOR job graph (sorted keys, sandboxes
113
and jobs in name order). Its BLAKE3 is the `pipeline_hash` used in receipts
114
and shown by the hub.
115
116
## 2. Tier 2: Firecracker (default)
117
118
Per job the runner:
119
120
1. Verifies the rootfs image: `BLAKE3(file)` must equal the pinned hash.
121
   Images live in `$FLOW_HOME/cas/<hash>.ext4`; an unknown hash fails the
122
   job ("rootfs not on this runner").
123
2. Materializes the state at its heads with `oxy` into a scratch
124
   directory (a clone at the state), removes `.oxy`, and builds a
125
   workspace image with `mkfs.ext4 -d` sized to the tree plus 512 MiB.
126
3. Boots Firecracker with no network device (so `isolated` holds by
127
   construction; `loopback` only brings up the guest's `lo`, so tests can
128
   serve and connect on 127.0.0.1 while nothing leaves the guest), `vcpu_count` and `mem_size_mib` from the sandbox, the
129
   pinned guest kernel, the rootfs as a **read-only** `vda`, the
130
   workspace as a read-write `vdb`, and an initramfs holding `flow-init`.
131
4. `flow-init` (a static binary, PID 1 in the guest) mounts `/proc`,
132
   `/sys`, `/dev`, the rootfs at `/newroot` read-only, `vdb` at
133
   `/newroot/src`, and tmpfs at `/newroot/tmp` and `/newroot/run`, then the
134
   job's scratch mounts (creating their mount points, owned by the job's
135
   user); it
136
   switches root, then runs `argv` (its program found in the guest's `PATH` by
137
   `flow-init` itself) with exactly the job's `env` (plus
138
   `PATH`, `HOME=/tmp`) in `/src` as uid 1000, with a job timeout.
139
   `/dev` gets the links devtmpfs lacks, `fd`, `stdin`, `stdout` and
140
   `stderr` into `/proc/self/fd`, so shell process substitution works. A job
141
   is told what it builds: `FLOW_REPO`, `FLOW_STATE` (the hub's state hash),
142
   `FLOW_HEADS` (full hashes joined with `+`), `FLOW_BOOKMARK`, `FLOW_RUN`
143
   (the run's number in its repository) and `FLOW_JOB`; `flow run` sets
144
   `FLOW_JOB` and `FLOW_RUN=local`. After them come the **variables** the
145
   hub holds for the repository (site, organisation, repository; the
146
   claim's `env`; `flow run --var NAME=VALUE` locally), never secrets. A
147
   variable the pipeline sets keeps its value. They are part of the job's
148
   environment, so of its receipt.
149
5. `flow-init` frames the child's output on the serial console as
150
   `\x1eO <line>` (stdout) and `\x1eE <line>` (stderr). After a passing
151
   job it prunes each build cache disk (mounted `strictatime`) of the
152
   files the job did not use when it is over the runner's
153
   `cache_prune_percent` of its size, and reports each as
154
   `\x1eC <json>` (spec §5.4, oxygenflow#72); then `\x1eX <code>`, and
155
   it powers off. The kernel runs `quiet` so the host sees
156
   only frames; anything else is kept as `console` records.
157
6. The host reads frames, streams them as log records, and kills the VM
158
   at the timeout (the guest cannot extend it). Scratch files are deleted
159
   afterwards. Guest memory is released with the process.
160
161
The guest kernel, `flow-init` and Firecracker itself are pinned by BLAKE3
162
in the runner's configuration; their hashes and the rootfs hash make the
163
receipt's `environment` together with `argv` and `env`.
164
165
## 3. Tier 1: syscall sandbox
166
167
`runtime "syscall"`: a child in new user, mount, PID, network, IPC and
168
UTS namespaces (no interfaces but loopback), the rootfs image's tree
169
(unpacked once per hash, read-only) as `/`, the workspace directory at
170
`/src`, tmpfs at `/tmp`; Landlock limits writes to `/src` and `/tmp`; a
171
seccomp filter refuses `socket` for anything but `AF_UNIX`, plus
172
`ptrace`, `mount`, `bpf`, `perf_event_open`, `keyctl` and module calls;
173
`cpu-limit` and `memory-limit` apply through a cgroup v2 scope when the
174
runner has a delegated cgroup, and the job fails to start otherwise
175
rather than run unbounded. Same frames and receipts as tier 2.
176
177
**How it runs.**
178
- **Helper:** for each job the runner starts `flow __sandbox <spec>` (its own binary, through `/proc/self/exe`). The helper joins the job's cgroup, makes the namespaces with the job's user (uid and gid 1000) mapped to the runner's, and forks the sandbox's PID 1.
179
- **Root:** PID 1 binds the unpacked tree read-only as the new root, the workspace at `/src`, tmpfs at `/tmp`, `/run` and `/run/secrets`, a fresh `/proc`, and a `/dev` of `null`, `zero`, `full`, `random` and `urandom` with `/dev/shm`. It brings `lo` up for `network "loopback"` and `"egress"` (with the vsock relay of spec §4.3.1 for the latter), pivots into the root and detaches the old one, so the host's files, the spec and the scratch directory are out of reach.
180
- **Job:** the job gets no new privileges, then Landlock (read and execute everywhere, write beneath `/src`, `/tmp`, `/dev` and its mounts), then the seccomp filter, then `execve` with exactly the job's environment, found the way `flow-init` finds it (the two share that code). The filter also refuses io_uring (which could open sockets past it), the new mount calls, kexec, swap and reboot.
181
- **Unpacking:** `debugfs rdump` unpacks the image into `trees/<hash>` in the home once, without mount rights. Set-id bits and device nodes are dropped.
182
- **Ephemeral mounts:** a `tmpfs` mount is a tmpfs of its size. An `ephemeral://disk` mount is a scratch directory, so its size is not enforced in this tier. One that names a `cache` starts from a directory a passing job of the slot kept, and goes back into the runner's cache pool when the job passes (spec §5.4, oxygenflow#70), pruned first by the runner of the files the job did not use when over the cap (oxygenflow#72).
183
- **Artifacts:** they are read from the workspace and mount directories without following links.
184
- **Receipts:** the environment's runtime is `syscall`. Its parts are the `flow` binary's BLAKE3, the host kernel's release, memory, CPUs and mounts.
185
- **Limits:** the job's cgroup gets `memory.max`, `memory.swap.max 0` and `cpu.max`. A job killed for memory is told so in its log. At the end the group is killed and removed.
186
187
**Where it is offered.** `flow tiers` prints the tiers this machine can run and why the others cannot; a runner offers only those (spec §11.1). The syscall tier needs:
188
- **Namespaces:** unprivileged user namespaces for the `flow` binary. Ubuntu's `kernel.apparmor_restrict_unprivileged_userns=1` can deny them to a program without an AppArmor profile.
189
- **Kernel features:** Landlock in the kernel's LSM list, and seccomp filters.
190
- **Cgroup:** a cgroup v2 group the runner may manage, with the cpu and memory controllers and no other process in it. That is a systemd service with `Delegate=yes`, or a user service under `systemd --user`. The runner moves itself into a `flow-main` leaf of that group and makes one group per job beside it. For `flow run`, use `systemd-run --user --scope -p Delegate=yes flow run`.
191
192
## 4. Receipts
193
194
Every finished job gets a receipt (spec §6.1): `job_name`,
195
`pipeline_hash`, `scope_tree_hash` (BLAKE3 over the sorted
196
`(path, content hash)` of the files `scope` matches; the whole tree when
197
there is no scope), `inputs` (empty in M0), `environment`, `outputs`
198
(empty), `exit_code`, `duration_ms`. It is encoded as canonical CBOR and
199
hashed as `BLAKE3("oxy1 receipt " || len || 0x00 || body)`, the `receipt`
200
object of the Oxygen spec (§14.6).
201
202
**Registering a runner.**
203
- **Token:** a hub administrator creates a one-time registration token on Admin → Runners.
204
- **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.
205
- **Key:** registration binds the key; the hub accepts receipts from that runner signed by no other.
206
- **Claims:** every claim carries the runner's description (`info`: architecture, OS, version, labels, capacity) for the Runners page.
207
- **Old configuration:** runners configured with a `hub.toml` token still work. Their key is bound on their first signed receipt.
208
209
**Signed receipts.** A runner keeps a composite key in its home
210
(`runner.key`, owner-only, and `runner.identity`), made on first start and
211
named after the runner. After each job it binds the receipt to the state of
212
the heads it fetched (computed by the runner, not taken from the claim) and
213
to the run's URL, signs that attestation, and sends the signed receipt with
214
`finish` as `signed_receipt` (hex). The hub checks it and keeps it in the
215
repository; anyone can then check it with `oxy receipt verify`. Cache reuse
216
(§6.2) follows.
217
218
## 5. Hub side (OxygenHub)
219
220
- Configuration: `[flow] runner_tokens = [...]` and `repos = ["owner/*"]`,
221
  the repositories whose pushes may start runs. No proposals trigger runs
222
  in M0, so code from people without write access never runs.
223
- On a push that moves a bookmark of an allowed repository, the hub reads
224
  `.flow/pipeline.kdl` from the new state, validates it with `flow-core`,
225
  and records a **run** (repository, bookmark, state address, pipeline
226
  hash, pusher) with one **job** per pipeline job. An invalid pipeline
227
  records a failed run with the error and its line.
228
- Runner API, bearer runner token:
229
  - `POST /api/v1/flow/claim` → the oldest queued job whose dependencies
230
    passed, with its run's state heads, repository clone URL, the job's
231
    compiled form and its sandbox, and a **job token**: read access to that
232
    repository only, valid until the job finishes or 2 hours pass. `204`
233
    when there is nothing.
234
  - `POST /api/v1/flow/jobs/{id}/log` appends records `{t, stream, text}`. An empty batch only asks whether the job still runs; the runner sends one every 5 s while a job is quiet, and stops the job's microVM when the hub answers `409` (the run was cancelled, oxygenflow#32). A cancelled job's end is not reported.
235
  - `POST /api/v1/flow/jobs/{id}/finish` with the exit code, duration and
236
    receipt; a job whose dependency failed becomes `skipped`.
237
- Each job is also a check named `flow/<job>` on the state (hub spec §13),
238
  so proposals, the status bar in VS Code and required checks see it.
239
- Pages: a **Flow** tab with runs (status, bookmark, state, pusher,
240
  duration); a run page with the job graph drawn as SVG (columns by
241
  dependency depth, status by colour and glyph), each job's log folded
242
  by default and the failed one open, refreshed while the run is live.
243
244
## 6. Earth
245
246
Under `/home/claude/flow`: `bin/firecracker` (release binary, pinned),
247
`kernel/vmlinux` (pinned), `cas/` (rootfs images), `work/` (scratch), and
248
`runner.toml`. `flow runner` runs as the `flow-runner` systemd service
249
under the `claude` user (member of `kvm`), polling the hub at
250
`https://earth.andymccall.xyz:7343`. The unit has `KillMode=mixed` and
251
`TimeoutStopSec=75min`, so a restart drains the runner instead of killing
252
its job (spec §11.5.1).
253
254
## 7. Not in M0
255
256
WASM tier; FastCDC-backed rootfs and `casfs` mounts (M0 copies the tree
257
into an image); mounts other than ephemeral scratch; artifacts as inputs of later jobs; receipt reuse; provenance
258
attestations; policy gates; secrets; proposal
259
runs; scope-based skipping; re-run and cancel from the hub.