OXYGENHUB

Execution receipts: the receipt object and attestation fc1a466a1e66

OxygenFlow's receipt becomes an Oxygen object type. An attestation of kind
receipt binds one to the state it ran on and is signed by the runner. A
signed receipt carries the runner's identity, so it verifies on its own.
Repositories keep receipts by state, and gc keeps them with their changes.
oxy receipt list, show, export, verify and import. Spec §14.6, Draft 0.8o.

closes #23
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-09-29 07:27 UTC
Signature
✔ signed by Andy McCall
Hash
fc1a466a1e66c2759e326cc3662ab9648f2e962dabe288a29e32b1483dd7691a
Depends on
132b884b9a8d 135212de5370 1d27d0a2b3c1 28fc23d7706a 28fee1c9eb6c 387335ff7f4d 4dcb8c0916b7 4dee277c327a 5b9c6ad45f6e 75036530b621 75167672ebcd 83d61e803ccd 83e0677b07fe 86e682c242c6 8f6b03b28e40 982864068a59 ba160f60219e c6aa5bd9d1c8 d82e44097323 ee06a6c557b7 fce44ae9ad29 ff0af1ee299d
Depended on by
284fe47d98b9 642da2636de2 ef7d4ea6f33b e59512f2f075 a2387d547373 f53cbe7a204e c63fed2e0465 02e691881a4e 5f661562b3a4 a39754d02c58 3a4b3e018326 b582948f1912 b689779e9e74 648189388f1a 47ce8b8e2d4b 4fe7d6045860 6f8aeb996558 dfb23a3a8cfa 9df6217f5f47 7dc9f65b6546 155313e68e15
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
50 atoms, 3 tree operations, 16 files

Browse the tree at this change

