OXYGENHUB

Diffusion: an upstream without checks is amber, not green 9aaa88b2d9c8

An upstream with no checks of its own never turns green, so consumers
following green would never be bumped. Its moves now bump them too,
marked amber: the upstream unverified label and a note that only the
consumer's pipeline tested it. A passing run later clears it, and a bump
already in place has its proposal's text and labels brought up to date.

refs #148
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-13 06:38 UTC
Signature
✔ signed by Andy McCall
Hash
9aaa88b2d9c8f74f0f937c1862231bc25549b6fb879deced0de9565faa868c6a
Depends on
1b6536f06132 1d634f698ecd 3776b70d9b17 42fa95540468 7a14bdaeb857 8620925e5c53 93be9ce4653e a7e6e8e6094c cc19697f8871 f01cb865fc60 f677d6270298
Depended on by
3b1bc27fe805 5d90e0e045e2 f4d6ef418a96 6ae3fa0af4f6 c4e432da4bc9
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
26 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified crates/hub/src/diffusion.rs +96 −17
…
179 unchanged lines
180 180
    /// The pin already holds the upstream state.
181 181
    UpToDate,
182 182
    /// The bump bookmark already moves the pin to that state.
183 -
    Already,
183 +
    Already { from: Vec<ChangeHash>, to: Vec<ChangeHash> },
184 184
    /// The pin names changes the upstream does not hold: not followed.
185 185
    Unknown,
186 186
    /// Taking the upstream's state with the pin's own extra changes would
…
98 unchanged lines
285 285
        b.sort();
286 286
        n.sort();
287 287
        if b == n {
288 -
            return Ok(Bumped::Already);
288 +
            return Ok(Bumped::Already { from: old, to: new_heads });
289 289
        }
290 290
    }
291 291
    let (key, secret) =
…
75 unchanged lines
367 367
        .unwrap_or_default()
