OXYGENHUB

Diffusion: turning it on catches a consumer up 3776b70d9b17

A consumer that turns Diffusion on is bumped at once to each upstream's
newest state whose run passed (or its newest state, following every
move), rather than waiting for the upstream to move again.

refs #148
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 01:25 UTC
Signature
✔ signed by Andy McCall
Hash
3776b70d9b1732b154af056951f3709461d06c90ab9bc0a868da243d9386b890
Depends on
19bb31ff1f5c 42fa95540468 93be9ce4653e a7e6e8e6094c cc19697f8871 d23464c0ac60 e71583501b1d f677d6270298
Depended on by
3b1bc27fe805 5d90e0e045e2 7e01a7665aa5 0e04dfc20211 5d54cf746e00 f4d6ef418a96 180dc43ad0e0 757195ad7a5d 9aaa88b2d9c8 f01cb865fc60 7a14bdaeb857
In bookmarks
api-unknown-fields cancel-landed-runs close-proposal-140 diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
12 atoms, 0 tree operations, 5 files

Browse the tree at this change

modified crates/hub/src/api/mod.rs +5 −0
…
601 unchanged lines
602 602
    .ok();
603 603
    let row = db::repos::get_by_id(&app.db, row.id).await?.unwrap_or(row);
604 604
    let rule = crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "");
605 +
    // Turned on: bumped at once to where its upstreams are (#148).
606 +
    if rule.follow != crate::diffusion::Follow::Off {
607 +
        let (app, row) = (app.clone(), row.clone());
608 +
        tokio::spawn(async move { crate::diffusion::catch_up(&app, &row).await });
609 +
    }
605 610
    Ok(Json(serde_json::json!({ "follow": rule.follow, "from": rule.from })))
606 611
}
607 612
…
1851 unchanged lines
modified crates/hub/src/diffusion.rs +55 −0
…
435 unchanged lines
436 436
/// push (`green` false) or a passing run of that state (`green` true).
437 437
/// Bump and propose in every consumer that follows it that way (§4.3).
438 438
pub async fn propagate(app: &Shared, upstream: &RepoRow, heads: Vec<ChangeHash>, green: bool) {
439 +
    propagate_to(app, upstream, heads, green, None).await
440 +
}
441 +
442 +
/// `propagate`, to one consumer only when `only` names it.
443 +
async fn propagate_to(app: &Shared, upstream: &RepoRow, heads: Vec<ChangeHash>, green: bool, only: Option<i64>) {
439 444
    let Some(uname) = RepoName::new(&upstream.owner, &upstream.name) else { return };
440 445
    let Ok(id) = app.repos.read(&uname, |r, _| Ok(r.id()?)).await else { return };
441 446
    let rows: Vec<(i64, String, String)> = sqlx::query_as("SELECT consumer_id, bookmark, path FROM diffusion_pins WHERE upstream = ?")
…
2 unchanged lines
444 449
        .await
445 450
        .unwrap_or_default();
446 451
    for (consumer_id, indexed, path) in rows {
452 +
        if only.is_some_and(|o| o != consumer_id) {
453 +
            continue;
454 +
        }
447 455
        let Ok(Some(consumer)) = db::repos::get_by_id(&app.db, consumer_id).await else { continue };
448 456
        let Some(cname) = RepoName::new(&consumer.owner, &consumer.name) else { continue };
449 457
        let learned = target(&app.db, &consumer).await;
…
24 unchanged lines
474 482
            }
475 483
            Ok(_) => {}
476 484
            Err(e) => tracing::warn!("diffusion: bump {}/{} {path}: {e}", consumer.owner, consumer.name),
485 +
        }
486 +
    }
