OXYGENHUB

Tags: immutable named states with tag attestations, in views and on the wire, policy-governed, exported to Git (spec 0.6h) 132b884b9a8d

Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-26 22:39 UTC
Signature
✔ signed by Andy McCall
Hash
132b884b9a8d9894c9ad7624370a26285446f9d49dbfc762e1ef16e0a2e88133
Depends on
252e8cb23fbc 2863456a0f6d 5b1114a04905 5b9c6ad45f6e 7421309f5372 750251b21a96 79ab8528ff62 83e0677b07fe 86e682c242c6 982864068a59 ba160f60219e fce44ae9ad29 ff0af1ee299d
Depended on by
2f0b4b0651ea b63fc7a77fc6 ee130dd9eef2 1ec6fdbc6bcb d8524a9f0117 d36ba2f79f95 028545dfa7a4 767ac6cd0545 e59512f2f075 4b9d06778581 e5d9b0513f3e 7ba155f20aa0 b2ef93bb2366 a46f3e4f50b1 14edbaaf5291 2c75672fb2fe 57eaac9107df 6caacfff1d4f fc1a466a1e66 6a81dbab471d 54cd08a1e7bf cc7a00565161 d42369abf180 19555025a2ac 3526d339519f eae595568832 84b9e9ac0ab4 75167672ebcd e02bf0cd0533 c792033af344 1196b29b5fa9 4dee277c327a 7b9809e67c3d 30ea9c1e70fa 94084087a876 28fc23d7706a 6d3eefbce2f0 4e88e774777a
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
72 atoms, 1 tree operation, 14 files

Browse the tree at this change

added crates/oxy-core/src/repo/tags.rs +145 −0
1 +
//! Tags (spec §10.1, §14.4): immutable named states. A tag is a `tags/<name>`
2 +
//! entry in the view pointing at a state object, plus a signed `tag`
3 +
//! attestation when the author has a key. A name is never reassigned; a
4 +
//! server refuses a second attestation for an existing tag name.
5 +
6 +
use super::*;
7 +
8 +
#[derive(Debug, Clone, PartialEq, Eq)]
9 +
pub struct TagInfo {
10 +
    pub name: String,
11 +
    pub state: ObjectRef,
12 +
    pub heads: Vec<ChangeHash>,
13 +
    pub message: String,
14 +
    /// The signer's identity name, when attested by a known key.
15 +
    pub signer: Option<String>,
16 +
}
17 +
18 +
impl Repo {
19 +
    fn tag_attest_path(&self, name: &str) -> PathBuf {
20 +
        self.oxy.join("attest").join("tags").join(name)
21 +
    }
22 +
23 +
    /// The attestation recorded for a tag, if any.
24 +
    pub fn tag_attest(&self, name: &str) -> Result<Option<(ObjectRef, SignedAttest)>> {
25 +
        match fs::read_to_string(self.tag_attest_path(name)) {
26 +
            Ok(h) => {
27 +
                let r = ObjectRef::from_hex(h.trim()).ok_or_else(|| RepoError::Msg("corrupt tag attestation pointer".into()))?;
28 +
                Ok(Some((r, SignedAttest { attest: self.store.get_attest(&r)?, sigs: self.store.signatures(&r)? })))
29 +
            }
30 +
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
31 +
            Err(e) => Err(e.into()),
32 +
        }
33 +
    }
34 +
35 +
    fn set_tag_attest(&self, name: &str, r: &ObjectRef) -> Result<()> {
36 +
        fs::create_dir_all(self.oxy.join("attest").join("tags"))?;
37 +
        write_atomic(&self.tag_attest_path(name), r.to_hex().as_bytes())
38 +
    }
39 +
40 +
    /// Create a tag named `name` for the state of `heads` (spec §10.1).
41 +
    pub fn tag_create(&self, name: &str, heads: &[ChangeHash], message: &str) -> Result<TagInfo> {
42 +
        crate::objects::validate_name(name).map_err(|e| RepoError::Msg(format!("tag name: {e}")))?;
43 +
        let (op_ref, op) = self.head_op()?;
44 +
        if op.view.tags.contains_key(name) {
45 +
            return fail(format!("tag {name:?} already exists; tags are immutable"));
46 +
        }
47 +
        let st = self.load_state(heads)?;
48 +
        let heads = st.maximal(heads);
49 +
        if heads.is_empty() {
50 +
            return fail("a tag needs at least one change");
51 +
        }
52 +
        let state = self.store.put_state(&StateObject::new(heads.clone()))?;
53 +
        let mut signer = None;
54 +
        if self.secret_key()?.is_some() {
55 +
            let sa = self.sign_attest(&Attest { repo: self.id()?, hlc: Hlc::now(), body: AttestBody::Tag { name: name.into(), state, message: message.into() } })?;
56 +
            let r = sa.attest.hash();
57 +
            self.set_tag_attest(name, &r)?;
58 +
            signer = sa.sigs.first().and_then(|s| self.store.get_identity(&s.key).ok()).map(|i| i.name);
59 +
        }
60 +
        let mut view = op.view.clone();
61 +
        view.tags.insert(name.into(), state);
62 +
        self.record_op(&[op_ref], &format!("oxy tag create {name}"), view)?;
63 +
        Ok(TagInfo { name: name.into(), state, heads, message: message.into(), signer })
64 +
    }
65 +
66 +
    /// Every tag, by name.
67 +
    pub fn tags(&self) -> Result<Vec<TagInfo>> {
68 +
        let view = self.view()?;
69 +
        let mut out = Vec::new();
70 +
        for (name, state) in &view.tags {
71 +
            let heads = self.store.get_state(state)?.heads;
72 +
            let (message, signer) = match self.tag_attest(name)? {
73 +
                Some((_, sa)) => {
74 +
                    let message = match &sa.attest.body {
75 +
                        AttestBody::Tag { message, .. } => message.clone(),
76 +
                        _ => String::new(),
77 +
                    };
78 +
                    (message, sa.sigs.first().and_then(|s| self.store.get_identity(&s.key).ok()).map(|i| i.name))
79 +
                }
80 +
                None => (String::new(), None),
81 +
            };
82 +
            out.push(TagInfo { name: name.clone(), state: *state, heads, message, signer });
83 +
        }
84 +
        Ok(out)
85 +
    }
86 +
87 +
    /// The heads a tag names, if it exists.
88 +
    pub fn tag_heads(&self, name: &str) -> Result<Option<Vec<ChangeHash>>> {
89 +
        match self.view()?.tags.get(name) {
90 +
            Some(st) => Ok(Some(self.store.get_state(st)?.heads)),
91 +
            None => Ok(None),
92 +
        }
93 +
    }
94 +
95 +
    /// Tags as `refs` reports them.
96 +
    pub(crate) fn tag_refs(&self) -> Result<Vec<crate::protocol::TagRef>> {
97 +
        let mut out = Vec::new();
98 +
        for t in self.tags()? {
99 +
            out.push(crate::protocol::TagRef { name: t.name.clone(), state: t.state, heads: t.heads, attestation: self.tag_attest(&t.name)?.map(|(_, a)| a) });
100 +
        }
101 +
        Ok(out)
102 +
    }
103 +
104 +
    /// Adopt a tag received from a remote or a push. Verifies the
105 +
    /// attestation when present (and requires one, by an authorized key,
106 +
    /// under a policy). An existing tag with a different state is refused.
107 +
    pub(crate) fn adopt_tag(&self, view: &mut View, t: &crate::protocol::TagRef, policy: Option<&crate::policy::Policy>) -> std::result::Result<(), String> {
108 +
        if let Some(existing) = view.tags.get(&t.name) {
109 +
            return if *existing == t.state { Ok(()) } else { Err(format!("tag {:?} already exists with a different state; tags are immutable", t.name)) };
110 +
        }
111 +
        let state = self.store.get_state(&t.state).map_err(|_| "tag state object is missing".to_string())?;
112 +
        for h in &state.heads {
113 +
            if !self.store.has(h) && !self.boundary().contains(h) {
114 +
                return Err(format!("tag {:?} names change {}, which is missing", t.name, h.short(12)));
115 +
            }
116 +
        }
117 +
        if let Some(sa) = &t.attestation {
118 +
            let AttestBody::Tag { name, state: st, .. } = &sa.attest.body else { return Err("tag attestation has the wrong kind".into()) };
119 +
            if *name != t.name || *st != t.state {
120 +
                return Err("tag attestation does not describe this tag".into());
121 +
            }
122 +
            if sa.attest.repo != self.id().map_err(|e| e.to_string())? {
123 +
                return Err("tag attestation is for a different repository".into());
124 +
            }
125 +
            let r = sa.attest.hash();
126 +
            let sig = sa.sigs.first().ok_or("tag attestation carries no signature")?;
127 +
            let id = self.store.get_identity(&sig.key).map_err(|_| format!("unknown key {}", sig.key.short(12)))?;
128 +
            verify_signature(&id, sig, Domain::Attest, &r).map_err(|e| e.to_string())?;
129 +
            if let Some(p) = policy {
130 +
                if !p.authorizes(&sig.key, &format!("tags/{}", t.name)) {
131 +
                    return Err(format!("key {} may not create tags/{} under this repository's policy", sig.key.short(12), t.name));
132 +
                }
133 +
            }
134 +
            let stored = self.store.put_attest(&sa.attest).map_err(|e| e.to_string())?;
135 +
            for s in &sa.sigs {
136 +
                self.store.put_signature(&stored, s).map_err(|e| e.to_string())?;
137 +
            }
138 +
            self.set_tag_attest(&t.name, &stored).map_err(|e| e.to_string())?;
139 +
        } else if policy.is_some() {
140 +
            return Err(format!("tag {:?} needs a signed attestation under this repository's policy", t.name));
141 +
        }
142 +
        view.tags.insert(t.name.clone(), t.state);
143 +
        Ok(())
144 +
    }
145 +
}
modified crates/oxy-core/src/attest.rs +14 −0
…
12 unchanged lines
13 13
    Ref { name: String, old_state: ObjectRef, new_state: ObjectRef, serial: u64 },
