OXYGENHUB

Verify and keep signed Flow receipts cc19697f8871

finish may carry the runner's signed receipt. The hub keeps it in the
repository when it verifies, signs the job's receipt, is for the run's
state and repository, and uses the key the runner's token first signed
with. Otherwise the result stands and the run page says why. Run pages
link kept receipts with their signer, change pages list the receipts of
their state, and /{owner}/{name}/receipts/{attest} downloads one for oxy
receipt verify. The oxygenflow pin moves to signing runners.

closes #37
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 07:42 UTC
Signature
✔ signed by Andy McCall
Hash
cc19697f8871064ada7194955455ed9ecd2a053bcc4410f657c9bcecc900e774
Depends on
1edac4b68c6d 380996db84e5 3f4b0ea6eabf 42fa95540468 65be9f9c3074 736f6813543d 93be9ce4653e 9df855f29948 d23464c0ac60 fd3db644df22
Depended on by
58cbf42447bc 965b950f9b96 2c31915f5c89 5d90e0e045e2 c2be148cfe83 f4d6ef418a96 93501da2a162 4fa0da126b70 eb2a3502601d 73ef9f767132 180dc43ad0e0 757195ad7a5d c4e432da4bc9 9aaa88b2d9c8 7a14bdaeb857 3776b70d9b17 a7e6e8e6094c 1d634f698ecd e71583501b1d f677d6270298 6ca0b971ff07 6adcd68b90b1 a46d15e72101 1c8276b90314 501d8b268dd4 44a3d2ebaddd 2fb70d019851 92a797b20912 67040b158ae2 4d2e44d79b03 efd29a0033e8 2a83f5303458 8933faff2043 070a7b64b368 b6b7e4cf6b24 4f6925337848 10708ab8f54d a3c8288b0141
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.1.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 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
22 atoms, 1 tree operation, 11 files

Browse the tree at this change

added crates/hub/migrations/0021_flow_receipts.sql +14 −0
1 +
-- Signed receipts (Oxygen spec §14.6): the receipt attestation a job's
2 +
-- receipt is kept in the repository as, the runner that signed it, and why a
3 +
-- signed receipt was not kept.
4 +
ALTER TABLE flow_jobs ADD COLUMN receipt_attest TEXT NOT NULL DEFAULT '';
5 +
ALTER TABLE flow_jobs ADD COLUMN receipt_signer TEXT NOT NULL DEFAULT '';
6 +
ALTER TABLE flow_jobs ADD COLUMN receipt_note TEXT NOT NULL DEFAULT '';
7 +
-- The key each runner token signs with, learned from its first signed
8 +
-- receipt; later receipts under that token must carry the same key.
9 +
CREATE TABLE flow_runner_keys (
10 +
    token_hash TEXT PRIMARY KEY,
11 +
    key TEXT NOT NULL,
12 +
    name TEXT NOT NULL,
13 +
    first_ms INTEGER NOT NULL
14 +
);
modified crates/hub/src/db/flow.rs +31 −0
…
64 unchanged lines
65 65
    pub log_count: i64,
66 66
    /// Comma-separated ids of other repositories the job token may read.
67 67
    pub also_read: String,
68 +
    /// The receipt attestation kept in the repository (Oxygen spec §14.6).
69 +
    pub receipt_attest: String,
70 +
    /// Who signed it: the runner's identity name.
71 +
    pub receipt_signer: String,
72 +
    /// Why a signed receipt was not kept.
73 +
    pub receipt_note: String,