487 +
}
488 +
489 +
/// The repository on this hub with Oxygen id `id` (hex).
490 +
async fn repo_by_oxy_id(app: &Shared, id: &str) -> Option<RepoRow> {
491 +
    for row in db::repos::list(&app.db).await.ok()? {
492 +
        let Some(name) = RepoName::new(&row.owner, &row.name) else { continue };
493 +
        if app.repos.read(&name, |r, _| Ok(r.id()?)).await.ok().map(hex::encode).as_deref() == Some(id) {
494 +
            return Some(row);
495 +
        }
496 +
    }
497 +
    None
498 +
}
499 +
500 +
/// The newest state of `upstream`'s default bookmark whose run passed.
501 +
async fn last_green(app: &Shared, upstream: &RepoRow) -> Option<Vec<ChangeHash>> {
502 +
    let heads: String =
503 +
        sqlx::query_scalar("SELECT heads FROM flow_runs WHERE repo_id = ? AND bookmark = ? AND status = 'passed' ORDER BY id DESC LIMIT 1")
504 +
            .bind(upstream.id)
505 +
            .bind(&upstream.default_bookmark)
506 +
            .fetch_optional(&app.db)
507 +
            .await
508 +
            .ok()
509 +
            .flatten()?;
510 +
    let h: Vec<ChangeHash> = heads.split('+').filter_map(ChangeHash::from_hex).collect();
511 +
    (!h.is_empty()).then_some(h)
512 +
}
513 +
514 +
/// Bring `consumer` up to date with the upstreams it pins, at once: when
515 +
/// it turns Diffusion on, it is bumped to each upstream's newest state
516 +
/// that passed (or simply its newest, when it follows every move).
517 +
pub async fn catch_up(app: &Shared, consumer: &RepoRow) {
518 +
    let upstreams: Vec<String> = sqlx::query_scalar("SELECT DISTINCT upstream FROM diffusion_pins WHERE consumer_id = ?")
519 +
        .bind(consumer.id)
520 +
        .fetch_all(&app.db)
521 +
        .await
522 +
        .unwrap_or_default();
523 +
    for id in upstreams {
524 +
        let Some(upstream) = repo_by_oxy_id(app, &id).await else { continue };
525 +
        if let Some(h) = last_green(app, &upstream).await {
526 +
            propagate_to(app, &upstream, h, true, Some(consumer.id)).await;
527 +
        }
528 +
        let Some(name) = RepoName::new(&upstream.owner, &upstream.name) else { continue };
529 +
        let b = upstream.default_bookmark.clone();
530 +
        if let Ok(h) = app.repos.read(&name, move |r, _| heads_of(r, &b)).await {
531 +
            propagate_to(app, &upstream, h, false, Some(consumer.id)).await;
477 532
        }
478 533
    }
479 534
}
…
146 unchanged lines
modified crates/hub/src/web/repo_settings.rs +4 −0
…
772 unchanged lines
773 773
    .await
774 774
    .ok();