14 14
    /// One signature covering a batch of changes (spec §14.4).
15 15
    Seal { hashes: Vec<ChangeHash> },
16 +
    /// An immutable named state (spec §10.1).
17 +
    Tag { name: String, state: ObjectRef, message: String },
16 18
}
17 19
18 20
#[derive(Clone, Debug, PartialEq, Eq)]
…
9 unchanged lines
28 30
        match self.body {
29 31
            AttestBody::Ref { .. } => "ref",
30 32
            AttestBody::Seal { .. } => "seal",
33 +
            AttestBody::Tag { .. } => "tag",
31 34
        }
32 35
    }
33 36
…
14 unchanged lines
48 51
            AttestBody::Seal { hashes } => {
49 52
                Value::map([(0, Value::Array(hashes.iter().map(|h| Value::Bytes(h.0.to_vec())).collect()))])
50 53
            }
54 +
            AttestBody::Tag { name, state, message } => {
55 +
                Value::map([(0, Value::Text(name.clone())), (1, Value::Bytes(state.0.to_vec())), (2, Value::Text(message.clone()))])
56 +
            }
51 57
        };
52 58
        Value::map([
53 59
            (0, Value::Uint(FORMAT_VERSION)),
…
36 unchanged lines
90 96
                }
91 97
                AttestBody::Seal { hashes }
92 98
            }
99 +
            "tag" => AttestBody::Tag {
100 +
                name: text(field(b, 0, "name")?, "name")?.to_owned(),
101 +
                state: oref(field(b, 1, "state")?, "state")?,
102 +
                message: text(field(b, 2, "message")?, "message")?.to_owned(),
103 +
            },
93 104
            other => return Err(ObjectError::Schema(format!("unknown attestation kind {other:?}"))),
94 105
        };
95 106
        Ok(Attest { repo, hlc, body })
…
17 unchanged lines
113 124
        let d = Attest::decode(&s.encode()).unwrap();
114 125
        assert!(matches!(&d.body, AttestBody::Seal { hashes } if hashes == &vec![ObjectRef([3; 32]), ObjectRef([9; 32])]));
115 126
        assert_ne!(s.hash(), r.hash());
127 +
        let t = Attest { repo: [1; 16], hlc: Hlc::default(), body: AttestBody::Tag { name: "v1".into(), state: ObjectRef([5; 32]), message: "release".into() } };
128 +
        assert_eq!(Attest::decode(&t.encode()).unwrap(), t);
129 +
        assert_eq!(t.kind(), "tag");
116 130
    }
117 131
118 132
    #[test]
…
7 unchanged lines
modified crates/oxy-core/src/oplog.rs +13 −0
…
21 unchanged lines
22 22
    pub remotes: BTreeMap<String, BTreeMap<String, ObjectRef>>,
23 23
    /// The `policy` reference (spec §10.1, §14.5): ObjectRef of the current policy.
24 24
    pub policy: Option<ObjectRef>,
25 +
    /// `tags/<name>`: immutable states (spec §10.1).
26 +
    pub tags: BTreeMap<String, ObjectRef>,
25 27
}
26 28
27 29
/// A format-1 view, only produced by `Op::decode_format1`.
…
49 unchanged lines
77 79
                ),
78 80
            ),
79 81
            (3, self.policy.map(|p| Value::Bytes(p.0.to_vec())).unwrap_or(Value::Null)),
82 +
            (4, Value::Array(self.tags.iter().map(|(n, st)| Value::Array(vec![Value::Text(n.clone()), Value::Bytes(st.0.to_vec())])).collect())),
80 83
        ])
81 84
    }
82 85
…
30 unchanged lines
113 116
        if let Some(p) = m.get(&3) {
114 117
            view.policy = if p.is_null() { None } else { Some(oref(p, "policy")?) };
115 118
        }
119 +
        if let Some(ts) = m.get(&4) {
120 +
            for t in array(ts, "tags")? {
121 +
                let pair = array(t, "tag")?;
122 +
                if pair.len() != 2 {
123 +
                    return Err(ObjectError::Schema("tag: expected [name, state]".into()));
124 +
                }
125 +
                view.tags.insert(text(&pair[0], "tag name")?.to_owned(), oref(&pair[1], "tag state")?);
126 +
            }
127 +
        }
116 128
        Ok(view)