68 74
}
69 75
70 76
impl Job {
…
320 unchanged lines
391 397
        .bind(heads)
392 398
        .fetch_optional(pool)
393 399
        .await
400 +
}
401 +
402 +
/// The key runner token `token_hash` signs with: the one already bound, or
403 +
/// `key` (named `name`) when this is the first.
404 +
pub async fn bind_runner_key(pool: &Pool, token_hash: &str, key: &str, name: &str) -> sqlx::Result<String> {
405 +
    sqlx::query("INSERT OR IGNORE INTO flow_runner_keys (token_hash, key, name, first_ms) VALUES (?, ?, ?, ?)")
406 +
        .bind(token_hash)
407 +
        .bind(key)
408 +
        .bind(name)
409 +
        .bind(crate::db::now_ms())
410 +
        .execute(pool)
411 +
        .await?;
412 +
    sqlx::query_scalar("SELECT key FROM flow_runner_keys WHERE token_hash = ?").bind(token_hash).fetch_one(pool).await
413 +
}
414 +
415 +
/// Record what became of job `job_id`'s signed receipt.
416 +
pub async fn set_receipt(pool: &Pool, job_id: i64, attest: &str, signer: &str, note: &str) -> sqlx::Result<()> {
417 +
    sqlx::query("UPDATE flow_jobs SET receipt_attest = ?, receipt_signer = ?, receipt_note = ? WHERE id = ?")
418 +
        .bind(attest)
419 +
        .bind(signer)
420 +
        .bind(note)
421 +
        .bind(job_id)
422 +
        .execute(pool)
423 +
        .await?;
424 +
    Ok(())
394 425
}
modified crates/hub/src/flow.rs +50 −3
…
180 unchanged lines
181 181
    }
