| 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 |
**Priority.** A pipeline may lower its jobs' place in the hub's queue, never raise it (oxygenhub#244): `priority "low"` (or `"normal"`) on a job, or at the top of the file for every job that does not say. A job's level is then the lower of its own and its repository's (the repository's, organisation's or site's, set on the hub), so nightly or bulk jobs can stand back for everyday ones; `high` and `urgent` are refused here, being the hub's to give, and a run a person expedites stays urgent whatever its jobs say. A priority is part of the compiled job; a job without one compiles as before. |
| 183 |
|
| 184 |
**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. |
| 185 |
|
| 186 |
### 3.2 Canonical Compilation |
| 187 |
Upon commit, .flow/pipeline.kdl is validated and compiled into a deterministic |
| 188 |
CBOR job-graph blob. Runners evaluate the canonical CBOR to prevent syntax or |
| 189 |
parser ambiguities across varying client platforms. |
| 190 |
|
| 191 |
## 4. Sandbox Execution Engine |
| 192 |
|
| 193 |
OxygenFlow implements three distinct isolation tiers depending on the workload: |
| 194 |
|
| 195 |
### 4.1 Level 0: WASM Component Sandbox |
| 196 |
- Intended for static linters, formatters, doc generators, policy checks, and |
| 197 |
test suites built for `wasm32-wasip1`; the tier phones and browsers run |
| 198 |
(docs/handover.md). |
| 199 |
- Built (oxygenflow#75): wasmtime 49 runs WASI preview 1 modules, each in a |
| 200 |
fresh instance, compiled by Cranelift (tens of milliseconds a module). |
| 201 |
Preview 2 components later. |
| 202 |
- `runtime "wasm"`; no `rootfs` and no network (`network` absent or |
| 203 |
`"isolated"`); `memory-limit` holds the module's linear memory. |
| 204 |
- `exec` names the module, a path among the job's files (its scope or an |
| 205 |
earlier job's artifact), optionally under `/src/`, or a glob with `*` in |
| 206 |
its file name: each match runs in turn, and the job's exit code is the |
| 207 |
first non-zero one after all have run. The rest of `exec` are the |
| 208 |
module's arguments. A module that traps exits 134. |
| 209 |
- The job's files are the only host files it sees, preopened as `/src` (and |
| 210 |
as the current directory, for relative paths); each `mount` is preopened at |
| 211 |
its target. Secrets and identities are not offered yet. |
| 212 |
- The receipt's environment names `wasmtime` and its version, the runner |
| 213 |
binary, the memory, and every module run with its BLAKE3. |
| 214 |
|
| 215 |
### 4.2 Level 1: Intercepted Syscall Isolation (gVisor / Landlock + seccomp-bpf) |
| 216 |
- Intended for standard compilation and hermetic test suites. |
| 217 |
- Emulates or filters Linux system call interfaces in user space. |
| 218 |
- Blocks access to raw network sockets, real-time hardware clocks, and device |
| 219 |
nodes. |
| 220 |
- Memory and execution CPU quotas are strictly bounded via cgroups v2. |
| 221 |
|
| 222 |
### 4.3 Level 2: MicroVM Isolation (Firecracker / Cloud-Hypervisor) |
| 223 |
- Intended for kernel tests, multi-container integration, and untrusted code. |
| 224 |
- Ephemeral Linux microVM booting in < 80 ms. |
| 225 |
- The root filesystem is presented as a read-only virtio-block device mapped |
| 226 |
directly to FastCDC chunks stored in Oxygen's CAS. |
| 227 |
- Guest VM memory is cleared upon termination. |
| 228 |
|
| 229 |
#### 4.3.1 Allow-listed egress (oxygenflow#55) |
| 230 |
|
| 231 |
A sandbox reaches the network only by naming its hosts: |
| 232 |
|
| 233 |
```kdl |
| 234 |
sandbox "deploy" { |
| 235 |
runtime "microvm" |
| 236 |
network "egress" { allow "*.googleapis.com" "registry.opentofu.org" } |
| 237 |
} |
| 238 |
``` |
| 239 |
|
| 240 |
The VM still has no network device. `flow-init` serves a proxy on the |
| 241 |
guest's `127.0.0.1:3128` (the job gets `HTTPS_PROXY`, `HTTP_PROXY` and |
| 242 |
their lower-case forms, unless it sets them) and relays each connection, |
| 243 |
unread, over Firecracker's vsock to the runner. The runner accepts only |
| 244 |
`CONNECT host:443` for a host the list allows (a name exactly, or any |
| 245 |
name under a `*.` domain, never the domain itself; never an address), |
| 246 |
resolves it itself (the guest does not resolve), refuses any name that |
| 247 |
resolves to an address that is not public, and relays the bytes without |
| 248 |
opening the TLS. Every decision, allowed or refused, is a `flow` line in |
| 249 |
the job's log. Patterns are lower-case host names of two or more labels, |
| 250 |
at most 32 per sandbox, and part of the sandbox's hash. |
| 251 |
|
| 252 |
Egress is off unless a runner turns it on (`egress = true` under |
| 253 |
`[runner]` in `flow.toml`); such a runner offers the `egress` label, and |
| 254 |
an egress sandbox needs it, so the hub hands its jobs only to runners |
| 255 |
that allow it, and a runner without it refuses one anyway. The syscall |
| 256 |
runtime has no egress yet. |
| 257 |
|
| 258 |
**Keyless receipts** (oxygenhub#241). With `keyless = true` under |
| 259 |
`[runner]` (or in `runner.toml`), a runner signs each job's receipt with a |
| 260 |
fresh key instead of its own: just before signing, it asks the hub to |
| 261 |
certify the key (`POST /api/v1/keyless/certify` with the job's token and |
| 262 |
the key's proof of possession, scope `seal`), checks the certificate is |
| 263 |
for that key and signed by the key the hub publishes at |
| 264 |
`/.well-known/oxyhub-key`, signs, attaches the certificate and the hub's |
| 265 |
identity to the receipt (Oxygen spec §14.6), and drops the key. When the |
| 266 |
hub cannot certify, the runner's own key signs and the job's log says |
| 267 |
why. The runner's key then only authenticates it to the hub. |
| 268 |
|
| 269 |
**Metrics** (oxygenflow#35). With `metrics = "127.0.0.1:9464"` under |
| 270 |
`[runner]` (off unless set), a runner serves Prometheus text at |
| 271 |
`/metrics` on that address, from a thread of its own: its version and |
| 272 |
uptime, jobs claimed and declined, finished jobs by result (`passed`, |
| 273 |
`failed`, `error`, `cancelled`), the seconds they ran together, and the |
| 274 |
jobs running now, each labelled with the runner's name. With |
| 275 |
`metrics_token`, a scrape must send it as a bearer token; without one, |
| 276 |
bind it to an address only the scraper reaches. The hub's own `/metrics` |
| 277 |
has the queue and run times of every runner's jobs. |
| 278 |
|
| 279 |
## 5. Content-Addressable Storage (CAS) & Zero-Copy Mounting |
| 280 |
|
| 281 |
OxygenFlow runners bypass traditional clone operations. Repositories are accessed |
| 282 |
directly via the local object store (.oxy/objects/) or streamed on demand |
| 283 |
using Oxygen's wire protocol. |
| 284 |
|
| 285 |
### 5.1 FUSE Overlay Mounting (flow-casfs) |
| 286 |
1. The runner receives a task targeting an Oxygen State ObjectRef (a sorted |
| 287 |
list of maximal heads). |
| 288 |
2. The state's tree is read from a state index (Oxygen's flat, checksummed |
| 289 |
description of a history-only state: its changes, tree view and each |
| 290 |
file's deferred history with its graph key), built once from the |
| 291 |
nearest checkpoint plus the changes after it and kept beside the store. |
| 292 |
Listing a directory needs nothing else. |
| 293 |
3. The tree is exposed to the sandbox read-only through flow-casfs (FUSE): |
| 294 |
- An opaque file's bytes are its stored blob, whose chunks (FastCDC, |
| 295 |
32 KiB to 256 KiB, mean 64 KiB, Oxygen spec Section 13.2) are read as |
| 296 |
they are needed. |
| 297 |
- A text file's bytes are a rendering of its history, not a stored blob. |
| 298 |
Renderings are cached by **graph key** (equal keys mean equal |
| 299 |
renderings) as ordinary Blob and Chunk objects: a file is rendered on |
| 300 |
first read or stat, and every later state in which it has the same |
| 301 |
history finds it at once. A file added once and never edited is its |
| 302 |
own text blob and needs no rendering at all. |
| 303 |
- Missing objects are fetched lazily using the POST /objects protocol. |
| 304 |
- To prevent FUSE context-switch bottlenecks on many small reads, |
| 305 |
flow-casfs caches whole small files and mmaps chunk data, relying on |
| 306 |
the host page cache. |
| 307 |
- All guest mutations are diverted to an overlay on the job's ephemeral |
| 308 |
disk (or a tmpfs); base objects remain immutable. |
| 309 |
4. The same mount backs web workspaces (OxygenHub spec 13.3): a |
| 310 |
`workspace` job keeps it for as long as its editor is open. |
| 311 |
5. Targets, measured on the Linux tip: mount under 100 ms, a first read of |
| 312 |
a file in milliseconds, and a full read of the tree no slower than a |
| 313 |
checkout writing it. |
| 314 |
|
| 315 |
### 5.2 Artifact Capture |
| 316 |
1. At task completion, flow-casfs scans the paths declared in the output block. |
| 317 |
2. Modified outputs are chunked with FastCDC, hashed via BLAKE3-256, and |
| 318 |
written as canonical Blob and Chunk objects. |
| 319 |
3. Output ObjectRefs are stored directly in the Oxygen repository. |
| 320 |
4. Outputs are read when the job passes. An artifact with `when "always"` |
| 321 |
(default `when "passed"`) is also read when the job fails or is stopped |
| 322 |
at its time limit, so a test report survives the failure it describes. |
| 323 |
From a job that did not pass it is kept and shown, but its receipt never |
| 324 |
names it, and one that cannot be read is only noted in the log. |
| 325 |
|
| 326 |
```kdl |
| 327 |
output { |
| 328 |
artifact "junit" { |
| 329 |
path "/src/target/junit.xml" |
| 330 |
when "always" |
| 331 |
} |
| 332 |
} |
| 333 |
``` |
| 334 |
|
| 335 |
5. A `report` output (OxygenHub `docs/reports.md`) names a directory |
| 336 |
holding `report.toml` at its top. The runner reads its regular files |
| 337 |
(links and anything else are left out, never followed; file names hold |
| 338 |
letters, digits, `.`, `_`, `-` and `+`; at most 2000 files and 512 MiB), |
| 339 |
uploads each to `POST /api/v1/flow/jobs/{job}/reports/{name}/files` with |
| 340 |
its path in `X-Flow-Report-Path`, then uploads a manifest, |
| 341 |
`{"version":1,"files":[{"path","blob","size"}]}`, as the output under |
| 342 |
the report's name, checking every blob the hub answers. The receipt |
| 343 |
names the manifest. Reports and artifacts share names; a report's kind |
| 344 |
is part of the job's hash (`[name, path, "report"]`). |
| 345 |
|
| 346 |
```kdl |
| 347 |
output { |
| 348 |
report "frame-timing" { |
| 349 |
path "/src/out/report" |
| 350 |
when "always" |
| 351 |
} |
| 352 |
} |
| 353 |
``` |
| 354 |
|
| 355 |
### 5.3.1 Artifacts between jobs (oxygenflow#58) |
| 356 |
|
| 357 |
A job takes an artifact of a job it depends on: |
| 358 |
|
| 359 |
```kdl |
| 360 |
job "apply" { |
| 361 |
depends-on "plan" |
| 362 |
input { |
| 363 |
artifact "tfplan" from "plan" |
| 364 |
path "/src/tfplan" |
| 365 |
} |
| 366 |
} |
| 367 |
``` |
| 368 |
|
| 369 |
The producer must be named in `depends-on` and declare that name as an |
| 370 |
artifact (not a report); paths are clean absolute paths under `/src`, at |
| 371 |
most 8 inputs, and they are part of the job's hash (`[artifact, from, |
| 372 |
path]`, field 10). Since the producer has passed when the job is handed |
| 373 |
out, the hub puts each input in the claim with the blob the producer's |
| 374 |
receipt names (a passing job's receipt names exactly the artifacts it |
| 375 |
uploaded). Before the job starts, the runner fetches each from |
| 376 |
`GET /api/v1/flow/jobs/{job}/inputs/{index}`, checks its Oxygen blob |
| 377 |
reference against that blob, refuses the job if they differ, and writes it |
| 378 |
at its path in the workspace (not part of the scope hash: it belongs to the |
| 379 |
run, not the repository). The job's receipt lists the blobs as its |
| 380 |
`inputs`, so an apply job's receipt proves which plan it applied. |
| 381 |
|
| 382 |
### 5.3.2 Approval gates (oxygenflow#57) |
| 383 |
|
| 384 |
A job can wait for a person: |
| 385 |
|
| 386 |
```kdl |
| 387 |
job "apply" { |
| 388 |
depends-on "plan" |
| 389 |
approval { |
| 390 |
by "maintain" // the least role: "write", "maintain" (default) or "admin" |
| 391 |
others-only #true // not the person whose push started the run |
| 392 |
expires "24h" // from when the jobs it depends on are done; up to 720h |
| 393 |
sealed #true // signed with the approver's key; default: on with an environment |
| 394 |
} |
| 395 |
} |
| 396 |
``` |
| 397 |
|
| 398 |
The hub hands such a job to no runner until someone with that role on the |
| 399 |
repository approves it, on the run page or through the API; others-only |
| 400 |
leaves out whoever pushed. Approving or rejecting is recorded on the job |
| 401 |
(who, when) and in the audit log. A rejected or expired job ends as |
| 402 |
cancelled and the jobs after it are skipped; a newer push to the |
| 403 |
bookmark cancels the run as before, so a stale plan is never applied. The |
| 404 |
approval is part of the job's hash (field 11). |
| 405 |
|
| 406 |
**Sealed approvals (oxygenhub#235).** With `sealed` (the default for a job |
| 407 |
that names an environment, §5.3.3), pressing Approve is not enough: the |
| 408 |
approver signs an Oxygen `approval` attestation with their own key |
| 409 |
(`oxy seal --flow URL --job NAME`; Oxygen spec §14.4) covering the run, the |
| 410 |
job's hash, the state, the input blobs (the reviewed plan) and the |
| 411 |
environment. The hub starts the job only once it verifies, the key is |
| 412 |
linked to an account with the role, and it is not a delegated key; it keeps |
| 413 |
the approval in the repository, and the job's identity tokens name the |
| 414 |
approver and the approval's hash. Rejecting needs no seal. |
| 415 |
|
| 416 |
**Agents (oxygenhub#229, #258).** A gate is there so a person decides. A |
| 417 |
decision made through an agent's token (an agent-marked token, or a keyless |
| 418 |
agent's grant) is refused unless the repository lets agents decide its |
| 419 |
gates (its Settings › OxygenFlow, for maintainers; off by default, audited |
| 420 |
when changed). Where allowed, it says so on the job, the run and the audit |
| 421 |
log ("approved by carol via claude-code"). A sealed approval is a person's |
| 422 |
own key, whatever carried it, and is not affected. |
| 423 |
|
| 424 |
### 5.3.3 Environments (oxygenflow#59) |
| 425 |
|
| 426 |
`environment "prod"` on a job says where it deploys (1 to 32 of `a-z`, |
| 427 |
`0-9`, `-`; part of the job's hash, field 12). The hub keeps what each |
| 428 |
environment of a repository runs, from the jobs that passed for it (state, |
| 429 |
run, when, who approved), on the repository's Deployments page. The job's |
| 430 |
identity tokens carry an `environment` claim only when the job was |
| 431 |
approved (§5.3.2): a job with no approval block, or one not approved, gets |
| 432 |
tokens without it. So a cloud's trust condition can require |
| 433 |
`environment == "prod"`, and nothing unapproved deploys there, even from |
| 434 |
the deploy bookmark. The approval block is the environment's protection |
| 435 |
rule for now. |
| 436 |
|
| 437 |
### 5.4 The Build Cache (oxygenflow#28) |
| 438 |
|
| 439 |
An `ephemeral://disk` mount may name a `cache`, and the files that key it: |
| 440 |
|
| 441 |
```kdl |
| 442 |
mount { |
| 443 |
source "ephemeral://disk" |
| 444 |
target "/src/target" |
| 445 |
size "24GiB" |
| 446 |
cache "target" |
| 447 |
key "Cargo.lock" "rust-toolchain.toml" |
| 448 |
} |
| 449 |
``` |
| 450 |
|
| 451 |
Such a disk is not always empty at the start. Each runner keeps a pool of |
| 452 |
the disks of passing jobs, by repository, job, cache name and bookmark, |
| 453 |
named by the **key**: the BLAKE3 of the sandbox image, the job's name, the |
| 454 |
cache name, and each key file's content in the job's state. A job's disk |
| 455 |
starts as, in order: its bookmark's disk with the same key (moved), its |
| 456 |
bookmark's newest disk (moved), another bookmark's with the same key and |
| 457 |
then another's newest (copied, so a proposal never takes the default |
| 458 |
bookmark's), and otherwise an empty disk. A microVM's disk must be the |
| 459 |
size the mount asks for. |
| 460 |
|
| 461 |
- **Only passing jobs keep a disk.** When the job and its outputs pass, |
| 462 |
its cached disks go back into the pool under their key; otherwise they |
| 463 |
are discarded with the scratch directory, so nothing a failing job wrote |
| 464 |
reaches a later one. |
| 465 |
- **The receipt says so.** The job's environment gains `cache TARGET` with |
| 466 |
the key and what the disk started from, so the receipt's environment |
| 467 |
hash differs from a cold build's. |
| 468 |
- **Bounded.** The pool keeps under `cache_gb` in the runner's config |
| 469 |
(default 64 GiB) by dropping the disks used longest ago. |
| 470 |
- **Pruned before it is kept** (oxygenflow#72). A kept disk would |
| 471 |
otherwise carry every build's files (each crate under every hash a |
| 472 |
lockfile or feature set gave it) until the job's disk is full. When a |
| 473 |
passing job's cache holds more than `cache_prune_percent` of its |
| 474 |
mount's `size` (runner config, default 60), the files the job did not |
| 475 |
use are removed, used longest ago first, until it is under that cap; one |
| 476 |
still over it is not kept, so the next run starts empty. A file was |
| 477 |
used if it was read, written or changed since the job started, or it |
| 478 |
shares a unit hash with one that was (a 16-digit hex word after a `-` |
| 479 |
in its path, as cargo names a unit's fingerprint, rlibs, binaries and |
| 480 |
build-script output: a crate cargo found fresh is only looked at, but |
| 481 |
its fingerprint is read), or is another link of one, or lies under one |
| 482 |
of the job's outputs. A microVM's guest (`flow-init`) prunes its disks |
| 483 |
before it powers off, having mounted them `strictatime` so every read |
| 484 |
is seen, and tells the runner what it kept (`\x1eC` frames); without |
| 485 |
word from it the runner only measures the image (`dumpe2fs`) and keeps |
| 486 |
it if it is under the cap. The syscall tier's runner prunes a directory |
| 487 |
itself, having set each file's atime to the epoch when the job took it, |
| 488 |
so even a `relatime` file system records the job's first read of each; |
| 489 |
on a `noatime` one nothing is pruned and only the size decides. The |
| 490 |
job's log says what happened: `cache target: kept 12.3 GiB of 23.1 GiB |
| 491 |
(pruned 10.8 GiB in 4113 files this run did not use; the cap is 60% of |
| 492 |
its 24.0 GiB disk)`, or `not kept:` and why. |
| 493 |
- **Unchanged sources keep their times** (oxygenflow#52). Beside each kept |
| 494 |
disk the pool keeps its sources: each workspace file's content hash and |
| 495 |
the mtime it had, in whole seconds (what the workspace image keeps). A |
| 496 |
job that takes the disk gives each file whose content is unchanged that |
| 497 |
mtime back before the workspace is imaged; a changed or new file keeps |
| 498 |
the time of its checkout, newer than anything on the disk. A build tool |
| 499 |
that judges freshness by time, such as cargo, then rebuilds exactly the |
| 500 |
crates whose sources changed, where a fresh checkout would make every |
| 501 |
path crate look changed. Directories are kept the same way, by their |
| 502 |
listing (the names in them): an unchanged one gets its old time back, |
| 503 |
one that gained or lost an entry keeps its new time, so a build |
| 504 |
script's `rerun-if-changed` on a directory sees exactly what changed. |
| 505 |
The log says how many files and directories kept their times. A build |
| 506 |
script that reads a per-state value (the Oxygen crates' `FLOW_HEADS`) |
| 507 |
reruns on every state; those crates read it only when `OXY_BUILD` is |
| 508 |
unset, so a pipeline that sets `OXY_BUILD` keeps them fresh. |
| 509 |
- **Jobs go where their cache is** (oxygenflow#51). Each claim lists the |
| 510 |
caches the runner holds a disk of (`caches`, each |
| 511 |
`flow_core::api::cache_slot(repo, job)`), and the hub ranks a runner |
| 512 |
holding a job's cache above one holding its image (§11.4): a job another |
| 513 |
free runner holds the cache for is held for it for up to 15 seconds, and |
| 514 |
`placed` says "holds its build cache". When several hold it, the |
| 515 |
freshest copy wins (oxygenflow#54): claims also say when each slot last |
| 516 |
kept a disk (`cache_times`, Unix ms), and a copy kept at least a minute |
| 517 |
later ranks above an older one, before the image and speed are |
| 518 |
weighed; `placed` says how old the copy is. |
| 519 |
- **The syscall tier keeps directories** (oxygenflow#70). Its |
| 520 |
`ephemeral://disk` mount is a host directory bound at the target, so |
| 521 |
its pool holds directories (`<key>.tree`) beside the microVM's images |
| 522 |
(`<key>.ext4`), taken and kept the same way: moved out of the pool for a |
| 523 |
job of the same bookmark (so two jobs of one slot running at once never |
| 524 |
take the same one), copied with their modes and times from another |
| 525 |
bookmark's, kept only when the job and its outputs pass and otherwise |
| 526 |
thrown away, and counted by the blocks of everything in them toward |
| 527 |
`cache_gb`. A directory has no size, so `size` does not choose between |
| 528 |
them. Each tier takes only its own form: a syscall job never starts from |
| 529 |
a microVM's image, nor a microVM from a directory. The job runs as the |
| 530 |
runner's user, so what it wrote is the runner's; before a directory goes |
| 531 |
into the pool nothing in it is left writable by group or others, or |
| 532 |
set-id, and the pool is readable by the runner alone. Claims report the |
| 533 |
slots of both forms in `caches` and `cache_times`, so jobs go where |
| 534 |
their cache is whichever tier they run in. |
| 535 |
- A cache is a speed-up, never a source of truth: jobs must build |
| 536 |
correctly from an empty disk, which a key change or an eviction gives |
| 537 |
them at any time. |
| 538 |
|
| 539 |
## 6. Commutative Execution & Change Caching |
| 540 |
|
| 541 |
In Oxygen, changes that do not share scopes (referenced atoms, placements, or |
| 542 |
nodes) commute. OxygenFlow exploits this invariant to provide granular caching. |
| 543 |
|
| 544 |
### 6.1 Execution Receipt Object |
| 545 |
Every successful or failed job run emits an immutable Receipt: |
| 546 |
|
| 547 |
```cbor |
| 548 |
Receipt { |
| 549 |
version [0]: 1 |
| 550 |
job_name [1]: text |
| 551 |
pipeline_hash [2]: ObjectRef |
| 552 |
scope_tree_hash [3]: ObjectRef // BLAKE3 tree hash of the materialized scope |
| 553 |
inputs [4]: [ObjectRef] // Artifacts and input files consumed |
| 554 |
environment [5]: ObjectRef // Hash of rootfs, compiler flags, and argv |
| 555 |
outputs [6]: [ObjectRef] // Produced artifact blobs |
| 556 |
exit_code [7]: u32 |
| 557 |
duration_ms [8]: u64 |
| 558 |
} |
| 559 |
``` |
| 560 |
|
| 561 |
A Receipt is hashed as an Oxygen object with type name "receipt": |
| 562 |
ObjectRef = BLAKE3-256("oxy1 receipt " || decimal(len) || 0x00 || body) |
| 563 |
|
| 564 |
### 6.2 Cache Re-use Invariant |
| 565 |
Let Scope(J) be the file paths and tree nodes declared by job J. |
| 566 |
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). |
| 567 |
|
| 568 |
If an existing Receipt R exists such that: |
| 569 |
R.job_name == J.name && |
| 570 |
R.pipeline_hash == Hash(J) && |
| 571 |
R.scope_tree_hash == TreeHash(Scope(J)) && |
| 572 |
R.environment == CurrentEnvironmentHash |
| 573 |
|
| 574 |
Then the job execution is bypassed. R.outputs are mapped into downstream |
| 575 |
dependents immediately as a cached-commuted hit. |
| 576 |
|
| 577 |
## 7. Native Provenance Specification |
| 578 |
|
| 579 |
OxygenFlow formalizes build provenance as a native, tamper-evident Oxygen Attest |
| 580 |
object (kind: "attest"). Provenance is generated exclusively by the runner |
| 581 |
hypervisor, not the guest payload. |
| 582 |
|
| 583 |
### 7.1 Provenance Object Schema |
| 584 |
|
| 585 |
```json |
| 586 |
Attest { |
| 587 |
version [0]: 1 |
| 588 |
kind [1]: "attest" |
| 589 |
repo [2]: bytes(16) // Oxygen Repository ID |
| 590 |
hlc [3]: HLC // Hybrid Logical Clock |
| 591 |
body [4]: { |
| 592 |
"type": "flow/provenance/v1", |
| 593 |
|
| 594 |
// Subject artifacts produced by the build |
| 595 |
"subjects": [ |
| 596 |
{ |
| 597 |
"name": text, |
| 598 |
"ref": ObjectRef, // BLAKE3 hash of output blob |
| 599 |
"media_type": text |
| 600 |
} |
| 601 |
], |
| 602 |
|
| 603 |
// Builder platform and execution identity |
| 604 |
"builder": { |
| 605 |
"runner_id": KeyId, // ObjectRef of runner's Oxygen Identity |
| 606 |
"isolation_level": text, // "wasm" | "gvisor" | "microvm" |
| 607 |
"hypervisor_image": ObjectRef // Rootfs blob digest |
| 608 |
}, |
| 609 |
|
| 610 |
// Exact input materials |
| 611 |
"materials": { |
| 612 |
"state_heads": [ChangeHash], // Oxygen heads of the state built |
| 613 |
"scope_tree_hash": ObjectRef, // Captures exact state without O(N) array bloat |
| 614 |
"inputs": [ |
| 615 |
{ |
| 616 |
"name": text, |
| 617 |
"ref": ObjectRef // Content hash of locked dependencies |
| 618 |
} |
| 619 |
] |
| 620 |
}, |
| 621 |
|
| 622 |
// Structured execution arguments |
| 623 |
"invocation": { |
| 624 |
"job": text, |
| 625 |
"pipeline_spec": ObjectRef, // ObjectRef of .flow/pipeline.kdl |
| 626 |
"argv": [text], // Unmodified execution vector |
| 627 |
"env": map<text, text> // Explicitly whitelisted environment |
| 628 |
}, |
| 629 |
|
| 630 |
// Environment observations |
| 631 |
"metadata": { |
| 632 |
"started_on": u64, // Wallclock milliseconds |
| 633 |
"finished_on": u64, |
| 634 |
"hermetic": bool, // Verified zero outbound network access |
| 635 |
"reproducible": bool |
| 636 |
} |
| 637 |
} |
| 638 |
} |
| 639 |
``` |
| 640 |
|
| 641 |
### 7.2 Composite Signatures |
| 642 |
All provenance attestations are signed using Oxygen's composite signature |
| 643 |
construction (Ed25519 and ML-DSA-65): |
| 644 |
|
| 645 |
```cbor |
| 646 |
Signature { |
| 647 |
key [0]: KeyId |
| 648 |
alg [1]: 1 // 1 = Ed25519 + ML-DSA-65 |
| 649 |
ed [2]: bytes(64) // Classical signature |
| 650 |
ml [3]: bytes(3309) // Post-quantum signature |
| 651 |
} |
| 652 |
``` |
| 653 |
|
| 654 |
Both signatures are computed over: |
| 655 |
"oxy1 sig attest\0" || KeyId || ObjectRef |
| 656 |
|
| 657 |
## 8. Policy Integration & Bookmark Gates |
| 658 |
|
| 659 |
Oxygen repositories enforce branch and bookmark protections via signed Policy |
| 660 |
objects (Oxygen Spec Section 14.5). OxygenFlow integrates provenance directly into |
| 661 |
policy evaluation during push validation. |
| 662 |
|
| 663 |
### 8.1 Policy Gate Definition |
| 664 |
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. |
| 665 |
|
| 666 |
```json |
| 667 |
PolicyRule { |
| 668 |
pattern: "bookmarks/production" |
| 669 |
keys: [ KeyId ] |
| 670 |
threshold: 1 |
| 671 |
require: "signed" |
| 672 |
allow_conflicts: false |
| 673 |
require_attestations: [ |
| 674 |
{ |
| 675 |
type: "flow/provenance/v1", |
| 676 |
job: "release-provenance", |
| 677 |
require_hermetic: true, |
| 678 |
signer_roles: [ "official-worker" ] |
| 679 |
} |
| 680 |
] |
| 681 |
} |
| 682 |
|
| 683 |
### 8.2 Push Verification Sequence (VCS Engine Enforcement) |
| 684 |
When a push request arrives at an Oxygen server attempting to update a |
| 685 |
protected bookmark: |
| 686 |
1. Object Integrity: Validate that all changes, chunks, and blobs exist and match |
| 687 |
their BLAKE3 hashes. |
| 688 |
2. Attestation Resolution: Verify that an Attest object of type |
| 689 |
"flow/provenance/v1" is present, referencing the exact heads of the new |
| 690 |
State. |
| 691 |
3. Identity Verification: Validate that the attestation's composite signature |
| 692 |
resolves to a KeyId listed as an authorized delegate of the repository |
| 693 |
policy roots. |
| 694 |
4. Invariant Checking: Confirm that the provenance statement records |
| 695 |
"hermetic: true" and exit code 0. |
| 696 |
5. Atomic Update: If validation fails at any point, the entire push is refused |
| 697 |
with ok = false. |
| 698 |
|
| 699 |
## 9. Zero-Environment Secret Brokering |
| 700 |
|
| 701 |
OxygenFlow forbids the injection of raw secret strings into process environment |
| 702 |
tables to eliminate accidental leaking via child process dumping, procfs |
| 703 |
reads, and verbose compiler logging. |
| 704 |
|
| 705 |
### 9.1 Ephemeral Secret Broker |
| 706 |
1. If a job requires a credential (e.g., signing key, cloud deployment token), |
| 707 |
it declares a secret mount: |
| 708 |
secret "deploy-token" { |
| 709 |
vault-ref "infra/tokens/prod" |
| 710 |
mount-path "/run/secrets/token" |
| 711 |
} |
| 712 |
2. The runner negotiates a short-lived token lease from the configured vault. |
| 713 |
3. The secret is mounted onto an ephemeral, in-memory ramfs location inside |
| 714 |
the sandbox with access mode 0400, owned exclusively by the worker process. |
| 715 |
4. Upon process termination, the memory pages are zeroized and the token is |
| 716 |
marked for immediate revocation at the vault. |
| 717 |
|
| 718 |
## 10. Conformance and Verification Requirements |
| 719 |
|
| 720 |
A conforming OxygenFlow implementation MUST pass the following tests: |
| 721 |
|
| 722 |
1. Injection Immunity: Attempting to supply unescaped shell metacharacters |
| 723 |
(; && | ` $() \n) in any job field MUST pass those characters as literal |
| 724 |
bytes to argv; subshells MUST NOT be spawned. |
| 725 |
2. FastCDC Determinism: Chunks produced by artifact extraction MUST match |
| 726 |
the gear table cuts and BLAKE3 hashes generated by Oxygen's core chunker. |
| 727 |
3. Commutation Hit Invariance: For any two commuting changes A and B, running |
| 728 |
a pipeline on State(A, B) MUST reuse cached receipts generated on State(A) |
| 729 |
for any job whose scope does not intersect the touched paths of B. |
| 730 |
4. Non-Downgrade Attestation: An attestation missing the ML-DSA-65 component |
| 731 |
MUST be rejected by policy verification. |
| 732 |
5. Hermetic Network Isolation: Jobs configured with network "isolated" MUST |
| 733 |
have outgoing TCP SYN and UDP packets dropped at the hypervisor boundary. |
| 734 |
## 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) |
| 735 |
|
| 736 |
A runner MUST never take a job it cannot run or cannot finish well: the |
| 737 |
wrong architecture, no hardware virtualisation for the MicroVM tier, too |
| 738 |
little memory, an image it does not hold, or a machine too slow for the |
| 739 |
job's time limit. Today a runner reports its architecture, OS, labels, |
| 740 |
job slots, memory and vCPUs when it registers and on each claim, and a |
| 741 |
claim carries the tiers it can run, the memory and vCPUs free now, and |
| 742 |
the images it holds; the hub matches tier, room, labels, scope and image. |
| 743 |
What is missing: architecture is reported but never matched (an image's |
| 744 |
architecture is implicit in its bytes), a pipeline cannot say which |
| 745 |
architecture it needs or offer a rootfs per architecture, speed and load |
| 746 |
play no part, and a runner cannot decline a job it was handed. |
| 747 |
|
| 748 |
### 11.1 Capability Profile |
| 749 |
|
| 750 |
At registration and on every claim a runner sends its profile: |
| 751 |
|
| 752 |
| Field | Meaning | |
| 753 |
|---|---| |
| 754 |
| `arch`, `os`, `kernel` | `x86_64`, `aarch64`, `riscv64`, …; the kernel release | |
| 755 |
| `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`) | |
| 756 |
| `memory_mib`, `cpus`, `max_jobs` | What its jobs share | |
| 757 |
| `images` | The rootfs images it holds, each with the architecture its manifest records (§11.2) | |
| 758 |
| `labels` | As §3.1 | |
| 759 |
| `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) | |
| 760 |
| `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 | |
| 761 |
|
| 762 |
### 11.2 Image Manifests Carry the Architecture |
| 763 |
|
| 764 |
`flow rootfs import` reads the ELF header of the image's `/bin/sh` and |
| 765 |
records its architecture in the CAS manifest beside the hash. A runner |
| 766 |
refuses to import an image for another architecture (there is no |
| 767 |
emulation tier). |
| 768 |
|
| 769 |
An image whose manifest records no architecture (imported before this |
| 770 |
section, or fetched from a hub that did not know it) is read, not |
| 771 |
guessed: `jet::rootfs` follows `/bin/sh` (else busybox, env or bash) |
| 772 |
through the image's ext4, symlinks included, to its ELF header, with no |
| 773 |
mount and no privileges. The runner records the answer in the manifest, |
| 774 |
so each image is read once, reports it with its claims, and declines a |
| 775 |
job whose image is for another architecture instead of running it and |
| 776 |
failing (§11.5). The hub reads the images it keeps the same way |
| 777 |
(OxygenHub, oxygenhub#173), and an image nobody can read is offered only |
| 778 |
to the architecture its jobs have passed on before. |
| 779 |
|
| 780 |
### 11.3 Job Requirements |
| 781 |
|
| 782 |
Hard requirements, all of which a runner MUST satisfy to be offered the |
| 783 |
job: the sandbox's tier, its rootfs (hence the image's architecture), |
| 784 |
`memory-limit`, `cpus`, every `runs-on` label of the job and its sandbox, |
| 785 |
and the runner's scope (OxygenHub spec §13.2). Two additions to the |
| 786 |
language: |
| 787 |
|
| 788 |
- `arch "riscv64"` on a sandbox pins the architecture; without it the |
| 789 |
architecture is the rootfs image's. |
| 790 |
- A rootfs per architecture: `rootfs { x86_64 "cas://blob/…"; riscv64 "cas://blob/…" }`. |
| 791 |
The job runs on whichever architecture takes it; the receipt and the |
| 792 |
provenance record the architecture and image used, and a check's |
| 793 |
description names them. |
| 794 |
|
| 795 |
Soft requirements guide placement but never block it: a job's `timeout` |
| 796 |
against the runner's speed class. The hub keeps, per job hash and speed |
| 797 |
class, the median duration of past runs; a runner whose class predicts a |
| 798 |
duration over the limit is offered the job only when no better runner |
| 799 |
is free. |
| 800 |
|
| 801 |
### 11.4 Placement |
| 802 |
|
| 803 |
The hub filters by the hard requirements, then ranks the runners that |
| 804 |
remain: idle before busy (`load`), a higher speed class first, a runner |
| 805 |
already holding the image before one that must fetch it (§5), fewer jobs |
| 806 |
waiting on it first. A job no runner can satisfy waits and the queue |
| 807 |
says what it waits for: tier, architecture, image, memory, labels or |
| 808 |
scope, so the gap is visible rather than a silent stall. |
| 809 |
|
| 810 |
### 11.5 Declining |
| 811 |
|
| 812 |
A runner double-checks a claim before it starts: the architecture, that |
| 813 |
`/dev/kvm` opens for the MicroVM tier, that the image's blob is present |
| 814 |
and verifies, that the memory is free. If any check fails it answers the |
| 815 |
hub with `declined` and a reason instead of running and failing; the hub |
| 816 |
re-queues the job, never offers it to that runner again until its |
| 817 |
profile changes, records the decline on the runner's page, and after |
| 818 |
three declines by different runners marks the job `unplaceable` with the |
| 819 |
reasons. A decline is never a failed check on the state. |
| 820 |
|
| 821 |
As built: the runner sends `POST /api/v1/flow/jobs/{id}/decline` with |
| 822 |
`{"reason": …}` before it fetches anything. The profile is a hash of the |
| 823 |
tiers, images, architecture, labels and capacity a runner claims with. |
| 824 |
A decline also lapses after ten minutes, so a passing reason such as |
| 825 |
memory in use does not bar a runner for good. An unplaceable job ends |
| 826 |
with status `error` and an error beginning `unplaceable:`. The hub |
| 827 |
works out why each ready job waits every 15 seconds, and the run page and |
| 828 |
the API (`waiting`) show it. |
| 829 |
|
| 830 |
### 11.5.1 Restarts (oxygenflow#49, #50) |
| 831 |
|
| 832 |
Neither a hub restart nor a runner restart costs a job anything. |
| 833 |
|
| 834 |
- **The hub away.** A job's uploads (artifacts, report files) and declines |
| 835 |
are tried again through refused connections and server errors (5xx, |
| 836 |
as a proxy answers while the hub restarts) for three minutes, waiting |
| 837 |
1, 2, 4, 8 and then 15 seconds; a refusal (4xx) is final. Log lines |
| 838 |
the hub could not take are kept and sent with the next batch, 200 lines |
| 839 |
at a time, up to 50,000 (beyond that the oldest go, and the log says how |
| 840 |
many). A job's result is kept on disk and offered for two hours, as |
| 841 |
before. Claims are not retried through a lost connection, so a job is |
| 842 |
never claimed twice; the runner simply asks again. |
| 843 |
- **The runner restarted.** The first SIGTERM or SIGINT drains the runner: |
| 844 |
it claims nothing more and stops once every job it holds has ended and |
| 845 |
been reported. A second signal stops it at once. Its systemd unit says |
| 846 |
`KillMode=mixed`, so only the runner is signalled and its microVMs run |
| 847 |
on, and `TimeoutStopSec` longer than the longest job, after which |
| 848 |
systemd kills what is left. A new binary is installed by replacing it |
| 849 |
and restarting the unit (`systemctl restart --no-block`): the old runner |
| 850 |
finishes its job, and `Restart=always` starts the new one. |
| 851 |
`flow runner drain` asks the same by a `drain.request` file in the |
| 852 |
runner's home, which its loop reads: the way on Windows, which has no |
| 853 |
SIGTERM (oxygenflow#83). |
| 854 |
|
| 855 |
### 11.5.2 Handing back (oxygenflow#76) |
| 856 |
|
| 857 |
A runner that leaves while it holds a job, with no time to finish it, |
| 858 |
hands the job back: `POST /api/v1/flow/jobs/{id}/release` with |
| 859 |
`{"reason": …}` (a phone off its allowed network, docs/handover.md §6). |
| 860 |
The hub queues the job again **ahead of new work**, whatever its |
| 861 |
priority, adds a line to its log saying who handed it back and why, and |
| 862 |
lets the next attempt's log follow on. It is not a decline: the runner is |
| 863 |
not held from the job and it does not count towards unplaceable. |
| 864 |
|
| 865 |
**Leases.** Holding a job is a lease the runner keeps by being heard from: |
| 866 |
claims, a heartbeat every 30 seconds while it claims nothing, its jobs' |
| 867 |
logs. When the hub has not heard from a runner for 3 minutes, its jobs |
| 868 |
are handed back the same way, so a runner that vanishes (out of every |
| 869 |
network, crashed) costs a restart of its job, not the job. A job handed |
| 870 |
back more than 5 times ends as an error saying so. |
| 871 |
|
| 872 |
**Notice.** A departure may come with notice (a phone taken off charge |
| 873 |
has a minute; a spot reclaim 30 seconds to 2 minutes). A claim carries |
| 874 |
`expected_ms`, how long the job is expected to take, when the hub has |
| 875 |
learned it: the median of the job's last five passing runs in that |
| 876 |
repository (reused passes left out). A leaving runner finishes and |
| 877 |
reports a job whose expected rest fits within the notice, and hands back |
| 878 |
the others at once, and any job still running when the notice is up. A |
| 879 |
job with no expected time is handed back at once. |
| 880 |
|
| 881 |
**Checkpoints (#77).** A job may name directories that hold its progress: |
| 882 |
`checkpoint "/src/target"` (one or more, under /src, not /src itself). |
| 883 |
When its runner hands it back, the runner packs them (files with their |
| 884 |
modes and modification times, which cargo's fingerprints read; at most |
| 885 |
512 MiB) and keeps them with `POST /api/v1/flow/jobs/{id}/checkpoint` |
| 886 |
before the release; the claim of the next attempt names the blob |
| 887 |
(`checkpoint`), and that runner fetches it (`GET` on the same path), |
| 888 |
checks it, and restores it over the job's cache before running the same |
| 889 |
exec, so a tool that skips finished work carries on. The receipt lists |
| 890 |
the checkpoint among the job's inputs. As built: the WASM and syscall |
| 891 |
tiers save and restore checkpoints, under a disk mount or in the tree; |
| 892 |
the MicroVM tier, whose files are in an image, starts again for now. |
| 893 |
|
| 894 |
As built (flow 0.3.2, OxygenHub 0.9.4): the runner as a library has |
| 895 |
`runner::leave(reason)` (no notice) and `runner::leave_within(reason, |
| 896 |
notice)` beside `runner::stop()`: each claims nothing more and stops the |
| 897 |
jobs it does not finish (their logs sent first), releasing them; if the |
| 898 |
release cannot be sent, the lease does it. A host's SIGTERM still drains |
| 899 |
by finishing what it holds. Checkpoints (#77), so the next attempt |
| 900 |
resumes rather than restarts, come next. |
| 901 |
|
| 902 |
### 11.5.3 Updating a runner (oxygenflow#85) |
| 903 |
|
| 904 |
`flow runner update --from FILE` installs a new `flow` build in place of |
| 905 |
the one it runs as (`--exe` names another), the runner's side of |
| 906 |
self-updating runners (docs/jetstream.md §8.2): |
| 907 |
|
| 908 |
1. **Refused** when the home's `flow.toml` says `[runner.update] allow = |
| 909 |
false`, when an earlier update is not yet proven, or when the build is |
| 910 |
older than the installed one (unless `--allow-older`). |
| 911 |
2. **Checked:** a 64-bit little-endian ELF for this machine; it names |
| 912 |
itself (`--version`, `flow X.Y.Z …`) and finds a tier it can run here |
| 913 |
(`tiers` against the runner's home), each within 30 seconds. |
| 914 |
3. **Staged and swapped:** copied beside the installed binary as |
| 915 |
`flow.new` and checked there; the installed one is kept as |
| 916 |
`flow.prev` (a hard link, else a copy), then `flow.new` replaces it in |
| 917 |
one rename, so `flow` is never missing. `--dry-run` stops before the |
| 918 |
swap and changes nothing. |
| 919 |
4. **The running runner leaves:** a runner writes its pid to |
| 920 |
`runner.pid` in its home, and the update sends it SIGUSR1 (after |
| 921 |
checking that pid is still a `flow … runner`). It then leaves with |
| 922 |
`--notice` (default 600 s, §11.5.2): jobs expected to finish in time |
| 923 |
finish, the rest are handed back with their checkpoints, and it exits |
| 924 |
0, so its unit's `Restart=always` starts the new build. A runner from |
| 925 |
before #85 is not told; restarting its unit drains it as before. |
| 926 |
5. **Proven or rolled back:** the update waits in `update.json` until the |
| 927 |
new build's first claim gets an answer from the hub. Each start counts; |
| 928 |
on the fourth start without that answer, or after ten minutes running |
| 929 |
without it, `flow.prev` is put back (and on a start, run in its place). |
| 930 |
`updates.log` in the home records every install, proof and rollback. |
| 931 |
The runner tells the hub at its next answer (`POST /api/v1/flow/runner/update`): |
| 932 |
`runs flow X (was Y); proven`, or `rolled back from flow X to Y: why`, |
| 933 |
on which the hub holds X back from every other runner until an |
| 934 |
administrator lifts the hold (oxygenhub#326). |
| 935 |
6. **On Windows** (oxygenflow#83) the same steps run with Windows' means: |
| 936 |
the build is checked as a PE executable for this machine; `flow.exe` |
| 937 |
is renamed to `flow.prev.exe` and `flow.new.exe` into its place (a |
| 938 |
running program can be renamed but not replaced, and cannot rename |
| 939 |
its own file, so a copy of it, `flow __rename`, does that); the runner |
| 940 |
is told by an `update.request` file in its home, which it reads in its |
| 941 |
loop, there being no SIGUSR1; and having left, it exits 75, so a Task |
| 942 |
Scheduler task set to restart on failure starts the new build. A |
| 943 |
rollback runs the restored build in its place and ends with it. |
| 944 |
|
| 945 |
### 11.6 Conformance |
| 946 |
|
| 947 |
1. Architecture Safety: a job whose image is `x86_64` MUST never be |
| 948 |
offered to a `riscv64` runner, with or without the image copied there. |
| 949 |
2. Tier Safety: a runner without `/dev/kvm` MUST NOT be offered a |
| 950 |
MicroVM job, and MUST decline one if handed it. |
| 951 |
3. Room: a job whose `memory-limit` exceeds the runner's free memory |
| 952 |
MUST wait, not be killed. |
| 953 |
4. Visibility: a job nobody can take MUST say why on the queue within |
| 954 |
one scheduling pass. |
| 955 |
5. Preference: with two idle runners that both satisfy a job, the hub |
| 956 |
MUST offer it to the higher speed class, and to the one holding the |
| 957 |
image when classes tie. |