OXYGENHUB

Host API: changes from an edit set, delegate-signed reference updates 84b9e9ac0ab4

Repo::build_change builds a change from edits on any state without a
workspace (removing markers resolves a conflict); host_ref_update signs a
reference attestation with a supplied key; apply_hosted runs both through
receive_push_report so every push check applies. Under a policy, a
delegate of an identity the rule lists may sign reference attestations
(spec §14.5, Draft 0.8g). Local-path pushes now bind unbound
repositories, as HTTP pushes do.
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-27 21:25 UTC
Signature
✔ signed by Andy McCall
Hash
84b9e9ac0ab48016b387bc5f5964a76b9ca9ce8a38dd190115000525e7f14e8d
Depends on
132b884b9a8d 28fc23d7706a 5b9c6ad45f6e 6d3eefbce2f0 75167672ebcd 7aa2dc9369e3 83e0677b07fe 86e682c242c6 96f7610fa666 982864068a59 e02bf0cd0533 ff0af1ee299d
Depended on by
64dc5197f9df b63fc7a77fc6 d36ba2f79f95 767ac6cd0545 e5d9b0513f3e 68a924846379 f2f91ceb0e9f 5cde7882d767 ff1a64f81c1c 7ba155f20aa0 ef26f9a28790 6f8aeb996558 f7510b63cceb cc7a00565161 387335ff7f4d eae595568832
In bookmarks
agents-in-readme bare-hashes carried-not-removed carry-identical doctor-trust identical-sides keyless-tags main merge-remote-bookmark only-help-103 paths-for-resolutions-and-amend push-notes show-stat subrepo-pin-state switch-says
In tags
v0.1.0 v0.10.0 v0.11.0 v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.4.3 v0.4.4 v0.4.5 v0.4.6 v0.5.0 v0.5.1 v0.6.0 v0.7.0 v0.8.0 v0.9.0
Edits
17 atoms, 1 tree operation, 6 files

Browse the tree at this change

