OXYGENHUB

Approval gates in the pipeline: approval { by; others-only; expires } e20897216221

flow-core parses a job's approval (by write, maintain (default) or admin;
others-only; expires 1m to 720h, default 24h) into Job.approval, hashed as
job field 11. The hub holds such a job for a person. Spec 5.3.2; tests.

Refs #57
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-16 03:50 UTC
Signature
✔ signed by Claude Code
Hash
e20897216221a7410ab42d12ea6161110e26bc8bd1b3f13e31453f1e9ff2aec0
Depends on
1abadd4a2fa6 46fe2c815cc3 6d53fb7d00ec a083cc802892 a67b97e9a717 bf7bb1b80fa3 d60aac6c3f8a e0918b887850 e2c125610f43 e576e08617dc
Depended on by
2be1fe4ad345 f4e51e63582d
In bookmarks
main
In tags
v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
Edits
13 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified crates/flow-core/src/canonical.rs +4 −0
…
106 unchanged lines
107 107
        fields.push((9, Value::Array(j.identities.iter().map(|i| Value::Array(vec![text(&i.audience), text(&i.mount_path)])).collect())));
108 108
    }
109 109
    // Inputs as [artifact, from, path] sorted by path; absent when none.
110 +
    // Approval as [by, others-only, expires seconds]; absent without one.
111 +
    if let Some(a) = &j.approval {
112 +
        fields.push((11, Value::Array(vec![text(&a.by), Value::Bool(a.others_only), Value::Uint(a.expires_s)])));
113 +
    }
110 114
    if !j.inputs.is_empty() {
111 115
        fields.push((
112 116
            10,
…
47 unchanged lines
modified crates/flow-core/src/lib.rs +2 −2
…
11 unchanged lines
12 12
13 13
pub use canonical::{object_hash, scope_tree_hash, Hash};
14 14
pub use pipeline::{
15 -
    arch_ok, egress_allows, egress_pattern_ok, Identity, Input, Job, Mount, MountSource, Network, Output, OutputKind, Pipeline, Problem,
16 -
    Problems, Runtime, Sandbox, Secret, EGRESS_LABEL, PIPELINE_PATH,
15 +
    arch_ok, egress_allows, egress_pattern_ok, Approval, Identity, Input, Job, Mount, MountSource, Network, Output, OutputKind, Pipeline,
16 +
    Problem, Problems, Runtime, Sandbox, Secret, EGRESS_LABEL, PIPELINE_PATH,
17 17
};
18 18
pub use receipt::{Environment, Receipt};
19 19
…
2 unchanged lines
modified crates/flow-core/src/pipeline.rs +59 −0
…
176 unchanged lines
177 177
    /// the producing job's receipt; this job's receipt lists them as inputs.
178 178
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
179 179
    pub inputs: Vec<Input>,
180 +
    /// Wait for a person before running (oxygenflow#57): `approval { by
181 +
    /// "maintain"; others-only #true; expires "24h" }`.
182 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
183 +
    pub approval: Option<Approval>,
180 184
    /// Secrets the job mounts, sorted by name (spec §9 `secret`): the hub
181 185
    /// supplies each by name, the runner mounts it as a file.
182 186
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
…
106 unchanged lines
289 293
    pub from: String,
290 294
    /// Where it is put: a clean absolute path under `/src`.
291 295
    pub path: String,
296 +
}
297 +
298 +
/// Who must approve a job before it runs, and for how long it waits
299 +
/// (oxygenflow#57).
300 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
301 +
pub struct Approval {
302 +
    /// The least role that approves: `write`, `maintain` (the default) or `admin`.
303 +
    pub by: String,
304 +
    /// Not the person whose push started the run.
305 +
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
306 +
    pub others_only: bool,
307 +
    /// How long it waits once the jobs it depends on are done, in seconds.
308 +
    pub expires_s: u64,
292 309
}
293 310
311 +
/// The longest a job waits for approval: 30 days.
312 +
const MAX_APPROVAL_S: u64 = 30 * 24 * 3600;
313 +
294 314
/// The most inputs a job takes.
295 315
const MAX_INPUTS: usize = 8;
296 316
…
1005 unchanged lines
1302 1322
            ctx.err(a.span(), format!("a job has at most {MAX_OUTPUTS} artifacts and reports"));
1303 1323
        } else {
1304 1324
            job.outputs.push(Output { name, path, always, kind });
1325 +
        }
1326 +
    }
