OXYGENHUB

Certificates name the grant owner's newest identity (refs andymccall/oxygen#82) 27babfa16580

A keyless certificate's answer carries author_chain: the grant owner's
newest linked identity and its predecessors back to the grant's key, so
a home with author.for authors as the owner's current identity. Pins
OxygenFlow main with oxygen's author.for; the agent test rotates and
follows.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key e003995a1df4
When
· 2026-10-26 10:50 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
27babfa165801e966a746a009b6f5cf8d610b3866e1200163b3ae02b9a8eb8d5
Depends on
026a76d251ce 380996db84e5 42fa95540468 8aa5ef384ef1 a66c05a6a881 a7e6e8e6094c b5668108487f dd0788b756f2
Depended on by
668b2ad7495c 84e3c09656b2 34388e612963 262ec9c51a5c 5a9a6e9a15bc
In bookmarks
api-unknown-fields diffusion-pin-bookmark main proposal-list-fast
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 v0.9.7 v0.9.8 v0.9.9
Edits
9 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +4 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
8 +
- **An agent follows its owner's newest identity (oxygen#82).** A keyless certificate's answer now carries `author_chain`: the grant owner's newest linked identity and its predecessors back to the grant's key, so an agent home with `author.for` authors as the owner's current identity, not one several rotations old.
9 +
- **The profile page's actions are one tidy group (#328).** Edit profile, Projects and Drops sat on two rows, touching each other and the panel below; they are now one evenly spaced block, Edit profile across the top and the two pages side by side.
10 +
7 11
- **Changelog entries no longer block a landing (oxygen#145).** Two proposals that each add a line at the same place in a file marked `merge=union` in `.oxyattributes` (`CHANGELOG.md merge=union`) used to leave the second unlandable where conflicts are refused. When a proposal's only conflicts are such adjacent insertions, landing it now adds the resolution, both sides kept, the earlier change's first, authored by whoever lands it and signed by the hub as their delegate. Any other conflict blocks as before.
8 12
9 13
## 0.9.6 (2026-10-26)
…
344 unchanged lines
modified crates/hub/src/keyless.rs +49 −6
…
53 unchanged lines
54 54
    /// check the grant, and pass it on with its push (oxygen#134).
55 55
    #[serde(skip_serializing_if = "Option::is_none")]
56 56
    pub grantor_identity: Option<String>,
57 +
    /// For an agent's certificate, the grant's owner's newest linked
58 +
    /// identity and its predecessors back to the grant's key (base64,
59 +
    /// newest first, the grant's key's own left out): a home with
60 +
    /// `author.for` authors as the first (oxygen#82). Empty when the
61 +
    /// grant's key is the newest.
62 +
    #[serde(skip_serializing_if = "Vec::is_empty")]
63 +
    pub author_chain: Vec<String>,
57 64
}
58 65
66 +
/// The newest identity linked to `account_id` whose `previous` chain
67 +
/// reaches `grantor`, with the chain down to (not including) it, newest
68 +
/// first (oxygen#82). Empty when none is newer.
69 +
pub async fn author_chain(app: &Shared, account_id: i64, grantor: oxy_core::hash::KeyId) -> Vec<Identity> {
70 +
    let linked = db::accounts::identities_of(&app.db, account_id).await.unwrap_or_default();
71 +
    let ids: std::collections::HashMap<oxy_core::hash::KeyId, Identity> =
72 +
        linked.iter().filter_map(|l| Identity::decode(&l.object).ok()).map(|i| (i.hash(), i)).collect();
73 +
    let mut best: Vec<Identity> = Vec::new();
74 +
    for head in ids.values() {
75 +
        let mut chain = vec![head.clone()];
76 +
        let mut cur = head;
77 +
        let reached = loop {
78 +
            match cur.previous {
79 +
                Some(p) if p == grantor => break true,
80 +
                Some(p) => match ids.get(&p) {
81 +
                    Some(prev) if chain.len() < 64 => {
82 +
                        chain.push(prev.clone());
83 +
                        cur = prev;
84 +
                    }
85 +
                    _ => break false,
86 +
                },
87 +
                None => break false,
88 +
            }
89 +
        };
90 +
        if reached && best.first().is_none_or(|b| head.created > b.created) {
91 +
            best = chain;
92 +
        }
93 +
    }
94 +
    best
95 +
}
96 +
59 97
/// Who a credential says its holder is.
60 98
struct Proven {
61 99
    subject: String,
…
153 unchanged lines
215 253
    };