added crates/oxy-core/src/receipt.rs +275 −0
1 +
//! Execution receipts (spec §14.6): what a runner ran, in what, on which
2 +
//! inputs and with what result. A receipt names no state, so the same
3 +
//! receipt serves every state whose scope is unchanged; an attestation of
4 +
//! kind `receipt`, signed by the runner, binds it to the state it ran on.
5 +
6 +
use crate::attest::{Attest, AttestBody};
7 +
use crate::cbor::{self, Value};
8 +
use crate::hash::{hash_object, ObjectRef, ObjectType};
9 +
use crate::identity::{verify, Domain, Identity, SecretKey, Signature};
10 +
use crate::objects::{array, bytes, check_version, field, map, oref, text, uint, Hlc, ObjectError};
11 +
12 +
#[derive(Clone, Debug, PartialEq, Eq)]
13 +
pub struct Receipt {
14 +
    pub job_name: String,
15 +
    /// The pipeline definition the job came from.
16 +
    pub pipeline_hash: ObjectRef,
17 +
    /// BLAKE3 over the sorted (path, content) of the files the job's scope
18 +
    /// matches; the whole tree when it has no scope.
19 +
    pub scope_tree_hash: ObjectRef,
20 +
    /// Artifacts and other inputs consumed beyond the tree.
21 +
    pub inputs: Vec<ObjectRef>,
22 +
    /// What the job ran in: isolation tier, rootfs, argv and environment.
23 +
    pub environment: ObjectRef,
24 +
    /// Blobs the job produced.
25 +
    pub outputs: Vec<ObjectRef>,
26 +
    pub exit_code: u32,
27 +
    pub duration_ms: u64,
28 +
}
29 +
30 +
fn refs(v: &[ObjectRef]) -> Value {
31 +
    Value::Array(v.iter().map(|h| Value::Bytes(h.0.to_vec())).collect())
32 +
}
33 +
34 +
fn refs_from(v: &Value, what: &str) -> Result<Vec<ObjectRef>, ObjectError> {
35 +
    array(v, what)?.iter().map(|h| oref(h, what)).collect()
36 +
}
37 +
38 +
impl Receipt {
39 +
    pub fn passed(&self) -> bool {
40 +
        self.exit_code == 0
41 +
    }
42 +
43 +
    pub fn to_value(&self) -> Value {
44 +
        Value::map([
45 +
            (0, Value::Uint(1)),
46 +
            (1, Value::Text(self.job_name.clone())),
47 +
            (2, Value::Bytes(self.pipeline_hash.0.to_vec())),
48 +
            (3, Value::Bytes(self.scope_tree_hash.0.to_vec())),
49 +
            (4, refs(&self.inputs)),
50 +
            (5, Value::Bytes(self.environment.0.to_vec())),
51 +
            (6, refs(&self.outputs)),
52 +
            (7, Value::Uint(self.exit_code as u64)),
53 +
            (8, Value::Uint(self.duration_ms)),
54 +
        ])
55 +
    }
56 +
57 +
    pub fn encode(&self) -> Vec<u8> {
58 +
        cbor::encode(&self.to_value())
59 +
    }
60 +
61 +
    pub fn hash(&self) -> ObjectRef {
62 +
        hash_object(ObjectType::Receipt, &self.encode())
63 +
    }
64 +
65 +
    pub fn decode(b: &[u8]) -> Result<Receipt, ObjectError> {
66 +
        let v = cbor::decode(b)?;
67 +
        let m = map(&v, "receipt")?;
68 +
        check_version(m)?;
69 +
        let exit_code = uint(field(m, 7, "exit_code")?, "exit_code")?;
70 +
        Ok(Receipt {
71 +
            job_name: text(field(m, 1, "job_name")?, "job_name")?.to_owned(),
72 +
            pipeline_hash: oref(field(m, 2, "pipeline_hash")?, "pipeline_hash")?,
73 +
            scope_tree_hash: oref(field(m, 3, "scope_tree_hash")?, "scope_tree_hash")?,
74 +
            inputs: refs_from(field(m, 4, "inputs")?, "inputs")?,
75 +
            environment: oref(field(m, 5, "environment")?, "environment")?,
76 +
            outputs: refs_from(field(m, 6, "outputs")?, "outputs")?,
77 +
            exit_code: u32::try_from(exit_code)
78 +
                .map_err(|_| ObjectError::Schema("exit_code: expected u32".into()))?,
79 +
            duration_ms: uint(field(m, 8, "duration_ms")?, "duration_ms")?,
80 +
        })
81 +
    }
82 +
}
83 +
84 +
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
85 +
pub enum ReceiptError {
86 +
    #[error("the attestation is not of kind receipt")]
87 +
    NotReceipt,
88 +
    #[error("the attestation names a different receipt")]
89 +
    WrongReceipt,
90 +
    #[error("no signature by the runner {0}")]
91 +
    Unsigned(String),
92 +
    #[error("the runner's signature does not verify: {0}")]
93 +
    BadSignature(String),
94 +
    #[error("{0}")]
95 +
    Schema(String),
96 +
}
97 +
98 +
impl From<ObjectError> for ReceiptError {
99 +
    fn from(e: ObjectError) -> Self {
100 +
        ReceiptError::Schema(e.to_string())
101 +
    }
102 +
}
103 +
104 +
/// A receipt as a runner hands it over: the receipt, the attestation
105 +
/// binding it to a state, the runner's signature over that attestation, and
106 +
/// the runner's identity, so it verifies with nothing else.
107 +
#[derive(Clone, Debug, PartialEq, Eq)]
108 +
pub struct SignedReceipt {
109 +
    pub receipt: Receipt,
110 +
    pub attest: Attest,
111 +
    pub sigs: Vec<Signature>,
112 +
    pub runner: Identity,
113 +
}
114 +
115 +
impl SignedReceipt {
116 +
    /// Bind `receipt` to `state` of repository `repo` for run `run` (a URL
117 +
    /// or other name of the run), signed by `runner` with `sk`.
118 +
    pub fn sign(
119 +
        receipt: Receipt,
120 +
        repo: [u8; 16],
121 +
        state: ObjectRef,
122 +
        run: &str,
123 +
        runner: &Identity,
124 +
        sk: &SecretKey,
125 +
    ) -> SignedReceipt {
126 +
        let attest = Attest {
127 +
            repo,
128 +
            hlc: Hlc::now(),
129 +
            body: AttestBody::Receipt {
130 +
                receipt: receipt.hash(),
131 +
                state,
132 +
                run: run.into(),
133 +
            },
134 +
        };
135 +
        let sig = sk.sign(Domain::Attest, &runner.hash(), &attest.hash());
136 +
        SignedReceipt {
137 +
            receipt,
138 +
            attest,
139 +
            sigs: vec![sig],
140 +
            runner: runner.clone(),
141 +
        }
142 +
    }
143 +
144 +
    /// The state the receipt was made on.
145 +
    pub fn state(&self) -> ObjectRef {
146 +
        match &self.attest.body {
147 +
            AttestBody::Receipt { state, .. } => *state,
148 +
            _ => ObjectRef([0; 32]),
149 +
        }
150 +
    }
151 +
152 +
    pub fn run(&self) -> &str {
153 +
        match &self.attest.body {
154 +
            AttestBody::Receipt { run, .. } => run,
155 +
            _ => "",
156 +
        }
157 +
    }
158 +
159 +
    /// The attestation is a receipt attestation for this receipt, and the
160 +
    /// runner signed it. Whether to trust the runner is the reader's call.
161 +
    pub fn verify(&self) -> Result<(), ReceiptError> {
162 +
        let AttestBody::Receipt { receipt, .. } = &self.attest.body else {
163 +
            return Err(ReceiptError::NotReceipt);
164 +
        };
165 +
        if *receipt != self.receipt.hash() {
166 +
            return Err(ReceiptError::WrongReceipt);
167 +
        }
168 +
        let key = self.runner.hash();
169 +
        let target = self.attest.hash();
170 +
        let sig = self
171 +
            .sigs
172 +
            .iter()
173 +
            .find(|s| s.key == key)
174 +
            .ok_or_else(|| ReceiptError::Unsigned(key.short(12)))?;
175 +
        verify(&self.runner, sig, Domain::Attest, &target)
176 +
            .map_err(|e| ReceiptError::BadSignature(e.to_string()))
177 +
    }
178 +
179 +
    /// `[receipt, attest, [Signature], identity]`, each object as its bytes.
180 +
    pub fn encode(&self) -> Vec<u8> {
181 +
        cbor::encode(&Value::Array(vec![
182 +
            Value::Bytes(self.receipt.encode()),
183 +
            Value::Bytes(self.attest.encode()),
184 +
            Value::Array(self.sigs.iter().map(|s| s.to_value()).collect()),
185 +
            Value::Bytes(self.runner.encode()),
186 +
        ]))
187 +
    }
188 +
189 +
    pub fn decode(b: &[u8]) -> Result<SignedReceipt, ReceiptError> {
190 +
        let v = cbor::decode(b).map_err(ObjectError::from)?;
191 +
        let a = array(&v, "signed receipt")?;
192 +
        if a.len() != 4 {
193 +
            return Err(ReceiptError::Schema(
194 +
                "signed receipt: expected [receipt, attest, sigs, identity]".into(),
195 +
            ));
196 +
        }
197 +
        Ok(SignedReceipt {
198 +
            receipt: Receipt::decode(bytes(&a[0], "receipt")?)?,
199 +
            attest: Attest::decode(bytes(&a[1], "attest")?)?,
200 +
            sigs: array(&a[2], "sigs")?
201 +
                .iter()
202 +
                .map(Signature::from_value)
203 +
                .collect::<Result<_, _>>()?,
204 +
            runner: Identity::decode(bytes(&a[3], "identity")?)?,
205 +
        })
206 +
    }
207 +
}
208 +
209 +
#[cfg(test)]
210 +
mod tests {
211 +
    use super::*;
212 +
213 +
    pub(crate) fn sample() -> Receipt {
214 +
        Receipt {
215 +
            job_name: "test".into(),
216 +
            pipeline_hash: ObjectRef([1; 32]),
217 +
            scope_tree_hash: ObjectRef([2; 32]),
218 +
            inputs: vec![],
219 +
            environment: ObjectRef([3; 32]),
220 +
            outputs: vec![ObjectRef([4; 32])],
221 +
            exit_code: 0,
222 +
            duration_ms: 1500,
223 +
        }
224 +
    }
225 +
226 +
    #[test]
227 +
    fn receipts_round_trip_and_hash_as_receipt_objects() {
228 +
        let r = sample();
229 +
        assert_eq!(Receipt::decode(&r.encode()).unwrap(), r);
230 +
        let mut h = blake3::Hasher::new();
231 +
        h.update(format!("oxy1 receipt {}\0", r.encode().len()).as_bytes());
232 +
        h.update(&r.encode());
233 +
        assert_eq!(
234 +
            r.hash().0,
235 +
            *h.finalize().as_bytes(),
236 +
            "OxygenFlow's receipt hash"
237 +
        );
238 +
        assert!(r.passed());
239 +
    }
240 +
241 +
    #[test]
242 +
    fn a_signed_receipt_verifies_and_tampering_does_not() {
243 +
        let sk = SecretKey::generate();
244 +
        let runner = sk.identity("earth-1", "runner@earth", 1);
245 +
        let sr = SignedReceipt::sign(
246 +
            sample(),
247 +
            [7; 16],
248 +
            ObjectRef([9; 32]),
249 +
            "andy/demo#3",
250 +
            &runner,
251 +
            &sk,
252 +
        );
253 +
        sr.verify().unwrap();
254 +
        let back = SignedReceipt::decode(&sr.encode()).unwrap();
255 +
        assert_eq!(back, sr);
256 +
        assert_eq!(back.state(), ObjectRef([9; 32]));
257 +
        assert_eq!(back.run(), "andy/demo#3");
258 +
259 +
        let mut forged = sr.clone();
260 +
        forged.receipt.exit_code = 0;
261 +
        forged.receipt.duration_ms = 1;
262 +
        assert_eq!(forged.verify(), Err(ReceiptError::WrongReceipt));
263 +
264 +
        let mut moved = sr.clone();
265 +
        if let AttestBody::Receipt { state, .. } = &mut moved.attest.body {
266 +
            *state = ObjectRef([8; 32]);
267 +
        }
268 +
        assert!(matches!(moved.verify(), Err(ReceiptError::BadSignature(_))));
269 +
270 +
        let other = SecretKey::generate().identity("mallory", "m@x", 1);
271 +
        let mut swapped = sr.clone();
272 +
        swapped.runner = other;
273 +
        assert!(matches!(swapped.verify(), Err(ReceiptError::Unsigned(_))));
274 +
    }
275 +
}
added crates/oxy-core/src/repo/receipts.rs +123 −0
1 +
//! Execution receipts in a repository (spec §14.6): kept beside the state
2 +
//! they were made on, with the runner's identity and signature.
3 +
4 +
use super::*;
5 +
use crate::receipt::SignedReceipt;
6 +
7 +
/// A stored receipt and what verifying it found.
8 +
#[derive(Clone, Debug)]
9 +
pub struct ReceiptInfo {
10 +
    /// The receipt attestation's ObjectRef, which names it.
11 +
    pub attest: ObjectRef,
12 +
    pub signed: SignedReceipt,
13 +
    /// Why it does not verify, if it does not.
14 +
    pub problem: Option<String>,
15 +
    /// The runner's key was revoked (spec §14.2).
16 +
    pub revoked: bool,
17 +
}
18 +
19 +
impl Repo {
20 +
    /// Verify `sr` and keep it: the receipt, the attestation with its
21 +
    /// signatures, and the runner's identity. Returns the attestation's
22 +
    /// ObjectRef. Receipts for another repository are refused.
23 +
    pub fn add_receipt(&self, sr: &SignedReceipt) -> Result<ObjectRef> {
24 +
        sr.verify()
25 +
            .map_err(|e| RepoError::Msg(format!("receipt does not verify: {e}")))?;
26 +
        if sr.attest.repo != self.id()? {
27 +
            return fail("that receipt is for another repository");
28 +
        }
29 +
        self.store.put_identity(&sr.runner)?;
30 +
        self.store.put_receipt(&sr.receipt)?;
31 +
        let r = self.store.put_attest(&sr.attest)?;
32 +
        for sig in &sr.sigs {
33 +
            self.store.put_signature(&r, sig)?;
34 +
        }
35 +
        self.store.index_receipt(&sr.state(), &r)?;
36 +
        Ok(r)
37 +
    }
38 +
39 +
    /// The receipt named by attestation `r`, verified again from the store.
40 +
    pub fn receipt(&self, r: &ObjectRef) -> Result<ReceiptInfo> {
41 +
        let attest = self.store.get_attest(r)?;
42 +
        let AttestBody::Receipt { receipt, .. } = &attest.body else {
43 +
            return fail(format!("#{} is not a receipt", r.short(12)));
44 +
        };
45 +
        let receipt = self.store.get_receipt(receipt)?;
46 +
        let sigs = self.store.signatures(r)?;
47 +
        let Some(key) = sigs.first().map(|s| s.key) else {
48 +
            return fail(format!("receipt #{} has no signature", r.short(12)));
49 +
        };
50 +
        let runner = self.store.get_identity(&key)?;
51 +
        let signed = SignedReceipt {
52 +
            receipt,
53 +
            attest,
54 +
            sigs,
55 +
            runner,
56 +
        };
57 +
        let problem = signed.verify().err().map(|e| e.to_string());
58 +
        Ok(ReceiptInfo {
59 +
            attest: *r,
60 +
            revoked: self.is_revoked(&key)?,
61 +
            signed,
62 +
            problem,
63 +
        })
64 +
    }
65 +
66 +
    /// Every receipt kept for the state of `heads`, oldest first.
67 +
    pub fn receipts(&self, heads: &[ChangeHash]) -> Result<Vec<ReceiptInfo>> {
68 +
        let state = hash_object(
69 +
            ObjectType::State,
70 +
            &StateObject::new(heads.to_vec()).encode(),
71 +
        );
72 +
        let mut out = self
73 +
            .store
74 +
            .receipts_for(&state)?
75 +
            .iter()
76 +
            .map(|r| self.receipt(r))
77 +
            .collect::<Result<Vec<_>>>()?;
78 +
        out.sort_by_key(|i| i.signed.attest.hlc);
79 +
        Ok(out)
80 +
    }
81 +
82 +
    /// Receipts to keep through garbage collection: those whose state's
83 +
    /// heads `keep` holds, with their receipts, runners and outputs.
84 +
    pub(crate) fn reachable_receipts(&self, keep: &mut HashSet<ObjectRef>) -> Result<()> {
85 +
        for state in self.store.receipt_states()? {
86 +
            let heads = match self.store.get_state(&state) {
87 +
                Ok(s) => s.heads,
88 +
                // A receipt may arrive before its state object; its heads
89 +
                // are unknown, so keep it.
90 +
                Err(StoreError::NotFound(_)) => vec![],
91 +
                Err(e) => return Err(e.into()),
92 +
            };
93 +
            if !heads.iter().all(|h| keep.contains(h)) {
94 +
                continue;
95 +
            }
96 +
            keep.insert(state);
97 +
            for a in self.store.receipts_for(&state)? {
98 +
                keep.insert(a);
99 +
                for sig in self.store.signatures(&a)? {
100 +
                    keep.insert(sig.key);
101 +
                }
102 +
                let Ok(attest) = self.store.get_attest(&a) else {
103 +
                    continue;
104 +
                };
105 +
                let AttestBody::Receipt { receipt, .. } = attest.body else {
106 +
                    continue;
107 +
                };
108 +
                keep.insert(receipt);
109 +
                let Ok(r) = self.store.get_receipt(&receipt) else {
110 +
                    continue;
111 +
                };
112 +
                for b in r.outputs.iter().chain(&r.inputs) {
113 +
                    if keep.insert(*b) {
114 +
                        if let Ok(bytes) = self.store.get_typed(b, ObjectType::Blob) {
115 +
                            keep.extend(Blob::decode(&bytes)?.chunks);
116 +
                        }
117 +
                    }
118 +
                }
119 +
            }
120 +
        }
121 +
        Ok(())
122 +
    }
123 +
}
added src/cli/receipt.rs +221 −0
1 +
//! Execution receipts (spec §14.6): list, show, verify and import the
2 +
//! signed receipts runners make.
3 +
4 +
use super::json;
5 +
use super::open;
6 +
use super::ui;
7 +
use anyhow::{bail, Result};
8 +
use oxy_core::receipt::SignedReceipt;
9 +
use oxy_core::repo::ReceiptInfo;
10 +
11 +
fn status(problem: Option<&str>, revoked: bool) -> String {
12 +
    match (problem, revoked) {
13 +
        (Some(p), _) => ui::err(format!("{} {p}", ui::glyph("✘", "x"))),
14 +
        (None, true) => ui::warn(format!("{} runner key revoked", ui::glyph("!", "!"))),
15 +
        (None, false) => ui::ok(format!("{} signed", ui::glyph("✔", "v"))),
16 +
    }
17 +
}
18 +
19 +
fn line(sr: &SignedReceipt, problem: Option<&str>, revoked: bool) -> String {
20 +
    let r = &sr.receipt;
21 +
    let result = if r.passed() {
22 +
        ui::ok(format!("{} passed", ui::glyph("●", "*")))
23 +
    } else {
24 +
        ui::err(format!("{} exit {}", ui::glyph("●", "*"), r.exit_code))
25 +
    };
26 +
    format!(
27 +
        "{} {}{}{}{}{}",
28 +
        result,
29 +
        ui::subject(&r.job_name),
30 +
        ui::sep() + &ui::dim(format!("{:.1} s", r.duration_ms as f64 / 1000.0)),
31 +
        ui::sep() + &ui::author(&sr.runner.name),
32 +
        ui::sep() + &status(problem, revoked),
33 +
        ui::sep() + &ui::dim(ui::ago(sr.attest.hlc.wall_ms)),
34 +
    )
35 +
}
36 +
37 +
fn to_json(
38 +
    attest: Option<String>,
39 +
    sr: &SignedReceipt,
40 +
    problem: Option<&str>,
41 +
    revoked: bool,
42 +
) -> String {
43 +
    let r = &sr.receipt;
44 +
    let refs = |v: &[oxy_core::hash::ObjectRef]| json::arr(v.iter().map(|h| json::s(&h.to_hex())));
45 +
    json::obj([
46 +
        ("attest", json::opt(attest.as_ref(), |a| json::s(a))),
47 +
        ("receipt", json::s(&r.hash().to_hex())),
48 +
        ("state", json::s(&sr.state().to_hex())),
49 +
        ("run", json::s(sr.run())),
50 +
        ("job", json::s(&r.job_name)),
51 +
        ("exit_code", json::n(r.exit_code)),
52 +
        ("duration_ms", json::n(r.duration_ms)),
53 +
        ("pipeline", json::s(&r.pipeline_hash.to_hex())),
54 +
        ("scope", json::s(&r.scope_tree_hash.to_hex())),
55 +
        ("environment", json::s(&r.environment.to_hex())),
56 +
        ("inputs", refs(&r.inputs)),
57 +
        ("outputs", refs(&r.outputs)),
58 +
        ("runner", json::s(&sr.runner.hash().to_hex())),
59 +
        ("runner_name", json::s(&sr.runner.name)),
60 +
        ("made_ms", json::n(sr.attest.hlc.wall_ms)),
61 +
        ("valid", json::b(problem.is_none())),
62 +
        ("problem", json::opt(problem, json::s)),
63 +
        ("revoked", json::b(revoked)),
64 +
    ])
65 +
}
66 +
67 +
fn detail(sr: &SignedReceipt, problem: Option<&str>, revoked: bool) {
68 +
    let r = &sr.receipt;
69 +
    println!("{}", line(sr, problem, revoked));
70 +
    println!(
71 +
        "{}",
72 +
        ui::kv("run", if sr.run().is_empty() { "-" } else { sr.run() })
73 +
    );
74 +
    println!(
75 +
        "{}",
76 +
        ui::kv("state", ui::hash(format!("#{}", sr.state().short(12))))
77 +
    );
78 +
    println!(
79 +
        "{}",
80 +
        ui::kv("receipt", ui::hash(format!("#{}", r.hash().short(12))))
81 +
    );
82 +
    println!(
83 +
        "{}",
84 +
        ui::kv("pipeline", ui::hash(r.pipeline_hash.short(12)))
85 +
    );
86 +
    println!("{}", ui::kv("scope", ui::hash(r.scope_tree_hash.short(12))));
87 +
    println!(
88 +
        "{}",
89 +
        ui::kv("environment", ui::hash(r.environment.short(12)))
90 +
    );
91 +
    println!(
92 +
        "{}",
93 +
        ui::kv(
94 +
            "runner",
95 +
            format!("{} {}", sr.runner.name, ui::dim(sr.runner.hash().short(12)))
96 +
        )
97 +
    );
98 +
    for (what, v) in [("input", &r.inputs), ("output", &r.outputs)] {
99 +
        for h in v.iter() {
100 +
            println!("{}", ui::kv(what, ui::hash(h.short(12))));
101 +
        }
102 +
    }
103 +
}
104 +
105 +
/// `oxy receipt list [REV]`: the receipts kept for a state.
106 +
pub fn list(rev: Option<&str>, as_json: bool) -> Result<i32> {
107 +
    let repo = open()?;
108 +
    let heads = match rev {
109 +
        Some(r) => repo.resolve_heads(r)?,
110 +
        None => repo.bookmark_heads()?,
111 +
    };
112 +
    let found = repo.receipts(&heads)?;
113 +
    if as_json {
114 +
        println!(
115 +
            "{}",
116 +
            json::arr(found.iter().map(|i| to_json(
117 +
                Some(i.attest.to_hex()),
118 +
                &i.signed,
119 +
                i.problem.as_deref(),
120 +
                i.revoked
121 +
            )))
122 +
        );
123 +
        return Ok(0);
124 +
    }
125 +
    if found.is_empty() {
126 +
        println!("{}", ui::dim("No receipts for this state."));
127 +
    }
128 +
    for i in &found {
129 +
        println!(
130 +
            "{} {}",
131 +
            ui::hash(format!("#{}", i.attest.short(12))),
132 +
            line(&i.signed, i.problem.as_deref(), i.revoked)
133 +
        );
134 +
    }
135 +
    Ok(0)
136 +
}
137 +
138 +
fn find(repo: &oxy_core::repo::Repo, prefix: &str, rev: Option<&str>) -> Result<ReceiptInfo> {
139 +
    let heads = match rev {
140 +
        Some(r) => repo.resolve_heads(r)?,
141 +
        None => repo.bookmark_heads()?,
142 +
    };
143 +
    let p = prefix.trim_start_matches('#').to_ascii_lowercase();
144 +
    let mut hits: Vec<ReceiptInfo> = repo
145 +
        .receipts(&heads)?
146 +
        .into_iter()
147 +
        .filter(|i| i.attest.to_hex().starts_with(&p))
148 +
        .collect();
149 +
    match hits.len() {
150 +
        1 => Ok(hits.remove(0)),
151 +
        0 => bail!("no receipt #{p} for this state; `oxy receipt list` shows them"),
152 +
        n => bail!("#{p} matches {n} receipts; give more of the hash"),
153 +
    }
154 +
}
155 +
156 +
/// `oxy receipt show HASH [--rev REV]`.
157 +
pub fn show(prefix: &str, rev: Option<&str>, as_json: bool) -> Result<i32> {
158 +
    let repo = open()?;
159 +
    let i = find(&repo, prefix, rev)?;
160 +
    if as_json {
161 +
        println!(
162 +
            "{}",
163 +
            to_json(
164 +
                Some(i.attest.to_hex()),
165 +
                &i.signed,
166 +
                i.problem.as_deref(),
167 +
                i.revoked
168 +
            )
169 +
        );
170 +
    } else {
171 +
        detail(&i.signed, i.problem.as_deref(), i.revoked);
172 +
    }
173 +
    Ok(if i.problem.is_some() { 1 } else { 0 })
174 +
}
175 +
176 +
/// `oxy receipt export HASH FILE`: the signed receipt, to hand to anyone.
177 +
pub fn export(prefix: &str, file: &str, rev: Option<&str>) -> Result<i32> {
178 +
    let repo = open()?;
179 +
    let i = find(&repo, prefix, rev)?;
180 +
    std::fs::write(file, i.signed.encode())?;
181 +
    println!(
182 +
        "{} {} {}",
183 +
        ui::done("Exported receipt"),
184 +
        ui::hash(format!("#{}", i.attest.short(12))),
185 +
        ui::path(file)
186 +
    );
187 +
    Ok(0)
188 +
}
189 +
190 +
fn read(file: &str) -> Result<SignedReceipt> {
191 +
    let bytes = std::fs::read(file).map_err(|e| anyhow::anyhow!("{file}: {e}"))?;
192 +
    SignedReceipt::decode(&bytes).map_err(|e| anyhow::anyhow!("{file}: not a signed receipt: {e}"))
193 +
}
194 +
195 +
/// `oxy receipt verify FILE`: check a signed receipt without keeping it.
196 +
pub fn verify(file: &str, as_json: bool) -> Result<i32> {
197 +
    let sr = read(file)?;
198 +
    let problem = sr.verify().err().map(|e| e.to_string());
199 +
    if as_json {
200 +
        println!("{}", to_json(None, &sr, problem.as_deref(), false));
201 +
    } else {
202 +
        detail(&sr, problem.as_deref(), false);
203 +
    }
204 +
    Ok(if problem.is_some() { 1 } else { 0 })
205 +
}
206 +
207 +
/// `oxy receipt import FILE`: verify a signed receipt and keep it.
208 +
pub fn import(file: &str) -> Result<i32> {
209 +
    let repo = open()?;
210 +
    let sr = read(file)?;
211 +
    let r = repo.add_receipt(&sr)?;
212 +
    println!(
213 +
        "{} {} {} {} {}",
214 +
        ui::done("Kept receipt"),
215 +
        ui::hash(format!("#{}", r.short(12))),
216 +
        ui::subject(&sr.receipt.job_name),
217 +
        ui::dim("by"),
218 +
        ui::author(&sr.runner.name)
219 +
    );
220 +
    Ok(0)
221 +
}
modified crates/oxy-core/src/attest.rs +23 −1
1 1
//! Attestations (spec §14.4): signed statements about references and batches
2 -
//! of changes. Only the `ref` and `seal` kinds exist so far.
2 +
//! of changes, and runs (receipts, §14.6).
3 3
4 4
use crate::cbor::{self, Value};
5 5
use crate::hash::{hash_object, ChangeHash, KeyId, ObjectRef, ObjectType};
…
24 unchanged lines
30 30
    Rotate { new: KeyId },
