OXYGENHUB

Keyless agents' release tags: log and serve a tag's acceptance and log place b5c199bd6b1a

With oxy's fix (andymccall/oxygen#129) the hub accepts a tag signed by a
key it certified under the policy's subject grant and countersigns when.
The hub now logs that acceptance beside the tag in its transparency log,
and the refs answer carries each tag's log entry, inclusion proof and
cosigned checkpoints as it does a bookmark's, so a later fetch checks
the certificate at the hub's or the witnesses' time. The test pushes a
keyless agent's tag and fetches it from a fresh clone.

Needs the oxygen pin bumped to the fix before it builds.

Refs andymccall/oxygen#129
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 5dd5040d43a5
When
· 2026-10-20 04:54 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
b5c199bd6b1a3c806938e2313ff1e52c63f64b21ed9200305ef1a2b224eeec88
Depends on
13b27cb2200c 42fa95540468 4f40eed0588f 6e006eaf1da4 8aa5ef384ef1 965b950f9b96 9c04c779f032 a7e6e8e6094c b5668108487f e5b97ac399e7
Depended on by
a0a1601eb5a5 ea55fb62f919
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
10 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 +
- **Keyless agents can push release tags (andymccall/oxygen#129):** A tag signed by a key the hub certified (an agent's or a Flow job's ten-minute key) was refused "unknown key", and then refused by the policy. With oxy's fix the hub accepts it under the policy's subject grant and countersigns when it accepted it, as it does a bookmark move. It logs the tag's acceptance in the transparency log beside the tag, and serves both with the tag in every refs answer, so a client fetching the tag months later checks the certificate at the hub's time. Runners read repositories with their own oxy, so they need rebuilding to check such tags; older ones ignore the new fields.
8 +
7 9
## 0.6.0 (2026-10-20)
8 10
9 11
- **An event bus between the hub's processes (#273):** on PostgreSQL, what one process publishes (a push, a check, a run's jobs, a comment, a waiting webhook delivery, a choice in the conflict resolver) reaches every other process over `LISTEN/NOTIFY`, so live pages on any web process see a push made through another, and a separate worker delivers a push's webhooks at once instead of on its once-a-minute pass. Nothing to configure; on SQLite the bus stays in process. Events say "look now" and are delivered at most once; a process that loses its listening connection listens again on a new one and logs that events may have been missed. A live page that reconnects with an event id its process never gave (another web process's, or one from before a restart) is now told to reload instead of waiting for newer ids. In the conflict resolver, choices and landings cross processes; who is present stays per process. See docs/roles.md.
…
252 unchanged lines
modified crates/hub/src/tlog.rs +63 −25
…
458 unchanged lines
459 459
        if let Some(tag) = u.name.strip_prefix("tags/") {
460 460
            if let Ok(Some((r, sa))) = repo.tag_attest(tag) {
461 461
                add(&mut out, "tag", r, sig_keys(&sa.sigs));
462 +
                // A short-lived key's tag, and when the hub accepted it
463 +
                // (oxygen#129).
464 +
                if let Ok(Some(acc)) = repo.acceptance_of(&r) {
465 +
                    add(&mut out, "accepted", acc.attest.hash(), sig_keys(&acc.sigs));
466 +
                }
462 467
            }
463 468
        } else if let Ok(Some((r, sa))) = repo.latest_ref_attest(&u.name) {
464 469
            add(&mut out, "ref", r, sig_keys(&sa.sigs));
…
19 unchanged lines
484 489
}
485 490
486 491
/// Add the log to a refs answer (Oxygen spec §14.8): the latest
487 -
/// checkpoint, and for each bookmark's attestation its entry and inclusion
488 -
/// proof against it. An attestation the hub serves but never logged (one
489 -
/// pushed before the log began) is logged now, and proved on a later fetch.
492 +
/// checkpoint, and for each bookmark's and tag's attestation its entry and
493 +
/// inclusion proof against it. An attestation the hub serves but never
494 +
/// logged (one pushed before the log began) is logged now, and proved on a
495 +
/// later fetch.
490 496
pub fn add_log(app: &Shared, row: Option<i64>, refs: &mut oxy_core::protocol::RefsResponse) -> Vec<Pending> {
491 497
    let mut backfill = Vec::new();
492 498
    let Some(note) = app.tlog.checkpoint() else { return backfill };
…
5 unchanged lines
498 504
    refs.witnesses = app.tlog.witnesses().iter().map(|p| (p.name.clone(), p.identity.encode())).collect();
499 505
    for r in refs.refs.iter_mut() {
500 506
        let Some(a) = &r.attestation else { continue };
501 -
        let object = a.attest.hash();
502 -
        let idx = tokio::task::block_in_place(|| {
503 -
            tokio::runtime::Handle::current().block_on(db::tlog::entry_of(&app.db, &object.to_hex(), "ref"))
504 -
        });
505 -
        match idx {
506 -
            Ok(Some(i)) if (i as u64) < size => {
507 -
                let (Ok(entry), Ok(proof)) = (app.tlog.entry(i as u64), app.tlog.prove_inclusion(size, i as u64)) else { continue };
508 -
                r.logged = Some(oxy_core::protocol::Logged { index: i as u64, entry, proof });
509 -
                // When witnesses first saw it (#240): the first cosigned
510 -
                // checkpoints that hold it, each with the entry's proof.
511 -
                let notes = tokio::task::block_in_place(|| {
512 -
                    tokio::runtime::Handle::current().block_on(db::tlog::cosigned_notes_after(&app.db, i, 2))
513 -
                })
514 -
                .unwrap_or_default();
515 -
                for (at, text) in notes {
516 -
                    if let Ok(proof) = app.tlog.prove_inclusion(at as u64, i as u64) {
517 -
                        r.integration.push(oxy_core::protocol::Integration { note: text, proof });
518 -
                    }
507 +
        if let Some((logged, integration)) = place(app, size, row, "ref", a, &mut backfill) {
508 +
            r.logged = Some(logged);
509 +
            r.integration = integration;
510 +
        }
511 +
    }
512 +
    // Tags too (oxygen#129): a short-lived key's tag is checked at its
513 +
    // integrated time on every later fetch.
514 +
    for t in refs.tags.iter_mut() {
515 +
        let Some(a) = &t.attestation else { continue };
516 +
        if let Some((logged, integration)) = place(app, size, row, "tag", a, &mut backfill) {
517 +
            t.logged = Some(logged);
518 +
            t.integration = integration;
519 +
        }
520 +
    }
521 +
    backfill
522 +
}
523 +
524 +
/// Attestation `a`'s entry (of `kind`) in a log of `size` entries, its
525 +
/// inclusion proof, and the first cosigned checkpoints that hold it; none
526 +
/// when it is not in that checkpoint yet, and queued in `backfill` when it
527 +
/// was never logged.
528 +
fn place(
529 +
    app: &Shared,
530 +
    size: u64,
531 +
    row: Option<i64>,
532 +
    kind: &'static str,
533 +
    a: &oxy_core::protocol::SignedAttest,
534 +
    backfill: &mut Vec<Pending>,
535 +
) -> Option<(oxy_core::protocol::Logged, Vec<oxy_core::protocol::Integration>)> {
536 +
    let object = a.attest.hash();
537 +
    let idx =
538 +
        tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(db::tlog::entry_of(&app.db, &object.to_hex(), kind)));
539 +
    match idx {
540 +
        Ok(Some(i)) if (i as u64) < size => {
541 +
            let (Ok(entry), Ok(proof)) = (app.tlog.entry(i as u64), app.tlog.prove_inclusion(size, i as u64)) else { return None };
542 +
            // When witnesses first saw it (#240): the first cosigned
543 +
            // checkpoints that hold it, each with the entry's proof.
544 +
            let notes =
545 +
                tokio::task::block_in_place(|| tokio::runtime::Handle::current().block_on(db::tlog::cosigned_notes_after(&app.db, i, 2)))
546 +
                    .unwrap_or_default();
547 +
            let mut integration = Vec::new();
548 +
            for (at, text) in notes {
549 +
                if let Ok(proof) = app.tlog.prove_inclusion(at as u64, i as u64) {
550 +
                    integration.push(oxy_core::protocol::Integration { note: text, proof });
519 551
                }
520 552
            }
521 -
            Ok(Some(_)) => {}
522 -
            Ok(None) => backfill.push(Pending { kind: "ref", repo: a.attest.repo, repo_row: row, object, signers: sig_keys(&a.sigs) }),
523 -
            Err(e) => eprintln!("tlog: looking up {}: {e}", &object.to_hex()[..12]),
553 +
            Some((oxy_core::protocol::Logged { index: i as u64, entry, proof }, integration))
554 +
        }
555 +
        Ok(Some(_)) => None,
556 +
        Ok(None) => {
557 +
            backfill.push(Pending { kind, repo: a.attest.repo, repo_row: row, object, signers: sig_keys(&a.sigs) });
558 +
            None
524 559
        }
560 +
        Err(e) => {
561 +
            eprintln!("tlog: looking up {}: {e}", &object.to_hex()[..12]);
562 +
            None
563 +
        }
525 564
    }
526 -
    backfill
527 565
}
528 566
529 567
/// Ask each witness to cosign the latest checkpoint, and serve their
…
268 unchanged lines
modified crates/hub/tests/flow.rs +79 −0
…
5736 unchanged lines
5737 5737
    assert!(refs.refs.iter().find(|r| r.name == "main").unwrap().accepted.is_none());
5738 5738
}
5739 5739
5740 +
/// A keyless agent's release tag (oxygen#129): a key the hub certified
5741 +
/// creates a tag and pushes it under the policy's subject grant; the hub
5742 +
/// accepts it, countersigns when, logs both and serves them with the tag,
5743 +
/// and a fresh clone fetches it at the hub's time rather than the tag's.
5744 +
#[tokio::test(flavor = "multi_thread")]
5745 +
async fn a_tag_by_a_key_the_hub_certified_is_accepted_and_fetched() {
5746 +
    use oxy_core::attest::AttestBody;
5747 +
    use oxy_core::cert::{Certificate, SignedCertificate};
5748 +
    use oxy_core::identity::Scope;
5749 +
    use oxy_core::repo::IdentityScope;
5750 +
    use oxy_core::tlog::Entry;
5751 +
    let h = hub().await;
5752 +
    create(&h.app, "andy", "demo").await;
5753 +
    let url = format!("{}/andy/demo", h.base);
5754 +
    let work = tempfile::tempdir().unwrap();
5755 +
    let mut a = client(&url, &work.path().join("a")).with_home(work.path().join("home-a"));
5756 +
    a.create_identity("Andy", "[email protected]", IdentityScope::User).unwrap();
5757 +
    write(&work.path().join("a"), "a.txt", "a\n");
5758 +
    a.commit("a", &[]).unwrap();
5759 +
    a.policy_init().unwrap();
5760 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
5761 +
    let hub_id = h.app.hub_key.identity.clone();
5762 +
    a.policy_grant_subject("agent:claude@andy", hub_id.clone(), Scope::CHANGES | Scope::REFS, None).unwrap();
5763 +
    assert!(a.push("origin", false).unwrap().iter().any(|r| r.name == "policy" && r.ok));
5764 +
5765 +
    // An agent whose fresh key the hub certified tags what it fetched.
5766 +
    let mut b = Repo::clone(&url, &work.path().join("b")).unwrap().with_home(work.path().join("home-b"));
5767 +
    b.config.set("remote.origin.token", "site").unwrap();
5768 +
    b.save_config().unwrap();
5769 +
    let (_, key) = b.create_identity("claude", "[email protected]", IdentityScope::User).unwrap();
5770 +
    let (_, secret) = oxygenhub::keys::load_secret(&h.app.config.keys_dir()).unwrap();
5771 +
    let now = db::now_ms() as u64;
5772 +
    let cert = SignedCertificate::sign(
5773 +
        Certificate {
5774 +
            key,
5775 +
            issuer: hub_id.hash(),
5776 +
            subject: "agent:claude@andy".into(),
5777 +
            claims: vec![],
5778 +
            scope: Scope::CHANGES | Scope::REFS,
5779 +
            not_before: now - 60_000,
5780 +
            expires: now + 600_000,
5781 +
            grant: None,
5782 +
        },
5783 +
        &hub_id,
5784 +
        &secret,
5785 +
    );
5786 +
    b.store().put_identity(&hub_id).unwrap();
5787 +
    b.store().put_certificate(&cert).unwrap();
5788 +
    let heads = b.bookmark_heads().unwrap();
5789 +
    b.tag_create("v0.2.0", &heads, "OxygenFlow 0.2.0").unwrap();
5790 +
    let r = b.push("origin", false).unwrap();
5791 +
    assert!(r.iter().any(|x| x.name == "tags/v0.2.0" && x.ok), "{r:?}");
5792 +
5793 +
    // The hub serves its acceptance of the tag, signed by its key, and
5794 +
    // logs both.
5795 +
    h.app.tlog.refresh_checkpoint(&hub_id, &secret).unwrap();
5796 +
    let refs = open_remote(&url, &RemoteOptions::default()).unwrap().refs().unwrap();
5797 +
    let tag = refs.tags.iter().find(|t| t.name == "v0.2.0").expect("the tag");
5798 +
    let (attest, acc) = (tag.attestation.clone().unwrap(), tag.accepted.clone().expect("an acceptance"));
5799 +
    assert!(matches!(acc.attest.body, AttestBody::Accepted { attest: r } if r == attest.attest.hash()));
5800 +
    assert_eq!(acc.sigs[0].key, hub_id.hash());
5801 +
    assert!(acc.attest.hlc.wall_ms >= now);
5802 +
    let logged = tag.logged.as_ref().expect("the tag's place in the log");
5803 +
    let entry = Entry::decode(&logged.entry).unwrap();
5804 +
    assert_eq!((entry.kind.as_str(), entry.object), ("tag", attest.attest.hash()));
5805 +
    let logged_acceptance = db::tlog::entry_of(&h.app.db, &acc.attest.hash().to_hex(), "accepted").await.unwrap();
5806 +
    assert!(logged_acceptance.is_some(), "the acceptance is logged too");
5807 +
5808 +
    // A fresh clone takes the tag at the hub's time.
5809 +
    let c = Repo::clone(&url, &work.path().join("c")).unwrap().with_home(work.path().join("home-c"));
5810 +
    assert_eq!(c.tag_heads("v0.2.0").unwrap(), Some(heads));
5811 +
    let report = c.fetch("origin").unwrap();
5812 +
    assert!(report.skipped.is_empty(), "{:?}", report.skipped);
5813 +
    // Andy's checkout too, the tag signed by the agent's key.
5814 +
    a.pull("origin").unwrap();
5815 +
    let t = a.tags().unwrap().into_iter().find(|t| t.name == "v0.2.0").expect("pulled");
5816 +
    assert_eq!(t.signer.as_deref(), Some("claude"));
5817 +
}
5818 +
5740 5819
/// Keyless step 5 (oxygenhub#241): Andy grants an agent with his own key;
5741 5820
/// the agent, holding only the grant token, commits and pushes in Andy's
5742 5821
/// name with fresh keys the hub certifies under the grant. A grant by a
…
198 unchanged lines
modified docs/transparency.md +3 −2
…
128 unchanged lines
129 129
than "revoked at T" (a decision for Andy, with revocation times); a
130 130
failure at the integrated time is flagged, not refused; one trusted
131 131
witness is enough (a k-of-n policy would take the k-th earliest time);
132 -
only bookmark attestations get a time so far (tags, seals and receipts
133 -
next).
132 +
bookmark and tag attestations get a time so far (tags since
133 +
andymccall/oxygen#129, so a keyless agent's release tag is checked at it;
134 +
seals and receipts next).
134 135
135 136
**RFC 3161 timestamps** from a timestamp authority (TSA) are the other way
136 137
to get independent time. They are worth adding later for objects that never
…
59 unchanged lines