OXYGENHUB

Another repository's pass is taken only on its signed receipt, re-verified from that repository's store and its key not revoked; only passes that ran there (closes #296's C2, refs #296) 06b756358f7e

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 2d7fba081de4
When
· 2026-10-27 23:24 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
06b756358f7e15fa0441184178b46de52dd21e161d43a0dbe337234f0d96db37
Depends on
42cd46c9b09e 42fa95540468 5588bb3631d2 91c306aa4706 a7e6e8e6094c b1860d6090ec b5668108487f
Depended on by
4dfa4599b3f7
In bookmarks
main
In tags
v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24
Edits
13 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **Another repository's pass is taken only on its signed receipt (#296).** A pass from upstream or from any other repository on the hub now stands only if its runner's signed receipt was kept (the hub checks it against the runner's bound key when the job finishes) and, read again from that repository's store, still verifies and its key is not revoked; otherwise the job runs here. Only a pass that ran in the repository offering it is taken, so the receipt is where it is checked.
8 +
7 9
<<<<<<< oxy conflict 1 of 1, side 1 of 2: change 681d2bd9 "Approval gates and expedite for clients: a run's jobs over the API say their approval, and POST …/flow/runs/N/expedite makes a run urgent as the run page does, sharing flow::expedite (refs oxygenhub-android#5)"
8 10
- **Approval gates and expedite for clients (oxygenhub-android#5).** A run's jobs over the API say `approval` (`waiting` while one waits for a person, `approved` or `rejected` after), and `POST /api/v1/repos/OWNER/NAME/flow/runs/N/expedite {"reason": …}` makes a run urgent as the run page's Expedite does (a maintainer, with a reason, within the owner's cap of urgent runs); the page and the API share it.
9 11
…
389 unchanged lines
modified crates/hub/src/db/flow.rs +19 −2
…
280 unchanged lines
281 281
    spec: &str,
282 282
    sandbox: &str,
283 283
) -> sqlx::Result<Option<Reused>> {
284 +
    passed_in(pool, repo_id, state, name, spec, sandbox, false).await
285 +
}
286 +
287 +
/// As [`passed_on_state`]; with `ran_here`, only a job that ran in that
288 +
/// repository, not one it reused: its signed receipt is in that
289 +
/// repository's store, where another repository's reuse checks it (#296).
290 +
async fn passed_in(
291 +
    pool: &Pool,
292 +
    repo_id: i64,
293 +
    state: &str,
294 +
    name: &str,
295 +
    spec: &str,
296 +
    sandbox: &str,
297 +
    ran_here: bool,
298 +
) -> sqlx::Result<Option<Reused>> {
284 299
    let row: Option<(i64, String, String, String, String, String)> = sqlx::query_as(
285 300
        "SELECT r.number, j.receipt, j.receipt_hash, j.receipt_attest, j.receipt_signer, j.receipt_note \
286 301
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id \
287 302
         WHERE r.repo_id = $1 AND r.state = $2 AND j.name = $3 AND j.spec = $4 AND j.sandbox = $5 AND j.status = 'passed' \
303 +
           AND (NOT $6 OR j.error NOT LIKE 'reused from %') \
288 304
         ORDER BY j.id DESC LIMIT 1",
289 305
    )
290 306
    .bind(repo_id)
…
1 unchanged lines
292 308
    .bind(name)
293 309
    .bind(spec)
294 310
    .bind(sandbox)
311 +
    .bind(ran_here)
295 312
    .fetch_optional(pool)
296 313
    .await?;
297 314
    Ok(row.map(|(run_number, receipt, receipt_hash, receipt_attest, receipt_signer, receipt_note)| Reused {
…
31 unchanged lines
329 346
        if !(up.visibility == "public" || up.owner == repo.owner) {
330 347
            continue;
331 348
        }
332 -
        if let Some(mut r) = passed_on_state(pool, up.id, state, name, spec, sandbox).await? {
349 +
        if let Some(mut r) = passed_in(pool, up.id, state, name, spec, sandbox, true).await? {
333 350
            r.repo = Some(format!("{}/{}", up.owner, up.name));
334 351
            return Ok(Some(r));
335 352
        }
…
29 unchanged lines
365 382
        "SELECT o.owner, o.name, r.number, j.receipt, j.receipt_hash, j.receipt_attest, j.receipt_signer, j.receipt_note \
366 383
         FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id JOIN repositories o ON o.id = r.repo_id \
367 384
         WHERE r.state = $1 AND j.name = $2 AND j.spec = $3 AND j.sandbox = $4 AND j.status = 'passed' \
368 -
           AND o.id <> $5 AND (o.visibility = 'public' OR o.owner = $6) \
385 +
           AND o.id <> $5 AND (o.visibility = 'public' OR o.owner = $6) AND j.error NOT LIKE 'reused from %' \
369 386
         ORDER BY j.id DESC LIMIT 1",
370 387
    )
371 388
    .bind(state)
…
1276 unchanged lines
modified crates/hub/src/flow.rs +28 −0
…
847 unchanged lines
848 848
                        j.reused =
849 849
                            fdb::passed_elsewhere(&app.db, row, state, &j.name, &j.spec, &j.sandbox).await.map_err(|e| e.to_string())?;
850 850
                    }
851 +
                    // Another repository's pass is taken only on its signed
852 +
                    // receipt, checked again now: otherwise it runs here.
853 +
                    if let Some((from, attest)) = j.reused.as_ref().and_then(|r| Some((r.repo.clone()?, r.receipt_attest.clone()))) {
854 +
                        if let Err(why) = receipt_holds(app, &from, &attest).await {
855 +
                            tracing::info!("flow: not reusing {from}'s pass of {} on {state}: {why}", j.name);
856 +
                            j.reused = None;
857 +
                        }
858 +
                    }
851 859
                }
852 860
            }
853 861
            let hash = hex::encode(p.hash());
…
1737 unchanged lines
2591 2599
            tracing::warn!("flow: placing after an expedite: {e}");
2592 2600
        }
2593 2601
        app.bus.live(&crate::events::topic::run(run.id), "jobs");
2602 +
    }
2603 +
    Ok(())
2604 +
}
2605 +
2606 +
/// Whether another repository's pass may stand here (#296): its runner's
2607 +
/// signed receipt was kept (the hub checked it against the runner's bound
2608 +
/// key when the job finished), and read again from that repository's store
2609 +
/// it still verifies and its key is not revoked.
2610 +
pub async fn receipt_holds(app: &Shared, from: &str, attest: &str) -> Result<(), String> {
2611 +
    if attest.is_empty() {
2612 +
        return Err("no signed receipt was kept".into());
2613 +
    }
2614 +
    let repo = crate::repos::RepoName::parse(from).ok_or("not a repository name")?;
2615 +
    let at = oxy_core::hash::ObjectRef::from_hex(attest).ok_or("not a receipt's hash")?;
2616 +
    let info = app.repos.read(&repo, move |r, _| Ok(r.receipt(&at)?)).await.map_err(|e| format!("the receipt: {e}"))?;
2617 +
    if let Some(problem) = info.problem {
2618 +
        return Err(format!("its receipt does not verify: {problem}"));
2619 +
    }
2620 +
    if info.revoked {
2621 +
        return Err("its runner's key is revoked".into());
2594 2622
    }
2595 2623
    Ok(())
2596 2624
}
…
511 unchanged lines
modified crates/hub/tests/flow.rs +52 −6
…
2017 unchanged lines
2018 2018
    c.commit("Start", &[]).unwrap();