1327 +
}
1328 +
1329 +
/// `approval { by "maintain"; others-only #true; expires "24h" }`, each
1330 +
/// optional: `maintain`, anyone with the role, 24 hours.
1331 +
fn parse_approval(ctx: &mut Ctx, node: &KdlNode) -> Option<Approval> {
1332 +
    if !node.entries().is_empty() {
1333 +
        ctx.err(node.span(), "`approval` takes a block: approval { by \"maintain\"; others-only #true; expires \"24h\" }");
1334 +
    }
1335 +
    let mut a = Approval { by: "maintain".into(), others_only: false, expires_s: 24 * 3600 };
1336 +
    let Some(children) = node.children() else { return Some(a) };
1337 +
    for c in children.nodes() {
1338 +
        ctx.no_children(c);
1339 +
        match c.name().value() {
1340 +
            "by" => match ctx.one_string(c).as_deref() {
1341 +
                Some(r @ ("write" | "maintain" | "admin")) => a.by = r.to_string(),
1342 +
                Some(r) => ctx.err(c.span(), format!("approval by {r:?}: expected \"write\", \"maintain\" or \"admin\"")),
1343 +
                None => {}
1344 +
            },
1345 +
            "others-only" => match c.entries() {
1346 +
                [e] if e.name().is_none() => match e.value() {
1347 +
                    KdlValue::Bool(b) => a.others_only = *b,
1348 +
                    _ => ctx.err(c.span(), "`others-only` takes #true or #false"),
1349 +
                },
1350 +
                _ => ctx.err(c.span(), "`others-only` takes #true or #false"),
1351 +
            },
1352 +
            "expires" => {
1353 +
                let Some(t) = ctx.one_string(c) else { continue };
1354 +
                match parse_duration(&t) {
1355 +
                    Some(secs) if (60..=MAX_APPROVAL_S).contains(&secs) => a.expires_s = secs,
1356 +
                    Some(_) => ctx.err(c.span(), format!("expires {t:?} must be from 1m to 720h")),
1357 +
                    None => ctx.err(c.span(), format!("expires {t:?}: expected hours, minutes and seconds, as \"24h\"")),
1358 +
                }
1359 +
            }
1360 +
            other => ctx.err(c.span(), format!("unknown approval setting `{other}`; approval takes `by`, `others-only` and `expires`")),
1305 1361
        }
1306 1362
    }
1363 +
    Some(a)
1307 1364
}
1308 1365
1309 1366
/// `input { artifact "NAME" from "JOB"; path "/src/FILE" }`.
…
59 unchanged lines
1369 1426
        identities: vec![],
1370 1427
        outputs: vec![],
1371 1428
        inputs: vec![],
1429 +
        approval: None,
1372 1430
        runs_on: vec![],
1373 1431
        timeout_s: None,
1374 1432
        on: None,
…
72 unchanged lines
1447 1505
                parse_output(ctx, c, &mut job);
1448 1506
            }
1449 1507
            "input" => parse_input(ctx, c, &mut job),
1508 +
            "approval" => job.approval = parse_approval(ctx, c),
1450 1509
            "secret" => parse_secret(ctx, c, &mut job),
1451 1510
            "identity" => parse_identity(ctx, c, &mut job),
1452 1511
            n if LATER_JOB.contains(&n) => ctx.err(c.span(), format!("`{n}` is not in M0 yet")),
…
32 unchanged lines
modified crates/flow-core/src/tests.rs +23 −0
…
669 unchanged lines
670 670
        "two inputs are put at",
671 671
    );
672 672
}
673 +
674 +
#[test]
675 +
fn a_job_can_wait_for_approval() {
676 +
    let job = |approval: &str| doc(&format!("job \"apply\" {{\n    sandbox \"alpine\"\n{approval}    exec \"apply\"\n}}\n"));
677 +
    let p = Pipeline::parse(&job("    approval { by \"maintain\"; others-only #true; expires \"2h\"; }\n")).unwrap();
678 +
    assert_eq!(p.jobs["apply"].approval, Some(crate::Approval { by: "maintain".into(), others_only: true, expires_s: 7200 }));
679 +
    let plain = Pipeline::parse(&job("    approval\n")).unwrap();
680 +
    assert_eq!(
681 +
        plain.jobs["apply"].approval,
682 +
        Some(crate::Approval { by: "maintain".into(), others_only: false, expires_s: 86400 }),
683 +
        "defaults"
684 +
    );
685 +
    assert_eq!(Pipeline::parse(&job("")).unwrap().jobs["apply"].approval, None);
686 +
    assert_ne!(p.hash(), plain.hash(), "the approval is part of the job");
687 +
    let has = |a: &str, want: &str| {
688 +
        let ps = problems(&job(a));
689 +
        assert!(ps.iter().any(|(_, m)| m.contains(want)), "{a}: {ps:?}");
690 +
    };
691 +
    has("    approval { by \"read\"; }\n", "expected \"write\", \"maintain\" or \"admin\"");
692 +
    has("    approval { others-only \"yes\"; }\n", "#true or #false");
693 +
    has("    approval { expires \"800h\"; }\n", "from 1m to 720h");
694 +
    has("    approval { when \"later\"; }\n", "unknown approval setting");
695 +
}
modified crates/flow/src/outputs.rs +1 −0
…
224 unchanged lines
225 225
            ],
226 226
            outputs: vec![],
227 227
            inputs: vec![],
228 +
            approval: None,
228 229
            runs_on: vec![],
229 230
            timeout_s: None,
230 231
            on: None,
…
84 unchanged lines
modified docs/spec.md +23 −0
…
324 unchanged lines
325 325
run, not the repository). The job's receipt lists the blobs as its
326 326
`inputs`, so an apply job's receipt proves which plan it applied.
327 327
328 +
### 5.3.2 Approval gates (oxygenflow#57)
329 +
330 +
A job can wait for a person:
331 +
332 +
```kdl
333 +
job "apply" {
334 +
    depends-on "plan"
335 +
    approval {
336 +
        by "maintain"        // the least role: "write", "maintain" (default) or "admin"
337 +
        others-only #true    // not the person whose push started the run
338 +
        expires "24h"        // from when the jobs it depends on are done; up to 720h
339 +
    }
340 +
}
341 +
```
342 +
343 +
The hub hands such a job to no runner until someone with that role on the
344 +
repository approves it, on the run page or through the API; others-only
345 +
leaves out whoever pushed. Approving or rejecting is recorded on the job
346 +
(who, when) and in the audit log. A rejected or expired job ends as
347 +
cancelled and the jobs after it are skipped; a newer push to the
348 +
bookmark cancels the run as before, so a stale plan is never applied. The
349 +
approval is part of the job's hash (field 11).
350 +
328 351
### 5.4 The Build Cache (oxygenflow#28)
329 352
330 353
An `ephemeral://disk` mount may name a `cache`, and the files that key it:
…
351 unchanged lines