368 368
}
369 369
370 -
/// Open the bump proposal for `bookmark`, or bring the open one up to
371 -
/// date: from `bookmark` into `target`, by the `diffusion` account,
372 -
/// labelled `dependencies`, its body the changelog. Its number.
370 +
/// Why a bump was made, which its proposal says.
371 +
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
372 +
pub enum Why {
373 +
    /// The upstream's state passed its checks.
374 +
    Green,
375 +
    /// The upstream moved (the consumer follows every move).
376 +
    Moved,
377 +
    /// The upstream has no checks at all: nothing vouched for it, so the
378 +
    /// proposal is marked amber, `upstream unverified`.
379 +
    Unverified,
380 +
}
381 +
382 +
/// Open the bump proposal for `path`'s bump bookmark, or bring the open
383 +
/// one up to date: into `target`, by the `diffusion` account, labelled
384 +
/// `dependencies` (and `upstream unverified` while that is so), its body
385 +
/// the changelog from the pin `from` to `to`. Its number.
373 386
pub async fn propose(
374 387
    app: &Shared,
375 388
    consumer: &RepoRow,
376 389
    target: &str,
377 390
    path: &str,
378 391
    upstream: &RepoRow,
379 -
    from: Vec<ChangeHash>,
380 -
    to: Vec<ChangeHash>,
392 +
    (from, to): (Vec<ChangeHash>, Vec<ChangeHash>),
393 +
    why: Why,
381 394
) -> Option<i64> {
382 395
    use crate::db::proposals as pdb;
383 396
    let bookmark = bump_bookmark(path);
…
5 unchanged lines
389 402
    let ub = &upstream.default_bookmark;
390 403
    let count = if lines.len() == 1 { "1 change".to_string() } else { format!("{} changes", lines.len()) };
391 404
    let title = format!("Diffusion: {path} to {slug} {ub} ({count})");
392 -
    let when =
393 -
        if Follow::parse(&consumer.diffusion) == Some(Follow::Any) { "as it is now" } else { "at its newest state whose checks passed" };
405 +
    let when = match why {
406 +
        Why::Green => "at its newest state whose checks passed",
407 +
        Why::Moved => "as it is now",
408 +
        Why::Unverified => "as it is now",
409 +
    };
410 +
    let amber = if why == Why::Unverified {
411 +
        format!("\n\n> **Upstream unverified.** {slug} has no checks of its own, so nothing has tested this state of it. Only this repository's pipeline tests the move; a later bump from a passing run of {slug} clears this.")
412 +
    } else {
413 +
        String::new()
414 +
    };
394 415
    let body = format!(
395 -
        "Moves the subrepo `{path}` to [{slug}](/{slug})'s `{ub}`, {when} (`{}`). Only the pin changes; this repository's own checks test it.\n\n**What it brings** ({count}):\n\n{}\n\nOpened and kept up to date by Diffusion (docs/diffusion.md). It never lands by itself.",
416 +
        "Moves the subrepo `{path}` to [{slug}](/{slug})'s `{ub}`, {when} (`{}`). Only the pin changes; this repository's own checks test it.{amber}\n\n**What it brings** ({count}):\n\n{}\n\nOpened and kept up to date by Diffusion (docs/diffusion.md). It never lands by itself.",
396 417
        short.join("+"),
397 418
        lines.join("\n")
398 419
    );
399 420
    let open = pdb::list(&app.db, consumer.id, &["open", "draft"]).await.ok()?.into_iter().find(|p| p.source_bookmark == bookmark);
400 -
    let number = match open {
421 +
    let (number, id) = match open {
401 422
        Some(p) => {
402 -
            pdb::edit(&app.db, p.id, &title, &body, target).await.ok()?;
403 -
            pdb::add_event(&app.db, p.id, ACCOUNT, "updated", &short.join("+")).await.ok();
404 -
            p.number
423 +
            if p.title != title || p.body != body {
424 +
                pdb::edit(&app.db, p.id, &title, &body, target).await.ok()?;
425 +
                pdb::add_event(&app.db, p.id, ACCOUNT, "updated", &short.join("+")).await.ok();
426 +
            }
427 +
            (p.number, p.id)
405 428
        }
406 429
        None => {
407 430
            let p = pdb::create(
…
21 unchanged lines
429 452
            {
430 453
                db::issues::set_proposal_label(&app.db, p.id, label.id, true).await.ok();
431 454
            }
432 -
            p.number
455 +
            (p.number, p.id)
433 456
        }
434 457
    };
458 +
    // Amber while the upstream has no checks; cleared by a green bump.
459 +
    let unverified = db::issues::label_by_name(&app.db, consumer.id, "upstream unverified").await.ok().flatten();
460 +
    match (why == Why::Unverified, unverified) {
461 +
        (true, l) => {
462 +
            let l = match l {
463 +
                Some(l) => Some(l),
464 +
                None => db::issues::upsert_label(
465 +
                    &app.db,
466 +
                    consumer.id,
467 +
                    "upstream unverified",
468 +
                    "#e3a008",
469 +
                    "The upstream has no checks (Diffusion)",
470 +
                )
471 +
                .await
472 +
                .ok(),
473 +
            };
474 +
            if let Some(l) = l {
475 +
                db::issues::set_proposal_label(&app.db, id, l.id, true).await.ok();
476 +
            }
477 +
        }
478 +
        (false, Some(l)) => {
479 +
            db::issues::set_proposal_label(&app.db, id, l.id, false).await.ok();
480 +
        }
481 +
        (false, None) => {}
482 +
    }
435 483
    Some(number)
436 484
}
437 485
…
46 unchanged lines
484 532
        .fetch_all(&app.db)
485 533
        .await
486 534
        .unwrap_or_default();
535 +
    let unverified = unverified(app, upstream).await;
487 536
    for (consumer_id, indexed, path) in rows {
488 537
        if only.is_some_and(|o| o != consumer_id) {
489 538
            continue;
…
7 unchanged lines
497 546
        if indexed != target {
498 547
            continue;
499 548
        }
549 +
        // An upstream with no checks never turns green: its moves count
550 +
        // for green followers too, marked amber (unverified).
500 551
        let go = match r.follow {
501 552
            Follow::Off => false,
502 -
            Follow::Green => green,
553 +
            Follow::Green => green || unverified,
503 554
            Follow::Any => !green,
504 555
        };
556 +
        let why = if green {
557 +
            Why::Green
558 +
        } else if unverified {
559 +
            Why::Unverified
560 +
        } else {
561 +
            Why::Moved
562 +
        };
505 563
        if !go {
506 564
            continue;
507 565
        }
…
1 unchanged lines
509 567
            Ok(Bumped::Moved { from, bookmark, heads: bumped, state, .. }) => {
510 568
                // Its pipeline runs as for a push, so the proposal is tested.
511 569
                tokio::spawn(start_runs(app.clone(), consumer.clone(), bookmark, state, bumped));
512 -
                let n = propose(app, &consumer, &target, &path, upstream, from, heads.clone()).await;
570 +
                let n = propose(app, &consumer, &target, &path, upstream, (from, heads.clone()), why).await;
513 571
                tracing::info!(
514 572
                    "diffusion: {}/{} {path} bumped to {}/{} (proposal {n:?})",
515 573
                    consumer.owner,
…
1 unchanged lines
517 575
                    upstream.owner,
518 576
                    upstream.name
519 577
                );
578 +
            }
579 +
            Ok(Bumped::Already { from, to }) => {
580 +
                // Already moved: its proposal's text and labels are brought
581 +
                // up to date all the same.
582 +
                propose(app, &consumer, &target, &path, upstream, (from, to), why).await;
520 583
            }
521 584
            Ok(other) => tracing::info!("diffusion: {}/{} {path}: no bump ({other:?})", consumer.owner, consumer.name),
522 585
            Err(e) => tracing::warn!("diffusion: bump {}/{} {path}: {e}", consumer.owner, consumer.name),
523 586
        }
524 587
    }
588 +
}
589 +
590 +
/// Whether `upstream` has no checks of its own: Flow is off for it, or
591 +
/// its default bookmark has never had a run. Its moves are then
592 +
/// "unverified" (amber), never green.
593 +
pub async fn unverified(app: &Shared, upstream: &RepoRow) -> bool {
594 +
    if !crate::flow::enabled(app, upstream) {
595 +
        return true;
596 +
    }
597 +
    let runs: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM flow_runs WHERE repo_id = ? AND bookmark = ?")
598 +
        .bind(upstream.id)
599 +
        .bind(&upstream.default_bookmark)
600 +
        .fetch_one(&app.db)
601 +
        .await
602 +
        .unwrap_or(0);
603 +
    runs == 0
525 604
}
526 605
527 606
/// The repository on this hub with Oxygen id `id` (hex).
…
191 unchanged lines
modified crates/hub/tests/flow.rs +63 −3
…
3196 unchanged lines
3197 3197
        "{pin:?}"
3198 3198
    );
