OXYGENHUB

Who did the work: via AGENT, rotated identities link themselves, a Flow badge 8933faff2043

A change a delegate signed for its author says so wherever the author
is shown: 'via NAME' with an agent badge in history rows, a via row
under the author on the change page (an arrow, the agent's identity,
the delegation it acted under and its expiry, its key), a count under
the author in Contributors, and a via object in the API's change
summaries. The author stays the human; a revoked or withdrawn
delegation turns the badge red.

A push that brings changes authored by a successor of a linked
identity, where every step kept the key pair (a renewal, a delegation
added or removed), links the successor to the same account and audits
it as identity.rotated: delegating to an agent no longer leaves the
author's changes unattributed until a key is re-uploaded. A rotation
to fresh keys still needs the challenge.

/{owner}/{name}/flow/badge.svg: the latest run on the default bookmark
as a small SVG for a README (passing, failing, running, no runs), for
whoever may read the repository.

Pins oxygenflow main, which pins oxygen main with author.signer, so
the test runs an agent commit end to end: the rotated identity links
itself, the change page has the via row, history rows and the API
carry it, Contributors count it under the author, and the badge
answers. Spec §5.3, §12, §13.2.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-04 12:52 UTC
Signature
✔ signed by Andy McCall
Hash
8933faff204360bab79c77fe01773fae1c7121c4b7dd71c54e4ba0effd8a8e97
Depends on
145306044896 19bb31ff1f5c 380996db84e5 42fa95540468 6ad368c5958e 736f6813543d 760a7dd30d6f 78001759604c 83d5052bf76c 92574625ee9a 93be9ce4653e b43a9ab0498f b64e2537d702 cc19697f8871 d23464c0ac60 de505df5cf8d f28389bd6aab
Depended on by
42c77f6515c2 c26267b3aba6 217814c239f0 7c06f5b28ade c8684d44d549 d9b3b84605a4 c9aacc0e7dc1 92cea629fdf6 36e5a9c15a84 17a3a84b00d1 554ccacfc123 c21ef37dd4a0 206c1fc1b06a d0fbea2eb46e a6c241e8eba0
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.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
31 atoms, 0 tree operations, 14 files

Browse the tree at this change

modified crates/hub-api/src/lib.rs +15 −0
…
79 unchanged lines
80 80
    pub deps: Vec<String>,
81 81
    pub head: bool,
82 82
    pub signature: Signature,
83 +
    /// Signed for the author by a delegate (an agent), when it was.
84 +
    #[serde(default, skip_serializing_if = "Option::is_none")]
85 +
    pub via: Option<Via>,
86 +
}
87 +
88 +
/// A delegate that signed a change for its author (Oxygen spec §14.2).
89 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
90 +
pub struct Via {
91 +
    pub name: String,
92 +
    pub key: String,
93 +
    /// The delegation's scope, `changes` for an agent.
94 +
    pub scope: String,
95 +
    pub expires_ms: Option<u64>,
96 +
    /// The delegation has since been revoked or withdrawn.
97 +
    pub revoked: bool,
83 98
}
84 99
85 100
/// Who wrote a change: the linked account when the signing key is linked,
…
962 unchanged lines
modified crates/hub/src/api/mod.rs +7 −0
…
355 unchanged lines
356 356
        when_ms: c.when_ms,
357 357
        deps: c.deps.clone(),
358 358
        head: c.head,
359 +
        via: c.via.as_ref().map(|v| Via {
360 +
            name: v.name.clone(),
361 +
            key: v.key.clone(),
362 +
            scope: v.scope.clone(),
363 +
            expires_ms: v.expires_ms,
364 +
            revoked: v.revoked,
365 +
        }),
359 366
        signature: match &c.signature {
360 367
            view::SigView::Unsigned => Signature::Unsigned,
361 368
            view::SigView::Valid { key, sealed, revoked, .. } => Signature::Valid { key: key.clone(), sealed: *sealed, revoked: *revoked },
…
1761 unchanged lines
modified crates/hub/src/protocol.rs +39 −0
…
208 unchanged lines
209 209
                .unwrap_or_default()
210 210
        };
211 211
        let messages: Vec<(String, String)> = if bookmarks.contains(&row.default_bookmark) { imported.clone() } else { vec![] };
212 +
        // Authors whose identity is a successor (a rotation, a renewal, a
213 +
        // delegation) of an earlier one: each step kept the key pair, so
214 +
        // if the chain reaches a linked key it is the same person, and the
215 +
        // successor is linked too, without re-uploading it (hub spec §5.3).
216 +
        let successors: Vec<(String, String, String, Vec<u8>, String)> = self
217 +
            .repos
218 +
            .open(&parsed)
