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