added crates/oxy-core/src/repo/hosted.rs +317 −0
1 +
//! What a host (a hub) does to a repository without a workspace (hub spec
2 +
//! §6.9, §7.6): build a change from an edit set on any state, sign a
3 +
//! reference update with a key it holds, and apply both through the same
4 +
//! validation a received push gets. Nothing here bypasses policy.
5 +
6 +
use super::*;
7 +
use crate::identity::SecretKey;
8 +
9 +
/// One file of an edit set: new content, or `None` to remove the path.
10 +
#[derive(Clone, Debug, PartialEq, Eq)]
11 +
pub struct FileEdit {
12 +
    pub path: String,
13 +
    pub content: Option<Vec<u8>>,
14 +
}
15 +
16 +
impl Repo {
17 +
    /// Build (but do not store or sign) a change that turns the state with
18 +
    /// `heads` into that state with `edits` applied, authored by `author`.
19 +
    /// Files the edits do not name keep their content, conflict markers
20 +
    /// included, so an edit that removes a file's markers resolves that
21 +
    /// file's conflict (a resolution change, spec §9.6).
22 +
    pub fn build_change(&self, heads: &[ChangeHash], edits: &[FileEdit], message: &str, author: KeyId) -> Result<Change> {
23 +
        if message.trim().is_empty() {
24 +
            return fail("a change message is required");
25 +
        }
26 +
        if edits.is_empty() {
27 +
            return fail("nothing to change");
28 +
        }
29 +
        let base = self.load_state(heads)?;
30 +
        let base_snapshot = base.materialize();
31 +
        let mut entries: BTreeMap<String, FsEntry> = BTreeMap::new();
32 +
        for (path, f) in &base_snapshot.files {
33 +
            let entry = match (&f.kind, &f.content) {
34 +
                (NodeKind::Dir, _) | (_, FileContent::Dir) => continue,
35 +
                (kind, FileContent::Text { bytes, .. }) => FsEntry::new(*kind, f.mode.exec, bytes.clone()),
36 +
                (kind, FileContent::Opaque { blobs }) => FsEntry { kind: *kind, exec: f.mode.exec, content: vec![], blob: blobs.first().copied(), content_hash: None },
37 +
            };
38 +
            entries.insert(path.clone(), entry);
39 +
        }
40 +
        for e in edits {
41 +
            let path = e.path.trim_matches('/');
42 +
            if path.is_empty() || path.split('/').any(|c| c.is_empty() || c == "." || c == ".." || c == OXY_DIR) {
43 +
                return fail(format!("{:?} is not a valid path", e.path));
44 +
            }
45 +
            match &e.content {
46 +
                Some(bytes) => {
47 +
                    let exec = entries.get(path).map(|x| x.exec).unwrap_or(false);
48 +
                    // A file cannot sit where a directory is, or inside a file.
49 +
                    if base_snapshot.files.get(path).is_some_and(|f| f.kind == NodeKind::Dir) {
50 +
                        return fail(format!("{path} is a directory"));
51 +
                    }
52 +
                    let mut parent = path;
53 +
                    while let Some((p, _)) = parent.rsplit_once('/') {
54 +
                        if base_snapshot.files.get(p).is_some_and(|f| f.kind != NodeKind::Dir) {
55 +
                            return fail(format!("{p} is a file, not a directory"));
56 +
                        }
57 +
                        parent = p;
58 +
                    }
59 +
                    entries.insert(path.to_string(), FsEntry::new(NodeKind::File, exec, bytes.clone()));
60 +
                }
61 +
                None => {
62 +
                    if entries.remove(path).is_none() {
63 +
                        // Removing a directory removes everything below it.
64 +
                        let prefix = format!("{path}/");
65 +
                        let before = entries.len();
66 +
                        entries.retain(|p, _| !p.starts_with(&prefix));
67 +
                        if entries.len() == before {
68 +
                            return fail(format!("{path} does not exist"));
69 +
                        }
70 +
                    }
71 +
                }
72 +
            }
73 +
        }
74 +
        let built = self.build_from_entries(&base, &base_snapshot, HashMap::new(), &entries)?;
75 +
        if let Some(s) = built.statuses.iter().find(|s| s.status == ChangeStatus::ConflictEdited) {
76 +
            return fail(format!("{} still contains conflict markers", s.path));
77 +
        }
78 +
        if built.tree_ops.is_empty() && built.edits.is_empty() {
79 +
            return fail("the edits change nothing");
80 +
        }
81 +
        let mut change = Change {
82 +
            change_id: ChangeId::random(),
83 +
            deps: vec![],
84 +
            author,
85 +
            hlc: Hlc::now(),
86 +
            message: message.trim_end().to_string(),
87 +
            tree_ops: built.tree_ops,
88 +
            edits: built.edits,
89 +
            kind: if built.resolves { ChangeKind::Resolution } else { ChangeKind::Normal },
90 +
        };
91 +
        let mut deps: BTreeSet<ChangeHash> = referenced_changes(&change, &base);
92 +
        deps.extend(built.contributors.iter().copied());
93 +
        change.deps = deps.into_iter().collect();
94 +
        change.normalize();
95 +
        Ok(change)
96 +
    }
97 +
98 +
    /// A reference update of `bookmark` to `heads`, attested by `signer`
99 +
    /// when given (the attestation a push would carry, spec §14.4).
100 +
    pub fn host_ref_update(&self, bookmark: &str, heads: &[ChangeHash], signer: Option<(&KeyId, &SecretKey)>) -> Result<RefUpdate> {
101 +
        let view = self.view()?;
102 +
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
103 +
        let old_state = view.bookmarks.get(bookmark).copied().unwrap_or(empty);
104 +
        let new_state = self.store.put_state(&StateObject::new(heads.to_vec()))?;
105 +
        let attestation = match signer {
106 +
            Some((key, sk)) => {
107 +
                let serial = self
108 +
                    .latest_ref_attest(bookmark)?
109 +
                    .and_then(|(_, a)| match a.attest.body {
110 +
                        AttestBody::Ref { serial, .. } => Some(serial),
111 +
                        _ => None,
112 +
                    })
113 +
                    .map(|s| s + 1)
114 +
                    .unwrap_or(1);
115 +
                let attest = Attest {
116 +
                    repo: self.id()?,
117 +
                    hlc: Hlc::now(),
118 +
                    body: AttestBody::Ref { name: bookmark.to_string(), old_state, new_state, serial },
119 +
                };
120 +
                let r = attest.hash();
121 +
                let sig = sk.sign(Domain::Attest, key, &r);
122 +
                Some(SignedAttest { attest, sigs: vec![sig] })
123 +
            }
124 +
            None => None,
125 +
        };
126 +
        Ok(RefUpdate { name: bookmark.to_string(), old_state, new_state, force: false, attestation })
127 +
    }
128 +
129 +
    /// Apply a host-made update as if it had been pushed: the new change
130 +
    /// (with its signatures) if any, the identities it needs, and the
131 +
    /// reference update, all through `receive_push_report`, so the same
132 +
    /// authentication, compare-and-swap and policy checks apply.
133 +
    pub fn apply_hosted(&self, change: Option<(Change, Vec<Signature>)>, identities: &[Identity], update: RefUpdate) -> Result<PushReport> {
134 +
        let mut bundle = Bundle::default();
135 +
        for id in identities {
136 +
            bundle.objects.push((ObjectType::Identity, id.encode()));
137 +
        }
138 +
        if let Ok(st) = self.store.get_state(&update.new_state) {
139 +
            bundle.objects.push((ObjectType::State, st.encode()));
140 +
        }
141 +
        if let Some((c, sigs)) = change {
142 +
            bundle.changes.push((c, sigs));
143 +
        }
144 +
        self.receive_push_report(bundle, vec![update], None, vec![])
145 +
    }
146 +
}
147 +
148 +
#[cfg(test)]
149 +
mod tests {
150 +
    use super::*;
151 +
    use crate::identity::SecretKey;
152 +
153 +
    fn hermetic() {
154 +
        static ONCE: std::sync::Once = std::sync::Once::new();
155 +
        static DIR: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
156 +
        ONCE.call_once(|| {
157 +
            let d = tempfile::tempdir().unwrap();
158 +
            std::env::set_var("OXY_HOME", d.path());
159 +
            DIR.set(d).ok();
160 +
        });
161 +
    }
162 +
163 +
    fn write(root: &Path, rel: &str, content: &[u8]) {
164 +
        let p = root.join(rel);
165 +
        fs::create_dir_all(p.parent().unwrap()).unwrap();
166 +
        fs::write(p, content).unwrap();
167 +
    }
168 +
169 +
    /// A user repository pushed to a bare host, and a hub key the user
170 +
    /// has delegated to. Returns (host, user, hub key, hub secret).
171 +
    fn setup(root: &Path, with_policy: bool, delegate: bool) -> (Repo, Repo, KeyId, SecretKey, Identity) {
172 +
        hermetic();
173 +
        let host_dir = root.join("host");
174 +
        Repo::init_bare_unbound(&host_dir).unwrap();
175 +
        let work = root.join("work");
176 +
        let mut user = Repo::init(&work).unwrap();
177 +
        user.create_identity("Andy", "[email protected]", IdentityScope::Repo).unwrap();
178 +
        user.config.set("core.sign", "change").unwrap();
179 +
        user.config.set("remote.origin.url", host_dir.to_str().unwrap()).unwrap();
180 +
        user.save_config().unwrap();
181 +
        let hub_sk = SecretKey::generate();
182 +
        let hub_id = hub_sk.identity("Hub", "hub@example", 1);
183 +
        let hub_key = hub_id.hash();
184 +
        if with_policy {
185 +
            user.policy_init().unwrap();
186 +
        }
187 +
        if delegate {
188 +
            user.delegate_add(hub_id.clone()).unwrap();
189 +
        }
190 +
        write(&work, "a.txt", b"one\ntwo\n");
191 +
        user.snapshot("t").unwrap();
192 +
        user.commit("first", &[]).unwrap();
193 +
        let r = user.push("origin", false).unwrap();
194 +
        assert!(r.iter().all(|x| x.ok), "{r:?}");
195 +
        (Repo::open(&host_dir).unwrap(), user, hub_key, hub_sk, hub_id)
196 +
    }
197 +
198 +
    #[test]
199 +
    fn a_host_edit_signed_by_a_delegate_lands() {
200 +
        let dir = tempfile::tempdir().unwrap();
201 +
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
202 +
        let author = user.config.author_key.unwrap();
203 +
        let heads = host.bookmark_heads().unwrap();
204 +
        let change = host.build_change(&heads, &[FileEdit { path: "a.txt".into(), content: Some(b"one\ntwo\nthree\n".to_vec()) }, FileEdit { path: "docs/new.md".into(), content: Some(b"# New\n".to_vec()) }], "Edit on the web", author).unwrap();
205 +
        assert_eq!(change.author, author);
206 +
        assert_eq!(change.kind, ChangeKind::Normal);
207 +
        let hash = change.hash();
208 +
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
209 +
        let mut new_heads = heads.clone();
210 +
        new_heads.push(hash);
211 +
        let update = host.host_ref_update("main", &[hash], Some((&hub_key, &hub_sk))).unwrap();
212 +
        let ids = vec![hub_id, user.identity(&author).unwrap()];
213 +
        let report = host.apply_hosted(Some((change, vec![sig])), &ids, update).unwrap();
214 +
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
215 +
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
216 +
        assert_eq!(host.file_at("a.txt", Some(&[hash])).unwrap(), b"one\ntwo\nthree\n");
217 +
        assert_eq!(host.file_at("docs/new.md", Some(&[hash])).unwrap(), b"# New\n");
218 +
        // Authored by the user, authenticated by the hub key as delegate.
219 +
        assert!(matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Valid { key, name, .. } if key == hub_key && name == "Hub"));
220 +
        let _ = new_heads;
221 +
        // Bad paths and empty edits are refused.
222 +
        assert!(host.build_change(&[hash], &[FileEdit { path: "../x".into(), content: Some(vec![]) }], "m", author).is_err());
223 +
        assert!(host.build_change(&[hash], &[FileEdit { path: "a.txt".into(), content: Some(b"one\ntwo\nthree\n".to_vec()) }], "m", author).is_err(), "no-op edits are refused");
224 +
        assert!(host.build_change(&[hash], &[FileEdit { path: "a.txt/b".into(), content: Some(vec![]) }], "m", author).is_err(), "a file inside a file");
225 +
        let rm = host.build_change(&[hash], &[FileEdit { path: "docs".into(), content: None }], "Remove docs", author).unwrap();
226 +
        assert!(!rm.tree_ops.is_empty());
227 +
    }