182 182
}
183 183
184 -
fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<(), ApiError> {
184 +
/// The runner's token, checked, as the BLAKE3 hex that names it.
185 +
fn runner_auth(app: &Shared, headers: &HeaderMap) -> Result<String, ApiError> {
185 186
    let Some(t) = oxy_server::bearer_token(headers) else {
186 187
        return Err(ApiError(StatusCode::UNAUTHORIZED, "a runner token is required".into()));
187 188
    };
188 189
    if app.config.flow.runner_tokens.iter().any(|x| crate::auth::mac_equal(x, t)) {
189 -
        Ok(())
190 +
        Ok(blake3::hash(t.as_bytes()).to_hex().to_string())
190 191
    } else {
191 192
        Err(ApiError(StatusCode::UNAUTHORIZED, "not a runner token".into()))
192 193
    }
…
162 unchanged lines
355 356
    Path(id): Path<i64>,
356 357
    Json(f): Json<Finish>,
357 358
) -> Result<StatusCode, ApiError> {
358 -
    runner_auth(&app, &headers)?;
359 +
    let token = runner_auth(&app, &headers)?;
359 360
    let job = running_job(&app, id).await?;
360 361
    let status = match (f.exit_code, &f.error) {
361 362
        (Some(0), None) => "passed",
…
16 unchanged lines
378 379
    .await?;
379 380
    if let Some(run) = fdb::run_by_id(&app.db, job.run_id).await? {
380 381
        if let Some(row) = db::repos::get_by_id(&app.db, run.repo_id).await? {
382 +
            if let (Some(r), Some(signed)) = (&f.receipt, &f.signed_receipt) {
383 +
                let (attest, signer, note) = match keep_receipt(&app, &token, &row, &run, r, signed).await {
384 +
                    Ok((attest, signer)) => (attest, signer, String::new()),
385 +
                    Err(e) => {
386 +
                        tracing::warn!("flow: job {} receipt not kept: {e}", job.id);
387 +
                        (String::new(), String::new(), e)
388 +
                    }
389 +
                };
390 +
                fdb::set_receipt(&app.db, job.id, &attest, &signer, &note).await?;
391 +
            }
381 392
            for j in changed {
382 393
                let summary = match j.status.as_str() {
383 394
                    "passed" => format!("passed in {}", duration(&j)),
…
6 unchanged lines
390 401
        }
391 402
    }
392 403
    Ok(StatusCode::NO_CONTENT)
404 +
}
405 +
406 +
/// Check a runner's signed receipt for a job of `run` and keep it in the
407 +
/// repository (Oxygen spec §14.6): the attestation's hex and the runner's
408 +
/// name, or why it was not kept. It must be the job's receipt, for the
409 +
/// run's state and repository, signed by the key the runner's token first
410 +
/// signed with.
411 +
pub async fn keep_receipt(
412 +
    app: &Shared,
413 +
    token_hash: &str,
414 +
    row: &db::repos::RepoRow,
415 +
    run: &fdb::Run,
416 +
    receipt: &flow_core::Receipt,
417 +
    signed_hex: &str,
418 +
) -> Result<(String, String), String> {
419 +
    use oxy_core::receipt::SignedReceipt;
420 +
    let bytes = hex::decode(signed_hex).map_err(|_| "the signed receipt is not hex".to_string())?;
421 +
    let sr = SignedReceipt::decode(&bytes).map_err(|e| format!("not a signed receipt: {e}"))?;
422 +
    sr.verify().map_err(|e| format!("it does not verify: {e}"))?;
423 +
    if sr.receipt.hash().0 != receipt.hash() {
424 +
        return Err("it signs another receipt than the job's".into());
425 +
    }
426 +
    let heads: Vec<oxy_core::hash::ChangeHash> = run.head_list().into_iter().filter_map(oxy_core::hash::ChangeHash::from_hex).collect();
427 +
    let state = oxy_core::hash::hash_object(oxy_core::hash::ObjectType::State, &oxy_core::objects::StateObject::new(heads).encode());
428 +
    if sr.state() != state {
429 +
        return Err("it is for another state than the run's".into());
430 +
    }
431 +
    let key = sr.runner.hash().to_hex();
432 +
    let bound = fdb::bind_runner_key(&app.db, token_hash, &key, &sr.runner.name).await.map_err(|e| e.to_string())?;
433 +
    if bound != key {
434 +
        return Err(format!("signed by {}, not the key this runner signs with ({})", &key[..12], &bound[..12.min(bound.len())]));
435 +
    }
436 +
    let name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
437 +
    let signer = sr.runner.name.clone();
438 +
    let attest = app.repos.write(&name, move |repo, _| Ok(repo.add_receipt(&sr)?)).await.map_err(|e| e.to_string())?;
439 +
    Ok((attest.to_hex(), signer))
393 440
}
394 441
395 442
/// A job's wall time, as the pages show it.
…
18 unchanged lines
modified crates/hub/src/web/flow.rs +79 −0
…
451 unchanged lines
452 452
        wi = w - 1.0,
453 453
    )
454 454
}
455 +
456 +
/// A receipt kept in a repository (Oxygen spec §14.6), as pages show it.
457 +
#[derive(Clone, Debug)]
458 +
pub struct ReceiptRow {
459 +
    pub attest: String,
460 +
    pub job: String,
461 +
    pub exit_code: u32,
462 +
    pub duration: String,
463 +
    pub runner: String,
464 +
    pub run: String,
465 +
    /// Why it does not verify, or that its runner's key was revoked.
466 +
    pub problem: String,
467 +
    pub made_ms: i64,
468 +
}
469 +
470 +
impl ReceiptRow {
471 +
    pub fn passed(&self) -> bool {
472 +
        self.exit_code == 0
473 +
    }
474 +
}
475 +
476 +
/// The receipts kept for the state of `heads`.
477 +
pub async fn receipts_of(app: &Shared, name: &crate::repos::RepoName, heads: Vec<oxy_core::hash::ChangeHash>) -> Vec<ReceiptRow> {
478 +
    let base = app.config.base_url();
479 +
    let found = app.repos.read(name, move |repo, _| Ok(repo.receipts(&heads)?)).await.unwrap_or_default();
480 +
    found
481 +
        .into_iter()
482 +
        .map(|i| {
483 +
            let r = &i.signed.receipt;
484 +
            let run = i.signed.run().to_string();
485 +
            ReceiptRow {
486 +
                attest: i.attest.to_hex(),
487 +
                job: r.job_name.clone(),
488 +
                exit_code: r.exit_code,
489 +
                duration: crate::flow::human_ms(r.duration_ms as i64),
490 +
                runner: i.signed.runner.name.clone(),
491 +
                // Only links to this hub are followed from the page.
492 +
                run: if run.starts_with(base) { run } else { String::new() },
493 +
                problem: match (i.problem, i.revoked) {
494 +
                    (Some(p), _) => p,
495 +
                    (None, true) => "the runner's key was revoked".into(),
496 +
                    (None, false) => String::new(),
497 +
                },
498 +
                made_ms: i.signed.attest.hlc.wall_ms as i64,
499 +
            }
500 +
        })
501 +
        .collect()
502 +
}
503 +
504 +
/// `GET /{owner}/{name}/receipts/{attest}`: a signed receipt, for
505 +
/// `oxy receipt verify` or `oxy receipt import`.
506 +
pub async fn receipt_file(
507 +
    State(app): State<Shared>,
508 +
    viewer: Viewer,
509 +
    Path((owner, name, attest)): Path<(String, String, String)>,
510 +
) -> Result<Response, Error> {
511 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/receipts/{attest}"), &viewer);
512 +
    let (repo, _row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
513 +
    let Some(r) = oxy_core::hash::ObjectRef::from_hex(attest.trim_end_matches(".receipt")) else {
514 +
        return Err(Error::not_found(shell));
515 +
    };
516 +
    let (job, bytes) = app
517 +
        .repos
518 +
        .read(&repo, move |repo, _| {
519 +
            let i = repo.receipt(&r)?;
520 +
            Ok((i.signed.receipt.job_name.clone(), i.signed.encode()))
521 +
        })
522 +
        .await
523 +
        .map_err(|_| Error::not_found(shell.clone()))?;
524 +
    let file: String = job.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect();
525 +
    Ok((
526 +
        [
527 +
            (axum::http::header::CONTENT_TYPE, "application/oxy-receipt".to_string()),
528 +
            (axum::http::header::CONTENT_DISPOSITION, format!("attachment; filename=\"{file}-{}.receipt\"", r.short(12))),
529 +
        ],
530 +
        bytes,
531 +
    )
532 +
        .into_response())
533 +
}
modified crates/hub/src/web/history.rs +7 −1
…
170 unchanged lines
171 171
    files: Vec<FileRows>,
172 172
    body_html: String,
173 173
    ignore_ws: bool,
174 +
    /// Receipts runners signed for the state whose only head is this change.
175 +
    receipts: Vec<super::flow::ReceiptRow>,
174 176
}
175 177
176 178
/// `?w=1` on a diff page hides whitespace-only changes.
…
37 unchanged lines
214 216
    let ignore_ws = q.options().ignore_whitespace;
215 217
    let body_html =
216 218
        if c.view.body.is_empty() { String::new() } else { format!("<pre class=\"msg\">{}</pre>", render::escape(&c.view.body)) };
217 -
    Ok(Page(ChangePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), c, files, body_html, ignore_ws }))