117 129
    }
118 130
}
…
147 unchanged lines
266 278
        view.workspaces.insert(WorkspaceId([5; 16]), ObjectRef([9; 32]));
267 279
        view.remotes.entry("origin".into()).or_default().insert("main".into(), ObjectRef([4; 32]));
268 280
        view.policy = Some(ObjectRef([6; 32]));
281 +
        view.tags.insert("v1".into(), ObjectRef([8; 32]));
269 282
        let op = Op { parents: vec![ObjectRef([7; 32])], hlc: Hlc { wall_ms: 5, logical: 1 }, command: "oxy commit".into(), view };
270 283
        let d = Op::decode(&op.encode()).unwrap();
271 284
        assert_eq!(d, op);
…
11 unchanged lines
modified crates/oxy-core/src/protocol.rs +69 −14
…
32 unchanged lines
33 33
    pub sigs: Vec<Signature>,
34 34
}
35 35
36 -
/// What `refs` returns: every bookmark plus the current policy, if any.
36 +
/// What `refs` returns: every bookmark, every tag, and the current policy.
37 37
#[derive(Clone, Debug, Default)]
38 38
pub struct RefsResponse {
39 39
    pub refs: Vec<RemoteRef>,
40 40
    pub policy: Option<SignedPolicy>,
41 +
    pub tags: Vec<TagRef>,
41 42
}
42 43
44 +
/// A tag as a remote reports it (spec §10.1).
45 +
#[derive(Clone, Debug, PartialEq, Eq)]
46 +
pub struct TagRef {
47 +
    pub name: String,
48 +
    pub state: ObjectRef,
49 +
    pub heads: Vec<ChangeHash>,
50 +
    pub attestation: Option<SignedAttest>,
51 +
}
52 +
43 53
/// One reference as a remote reports it.
44 54
#[derive(Clone, Debug)]
45 55
pub struct RemoteRef {
…
53 unchanged lines
99 109
    fn checkpoint(&self, heads: &[ChangeHash]) -> Result<Bundle>;
100 110
    /// The changes within `depth` dependency steps of `heads` (spec §17).
101 111
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>>;
102 -
    /// Push changes, bookmark updates, and optionally a newer policy. The
103 -
    /// policy is applied first; a result named `policy` reports it.
104 -
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>>;
112 +
    /// Push changes, bookmark updates, new tags, and optionally a newer
113 +
    /// policy. The policy is applied first; a result named `policy` reports
114 +
    /// it, and each tag reports as `tags/<name>`.
115 +
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>, tags: Vec<TagRef>) -> Result<Vec<RefResult>>;
105 116
}
106 117
107 118
// ---------------------------------------------------------------- local
…
31 unchanged lines
139 150
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> {
140 151
        self.repo.shallow_changes(heads, depth)
141 152
    }
142 -
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> {
143 -
        self.repo.receive_push(bundle, updates, policy)
153 +
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>, tags: Vec<TagRef>) -> Result<Vec<RefResult>> {
154 +
        self.repo.receive_push(bundle, updates, policy, tags)
144 155
    }
145 156
}
146 157
…
94 unchanged lines
241 252
    fn shallow(&self, heads: &[ChangeHash], depth: u32) -> Result<Vec<ChangeHash>> {
242 253
        wire::decode_hashes(&self.call(true, "shallow", Some(&wire::encode_shallow(heads, depth)))?)
243 254
    }
244 -
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> {
245 -
        wire::decode_push_response(&self.call(true, "push", Some(&wire::encode_push(&bundle, &updates, policy.as_ref())))?)
255 +
    fn push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>, tags: Vec<TagRef>) -> Result<Vec<RefResult>> {
256 +
        wire::decode_push_response(&self.call(true, "push", Some(&wire::encode_push(&bundle, &updates, policy.as_ref(), &tags)))?)
246 257
    }
247 258
}
248 259
…
133 unchanged lines
382 393
        o::bytes(field(m, 1, "repo id")?, "repo id")?.try_into().map_err(|_| schema("repo id: expected 16 bytes"))
383 394
    }
384 395
396 +
    fn tags_value(tags: &[TagRef]) -> Value {
397 +
        Value::Array(
398 +
            tags.iter()
399 +
                .map(|t| {
400 +
                    Value::map([
401 +
                        (0, Value::Text(t.name.clone())),
402 +
                        (1, Value::Bytes(t.state.0.to_vec())),
403 +
                        (2, hashes_value(&t.heads)),
404 +
                        (3, opt_attest_value(&t.attestation)),
405 +
                    ])
406 +
                })
407 +
                .collect(),
408 +
        )
409 +
    }
410 +
    fn tags_from(v: &Value) -> Result<Vec<TagRef>> {
411 +
        let mut out = Vec::new();
412 +
        for t in o::array(v, "tags")? {
413 +
            let tm = map(t, "tag")?;
414 +
            out.push(TagRef {
415 +
                name: o::text(field(tm, 0, "name")?, "name")?.to_owned(),
416 +
                state: o::oref(field(tm, 1, "state")?, "state")?,
417 +
                heads: hashes_from(field(tm, 2, "heads")?)?,
418 +
                attestation: opt_attest_from(field(tm, 3, "attestation")?)?,
419 +
            });
420 +
        }
421 +
        Ok(out)
422 +
    }
423 +
385 424
    pub fn encode_refs(id: [u8; 16], resp: &RefsResponse) -> Vec<u8> {
386 425
        cbor::encode(&Value::map([
387 426
            (
…
15 unchanged lines
403 442
            ),
404 443
            (1, Value::Bytes(id.to_vec())),
405 444
            (2, opt_policy_value(resp.policy.as_ref())),
445 +
            (3, tags_value(&resp.tags)),
406 446
        ]))
407 447
    }
408 448
    pub fn decode_refs(bytes: &[u8]) -> Result<([u8; 16], RefsResponse)> {
…
16 unchanged lines
425 465
            Some(p) => opt_policy_from(p)?,
426 466
            None => None,
427 467
        };
428 -
        Ok((id, RefsResponse { refs, policy }))
468 +
        let tags = match m.get(&3) {
469 +
            Some(t) => tags_from(t)?,
470 +
            None => Vec::new(),
471 +
        };
472 +
        Ok((id, RefsResponse { refs, policy, tags }))
429 473
    }
430 474
431 475
    pub fn encode_negotiate(have: &[ChangeHash], want: &[ChangeHash]) -> Vec<u8> {
…
37 unchanged lines
469 513
        hashes_from(field(map(&v, "hashes")?, 0, "hashes")?)
470 514
    }