228 +
229 +
    #[test]
230 +
    fn a_host_edit_by_a_key_that_is_not_a_delegate_is_refused() {
231 +
        let dir = tempfile::tempdir().unwrap();
232 +
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, false);
233 +
        let author = user.config.author_key.unwrap();
234 +
        let heads = host.bookmark_heads().unwrap();
235 +
        let change = host.build_change(&heads, &[FileEdit { path: "a.txt".into(), content: Some(b"x\n".to_vec()) }], "Edit", author).unwrap();
236 +
        let hash = change.hash();
237 +
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
238 +
        let update = host.host_ref_update("main", &[hash], Some((&hub_key, &hub_sk))).unwrap();
239 +
        let report = host.apply_hosted(Some((change, vec![sig])), &[hub_id, user.identity(&author).unwrap()], update).unwrap();
240 +
        // Without a policy the host accepts unauthenticated changes, but the
241 +
        // signature does not verify as the author's: it stays unauthenticated.
242 +
        assert!(report.results.iter().all(|r| r.ok));
243 +
        assert!(matches!(host.authenticate_change(&hash).unwrap(), SignatureStatus::Invalid(why) if why.contains("neither the author")));
244 +
    }
245 +
246 +
    #[test]
247 +
    fn under_a_policy_a_delegate_may_update_the_reference_and_a_stranger_may_not() {
248 +
        let dir = tempfile::tempdir().unwrap();
249 +
        let (host, user, hub_key, hub_sk, hub_id) = setup(dir.path(), true, true);
250 +
        assert!(host.current_policy().unwrap().is_some());
251 +
        let author = user.config.author_key.unwrap();
252 +
        let heads = host.bookmark_heads().unwrap();
253 +
        let change = host.build_change(&heads, &[FileEdit { path: "a.txt".into(), content: Some(b"one\n".to_vec()) }], "Trim", author).unwrap();
254 +
        let hash = change.hash();
255 +
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hash);
256 +
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
257 +
        let update = host.host_ref_update("main", &[hash], Some((&hub_key, &hub_sk))).unwrap();