219 +
    let receipts = match oxy_core::hash::ChangeHash::from_hex(&c.view.hash) {
220 +
        Some(h) => super::flow::receipts_of(&app, &repo, vec![h]).await,
221 +
        None => vec![],
222 +
    };
223 +
    Ok(Page(ChangePage { shell, repo: RepoShell::new(&app, &row, "changes").with_role(role), c, files, body_html, ignore_ws, receipts }))
218 224
}
219 225
220 226
#[derive(Template)]
…
110 unchanged lines
modified crates/hub/src/web/mod.rs +1 −0
…
149 unchanged lines
150 150
        .route("/{owner}/{name}/flow/{id}/rerun", post(flow::rerun))
151 151
        .route("/{owner}/{name}/flow/stamp/{r}", get(flow::stamp))
152 152
        .route("/{owner}/{name}/flow/at/{r}", get(flow::at))
153 +
        .route("/{owner}/{name}/receipts/{attest}", get(flow::receipt_file))
153 154
        .route("/{owner}/{name}/releases", get(releases::list))
154 155
        .route("/{owner}/{name}/releases/new", get(releases::new_form).post(releases::create))
155 156
        .route("/{owner}/{name}/releases/tag/{*tag}", get(releases::show))
…
431 unchanged lines
modified crates/hub/tests/flow.rs +84 −0
…
694 unchanged lines
695 695
    let v: serde_json::Value = serde_json::from_str(&body).unwrap();