2019 2019
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2020 2020
    let first = runs_of(&h.app, "andy", "game").await.remove(0);
2021 -
    sqlx::query("UPDATE flow_jobs SET status = 'passed', exit_code = 0, receipt_hash = 'r-' || name WHERE run_id = $1")
2022 -
        .bind(first.id)
2023 -
        .execute(&h.app.db)
2024 -
        .await
2025 -
        .unwrap();
2021 +
    // Upstream's jobs pass on a runner, each with its signed receipt, which
2022 +
    // the hub checks against the runner's key and keeps (#296's C2).
2023 +
    {
2024 +
        use oxy_core::hash::{hash_object, ChangeHash, ObjectType};
2025 +
        use oxy_core::identity::SecretKey;
2026 +
        use oxy_core::objects::StateObject;
2027 +
        use oxy_core::receipt::SignedReceipt;
2028 +
        let heads: Vec<ChangeHash> = first.head_list().into_iter().filter_map(ChangeHash::from_hex).collect();
2029 +
        let state = hash_object(ObjectType::State, &StateObject::new(heads).encode());
2030 +
        let repo_id = h.app.repos.open(&RepoName::parse("andy/game").unwrap()).unwrap().id().unwrap();
2031 +
        let sk = SecretKey::generate();
2032 +
        let runner = sk.identity("test-1 (OxygenFlow runner)", "", 1);
2033 +
        let run_url = format!("{}/andy/game/flow/{}", h.base, first.number);
2034 +
        while let Some(j) = claim(&h.app).await {
2035 +
            let r = receipt(j["job"]["name"].as_str().unwrap(), j["pipeline_hash"].as_str().unwrap(), 0);
2036 +
            let parsed: flow_core::Receipt = serde_json::from_value(r.clone()).unwrap();
2037 +
            let signed = hex::encode(SignedReceipt::sign(parsed.to_oxy(), repo_id, state, &run_url, &runner, &sk).encode());
2038 +
            let body = serde_json::json!({ "exit_code": 0, "duration_ms": 5, "receipt": r, "signed_receipt": signed });
2039 +
            assert_eq!(finish(&h.app, j["job_id"].as_i64().unwrap(), body).await, StatusCode::NO_CONTENT);
2040 +
        }
2041 +
    }