258 +
        let report = host.apply_hosted(Some((change, vec![sig])), &ids, update).unwrap();
259 +
        assert!(report.results.iter().all(|r| r.ok), "{:?}", report.results);
260 +
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash]);
261 +
262 +
        // A stranger's key cannot move the reference, even with the user as author.
263 +
        let stranger = SecretKey::generate();
264 +
        let sid = stranger.identity("Mallory", "m@example", 1);
265 +
        let skey = sid.hash();
266 +
        let change2 = host.build_change(&[hash], &[FileEdit { path: "a.txt".into(), content: Some(b"evil\n".to_vec()) }], "Evil", author).unwrap();
267 +
        let h2 = change2.hash();
268 +
        let sig2 = stranger.sign(Domain::Change, &skey, &h2);
269 +
        let update = host.host_ref_update("main", &[h2], Some((&skey, &stranger))).unwrap();
270 +
        let report = host.apply_hosted(Some((change2, vec![sig2])), &[sid], update).unwrap();
271 +
        assert!(report.results.iter().all(|r| !r.ok), "{:?}", report.results);
272 +
        assert_eq!(host.bookmark_heads().unwrap(), vec![hash], "the reference did not move");
273 +
        // An unsigned update is refused under a policy too.
274 +
        let update = host.host_ref_update("main", &[hash], None).unwrap();