696 696
    assert_eq!(v["status"], 404, "{body}");
697 697
}
698 +
699 +
async fn get_bytes(app: &Shared, path: &str) -> (StatusCode, Vec<u8>) {
700 +
    let res = oxygenhub::router(app.clone()).oneshot(Request::get(path).body(Body::empty()).unwrap()).await.unwrap();
701 +
    let status = res.status();
702 +
    (status, res.into_body().collect().await.unwrap().to_bytes().to_vec())
703 +
}
704 +
705 +
#[tokio::test(flavor = "multi_thread")]
706 +
async fn signed_receipts_are_checked_kept_and_shown() {
707 +
    use oxy_core::hash::{hash_object, ChangeHash, ObjectRef, ObjectType};
708 +
    use oxy_core::identity::SecretKey;
709 +
    use oxy_core::objects::StateObject;
710 +
    use oxy_core::receipt::SignedReceipt;
711 +
    let h = hub().await;
712 +
    create(&h.app, "andy", "demo").await;
713 +
    let url = format!("{}/andy/demo", h.base);
714 +
    let work = tempfile::tempdir().unwrap();
715 +
    let c = client(&url, work.path());
716 +
    write(work.path(), ".flow/pipeline.kdl", &pipeline(""));
717 +
    c.snapshot("test").unwrap();
718 +
    c.commit("Add a pipeline", &[]).unwrap();
719 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
720 +
    let run = runs_of(&h.app, "andy", "demo").await.remove(0);
721 +
    let heads: Vec<ChangeHash> = run.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
722 +
    let state = hash_object(ObjectType::State, &StateObject::new(heads.clone()).encode());
723 +
    let repo_id = h.app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap().id().unwrap();
724 +
    let sk = SecretKey::generate();
725 +
    let runner = sk.identity("test-1 (OxygenFlow runner)", "", 1);
726 +
    let sign = |r: &serde_json::Value, state: ObjectRef, sk: &SecretKey, id: &oxy_core::identity::Identity| {
727 +
        let r: flow_core::Receipt = serde_json::from_value(r.clone()).unwrap();
728 +
        let run_url = format!("{url}/flow/{}", run.number);
729 +
        hex::encode(SignedReceipt::sign(r.to_oxy(), repo_id, state, &run_url, id, sk).encode())
730 +
    };
731 +
732 +
    let a = claim(&h.app).await.unwrap();
733 +
    let b = claim(&h.app).await.unwrap();
734 +
    let ph = a["pipeline_hash"].as_str().unwrap().to_string();
735 +
    let (a_id, b_id) = (a["job_id"].as_i64().unwrap(), b["job_id"].as_i64().unwrap());
736 +
    // The first signed receipt binds the runner token to its key, and is kept.
737 +
    let rb = receipt("build", &ph, 0);
738 +
    let body = serde_json::json!({ "exit_code": 0, "duration_ms": 700, "receipt": rb, "signed_receipt": sign(&rb, state, &sk, &runner) });
739 +
    assert_eq!(finish(&h.app, a_id, body).await, StatusCode::NO_CONTENT);
740 +
    // Another key under the same token: the result counts, the receipt is not kept.
741 +
    let other = SecretKey::generate();
742 +
    let rl = receipt("lint", &ph, 0);
743 +
    let body = serde_json::json!({ "exit_code": 0, "duration_ms": 300, "receipt": rl,
744 +
        "signed_receipt": sign(&rl, state, &other, &other.identity("mallory", "", 1)) });
745 +
    assert_eq!(finish(&h.app, b_id, body).await, StatusCode::NO_CONTENT);
746 +
    // The right key, but for another state.
747 +
    let t = claim(&h.app).await.unwrap();
748 +
    let rt = receipt("test", &ph, 0);
749 +
    let body = serde_json::json!({ "exit_code": 0, "duration_ms": 500, "receipt": rt,
750 +
        "signed_receipt": sign(&rt, ObjectRef([9; 32]), &sk, &runner) });
751 +
    assert_eq!(finish(&h.app, t["job_id"].as_i64().unwrap(), body).await, StatusCode::NO_CONTENT);
752 +
753 +
    let jobs = db::flow::jobs(&h.app.db, run.id).await.unwrap();
754 +
    let job = |n: &str| jobs.iter().find(|j| j.name == n).unwrap().clone();
755 +
    assert_eq!(job("build").status, "passed");
756 +
    assert_eq!((job("build").receipt_attest.len(), job("build").receipt_signer.as_str()), (64, "test-1 (OxygenFlow runner)"));
757 +
    assert_eq!(job("lint").status, "passed", "the job's result stands");
758 +
    assert!(job("lint").receipt_attest.is_empty());
759 +
    assert!(job("lint").receipt_note.contains("not the key this runner signs with"), "{}", job("lint").receipt_note);
760 +
    assert!(job("test").receipt_note.contains("another state"), "{}", job("test").receipt_note);
761 +
762 +
    // The repository keeps it, and anyone who can read the repository can
763 +
    // download it and check it on their own.
764 +
    let kept = h.app.repos.open(&RepoName::parse("andy/demo").unwrap()).unwrap().receipts(&heads).unwrap();
765 +
    assert_eq!(kept.len(), 1);
766 +
    let attest = job("build").receipt_attest;
767 +
    let (st, bytes) = get_bytes(&h.app, &format!("/andy/demo/receipts/{attest}")).await;
768 +
    assert_eq!(st, StatusCode::OK);
769 +
    let sr = SignedReceipt::decode(&bytes).unwrap();
770 +
    sr.verify().unwrap();
771 +
    assert_eq!((sr.state(), sr.receipt.job_name.as_str()), (state, "build"));
772 +
    assert_eq!(get_bytes(&h.app, &format!("/andy/demo/receipts/{}", "0".repeat(64))).await.0, StatusCode::NOT_FOUND);
773 +
774 +
    // Pages: the run names the signer or why a receipt was not kept; the
775 +
    // change whose state it is lists it.
776 +
    let (_, page) = call(&h.app, "GET", &format!("/andy/demo/flow/{}", run.number), None, None).await;
777 +
    assert!(page.contains("signed receipt</a> by test-1"), "run page");
778 +
    assert!(page.contains("receipt not kept"), "run page");
779 +
    let (_, page) = call(&h.app, "GET", &format!("/andy/demo/change/{}", heads[0].to_hex()), None, None).await;
780 +
    assert!(page.contains("<dt>Receipts</dt>") && page.contains(&format!("/andy/demo/receipts/{attest}")), "change page");
781 +
}
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +8 −1
…
424 unchanged lines
425 425
- **Triggers.** A push that moves a bookmark of an allowed repository starts a **run** on the bookmark's new state, numbered within its repository from 1 (the run's page is `/{owner}/{name}/flow/{number}`): the hub reads `.flow/pipeline.kdl` from that state and validates it with `flow-core` (the same code `flow check` runs). An invalid pipeline records a run in `error` with the problems and their lines; a state without a pipeline records nothing. Tags and proposals start no runs yet, so only code from people with write access ever runs. Writers also start runs without moving a bookmark: **Run pipeline** on the Flow tab runs a bookmark's current state, and **Re-run** on a finished run's page runs its state again. The new run's checks replace the old ones for that state, and the run page says what started it (pushed by, started by, re-run of #N by).
426 426
- **Runs and jobs.** A run holds the repository, bookmark, state hash and heads, pipeline hash and pusher; one job per pipeline job with its compiled form, sandbox and dependencies. Job states: `queued`, `running`, `passed`, `failed`, `skipped` (a dependency did not pass), `error` (the runner could not run it). A run is `running` while any job is, then `passed` when every job passed, else `failed`.
427 427
- **Runner API** (runner token): `POST /api/v1/flow/claim` hands out the oldest queued job whose dependencies passed and whose tier the runner offers, marking it `running`, with a **job token**; `204` when there is none. `POST /api/v1/flow/jobs/{id}/log` appends log records (text cut at 16 KiB, at most 50 000 per job); `POST /api/v1/flow/jobs/{id}/finish` records the exit code and receipt (the hub recomputes the receipt hash) and settles dependents. Only the runner holding the job may report on it.
428 +
- **Signed receipts** (Oxygen spec §14.6): `finish` may carry `signed_receipt`, the runner's signed receipt as hex. The hub keeps it in the repository when four things hold:
429 +
  - it verifies;