471 515
472 -
    pub fn encode_push(bundle: &Bundle, updates: &[RefUpdate], policy: Option<&SignedPolicy>) -> Vec<u8> {
516 +
    pub fn encode_push(bundle: &Bundle, updates: &[RefUpdate], policy: Option<&SignedPolicy>, tags: &[TagRef]) -> Vec<u8> {
473 517
        cbor::encode(&Value::map([
474 518
            (0, bundle_value(bundle)),
475 519
            (2, opt_policy_value(policy)),
520 +
            (3, tags_value(tags)),
476 521
            (
477 522
                1,
478 523
                Value::Array(
…
13 unchanged lines
492 537
            ),
493 538
        ]))
494 539
    }
495 -
    pub fn decode_push(bytes: &[u8]) -> Result<(Bundle, Vec<RefUpdate>, Option<SignedPolicy>)> {
540 +
    /// A decoded push: bundle, bookmark updates, policy, tags.
541 +
    pub type PushParts = (Bundle, Vec<RefUpdate>, Option<SignedPolicy>, Vec<TagRef>);
542 +
543 +
    pub fn decode_push(bytes: &[u8]) -> Result<PushParts> {
496 544
        let v = cbor::decode(bytes).map_err(ObjectError::from)?;
497 545
        let m = map(&v, "push")?;
498 546
        let bundle = bundle_from_value(field(m, 0, "bundle")?)?;
…
12 unchanged lines
511 559
            Some(p) => opt_policy_from(p)?,
512 560
            None => None,
513 561
        };
514 -
        Ok((bundle, updates, policy))
562 +
        let tags = match m.get(&3) {
563 +
            Some(t) => tags_from(t)?,
564 +
            None => Vec::new(),
565 +
        };
566 +
        Ok((bundle, updates, policy, tags))
515 567
    }
516 568
517 569
    pub fn encode_push_response(results: &[RefResult]) -> Vec<u8> {
…
55 unchanged lines
573 625
        ];
574 626
        let policy = Policy::single_root([3; 16], id.clone(), 99);
575 627
        let psig = sk.sign(crate::identity::Domain::Policy, &key, &policy.hash());
576 -
        let resp = RefsResponse { refs, policy: Some(SignedPolicy { policy: policy.clone(), sigs: vec![psig.clone()] }) };
628 +
        let tag = TagRef { name: "v1".into(), state: ObjectRef([2; 32]), heads: vec![c.hash()], attestation: None };
629 +
        let resp = RefsResponse { refs, policy: Some(SignedPolicy { policy: policy.clone(), sigs: vec![psig.clone()] }), tags: vec![tag.clone()] };
577 630
        let (id_back, back) = wire::decode_refs(&wire::encode_refs([3; 16], &resp)).unwrap();
578 631
        assert_eq!(id_back, [3; 16]);
579 632
        assert_eq!(back.refs.len(), 2);
580 633
        assert_eq!(back.refs[0].attestation.as_ref().unwrap().attest, att);
581 634
        assert_eq!(back.refs[1].last_serial, None);
582 635
        assert_eq!(back.policy.as_ref().unwrap().policy, policy);
636 +
        assert_eq!(back.tags, vec![tag.clone()]);
583 637
584 638
        let (h, w) = wire::decode_negotiate(&wire::encode_negotiate(&[ObjectRef([1; 32])], &[ObjectRef([2; 32])])).unwrap();
585 639
        assert_eq!((h, w), (vec![ObjectRef([1; 32])], vec![ObjectRef([2; 32])]));
…
6 unchanged lines
592 646
593 647
        let up = RefUpdate { name: "main".into(), old_state: ObjectRef([1; 32]), new_state: ObjectRef([2; 32]), force: true, attestation: Some(SignedAttest { attest: att, sigs: vec![sig] }) };
594 648
        let sp = SignedPolicy { policy, sigs: vec![psig] };
595 -
        let (b2, ups, pol) = wire::decode_push(&wire::encode_push(&bundle, std::slice::from_ref(&up), Some(&sp))).unwrap();
649 +
        let (b2, ups, pol, tags) = wire::decode_push(&wire::encode_push(&bundle, std::slice::from_ref(&up), Some(&sp), std::slice::from_ref(&tag))).unwrap();
596 650
        assert_eq!(pol, Some(sp));
651 +
        assert_eq!(tags, vec![tag]);
597 652
        assert_eq!(b2.changes.len(), 1);
598 653
        assert_eq!(ups[0].name, "main");
599 654
        assert!(ups[0].force);
…
6 unchanged lines
modified crates/oxy-core/src/repo/gc.rs +8 −0
…
61 unchanged lines
62 62
                }
63 63
            }
64 64
        }
65 +
        // Tags: their states and attestations.
66 +
        let view_now = self.view()?;
67 +
        states.extend(view_now.tags.values().copied());
68 +
        for name in view_now.tags.keys() {
69 +
            if let Some((r, _)) = self.tag_attest(name)? {
70 +
                attests.push(r);
71 +
            }
72 +
        }
65 73
        // Reference attestations.