275 +
        let report = host.apply_hosted(None, &[], update).unwrap();
276 +
        assert!(report.results.iter().all(|r| !r.ok));
277 +
    }
278 +
279 +
    #[test]
280 +
    fn a_host_edit_that_removes_markers_resolves_the_conflict() {
281 +
        let dir = tempfile::tempdir().unwrap();
282 +
        let (host, mut user, hub_key, hub_sk, hub_id) = setup(dir.path(), false, true);
283 +
        let author = user.config.author_key.unwrap();
284 +
        let base = host.bookmark_heads().unwrap();
285 +
        // Two independent edits of the same line: a conflict in the union.
286 +
        let a = host.build_change(&base, &[FileEdit { path: "a.txt".into(), content: Some(b"ONE\ntwo\n".to_vec()) }], "A", author).unwrap();
287 +
        let b = host.build_change(&base, &[FileEdit { path: "a.txt".into(), content: Some(b"uno\ntwo\n".to_vec()) }], "B", author).unwrap();
288 +
        let (ha, hb) = (a.hash(), b.hash());
289 +
        let ids = vec![hub_id.clone(), user.identity(&author).unwrap()];
290 +
        for (c, h) in [(a, ha), (b, hb)] {
291 +
            let sig = hub_sk.sign(Domain::Change, &hub_key, &h);
292 +
            let mut heads = host.bookmark_heads().unwrap();
293 +
            heads.retain(|x| !base.contains(x));
294 +
            heads.push(h);
295 +
            let update = host.host_ref_update("main", &heads, Some((&hub_key, &hub_sk))).unwrap();
296 +
            let r = host.apply_hosted(Some((c, vec![sig])), &ids, update).unwrap();
297 +
            assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
298 +
        }
299 +
        let heads = host.bookmark_heads().unwrap();
300 +
        assert_eq!(heads.len(), 2);
301 +
        let marked = String::from_utf8(host.file_at("a.txt", Some(&heads)).unwrap()).unwrap();
302 +
        assert!(marked.contains("<<<<<<< oxy conflict"), "{marked}");
303 +
        // Saving with markers left in is refused; saving without them resolves.
304 +
        assert!(host.build_change(&heads, &[FileEdit { path: "a.txt".into(), content: Some(format!("{marked}x\n").into_bytes()) }], "half", author).is_err());
305 +
        let res = host.build_change(&heads, &[FileEdit { path: "a.txt".into(), content: Some(b"One\ntwo\n".to_vec()) }], "Resolve", author).unwrap();
306 +
        assert_eq!(res.kind, ChangeKind::Resolution);
307 +
        assert!(res.deps.contains(&ha) && res.deps.contains(&hb), "a resolution depends on both sides");
308 +
        let hr = res.hash();
309 +
        let sig = hub_sk.sign(Domain::Change, &hub_key, &hr);
310 +
        let update = host.host_ref_update("main", &[hr], Some((&hub_key, &hub_sk))).unwrap();
311 +
        let r = host.apply_hosted(Some((res, vec![sig])), &ids, update).unwrap();
312 +
        assert!(r.results.iter().all(|x| x.ok), "{:?}", r.results);
313 +
        assert_eq!(host.file_at("a.txt", Some(&[hr])).unwrap(), b"One\ntwo\n");
314 +
        assert!(!host.load_state(&[hr]).unwrap().materialize().is_conflicted());
315 +
        let _ = &mut user;
316 +
    }