430 +
  - it signs the job's receipt;
431 +
  - it is for the run's state (computed from the run's heads) and this repository;
432 +
  - 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`.
433 +
434 +
  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`.
428 435
- **Job tokens.** Random, stored as a hash, valid only for reading the job's repository over the wire protocol while the job is running and for at most two hours. They cannot push, call the API or read other repositories.
429 436
- **Checks.** Each job is also the check `flow/<job>` on the run's state (§13.1), so proposals, required checks and the editor's status bar see it.
430 437
- **Pages.** A **Flow** tab lists runs (status, bookmark, state, pusher, when, duration). A run page draws the job graph as SVG, with columns by dependency depth and status by colour and glyph, and each job's log below (folded, the failed ones open); it refreshes while the run is live. Readers of the repository can see both.
…
2 unchanged lines
433 440
434 441
- `/{owner}/{name}/flow/stamp/{ref}` is an SVG. For a state address (heads joined with `+`) or a change, it shows the newest run of exactly that state: one glyph per job in dependency order, the run's status, the state's short hash and the run's date. It shows "no run" when there is none. The same data is machine-readable in the SVG's `<metadata>`: heads, run number, status, and each job's status and receipt hash. For a bookmark or tag, it redirects to the stamp of the ref's current state, so even the image names what was checked. A finished run's stamp may be cached for an hour; any other stamp for a minute.
435 442
- In rendered Markdown, the relative paths `.flow/stamp` (as an image) and `.flow/run` (as a link) resolve against the state being viewed. The first is served by `/raw/{ref}/.flow/stamp`, the second by `/file/{ref}/.flow/run` (the run page, or the Flow tab when that state has no run). So `[![flow](.flow/stamp)](.flow/run)` in a README shows how the viewed state did: browsing a tag shows the tag's result, not the bookmark's. A real file at either path takes precedence.
436 -
- Later, stamps also say which runner key sealed the result (once runner seals exist) and which jobs reused an earlier receipt.
443 +
- Later, stamps also say which runner key signed each receipt, and which jobs reused an earlier receipt.
437 444
438 445
The older runner draft (`docs/runner.md`) is superseded by OxygenFlow; its hub-side ideas (environments, approvals for outsider proposals, caches, artifacts) carry over as later Flow milestones.
439 446
…
147 unchanged lines
modified templates/change.html +1 −0
…
16 unchanged lines
17 17
    <dt>Depended on by</dt><dd>{% if c.dependents.is_empty() %}<span class="mute">nothing yet</span>{% else %}{% for d in c.dependents %}<a class="chip" href="{{ repo.url }}/change/{{ d.hash }}" title="{{ d.title }}">{{ d.short }}</a> {% endfor %}{% endif %}</dd>