216 254
    // The grant's signer, as linked to its account (the grant was checked
217 255
    // against it when it was made).
218 -
    let grantor_identity = match &cert.grant {
219 -
        Some(g) => db::accounts::identity_by_key(&app.db, &g.grant.grantor.to_hex())
220 -
            .await
221 -
            .ok()
222 -
            .flatten()
223 -
            .map(|l| oxy_core::dsse::base64(&l.object)),
256 +
    let grantor = match &cert.grant {
257 +
        Some(g) => db::accounts::identity_by_key(&app.db, &g.grant.grantor.to_hex()).await.ok().flatten(),
224 258
        None => None,
259 +
    };
260 +
    let grantor_account = grantor.as_ref().map(|l| l.account_id);
261 +
    let grantor_identity = grantor.map(|l| oxy_core::dsse::base64(&l.object));
262 +
    let author_chain = match (&cert.grant, &grantor_account) {
263 +
        (Some(g), Some(account)) => {
264 +
            author_chain(&app, *account, g.grant.grantor).await.iter().map(|i| oxy_core::dsse::base64(&i.encode())).collect()
265 +
        }
266 +
        _ => Vec::new(),
225 267
    };
226 268
    let signed = SignedCertificate::sign(cert, &app.hub_key.identity, &secret);
227 269
    let detail = format!("{} for {} until {}", signed.cert.key_id().short(12), proven.subject, expires);
…
15 unchanged lines
243 285
        subject: proven.subject,
244 286
        expires_ms: expires,
245 287
        grantor_identity,
288 +
        author_chain,
246 289
    }))
247 290
}
248 291
…
143 unchanged lines
modified crates/hub/tests/flow.rs +25 −0
…
6472 unchanged lines
6473 6473
        fresh_host.authenticate_change(&first).unwrap()
6474 6474
    );
6475 6475
6476 +
    // oxygen#82: Andy rotates. A home with `author.for` follows his newest
6477 +
    // identity, which the hub names with each certificate, and its changes
6478 +
    // still verify by the grant his earlier key made.
6479 +
    let rot = a.rotate_identity().unwrap();
6480 +
    db::accounts::link_identity(&h.app.db, andy.id, &rot.new.to_hex(), "Andy", "[email protected]", &rot.identity.encode()).await.unwrap();
6481 +
    assert!(a.push("origin", false).unwrap().iter().all(|r| r.ok));
6482 +
    let (mut warm, dir) = agent();
6483 +
    warm.set_user_config("author.for", "andy").unwrap();
6484 +
    write(&dir, "warm.txt", "before the hub has said\n");
6485 +
    warm.commit("Warm", &[]).unwrap();
6486 +
    assert!(warm.push("origin", false).unwrap().iter().all(|r| r.ok));
6487 +
    let followed = std::fs::read_to_string(agent_home.join("keys").join("author.for")).unwrap();
6488 +
    assert_eq!(followed.trim(), rot.new.to_hex(), "the hub named Andy's newest identity");
6489 +
    let (follower, dir) = agent();
6490 +
    assert_eq!(follower.config.author_key, Some(rot.new));
6491 +
    write(&dir, "follow.txt", "as Andy's newest identity\n");
6492 +
    let followed = follower.commit("Followed", &[]).unwrap();
6493 +
    assert!(follower.push("origin", false).unwrap().iter().all(|r| r.ok));
6494 +
    assert_eq!(host.store().get_change_meta(&followed).unwrap().author, rot.new);
6495 +
    assert!(
6496 +
        matches!(host.authenticate_change(&followed).unwrap(), SignatureStatus::Valid { sealed: true, .. }),
6497 +
        "{:?}",
6498 +
        host.authenticate_change(&followed).unwrap()
6499 +
    );
6500 +
6476 6501
    // Revoked: the next command gets no certificate.
6477 6502
    let (st, _) = call(&h.app, "DELETE", &format!("/api/v1/agents/grants/{grant_id}"), Some(&pat), None).await;
6478 6503
    assert_eq!(st, StatusCode::NO_CONTENT);
…
408 unchanged lines
replaced deps/oxygenflow +0 −0

No line changes.