OXYGENHUB
# 448568561f2a oxygenflow / docs / m0.md
7.3 KiB · Markdown · last changed OxygenFlow M0: pipeline parser and canonical job graph, receipts, the runner API types, flow-init, the Firecracker tier, flow check, run, rootfs import and runner 1 month ago renderedsource blame history raw

OxygenFlow M0: first pipeline on Earth

StatusPlan, 2026-09-28
Implementsa subset of docs/spec.md (Draft 0.1)
GoalA 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.

M0 is the smallest slice that exercises the real architecture: a typed pipeline compiled to a canonical form, host-side execution in the default isolation tier, receipts computed by the host, and results reported to the hub as checks. Everything M0 leaves out is listed at the end.

1. Pipeline subset

The file is .flow/pipeline.kdl in the state being built (KDL 2.0). M0 accepts:

schema-version 1

sandbox "alpine" {
    runtime "microvm"            // "microvm" (default) | "syscall"; "wasm" is refused in M0
    rootfs "cas://blob/<64 hex>"  // BLAKE3 of an ext4 image the runner holds
    memory-limit "1GiB"          // KiB, MiB, GiB
    cpu-limit 2
    network "isolated"           // M0 accepts only "isolated"
}

job "lint" {
    sandbox "alpine"
    scope "src/**" "README.md"   // recorded; the job runs on every push in M0
    env { LANG "C.UTF-8" }
    exec "grep" "-rn" "TODO" "src"
}

job "test" {
    sandbox "alpine"
    depends-on "lint"
    exec "sh" "-eu" "test.sh"
}

Rules, all checked by flow check and again by the hub before anything runs (spec §2.2 I2, §3.2):

  • Unknown nodes, arguments or properties are errors, with the line.
  • Every string stays a string; cpu-limit is an integer from 1 to 16; memory-limit parses to 64 MiB to 16 GiB.
  • A job names a declared sandbox, has a non-empty exec of literal strings, and depends-on only declared jobs; the graph has no cycle.
  • Names are 1 to 64 characters from [A-Za-z0-9._-].
  • exec becomes argv unchanged: metacharacters are bytes (spec §10.1). A job that wants a shell names one, as sh above; nothing adds one.
  • mount, output, publish and secret are recognised and refused with "not in M0", so a pipeline written for the full spec fails clearly.

The pipeline compiles to a canonical CBOR job graph (sorted keys, sandboxes and jobs in name order). Its BLAKE3 is the pipeline_hash used in receipts and shown by the hub.

2. Tier 2: Firecracker (default)

Per job the runner:

  1. Verifies the rootfs image: BLAKE3(file) must equal the pinned hash. Images live in $FLOW_HOME/cas/<hash>.ext4; an unknown hash fails the job ("rootfs not on this runner").
  2. Materializes the state at its heads with oxy into a scratch directory (a clone at the state), removes .oxy, and builds a workspace image with mkfs.ext4 -d sized to the tree plus 512 MiB.
  3. Boots Firecracker with no network device (so isolated holds by construction), vcpu_count and mem_size_mib from the sandbox, the pinned guest kernel, the rootfs as a read-only vda, the workspace as a read-write vdb, and an initramfs holding flow-init.
  4. flow-init (a static binary, PID 1 in the guest) mounts /proc, /sys, /dev, the rootfs at /newroot read-only, vdb at /newroot/src, and tmpfs at /newroot/tmp and /newroot/run; it switches root, then runs argv with exactly the job's env (plus PATH, HOME=/tmp) in /src as uid 1000, with a job timeout.
  5. flow-init frames the child's output on the serial console as \x1eO <line> (stdout) and \x1eE <line> (stderr), then \x1eX <code>, and powers off. The kernel runs quiet so the host sees only frames; anything else is kept as console records.
  6. The host reads frames, streams them as log records, and kills the VM at the timeout (the guest cannot extend it). Scratch files are deleted afterwards. Guest memory is released with the process.

The guest kernel, flow-init and Firecracker itself are pinned by BLAKE3 in the runner's configuration; their hashes and the rootfs hash make the receipt's environment together with argv and env.

3. Tier 1: syscall sandbox

runtime "syscall": a child in new user, mount, PID, network, IPC and UTS namespaces (no interfaces but loopback), the rootfs image's tree (unpacked once per hash, read-only) as /, the workspace directory at /src, tmpfs at /tmp; Landlock limits writes to /src and /tmp; a seccomp filter refuses socket for anything but AF_UNIX, plus ptrace, mount, bpf, perf_event_open, keyctl and module calls; cpu-limit and memory-limit apply through a cgroup v2 scope when the runner has a delegated cgroup, and the job fails to start otherwise rather than run unbounded. Same frames and receipts as tier 2. Done after tier 2 works on Earth.

4. Receipts

Every finished job gets a receipt (spec §6.1): job_name, pipeline_hash, scope_tree_hash (BLAKE3 over the sorted (path, content hash) of the files scope matches; the whole tree when there is no scope), inputs (empty in M0), environment, outputs (empty), exit_code, duration_ms. It is encoded as canonical CBOR and hashed as BLAKE3("oxy1 receipt " || len || 0x00 || body). M0 stores it on the hub with the job; storing receipts as Oxygen objects needs the receipt object type in the Oxygen spec, and cache reuse (§6.2) follows.

5. Hub side (OxygenHub)

  • Configuration: [flow] runner_tokens = [...] and repos = ["owner/*"], the repositories whose pushes may start runs. No proposals trigger runs in M0, so code from people without write access never runs.
  • On a push that moves a bookmark of an allowed repository, the hub reads .flow/pipeline.kdl from the new state, validates it with flow-core, and records a run (repository, bookmark, state address, pipeline hash, pusher) with one job per pipeline job. An invalid pipeline records a failed run with the error and its line.
  • Runner API, bearer runner token:
    • POST /api/v1/flow/claim → the oldest queued job whose dependencies passed, with its run's state heads, repository clone URL, the job's compiled form and its sandbox, and a job token: read access to that repository only, valid until the job finishes or 2 hours pass. 204 when there is nothing.
    • POST /api/v1/flow/jobs/{id}/log appends records {t, stream, text}.
    • POST /api/v1/flow/jobs/{id}/finish with the exit code, duration and receipt; a job whose dependency failed becomes skipped.
  • Each job is also a check named flow/<job> on the state (hub spec §13), so proposals, the status bar in VS Code and required checks see it.
  • Pages: a Flow tab with runs (status, bookmark, state, pusher, duration); a run page with the job graph drawn as SVG (columns by dependency depth, status by colour and glyph), each job's log folded by default and the failed one open, refreshed while the run is live.

6. Earth

Under /home/claude/flow: bin/firecracker (release binary, pinned), kernel/vmlinux (pinned), cas/ (rootfs images), work/ (scratch), and runner.toml. flow runner runs as the flow-runner systemd service under the claude user (member of kvm), polling the hub at https://earth.andymccall.xyz:7343.

7. Not in M0

WASM tier; FastCDC-backed rootfs and casfs mounts (M0 copies the tree into an image); mount, output and artifacts; receipt reuse; provenance attestations and composite signatures; policy gates; secrets; proposal runs; scope-based skipping; re-run and cancel from the hub.