2042 +
    let up_jobs = db::flow::jobs(&h.app.db, first.id).await.unwrap();
2043 +
    assert!(up_jobs.iter().all(|j| j.status == "passed" && j.receipt_attest.len() == 64), "{up_jobs:?}");
2026 2044
    // The same changes pushed to the fork: the same state.
2027 2045
    c.config.set("remote.origin.url", &format!("{}/bob/game", h.base)).unwrap();
2028 2046
    c.save_config().unwrap();
…
3 unchanged lines
2032 2050
    let jobs = db::flow::jobs(&h.app.db, run.id).await.unwrap();
2033 2051
    for j in &jobs {
2034 2052
        assert_eq!((j.status.as_str(), j.reused_from(), j.reused_repo()), ("passed", Some(first.number), Some("andy/game")), "{j:?}");
2035 -
        assert_eq!(j.receipt_hash, format!("r-{}", j.name));
2053 +
        let from = up_jobs.iter().find(|u| u.name == j.name).unwrap();
2054 +
        assert_eq!(j.receipt_attest, from.receipt_attest, "upstream's signed receipt stands for it");
2036 2055
    }
2037 2056
    let (_, page) = call(&h.app, "GET", &format!("/bob/game/flow/{}", run.number), None, None).await;
2038 2057
    assert!(page.contains(&format!("href=\"/andy/game/flow/{}\">andy/game run #{}</a>", first.number, first.number)), "{page}");
2039 2058
    let checks = db::hooks::checks_for_state(&h.app.db, fork.id, &run.state).await.unwrap();
2040 2059
    assert!(checks.iter().any(|c| c.summary.contains("passed in andy/game run #")), "{checks:?}");
2060 +
2061 +
    // A pass whose receipt does not hold (here, one the store never kept)
2062 +
    // is not taken: that job runs in the fork; the others are reused.
2063 +
    let lint = up_jobs.iter().find(|j| j.name == "lint").unwrap();
2064 +
    sqlx::query("UPDATE flow_jobs SET receipt_attest = $1 WHERE id = $2")
2065 +
        .bind("ab".repeat(32))
2066 +
        .bind(lint.id)
2067 +
        .execute(&h.app.db)
2068 +
        .await
2069 +
        .unwrap();
2070 +
    create(&h.app, "bob", "game2").await;
2071 +
    let fork2 = db::repos::get(&h.app.db, "bob", "game2").await.unwrap().unwrap();
2072 +
    db::repos::set_forked_from(&h.app.db, fork2.id, up.id).await.unwrap();
2073 +
    sqlx::query("UPDATE repositories SET flow = 'on' WHERE id = $1").bind(fork2.id).execute(&h.app.db).await.unwrap();
2074 +
    c.config.set("remote.origin.url", &format!("{}/bob/game2", h.base)).unwrap();
2075 +
    c.save_config().unwrap();
2076 +
    assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok));
2077 +
    let jobs2 = db::flow::jobs(&h.app.db, runs_of(&h.app, "bob", "game2").await.remove(0).id).await.unwrap();
2078 +
    let status = |name: &str| jobs2.iter().find(|j| j.name == name).map(|j| (j.status.clone(), j.reused_repo().map(String::from))).unwrap();
2079 +
    assert_eq!(status("lint"), ("queued".to_string(), None), "{jobs2:?}");
2080 +
    assert_eq!(status("build"), ("passed".to_string(), Some("andy/game".to_string())), "{jobs2:?}");
2081 +
    sqlx::query("UPDATE flow_jobs SET receipt_attest = $1 WHERE id = $2")
2082 +
        .bind(&lint.receipt_attest)
2083 +
        .bind(lint.id)
2084 +
        .execute(&h.app.db)
2085 +
        .await
2086 +
        .unwrap();
2041 2087
2042 2088
    // Any repository on the hub holding the same state, not only forks:
2043 2089
    // carol's, which no one forked, starts green from a public pass.
…
5053 unchanged lines