219 +
            .map(|r| {
220 +
                let mut seen = std::collections::HashSet::new();
221 +
                let mut out = Vec::new();
222 +
                for h in &report.imported {
223 +
                    let Ok(meta) = r.store().get_change_meta(h) else { continue };
224 +
                    let mut key = meta.author;
225 +
                    while !key.is_zero() && seen.insert(key) {
226 +
                        let Ok(id) = r.store().get_identity(&key) else { break };
227 +
                        let Some(prev) = id.previous else { break };
228 +
                        let Ok(before) = r.store().get_identity(&prev) else { break };
229 +
                        if before.ed25519_pk != id.ed25519_pk || before.mldsa65_pk != id.mldsa65_pk {
230 +
                            break;
231 +
                        }
232 +
                        out.push((key.to_hex(), id.name.clone(), id.email.clone(), id.encode(), prev.to_hex()));
233 +
                        key = prev;
234 +
                    }
235 +
                }
236 +
                out
237 +
            })
238 +
            .unwrap_or_default();
212 239
        let on = if bookmarks.contains(&row.default_bookmark) {
213 240
            row.default_bookmark.clone()
214 241
        } else {
…
12 unchanged lines
227 254
            let who = pusher.as_ref().map(|(id, name)| (*id, name.as_str()));
228 255
            if let Err(e) = db::repos::record_push(&pool, row.id, who, &results).await {
229 256
                tracing::warn!("recording push to {}: {e}", parsed);
257 +
            }
258 +
            // Oldest first: a link made for one step lets the next step link.
259 +
            for (key, name, email, object, prev) in successors.iter().rev() {
260 +
                let Ok(None) = db::accounts::identity_by_key(&pool, key).await else { continue };
261 +
                let Ok(Some(earlier)) = db::accounts::identity_by_key(&pool, prev).await else { continue };
262 +
                match db::accounts::link_identity(&pool, earlier.account_id, key, name, email, object).await {
263 +
                    Ok(()) => {
264 +
                        tracing::info!("linked identity {} to account {} as a successor of {}", &key[..12], earlier.account_id, &prev[..12]);
265 +
                        db::accounts::audit(&pool, None, "identity.rotated", key, &format!("successor of {}", &prev[..12])).await.ok();
266 +
                    }
267 +
                    Err(e) => tracing::warn!("linking successor identity {}: {e}", &key[..12]),
268 +
                }
230 269
            }
231 270
            let app = app_hint.lock().unwrap().as_ref().and_then(|w| w.upgrade());
232 271
            if let Some(app) = app {
…
49 unchanged lines
modified crates/hub/src/render/graph.rs +1 −0
…
96 unchanged lines
97 97
            head: false,
98 98
            resolution: false,
99 99
            signature: SigView::Unsigned,
100 +
            via: None,
100 101
            labels: vec![],
101 102
        }
102 103
    }
…
17 unchanged lines
modified crates/hub/src/repos/view.rs +101 −4
…
211 unchanged lines
212 212
    }
213 213
}
214 214
215 +
/// Who signed for the author when it was not the author: a delegate, such
216 +
/// as an agent, named by its identity, with the delegation that let it
217 +
/// (Oxygen's agent workflows doc §6).
218 +
#[derive(Clone, Debug, PartialEq, Eq)]
219 +
pub struct ViaView {
220 +
    pub name: String,
221 +
    pub email: String,
222 +
    pub key: String,
223 +
    pub short: String,
224 +
    /// The delegation's scope as written, `changes` for an agent.
225 +
    pub scope: String,
226 +
    pub expires_ms: Option<u64>,
227 +
    /// The signing key was revoked, or the delegation is no longer in the
228 +
    /// author's identity: the change deserves a second look.
229 +
    pub revoked: bool,
230 +
}
231 +
232 +
impl ViaView {
233 +
    /// The delegation, as the change page's `via` row says it.
234 +
    pub fn delegation(&self) -> String {
235 +
        if self.revoked {
236 +
            return "delegation revoked".into();
237 +
        }
238 +
        match self.expires_ms {
239 +
            Some(e) => format!("delegate for {}, until {}", self.scope, when_utc(e)),
240 +
            None => format!("delegate for {}", self.scope),
241 +
        }
242 +
    }
243 +
244 +
    /// What hovering the badge says.
245 +
    pub fn title(&self) -> String {
246 +
        let until = match self.expires_ms {
247 +
            Some(e) => format!(", until {}", when_utc(e)),
248 +
            None => String::new(),
249 +
        };
250 +
        if self.revoked {
251 +
            format!("signed by {} as a delegate of the author; that delegation has since been revoked", self.name)
252 +
        } else {
253 +
            format!("signed by {} as a delegate of the author ({}{until}), key {}", self.name, self.scope, self.short)
254 +
        }
255 +
    }
256 +
}
257 +
258 +
/// `2026-10-04 20:12Z` for a wall time in milliseconds.
259 +
fn when_utc(ms: u64) -> String {
260 +
    time::OffsetDateTime::from_unix_timestamp_nanos(ms as i128 * 1_000_000)
261 +
        .ok()
262 +
        .and_then(|t| t.format(&time::macros::format_description!("[year]-[month]-[day] [hour]:[minute]Z")).ok())
263 +
        .unwrap_or_else(|| ms.to_string())
264 +
}
265 +
266 +
/// The delegate that signed `hash` for the author, when one did.
267 +
pub fn via(repo: &Repo, author: &KeyId, sig: &SigView) -> Option<ViaView> {
268 +
    let SigView::Valid { key, name, revoked, .. } = sig else { return None };
269 +
    if author.is_zero() || *key == author.to_hex() {
270 +
        return None;
271 +
    }
272 +
    let signer = KeyId::from_hex(key)?;
273 +
    let entry = repo.store().get_identity(author).ok().and_then(|a| a.delegate(&signer).copied());
274 +
    let email = repo.store().get_identity(&signer).map(|i| i.email).unwrap_or_default();
275 +
    Some(ViaView {
276 +
        name: name.clone(),
277 +
        email,
278 +
        key: key.clone(),
279 +
        short: signer.short(12),
280 +
        scope: entry.map(|d| d.scope.to_string()).unwrap_or_else(|| "changes".into()),
281 +
        expires_ms: entry.and_then(|d| d.expires),
282 +
        revoked: *revoked || entry.is_none(),
283 +
    })
284 +
}
285 +
215 286
/// One change as history rows and change pages show it.
216 287
#[derive(Clone, Debug)]
217 288
pub struct ChangeView {
…
7 unchanged lines
225 296
    pub head: bool,
226 297
    pub resolution: bool,
227 298
    pub signature: SigView,
299 +
    /// Signed for the author by a delegate (an agent), when it was.
300 +
    pub via: Option<ViaView>,
228 301
    /// Bookmarks and tags whose state has this change as a head.
229 302
    pub labels: Vec<Label>,
230 303
}
…
45 unchanged lines
276 349
    labels: &HashMap<ChangeHash, Vec<Label>>,