317 +
}
modified crates/oxy-core/src/protocol.rs +13 −1
…
143 unchanged lines
144 144
/// Another repository on this machine, served in-process.
145 145
pub struct LocalRemote {
146 146
    pub repo: Repo,
147 +
    /// The pushing repository's id, which binds an unbound target on the
148 +
    /// first push, as `Oxy-Repo-Id` does over HTTP.
149 +
    pub claimed_id: Option<[u8; 16]>,
147 150
}
148 151
149 152
impl LocalRemote {
150 153
    pub fn open(path: &Path) -> Result<LocalRemote> {
151 154
        Ok(LocalRemote {
152 155
            repo: Repo::open(path)?,
156 +
            claimed_id: None,
153 157
        })
154 158
    }
155 159
}
…
27 unchanged lines
183 187
        policy: Option<SignedPolicy>,
184 188
        tags: Vec<TagRef>,
185 189
    ) -> Result<Vec<RefResult>> {
190 +
        if self.repo.is_unbound() {
191 +
            match self.claimed_id {
192 +
                Some(id) => self.repo.bind_id(id)?,
193 +
                None => return Err(RepoError::Msg("the remote is unbound and this client did not say which repository it is".into())),
194 +
            }
195 +
        }
186 196
        self.repo.receive_push(bundle, updates, policy, tags)
187 197
    }
188 198
}
…
176 unchanged lines
365 375
    if path.contains("://") {
366 376
        return Err(RepoError::Msg(format!("unsupported remote URL {url:?}")));
367 377
    }
368 -
    Ok(Box::new(LocalRemote::open(Path::new(path))?))
378 +
    let mut local = LocalRemote::open(Path::new(path))?;
379 +
    local.claimed_id = options.repo_id;
380 +
    Ok(Box::new(local))
369 381
}
370 382
371 383
// ---------------------------------------------------------------- wire
…
603 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +2 −0
…
65 unchanged lines
66 66
mod git;
67 67
mod history;
68 68
mod hooks;
69 +
mod hosted;
69 70
mod identity;
70 71
mod inspect;
71 72
mod policy;
…
12 unchanged lines
84 85
pub use gc::GcReport;
85 86
pub use git::{GitExportReport, GitImportReport};
86 87
pub use hooks::{json_array, json_str, HookOutcome};
88 +
pub use hosted::FileEdit;
87 89
pub use identity::{Rotation, SignatureStatus};
88 90
pub use inspect::{BisectStep, BlameLine};
89 91
pub use policy::PolicyUpdate;
…
1132 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +19 −1
…
412 unchanged lines
413 413
    /// Verify a reference attestation against an update it claims to describe.
414 414
    /// `allow_equal` lets a client re-read the attestation it already has; a
415 415
    /// server receiving a push requires a strictly greater serial.
416 +
    /// A reference attestation signed by a delegate of an identity the
417 +
    /// rule lists is authorized as that identity (spec §14.5): the identity
418 +
    /// named the delegate, and the policy names the identity. Revoked keys
419 +
    /// authorize nothing either way.
420 +
    fn delegate_authorized(&self, p: &crate::policy::Policy, signer: &KeyId, reference: &str) -> bool {
421 +
        if p.is_revoked(signer) {
422 +
            return false;
423 +
        }
424 +
        let Some(rule) = p.rule_for(reference) else { return false };
425 +
        rule.keys.iter().any(|k| {
426 +
            !p.is_revoked(k)
427 +
                && p.identity(k)
428 +
                    .cloned()
429 +
                    .or_else(|| self.store.get_identity(k).ok())
430 +
                    .is_some_and(|id| id.delegates.contains(signer))
431 +
        })
432 +
    }