775 775
    let row = db::repos::get_by_id(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?.unwrap_or(row);
776 +
    if crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow != crate::diffusion::Follow::Off {
777 +
        let (app, row) = (app.clone(), row.clone());
778 +
        tokio::spawn(async move { crate::diffusion::catch_up(&app, &row).await });
779 +
    }
776 780
    let notice = match crate::diffusion::rule(&app.config.diffusion.follow, &row, None, "").follow {
777 781
        crate::diffusion::Follow::Off => "Diffusion is off: upstream moves open no proposals here.",
778 782
        crate::diffusion::Follow::Green => {
…
182 unchanged lines
modified crates/hub/tests/flow.rs +55 −2
…
3301 unchanged lines
3302 3302
    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
3303 3303
    assert!(db::proposals::list(&h.app.db, game_row.id, &[]).await.unwrap().is_empty(), "off proposes nothing");
3304 3304
3305 -
    // Following every move: the next push proposes, by the diffusion account.
3305 +
    // Turned on to follow every move: bumped at once to where the upstream
3306 +
    // is (v2), by the diffusion account; the next push updates it.
3306 3307
    let put = |body: &'static str| call(&h.app, "PUT", "/api/v1/repos/andy/game/diffusion", Some("site"), Some(body.into()));
3307 3308
    assert_eq!(put(r#"{"follow":"any"}"#).await.0, StatusCode::OK);
3309 +
    let first = bump().await.expect("caught up when turned on");
3310 +
    assert!(first.title.contains("1 change") && first.body.contains("api v2: faster sprites"), "{:?}", first.title);
3308 3311
    write(api_work.path(), "a3.c", "v3\n");
3309 3312
    api.snapshot("t").unwrap();
3310 3313
    api.commit("api v3: save slots past ten", &[]).unwrap();
3311 3314
    assert!(api.push("origin", false).unwrap().iter().all(|r| r.ok));
3312 -
    let p = bump().await.expect("a bump proposal");
3315 +
    let mut p = first.clone();
3316 +
    for _ in 0..100 {
3317 +
        p = bump().await.unwrap();
3318 +
        if p.body.contains("api v3") {
3319 +
            break;
3320 +
        }
3321 +
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3322 +
    }
3323 +
    assert_eq!(p.number, first.number, "the same proposal");
3313 3324
    assert_eq!((p.author.as_str(), p.target.as_str()), ("diffusion", "main"));
3314 3325
    assert!(p.title.contains("deps/api") && p.title.contains("andy/api") && p.title.contains("2 changes"), "{}", p.title);
3315 3326
    assert!(p.body.contains("api v2: faster sprites") && p.body.contains("api v3: save slots past ten"), "{}", p.body);
…
24 unchanged lines
3340 3351
    assert_eq!(p2.number, p.number, "the same proposal");
3341 3352
    assert!(p2.title.contains("3 changes"), "{}", p2.title);
3342 3353
    assert_eq!(db::proposals::list(&h.app.db, game_row.id, &[]).await.unwrap().len(), 1, "never a pile of bumps");
3354 +
3355 +
    // A consumer turning Diffusion on is bumped at once to the upstream's
3356 +
    // newest passing state, without waiting for it to move again.
3357 +
    create(&h.app, "andy", "game2").await;
3358 +
    let g2_work = tempfile::tempdir().unwrap();
3359 +
    let g2 = client(&format!("{}/andy/game2", h.base), g2_work.path());
3360 +
    g2.submodule_add(&format!("{}/andy/api", h.base), "deps/api", Some("main")).unwrap();
3361 +
    g2.snapshot("t").unwrap();
3362 +
    g2.commit("game2 on api", &[]).unwrap();
3363 +
    assert!(g2.push("origin", false).unwrap().iter().all(|r| r.ok));
3364 +
    // Its pin is api's newest state already: move api on, pass the run.
3365 +
    write(api_work.path(), "a5.c", "v5\n");
3366 +
    api.snapshot("t").unwrap();
3367 +
    api.commit("api v5", &[]).unwrap();
3368 +
    assert!(api.push("origin", false).unwrap().iter().all(|r| r.ok));
3369 +
    pass_all().await;
3370 +
    let g2_row = db::repos::get(&h.app.db, "andy", "game2").await.unwrap().unwrap();
3371 +
    for _ in 0..100 {
3372 +
        if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = ?")
3373 +
            .bind(g2_row.id)
3374 +
            .fetch_one(&h.app.db)
3375 +
            .await
3376 +
            .unwrap()
3377 +
            > 0
3378 +
        {
3379 +
            break;
3380 +
        }
3381 +
        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
3382 +
    }
3383 +
    assert!(db::proposals::list(&h.app.db, g2_row.id, &[]).await.unwrap().is_empty(), "off until turned on");
3384 +
    let (st, _) = call(&h.app, "PUT", "/api/v1/repos/andy/game2/diffusion", Some("site"), Some(r#"{"follow":"green"}"#.into())).await;
3385 +
    assert_eq!(st, StatusCode::OK);
3386 +
    let mut caught = None;
3387 +
    for _ in 0..100 {
3388 +
        if let Some(p) = db::proposals::list(&h.app.db, g2_row.id, &["open"]).await.unwrap().into_iter().next() {
3389 +
            caught = Some(p);
3390 +
            break;
3391 +
        }
3392 +
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3393 +
    }
3394 +
    let caught = caught.expect("bumped as soon as it was turned on");
3395 +
    assert!(caught.body.contains("api v5") && caught.title.contains("1 change"), "{:?} {:?}", caught.title, caught.body);
3343 3396
}
3344 3397
3345 3398
/// Whether any job is queued or running.
…
7 unchanged lines
modified docs/spec.md +1 −1
…
514 unchanged lines
515 515
  - its key is the one the runner's token first signed with. The first signed receipt under a token binds that token to its key in `flow_runner_keys`.
516 516
517 517
  Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`.
518 -
- **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). **Diffusion** (#148, `docs/diffusion.md`): a push that moves a repository's default bookmark, or the bookmark its work lands on (the most common target of its proposals landed in the last 90 days), records each Oxygen subrepo pin in the new state (`diffusion_pins`: consumer, bookmark, path, the upstream's Oxygen repository id, the pinned heads); a hub that had no index fills it at start. `GET /repos/{owner}/{name}/consumers` lists the consumers the caller may read, each with its pin and how many changes of this repository's default bookmark it lacks (`behind`, null when the pin names changes this repository does not hold), and the overview shows them as Used by. A consumer follows a subrepo's upstream as its `.oxymodules` entry says (`follow = off | green | any`, `land = propose | auto`, `target` naming the bookmark to bump, `watch` listing paths whose upstream changes are flagged), else as its repository setting says (Settings › Diffusion, or `PUT /repos/{owner}/{name}/diffusion {"follow": ...}` by its owners and admins; migration 0058), else as `[diffusion] follow` in hub.toml says (default `off`). A **bump** moves a consumer's pin at a path to a newer upstream state: one change on the target bookmark's heads that replaces only the pin (oxy-core `build_pin_change`), signed with the hub's key, on the bookmark `diffusion/<path>`, which the hub owns and rebuilds on the target each time (a forced update). It is skipped when the pin already holds the state, when the bump bookmark already moves it there, or when the state lacks changes the pin has (consumers only move forward). A bump is **proposed** once per bump bookmark, by the hub's `diffusion` account (made on first use, never able to sign in), into the target, labelled `dependencies`, its body the upstream changes the pin lacks (newest first, linking to the upstream's change pages); a later bump edits the same open proposal. It is triggered when a push moves the upstream's default bookmark (for consumers following `any`) or when a run of that bookmark passes (for `green`). Diffusion never lands a proposal. **Images page** (#146): Admin › Runners › Images lists every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days (from each job's sandbox), newest use first: its size and architecture, where it is (the hub, each runner holding it, each runner fetching it from the swarm now), the repositories whose jobs used it, and who sent it; an image held nowhere is marked. **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address, its site and the BLAKE3 of its TLS certificate with each claim (`swarm`; without a certificate it is not in the swarm), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, each with its certificate's fingerprint, which the fetcher pins its TLS connection to, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409).
518 +
- **Architecture** (OxygenFlow spec §11.2–§11.3, oxygenflow#36): a runner reports each image's architecture on its claims (`image_archs`), kept in `flow_images`; a job is offered only to a runner whose architecture matches its sandbox's `arch`, else its image's as known; unknown matches, as before. The queue says which architecture a waiting job asks for. **Images by hash** (oxygenflow#39, Jetstream): `flow rootfs push HASH` sends a rootfs image as the FastCDC chunks the hub lacks (`POST /flow/images/missing`, `PUT /flow/images/chunks/{hash}`, by a token that may write repositories) and then its manifest (`PUT /flow/images/{hash}`), which the hub accepts only when every chunk is held and they make an image with that hash; chunks are kept zstd-compressed under `<data>/images`, shared by every image, and `jet_images` lists what is held. Runners and accounts fetch with `GET /flow/images/{hash}` and `GET /flow/images/chunks/{hash}`. A runner whose claims say `fetches_images` is offered jobs whose image only the hub holds, and fetches it before its preflight. Each claim also carries the runner's `facts` (OxygenFlow spec §11.1, oxygenflow#40): free disk, load, speed score and class, kernel; the hub keeps the latest on the runner, shows them on Admin › Runners and in `GET /runners`. A job's finish carries the most disk and memory it took (`peak_disk_mib`, `peak_memory_mib`, oxygenflow#41); a new job's `need_disk_mib` is the most its last five runs of the same repository and name took, plus a quarter and 64 MiB, and it is offered only to a runner whose claim reports at least that much free disk; the waiting reason says so. **Ranking** (oxygenflow#42): a job that another free runner which polled in the last 10 s would do better (it holds the job's image while the claimant would fetch it, or has a higher speed class) is held from the claimant for up to 15 s, then any runner that fits takes it; each job records why it ran where it did (`placed`: "holds the image, idle, speed 100 (class 3)", with "after N s held for a better runner" when it was), shown on the run page, in the API and by `oh run view`. A run whose jobs pin an image held by neither the hub nor a runner sets a failing `pipeline/images` check on its state (#137), naming the images and how to send them (`flow rootfs push`), so a proposal pinning a new image shows it before it lands; a later run of the state turns it to passed once every image is held. A placement pass every 15 seconds works out why each ready queued job no runner can take waits, from what each online runner offered at its last claim (scope, tier, architecture, image held, labels, memory and CPUs, a recent decline), and shows it on the job (API `waiting`, `oh run view` and `oh run watch`, its panel open on the run page) and in its check's summary, so a proposal says why its check is stuck (#28). **Diffusion** (#148, `docs/diffusion.md`): a push that moves a repository's default bookmark, or the bookmark its work lands on (the most common target of its proposals landed in the last 90 days), records each Oxygen subrepo pin in the new state (`diffusion_pins`: consumer, bookmark, path, the upstream's Oxygen repository id, the pinned heads); a hub that had no index fills it at start. `GET /repos/{owner}/{name}/consumers` lists the consumers the caller may read, each with its pin and how many changes of this repository's default bookmark it lacks (`behind`, null when the pin names changes this repository does not hold), and the overview shows them as Used by. A consumer follows a subrepo's upstream as its `.oxymodules` entry says (`follow = off | green | any`, `land = propose | auto`, `target` naming the bookmark to bump, `watch` listing paths whose upstream changes are flagged), else as its repository setting says (Settings › Diffusion, or `PUT /repos/{owner}/{name}/diffusion {"follow": ...}` by its owners and admins; migration 0058), else as `[diffusion] follow` in hub.toml says (default `off`). A **bump** moves a consumer's pin at a path to a newer upstream state: one change on the target bookmark's heads that replaces only the pin (oxy-core `build_pin_change`), signed with the hub's key, on the bookmark `diffusion/<path>`, which the hub owns and rebuilds on the target each time (a forced update). It is skipped when the pin already holds the state, when the bump bookmark already moves it there, or when the state lacks changes the pin has (consumers only move forward). A bump is **proposed** once per bump bookmark, by the hub's `diffusion` account (made on first use, never able to sign in), into the target, labelled `dependencies`, its body the upstream changes the pin lacks (newest first, linking to the upstream's change pages); a later bump edits the same open proposal. It is triggered when a push moves the upstream's default bookmark (for consumers following `any`) or when a run of that bookmark passes (for `green`). Diffusion never lands a proposal. Turning Diffusion on for a consumer (the setting or the API) catches it up at once: it is bumped to each upstream's newest state whose default-bookmark run passed, or to its newest state when it follows every move. **Images page** (#146): Admin › Runners › Images lists every rootfs image a runner holds, the hub keeps, or a job ran on in the last 90 days (from each job's sandbox), newest use first: its size and architecture, where it is (the hub, each runner holding it, each runner fetching it from the swarm now), the repositories whose jobs used it, and who sent it; an image held nowhere is marked. **The swarm** (#144, oxygenflow docs/jetstream.md §8.1): a runner in an image swarm reports its peer address, its site and the BLAKE3 of its TLS certificate with each claim (`swarm`; without a certificate it is not in the swarm), kept in memory and shown on Admin › Runners. `POST /flow/swarm/announce {image, state}` (by a runner; `fetching`, `done` or `stopped`) returns the online runners in its site that hold the image or announced fetching it in the last 45 seconds, each with its certificate's fingerprint, which the fetcher pins its TLS connection to, and a ticket signed with the hub key (JSON naming the runner, its site and an expiry 15 minutes on, hex, then the signature) that peers check against `/.well-known/oxyhub-key` before serving chunks; a runner that has not reported an address is told to add `[runner.swarm]` (409).
519 519
- **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same.
520 520
- **Secrets** (oxygenhub#107; OxygenFlow spec §9): the same three levels hold **Secrets**, named as a pipeline's `secret "name" { mount-path "/run/secrets/…" }` names them (1 to 64 of `A-Z a-z 0-9 . _ -`, a value up to 64 KiB, up to 100 per level), typed once and sealed under `keys/secrets.key`; pages show names, when each was set and where it comes from, never a value. A claim carries only the secrets its job declares, each with its path and value, from the innermost level that sets the name; one no level sets is noted at the top of the job's log and left out, so the job fails on the missing file rather than running with a stand-in. Flow writes each as a file (0400, the job's user's, on an in-memory mount) before the job starts and never puts it in the environment, the receipt or an artifact. Log intake replaces each value, and its base64, with `***` before a record is stored. Changes are audited (`flow.secret.set`, `flow.secret.deleted`), never with the value.
521 521
- **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`.
…
231 unchanged lines