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