31 31
    /// `key` must no longer be trusted (spec §14.2).
32 32
    Revoke { key: KeyId, reason: String },
33 +
    /// A runner ran the job of `receipt` on `state` (spec §14.6); `run`
34 +
    /// names the run, usually by URL.
35 +
    Receipt {
36 +
        receipt: ObjectRef,
37 +
        state: ObjectRef,
38 +
        run: String,
39 +
    },
33 40
}
34 41
35 42
#[derive(Clone, Debug, PartialEq, Eq)]
…
12 unchanged lines
48 55
            AttestBody::Tag { .. } => "tag",
49 56
            AttestBody::Rotate { .. } => "rotate",
50 57
            AttestBody::Revoke { .. } => "revoke",
58 +
            AttestBody::Receipt { .. } => "receipt",
51 59
        }
52 60
    }
53 61
…
37 unchanged lines
91 99
            AttestBody::Revoke { key, reason } => Value::map([
92 100
                (0, Value::Bytes(key.0.to_vec())),
93 101
                (1, Value::Text(reason.clone())),
102 +
            ]),
103 +
            AttestBody::Receipt {
104 +
                receipt,
105 +
                state,
106 +
                run,
107 +
            } => Value::map([
108 +
                (0, Value::Bytes(receipt.0.to_vec())),
109 +
                (1, Value::Bytes(state.0.to_vec())),
110 +
                (2, Value::Text(run.clone())),
94 111
            ]),