18 18
    <dt>In bookmarks</dt><dd>{% for b in c.in_bookmarks %}<a class="lbl lbl-bm" href="{{ repo.url }}/tree/{{ b }}">{{ b }}</a> {% endfor %}{% if c.in_bookmarks.is_empty() %}<span class="mute">none</span>{% endif %}</dd>
19 19
    {% if !c.in_tags.is_empty() %}<dt>In tags</dt><dd>{% for t in c.in_tags %}<a class="lbl lbl-tag" href="{{ repo.url }}/tree/{{ t }}">{{ t }}</a> {% endfor %}</dd>{% endif %}
20 +
    {% if !receipts.is_empty() %}<dt>Receipts</dt><dd class="receipts">{% for r in receipts %}<div><span class="flow-glyph st-{% if r.passed() %}passed{% else %}failed{% endif %}">{% if r.passed() %}✔{% else %}✘{% endif %}</span> {% if r.run.is_empty() %}<strong>{{ r.job }}</strong>{% else %}<a href="{{ r.run }}"><strong>{{ r.job }}</strong></a>{% endif %} <span class="small mute">{% if r.passed() %}passed{% else %}exit {{ r.exit_code }}{% endif %} in {{ r.duration }} · signed by {{ r.runner }} · <time title="{{ r.made_ms|when }}">{{ r.made_ms|ago }}</time></span>{% if !r.problem.is_empty() %} <span class="small warn">{{ r.problem }}</span>{% endif %} <a class="small" href="{{ repo.url }}/receipts/{{ r.attest }}" title="The signed receipt, for oxy receipt verify">download</a></div>{% endfor %}</dd>{% endif %}