66 74
        for name in self.view()?.bookmarks.keys() {
67 75
            if let Some((r, _)) = self.latest_ref_attest(name)? {
…
61 unchanged lines
modified crates/oxy-core/src/repo/git.rs +22 −3
…
255 unchanged lines
256 256
            prev = Some((state_ref, state.heads.clone()));
257 257
            report.exported += 1;
258 258
        }
259 -
        match last_mark {
260 -
            Some(m) => stream.extend_from_slice(format!("reset refs/heads/{bookmark}\nfrom :{m}\n\ndone\n").as_bytes()),
261 -
            None => stream.extend_from_slice(b"done\n"),
259 +
        if let Some(m) = last_mark {
260 +
            stream.extend_from_slice(format!("reset refs/heads/{bookmark}\nfrom :{m}\n\n").as_bytes());
262 261
        }
262 +
        // Tags: a tag whose state was exported as a bookmark state points at
263 +
        // that commit; otherwise its state gets a commit of its own.
264 +
        for t in self.tags()? {
265 +
            let mark = match marks.get(&t.state) {
266 +
                Some(m) => *m,
267 +
                None => {
268 +
                    let loaded = self.load_state(&t.heads)?;
269 +
                    let snapshot = loaded.materialize();
270 +
                    let mark = next_mark;
271 +
                    next_mark += 1;
272 +
                    let message = format!("oxy: tag {}\n\n{}Oxy-State: {}\n", t.name, if t.message.is_empty() { String::new() } else { format!("{}\n\n", t.message) }, t.state.to_hex());
273 +
                    self.write_commit(&mut stream, "refs/oxy/export", mark, "oxy <oxy@localhost>", Hlc::now().wall_ms / 1000, &message, &[], &snapshot)?;
274 +
                    marks.insert(t.state, mark);
275 +
                    report.exported += 1;
276 +
                    mark
277 +
                }
278 +
            };
279 +
            stream.extend_from_slice(format!("reset refs/tags/{}\nfrom :{mark}\n\n", t.name).as_bytes());
280 +
        }
281 +
        stream.extend_from_slice(b"done\n");
263 282
264 283
        let mut cmd = Command::new("git");
265 284
        // `--force`: the scratch ref may jump between unrelated states.
…
166 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +14 −0
…
61 unchanged lines
62 62
mod prompt;
63 63
mod snapshot;
64 64
mod sync;
65 +
mod tags;
65 66
#[cfg(test)]
66 67
mod tests;
67 68
…
5 unchanged lines
73 74
pub use identity::SignatureStatus;
74 75
pub use snapshot::{added_node_paths, has_conflict_markers, is_binary, referenced_changes, render_change, ChangeStatus, PathStatus, SnapshotResult};
75 76
pub use sync::{FetchReport, PullReport};
77 +
pub use tags::TagInfo;
76 78
77 79
fn write_atomic(path: &Path, data: &[u8]) -> Result<()> {
78 80
    let tmp = path.with_extension("tmp");
…
440 unchanged lines
519 521
        Ok(entries)
520 522
    }
521 523
524 +
    /// Resolve a spec to a set of heads: a tag name, the current bookmark's
525 +
    /// name, or a single change (`resolve_change`).
526 +
    pub fn resolve_heads(&self, spec: &str) -> Result<Vec<ChangeHash>> {
527 +
        if spec == self.config.bookmark {
528 +
            return self.bookmark_heads();
529 +
        }
530 +
        if let Some(heads) = self.tag_heads(spec)? {
531 +
            return Ok(heads);
532 +
        }
533 +
        Ok(vec![self.resolve_change(spec)?])
534 +
    }
535 +
522 536
    /// Resolve a change by ChangeId prefix, `#hash` prefix, `@` (working copy), or bookmark.
523 537
    pub fn resolve_change(&self, spec: &str) -> Result<ChangeHash> {
524 538
        if spec == "@" {
…
76 unchanged lines
modified crates/oxy-core/src/repo/sync.rs +47 −5
…
6 unchanged lines
7 7
pub struct FetchReport {
8 8
    /// Bookmark name, its heads, and how many changes were new.
9 9
    pub refs: Vec<(String, Vec<ChangeHash>, usize)>,
10 +
    /// Tags adopted from the remote.
11 +
    pub new_tags: usize,
10 12
    /// The remote has never presented a policy, so nothing was verified
11 13
    /// against one (spec §14.5: warn loudly).
12 14
    pub unverified: bool,
…
42 unchanged lines
55 57
            let attestation = latest.filter(|a| matches!(&a.attest.body, AttestBody::Ref { new_state, .. } if new_state == state));
56 58
            out.push(RemoteRef { name: name.clone(), state: *state, heads: self.store.get_state(state)?.heads, attestation, last_serial });
57 59
        }
58 -
        Ok(RefsResponse { refs: out, policy: self.current_policy()?.map(|(_, sp)| sp) })
60 +
        Ok(RefsResponse { refs: out, policy: self.current_policy()?.map(|(_, sp)| sp), tags: self.tag_refs()? })
59 61
    }
60 62
61 63
    /// Every change reachable from `heads` that is in the store.
…
290 unchanged lines
352 354
    /// attestations that are present must still be valid. With a policy,
353 355
    /// every change and update is checked against it. A newer policy in the
354 356
    /// push is applied first when it is signed by the current roots.
355 -
    pub fn receive_push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>) -> Result<Vec<RefResult>> {
357 +
    pub fn receive_push(&self, bundle: Bundle, updates: Vec<RefUpdate>, policy: Option<SignedPolicy>, tags: Vec<crate::protocol::TagRef>) -> Result<Vec<RefResult>> {
356 358
        let mut results = Vec::new();
357 359
        if let Some(sp) = policy {
358 360
            let current = self.current_policy()?.map(|(_, c)| c.policy);
…
32 unchanged lines
391 393
            }
392 394
            results.push(RefResult { name: u.name, ok, reason });
393 395
        }
396 +
        for t in &tags {
397 +
            let name = format!("tags/{}", t.name);
398 +
            match self.adopt_tag(&mut view, t, policy.as_ref()) {
399 +
                Ok(()) => {
400 +
                    names.push(name.clone());
401 +
                    results.push(RefResult { name, ok: true, reason: String::new() });
402 +
                }
403 +
                Err(e) => results.push(RefResult { name, ok: false, reason: e }),
404 +
            }
405 +
        }
394 406
        if !names.is_empty() {
395 407
            self.record_op(&[op_ref], &format!("push received: {}", names.join(" ")), view)?;
396 408
        }
…
258 unchanged lines
655 667
            view.remotes.entry(remote_name.to_string()).or_default().insert(r.name.clone(), state);
656 668
            report.push((r.name, r.heads, imported.len()));
657 669
        }
670 +
        let mut new_tags = 0;
671 +
        for t in &resp.tags {
672 +
            if view.tags.contains_key(&t.name) {
673 +
                continue;
674 +
            }
675 +
            // The tag's changes must be here: fetch them if the bookmarks did not bring them.
676 +
            let missing: Vec<ChangeHash> = t.heads.iter().filter(|h| !self.store.has(h)).copied().collect();
677 +
            if !missing.is_empty() {
678 +
                let needed = remote.negotiate(&self.known_heads(&view)?, &missing)?;
679 +
                let bundle = remote.fetch(&needed, max_blob)?;
680 +
                self.import_bundle(&bundle, false)?;
681 +
            }
682 +
            self.store.put_state(&StateObject::new(t.heads.clone()))?;
683 +
            self.adopt_tag(&mut view, t, policy.as_ref()).map_err(|e| RepoError::Msg(format!("{remote_name} {}: {e}", t.name)))?;
684 +
            new_tags += 1;
685 +
        }
658 686
        if view != op.view {
659 687
            self.record_op(&[op_ref], &format!("oxy fetch {remote_name}"), view)?;
660 688
        }
661 -
        Ok(FetchReport { refs: report, unverified })
689 +
        Ok(FetchReport { refs: report, unverified, new_tags })
662 690
    }
663 691
664 692
    /// Shallow fetch of the remote's bookmark: its checkpoint as our base,
…
114 unchanged lines
779 807
        if remote_has_policy && self.secret_key()?.is_none() {
780 808
            return fail(format!("{remote_name} enforces a trust policy; pushing needs an identity (`oxy identity create`)"));
781 809
        }
810 +
        let remote_tags: BTreeSet<String> = resp.tags.iter().map(|t| t.name.clone()).collect();
811 +
        let tags_to_send: Vec<crate::protocol::TagRef> = self.tag_refs()?.into_iter().filter(|t| !remote_tags.contains(&t.name)).collect();
782 812
        let refs = resp.refs;
783 813
        let empty = self.store.put_state(&StateObject::new(vec![]))?;
784 814
        let (remote_state, remote_heads, last_serial) = match refs.iter().find(|r| r.name == bookmark) {
…
5 unchanged lines
790 820
        // state is unchanged, so the current attestation is always by a key
791 821
        // the current policy authorizes (a revoked key's attestation would
792 822
        // otherwise be refused by every later fetch).
793 -
        if new_state == remote_state && policy_to_send.is_none() {
823 +
        if new_state == remote_state && policy_to_send.is_none() && tags_to_send.is_empty() {
794 824
            return Ok(vec![RefResult { name: bookmark, ok: true, reason: "already up to date".into() }]);
795 825
        }
796 826
        for h in &remote_heads {
…
39 unchanged lines
836 866
            _ => None,
837 867
        });
838 868
        let sent_serial = policy_to_send.as_ref().map(|p| p.policy.serial);
839 -
        let results = remote.push(bundle, vec![RefUpdate { name: bookmark.clone(), old_state: remote_state, new_state, force, attestation }], policy_to_send)?;
869 +
        // Tag states must be present on the remote: bundle their changes too.
870 +
        for t in &tags_to_send {
871 +
            let extra = self.negotiate(&remote_heads, &t.heads)?;
872 +
            let extra: Vec<ChangeHash> = extra.into_iter().filter(|h| !to_send.contains(h)).collect();
873 +
            if !extra.is_empty() {
874 +
                let more = self.bundle_for(&extra)?;
875 +
                bundle.changes.extend(more.changes);
876 +
                bundle.attests.extend(more.attests);
877 +
                bundle.objects.extend(more.objects);
878 +
            }
879 +
            bundle.objects.push((ObjectType::State, StateObject::new(t.heads.clone()).encode()));
880 +
        }
881 +
        let results = remote.push(bundle, vec![RefUpdate { name: bookmark.clone(), old_state: remote_state, new_state, force, attestation }], policy_to_send, tags_to_send)?;
840 882
        if let (Some(serial), true) = (sent_serial, results.iter().any(|r| r.ok && r.name == "policy")) {
841 883
            self.record_policy_serial(remote_name, serial)?;
842 884
        }
…
65 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +70 −8
…
476 unchanged lines
477 477
478 478
    // Missing head.
479 479
    let ghost = origin.store().put_state(&StateObject::new(vec![ObjectRef([9; 32])])).unwrap();
480 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: ghost, force: false, attestation: None }], None).unwrap();
480 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: ghost, force: false, attestation: None }], None, vec![]).unwrap();
481 481
    assert!(!r[0].ok && r[0].reason.contains("missing"), "{}", r[0].reason);