433 +
416 434
    fn check_ref_attest(
417 435
        &self,
418 436
        sa: &SignedAttest,
…
206 unchanged lines
625 643
                .first()
626 644
                .map(|s| s.key)
627 645
                .ok_or("attestation carries no signature")?;
628 -
            if !p.authorizes(&signer, &u.name) {
646 +
            if !p.authorizes(&signer, &u.name) && !self.delegate_authorized(p, &signer, &u.name) {
629 647
                return Err(format!(
630 648
                    "key {} may not update {:?} under this repository's policy",
631 649
                    signer.short(12),
…
785 unchanged lines
modified docs/plan.md +1 −1
…
87 unchanged lines
88 88
- [x] `oxy identity sign-challenge` and `identity::{sign_challenge, verify_challenge, challenge_target}` (hub 0.2).
89 89
- [ ] `oxy seal <hash>..` and `--proposal URL` (hub 0.3).
90 90
- [ ] `oxy proposal show|land URL` (hub 0.3).
91 -
- [ ] Delegate-signed changes from an edit set without a workspace (hub 0.6).
91 +
- [x] Delegate-signed changes from an edit set without a workspace: `Repo::build_change(heads, edits, message, author)`, `host_ref_update(bookmark, heads, signer)`, `apply_hosted(change, identities, update)` through `receive_push_report`; delegates of a rule's identities may sign reference attestations (spec §14.5, Draft 0.8g).
92 92
- [ ] `Repo::log_window` and a shared `graph::layout` (hub 0.1 uses `log_state` and its own layout until then).
93 93
- [x] `receive_push_report` with `PushReport { results, updated: [RefUpdated { name, old_state, new_state, heads }], imported }`; `Backend::after_push` receives it.
modified docs/spec.md +3 −2
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.8f |
5 +
| **Status** | Draft 0.8g |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
642 unchanged lines
651 651
652 652
- The initial policy is signed by the root keys; every update MUST be signed by `roots.threshold` root keys of the policy it replaces and have a higher `serial`. An update that a single holder cannot complete is a **proposal**: the signed policy (`[policy bytes, [Signature]]`, §16.3) is passed to other root holders, each of whom adds a signature with `oxy policy sign`; whoever completes the threshold applies it. Rotating a root key (§14.2) substitutes the successor in the roots and rules; such a policy is signed by both the previous and the successor key so a fresh clone can bootstrap from it.
653 653
- A change's signer is authorized by a rule if the rule lists it, or lists a successor of it, or lists the change's `author` (or a successor) when the signer is that author's delegate.
654 +
- A reference attestation's signer is authorized by a rule if the rule lists it, or lists an identity whose `delegates` name it (§14.2). Delegation is the identity's own signed statement and the policy names that identity, so a delegate moves references exactly as far as its delegator may; revoking either key ends it. Hosts use this to land and to commit browser edits on a user's behalf with their own key.
654 655
- A change or attestation is **authorized** for reference `r` if its signer is listed in the first rule whose pattern matches `r`, and the rule's threshold of distinct authorized signers is met across the attestation and each change in the closure of `new_state` not previously in the closure of `old_state`. `require` selects which authenticators count for changes: individual signatures only, seals only, or either. Every change MUST additionally satisfy the author-or-delegate rule of §14.4 regardless of `require`.
655 656
- Clients MUST verify policy on fetch, MUST refuse to update a `remotes/` reference whose attestation is unauthorized, and MUST warn loudly on an expired policy. Servers MUST enforce the same rules on push.
656 657
- A repository without a `policy` reference is **unverified**; every command that talks to a remote MUST print a one-line warning until one is created. `oxy policy init` creates one with the author's key as the sole root; `oxy policy grant` and `oxy policy revoke` produce successors.
…
261 unchanged lines
918 919
919 920
---
920 921
921 -
*Changelog.* Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
922 +
*Changelog.* Draft 0.8g (2026-09-27): delegates of a rule's identities may sign reference attestations (§14.5); local-path pushes bind unbound repositories like HTTP ones. Draft 0.8f (2026-09-27): challenge signatures (§14.3) for hosts. Draft 0.8e (2026-09-27): anonymous changes (zero author) are authenticated by a seal from any key, so imported and pre-identity history can be pushed under a policy (§14.4). Draft 0.8d (2026-09-27): unbound hosted repositories adopt the first pusher's id (§16.1). Draft 0.8c (2026-09-27): bare repositories (`oxy init --bare`, §12.2: no workspace, no working copy; hosting) and the reference server as an embeddable library (§15.1). Draft 0.8b (2026-09-27): the machine interface for editors (§24): `root`, `cat`, `version`, `hunks`, `discard`, and `--json` on the reporting commands.
922 923
923 924
Draft 0.8a (2026-09-27): every Draft 0.8 addition is implemented in the reference implementation (§26.1). Clarifications from doing so: an unregistered nested repository is left alone and a tracked subrepo whose directory is absent keeps its pin (§7.8); Oxygen pins are omitted from Git export (§23).
924 925
…
59 unchanged lines