20 21
    <dt>Edits</dt><dd>{{ c.atoms|plural("atom", "atoms") }}, {{ c.tree_ops|plural("tree operation", "tree operations") }}, {{ files.len()|plural("file", "files") }}</dd>
21 22
  </dl>
22 23
  <p class="small"><a href="{{ repo.url }}/tree/{{ c.view.hash }}">Browse the tree at this change</a></p>
…
4 unchanged lines
modified templates/flow_run.html +1 −1
…
27 unchanged lines
28 28
</section>
29 29
{% for l in logs %}
30 30
<details class="panel flow-job st-{{ l.j.status }}" id="job-{{ l.j.id }}" data-job="{{ l.j.id }}"{% if l.j.status == "failed" || l.j.status == "error" || l.j.status == "running" %} open{% endif %}>
31 -
  <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on {{ l.j.runner }}{% endif %}</span>{% if !l.j.receipt_hash.is_empty() %} <span class="small mute">· receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}</summary>
31 +
  <summary><span class="flow-glyph st-{{ l.j.status }}" data-glyph>{{ crate::web::flow::glyph(l.j.status) }}</span> <strong>{{ l.j.name }}</strong> <span class="small mute" data-meta>{{ l.j.status }}{% if let Some(c) = l.j.exit_code %} · exit {{ c }}{% endif %} · {{ l.duration }}{% if !l.j.runner.is_empty() %} · on {{ l.j.runner }}{% endif %}</span>{% if !l.j.receipt_attest.is_empty() %} <span class="small mute">· <a href="{{ repo.url }}/receipts/{{ l.j.receipt_attest }}" title="Receipt {{ l.j.receipt_hash }}, kept in the repository; check it with oxy receipt verify">signed receipt</a> by {{ l.j.receipt_signer }}</span>{% else if !l.j.receipt_note.is_empty() %} <span class="small warn" title="{{ l.j.receipt_note }}">· receipt not kept: {{ l.j.receipt_note }}</span>{% else if !l.j.receipt_hash.is_empty() %} <span class="small mute">· unsigned receipt <code title="{{ l.j.receipt_hash }}">{{ l.j.receipt_hash|short }}</code></span>{% endif %}</summary>
32 32
  {% if !l.j.error.is_empty() %}<div class="strip bad">{{ l.j.error }}</div>{% endif %}
33 33
  <pre class="flow-log" data-log data-seq="{{ l.last_seq }}">{% for line in l.lines %}<span class="ln s-{{ line.stream }}"><span class="t">{{ line.t / 1000 }}.{{ "{:01}"|format(line.t % 1000 / 100) }}s</span>{{ line.text }}</span>
34 34
{% endfor %}</pre>
…
6 unchanged lines