95 112
        };
96 113
        Value::map([
…
56 unchanged lines
153 170
            "revoke" => AttestBody::Revoke {
154 171
                key: oref(field(b, 0, "key")?, "key")?,
155 172
                reason: text(field(b, 1, "reason")?, "reason")?.to_owned(),
173 +
            },
174 +
            "receipt" => AttestBody::Receipt {
175 +
                receipt: oref(field(b, 0, "receipt")?, "receipt")?,
176 +
                state: oref(field(b, 1, "state")?, "state")?,
177 +
                run: text(field(b, 2, "run")?, "run")?.to_owned(),
156 178
            },
157 179
            other => {
158 180
                return Err(ObjectError::Schema(format!(
…
69 unchanged lines
modified crates/oxy-core/src/hash.rs +4 −1
…
62 unchanged lines
63 63
    Identity,
64 64
    Policy,
65 65
    State,
66 +
    Receipt,
66 67
}
67 68
68 69
impl ObjectType {
69 -
    pub const ALL: [ObjectType; 9] = [
70 +
    pub const ALL: [ObjectType; 10] = [
70 71
        ObjectType::Chunk,
71 72
        ObjectType::Blob,
72 73
        ObjectType::Change,
…
3 unchanged lines
76 77
        ObjectType::Identity,
77 78
        ObjectType::Policy,
78 79
        ObjectType::State,
80 +
        ObjectType::Receipt,
79 81
    ];
80 82
81 83
    pub fn name(self) -> &'static str {
…
7 unchanged lines
89 91
            ObjectType::Identity => "identity",
90 92
            ObjectType::Policy => "policy",
91 93
            ObjectType::State => "state",
94 +
            ObjectType::Receipt => "receipt",
92 95
        }
93 96
    }
94 97
…
59 unchanged lines
modified crates/oxy-core/src/lib.rs +1 −0
…
18 unchanged lines
19 19
pub mod policy;
20 20
pub mod portable;
21 21
pub mod protocol;
22 +
pub mod receipt;
22 23
pub mod repo;
23 24
pub mod rewrite;
24 25
pub mod state;
…
4 unchanged lines
modified crates/oxy-core/src/repo/fsck.rs +1 −0
…
96 unchanged lines
97 97
        ObjectType::Attest => Attest::decode(body).map(|_| ()),
98 98
        ObjectType::Identity => Identity::decode(body).map(|_| ()),
99 99
        ObjectType::Policy => crate::policy::Policy::decode(body).map(|_| ()),
100 +
        ObjectType::Receipt => crate::receipt::Receipt::decode(body).map(|_| ()),
100 101
        ObjectType::State => crate::objects::StateObject::decode(body).map(|_| ()),
101 102
    };
102 103
    r.map_err(|e| e.to_string())
…
364 unchanged lines
modified crates/oxy-core/src/repo/gc.rs +3 −1
…
1 unchanged lines
2 2
//! nothing reachable refers to.
3 3
//!
4 4
//! Roots: the current view, the views of every operation newer than
5 -
//! `core.op-retention`, reference attestations, and the policy. Operation
5 +
//! `core.op-retention`, reference attestations, and the policy; receipts
6 +
//! live as long as the changes of their state. Operation
6 7
//! objects are always kept so the log stays walkable, but an operation older
7 8
//! than the retention window no longer keeps its states alive, so undoing
8 9
//! that far back may fail once its objects are gone.
…
133 unchanged lines
142 143
            }
143 144
            attests.extend(self.store.seals_for(&h)?);
144 145
        }
146 +
        self.reachable_receipts(&mut keep)?;
145 147
        for a in attests {
146 148
            if keep.insert(a) {
147 149
                for sig in self.store.signatures(&a)? {
…
7 unchanged lines
modified crates/oxy-core/src/repo/mod.rs +2 −0
…
72 unchanged lines
73 73
mod inspect;
74 74
mod policy;
75 75
mod prompt;
76 +
mod receipts;
76 77
mod snapshot;
77 78
mod subrepos;
78 79
mod sync;
…
15 unchanged lines
94 95
pub use inspect::{BisectStep, BlameLine, Description};
95 96
pub use policy::PolicyUpdate;
96 97
pub use prompt::{PromptInfo, DEFAULT_PROMPT_FORMAT, GLYPH_PROMPT_FORMAT};
98 +
pub use receipts::ReceiptInfo;
97 99
pub use snapshot::{
98 100
    added_node_paths, diff_files_of, has_conflict_markers, is_binary, referenced_changes,
99 101
    render_change, render_hunks, similarity, ChangeStatus, DiffHunk, FileDiff, Hunk, PathStatus,
…
1540 unchanged lines
modified crates/oxy-core/src/repo/tests.rs +66 −0
…
5386 unchanged lines
5387 5387
    assert!(repo.line_history(&heads, "f.rs", 0, 1).is_err());
5388 5388
    assert!(repo.line_history(&heads, "nope.rs", 1, 1).is_err());
5389 5389
}
5390 +
5391 +
#[test]
5392 +
fn receipts_are_kept_with_their_state_and_verified() {
5393 +
    use crate::receipt::{Receipt, SignedReceipt};
5394 +
    hermetic();
5395 +
    let dir = tempfile::tempdir().unwrap();
5396 +
    let root = dir.path();
5397 +
    let repo = Repo::init(root).unwrap().with_home(root.join("home"));
5398 +
    write(root, "a.txt", b"1\n");
5399 +
    repo.commit("A", &[]).unwrap();
5400 +
    let heads = repo.bookmark_heads().unwrap();
5401 +
    let state = repo
5402 +
        .store()
5403 +
        .put_state(&StateObject::new(heads.clone()))
5404 +
        .unwrap();
5405 +
    let out = repo.store_blob(b"built\n").unwrap();
5406 +
    let receipt = Receipt {
5407 +
        job_name: "build".into(),
5408 +
        pipeline_hash: ObjectRef([1; 32]),
5409 +
        scope_tree_hash: ObjectRef([2; 32]),
5410 +
        inputs: vec![],
5411 +
        environment: ObjectRef([3; 32]),
5412 +
        outputs: vec![out],
5413 +
        exit_code: 0,
5414 +
        duration_ms: 42,
5415 +
    };
5416 +
    let sk = SecretKey::generate();
5417 +
    let runner = sk.identity("earth-1", "runner@earth", 1);
5418 +
    let sr = SignedReceipt::sign(
5419 +
        receipt.clone(),
5420 +
        repo.id().unwrap(),
5421 +
        state,
5422 +
        "demo#1",
5423 +
        &runner,
5424 +
        &sk,
5425 +
    );
5426 +
5427 +
    let r = repo.add_receipt(&sr).unwrap();
5428 +
    let found = repo.receipts(&heads).unwrap();
5429 +
    assert_eq!(found.len(), 1);
5430 +
    assert_eq!(found[0].attest, r);
5431 +
    assert_eq!(found[0].signed, sr);
5432 +
    assert!(found[0].problem.is_none() && !found[0].revoked);
5433 +
    assert!(repo.receipts(&[ObjectRef([5; 32])]).unwrap().is_empty());
5434 +
5435 +
    // Another repository's receipt, or a forged one, is refused.
5436 +
    let elsewhere = SignedReceipt::sign(receipt.clone(), [0; 16], state, "x", &runner, &sk);
5437 +
    assert!(repo
5438 +
        .add_receipt(&elsewhere)
5439 +
        .unwrap_err()
5440 +
        .to_string()
5441 +
        .contains("another repository"));
5442 +
    let mut forged = sr.clone();
5443 +
    forged.receipt.exit_code = 1;
5444 +
    assert!(repo
5445 +
        .add_receipt(&forged)
5446 +
        .unwrap_err()
5447 +
        .to_string()
5448 +
        .contains("does not verify"));
5449 +
5450 +
    // gc keeps the receipt, its runner and its output while the state lives.
5451 +
    repo.gc(true).unwrap();
5452 +
    assert_eq!(repo.receipts(&heads).unwrap().len(), 1);
5453 +
    assert_eq!(repo.read_blob(&out).unwrap(), b"built\n");
5454 +
    assert!(repo.fsck().unwrap().problems.is_empty());
5455 +
}
modified crates/oxy-core/src/store.rs +71 −15
…
11 unchanged lines
12 12
use crate::hash::{hash_object, header, ObjectRef, ObjectType};
13 13
use crate::identity::{Identity, Signature};
14 14
use crate::objects::{Change, ObjectError, StateObject};
15 +
use crate::receipt::Receipt;
15 16
use rand::RngCore;
16 17
use std::cell::RefCell;
17 18
use std::fs;
…
385 unchanged lines
403 404
    }
404 405
405 406
    fn remove_sidecars(&self, r: &ObjectRef) -> Result<()> {
406 -
        for path in [self.sig_path(r), self.seal_index_path(r)] {
407 +
        let sidecars = [
408 +
            self.sig_path(r),
409 +
            self.index_path("seals", r),
410 +
            self.index_path("receipts", r),
411 +
        ];
412 +
        for path in sidecars {
407 413
            match fs::remove_file(&path) {
408 414
                Ok(()) => {
409 415
                    let _ = fs::remove_dir(path.parent().unwrap());
…
472 unchanged lines
882 888
883 889
    pub fn get_attest(&self, r: &ObjectRef) -> Result<Attest> {
884 890
        Ok(Attest::decode(&self.get_typed(r, ObjectType::Attest)?)?)
891 +
    }
892 +
893 +
    pub fn put_receipt(&self, r: &Receipt) -> Result<ObjectRef> {
894 +
        self.put(ObjectType::Receipt, &r.encode())
885 895
    }
886 896
887 -
    fn seal_index_path(&self, change: &ObjectRef) -> PathBuf {
888 -
        let h = change.to_hex();
889 -
        self.dir.join("seals").join(&h[..2]).join(&h[2..])
897 +
    pub fn get_receipt(&self, r: &ObjectRef) -> Result<Receipt> {
898 +
        Ok(Receipt::decode(&self.get_typed(r, ObjectType::Receipt)?)?)
890 899
    }
891 900
892 -
    /// Record that seal attestation `attest` covers `change`.
893 -
    pub fn index_seal(&self, change: &ObjectRef, attest: &ObjectRef) -> Result<()> {
894 -
        let mut seals = self.seals_for(change)?;
895 -
        if seals.contains(attest) {
901 +
    fn index_path(&self, index: &str, key: &ObjectRef) -> PathBuf {
902 +
        let h = key.to_hex();
903 +
        self.dir.join(index).join(&h[..2]).join(&h[2..])
904 +
    }
905 +
906 +
    /// Add `value` to the sorted list kept for `key` in `index`.
907 +
    fn index_add(&self, index: &str, key: &ObjectRef, value: &ObjectRef) -> Result<()> {
908 +
        let mut values = self.index_get(index, key)?;
909 +
        if values.contains(value) {
896 910
            return Ok(());
897 911
        }
898 -
        seals.push(*attest);
899 -
        seals.sort();
900 -
        let path = self.seal_index_path(change);
912 +
        values.push(*value);
913 +
        values.sort();
914 +
        let path = self.index_path(index, key);
901 915
        fs::create_dir_all(path.parent().unwrap())?;
902 -
        let text: String = seals.iter().map(|s| format!("{}\n", s.to_hex())).collect();
916 +
        let text: String = values.iter().map(|s| format!("{}\n", s.to_hex())).collect();
903 917
        crate::durable::write_atomic(&path, text.as_bytes())?;
904 918
        Ok(())
905 919
    }
906 920
907 -
    /// Seal attestations known to cover `change`.
908 -
    pub fn seals_for(&self, change: &ObjectRef) -> Result<Vec<ObjectRef>> {
909 -
        match fs::read_to_string(self.seal_index_path(change)) {
921 +
    fn index_get(&self, index: &str, key: &ObjectRef) -> Result<Vec<ObjectRef>> {
922 +
        match fs::read_to_string(self.index_path(index, key)) {
910 923
            Ok(t) => Ok(t.lines().filter_map(ObjectRef::from_hex).collect()),
911 924
            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(vec![]),
912 925
            Err(e) => Err(e.into()),
913 926
        }
927 +
    }
928 +
929 +
    /// Record that seal attestation `attest` covers `change`.
930 +
    pub fn index_seal(&self, change: &ObjectRef, attest: &ObjectRef) -> Result<()> {
931 +
        self.index_add("seals", change, attest)
932 +
    }
933 +
934 +
    /// Seal attestations known to cover `change`.
935 +
    pub fn seals_for(&self, change: &ObjectRef) -> Result<Vec<ObjectRef>> {
936 +
        self.index_get("seals", change)
937 +
    }
938 +
939 +
    /// Record receipt attestation `attest` for `state` (spec §14.6).
940 +
    pub fn index_receipt(&self, state: &ObjectRef, attest: &ObjectRef) -> Result<()> {
941 +
        self.index_add("receipts", state, attest)
942 +
    }
943 +
944 +
    /// Receipt attestations recorded for `state`.
945 +
    pub fn receipts_for(&self, state: &ObjectRef) -> Result<Vec<ObjectRef>> {
946 +
        self.index_get("receipts", state)
947 +
    }
948 +
949 +
    /// Every state with a receipt, for garbage collection.
950 +
    pub fn receipt_states(&self) -> Result<Vec<ObjectRef>> {
951 +
        let mut out = Vec::new();
952 +
        let dir = self.dir.join("receipts");
953 +
        let top = match fs::read_dir(&dir) {
954 +
            Ok(d) => d,
955 +
            Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
956 +
            Err(e) => return Err(e.into()),
957 +
        };
958 +
        for d in top {
959 +
            let d = d?;
960 +
            let prefix = d.file_name().to_string_lossy().into_owned();
961 +
            for f in fs::read_dir(d.path())? {
962 +
                let name = f?.file_name().to_string_lossy().into_owned();
963 +
                if let Some(r) = ObjectRef::from_hex(&format!("{prefix}{name}")) {
964 +
                    out.push(r);
965 +
                }
966 +
            }
967 +
        }
968 +
        out.sort();
969 +
        Ok(out)
914 970
    }
915 971
916 972
    pub fn put_identity(&self, id: &Identity) -> Result<ObjectRef> {
…
519 unchanged lines
modified docs/backlog-overnight-2.md +5 −2
…
86 unchanged lines
87 87
- [x] P1c Name conflicts on the resolver: keep one candidate, which takes the name as a move.
88 88
- [x] P1d "Resolve in VS Code": the resolver hands a conflict to oxygen-vscode (a vscode:// URI the extension handles: clone or open, switch to the bookmark, open the file).
89 89
- [x] P1e Replace conflicts of binary files: oxy-core cannot yet resolve one by keeping an existing candidate (writing a candidate's bytes counts as no change); make that a resolution, then offer it on the resolver.
90 -
- [ ] P1 **Conflict resolution on OxygenHub** (Andy's list, item 1). A resolver page for a conflicted state:
90 +
- [x] P1 **Conflict resolution on OxygenHub** (Andy's list, item 1). A resolver page for a conflicted state:
91 91
  - Per conflict region: pick a side, several sides, or none; edit freely; tree conflicts too (keep a candidate).
92 92
  - Commit the result as a `kind = resolution` change on the bookmark, through the web-edit path. Signing: sealed when the author next pushes, or "resolve in VS Code" hands off to oxygen-vscode.
93 93
  - Shareable link to a conflict.
…
8 unchanged lines
102 102
  - The cross-file semantic graph stays out; noted as future work.
103 103
- [x] P3a The swimlane board on the hub: lanes of independent changes, dependency edges, no merge nodes (first part of P3, below).
104 104
- [x] P3b Drag and drop on the board, proposal bookmarks only: dropping a change onto another proposes a squash, dragging a dependent chain proposes a rebase; the hub writes the plan, the author's oxy or oxygen-vscode applies and signs it.
105 -
- [ ] P3 **Swimlanes** (item 3):
105 +
- [x] P3 **Swimlanes** (item 3):
106 106
  - Hub: a bookmark's changes drawn as lanes of independent changes (oxy-core `lanes`), with dependencies as edges and no merge nodes.
107 107
  - P3b: drag and drop on proposal bookmarks only. Dropping a change onto another proposes a squash, dragging a dependent chain proposes a rebase. The hub produces a plan and the author's oxy or oxygen-vscode applies and signs it (V6 has split, squash and rebase). Reordering independent changes means nothing and is not offered.
108 +
- [ ] P4a The `receipt` object and attestation in the Oxygen spec and oxy-core: a signed receipt verifies on its own; the repository keeps receipts by state; `oxy receipt list|show|export|verify|import` (first part of P4, below).
109 +
- [ ] P4c The runner signs each receipt with its own identity; the hub verifies it against its known runner keys, keeps it in the repository, and shows it on the run, the check and the change.
110 +
- [ ] P4d Flow `output` and artifacts: a job's declared outputs become blobs named by its receipt, and a release asset links to the receipt that built it.
108 111
- [ ] P4 **Signed Flow receipts**:
109 112
  - A `receipt` object type in the Oxygen spec.
110 113
  - A runner identity whose key (composite Ed25519 + ML-DSA-65) signs each receipt.
…
587 unchanged lines
modified docs/guide.md +14 −0
…
288 unchanged lines
289 289
push and fetch. Workspaces have their own working copy and bookmark
290 290
(`workspace.bookmark`) but share objects and history.
291 291
292 +
### Receipts from CI
293 +
294 +
A runner such as OxygenFlow signs a receipt for every job it runs: what ran,
295 +
in what environment, on which files, and the result. Receipts are kept with
296 +
the state they ran on.
297 +
298 +
```
299 +
oxy receipt list                         # receipts for the bookmark's state
300 +
oxy receipt show 3f2a9c                  # one in full, with its signature checked
301 +
oxy receipt export 3f2a9c build.receipt  # hand it to someone
302 +
oxy receipt verify build.receipt         # check one without keeping it
303 +
oxy receipt import build.receipt         # verify and keep it
304 +
```
305 +
292 306
## 11. Big repositories: partial, sparse, shallow
293 307
294 308
```
…
148 unchanged lines
modified docs/spec.md +29 −3
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.8n |
5 +
| **Status** | Draft 0.8o |
6 6
| **Format version** | 1 |
7 7
| **Date** | 2026-09-26 |
8 8
| **Audience** | Implementers, security reviewers, and contributors |
…
152 unchanged lines
161 161
| `identity` | A contributor's keys. | §14.2 |
162 162
| `policy` | The repository's trust policy. | §14.5 |
163 163
| `state` | A sorted list of heads naming a state. | §10.1 |
164 +
| `receipt` | What a runner ran and its result. | §14.6 |
164 165
165 166
### 5.4 Signatures are outside the hash
166 167
…
453 unchanged lines
620 621
```
621 622
Attest {
622 623
  version [0]: 1
623 -
  kind    [1]: "ref" | "tag" | "seal" | "rotate" | "revoke" | "lock"
624 +
  kind    [1]: "ref" | "tag" | "seal" | "rotate" | "revoke" | "lock" | "receipt"
624 625
  repo    [2]: bytes(16)               // repository ID, chosen at init
625 626
  hlc     [3]: HLC
626 627
  body    [4]: map                     // per kind
…
4 unchanged lines
631 632
rotate: { new: KeyId }
632 633
revoke: { key: KeyId, reason }
633 634
lock:   { node: NodeId, path, acquire: bool, expires }
635 +
receipt: { receipt: ObjectRef, state: ObjectRef, run: text }   // §14.6
634 636
```
635 637
636 638
A **seal** is one signature over a batch of change hashes. A composite signature is 3373 bytes, which is larger than a typical change, so signing every change individually roughly doubles the size of history in high-frequency workflows. A seal replaces that with one signature per push.
…
31 unchanged lines
668 670
- **Propagation.** The `refs` response carries the current signed policy together with the earlier policies the server still holds, and a push MAY carry a newer policy with the intermediate ones in its bundle, applied before any bookmark update. A receiver behind by several serials verifies each step against the one before it. A receiver accepts a policy only if it is for the same repository, its serial exceeds the current one, and it is signed by the current policy's root threshold; the first policy a repository ever sees vouches for itself, which means at least one valid signature by one of its own roots (trust on first use).
669 671
- **Downgrade protection.** A client records the highest policy serial each remote has presented. A remote that later presents none, or a lower serial, is refused. A remote that has never presented a policy is unverified: the client fetches with a warning and cannot verify what it received. Policy signatures use the domain `policy` (§14.3).
670 672
- **Existing history.** A policy governs changes added after it. The receiver of a push checks the changes the new state adds beyond the state it already had, so history that predates the policy is not re-verified. A fetching client verifies the bookmark's attestation, which binds the whole state through its head hashes, and does not re-verify individual changes; it therefore trusts what an authorized attester vouched for, which is the point of the policy.
673 +
674 +
### 14.6 Execution receipts
675 +
676 +
A runner (OxygenFlow, or any CI) records each job it ran as a `receipt` object:
677 +
678 +
```
679 +
Receipt {
680 +
  version         [0]: 1
681 +
  job_name        [1]: text
682 +
  pipeline_hash   [2]: ObjectRef      // the pipeline definition the job came from
683 +
  scope_tree_hash [3]: ObjectRef      // BLAKE3 over the sorted (path, content) of the files the job's scope matches; the whole tree without a scope
684 +
  inputs          [4]: [ObjectRef]    // artifacts and other inputs beyond the tree
685 +
  environment     [5]: ObjectRef      // what it ran in: isolation tier, rootfs, argv, environment
686 +
  outputs         [6]: [ObjectRef]    // blobs the job produced
687 +
  exit_code       [7]: u32            // 0 passed
688 +
  duration_ms     [8]: u64
689 +
}
690 +
```
671 691
692 +
A receipt names no state: the same job on the same scope in the same environment gives the same receipt, which is what lets a runner reuse it for a later state whose scope is unchanged. An attestation of kind `receipt` (§14.4) binds a receipt to the `state` it ran on and names the `run` (usually its URL). The runner signs that attestation with its own identity (§14.2), an ordinary composite key, in domain `attest`.
693 +
694 +
**Signed receipt.** A runner hands a receipt over as `[receipt bytes, attest bytes, [Signature], identity bytes]`, so it verifies with nothing else: the attestation's `receipt` is the receipt's ObjectRef, and a signature by the included identity verifies over the attestation. Whether a runner is trusted is the reader's decision (a host lists its runners' keys); a revoked runner key is reported.
695 +
696 +
**Storage.** A repository keeps receipts beside the state they name: the receipt, the attestation with its signatures, and the runner's identity, indexed by state ObjectRef. Receipts live as long as the changes of their state, and keep their outputs and inputs alive (§21). `oxy receipt list|show|export|verify|import` reads and exchanges them; a receipt for another repository is refused.
697 +
672 698
## 15. Transport
673 699
674 700
Every transport carries the same wire protocol (§16). The transport provides the channel; integrity and authenticity of history come from the object model regardless of channel.
…
271 unchanged lines
946 972
947 973
---
948 974
949 -
*Changelog.* Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11).
975 +
*Changelog.* Draft 0.8o (2026-09-29): execution receipts, the `receipt` object and attestation (§14.6). Draft 0.8n (2026-09-29): line history, `oxy log -L` (§27). Draft 0.8m (2026-09-29): resolving replace conflicts (§9.3). Draft 0.8l (2026-09-28): deleting a reference on a remote; first serials from the clock (§16.4). Draft 0.8k (2026-09-28): the manifest records graph keys, so unchanged files are neither read nor rendered (§12.4). Draft 0.8j (2026-09-28): checkpoint payload version 2, file graphs by reference (§11).
950 976
951 977
Draft 0.8i (2026-09-28): portability rules for exec bits and path names (§7.7); found by verifying a real Git import commit by commit: deletions depend on the inserters of the lines right after them, and replacements on the change that set their old blob (§8.1); replace records are superseded causally, so a reverted binary keeps its blob (§9.3); merges resolve opaque conflicts to the commit's blob, and `oxy git verify` (§23). Draft 0.8h (2026-09-28): `oxy fsck` (§21.1); durability rules and the operation head as commit point (§13.6); the operation head moves by compare-and-swap and concurrent operations are merged (§12.1); `oxy doctor` (§21.2). 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.
952 978
…
61 unchanged lines
modified src/cli/mod.rs +67 −0
…
3 unchanged lines
4 4
mod identity;
5 5
mod json;
6 6
mod proposal;
7 +
mod receipt;
7 8
mod sync;
8 9
pub mod ui;
9 10
mod workspace;
…
318 unchanged lines
328 329
        #[command(subcommand)]
329 330
        cmd: SubmoduleCmd,
330 331
    },
332 +
    /// Execution receipts: signed records of what a runner ran on a state
333 +
    Receipt {
334 +
        #[command(subcommand)]
335 +
        cmd: ReceiptCmd,
336 +
    },
331 337
    /// Tags: immutable named states
332 338
    Tag {
333 339
        #[command(subcommand)]
…
246 unchanged lines
580 586
}
581 587
582 588
#[derive(Subcommand)]
589 +
enum ReceiptCmd {
590 +
    /// The receipts kept for a state (default: the bookmark heads)
591 +
    List {
592 +
        /// Change, bookmark or tag whose state to look at
593 +
        rev: Option<String>,
594 +
        /// Machine-readable output: one JSON document on stdout
595 +
        #[arg(long)]
596 +
        json: bool,
597 +
    },
598 +
    /// One receipt in full, and whether its signature verifies
599 +
    Show {
600 +
        /// The receipt (a prefix of its hash, as `list` shows it)
601 +
        receipt: String,
602 +
        /// Change, bookmark or tag whose state holds it
603 +
        #[arg(long)]
604 +
        rev: Option<String>,
605 +
        /// Machine-readable output: one JSON document on stdout
606 +
        #[arg(long)]
607 +
        json: bool,
608 +
    },
609 +
    /// Write a signed receipt to FILE
610 +
    Export {
611 +
        /// The receipt (a prefix of its hash, as `list` shows it)
612 +
        receipt: String,
613 +
        /// Where to write it
614 +
        file: String,
615 +
        /// Change, bookmark or tag whose state holds it
616 +
        #[arg(long)]
617 +
        rev: Option<String>,
618 +
    },
619 +
    /// Check a signed receipt in FILE without keeping it
620 +
    Verify {
621 +
        /// A signed receipt, as a runner or `export` writes it
622 +
        file: String,
623 +
        /// Machine-readable output: one JSON document on stdout
624 +
        #[arg(long)]
625 +
        json: bool,
626 +
    },
627 +
    /// Verify a signed receipt in FILE and keep it with its state
628 +
    Import {
629 +
        /// A signed receipt, as a runner or `export` writes it
630 +
        file: String,
631 +
    },
632 +
}
633 +
634 +
#[derive(Subcommand)]
583 635
enum TagCmd {
584 636
    /// Tag the state of CHANGE... (default: the bookmark heads)
585 637
    Create {
…
406 unchanged lines
992 1044
            SubmoduleCmd::Update { init, paths } => sync::submodule_update(init, &paths),
993 1045
            SubmoduleCmd::Status { json } => sync::submodule_status(json),
994 1046
            SubmoduleCmd::Remove { path } => sync::submodule_remove(&path),
1047 +
        },
1048 +
        Cmd::Receipt { cmd } => match cmd {
1049 +
            ReceiptCmd::List { rev, json } => receipt::list(rev.as_deref(), json),
1050 +
            ReceiptCmd::Show {
1051 +
                receipt: r,
1052 +
                rev,
1053 +
                json,
1054 +
            } => receipt::show(&r, rev.as_deref(), json),
1055 +
            ReceiptCmd::Export {
1056 +
                receipt: r,
1057 +
                file,
1058 +
                rev,
1059 +
            } => receipt::export(&r, &file, rev.as_deref()),
1060 +
            ReceiptCmd::Verify { file, json } => receipt::verify(&file, json),
1061 +
            ReceiptCmd::Import { file } => receipt::import(&file),
995 1062
        },
996 1063
        Cmd::Tag { cmd } => match cmd {
997 1064
            TagCmd::Create {
…
141 unchanged lines
modified tests/cli_json.rs +79 −0
…
297 unchanged lines
298 298
    assert_ne!(code, 0);
299 299
    assert!(err.contains("START,END:PATH"), "{err}");
300 300
}
301 +
302 +
#[test]
303 +
fn receipts_verify_import_list_show_and_export() {
304 +
    use oxy_core::hash::{ObjectRef, ObjectType};
305 +
    use oxy_core::identity::SecretKey;
306 +
    use oxy_core::objects::StateObject;
307 +
    use oxy_core::receipt::{Receipt, SignedReceipt};
308 +
    let tmp = tempfile::tempdir().unwrap();
309 +
    let home = tmp.path().join("home");
310 +
    std::fs::create_dir_all(&home).unwrap();
311 +
    let r = tmp.path().join("repo");
312 +
    assert_eq!(oxy(tmp.path(), &home, &["init", r.to_str().unwrap()]).0, 0);
313 +
    write(&r, "a.txt", "a\n");
314 +
    assert_eq!(oxy(&r, &home, &["commit", "-m", "A"]).0, 0);
315 +
316 +
    let repo = oxy_core::repo::Repo::open(&r).unwrap();
317 +
    let heads = repo.bookmark_heads().unwrap();
318 +
    let state = oxy_core::hash::hash_object(ObjectType::State, &StateObject::new(heads).encode());
319 +
    let receipt = Receipt {
320 +
        job_name: "test".into(),
321 +
        pipeline_hash: ObjectRef([1; 32]),
322 +
        scope_tree_hash: ObjectRef([2; 32]),
323 +
        inputs: vec![],
324 +
        environment: ObjectRef([3; 32]),
325 +
        outputs: vec![],
326 +
        exit_code: 0,
327 +
        duration_ms: 2500,
328 +
    };
329 +
    let sk = SecretKey::generate();
330 +
    let runner = sk.identity("earth-1", "runner@earth", 1);
331 +
    let sr = SignedReceipt::sign(receipt, repo.id().unwrap(), state, "demo#7", &runner, &sk);
332 +
    let file = tmp.path().join("r.receipt");
333 +
    std::fs::write(&file, sr.encode()).unwrap();
334 +
    let f = file.to_str().unwrap();
335 +
336 +
    let v = json(&r, &home, &["receipt", "verify", f, "--json"]);
337 +
    assert_eq!(
338 +
        (v["valid"].as_bool(), v["job"].as_str(), v["run"].as_str()),
339 +
        (Some(true), Some("test"), Some("demo#7"))
340 +
    );
341 +
    assert!(v["attest"].is_null());
342 +
343 +
    assert!(json(&r, &home, &["receipt", "list", "--json"])
344 +
        .as_array()
345 +
        .unwrap()
346 +
        .is_empty());
347 +
    assert_eq!(oxy(&r, &home, &["receipt", "import", f]).0, 0);
348 +
    let list = json(&r, &home, &["receipt", "list", "--json"]);
349 +
    let list = list.as_array().unwrap();
350 +
    assert_eq!(list.len(), 1);
351 +
    assert_eq!(list[0]["runner_name"], "earth-1");
352 +
    assert_eq!(list[0]["state"], state.to_hex());
353 +
    let attest = list[0]["attest"].as_str().unwrap().to_string();
354 +
    let shown = json(&r, &home, &["receipt", "show", &attest[..10], "--json"]);
355 +
    assert_eq!(shown["duration_ms"], 2500);
356 +
357 +
    let out = tmp.path().join("out.receipt");
358 +
    assert_eq!(
359 +
        oxy(
360 +
            &r,
361 +
            &home,
362 +
            &["receipt", "export", &attest[..10], out.to_str().unwrap()]
363 +
        )
364 +
        .0,
365 +
        0
366 +
    );
367 +
    assert_eq!(std::fs::read(&out).unwrap(), sr.encode());
368 +
369 +
    // A receipt whose result was changed after signing does not verify.
370 +
    let mut forged = sr.clone();
371 +
    forged.receipt.exit_code = 3;
372 +
    std::fs::write(&file, forged.encode()).unwrap();
373 +
    let (code, out, _) = oxy(&r, &home, &["receipt", "verify", f]);
374 +
    assert_eq!(code, 1);
375 +
    assert!(String::from_utf8_lossy(&out).contains("different receipt"));
376 +
    let (code, _, err) = oxy(&r, &home, &["receipt", "import", f]);
377 +
    assert_ne!(code, 0);
378 +
    assert!(err.contains("does not verify"), "{err}");
379 +
}