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 |