482 482
483 483
    // Stale old_state.
484 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: None }], None).unwrap();
484 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: None }], None, vec![]).unwrap();
485 485
    assert!(!r[0].ok && r[0].reason.contains("stale"), "{}", r[0].reason);
486 486
487 487
    // Non-fast-forward without force is refused; with force it passes.
488 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: false, attestation: None }], None).unwrap();
488 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: false, attestation: None }], None, vec![]).unwrap();
489 489
    assert!(!r[0].ok && r[0].reason.contains("fast-forward"), "{}", r[0].reason);
490 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: true, attestation: None }], None).unwrap();
490 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: s1, new_state: empty, force: true, attestation: None }], None, vec![]).unwrap();
491 491
    assert!(r[0].ok);
492 492
    assert_eq!(origin.bookmark_heads().unwrap(), vec![]);
493 493
494 494
    // Attestation with a stale serial is a rollback and is refused.
495 495
    let sa = a.sign_attest(&Attest { repo: a.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: "main".into(), old_state: empty, new_state: s1, serial: 1 } }).unwrap();
496 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa.clone()) }], None).unwrap();
496 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa.clone()) }], None, vec![]).unwrap();
497 497
    assert!(!r[0].ok && r[0].reason.contains("serial"), "{}", r[0].reason);
498 498
    // An attestation for a different update is refused too.
499 499
    let sa2 = a.sign_attest(&Attest { repo: a.id().unwrap(), hlc: Hlc::now(), body: AttestBody::Ref { name: "main".into(), old_state: s1, new_state: empty, serial: 5 } }).unwrap();
500 -
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa2) }], None).unwrap();
500 +
    let r = origin.receive_push(Bundle::default(), vec![RefUpdate { name: "main".into(), old_state: empty, new_state: s1, force: false, attestation: Some(sa2) }], None, vec![]).unwrap();
501 501
    assert!(!r[0].ok && r[0].reason.contains("describe"), "{}", r[0].reason);
502 502
    let _ = key;
503 503
}
…
220 unchanged lines
724 724
    let fh = forged.hash();
725 725
    let (_, sk_b) = b.secret_key().unwrap().unwrap();
726 726
    let forged = SignedPolicy { policy: forged, sigs: vec![sk_b.sign(Domain::Policy, &key_b, &fh)] };
727 -
    let r = origin.receive_push(Bundle::default(), vec![], Some(forged)).unwrap();
727 +
    let r = origin.receive_push(Bundle::default(), vec![], Some(forged), vec![]).unwrap();
728 728
    assert!(!r[0].ok && r[0].reason.contains("root"), "{:?}", r);
729 729
    let stale = a.current_policy().unwrap().unwrap().1;
730 730
    let mut old = stale.clone();
731 731
    old.policy.serial = 1;
732 -
    let r = origin.receive_push(Bundle::default(), vec![], Some(old)).unwrap();
732 +
    let r = origin.receive_push(Bundle::default(), vec![], Some(old), vec![]).unwrap();
733 733
    assert!(!r[0].ok && r[0].reason.contains("serial"), "{:?}", r);
734 734
735 735
    // A revokes B; after B pulls the policy, B's pushes are refused again.
…
579 unchanged lines
1315 1315
    assert!(a.store().has(&hashes[0]));
1316 1316
    assert_eq!(a.load_state_full(&a.bookmark_heads().unwrap()).unwrap().len(), 10);
1317 1317
}
1318 +
1319 +
#[test]
1320 +
fn tags_are_immutable_named_states_and_travel() {
1321 +
    hermetic();
1322 +
    let dir = tempfile::tempdir().unwrap();
1323 +
    let root = dir.path();
1324 +
    let origin = Repo::init(&root.join("origin")).unwrap();
1325 +
    let url = root.join("origin").to_string_lossy().to_string();
1326 +
    let mut a = Repo::clone(&url, &root.join("a")).unwrap().with_home(root.join("home-a"));
1327 +
    let (key_a, _) = a.create_identity("Ada", "[email protected]", IdentityScope::User).unwrap();
1328 +
    write(&root.join("a"), "f.txt", b"1\n");
1329 +
    let h1 = a.commit("one", &[]).unwrap();
1330 +
    write(&root.join("a"), "f.txt", b"1\n2\n");
1331 +
    let h2 = a.commit("two", &[]).unwrap();
1332 +
1333 +
    let t = a.tag_create("v1", &[h1], "first release").unwrap();
1334 +
    assert_eq!((t.heads.clone(), t.signer.as_deref()), (vec![h1], Some("Ada")));
1335 +
    assert!(a.tag_create("v1", &[h2], "").is_err(), "immutable");
1336 +
    assert!(a.tag_create("bad/name", &[h2], "").is_err());
1337 +
    assert_eq!(a.tag_heads("v1").unwrap(), Some(vec![h1]));
1338 +
    assert_eq!(a.resolve_heads("v1").unwrap(), vec![h1]);
1339 +
    assert_eq!(a.resolve_heads("main").unwrap(), vec![h2]);
1340 +
    let (_, sa) = a.tag_attest("v1").unwrap().unwrap();
1341 +
    assert_eq!(sa.sigs[0].key, key_a);
1342 +
    // Checkout by tag.
1343 +
    a.checkout(&a.resolve_heads("v1").unwrap(), false, "co").unwrap();
1344 +
    assert_eq!(fs::read(root.join("a/f.txt")).unwrap(), b"1\n");
1345 +
    a.checkout(&a.bookmark_heads().unwrap(), false, "co").unwrap();
1346 +
1347 +
    // Push carries the tag; a second attestation for the name is refused.
1348 +
    let r = a.push("origin", false).unwrap();
1349 +
    assert!(r.iter().any(|x| x.name == "tags/v1" && x.ok), "{r:?}");
1350 +
    assert_eq!(origin.tag_heads("v1").unwrap(), Some(vec![h1]));
1351 +
    let mut forged = a.tag_refs().unwrap().into_iter().find(|t| t.name == "v1").unwrap();
1352 +
    forged.state = origin.store().put_state(&StateObject::new(vec![h2])).unwrap();
1353 +
    forged.heads = vec![h2];
1354 +
    forged.attestation = None;
1355 +
    let r = origin.receive_push(Bundle::default(), vec![], None, vec![forged]).unwrap();
1356 +
    assert!(!r[0].ok && r[0].reason.contains("immutable"), "{r:?}");
1357 +
1358 +
    // A clone gets the tag; a later tag arrives on fetch.
1359 +
    let b = Repo::clone(&url, &root.join("b")).unwrap().with_home(root.join("home-b"));
1360 +
    assert_eq!(b.tag_heads("v1").unwrap(), Some(vec![h1]));
1361 +
    assert!(b.tags().unwrap()[0].signer.as_deref() == Some("Ada"));
1362 +
    a.tag_create("v2", &[h2], "second").unwrap();
1363 +
    a.push("origin", false).unwrap();
1364 +
    let rep = b.fetch("origin").unwrap();
1365 +
    assert_eq!(rep.new_tags, 1);
1366 +
    assert_eq!(b.tag_heads("v2").unwrap(), Some(vec![h2]));
1367 +
1368 +
    // Under a policy, an unattested tag is refused and an attested one by the root passes.
1369 +
    a.policy_init().unwrap();
1370 +
    a.push("origin", false).unwrap();
1371 +
    let unattested = crate::protocol::TagRef { name: "v3".into(), state: origin.store().put_state(&StateObject::new(vec![h2])).unwrap(), heads: vec![h2], attestation: None };
1372 +
    let r = origin.receive_push(Bundle::default(), vec![], None, vec![unattested]).unwrap();
1373 +
    assert!(!r[0].ok && r[0].reason.contains("attestation"), "{r:?}");
1374 +
    a.tag_create("v3", &[h2], "").unwrap();
1375 +
    let r = a.push("origin", false).unwrap();
1376 +
    assert!(r.iter().any(|x| x.name == "tags/v3" && x.ok), "{r:?}");
1377 +
    assert!(a.gc(true).is_ok());
1378 +
    assert_eq!(a.tags().unwrap().len(), 3, "tags survive gc");
1379 +
}
modified crates/oxy-server/src/lib.rs +2 −2
…
170 unchanged lines
171 171
    app.authorize_push(&headers)?;
