a083cc80 Andy McCall 1 month ago ↶
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
b647ab72 Andy McCall 1 month ago ↶
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
a083cc80 Andy McCall 1 month ago ↶
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
ea81796b Andy McCall 20 days ago ↶
94
network "isolated" // Options: "isolated" | "loopback" | "egress" { allow "host"... } (§4.3.1)
a083cc80 Andy McCall 1 month ago ↶
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
bf7bb1b8 Andy McCall 1 month ago ↶
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
02b127d2 Andy McCall 1 month ago ↶
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
e576e086 Andy McCall 1 month ago ↶
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
a083cc80 Andy McCall 1 month ago ↶
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, and policy checks.
195
- Runs on a standard WebAssembly System Interface (WASI) runtime.
196
- Startup latency: < 5 ms.
197
- Zero host I/O; virtualized file system presented in-memory.
198
199
### 4.2 Level 1: Intercepted Syscall Isolation (gVisor / Landlock + seccomp-bpf)
200
- Intended for standard compilation and hermetic test suites.
201
- Emulates or filters Linux system call interfaces in user space.
202
- Blocks access to raw network sockets, real-time hardware clocks, and device
203
nodes.
204
- Memory and execution CPU quotas are strictly bounded via cgroups v2.
205
206
### 4.3 Level 2: MicroVM Isolation (Firecracker / Cloud-Hypervisor)
207
- Intended for kernel tests, multi-container integration, and untrusted code.
208
- Ephemeral Linux microVM booting in < 80 ms.
209
- The root filesystem is presented as a read-only virtio-block device mapped
210
directly to FastCDC chunks stored in Oxygen's CAS.
211
- Guest VM memory is cleared upon termination.
212
ea81796b Andy McCall 20 days ago ↶
213
#### 4.3.1 Allow-listed egress (oxygenflow#55)
214
215
A sandbox reaches the network only by naming its hosts:
216
217
``` kdl
218
sandbox "deploy" {
219
runtime "microvm"
220
network "egress" { allow "*.googleapis.com" "registry.opentofu.org" }
221
}
222
```
223
224
The VM still has no network device. ` flow-init` serves a proxy on the
225
guest's ` 127.0.0.1:3128` (the job gets ` HTTPS_PROXY` , ` HTTP_PROXY` and
226
their lower-case forms, unless it sets them) and relays each connection,
227
unread, over Firecracker's vsock to the runner. The runner accepts only
228
` CONNECT host:443` for a host the list allows (a name exactly, or any
229
name under a ` *.` domain, never the domain itself; never an address),
230
resolves it itself (the guest does not resolve), refuses any name that
231
resolves to an address that is not public, and relays the bytes without
232
opening the TLS. Every decision, allowed or refused, is a ` flow` line in
233
the job's log. Patterns are lower-case host names of two or more labels,
234
at most 32 per sandbox, and part of the sandbox's hash.
235
236
Egress is off unless a runner turns it on (` egress = true` under
237
` [runner]` in ` flow.toml` ); such a runner offers the ` egress` label, and
238
an egress sandbox needs it, so the hub hands its jobs only to runners
239
that allow it, and a runner without it refuses one anyway. The syscall
240
runtime has no egress yet.
241
35f7a849 Andy McCall 18 days ago ↶
242
** Keyless receipts** (oxygenhub#241). With ` keyless = true` under
243
` [runner]` (or in ` runner.toml` ), a runner signs each job's receipt with a
244
fresh key instead of its own: just before signing, it asks the hub to
245
certify the key (` POST /api/v1/keyless/certify` with the job's token and
246
the key's proof of possession, scope ` seal` ), checks the certificate is
247
for that key and signed by the key the hub publishes at
248
` /.well-known/oxyhub-key` , signs, attaches the certificate and the hub's
249
identity to the receipt (Oxygen spec §14.6), and drops the key. When the
250
hub cannot certify, the runner's own key signs and the job's log says
251
why. The runner's key then only authenticates it to the hub.
252
4be5e4cd Andy McCall 15 days ago ↶
253
** Metrics** (oxygenflow#35). With ` metrics = "127.0.0.1:9464"` under
254
` [runner]` (off unless set), a runner serves Prometheus text at
255
` /metrics` on that address, from a thread of its own: its version and
256
uptime, jobs claimed and declined, finished jobs by result (` passed` ,
257
` failed` , ` error` , ` cancelled` ), the seconds they ran together, and the
258
jobs running now, each labelled with the runner's name. With
259
` metrics_token` , a scrape must send it as a bearer token; without one,
260
bind it to an address only the scraper reaches. The hub's own ` /metrics`
261
has the queue and run times of every runner's jobs.
262
a083cc80 Andy McCall 1 month ago ↶
263
## 5. Content-Addressable Storage (CAS) & Zero-Copy Mounting
264
265
OxygenFlow runners bypass traditional clone operations. Repositories are accessed
266
directly via the local object store (.oxy/objects/) or streamed on demand
267
using Oxygen's wire protocol.
268
269
### 5.1 FUSE Overlay Mounting (flow-casfs)
270
1. The runner receives a task targeting an Oxygen State ObjectRef (a sorted
271
list of maximal heads).
272
2. The nearest Checkpoint is resolved. Uncommitted changes between the
273
checkpoint and heads are replayed into a virtual tree in-memory.
274
3. The virtual tree is exposed to the sandbox via flow-casfs:
275
- File reads resolve chunks using the FastCDC gear table (32 KiB to 256 KiB,
276
mean 64 KiB) pinned in Oxygen Spec Section 13.2.
277
- Missing chunks are fetched lazily using the POST /objects protocol.
278
- To prevent FUSE context-switch bottlenecks on many small reads, flow-casfs aggressively mmaps chunk data and utilizes host page caching.
279
- All guest mutations are diverted to a scratch tmpfs layer; base objects
280
remain immutable.
281
282
### 5.2 Artifact Capture
283
1. At task completion, flow-casfs scans the paths declared in the output block.
284
2. Modified outputs are chunked with FastCDC, hashed via BLAKE3-256, and
285
written as canonical Blob and Chunk objects.
286
3. Output ObjectRefs are stored directly in the Oxygen repository.
9a6fac2b Andy McCall 1 month ago ↶
287
4. Outputs are read when the job passes. An artifact with ` when "always"`
288
(default ` when "passed"` ) is also read when the job fails or is stopped
289
at its time limit, so a test report survives the failure it describes.
290
From a job that did not pass it is kept and shown, but its receipt never
291
names it, and one that cannot be read is only noted in the log.
a083cc80 Andy McCall 1 month ago ↶
292
9a6fac2b Andy McCall 1 month ago ↶
293
``` kdl
294
output {
295
artifact "junit" {
296
path "/src/target/junit.xml"
297
when "always"
298
}
299
}
300
```
301
6d53fb7d Andy McCall 25 days ago ↶
302
5. A ` report` output (OxygenHub ` docs/reports.md` ) names a directory
303
holding ` report.toml` at its top. The runner reads its regular files
304
(links and anything else are left out, never followed; file names hold
305
letters, digits, ` .` , ` _` , ` -` and ` +` ; at most 2000 files and 512 MiB),
306
uploads each to ` POST /api/v1/flow/jobs/{job}/reports/{name}/files` with
307
its path in ` X-Flow-Report-Path` , then uploads a manifest,
308
` {"version":1,"files":[{"path","blob","size"}]}` , as the output under
309
the report's name, checking every blob the hub answers. The receipt
310
names the manifest. Reports and artifacts share names; a report's kind
311
is part of the job's hash (` [name, path, "report"]` ).
312
313
``` kdl
314
output {
315
report "frame-timing" {
316
path "/src/out/report"
317
when "always"
318
}
319
}
320
```
321
46fe2c81 Andy McCall 20 days ago ↶
322
### 5.3.1 Artifacts between jobs (oxygenflow#58)
323
324
A job takes an artifact of a job it depends on:
325
326
``` kdl
327
job "apply" {
328
depends-on "plan"
329
input {
330
artifact "tfplan" from "plan"
331
path "/src/tfplan"
332
}
333
}
334
```
335
336
The producer must be named in ` depends-on` and declare that name as an
337
artifact (not a report); paths are clean absolute paths under ` /src` , at
338
most 8 inputs, and they are part of the job's hash (` [artifact, from,
339
path]` , field 10). Since the producer has passed when the job is handed
340
out, the hub puts each input in the claim with the blob the producer's
341
receipt names (a passing job's receipt names exactly the artifacts it
342
uploaded). Before the job starts, the runner fetches each from
343
` GET /api/v1/flow/jobs/{job}/inputs/{index}` , checks its Oxygen blob
344
reference against that blob, refuses the job if they differ, and writes it
345
at its path in the workspace (not part of the scope hash: it belongs to the
346
run, not the repository). The job's receipt lists the blobs as its
347
` inputs` , so an apply job's receipt proves which plan it applied.
348
e2089721 Andy McCall 20 days ago ↶
349
### 5.3.2 Approval gates (oxygenflow#57)
350
351
A job can wait for a person:
352
353
``` kdl
354
job "apply" {
355
depends-on "plan"
356
approval {
357
by "maintain" // the least role: "write", "maintain" (default) or "admin"
358
others-only #true // not the person whose push started the run
359
expires "24h" // from when the jobs it depends on are done; up to 720h
2be1fe4a Andy McCall 20 days ago ↶
360
sealed #true // signed with the approver's key; default: on with an environment
e2089721 Andy McCall 20 days ago ↶
361
}
362
}
363
```
364
365
The hub hands such a job to no runner until someone with that role on the
366
repository approves it, on the run page or through the API; others-only
367
leaves out whoever pushed. Approving or rejecting is recorded on the job
368
(who, when) and in the audit log. A rejected or expired job ends as
369
cancelled and the jobs after it are skipped; a newer push to the
370
bookmark cancels the run as before, so a stale plan is never applied. The
371
approval is part of the job's hash (field 11).
372
2be1fe4a Andy McCall 20 days ago ↶
373
** Sealed approvals (oxygenhub#235).** With ` sealed` (the default for a job
374
that names an environment, §5.3.3), pressing Approve is not enough: the
375
approver signs an Oxygen ` approval` attestation with their own key
376
(` oxy seal --flow URL --job NAME` ; Oxygen spec §14.4) covering the run, the
377
job's hash, the state, the input blobs (the reviewed plan) and the
378
environment. The hub starts the job only once it verifies, the key is
379
linked to an account with the role, and it is not a delegated key; it keeps
380
the approval in the repository, and the job's identity tokens name the
381
approver and the approval's hash. Rejecting needs no seal.
382
f4e51e63 Andy McCall 20 days ago ↶
383
### 5.3.3 Environments (oxygenflow#59)
384
385
` environment "prod"` on a job says where it deploys (1 to 32 of ` a-z` ,
386
` 0-9` , ` -` ; part of the job's hash, field 12). The hub keeps what each
387
environment of a repository runs, from the jobs that passed for it (state,
388
run, when, who approved), on the repository's Deployments page. The job's
389
identity tokens carry an ` environment` claim only when the job was
390
approved (§5.3.2): a job with no approval block, or one not approved, gets
391
tokens without it. So a cloud's trust condition can require
392
` environment == "prod"` , and nothing unapproved deploys there, even from
393
the deploy bookmark. The approval block is the environment's protection
394
rule for now.
395
d60aac6c Andy McCall 22 days ago ↶
396
### 5.4 The Build Cache (oxygenflow#28)
397
398
An ` ephemeral://disk` mount may name a ` cache` , and the files that key it:
399
400
``` kdl
401
mount {
402
source "ephemeral://disk"
403
target "/src/target"
404
size "24GiB"
405
cache "target"
406
key "Cargo.lock" "rust-toolchain.toml"
407
}
408
```
409
410
Such a disk is not always empty at the start. Each runner keeps a pool of
411
the disks of passing jobs, by repository, job, cache name and bookmark,
412
named by the ** key** : the BLAKE3 of the sandbox image, the job's name, the
413
cache name, and each key file's content in the job's state. A job's disk
414
starts as, in order: its bookmark's disk with the same key (moved), its
415
bookmark's newest disk (moved), another bookmark's with the same key and
416
then another's newest (copied, so a proposal never takes the default
2594278f Andy McCall 16 days ago ↶
417
bookmark's), and otherwise an empty disk. A microVM's disk must be the
418
size the mount asks for.
d60aac6c Andy McCall 22 days ago ↶
419
420
- ** Only passing jobs keep a disk.** When the job and its outputs pass,
421
its cached disks go back into the pool under their key; otherwise they
422
are discarded with the scratch directory, so nothing a failing job wrote
423
reaches a later one.
424
- ** The receipt says so.** The job's environment gains ` cache TARGET` with
425
the key and what the disk started from, so the receipt's environment
426
hash differs from a cold build's.
427
- ** Bounded.** The pool keeps under ` cache_gb` in the runner's config
428
(default 64 GiB) by dropping the disks used longest ago.
077a6524 Andy McCall 16 days ago ↶
429
- ** Pruned before it is kept** (oxygenflow#72). A kept disk would
430
otherwise carry every build's files (each crate under every hash a
431
lockfile or feature set gave it) until the job's disk is full. When a
432
passing job's cache holds more than ` cache_prune_percent` of its
433
mount's ` size` (runner config, default 60), the files the job did not
434
use are removed, used longest ago first, until it is under that cap; one
435
still over it is not kept, so the next run starts empty. A file was
436
used if it was read, written or changed since the job started, or it
437
shares a unit hash with one that was (a 16-digit hex word after a ` -`
438
in its path, as cargo names a unit's fingerprint, rlibs, binaries and
439
build-script output: a crate cargo found fresh is only looked at, but
440
its fingerprint is read), or is another link of one, or lies under one
441
of the job's outputs. A microVM's guest (` flow-init` ) prunes its disks
442
before it powers off, having mounted them ` strictatime` so every read
443
is seen, and tells the runner what it kept (` \x1eC` frames); without
444
word from it the runner only measures the image (` dumpe2fs` ) and keeps
445
it if it is under the cap. The syscall tier's runner prunes a directory
446
itself, having set each file's atime to the epoch when the job took it,
447
so even a ` relatime` file system records the job's first read of each;
448
on a ` noatime` one nothing is pruned and only the size decides. The
449
job's log says what happened: ` cache target: kept 12.3 GiB of 23.1 GiB
450
(pruned 10.8 GiB in 4113 files this run did not use; the cap is 60% of
451
its 24.0 GiB disk)` , or ` not kept:` and why.
82cf6e91 Andy McCall 22 days ago ↶
452
- ** Unchanged sources keep their times** (oxygenflow#52). Beside each kept
453
disk the pool keeps its sources: each workspace file's content hash and
454
the mtime it had, in whole seconds (what the workspace image keeps). A
455
job that takes the disk gives each file whose content is unchanged that
456
mtime back before the workspace is imaged; a changed or new file keeps
457
the time of its checkout, newer than anything on the disk. A build tool
458
that judges freshness by time, such as cargo, then rebuilds exactly the
459
crates whose sources changed, where a fresh checkout would make every
460
path crate look changed. The log says how many files kept their times.
461
- ** Jobs go where their cache is** (oxygenflow#51). Each claim lists the
462
caches the runner holds a disk of (` caches` , each
463
` flow_core::api::cache_slot(repo, job)` ), and the hub ranks a runner
464
holding a job's cache above one holding its image (§11.4): a job another
465
free runner holds the cache for is held for it for up to 15 seconds, and
1e6b9fb1 Andy McCall 22 days ago ↶
466
` placed` says "holds its build cache". When several hold it, the
467
freshest copy wins (oxygenflow#54): claims also say when each slot last
468
kept a disk (` cache_times` , Unix ms), and a copy kept at least a minute
469
later ranks above an older one, before the image and speed are
470
weighed; ` placed` says how old the copy is.
2594278f Andy McCall 16 days ago ↶
471
- ** The syscall tier keeps directories** (oxygenflow#70). Its
472
` ephemeral://disk` mount is a host directory bound at the target, so
473
its pool holds directories (` <key>.tree` ) beside the microVM's images
474
(` <key>.ext4` ), taken and kept the same way: moved out of the pool for a
475
job of the same bookmark (so two jobs of one slot running at once never
476
take the same one), copied with their modes and times from another
477
bookmark's, kept only when the job and its outputs pass and otherwise
478
thrown away, and counted by the blocks of everything in them toward
479
` cache_gb` . A directory has no size, so ` size` does not choose between
480
them. Each tier takes only its own form: a syscall job never starts from
481
a microVM's image, nor a microVM from a directory. The job runs as the
482
runner's user, so what it wrote is the runner's; before a directory goes
483
into the pool nothing in it is left writable by group or others, or
484
set-id, and the pool is readable by the runner alone. Claims report the
485
slots of both forms in ` caches` and ` cache_times` , so jobs go where
486
their cache is whichever tier they run in.
d60aac6c Andy McCall 22 days ago ↶
487
- A cache is a speed-up, never a source of truth: jobs must build
488
correctly from an empty disk, which a key change or an eviction gives
489
them at any time.
490
a083cc80 Andy McCall 1 month ago ↶
491
## 6. Commutative Execution & Change Caching
492
493
In Oxygen, changes that do not share scopes (referenced atoms, placements, or
494
nodes) commute. OxygenFlow exploits this invariant to provide granular caching.
495
496
### 6.1 Execution Receipt Object
497
Every successful or failed job run emits an immutable Receipt:
498
499
``` cbor
500
Receipt {
501
version [0]: 1
502
job_name [1]: text
503
pipeline_hash [2]: ObjectRef
504
scope_tree_hash [3]: ObjectRef // BLAKE3 tree hash of the materialized scope
505
inputs [4]: [ObjectRef] // Artifacts and input files consumed
506
environment [5]: ObjectRef // Hash of rootfs, compiler flags, and argv
507
outputs [6]: [ObjectRef] // Produced artifact blobs
508
exit_code [7]: u32
509
duration_ms [8]: u64
510
}
511
```
512
513
A Receipt is hashed as an Oxygen object with type name "receipt":
514
ObjectRef = BLAKE3-256("oxy1 receipt " || decimal(len) || 0x00 || body)
515
516
### 6.2 Cache Re-use Invariant
517
Let Scope(J) be the file paths and tree nodes declared by job J.
518
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).
519
520
If an existing Receipt R exists such that:
521
R.job_name == J.name & &
522
R.pipeline_hash == Hash(J) & &
523
R.scope_tree_hash == TreeHash(Scope(J)) & &
524
R.environment == CurrentEnvironmentHash
525
526
Then the job execution is bypassed. R.outputs are mapped into downstream
527
dependents immediately as a cached-commuted hit.
528
529
## 7. Native Provenance Specification
530
531
OxygenFlow formalizes build provenance as a native, tamper-evident Oxygen Attest
532
object (kind: "attest"). Provenance is generated exclusively by the runner
533
hypervisor, not the guest payload.
534
535
### 7.1 Provenance Object Schema
536
537
``` json
538
Attest {
539
version [0]: 1
540
kind [1]: "attest"
541
repo [2]: bytes(16) // Oxygen Repository ID
542
hlc [3]: HLC // Hybrid Logical Clock
543
body [4]: {
544
"type": "flow/provenance/v1",
545
546
// Subject artifacts produced by the build
547
"subjects": [
548
{
549
"name": text,
550
"ref": ObjectRef, // BLAKE3 hash of output blob
551
"media_type": text
552
}
553
],
554
555
// Builder platform and execution identity
556
"builder": {
557
"runner_id": KeyId, // ObjectRef of runner's Oxygen Identity
558
"isolation_level": text, // "wasm" | "gvisor" | "microvm"
559
"hypervisor_image": ObjectRef // Rootfs blob digest
560
},
561
562
// Exact input materials
563
"materials": {
564
"state_heads": [ChangeHash], // Oxygen heads of the state built
565
"scope_tree_hash": ObjectRef, // Captures exact state without O(N) array bloat
566
"inputs": [
567
{
568
"name": text,
569
"ref": ObjectRef // Content hash of locked dependencies
570
}
571
]
572
},
573
574
// Structured execution arguments
575
"invocation": {
576
"job": text,
577
"pipeline_spec": ObjectRef, // ObjectRef of .flow/pipeline.kdl
578
"argv": [text], // Unmodified execution vector
579
"env": map<text, text> // Explicitly whitelisted environment
580
},
581
582
// Environment observations
583
"metadata": {
584
"started_on": u64, // Wallclock milliseconds
585
"finished_on": u64,
586
"hermetic": bool, // Verified zero outbound network access
587
"reproducible": bool
588
}
589
}
590
}
591
```
592
593
### 7.2 Composite Signatures
594
All provenance attestations are signed using Oxygen's composite signature
595
construction (Ed25519 and ML-DSA-65):
596
597
``` cbor
598
Signature {
599
key [0]: KeyId
600
alg [1]: 1 // 1 = Ed25519 + ML-DSA-65
601
ed [2]: bytes(64) // Classical signature
602
ml [3]: bytes(3309) // Post-quantum signature
603
}
604
```
605
606
Both signatures are computed over:
607
"oxy1 sig attest\0" || KeyId || ObjectRef
608
609
## 8. Policy Integration & Bookmark Gates
610
611
Oxygen repositories enforce branch and bookmark protections via signed Policy
612
objects (Oxygen Spec Section 14.5). OxygenFlow integrates provenance directly into
613
policy evaluation during push validation.
614
615
### 8.1 Policy Gate Definition
616
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.
617
618
``` json
619
PolicyRule {
620
pattern: "bookmarks/production"
621
keys: [ KeyId ]
622
threshold: 1
623
require: "signed"
624
allow_conflicts: false
625
require_attestations: [
626
{
627
type: "flow/provenance/v1",
628
job: "release-provenance",
629
require_hermetic: true,
630
signer_roles: [ "official-worker" ]
631
}
632
]
633
}
634
635
### 8.2 Push Verification Sequence (VCS Engine Enforcement)
636
When a push request arrives at an Oxygen server attempting to update a
637
protected bookmark:
638
1. Object Integrity: Validate that all changes, chunks, and blobs exist and match
639
their BLAKE3 hashes.
640
2. Attestation Resolution: Verify that an Attest object of type
641
"flow/provenance/v1" is present, referencing the exact heads of the new
642
State.
643
3. Identity Verification: Validate that the attestation's composite signature
644
resolves to a KeyId listed as an authorized delegate of the repository
645
policy roots.
646
4. Invariant Checking: Confirm that the provenance statement records
647
"hermetic: true" and exit code 0.
648
5. Atomic Update: If validation fails at any point, the entire push is refused
649
with ok = false.
650
651
## 9. Zero-Environment Secret Brokering
652
653
OxygenFlow forbids the injection of raw secret strings into process environment
654
tables to eliminate accidental leaking via child process dumping, procfs
655
reads, and verbose compiler logging.
656
657
### 9.1 Ephemeral Secret Broker
658
1. If a job requires a credential (e.g., signing key, cloud deployment token),
659
it declares a secret mount:
660
secret "deploy-token" {
661
vault-ref "infra/tokens/prod"
662
mount-path "/run/secrets/token"
663
}
664
2. The runner negotiates a short-lived token lease from the configured vault.
665
3. The secret is mounted onto an ephemeral, in-memory ramfs location inside
666
the sandbox with access mode 0400, owned exclusively by the worker process.
667
4. Upon process termination, the memory pages are zeroized and the token is
668
marked for immediate revocation at the vault.
669
670
## 10. Conformance and Verification Requirements
671
672
A conforming OxygenFlow implementation MUST pass the following tests:
673
674
1. Injection Immunity: Attempting to supply unescaped shell metacharacters
675
(; && | ` $() \n) in any job field MUST pass those characters as literal
676
bytes to argv; subshells MUST NOT be spawned.
677
2. FastCDC Determinism: Chunks produced by artifact extraction MUST match
678
the gear table cuts and BLAKE3 hashes generated by Oxygen's core chunker.
679
3. Commutation Hit Invariance: For any two commuting changes A and B, running
680
a pipeline on State(A, B) MUST reuse cached receipts generated on State(A)
681
for any job whose scope does not intersect the touched paths of B.
682
4. Non-Downgrade Attestation: An attestation missing the ML-DSA-65 component
683
MUST be rejected by policy verification.
684
5. Hermetic Network Isolation: Jobs configured with network "isolated" MUST
fd2d4822 Andy McCall 1 month ago ↶
685
have outgoing TCP SYN and UDP packets dropped at the hypervisor boundary.
630ee660 Andy McCall 1 month ago ↶
686
## 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)
fd2d4822 Andy McCall 1 month ago ↶
687
688
A runner MUST never take a job it cannot run or cannot finish well: the
689
wrong architecture, no hardware virtualisation for the MicroVM tier, too
690
little memory, an image it does not hold, or a machine too slow for the
691
job's time limit. Today a runner reports its architecture, OS, labels,
692
job slots, memory and vCPUs when it registers and on each claim, and a
693
claim carries the tiers it can run, the memory and vCPUs free now, and
694
the images it holds; the hub matches tier, room, labels, scope and image.
695
What is missing: architecture is reported but never matched (an image's
696
architecture is implicit in its bytes), a pipeline cannot say which
697
architecture it needs or offer a rootfs per architecture, speed and load
698
play no part, and a runner cannot decline a job it was handed.
699
700
### 11.1 Capability Profile
701
702
At registration and on every claim a runner sends its profile:
703
704
| Field | Meaning |
705
|---|---|
706
| `arch`, `os`, `kernel` | `x86_64`, `aarch64`, `riscv64`, …; the kernel release |
707
| `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`) |
708
| `memory_mib`, `cpus`, `max_jobs` | What its jobs share |
709
| `images` | The rootfs images it holds, each with the architecture its manifest records (§11.2) |
710
| `labels` | As §3.1 |
62829d15 Andy McCall 24 days ago ↶
711
| `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) |
712
| `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 |
fd2d4822 Andy McCall 1 month ago ↶
713
714
### 11.2 Image Manifests Carry the Architecture
715
716
`flow rootfs import` reads the ELF header of the image's `/bin/sh` and
717
records its architecture in the CAS manifest beside the hash. A runner
718
refuses to import an image for another architecture (there is no
719
emulation tier). An image imported before this section has no recorded
720
architecture and is treated as the importing runner's.
721
722
### 11.3 Job Requirements
723
724
Hard requirements, all of which a runner MUST satisfy to be offered the
725
job: the sandbox's tier, its rootfs (hence the image's architecture),
726
`memory-limit`, `cpus`, every `runs-on` label of the job and its sandbox,
727
and the runner's scope (OxygenHub spec §13.2). Two additions to the
728
language:
729
730
- `arch "riscv64"` on a sandbox pins the architecture; without it the
731
architecture is the rootfs image's.
732
- A rootfs per architecture: `rootfs { x86_64 "cas://blob/…"; riscv64 "cas://blob/…" }`.
733
The job runs on whichever architecture takes it; the receipt and the
734
provenance record the architecture and image used, and a check's
735
description names them.
736
737
Soft requirements guide placement but never block it: a job's `timeout`
738
against the runner's speed class. The hub keeps, per job hash and speed
739
class, the median duration of past runs; a runner whose class predicts a
740
duration over the limit is offered the job only when no better runner
741
is free.
742
743
### 11.4 Placement
744
745
The hub filters by the hard requirements, then ranks the runners that
746
remain: idle before busy (`load`), a higher speed class first, a runner
747
already holding the image before one that must fetch it (§5), fewer jobs
748
waiting on it first. A job no runner can satisfy waits and the queue
749
says what it waits for: tier, architecture, image, memory, labels or
750
scope, so the gap is visible rather than a silent stall.
751
752
### 11.5 Declining
753
754
A runner double-checks a claim before it starts: the architecture, that
755
`/dev/kvm` opens for the MicroVM tier, that the image's blob is present
756
and verifies, that the memory is free. If any check fails it answers the
757
hub with `declined` and a reason instead of running and failing; the hub
758
re-queues the job, never offers it to that runner again until its
759
profile changes, records the decline on the runner's page, and after
760
three declines by different runners marks the job `unplaceable` with the
761
reasons. A decline is never a failed check on the state.
762
630ee660 Andy McCall 1 month ago ↶
763
As built: the runner sends `POST /api/v1/flow/jobs/{id}/decline` with
764
`{"reason": …}` before it fetches anything. The profile is a hash of the
765
tiers, images, architecture, labels and capacity a runner claims with.
766
A decline also lapses after ten minutes, so a passing reason such as
767
memory in use does not bar a runner for good. An unplaceable job ends
768
with status `error` and an error beginning `unplaceable:`. The hub
769
works out why each ready job waits every 15 seconds, and the run page and
770
the API (`waiting`) show it.
771
89171fca Andy McCall 22 days ago ↶
772
### 11.5.1 Restarts (oxygenflow#49, #50)
773
774
Neither a hub restart nor a runner restart costs a job anything.
775
776
- **The hub away.** A job's uploads (artifacts, report files) and declines
777
are tried again through refused connections and server errors (5xx,
778
as a proxy answers while the hub restarts) for three minutes, waiting
779
1, 2, 4, 8 and then 15 seconds; a refusal (4xx) is final. Log lines
780
the hub could not take are kept and sent with the next batch, 200 lines
781
at a time, up to 50,000 (beyond that the oldest go, and the log says how
782
many). A job's result is kept on disk and offered for two hours, as
783
before. Claims are not retried through a lost connection, so a job is
784
never claimed twice; the runner simply asks again.
785
- **The runner restarted.** The first SIGTERM or SIGINT drains the runner:
786
it claims nothing more and stops once every job it holds has ended and
787
been reported. A second signal stops it at once. Its systemd unit says
788
`KillMode=mixed`, so only the runner is signalled and its microVMs run
789
on, and `TimeoutStopSec` longer than the longest job, after which
790
systemd kills what is left. A new binary is installed by replacing it
791
and restarting the unit (`systemctl restart --no-block`): the old runner
792
finishes its job, and `Restart=always` starts the new one.
793
fd2d4822 Andy McCall 1 month ago ↶
794
### 11.6 Conformance
795
796
1. Architecture Safety: a job whose image is `x86_64` MUST never be
797
offered to a `riscv64` runner, with or without the image copied there.
798
2. Tier Safety: a runner without `/dev/kvm` MUST NOT be offered a
799
MicroVM job, and MUST decline one if handed it.
800
3. Room: a job whose `memory-limit` exceeds the runner's free memory
801
MUST wait, not be killed.
802
4. Visibility: a job nobody can take MUST say why on the queue within
803
one scheduling pass.
804
5. Preference: with two idle runners that both satisfy a job, the hub
805
MUST offer it to the higher speed class, and to the one holding the
806
image when classes tie.