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.