172 172
    blocking(move || {
173 173
        let r = app.open(&repo)?;
174 -
        let (bundle, updates, policy) = wire::decode_push(&body)?;
174 +
        let (bundle, updates, policy, tags) = wire::decode_push(&body)?;
175 175
        let lock = app.lock_for(&repo);
176 176
        let _guard = lock.lock().unwrap();
177 -
        Ok(ok(wire::encode_push_response(&r.receive_push(bundle, updates, policy)?)))
177 +
        Ok(ok(wire::encode_push_response(&r.receive_push(bundle, updates, policy, tags)?)))
178 178
    })
179 179
    .await
180 180
}
…
39 unchanged lines
modified docs/spec.md +8 −6
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.6g |
5 +
| **Status** | Draft 0.6h |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
176 unchanged lines
185 185
| `OpRef` (placement or mode reference) | `[bytes(32) change hash, uint index]`; the all-zero hash means this change. |
186 186
| `AtomRef` | `0` for `START`, `1` for `END`, otherwise `[bytes(32) change hash, uint index]` with the all-zero hash meaning this change. |
187 187
| `AtomRange` | `[bytes(32) change hash, uint from, uint to]`, half-open, non-empty. |
188 -
| `View` (in `Op`) | Key 0 bookmarks: array of `[text name, bytes(32) state ref]` sorted by name; key 1 workspaces: array of `[bytes(16) id, bytes(32) change]` sorted by id. Remote and tag namespaces are added in later drafts. Repository format 1 stored `[name, [heads]]`; format 2 stores the state ref. |
188 +
| `View` (in `Op`) | Key 0 bookmarks: array of `[text name, bytes(32) state ref]` sorted by name; key 1 workspaces: array of `[bytes(16) id, bytes(32) change]` sorted by id; key 2 remotes: array of `[text remote, [[text bookmark, bytes(32) state ref]]]`; key 3 policy ref or null; key 4 tags: array of `[text name, bytes(32) state ref]`. Repository format 1 stored `[name, [heads]]`; format 2 stores the state ref. |
189 189
| `Signature` | Map: 0 key id, 1 alg (1 = Ed25519 + ML-DSA-65), 2 Ed25519 bytes(64), 3 ML-DSA-65 bytes(3309). A signature list is a CBOR array of these. |
190 190
| Secret key file | Map: 0 version, 1 key id, 2 Ed25519 seed bytes(32), 3 ML-DSA-65 seed bytes(32). Kept only in the user's key directory. |
191 191
…
212 unchanged lines
404 404
405 405
Every published bookmark update is accompanied by an attestation (§14.4) of kind `ref` that binds the repository ID, the reference name, the previous state, the new state, and a per-reference monotonically increasing `serial`. Servers MUST store the latest attestation per reference and serve it with the reference. Clients MUST verify the attestation against policy before updating a `remotes/` reference and MUST reject an attestation whose `serial` is lower than one they have already recorded for that reference (rollback protection). Clients SHOULD warn when the newest attestation's `hlc.wall_ms` is older than `remote.max-age` (default 30 days) and the remote claims no update.
406 406
407 -
Tags are attestations of kind `tag` and are never updated; a server MUST reject a second attestation for an existing tag name.
407 +
Tags are attestations of kind `tag` and are never updated; a server MUST reject a second attestation for an existing tag name, and any receiver MUST refuse a tag whose name it already holds with a different state. `oxy tag create NAME [CHANGE..]` tags the named state (default the bookmark's heads); tags travel with `refs` and `push` (§16.3), and a policy rule for `tags/<name>` governs who may create them.
408 408
409 409
## 11. Checkpoints
410 410
…
277 unchanged lines
688 688
| `Bundle` | `{0: [[change bytes, [Signature]]], 1: [signed attestation], 2: [[type name, object bytes]]}`; changes in dependency order; objects are chunks, blobs, identities, and states only |
689 689
| `capabilities` response | `{0: [versions], 1: repo id bytes(16), 2: [feature names]}` |
690 690
| Signed policy | `[policy bytes, [Signature]]` |
691 -
| `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 1: repo id, 2: signed policy or null}`; the attestation is present only when it describes the current state |
691 +
| `refs` response | `{0: [{0: name, 1: state ref, 2: [heads], 3: signed attestation or null, 4: last serial or null}], 1: repo id, 2: signed policy or null, 3: [tag]}`; the attestation is present only when it describes the current state; a tag is `{0: name, 1: state ref, 2: [heads], 3: signed attestation or null}` |
692 692
| `negotiate` request | `{0: [have], 1: [want]}`; response `{0: [hashes]}` in dependency order |
693 693
| `fetch` request | `{0: [hashes], 1: max blob size or null}`; response a `Bundle` without blobs above the size |
694 694
| `objects` request | `{0: [refs]}`; response a `Bundle` carrying only objects, a blob accompanied by its chunks |
695 695
| `checkpoint` request | `{0: [heads]}`; response a `Bundle` of objects: the checkpoint of exactly those heads, its payload blob, and the blob's chunks |
696 696
| `shallow` request | `{0: [heads], 1: depth}`; response `{0: [hashes]}`, the changes within `depth` steps of the heads, dependencies first |
697 -
| `push` request | `{0: Bundle, 1: [{0: name, 1: old state, 2: new state, 3: force, 4: signed attestation or null}], 2: signed policy or null}`; response `{0: [{0: name, 1: ok, 2: reason}]}`, where a result named `policy` reports the policy update |
697 +
| `push` request | `{0: Bundle, 1: [{0: name, 1: old state, 2: new state, 3: force, 4: signed attestation or null}], 2: signed policy or null, 3: [tag]}`; response `{0: [{0: name, 1: ok, 2: reason}]}`, where a result named `policy` reports the policy update and `tags/<name>` each tag |
698 698
699 699
The HTTP content type is `application/oxy-cbor`.
700 700
…
141 unchanged lines
842 842
843 843
---
844 844
845 -
*Changelog.* Draft 0.6g (2026-09-27): shallow repositories as checkpoint plus history window, the `checkpoint` and `shallow` messages, deepening (§16.3, §17).
845 +
*Changelog.* Draft 0.6h (2026-09-27): tags in views, on the wire, and under policy (§5.5, §10.2, §16.3).
846 +
847 +
Draft 0.6g (2026-09-27): shallow repositories as checkpoint plus history window, the `checkpoint` and `shallow` messages, deepening (§16.3, §17).
846 848
847 849
Draft 0.6f (2026-09-27): sparse checkout semantics (§17).
848 850
…
27 unchanged lines
modified src/cli/mod.rs +21 −0
…
107 unchanged lines
108 108
        #[command(subcommand)]
109 109
        cmd: OpCmd,
110 110
    },