277 350
) -> ChangeView {
278 351
    let (title, body) = split_message(&meta.message);
352 +
    let signature = signature(repo, hash);
353 +
    let via = via(repo, &meta.author, &signature);
279 354
    ChangeView {
280 355
        hash: hash.to_hex(),
281 356
        short: hash.short(12),
…
4 unchanged lines
286 361
        deps: meta.deps.iter().map(|d| d.to_hex()).collect(),
287 362
        head,
288 363
        resolution: meta.kind == oxy_core::objects::ChangeKind::Resolution,
289 -
        signature: signature(repo, hash),
364 +
        signature,
365 +
        via,
290 366
        labels: labels.get(hash).cloned().unwrap_or_default(),
291 367
    }
292 368
}
…
547 unchanged lines
840 916
    pub changes: usize,
841 917
    /// The hub account the key is linked to, filled by the web layer.
842 918
    pub account: Option<String>,
919 +
    /// Of `changes`, how many a delegate signed for this author, by the
920 +
    /// delegate's name (an agent's work stays the author's, and is shown).
921 +
    pub via: Vec<(String, usize)>,
843 922
}
844 923
845 924
/// One language's share of a state's code, by bytes.
…
5 unchanged lines
851 930
    pub colour: String,
852 931
}
853 932
854 -
/// Authors of the changes of a state, most changes first.
933 +
/// Authors of the changes of a state, most changes first. A change a
934 +
/// delegate signed counts for its author, and the delegate is named under
935 +
/// them: the signing key is read, not verified (the change rows do that).
855 936
pub fn contributors(repo: &Repo, state: &oxy_core::state::State) -> Vec<Contributor> {
856 937
    let mut by: HashMap<String, Contributor> = HashMap::new();
857 -
    for (_, meta) in state.changes() {
938 +
    let mut names: HashMap<KeyId, String> = HashMap::new();
939 +
    for (hash, meta) in state.changes() {
858 940
        let a = author(repo, &meta.author, &meta.message);
859 941
        let id = if a.key.is_empty() { format!("git:{}", a.email.to_lowercase()) } else { a.key.clone() };
860 942
        // Unsigned changes with no Git author carry no name at all.
861 943
        let name =
862 944
            if a.key.is_empty() && a.email.is_empty() && a.name == "unknown" { "Unattributed changes".to_string() } else { a.name.clone() };
863 -
        by.entry(id).or_insert_with(|| Contributor { name, key: a.key.clone(), changes: 0, account: None }).changes += 1;
945 +
        let entry = by.entry(id).or_insert_with(|| Contributor { name, key: a.key.clone(), changes: 0, account: None, via: Vec::new() });
946 +
        entry.changes += 1;
947 +
        if !meta.author.is_zero() {
948 +
            let signer = repo.store().signatures(hash).ok().and_then(|s| s.first().map(|s| s.key)).filter(|k| *k != meta.author);
949 +
            if let Some(k) = signer {
950 +
                let who =
951 +
                    names.entry(k).or_insert_with(|| repo.store().get_identity(&k).map(|i| i.name).unwrap_or_else(|_| k.short(12))).clone();
952 +
                match entry.via.iter_mut().find(|(n, _)| *n == who) {
953 +
                    Some((_, n)) => *n += 1,
954 +
                    None => entry.via.push((who, 1)),
955 +
                }
956 +
            }
957 +
        }
864 958
    }
865 959
    let mut out: Vec<Contributor> = by.into_values().collect();
960 +
    for c in &mut out {
961 +
        c.via.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
962 +
    }
866 963
    out.sort_by(|a, b| b.changes.cmp(&a.changes).then(a.name.cmp(&b.name)));
867 964
    out
868 965
}
…
529 unchanged lines
modified crates/hub/src/web/flow.rs +35 −0
…
684 unchanged lines
685 685
        .map_err(|m| Error::bad(shell.clone(), m))?;
686 686
    Ok(Redirect::to(&format!("/{owner}/{name}/releases/tag/{}", rel.tag)).into_response())
687 687
}
688 +
689 +
/// `/{owner}/{name}/flow/badge.svg`: the latest run on the default
690 +
/// bookmark as a small SVG for a README, for whoever may read the
691 +
/// repository. `passed` green, `failed` or `error` red, a run still going
692 +
/// blue, none grey.
693 +
pub async fn badge(
694 +
    State(app): State<Shared>,
695 +
    viewer: Viewer,
696 +
    Path((owner, name)): Path<(String, String)>,
697 +
) -> Result<axum::response::Response, Error> {
698 +
    use axum::response::IntoResponse;
699 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/flow"), &viewer);
700 +
    let (_repo, row, _role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
701 +
    let latest = fdb::latest_on(&app.db, row.id, &row.default_bookmark).await.map_err(|e| Error::from_db(shell.clone(), e))?;
702 +
    let (text, colour) = match latest.as_ref().map(|r| r.status.as_str()) {
703 +
        Some("passed") => ("passing", "#2da44e"),
704 +
        Some("failed") | Some("error") => ("failing", "#cf222e"),
705 +
        Some("cancelled") => ("cancelled", "#6e7781"),
706 +
        Some(_) => ("running", "#0969da"),
707 +
        None => ("no runs", "#6e7781"),
708 +
    };
709 +
    let label = "OxygenFlow";
710 +
    let (lw, tw) = (label.len() as u32 * 7 + 10, text.len() as u32 * 7 + 10);
711 +
    let svg = format!(
712 +
        r##"<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="20" role="img" aria-label="{label}: {text}"><title>{label}: {text}</title><rect width="{lw}" height="20" rx="3" fill="#555"/><rect x="{lw}" width="{tw}" height="20" rx="3" fill="{colour}"/><rect x="{lw}" width="4" height="20" fill="{colour}"/><g fill="#fff" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle"><text x="{lx}" y="14">{label}</text><text x="{tx}" y="14">{text}</text></g></svg>"##,
713 +
        w = lw + tw,
714 +
        lx = lw / 2,
715 +
        tx = lw + tw / 2,
716 +
    );
717 +
    Ok((
718 +
        [(axum::http::header::CONTENT_TYPE, "image/svg+xml; charset=utf-8"), (axum::http::header::CACHE_CONTROL, "no-cache, max-age=60")],
719 +
        svg,
720 +
    )
721 +
        .into_response())
722 +
}
modified crates/hub/src/web/mod.rs +1 −0
…
169 unchanged lines
170 170
        .route("/{owner}/{name}/resolve-live/{bookmark}/{*path}", get(resolve::live_events).post(resolve::live_choose))
171 171
        .route("/{owner}/{name}/newfile/{r}", get(edit::new_form))
172 172
        .route("/{owner}/{name}/flow", get(flow::runs))
173 +
        .route("/{owner}/{name}/flow/badge.svg", get(flow::badge))
173 174
        .route("/{owner}/{name}/flow/{id}", get(flow::run))
174 175
        .route("/{owner}/{name}/flow/{id}/live", get(flow::live))
175 176
        .route("/{owner}/{name}/flow/start", post(flow::start))
…
448 unchanged lines
modified crates/hub/src/web/proposals.rs +7 −0
…
1258 unchanged lines
1259 1259
                when_ms: c.when_ms,
1260 1260
                deps: c.deps.clone(),
1261 1261
                head: c.head,
1262 +
                via: c.via.as_ref().map(|v| hub_api::Via {
1263 +
                    name: v.name.clone(),
1264 +
                    key: v.key.clone(),
1265 +
                    scope: v.scope.clone(),
1266 +
                    expires_ms: v.expires_ms,
1267 +
                    revoked: v.revoked,
1268 +
                }),
1262 1269
                signature: match &c.signature {
1263 1270
                    view::SigView::Unsigned => hub_api::Signature::Unsigned,
1264 1271
                    view::SigView::Valid { key, sealed, revoked, .. } => {
…
175 unchanged lines
modified crates/hub/tests/edits.rs +94 −0
…
161 unchanged lines
162 162
    let key2 = client.config.author_key.unwrap();
163 163
    let id2 = client.identity(&key2).unwrap();
164 164
    assert!(id2.delegate(&app.hub_key.key).is_some());
165 +
    // The successor was pushed with a change it authored? Not yet: link it
166 +
    // by hand here, as before; the auto-link is checked further down.
165 167
    db::accounts::link_identity(&app.db, andy_id, &key2.to_hex(), &id2.name, &id2.email, &id2.encode()).await.unwrap();
166 168
167 169
    // An edit lands on main, authored by Andy, signed by the hub as his delegate.
…
308 unchanged lines
476 478
        client.log().unwrap().into_iter().filter(|e| !e.is_working_copy).map(|e| client.authenticate_change(&e.hash).unwrap()).collect();
477 479
    assert!(v.iter().all(|s| matches!(s, oxy_core::repo::SignatureStatus::Valid { .. })), "{v:?}");
478 480
}
481 +
482 +
/// A rotation reaches the hub with the next push: an author whose identity
483 +
/// is a successor of a linked one, with the same key pair, is linked
484 +
/// without another challenge. An agent's change shows who signed for the
485 +
/// author, on the change page, in history rows and over the API.
486 +
#[tokio::test(flavor = "multi_thread")]
487 +
async fn a_rotated_identity_links_itself_and_an_agent_s_change_says_via() {
488 +
    hermetic();
489 +
    let dir = tempfile::tempdir().unwrap();
490 +
    let mut config = Config::load(dir.path()).unwrap();
491 +
    config.auth.push_tokens = vec!["site".into()];
492 +
    config.registration.mode = "open".into();
493 +
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
494 +
    let port = listener.local_addr().unwrap().port();
495 +
    config.server.base_url = format!("http://127.0.0.1:{port}");
496 +
    let app = oxygenhub::build(config).await.unwrap();
497 +
    let router = oxygenhub::router(app.clone());
498 +
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
499 +
    let base = format!("http://127.0.0.1:{port}");
500 +
    let app = &app;
501 +
    let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await;
502 +
    assert!(r.cookie.is_some());
503 +
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
504 +
    let repo = RepoName::parse("andy/demo").unwrap();
505 +
    app.repos.create(&repo, None).unwrap();
506 +
    db::repos::create(&app.db, "andy", "demo", "", hub_api::Visibility::Public).await.unwrap();
507 +
    let work = tempfile::tempdir().unwrap();
508 +
    let home = tempfile::tempdir().unwrap();
509 +
    let mut client = Repo::init(work.path()).unwrap().with_home(home.path());
510 +
    client.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
511 +
    client.config.set("core.sign", "change").unwrap();
512 +
    client.config.set("remote.origin.url", &format!("{base}/andy/demo")).unwrap();
513 +
    client.config.set("remote.origin.token", "site").unwrap();
514 +
    client.save_config().unwrap();
515 +
    write(work.path(), "a.txt", b"one\n");
516 +
    client.snapshot("t").unwrap();
517 +
    client.commit("first", &[]).unwrap();
518 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
519 +
    let key1 = client.config.author_key.unwrap();
520 +
    let id1 = client.identity(&key1).unwrap();
521 +
    db::accounts::link_identity(&app.db, andy_id, &key1.to_hex(), &id1.name, &id1.email, &id1.encode()).await.unwrap();
522 +
523 +
    // An agent with its own key; Andy delegates (a rotation) and commits.
524 +
    let agent_home = tempfile::tempdir().unwrap();
525 +
    let mut agent_repo = Repo::init(&work.path().join("agent-scratch")).unwrap().with_home(agent_home.path());
526 +
    let (agent_key, agent_id) = agent_repo.create_identity("Antigravity", "[email protected]", IdentityScope::User).unwrap();
527 +
    client.delegate_add(agent_id, oxy_core::identity::Scope::CHANGES, None).unwrap();
528 +
    let key2 = client.config.author_key.unwrap();
529 +
    assert_ne!(key1, key2);
530 +
    write(work.path(), "a.txt", b"one\ntwo\n");
531 +
    client.snapshot("t").unwrap();
532 +
    let by_andy = client.commit("second, by Andy", &[]).unwrap();
533 +
    // The agent commits in Andy's checkout: Andy as author, its own signature.
534 +
    let mut as_agent = Repo::open(work.path()).unwrap().with_home(agent_home.path());
535 +
    as_agent.set_user_config("author.signer", &agent_key.to_hex()).unwrap();
536 +
    as_agent.set_user_config("core.sign", "change").unwrap();
537 +
    write(work.path(), "b.txt", b"by the agent\n");
538 +
    as_agent.snapshot("t").unwrap();
539 +
    let by_agent = as_agent.commit("third, by the agent", &[]).unwrap();
540 +
    // Andy pushes both.
541 +
    assert!(client.push("origin", false).unwrap().iter().all(|r| r.ok));
542 +
    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
543 +
544 +
    // The successor is linked to Andy's account by the push.
545 +
    let linked = db::accounts::identity_by_key(&app.db, &key2.to_hex()).await.unwrap();
546 +
    assert_eq!(linked.map(|l| l.account_id), Some(andy_id), "the rotated identity links itself");
547 +
    assert!(db::accounts::identity_by_key(&app.db, &agent_key.to_hex()).await.unwrap().is_none(), "an agent's key is nobody's account");
548 +
549 +
    // The change page: Andy as author, a via row naming the agent.
550 +
    let page = send(app, "GET", &format!("/andy/demo/change/{}", by_agent.to_hex()), None, None).await.body;
551 +
    assert!(page.contains("<dt>via</dt>"), "{page}");
552 +
    assert!(page.contains("Antigravity") && page.contains("delegate for changes"), "{page}");
553 +
    assert!(page.contains("href=\"/andy\""), "the author is Andy's account: {page}");
554 +
    let andy_page = send(app, "GET", &format!("/andy/demo/change/{}", by_andy.to_hex()), None, None).await.body;
555 +
    assert!(!andy_page.contains("<dt>via</dt>"), "{andy_page}");
556 +
    // History rows and the API say so too.
557 +
    let rows = send(app, "GET", "/andy/demo/changes/main", None, None).await.body;
558 +
    assert!(rows.contains("via Antigravity"), "{rows}");
559 +
    let hist = send(app, "GET", "/api/v1/repos/andy/demo/history/main", None, None).await.body;
560 +
    let v: serde_json::Value = serde_json::from_str(&hist).unwrap();
561 +
    let agent_change = v["changes"].as_array().unwrap().iter().find(|c| c["hash"] == by_agent.to_hex()).unwrap();
562 +
    assert_eq!(agent_change["via"]["name"], "Antigravity");
563 +
    assert_eq!(agent_change["via"]["scope"], "changes");
564 +
    assert_eq!(agent_change["author"]["account"], "andy");
565 +
    // Contributors: the agent's change counts for Andy, named under him.
566 +
    let overview = send(app, "GET", "/andy/demo", None, None).await.body;
567 +
    assert!(overview.contains("via Antigravity 1"), "{overview}");
568 +
    // The badge: no runs yet.
569 +
    let badge = send(app, "GET", "/andy/demo/flow/badge.svg", None, None).await;
570 +
    assert_eq!(badge.status, StatusCode::OK);
571 +
    assert!(badge.body.contains("<svg") && badge.body.contains("no runs"), "{}", badge.body);
572 +
}
replaced deps/oxygenflow +0 −0

No line changes.

modified docs/spec.md +4 −2
…
179 unchanged lines
180 180
181 181
An Oxygen identity is a signed object holding an Ed25519 + ML-DSA-65 key pair's public halves (spec §14.2). An account links an identity by uploading the identity object (`oxy identity export`) and proving possession: the hub issues a 32-byte challenge and the client signs it with `oxy identity sign-challenge` (§18); the hub verifies both signatures. A linked key is what makes a pushed change *this account's* change: author attribution in every page comes from the change's signing key, not from who held the token that pushed it. A change signed by an unlinked key shows its identity's name (or KeyId when the identity is unknown) with a "key not linked" tag rather than a user name.
182 182
183 -
An identity can be linked to at most one account. Rotation (`oxy identity rotate`) produces a new identity whose object names the old key; the hub accepts the rotated identity for the same account without a new challenge when the rotation is signed by the old key.
183 +
An identity can be linked to at most one account. Rotation (`oxy identity rotate`, `renew`, `delegate add|remove`) produces a successor identity whose object names the old key. When a push brings changes authored by a successor of a linked identity, and every step of the chain kept the key pair, the hub links the successor to the same account itself and records `identity.rotated` in the audit log: delegating to an agent never breaks attribution and needs no re-upload. A rotation to a fresh key pair (`oxy identity rotate`) still needs the new identity linked with a challenge.
184 184
185 185
### 5.4 Authentication for the wire protocol
186 186
…
254 unchanged lines
441 441
442 442
### 12.1 API
443 443
444 -
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
444 +
JSON under `/api/v1`, bearer tokens, `application/json`, RFC 7807 problem responses (an unknown route too), cursor pagination (`?after=`), ETags on cacheable resources, and rate limits per token. Resources mirror the pages: `/repos/{owner}/{name}`, `/bookmarks`, `/changes/{hash}`, `/tree/{ref}/{path}`, `/file/{ref}/{path}`, `/blame`, `/history/{ref}` (`?path=`, `?offset=&limit=`, or `?r=REVSET` for the changes a revset selects, evaluated under a two-second budget and answering 400 when it does not parse or goes past it; Oxygen's docs/revset_query_language.md), `/compare`, `/conflicts`, `/proposals` (`?state=open|draft|landed|closed|all`, open ones and drafts by default; a source bookmark may hold `/`, and `owner/name:bookmark` naming the repository itself is its own bookmark), `/proposals/{n}/comments|reviews|checks|files`, `PATCH /proposals/{n}` (the title and description, for its author or a maintainer, and `labels`, the whole set by name, for triage; what is not given stays), `POST /proposals/{n}/land` (repo:write: what the Land button does, `409` with the blockers when it cannot; a landed proposal keeps the changes it landed and reports `landed` with who, how (`web`, or `api (token NAME)`) and when, which its event, the audit log and the push record also say, adding when nobody approved), `/proposals/{n}/content/{base|head}/{path}` (a changed file's bytes in the target's state or the projected state, for editor diff views), `/issues` (creating an issue can set its labels, assignees and milestone; `PATCH /issues/{n}` edits the title and body for the author or triage, and replaces labels, assignees or the milestone for triage, recording each on the timeline; unknown names are refused before anything changes), `/labels` (`POST` creates or changes one, `DELETE /labels/{name}` removes it; triage), `/milestones` (`POST` creates one or changes the description and due date of the one with that title, `PATCH /milestones/{title}` changes title, description, due date or state; triage), `/releases` (`PATCH /releases/{tag}` changes the title, notes or prerelease flag, repo:write), `/tags`, `/trust`, `/pushes`, `/hooks`, `/checks`, `/user`, `/notifications` (the caller's inbox and unread count; `/notifications/{id}/read` and `/notifications/read` mark read; scope `user:read`), `/users/{name}`, `/orgs/{name}`, `/checks/{state}` (the checks on a state, named by its hash, by its heads joined with `+`, by the one head of a single-head state, or by a bookmark), `/checks/required` (`GET`, and `PUT` of the whole list by a repository admin: the checks a proposal needs, passed, to land), `/projects/{owner}` (list, create) and `/projects/{owner}/{n}` (the board as the caller may see it, `PATCH` for title, description, privacy and closing, `/cards` to add a reference or note to a column named by id or name, `/cards/{id}` to move or remove one; `repo:write` and the page's rights to change), `/flow/runs` (`GET`: runs newest first, `?before=N&limit=`; `POST {"bookmark"}` or `{"change"}`, repo:write: run a bookmark's current state, the default bookmark when none is named, or one change's state), `/flow/runs/{n}` (a run with its jobs), `/flow/runs/{n}/jobs/{job}/log` (`?after=seq&limit=`) and `/flow/runs/{n}/rerun` (repo:write: a finished run again), both answering `201 {"number", "url"}` or `409` with why nothing started, `/flow/runs/{n}/cancel` (repo:write: a run still going ends as `cancelled`, its running jobs stopped when their runner next reports; `409` when it had already ended; a push to a bookmark cancels the push runs of it still going), `/search`. A change's author carries the account its key is linked to, or `null` and a `note` saying the key is not linked and where its owner links it; `via` names the delegate (an agent) that signed for the author when one did, with the delegation's scope and expiry and whether it has since been revoked, and the pages show the same as an `agent` badge, a `via` row under the author on the change page, and a count under the author in Contributors (the change page shows a "key not linked" tag, with a link to Settings → Keys for signed-in viewers). Shapes reuse `oxy --json` shapes wherever one exists (change, status, blame, diff) so tools written against the CLI work against the hub. `crates/hub-api` holds the types with `serde` derives and is what the runner and the VS Code extension will link.
445 445
446 446
### 12.2 Webhooks
447 447
…
57 unchanged lines
505 505
- **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.
506 506
- **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.
507 507
508 +
`/{owner}/{name}/flow/badge.svg` is a small SVG of the latest run on the default bookmark (`passing`, `failing`, `running`, `no runs`) for a README, for whoever may read the repository. It is a convenience, with the weakness of every badge; the stamps below are the real thing.
509 +
508 510
**State stamps** replace CI badges, which are mutable images claiming that a branch passes, name no state and cannot be checked. A stamp belongs to a state:
509 511
510 512
- `/{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.
…
173 unchanged lines
modified templates/change.html +2 −1
…
8 unchanged lines
9 9
  <div class="head"><h1>{{ c.view.title }} <small class="px">{{ c.view.short }}</small>{% if c.view.resolution %} <span class="tag" title="Resolves conflicts; depends on every change that wrote a side">resolution</span>{% endif %}</h1></div>
10 10
  {{ body_html|safe }}
11 11
  <dl class="facts">
12 -
    <dt>Author</dt><dd>{% if let Some(a) = c.view.author.account %}<a href="/{{ a }}">{{ c.view.author.name }}</a> <span class="tag">@{{ a }}</span>{% else %}{{ c.view.author.name }}{% endif %}{% if !c.view.author.email.is_empty() %} <span class="mute">&lt;{{ c.view.author.email }}&gt;</span>{% endif %}{% if !c.view.author.key.is_empty() && c.view.author.account.is_none() %} <span class="tag" title="The key that signed this change is not linked to any account.">key not linked</span>{% if shell.user.is_some() %} <a class="small" href="/settings/keys">link your key</a>{% endif %}{% endif %}</dd>
12 +
    <dt>Author</dt><dd>{% if let Some(a) = c.view.author.account %}<a href="/{{ a }}">{{ c.view.author.name }}</a> <span class="tag">@{{ a }}</span>{% else %}{{ c.view.author.name }}{% endif %}{% if !c.view.author.email.is_empty() %} <span class="mute">&lt;{{ c.view.author.email }}&gt;</span>{% endif %}{% if !c.view.author.key.is_empty() && c.view.author.account.is_none() %} <span class="tag" title="The key that signed this change is not linked to any account.">key not linked</span>{% if shell.user.is_some() %} <a class="small" href="/settings/keys">link your key</a>{% endif %}{% endif %}</dd>{% if let Some(v) = c.view.via %}
13 +
    <dt>via</dt><dd><span class="mute" aria-hidden="true">↳</span> <span class="tag{% if v.revoked %} bad{% endif %}" title="{{ v.title() }}">agent</span> {{ v.name }}{% if !v.email.is_empty() %} <span class="mute">&lt;{{ v.email }}&gt;</span>{% endif %} <span class="mute small">· {{ v.delegation() }} · key {{ v.short }}</span></dd>{% endif %}
13 14
    <dt>When</dt><dd><time title="{{ c.view.when_ms|when }}">{{ c.view.when_ms|ago }}</time> · {{ c.view.when_ms|when }}</dd>
14 15
    <dt>Signature</dt><dd>{% call m::sig(c.view.signature) %} {{ c.view.signature.title() }}</dd>
15 16
    <dt>Hash</dt><dd><code class="full">{{ c.view.hash }}</code> <button class="small" data-copy="{{ c.view.hash }}">copy</button></dd>
…
12 unchanged lines
modified templates/overview.html +1 −1
…
47 unchanged lines
48 48
    {% if !o.contributors.is_empty() %}
49 49
    <h3>Contributors <span class="count-chip">{{ o.contributors.len() }}</span></h3>
50 50
    <ul class="plain contributors">{% for c in o.contributors.iter().take(10) %}
51 -
      <li>{% if let Some(u) = c.account %}<a href="/{{ u }}"><img class="avatar sm" src="/avatar/{{ u }}" alt="" width="20" height="20"> {{ u }}</a> <span class="mute small">{{ c.name }}</span>{% else %}<span class="avatar-none" aria-hidden="true">○</span> {{ c.name }}{% endif %} <span class="mute small" title="changes">· {{ c.changes }}</span></li>
51 +
      <li>{% if let Some(u) = c.account %}<a href="/{{ u }}"><img class="avatar sm" src="/avatar/{{ u }}" alt="" width="20" height="20"> {{ u }}</a> <span class="mute small">{{ c.name }}</span>{% else %}<span class="avatar-none" aria-hidden="true">○</span> {{ c.name }}{% endif %} <span class="mute small" title="changes">· {{ c.changes }}</span>{% if !c.via.is_empty() %}<br><span class="mute small">via {% for v in c.via %}{{ v.0 }} {{ v.1 }}{% if !loop.last %}, {% endif %}{% endfor %}</span>{% endif %}</li>
52 52
    {% endfor %}</ul>
53 53
    {% if o.contributors.len() > 10 %}<p class="small mute">and {{ o.contributors.len() - 10 }} more</p>{% endif %}
54 54
    {% endif %}
…
20 unchanged lines
modified templates/repo_tabs.html +1 −1
…
28 unchanged lines
29 29
    {% call labels(c) %}
30 30
    {% if c.resolution %}<span class="tag">resolution</span>{% endif %}
31 31
    {% if c.head %}<span class="tag head">head</span>{% endif %}
32 -
    <div class="meta"><span class="who">{% if let Some(a) = c.author.account %}<a href="/{{ a }}">{{ c.author.name }}</a>{% else %}{{ c.author.name }}{% endif %}</span> · <time title="{{ c.when_ms|when }}">{{ c.when_ms|ago }}</time></div>
32 +
    <div class="meta"><span class="who">{% if let Some(a) = c.author.account %}<a href="/{{ a }}">{{ c.author.name }}</a>{% else %}{{ c.author.name }}{% endif %}{% if let Some(v) = c.via %} <span class="mute">via {{ v.name }}</span> <span class="tag{% if v.revoked %} bad{% endif %}" title="{{ v.title() }}">agent</span>{% endif %}</span> · <time title="{{ c.when_ms|when }}">{{ c.when_ms|ago }}</time></div>
33 33
  </div>
34 34
  <div class="chg-side">
35 35
    {% call sig(c.signature) %}
…
39 unchanged lines