| 1 |
# OxygenFlow (flow) — Deterministic Execution & Provenance Pipeline Specification |
| 2 |
|
| 3 |
|
| 4 |
|
| 5 |
| **Format version** | 1 | |
| 6 |
| **Date** | 2026-09-28 | |
| 7 |
| **Status** | Draft 0.1 | |
| 8 |
| **Target VCS** | Oxygen (oxy) Draft 0.8i | |
| 9 |
| **Audience** | Implementers, Security Architects, and Systems Engineers | |
| 10 |
|
| 11 |
|
| 12 |
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be |
| 13 |
interpreted as described in RFC 2119. |
| 14 |
|
| 15 |
## 1. Overview and Core Invariants |
| 16 |
|
| 17 |
OxygenFlow (flow) is a content-addressed, hermetic execution engine, runner system, |
| 18 |
and pipeline language designed natively for the Oxygen (oxy) version control |
| 19 |
system. |
| 20 |
|
| 21 |
Existing runner engines (GitHub Actions, GitLab CI, Forgejo Runner) suffer from |
| 22 |
structural flaws: |
| 23 |
1. Workspace Bloat: Full clones, index unpacks, and checkout scans dominate I/O. |
| 24 |
2. String Injection: Shell interpolation within untyped YAML files produces |
| 25 |
expression injection and command execution exploits. |
| 26 |
3. Decoupled Supply Chain: Attestations (SLSA, in-toto, Cosign) exist outside |
| 27 |
the VCS, relying on external registries rather than native repository trust. |
| 28 |
4. Coarse Caching: Opaque tarballs keyed by arbitrary string hashes fail to |
| 29 |
leverage commutative history or granular dependency graphs. |
| 30 |
|
| 31 |
OxygenFlow solves these liabilities through direct integration with Oxygen: |
| 32 |
- Zero-Copy Workspaces: Jobs mount workspaces directly from Oxygen's FastCDC- |
| 33 |
chunked, BLAKE3-addressed Content-Addressable Store (CAS) using Copy-on-Write |
| 34 |
(COW) overlays. |
| 35 |
- Commutative History & Impact Trees: Pipelines evaluate over change closures |
| 36 |
rather than linear snapshot sequences. Commuting changes automatically reuse |
| 37 |
cached execution receipts without redundant re-execution. |
| 38 |
- Micro-Engine Sandboxing: Jobs run inside isolated environments (WASM, gVisor, |
| 39 |
or MicroVMs) with strict, deny-by-default capabilities and no ambient |
| 40 |
network egress. |
| 41 |
- Native Cryptographic Provenance: Provenance and execution receipts are |
| 42 |
generated outside the untrusted guest, encoded as deterministic CBOR, and |
| 43 |
signed using composite Ed25519 + ML-DSA-65 keys as native Oxygen Attest |
| 44 |
objects governed by in-repository Policy rules. |
| 45 |
|
| 46 |
## 2. Threat Model and Security Architecture |
| 47 |
|
| 48 |
### 2.1 Adversaries |
| 49 |
- Untrusted Proposal Author: Submits code to an open repository containing |
| 50 |
malicious build configurations intended to escape the runner, exfiltrate |
| 51 |
secrets, or corrupt shared worker caches. |
| 52 |
- Malicious Dependency / Upstream: Third-party dependencies invoked during |
| 53 |
build time attempting out-of-band network calls or host file modification. |
| 54 |
- Compromised Runner / Malicious Worker: An execution node attempting to |
| 55 |
forge successful build receipts or inject backdoors into published artifacts. |
| 56 |
|
| 57 |
### 2.2 Security Invariants |
| 58 |
- I1 Deny-by-Default Execution: Sandboxes possess no network interfaces |
| 59 |
(excluding loopback when requested) and no host filesystem access unless |
| 60 |
explicitly granted in policy-signed configuration. |
| 61 |
- I2 Injection-Free Invocation: Configuration parsing directly yields an argv |
| 62 |
string vector executed via execve(). Shell interpretation (/bin/sh -c) does |
| 63 |
not exist. |
| 64 |
- I3 Host-Enforced Provenance: The running payload cannot author, inspect, or |
| 65 |
alter its own provenance statement. Provenance is emitted solely by the |
| 66 |
isolation hypervisor boundary. |
| 67 |
- I4 Cryptographic Identity & Authorization: Runners execute using an Oxygen |
| 68 |
Identity whose KeyId is enrolled as a delegate in the repository's Policy. |
| 69 |
Unenrolled runners cannot publish recognized attestations or advance gates. |
| 70 |
|
| 71 |
## 3. The OxygenFlow Configuration Language (.flow) |
| 72 |
|
| 73 |
Pipelines are declared in KDL syntax within .flow/pipeline.kdl at the |
| 74 |
repository root. KDL provides strict typing, structured nesting, and rejects |
| 75 |
arbitrary text macro expansion. |
| 76 |
|
| 77 |
Strings follow KDL 2.0, which has no `\033`, `\e` or `\x` escapes: ESC, |
| 78 |
for ANSI colours in a job's output, is `\u{1b}` (`"\u{1b}[32mok\u{1b}[0m"`), |
| 79 |
and `flow check` says so when it meets one of the others. Inside the |
| 80 |
guest, `/dev/fd` and `/dev/stdin|stdout|stderr` link to `/proc/self/fd` |
| 81 |
as on any Linux system, so bash process substitution works. |
| 82 |
|
| 83 |
### 3.1 Pipeline Specification Schema |
| 84 |
|
| 85 |
```kdl |
| 86 |
schema-version 1 |
| 87 |
|
| 88 |
// Define the hermetic execution boundary |
| 89 |
sandbox "rust-builder" { |
| 90 |
runtime "microvm" // Options: "wasm" | "gvisor" | "microvm" |
| 91 |
rootfs "cas://blob/9b5c2a1e8f3d4e7a6b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a" |
| 92 |
memory-limit "4GiB" |
| 93 |
cpu-limit 4 |
| 94 |
network "isolated" // Options: "isolated" | "loopback" | "egress" { allow "host"... } (§4.3.1) |
| 95 |
} |
| 96 |
|
| 97 |
// Job evaluated based on Oxygen change closure |
| 98 |
job "compile-core" { |
| 99 |
sandbox "rust-builder" |
| 100 |
|
| 101 |
// Commutation boundary: runs only if change closure touches these paths |
| 102 |
scope "crates/oxy-core/**" "crates/oxy-server/**" "Cargo.*" |
| 103 |
|
| 104 |
// Zero-copy workspace mount backed by Oxygen CAS |
| 105 |
mount { |
| 106 |
source "oxy://workspace" |
| 107 |
target "/src" |
| 108 |
include "crates/oxy-core" "crates/oxy-server" "Cargo.toml" "Cargo.lock" |
| 109 |
readonly #true |
| 110 |
} |
| 111 |
|
| 112 |
// Ephemeral scratch space |
| 113 |
mount { |
| 114 |
source "ephemeral://tmpfs" |
| 115 |
target "/src/target" |
| 116 |
size "8GiB" |
| 117 |
} |
| 118 |
|
| 119 |
env { |
| 120 |
RUSTFLAGS "-Dwarnings" |
| 121 |
CARGO_INCREMENTAL "0" |
| 122 |
} |
| 123 |
|
| 124 |
// Direct argv execution vector (no subshell) |
| 125 |
exec "cargo" "build" "--release" "--package" "oxy-core" |
| 126 |
|
| 127 |
output { |
| 128 |
artifact "oxy-core-lib" { |
| 129 |
path "/src/target/release/liboxy_core.rlib" |
| 130 |
format "blob" |
| 131 |
} |
| 132 |
} |
| 133 |
} |
| 134 |
|
| 135 |
job "unit-tests" { |
| 136 |
sandbox "rust-builder" |
| 137 |
depends-on "compile-core" |
| 138 |
|
| 139 |
mount { |
| 140 |
source "oxy://workspace" |
| 141 |
target "/src" |
| 142 |
readonly #true |
| 143 |
} |
| 144 |
|
| 145 |
mount { |
| 146 |
source "artifact://compile-core/oxy-core-lib" |
| 147 |
target "/src/target/release/liboxy_core.rlib" |
| 148 |
} |
| 149 |
|
| 150 |
exec "cargo" "test" "--release" "--package" "oxy-core" |
| 151 |
} |
| 152 |
|
| 153 |
publish "release-provenance" { |
| 154 |
depends-on "unit-tests" |
| 155 |
|
| 156 |
attest { |
| 157 |
kind "provenance" |
| 158 |
bind-ref "bookmarks/main" |
| 159 |
include-artifacts "compile-core/oxy-core-lib" |
| 160 |
} |
| 161 |
} |
| 162 |
|
| 163 |
|
| 164 |
**What starts a job.** An `on` block, at the top of the pipeline or in a job (the job's wins), names the events that run it: `push` with bookmark globs (none: any bookmark), `tag` with tag globs (none: any tag), `manual` ("Run pipeline", `oh run start`) and `schedule` with five-field cron lines on the hub's clock (UTC), run on the default bookmark. Without `on`, a job runs on every push and by hand, as before. A run has only the jobs its event starts (and what they depend on); an event that starts no job starts no run. A rerun repeats its run's event. |
| 165 |
|
| 166 |
```kdl |
| 167 |
on { |
| 168 |
push "main" "feature/*" |
| 169 |
manual |
| 170 |
} |
| 171 |
job "dist" { |
| 172 |
on { |
| 173 |
tag "v*" |
| 174 |
manual |
| 175 |
} |
| 176 |
… |
| 177 |
} |
| 178 |
``` |
| 179 |
|
| 180 |
**Time limits.** A job may say how long it may run with `timeout "90m"` (hours, minutes and seconds, as `"2h"`, `"1h30m"` or `"45s"`, from a minute to a day); a sandbox's `timeout` is the default for its jobs. Without one, the hub's default applies, and the hub caps every job at the site's maximum (both set by an administrator on Admin → Runners). The runner stops a job at its limit, and the hub ends one its runner stopped reporting a few minutes after it. A timeout is part of the compiled job, so a job without one compiles as before. |
| 181 |
|
| 182 |
**Runner labels.** A sandbox or a job may say `runs-on "LABEL" ...`: lower-case letters, digits, `-`, `_` and `.`, at most 64 bytes each, given on one or more lines. A job needs a runner that has every label its sandbox and it name. A runner reports its labels and architecture when it registers and on each claim (`flow runner register --label`). The hub hands a job only to a runner with all of them, within the runner's scope (OxygenHub spec 13.2), and a job no runner can take waits, saying so on the queue. Labels are part of the compiled job, so a job that asks for none compiles as before. |
| 183 |
|
| 184 |
### 3.2 Canonical Compilation |
| 185 |
Upon commit, .flow/pipeline.kdl is validated and compiled into a deterministic |
| 186 |
CBOR job-graph blob. Runners evaluate the canonical CBOR to prevent syntax or |
| 187 |
parser ambiguities across varying client platforms. |
| 188 |
|
| 189 |
## 4. Sandbox Execution Engine |
| 190 |
|
| 191 |
OxygenFlow implements three distinct isolation tiers depending on the workload: |
| 192 |
|
| 193 |
### 4.1 Level 0: WASM Component Sandbox |
| 194 |
- Intended for static linters, formatters, doc generators, policy checks, and |
| 195 |
test suites built for `wasm32-wasip1`; the tier phones and browsers run |
| 196 |
(docs/handover.md). |
| 197 |
- Built (oxygenflow#75): wasmtime 49 runs WASI preview 1 modules, each in a |
| 198 |
fresh instance, compiled by Cranelift (tens of milliseconds a module). |
| 199 |
Preview 2 components later. |
| 200 |
- `runtime "wasm"`; no `rootfs` and no network (`network` absent or |
| 201 |
`"isolated"`); `memory-limit` holds the module's linear memory. |
| 202 |
- `exec` names the module, a path among the job's files (its scope or an |
| 203 |
earlier job's artifact), optionally under `/src/`, or a glob with `*` in |
| 204 |
its file name: each match runs in turn, and the job's exit code is the |
| 205 |
first non-zero one after all have run. The rest of `exec` are the |
| 206 |
module's arguments. A module that traps exits 134. |
| 207 |
- The job's files are the only host files it sees, preopened as `/src` (and |
| 208 |
as the current directory, for relative paths); each `mount` is preopened at |
| 209 |
its target. Secrets and identities are not offered yet. |
| 210 |
- The receipt's environment names `wasmtime` and its version, the runner |
| 211 |
binary, the memory, and every module run with its BLAKE3. |
| 212 |
|
| 213 |
### 4.2 Level 1: Intercepted Syscall Isolation (gVisor / Landlock + seccomp-bpf) |
| 214 |
- Intended for standard compilation and hermetic test suites. |
| 215 |
- Emulates or filters Linux system call interfaces in user space. |
| 216 |
- Blocks access to raw network sockets, real-time hardware clocks, and device |
| 217 |
nodes. |
| 218 |
- Memory and execution CPU quotas are strictly bounded via cgroups v2. |
| 219 |
|
| 220 |
### 4.3 Level 2: MicroVM Isolation (Firecracker / Cloud-Hypervisor) |
| 221 |
- Intended for kernel tests, multi-container integration, and untrusted code. |
| 222 |
- Ephemeral Linux microVM booting in < 80 ms. |
| 223 |
- The root filesystem is presented as a read-only virtio-block device mapped |
| 224 |
directly to FastCDC chunks stored in Oxygen's CAS. |
| 225 |
- Guest VM memory is cleared upon termination. |
| 226 |
|
| 227 |
#### 4.3.1 Allow-listed egress (oxygenflow#55) |
| 228 |
|
| 229 |
A sandbox reaches the network only by naming its hosts: |
| 230 |
|
| 231 |
```kdl |
| 232 |
sandbox "deploy" { |
| 233 |
runtime "microvm" |
| 234 |
network "egress" { allow "*.googleapis.com" "registry.opentofu.org" } |
| 235 |
} |
| 236 |
``` |
| 237 |
|
| 238 |
The VM still has no network device. `flow-init` serves a proxy on the |
| 239 |
guest's `127.0.0.1:3128` (the job gets `HTTPS_PROXY`, `HTTP_PROXY` and |
| 240 |
their lower-case forms, unless it sets them) and relays each connection, |
| 241 |
unread, over Firecracker's vsock to the runner. The runner accepts only |
| 242 |
`CONNECT host:443` for a host the list allows (a name exactly, or any |
| 243 |
name under a `*.` domain, never the domain itself; never an address), |
| 244 |
resolves it itself (the guest does not resolve), refuses any name that |
| 245 |
resolves to an address that is not public, and relays the bytes without |
| 246 |
opening the TLS. Every decision, allowed or refused, is a `flow` line in |
| 247 |
the job's log. Patterns are lower-case host names of two or more labels, |
| 248 |
at most 32 per sandbox, and part of the sandbox's hash. |
| 249 |
|
| 250 |
Egress is off unless a runner turns it on (`egress = true` under |
| 251 |
`[runner]` in `flow.toml`); such a runner offers the `egress` label, and |
| 252 |
an egress sandbox needs it, so the hub hands its jobs only to runners |
| 253 |
that allow it, and a runner without it refuses one anyway. The syscall |
| 254 |
runtime has no egress yet. |
| 255 |
|
| 256 |
**Keyless receipts** (oxygenhub#241). With `keyless = true` under |
| 257 |
`[runner]` (or in `runner.toml`), a runner signs each job's receipt with a |
| 258 |
fresh key instead of its own: just before signing, it asks the hub to |
| 259 |
certify the key (`POST /api/v1/keyless/certify` with the job's token and |
| 260 |
the key's proof of possession, scope `seal`), checks the certificate is |
| 261 |
for that key and signed by the key the hub publishes at |
| 262 |
`/.well-known/oxyhub-key`, signs, attaches the certificate and the hub's |
| 263 |
identity to the receipt (Oxygen spec §14.6), and drops the key. When the |
| 264 |
hub cannot certify, the runner's own key signs and the job's log says |
| 265 |
why. The runner's key then only authenticates it to the hub. |
| 266 |
|
| 267 |
**Metrics** (oxygenflow#35). With `metrics = "127.0.0.1:9464"` under |
| 268 |
`[runner]` (off unless set), a runner serves Prometheus text at |
| 269 |
`/metrics` on that address, from a thread of its own: its version and |
| 270 |
uptime, jobs claimed and declined, finished jobs by result (`passed`, |
| 271 |
`failed`, `error`, `cancelled`), the seconds they ran together, and the |
| 272 |
jobs running now, each labelled with the runner's name. With |
| 273 |
`metrics_token`, a scrape must send it as a bearer token; without one, |
| 274 |
bind it to an address only the scraper reaches. The hub's own `/metrics` |
| 275 |
has the queue and run times of every runner's jobs. |
| 276 |
|
| 277 |
## 5. Content-Addressable Storage (CAS) & Zero-Copy Mounting |
| 278 |
|
| 279 |
OxygenFlow runners bypass traditional clone operations. Repositories are accessed |
| 280 |
directly via the local object store (.oxy/objects/) or streamed on demand |
| 281 |
using Oxygen's wire protocol. |
| 282 |
|
| 283 |
### 5.1 FUSE Overlay Mounting (flow-casfs) |
| 284 |
1. The runner receives a task targeting an Oxygen State ObjectRef (a sorted |
| 285 |
list of maximal heads). |
| 286 |
2. The nearest Checkpoint is resolved. Uncommitted changes between the |
| 287 |
checkpoint and heads are replayed into a virtual tree in-memory. |
| 288 |
3. The virtual tree is exposed to the sandbox via flow-casfs: |
| 289 |
- File reads resolve chunks using the FastCDC gear table (32 KiB to 256 KiB, |
| 290 |
mean 64 KiB) pinned in Oxygen Spec Section 13.2. |
| 291 |
- Missing chunks are fetched lazily using the POST /objects protocol. |
| 292 |
- To prevent FUSE context-switch bottlenecks on many small reads, flow-casfs aggressively mmaps chunk data and utilizes host page caching. |
| 293 |
- All guest mutations are diverted to a scratch tmpfs layer; base objects |
| 294 |
remain immutable. |
| 295 |
|
| 296 |
### 5.2 Artifact Capture |
| 297 |
1. At task completion, flow-casfs scans the paths declared in the output block. |
| 298 |
2. Modified outputs are chunked with FastCDC, hashed via BLAKE3-256, and |
| 299 |
written as canonical Blob and Chunk objects. |
| 300 |
3. Output ObjectRefs are stored directly in the Oxygen repository. |
| 301 |
4. Outputs are read when the job passes. An artifact with `when "always"` |
| 302 |
(default `when "passed"`) is also read when the job fails or is stopped |
| 303 |
at its time limit, so a test report survives the failure it describes. |
| 304 |
From a job that did not pass it is kept and shown, but its receipt never |
| 305 |
names it, and one that cannot be read is only noted in the log. |
| 306 |
|
| 307 |
```kdl |
| 308 |
output { |
| 309 |
artifact "junit" { |
| 310 |
path "/src/target/junit.xml" |
| 311 |
when "always" |
| 312 |
} |
| 313 |
} |
| 314 |
``` |
| 315 |
|
| 316 |
5. A `report` output (OxygenHub `docs/reports.md`) names a directory |
| 317 |
holding `report.toml` at its top. The runner reads its regular files |
| 318 |
(links and anything else are left out, never followed; file names hold |
| 319 |
letters, digits, `.`, `_`, `-` and `+`; at most 2000 files and 512 MiB), |
| 320 |
uploads each to `POST /api/v1/flow/jobs/{job}/reports/{name}/files` with |
| 321 |
its path in `X-Flow-Report-Path`, then uploads a manifest, |
| 322 |
`{"version":1,"files":[{"path","blob","size"}]}`, as the output under |
| 323 |
the report's name, checking every blob the hub answers. The receipt |
| 324 |
names the manifest. Reports and artifacts share names; a report's kind |
| 325 |
is part of the job's hash (`[name, path, "report"]`). |
| 326 |
|
| 327 |
```kdl |
| 328 |
output { |
| 329 |
report "frame-timing" { |
| 330 |
path "/src/out/report" |
| 331 |
when "always" |
| 332 |
} |
| 333 |
} |
| 334 |
``` |
| 335 |
|
| 336 |
### 5.3.1 Artifacts between jobs (oxygenflow#58) |
| 337 |
|
| 338 |
A job takes an artifact of a job it depends on: |
| 339 |
|
| 340 |
```kdl |
| 341 |
job "apply" { |
| 342 |
depends-on "plan" |
| 343 |
input { |
| 344 |
artifact "tfplan" from "plan" |
| 345 |
path "/src/tfplan" |
| 346 |
} |
| 347 |
} |
| 348 |
``` |
| 349 |
|
| 350 |
The producer must be named in `depends-on` and declare that name as an |
| 351 |
artifact (not a report); paths are clean absolute paths under `/src`, at |
| 352 |
most 8 inputs, and they are part of the job's hash (`[artifact, from, |
| 353 |
path]`, field 10). Since the producer has passed when the job is handed |
| 354 |
out, the hub puts each input in the claim with the blob the producer's |
| 355 |
receipt names (a passing job's receipt names exactly the artifacts it |
| 356 |
uploaded). Before the job starts, the runner fetches each from |
| 357 |
`GET /api/v1/flow/jobs/{job}/inputs/{index}`, checks its Oxygen blob |
| 358 |
reference against that blob, refuses the job if they differ, and writes it |
| 359 |
at its path in the workspace (not part of the scope hash: it belongs to the |
| 360 |
run, not the repository). The job's receipt lists the blobs as its |
| 361 |
`inputs`, so an apply job's receipt proves which plan it applied. |
| 362 |
|
| 363 |
### 5.3.2 Approval gates (oxygenflow#57) |
| 364 |
|
| 365 |
A job can wait for a person: |
| 366 |
|
| 367 |
```kdl |
| 368 |
job "apply" { |
| 369 |
depends-on "plan" |
| 370 |
approval { |
| 371 |
by "maintain" // the least role: "write", "maintain" (default) or "admin" |
| 372 |
others-only #true // not the person whose push started the run |
| 373 |
expires "24h" // from when the jobs it depends on are done; up to 720h |
| 374 |
sealed #true // signed with the approver's key; default: on with an environment |
| 375 |
} |
| 376 |
} |
| 377 |
``` |
| 378 |
|
| 379 |
The hub hands such a job to no runner until someone with that role on the |
| 380 |
repository approves it, on the run page or through the API; others-only |
| 381 |
leaves out whoever pushed. Approving or rejecting is recorded on the job |
| 382 |
(who, when) and in the audit log. A rejected or expired job ends as |
| 383 |
cancelled and the jobs after it are skipped; a newer push to the |
| 384 |
bookmark cancels the run as before, so a stale plan is never applied. The |
| 385 |
approval is part of the job's hash (field 11). |
| 386 |
|
| 387 |
**Sealed approvals (oxygenhub#235).** With `sealed` (the default for a job |
| 388 |
that names an environment, §5.3.3), pressing Approve is not enough: the |
| 389 |
approver signs an Oxygen `approval` attestation with their own key |
| 390 |
(`oxy seal --flow URL --job NAME`; Oxygen spec §14.4) covering the run, the |
| 391 |
job's hash, the state, the input blobs (the reviewed plan) and the |
| 392 |
environment. The hub starts the job only once it verifies, the key is |
| 393 |
linked to an account with the role, and it is not a delegated key; it keeps |
| 394 |
the approval in the repository, and the job's identity tokens name the |
| 395 |
approver and the approval's hash. Rejecting needs no seal. |
| 396 |
|
| 397 |
### 5.3.3 Environments (oxygenflow#59) |
| 398 |
|
| 399 |
`environment "prod"` on a job says where it deploys (1 to 32 of `a-z`, |
| 400 |
`0-9`, `-`; part of the job's hash, field 12). The hub keeps what each |
| 401 |
environment of a repository runs, from the jobs that passed for it (state, |
| 402 |
run, when, who approved), on the repository's Deployments page. The job's |
| 403 |
identity tokens carry an `environment` claim only when the job was |
| 404 |
approved (§5.3.2): a job with no approval block, or one not approved, gets |
| 405 |
tokens without it. So a cloud's trust condition can require |
| 406 |
`environment == "prod"`, and nothing unapproved deploys there, even from |
| 407 |
the deploy bookmark. The approval block is the environment's protection |
| 408 |
rule for now. |
| 409 |
|
| 410 |
### 5.4 The Build Cache (oxygenflow#28) |
| 411 |
|
| 412 |
An `ephemeral://disk` mount may name a `cache`, and the files that key it: |
| 413 |
|
| 414 |
```kdl |
| 415 |
mount { |
| 416 |
source "ephemeral://disk" |
| 417 |
target "/src/target" |
| 418 |
size "24GiB" |
| 419 |
cache "target" |
| 420 |
key "Cargo.lock" "rust-toolchain.toml" |
| 421 |
} |
| 422 |
``` |
| 423 |
|
| 424 |
Such a disk is not always empty at the start. Each runner keeps a pool of |
| 425 |
the disks of passing jobs, by repository, job, cache name and bookmark, |
| 426 |
named by the **key**: the BLAKE3 of the sandbox image, the job's name, the |
| 427 |
cache name, and each key file's content in the job's state. A job's disk |
| 428 |
starts as, in order: its bookmark's disk with the same key (moved), its |
| 429 |
bookmark's newest disk (moved), another bookmark's with the same key and |
| 430 |
then another's newest (copied, so a proposal never takes the default |
| 431 |
bookmark's), and otherwise an empty disk. A microVM's disk must be the |
| 432 |
size the mount asks for. |
| 433 |
|
| 434 |
- **Only passing jobs keep a disk.** When the job and its outputs pass, |
| 435 |
its cached disks go back into the pool under their key; otherwise they |
| 436 |
are discarded with the scratch directory, so nothing a failing job wrote |
| 437 |
reaches a later one. |
| 438 |
- **The receipt says so.** The job's environment gains `cache TARGET` with |
| 439 |
the key and what the disk started from, so the receipt's environment |
| 440 |
hash differs from a cold build's. |
| 441 |
- **Bounded.** The pool keeps under `cache_gb` in the runner's config |
| 442 |
(default 64 GiB) by dropping the disks used longest ago. |
| 443 |
- **Pruned before it is kept** (oxygenflow#72). A kept disk would |
| 444 |
otherwise carry every build's files (each crate under every hash a |
| 445 |
lockfile or feature set gave it) until the job's disk is full. When a |
| 446 |
passing job's cache holds more than `cache_prune_percent` of its |
| 447 |
mount's `size` (runner config, default 60), the files the job did not |
| 448 |
use are removed, used longest ago first, until it is under that cap; one |
| 449 |
still over it is not kept, so the next run starts empty. A file was |
| 450 |
used if it was read, written or changed since the job started, or it |
| 451 |
shares a unit hash with one that was (a 16-digit hex word after a `-` |
| 452 |
in its path, as cargo names a unit's fingerprint, rlibs, binaries and |
| 453 |
build-script output: a crate cargo found fresh is only looked at, but |
| 454 |
its fingerprint is read), or is another link of one, or lies under one |
| 455 |
of the job's outputs. A microVM's guest (`flow-init`) prunes its disks |
| 456 |
before it powers off, having mounted them `strictatime` so every read |
| 457 |
is seen, and tells the runner what it kept (`\x1eC` frames); without |
| 458 |
word from it the runner only measures the image (`dumpe2fs`) and keeps |
| 459 |
it if it is under the cap. The syscall tier's runner prunes a directory |
| 460 |
itself, having set each file's atime to the epoch when the job took it, |
| 461 |
so even a `relatime` file system records the job's first read of each; |
| 462 |
on a `noatime` one nothing is pruned and only the size decides. The |
| 463 |
job's log says what happened: `cache target: kept 12.3 GiB of 23.1 GiB |
| 464 |
(pruned 10.8 GiB in 4113 files this run did not use; the cap is 60% of |
| 465 |
its 24.0 GiB disk)`, or `not kept:` and why. |
| 466 |
- **Unchanged sources keep their times** (oxygenflow#52). Beside each kept |
| 467 |
disk the pool keeps its sources: each workspace file's content hash and |
| 468 |
the mtime it had, in whole seconds (what the workspace image keeps). A |
| 469 |
job that takes the disk gives each file whose content is unchanged that |
| 470 |
mtime back before the workspace is imaged; a changed or new file keeps |
| 471 |
the time of its checkout, newer than anything on the disk. A build tool |
| 472 |
that judges freshness by time, such as cargo, then rebuilds exactly the |
| 473 |
crates whose sources changed, where a fresh checkout would make every |
| 474 |
path crate look changed. The log says how many files kept their times. |
| 475 |
- **Jobs go where their cache is** (oxygenflow#51). Each claim lists the |
| 476 |
caches the runner holds a disk of (`caches`, each |
| 477 |
`flow_core::api::cache_slot(repo, job)`), and the hub ranks a runner |
| 478 |
holding a job's cache above one holding its image (§11.4): a job another |
| 479 |
free runner holds the cache for is held for it for up to 15 seconds, and |
| 480 |
`placed` says "holds its build cache". When several hold it, the |
| 481 |
freshest copy wins (oxygenflow#54): claims also say when each slot last |
| 482 |
kept a disk (`cache_times`, Unix ms), and a copy kept at least a minute |
| 483 |
later ranks above an older one, before the image and speed are |
| 484 |
weighed; `placed` says how old the copy is. |
| 485 |
- **The syscall tier keeps directories** (oxygenflow#70). Its |
| 486 |
`ephemeral://disk` mount is a host directory bound at the target, so |
| 487 |
its pool holds directories (`<key>.tree`) beside the microVM's images |
| 488 |
(`<key>.ext4`), taken and kept the same way: moved out of the pool for a |
| 489 |
job of the same bookmark (so two jobs of one slot running at once never |
| 490 |
take the same one), copied with their modes and times from another |
| 491 |
bookmark's, kept only when the job and its outputs pass and otherwise |
| 492 |
thrown away, and counted by the blocks of everything in them toward |
| 493 |
`cache_gb`. A directory has no size, so `size` does not choose between |
| 494 |
them. Each tier takes only its own form: a syscall job never starts from |
| 495 |
a microVM's image, nor a microVM from a directory. The job runs as the |
| 496 |
runner's user, so what it wrote is the runner's; before a directory goes |
| 497 |
into the pool nothing in it is left writable by group or others, or |
| 498 |
set-id, and the pool is readable by the runner alone. Claims report the |
| 499 |
slots of both forms in `caches` and `cache_times`, so jobs go where |
| 500 |
their cache is whichever tier they run in. |
| 501 |
- A cache is a speed-up, never a source of truth: jobs must build |
| 502 |
correctly from an empty disk, which a key change or an eviction gives |
| 503 |
them at any time. |
| 504 |
|
| 505 |
## 6. Commutative Execution & Change Caching |
| 506 |
|
| 507 |
In Oxygen, changes that do not share scopes (referenced atoms, placements, or |
| 508 |
nodes) commute. OxygenFlow exploits this invariant to provide granular caching. |
| 509 |
|
| 510 |
### 6.1 Execution Receipt Object |
| 511 |
Every successful or failed job run emits an immutable Receipt: |
| 512 |
|
| 513 |
```cbor |
| 514 |
Receipt { |
| 515 |
version [0]: 1 |
| 516 |
job_name [1]: text |
| 517 |
pipeline_hash [2]: ObjectRef |
| 518 |
scope_tree_hash [3]: ObjectRef // BLAKE3 tree hash of the materialized scope |
| 519 |
inputs [4]: [ObjectRef] // Artifacts and input files consumed |
| 520 |
environment [5]: ObjectRef // Hash of rootfs, compiler flags, and argv |
| 521 |
outputs [6]: [ObjectRef] // Produced artifact blobs |
| 522 |
exit_code [7]: u32 |
| 523 |
duration_ms [8]: u64 |
| 524 |
} |
| 525 |
``` |
| 526 |
|
| 527 |
A Receipt is hashed as an Oxygen object with type name "receipt": |
| 528 |
ObjectRef = BLAKE3-256("oxy1 receipt " || decimal(len) || 0x00 || body) |
| 529 |
|
| 530 |
### 6.2 Cache Re-use Invariant |
| 531 |
Let Scope(J) be the file paths and tree nodes declared by job J. |
| 532 |
Because changes that do not share scopes commute, we do not need to track lists of ChangeHashes. Instead, we hash the materialized tree of Scope(J). |
| 533 |
|
| 534 |
If an existing Receipt R exists such that: |
| 535 |
R.job_name == J.name && |
| 536 |
R.pipeline_hash == Hash(J) && |
| 537 |
R.scope_tree_hash == TreeHash(Scope(J)) && |
| 538 |
R.environment == CurrentEnvironmentHash |
| 539 |
|
| 540 |
Then the job execution is bypassed. R.outputs are mapped into downstream |
| 541 |
dependents immediately as a cached-commuted hit. |
| 542 |
|
| 543 |
## 7. Native Provenance Specification |
| 544 |
|
| 545 |
OxygenFlow formalizes build provenance as a native, tamper-evident Oxygen Attest |
| 546 |
object (kind: "attest"). Provenance is generated exclusively by the runner |
| 547 |
hypervisor, not the guest payload. |
| 548 |
|
| 549 |
### 7.1 Provenance Object Schema |
| 550 |
|
| 551 |
```json |
| 552 |
Attest { |
| 553 |
version [0]: 1 |
| 554 |
kind [1]: "attest" |
| 555 |
repo [2]: bytes(16) // Oxygen Repository ID |
| 556 |
hlc [3]: HLC // Hybrid Logical Clock |
| 557 |
body [4]: { |
| 558 |
"type": "flow/provenance/v1", |
| 559 |
|
| 560 |
// Subject artifacts produced by the build |
| 561 |
"subjects": [ |
| 562 |
{ |
| 563 |
"name": text, |
| 564 |
"ref": ObjectRef, // BLAKE3 hash of output blob |
| 565 |
"media_type": text |
| 566 |
} |
| 567 |
], |
| 568 |
|
| 569 |
// Builder platform and execution identity |
| 570 |
"builder": { |
| 571 |
"runner_id": KeyId, // ObjectRef of runner's Oxygen Identity |
| 572 |
"isolation_level": text, // "wasm" | "gvisor" | "microvm" |
| 573 |
"hypervisor_image": ObjectRef // Rootfs blob digest |
| 574 |
}, |
| 575 |
|
| 576 |
// Exact input materials |
| 577 |
"materials": { |
| 578 |
"state_heads": [ChangeHash], // Oxygen heads of the state built |
| 579 |
"scope_tree_hash": ObjectRef, // Captures exact state without O(N) array bloat |
| 580 |
"inputs": [ |
| 581 |
{ |
| 582 |
"name": text, |
| 583 |
"ref": ObjectRef // Content hash of locked dependencies |
| 584 |
} |
| 585 |
] |
| 586 |
}, |
| 587 |
|
| 588 |
// Structured execution arguments |
| 589 |
"invocation": { |
| 590 |
"job": text, |
| 591 |
"pipeline_spec": ObjectRef, // ObjectRef of .flow/pipeline.kdl |
| 592 |
"argv": [text], // Unmodified execution vector |
| 593 |
"env": map<text, text> // Explicitly whitelisted environment |
| 594 |
}, |
| 595 |
|
| 596 |
// Environment observations |
| 597 |
"metadata": { |
| 598 |
"started_on": u64, // Wallclock milliseconds |
| 599 |
"finished_on": u64, |
| 600 |
"hermetic": bool, // Verified zero outbound network access |
| 601 |
"reproducible": bool |
| 602 |
} |
| 603 |
} |
| 604 |
} |
| 605 |
``` |
| 606 |
|
| 607 |
### 7.2 Composite Signatures |
| 608 |
All provenance attestations are signed using Oxygen's composite signature |
| 609 |
construction (Ed25519 and ML-DSA-65): |
| 610 |
|
| 611 |
```cbor |
| 612 |
Signature { |
| 613 |
key [0]: KeyId |
| 614 |
alg [1]: 1 // 1 = Ed25519 + ML-DSA-65 |
| 615 |
ed [2]: bytes(64) // Classical signature |
| 616 |
ml [3]: bytes(3309) // Post-quantum signature |
| 617 |
} |
| 618 |
``` |
| 619 |
|
| 620 |
Both signatures are computed over: |
| 621 |
"oxy1 sig attest\0" || KeyId || ObjectRef |
| 622 |
|
| 623 |
## 8. Policy Integration & Bookmark Gates |
| 624 |
|
| 625 |
Oxygen repositories enforce branch and bookmark protections via signed Policy |
| 626 |
objects (Oxygen Spec Section 14.5). OxygenFlow integrates provenance directly into |
| 627 |
policy evaluation during push validation. |
| 628 |
|
| 629 |
### 8.1 Policy Gate Definition |
| 630 |
A Policy rule governing a bookmark MAY demand valid OxygenFlow attestations. To mitigate a compromised authorized worker, high-security repositories can require **Consensus Builds** by setting the threshold > 1. This requires multiple independent runner identities to arrive at the exact same output hashes and both sign it. |
| 631 |
|
| 632 |
```json |
| 633 |
PolicyRule { |
| 634 |
pattern: "bookmarks/production" |
| 635 |
keys: [ KeyId ] |
| 636 |
threshold: 1 |
| 637 |
require: "signed" |
| 638 |
allow_conflicts: false |
| 639 |
require_attestations: [ |
| 640 |
{ |
| 641 |
type: "flow/provenance/v1", |
| 642 |
job: "release-provenance", |
| 643 |
require_hermetic: true, |
| 644 |
signer_roles: [ "official-worker" ] |
| 645 |
} |
| 646 |
] |
| 647 |
} |
| 648 |
|
| 649 |
### 8.2 Push Verification Sequence (VCS Engine Enforcement) |
| 650 |
When a push request arrives at an Oxygen server attempting to update a |
| 651 |
protected bookmark: |
| 652 |
1. Object Integrity: Validate that all changes, chunks, and blobs exist and match |
| 653 |
their BLAKE3 hashes. |
| 654 |
2. Attestation Resolution: Verify that an Attest object of type |
| 655 |
"flow/provenance/v1" is present, referencing the exact heads of the new |
| 656 |
State. |
| 657 |
3. Identity Verification: Validate that the attestation's composite signature |
| 658 |
resolves to a KeyId listed as an authorized delegate of the repository |
| 659 |
policy roots. |
| 660 |
4. Invariant Checking: Confirm that the provenance statement records |
| 661 |
"hermetic: true" and exit code 0. |
| 662 |
5. Atomic Update: If validation fails at any point, the entire push is refused |
| 663 |
with ok = false. |
| 664 |
|
| 665 |
## 9. Zero-Environment Secret Brokering |
| 666 |
|
| 667 |
OxygenFlow forbids the injection of raw secret strings into process environment |
| 668 |
tables to eliminate accidental leaking via child process dumping, procfs |
| 669 |
reads, and verbose compiler logging. |
| 670 |
|
| 671 |
### 9.1 Ephemeral Secret Broker |
| 672 |
1. If a job requires a credential (e.g., signing key, cloud deployment token), |
| 673 |
it declares a secret mount: |
| 674 |
secret "deploy-token" { |
| 675 |
vault-ref "infra/tokens/prod" |
| 676 |
mount-path "/run/secrets/token" |
| 677 |
} |
| 678 |
2. The runner negotiates a short-lived token lease from the configured vault. |
| 679 |
3. The secret is mounted onto an ephemeral, in-memory ramfs location inside |
| 680 |
the sandbox with access mode 0400, owned exclusively by the worker process. |
| 681 |
4. Upon process termination, the memory pages are zeroized and the token is |
| 682 |
marked for immediate revocation at the vault. |
| 683 |
|
| 684 |
## 10. Conformance and Verification Requirements |
| 685 |
|
| 686 |
A conforming OxygenFlow implementation MUST pass the following tests: |
| 687 |
|
| 688 |
1. Injection Immunity: Attempting to supply unescaped shell metacharacters |
| 689 |
(; && | ` $() \n) in any job field MUST pass those characters as literal |
| 690 |
bytes to argv; subshells MUST NOT be spawned. |
| 691 |
2. FastCDC Determinism: Chunks produced by artifact extraction MUST match |
| 692 |
the gear table cuts and BLAKE3 hashes generated by Oxygen's core chunker. |
| 693 |
3. Commutation Hit Invariance: For any two commuting changes A and B, running |
| 694 |
a pipeline on State(A, B) MUST reuse cached receipts generated on State(A) |
| 695 |
for any job whose scope does not intersect the touched paths of B. |
| 696 |
4. Non-Downgrade Attestation: An attestation missing the ML-DSA-65 component |
| 697 |
MUST be rejected by policy verification. |
| 698 |
5. Hermetic Network Isolation: Jobs configured with network "isolated" MUST |
| 699 |
have outgoing TCP SYN and UDP packets dropped at the hypervisor boundary. |
| 700 |
## 11. Runner Capabilities and Placement (proposed 2026-10-04; §11.2 and `arch` of §11.3 done the same day; tiers of §11.1, §11.5 and the waiting reasons of §11.4 done 2026-10-05, oxygenflow#36) |
| 701 |
|
| 702 |
A runner MUST never take a job it cannot run or cannot finish well: the |
| 703 |
wrong architecture, no hardware virtualisation for the MicroVM tier, too |
| 704 |
little memory, an image it does not hold, or a machine too slow for the |
| 705 |
job's time limit. Today a runner reports its architecture, OS, labels, |
| 706 |
job slots, memory and vCPUs when it registers and on each claim, and a |
| 707 |
claim carries the tiers it can run, the memory and vCPUs free now, and |
| 708 |
the images it holds; the hub matches tier, room, labels, scope and image. |
| 709 |
What is missing: architecture is reported but never matched (an image's |
| 710 |
architecture is implicit in its bytes), a pipeline cannot say which |
| 711 |
architecture it needs or offer a rootfs per architecture, speed and load |
| 712 |
play no part, and a runner cannot decline a job it was handed. |
| 713 |
|
| 714 |
### 11.1 Capability Profile |
| 715 |
|
| 716 |
At registration and on every claim a runner sends its profile: |
| 717 |
|
| 718 |
| Field | Meaning | |
| 719 |
|---|---| |
| 720 |
| `arch`, `os`, `kernel` | `x86_64`, `aarch64`, `riscv64`, …; the kernel release | |
| 721 |
| `tiers` | The sandbox tiers it can honour: `microvm` only with `/dev/kvm` usable (a board without KVM, such as an OrangePi RV2, offers `syscall`, §4.2, and `wasm`) | |
| 722 |
| `memory_mib`, `cpus`, `max_jobs` | What its jobs share | |
| 723 |
| `images` | The rootfs images it holds, each with the architecture its manifest records (§11.2) | |
| 724 |
| `labels` | As §3.1 | |
| 725 |
| `speed` | A score and a class from 1 (slow) to 5 (fast), measured by the runner at start, again every 15 minutes while it runs no job, and by `flow runner bench`: a fixed one-thread workload (BLAKE3 over 256 MiB, and a sort of 4 Mi words, where a C compiler cannot be assumed) whose time on the reference machine (Earth, about 225 ms) scores 100; class 3 is 70–139, each class a factor of two (done 2026-10-11, `facts` on the claim) | |
| 726 |
| `load` | The 1-minute load average (×100), and the free disk where jobs' disks are made, sent with every claim (`facts`); memory free and jobs running as before | |
| 727 |
|
| 728 |
### 11.2 Image Manifests Carry the Architecture |
| 729 |
|
| 730 |
`flow rootfs import` reads the ELF header of the image's `/bin/sh` and |
| 731 |
records its architecture in the CAS manifest beside the hash. A runner |
| 732 |
refuses to import an image for another architecture (there is no |
| 733 |
emulation tier). An image imported before this section has no recorded |
| 734 |
architecture and is treated as the importing runner's. |
| 735 |
|
| 736 |
### 11.3 Job Requirements |
| 737 |
|
| 738 |
Hard requirements, all of which a runner MUST satisfy to be offered the |
| 739 |
job: the sandbox's tier, its rootfs (hence the image's architecture), |
| 740 |
`memory-limit`, `cpus`, every `runs-on` label of the job and its sandbox, |
| 741 |
and the runner's scope (OxygenHub spec §13.2). Two additions to the |
| 742 |
language: |
| 743 |
|
| 744 |
- `arch "riscv64"` on a sandbox pins the architecture; without it the |
| 745 |
architecture is the rootfs image's. |
| 746 |
- A rootfs per architecture: `rootfs { x86_64 "cas://blob/…"; riscv64 "cas://blob/…" }`. |
| 747 |
The job runs on whichever architecture takes it; the receipt and the |
| 748 |
provenance record the architecture and image used, and a check's |
| 749 |
description names them. |
| 750 |
|
| 751 |
Soft requirements guide placement but never block it: a job's `timeout` |
| 752 |
against the runner's speed class. The hub keeps, per job hash and speed |
| 753 |
class, the median duration of past runs; a runner whose class predicts a |
| 754 |
duration over the limit is offered the job only when no better runner |
| 755 |
is free. |
| 756 |
|
| 757 |
### 11.4 Placement |
| 758 |
|
| 759 |
The hub filters by the hard requirements, then ranks the runners that |
| 760 |
remain: idle before busy (`load`), a higher speed class first, a runner |
| 761 |
already holding the image before one that must fetch it (§5), fewer jobs |
| 762 |
waiting on it first. A job no runner can satisfy waits and the queue |
| 763 |
says what it waits for: tier, architecture, image, memory, labels or |
| 764 |
scope, so the gap is visible rather than a silent stall. |
| 765 |
|
| 766 |
### 11.5 Declining |
| 767 |
|
| 768 |
A runner double-checks a claim before it starts: the architecture, that |
| 769 |
`/dev/kvm` opens for the MicroVM tier, that the image's blob is present |
| 770 |
and verifies, that the memory is free. If any check fails it answers the |
| 771 |
hub with `declined` and a reason instead of running and failing; the hub |
| 772 |
re-queues the job, never offers it to that runner again until its |
| 773 |
profile changes, records the decline on the runner's page, and after |
| 774 |
three declines by different runners marks the job `unplaceable` with the |
| 775 |
reasons. A decline is never a failed check on the state. |
| 776 |
|
| 777 |
As built: the runner sends `POST /api/v1/flow/jobs/{id}/decline` with |
| 778 |
`{"reason": …}` before it fetches anything. The profile is a hash of the |
| 779 |
tiers, images, architecture, labels and capacity a runner claims with. |
| 780 |
A decline also lapses after ten minutes, so a passing reason such as |
| 781 |
memory in use does not bar a runner for good. An unplaceable job ends |
| 782 |
with status `error` and an error beginning `unplaceable:`. The hub |
| 783 |
works out why each ready job waits every 15 seconds, and the run page and |
| 784 |
the API (`waiting`) show it. |
| 785 |
|
| 786 |
### 11.5.1 Restarts (oxygenflow#49, #50) |
| 787 |
|
| 788 |
Neither a hub restart nor a runner restart costs a job anything. |
| 789 |
|
| 790 |
- **The hub away.** A job's uploads (artifacts, report files) and declines |
| 791 |
are tried again through refused connections and server errors (5xx, |
| 792 |
as a proxy answers while the hub restarts) for three minutes, waiting |
| 793 |
1, 2, 4, 8 and then 15 seconds; a refusal (4xx) is final. Log lines |
| 794 |
the hub could not take are kept and sent with the next batch, 200 lines |
| 795 |
at a time, up to 50,000 (beyond that the oldest go, and the log says how |
| 796 |
many). A job's result is kept on disk and offered for two hours, as |
| 797 |
before. Claims are not retried through a lost connection, so a job is |
| 798 |
never claimed twice; the runner simply asks again. |
| 799 |
- **The runner restarted.** The first SIGTERM or SIGINT drains the runner: |
| 800 |
it claims nothing more and stops once every job it holds has ended and |
| 801 |
been reported. A second signal stops it at once. Its systemd unit says |
| 802 |
`KillMode=mixed`, so only the runner is signalled and its microVMs run |
| 803 |
on, and `TimeoutStopSec` longer than the longest job, after which |
| 804 |
systemd kills what is left. A new binary is installed by replacing it |
| 805 |
and restarting the unit (`systemctl restart --no-block`): the old runner |
| 806 |
finishes its job, and `Restart=always` starts the new one. |
| 807 |
|
| 808 |
### 11.6 Conformance |
| 809 |
|
| 810 |
1. Architecture Safety: a job whose image is `x86_64` MUST never be |
| 811 |
offered to a `riscv64` runner, with or without the image copied there. |
| 812 |
2. Tier Safety: a runner without `/dev/kvm` MUST NOT be offered a |
| 813 |
MicroVM job, and MUST decline one if handed it. |
| 814 |
3. Room: a job whose `memory-limit` exceeds the runner's free memory |
| 815 |
MUST wait, not be killed. |
| 816 |
4. Visibility: a job nobody can take MUST say why on the queue within |
| 817 |
one scheduling pass. |
| 818 |
5. Preference: with two idle runners that both satisfy a job, the hub |
| 819 |
MUST offer it to the higher speed class, and to the one holding the |
| 820 |
image when classes tie. |