3199 3199
    assert!(bump_state.files.contains_key("game.c"), "the rest of the game is as it was");
3200 -
    assert_eq!(
3200 +
    assert!(matches!(
3201 3201
        oxygenhub::diffusion::bump(&h.app, &game_row, "main", "deps/api", &api_row, api_heads.clone()).await.unwrap(),
3202 -
        oxygenhub::diffusion::Bumped::Already
3203 -
    );
3202 +
        oxygenhub::diffusion::Bumped::Already { .. }
3203 +
    ));
3204 3204
    // A state lacking changes the pin has (as when the consumer pinned an
3205 3205
    // upstream proposal not landed yet) is combined with the pin, never
3206 3206
    // dropping what it relies on.
…
218 unchanged lines
3425 3425
        .await
3426 3426
        .unwrap_or(0)
3427 3427
        > 0
3428 +
}
3429 +
3430 +
/// An upstream with no checks never turns green: a consumer following green
3431 +
/// still gets its bumps, marked amber, `upstream unverified` (#148).
3432 +
#[tokio::test(flavor = "multi_thread")]
3433 +
async fn bumps_from_an_upstream_without_checks_are_marked_unverified() {
3434 +
    let h = hub().await;
3435 +
    db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap();
3436 +
    create(&h.app, "andy", "lib").await;
3437 +
    create(&h.app, "andy", "app").await;
3438 +
    let lib_work = tempfile::tempdir().unwrap();
3439 +
    let lib = client(&format!("{}/andy/lib", h.base), lib_work.path());
3440 +
    write(lib_work.path(), "lib.h", "v1\n");
3441 +
    lib.snapshot("t").unwrap();
3442 +
    lib.commit("lib v1", &[]).unwrap();
3443 +
    assert!(lib.push("origin", false).unwrap().iter().all(|r| r.ok));
3444 +
    let app_work = tempfile::tempdir().unwrap();
3445 +
    let app = client(&format!("{}/andy/app", h.base), app_work.path());
3446 +
    app.submodule_add(&format!("{}/andy/lib", h.base), "deps/lib", Some("main")).unwrap();
3447 +
    app.snapshot("t").unwrap();
3448 +
    app.commit("app on lib", &[]).unwrap();
3449 +
    assert!(app.push("origin", false).unwrap().iter().all(|r| r.ok));
3450 +
    let app_row = db::repos::get(&h.app.db, "andy", "app").await.unwrap().unwrap();
3451 +
    for _ in 0..100 {
3452 +
        if sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM diffusion_pins WHERE consumer_id = ?")
3453 +
            .bind(app_row.id)
3454 +
            .fetch_one(&h.app.db)
3455 +
            .await
3456 +
            .unwrap()
3457 +
            > 0
3458 +
        {
3459 +
            break;
3460 +
        }
3461 +
        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
3462 +
    }
3463 +
    let (st, _) = call(&h.app, "PUT", "/api/v1/repos/andy/app/diffusion", Some("site"), Some(r#"{"follow":"green"}"#.into())).await;
3464 +
    assert_eq!(st, StatusCode::OK);
3465 +
    write(lib_work.path(), "lib.c", "v2\n");
3466 +
    lib.snapshot("t").unwrap();
3467 +
    lib.commit("lib v2", &[]).unwrap();
3468 +
    assert!(lib.push("origin", false).unwrap().iter().all(|r| r.ok));
3469 +
    let mut found = None;
3470 +
    for _ in 0..100 {
3471 +
        if let Some(p) = db::proposals::list(&h.app.db, app_row.id, &["open"]).await.unwrap().into_iter().next() {
3472 +
            found = Some(p);
3473 +
            break;
3474 +
        }
3475 +
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3476 +
    }
3477 +
    let p = found.expect("a bump although the upstream never goes green");
3478 +
    assert!(p.body.contains("Upstream unverified"), "{}", p.body);
3479 +
    let mut labels = Vec::new();
3480 +
    for _ in 0..100 {
3481 +
        labels = db::issues::proposal_labels(&h.app.db, p.id).await.unwrap().into_iter().map(|l| l.name).collect::<Vec<String>>();
3482 +
        if labels.len() == 2 {
3483 +
            break;
3484 +
        }
3485 +
        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
3486 +
    }
3487 +
    assert!(labels.contains(&"upstream unverified".to_string()) && labels.contains(&"dependencies".to_string()), "{labels:?}");
3428 3488
}
modified docs/diffusion.md +5 −1
…
70 unchanged lines
71 71
3. else the hub's default (`[diffusion] follow` in hub.toml, `off`).
72 72
73 73
`green` waits for the upstream state's required checks to pass; `any`
74 -
follows every move. `auto` lands a green bump itself when the consumer's
74 +
follows every move. An upstream with no checks at all is **unverified**
75 +
(amber): it never turns green, so its moves bump green followers too, and
76 +
the proposal is labelled `upstream unverified` with a note saying only the
77 +
consumer's pipeline tested it (decided by Andy, 2026-10-13). A passing run
78 +
of the upstream later clears it. `auto` lands a green bump itself when the consumer's
75 79
trust policy and required checks allow it; D1 only proposes.
76 80
77 81
### 4.3 The bump
…
132 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). When the pin has changes the upstream's state lacks (say, from an upstream proposal not landed yet), the bump moves to both, so a consumer never loses changes it relies on; it is skipped when that combination would conflict, when the pin names changes the upstream does not hold, when the pin already holds the state, or when the bump bookmark already moves it there, and the hub logs why. 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).
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). When the pin has changes the upstream's state lacks (say, from an upstream proposal not landed yet), the bump moves to both, so a consumer never loses changes it relies on; it is skipped when that combination would conflict, when the pin names changes the upstream does not hold, when the pin already holds the state, or when the bump bookmark already moves it there, and the hub logs why. 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. An upstream with no checks of its own (Flow off, or no run ever of its default bookmark) is **unverified**: it never turns green, so its moves also bump consumers following `green`, and those proposals are marked amber with the `upstream unverified` label and a note in the body; a later bump from a passing run removes the label. A bump already in place refreshes its proposal's text and labels. 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