111 +
    /// Tags: immutable named states
112 +
    Tag {
113 +
        #[command(subcommand)]
114 +
        cmd: TagCmd,
115 +
    },
111 116
    /// Manage remotes
112 117
    Remote {
113 118
        #[command(subcommand)]
…
60 unchanged lines
174 179
}
175 180
176 181
#[derive(Subcommand)]
182 +
enum TagCmd {
183 +
    /// Tag the state of CHANGE... (default: the bookmark heads)
184 +
    Create {
185 +
        name: String,
186 +
        changes: Vec<String>,
187 +
        #[arg(short, long, default_value = "")]
188 +
        message: String,
189 +
    },
190 +
    List,
191 +
}
192 +
193 +
#[derive(Subcommand)]
177 194
enum RemoteCmd {
178 195
    Add { name: String, url: String },
179 196
    Remove { name: String },
…
77 unchanged lines
257 274
        Cmd::Clone { url, dest, max_blob, sparse, depth } => sync::clone(&url, dest, max_blob.as_deref(), &sparse, depth),
258 275
        Cmd::Deepen { depth, remote } => sync::deepen(&remote, depth),
259 276
        Cmd::Sparse { paths } => workspace::sparse(&paths),
277 +
        Cmd::Tag { cmd } => match cmd {
278 +
            TagCmd::Create { name, changes, message } => workspace::tag_create(&name, &changes, &message),
279 +
            TagCmd::List => workspace::tag_list(),
280 +
        },
260 281
        Cmd::Remote { cmd } => match cmd {
261 282
            RemoteCmd::Add { name, url } => sync::remote_add(&name, &url),
262 283
            RemoteCmd::Remove { name } => sync::remote_remove(&name),
…
23 unchanged lines
modified src/cli/sync.rs +3 −0
…
66 unchanged lines
67 67
    for (name, heads, n) in &report.refs {
68 68
        println!("{} {}, {}", ui::id(format!("{remote}/{name}")), plural(heads.len(), "head"), plural(*n, "new change"));
69 69
    }
70 +
    if report.new_tags > 0 {
71 +
        println!("{}", ui::dim(format!("{} adopted", plural(report.new_tags, "tag"))));
72 +
    }
70 73
    if report.unverified {
71 74
        eprintln!("{} {remote} has no trust policy; nothing fetched from it was verified", ui::warn("warning:"));
72 75
    }
…
109 unchanged lines
modified src/cli/workspace.rs +48 −1
…
132 unchanged lines
133 133
    let heads = if changes.is_empty() {
134 134
        repo.bookmark_heads()?
135 135
    } else {
136 -
        changes.iter().map(|c| repo.resolve_change(c)).collect::<Result<Vec<_>, _>>()?
136 +
        let mut heads = Vec::new();
137 +
        for c in changes {
138 +
            heads.extend(repo.resolve_heads(c)?);
139 +
        }
140 +
        heads
137 141
    };
138 142
    let spec = if changes.is_empty() { repo.config.bookmark.clone() } else { changes.join(" ") };
139 143
    let sp = ui::spinner(&format!("Checking out {spec}…"));
…
161 unchanged lines
301 305
    sp.done(&format!("{} {}: {} written, {} removed", ui::ok("Sparse filter set to"), ui::path(paths.join(",")), plural(r.written, "file"), r.removed));
302 306
    Ok(0)
303 307
}
308 +
309 +
pub fn tag_create(name: &str, changes: &[String], message: &str) -> Result<i32> {
310 +
    let repo = open()?;
311 +
    repo.snapshot("oxy tag")?;
312 +
    let heads = if changes.is_empty() {
313 +
        repo.bookmark_heads()?
314 +
    } else {
315 +
        let mut heads = Vec::new();
316 +
        for c in changes {
317 +
            heads.extend(repo.resolve_heads(c)?);
318 +
        }
319 +
        heads
320 +
    };
321 +
    let t = repo.tag_create(name, &heads, message)?;
322 +
    println!(
323 +
        "{} {} -> {} ({}){}",
324 +
        ui::ok("Tagged"),
325 +
        ui::id(&t.name),
326 +
        ui::hash(format!("#{}", t.state.short(12))),
327 +
        plural(t.heads.len(), "head"),
328 +
        t.signer.map(|s| format!(", signed by {s}")).unwrap_or_else(|| ui::dim(", unsigned"))
329 +
    );
330 +
    Ok(0)
331 +
}
332 +
333 +
pub fn tag_list() -> Result<i32> {
334 +
    let repo = open()?;
335 +
    let tags = repo.tags()?;
336 +
    if tags.is_empty() {
337 +
        println!("{}", ui::dim("No tags."));
338 +
    }
339 +
    for t in tags {
340 +
        println!(
341 +
            "{} {} {}{}{}",
342 +
            ui::id(&t.name),
343 +
            ui::hash(format!("#{}", t.state.short(12))),
344 +
            ui::dim(format!("({})", plural(t.heads.len(), "head"))),
345 +
            if t.message.is_empty() { String::new() } else { format!("  {}", t.message.lines().next().unwrap_or("")) },
346 +
            t.signer.map(|s| ui::dim(format!("  [{s}]"))).unwrap_or_default()
347 +
        );
348 +
    }
349 +
    Ok(0)
350 +
}